diff --git a/prisma/migrations/20261022220035_region_center_coordinates/migration.sql b/prisma/migrations/20261022220035_region_center_coordinates/migration.sql new file mode 100644 index 00000000..61974199 --- /dev/null +++ b/prisma/migrations/20261022220035_region_center_coordinates/migration.sql @@ -0,0 +1,90 @@ +-- 2차 지역 중심 좌표(카카오 주소 검색의 시군구 대표점). 시드(regions.generated.json)와 같은 값이며 scripts/fill-region-centers.ts가 만든다. +-- 좌표가 비어 있는 행만 채워 관리자가 넣은 값은 그대로 둔다. 지역이 없는 빈 DB에서는 아무 행도 건드리지 않는다. +UPDATE `region` r +JOIN ( + SELECT 'sgg-11110' AS `slug`, 37.5747730 AS `lat`, 126.9796145 AS `lng` + UNION ALL SELECT 'sgg-11140', 37.5638078, 126.9975552 + UNION ALL SELECT 'sgg-11170', 37.5324274, 126.9905778 + UNION ALL SELECT 'sgg-11200', 37.5634272, 127.0369301 + UNION ALL SELECT 'sgg-11215', 37.5362839, 127.0877548 + UNION ALL SELECT 'sgg-11230', 37.5744184, 127.0397475 + UNION ALL SELECT 'sgg-11260', 37.6065432, 127.0928203 + UNION ALL SELECT 'sgg-11290', 37.5893588, 127.0167029 + UNION ALL SELECT 'sgg-11305', 37.6371279, 127.0242048 + UNION ALL SELECT 'sgg-11320', 37.6686914, 127.0472105 + UNION ALL SELECT 'sgg-11350', 37.6543618, 127.0564305 + UNION ALL SELECT 'sgg-11380', 37.6028174, 126.9289410 + UNION ALL SELECT 'sgg-11410', 37.5791619, 126.9368157 + UNION ALL SELECT 'sgg-11440', 37.5662142, 126.9019551 + UNION ALL SELECT 'sgg-11470', 37.5169885, 126.8665014 + UNION ALL SELECT 'sgg-11500', 37.5509646, 126.8495338 + UNION ALL SELECT 'sgg-11530', 37.4954331, 126.8875053 + UNION ALL SELECT 'sgg-11545', 37.4568411, 126.8954568 + UNION ALL SELECT 'sgg-11560', 37.5263636, 126.8962739 + UNION ALL SELECT 'sgg-11590', 37.5042365, 126.9402717 + UNION ALL SELECT 'sgg-11620', 37.4782566, 126.9515212 + UNION ALL SELECT 'sgg-11650', 37.4835888, 127.0327345 + UNION ALL SELECT 'sgg-11680', 37.5173319, 127.0473774 + UNION ALL SELECT 'sgg-11710', 37.5145837, 127.1059177 + UNION ALL SELECT 'sgg-11740', 37.5301933, 127.1237925 + UNION ALL SELECT 'sgg-28125', 37.4738610, 126.6432819 + UNION ALL SELECT 'sgg-28155', 37.4897692, 126.5599390 + UNION ALL SELECT 'sgg-28177', 37.4635861, 126.6505649 + UNION ALL SELECT 'sgg-28185', 37.4101014, 126.6782749 + UNION ALL SELECT 'sgg-28200', 37.4473488, 126.7314877 + UNION ALL SELECT 'sgg-28237', 37.5070417, 126.7218642 + UNION ALL SELECT 'sgg-28245', 37.5374957, 126.7377047 + UNION ALL SELECT 'sgg-28275', 37.5454335, 126.6760285 + UNION ALL SELECT 'sgg-28290', 37.5915361, 126.6815716 + UNION ALL SELECT 'sgg-28710', 37.7465178, 126.4880127 + UNION ALL SELECT 'sgg-28720', 37.9490282, 124.6727424 + UNION ALL SELECT 'sgg-41111', 37.3039218, 127.0102734 + UNION ALL SELECT 'sgg-41113', 37.2576307, 126.9718725 + UNION ALL SELECT 'sgg-41115', 37.2825588, 127.0201215 + UNION ALL SELECT 'sgg-41117', 37.2596036, 127.0466248 + UNION ALL SELECT 'sgg-41131', 37.4503897, 127.1456174 + UNION ALL SELECT 'sgg-41133', 37.4305990, 127.1372317 + UNION ALL SELECT 'sgg-41135', 37.3827460, 127.1188299 + UNION ALL SELECT 'sgg-41150', 37.7380566, 127.0338991 + UNION ALL SELECT 'sgg-41171', 37.3867041, 126.9325104 + UNION ALL SELECT 'sgg-41173', 37.3926060, 126.9514229 + UNION ALL SELECT 'sgg-41192', 37.4965957, 126.7870168 + UNION ALL SELECT 'sgg-41194', 37.4800578, 126.7999525 + UNION ALL SELECT 'sgg-41196', 37.5283805, 126.7957553 + UNION ALL SELECT 'sgg-41210', 37.4785675, 126.8646478 + UNION ALL SELECT 'sgg-41220', 36.9922607, 127.1126884 + UNION ALL SELECT 'sgg-41250', 37.9035986, 127.0604407 + UNION ALL SELECT 'sgg-41271', 37.3009718, 126.8464826 + UNION ALL SELECT 'sgg-41273', 37.3211465, 126.8153255 + UNION ALL SELECT 'sgg-41281', 37.6374796, 126.8321881 + UNION ALL SELECT 'sgg-41285', 37.6588746, 126.7750080 + UNION ALL SELECT 'sgg-41287', 37.6779514, 126.7454771 + UNION ALL SELECT 'sgg-41290', 37.4292328, 126.9877210 + UNION ALL SELECT 'sgg-41310', 37.5943524, 127.1296418 + UNION ALL SELECT 'sgg-41360', 37.6360236, 127.2164758 + UNION ALL SELECT 'sgg-41370', 37.1498790, 127.0775265 + UNION ALL SELECT 'sgg-41390', 37.3801151, 126.8029778 + UNION ALL SELECT 'sgg-41410', 37.3616318, 126.9352059 + UNION ALL SELECT 'sgg-41430', 37.3447600, 126.9682738 + UNION ALL SELECT 'sgg-41450', 37.5392746, 127.2148606 + UNION ALL SELECT 'sgg-41461', 37.2343060, 127.2013571 + UNION ALL SELECT 'sgg-41463', 37.2804109, 127.1147132 + UNION ALL SELECT 'sgg-41465', 37.3221153, 127.0976817 + UNION ALL SELECT 'sgg-41480', 37.7600450, 126.7798814 + UNION ALL SELECT 'sgg-41500', 37.2722676, 127.4350893 + UNION ALL SELECT 'sgg-41550', 37.0080705, 127.2797903 + UNION ALL SELECT 'sgg-41570', 37.6152687, 126.7156579 + UNION ALL SELECT 'sgg-41591', 37.1377095, 126.9256732 + UNION ALL SELECT 'sgg-41593', 37.2091796, 126.9591831 + UNION ALL SELECT 'sgg-41595', 37.2087486, 127.0360897 + UNION ALL SELECT 'sgg-41597', 37.1972782, 127.0970070 + UNION ALL SELECT 'sgg-41610', 37.4293942, 127.2551387 + UNION ALL SELECT 'sgg-41630', 37.7853137, 127.0457861 + UNION ALL SELECT 'sgg-41650', 37.8949928, 127.2003329 + UNION ALL SELECT 'sgg-41670', 37.2984234, 127.6370588 + UNION ALL SELECT 'sgg-41800', 38.0965223, 127.0753558 + UNION ALL SELECT 'sgg-41820', 37.8314413, 127.5095702 + UNION ALL SELECT 'sgg-41830', 37.4917883, 127.4875785 +) c ON c.`slug` = r.`slug` +SET r.`center_lat` = c.`lat`, r.`center_lng` = c.`lng`, r.`updated_at` = CURRENT_TIMESTAMP(3) +WHERE r.`level` = 2 AND r.`center_lat` IS NULL AND r.`center_lng` IS NULL; diff --git a/prisma/seed/data/regions.generated.json b/prisma/seed/data/regions.generated.json index 870c5743..924f6dbe 100644 --- a/prisma/seed/data/regions.generated.json +++ b/prisma/seed/data/regions.generated.json @@ -39,581 +39,747 @@ "name": "종로구", "parentSlug": "seoul-north", "sigunguCode": "11110", - "sortOrder": 0 + "sortOrder": 0, + "centerLat": "37.5747730", + "centerLng": "126.9796145" }, { "slug": "sgg-11140", "name": "중구", "parentSlug": "seoul-north", "sigunguCode": "11140", - "sortOrder": 1 + "sortOrder": 1, + "centerLat": "37.5638078", + "centerLng": "126.9975552" }, { "slug": "sgg-11170", "name": "용산구", "parentSlug": "seoul-north", "sigunguCode": "11170", - "sortOrder": 2 + "sortOrder": 2, + "centerLat": "37.5324274", + "centerLng": "126.9905778" }, { "slug": "sgg-11200", "name": "성동구", "parentSlug": "seoul-north", "sigunguCode": "11200", - "sortOrder": 3 + "sortOrder": 3, + "centerLat": "37.5634272", + "centerLng": "127.0369301" }, { "slug": "sgg-11215", "name": "광진구", "parentSlug": "seoul-north", "sigunguCode": "11215", - "sortOrder": 4 + "sortOrder": 4, + "centerLat": "37.5362839", + "centerLng": "127.0877548" }, { "slug": "sgg-11230", "name": "동대문구", "parentSlug": "seoul-north", "sigunguCode": "11230", - "sortOrder": 5 + "sortOrder": 5, + "centerLat": "37.5744184", + "centerLng": "127.0397475" }, { "slug": "sgg-11260", "name": "중랑구", "parentSlug": "seoul-north", "sigunguCode": "11260", - "sortOrder": 6 + "sortOrder": 6, + "centerLat": "37.6065432", + "centerLng": "127.0928203" }, { "slug": "sgg-11290", "name": "성북구", "parentSlug": "seoul-north", "sigunguCode": "11290", - "sortOrder": 7 + "sortOrder": 7, + "centerLat": "37.5893588", + "centerLng": "127.0167029" }, { "slug": "sgg-11305", "name": "강북구", "parentSlug": "seoul-north", "sigunguCode": "11305", - "sortOrder": 8 + "sortOrder": 8, + "centerLat": "37.6371279", + "centerLng": "127.0242048" }, { "slug": "sgg-11320", "name": "도봉구", "parentSlug": "seoul-north", "sigunguCode": "11320", - "sortOrder": 9 + "sortOrder": 9, + "centerLat": "37.6686914", + "centerLng": "127.0472105" }, { "slug": "sgg-11350", "name": "노원구", "parentSlug": "seoul-north", "sigunguCode": "11350", - "sortOrder": 10 + "sortOrder": 10, + "centerLat": "37.6543618", + "centerLng": "127.0564305" }, { "slug": "sgg-11380", "name": "은평구", "parentSlug": "seoul-north", "sigunguCode": "11380", - "sortOrder": 11 + "sortOrder": 11, + "centerLat": "37.6028174", + "centerLng": "126.9289410" }, { "slug": "sgg-11410", "name": "서대문구", "parentSlug": "seoul-north", "sigunguCode": "11410", - "sortOrder": 12 + "sortOrder": 12, + "centerLat": "37.5791619", + "centerLng": "126.9368157" }, { "slug": "sgg-11440", "name": "마포구", "parentSlug": "seoul-north", "sigunguCode": "11440", - "sortOrder": 13 + "sortOrder": 13, + "centerLat": "37.5662142", + "centerLng": "126.9019551" }, { "slug": "sgg-11470", "name": "양천구", "parentSlug": "seoul-south", "sigunguCode": "11470", - "sortOrder": 14 + "sortOrder": 14, + "centerLat": "37.5169885", + "centerLng": "126.8665014" }, { "slug": "sgg-11500", "name": "강서구", "parentSlug": "seoul-south", "sigunguCode": "11500", - "sortOrder": 15 + "sortOrder": 15, + "centerLat": "37.5509646", + "centerLng": "126.8495338" }, { "slug": "sgg-11530", "name": "구로구", "parentSlug": "seoul-south", "sigunguCode": "11530", - "sortOrder": 16 + "sortOrder": 16, + "centerLat": "37.4954331", + "centerLng": "126.8875053" }, { "slug": "sgg-11545", "name": "금천구", "parentSlug": "seoul-south", "sigunguCode": "11545", - "sortOrder": 17 + "sortOrder": 17, + "centerLat": "37.4568411", + "centerLng": "126.8954568" }, { "slug": "sgg-11560", "name": "영등포구", "parentSlug": "seoul-south", "sigunguCode": "11560", - "sortOrder": 18 + "sortOrder": 18, + "centerLat": "37.5263636", + "centerLng": "126.8962739" }, { "slug": "sgg-11590", "name": "동작구", "parentSlug": "seoul-south", "sigunguCode": "11590", - "sortOrder": 19 + "sortOrder": 19, + "centerLat": "37.5042365", + "centerLng": "126.9402717" }, { "slug": "sgg-11620", "name": "관악구", "parentSlug": "seoul-south", "sigunguCode": "11620", - "sortOrder": 20 + "sortOrder": 20, + "centerLat": "37.4782566", + "centerLng": "126.9515212" }, { "slug": "sgg-11650", "name": "서초구", "parentSlug": "seoul-south", "sigunguCode": "11650", - "sortOrder": 21 + "sortOrder": 21, + "centerLat": "37.4835888", + "centerLng": "127.0327345" }, { "slug": "sgg-11680", "name": "강남구", "parentSlug": "seoul-south", "sigunguCode": "11680", - "sortOrder": 22 + "sortOrder": 22, + "centerLat": "37.5173319", + "centerLng": "127.0473774" }, { "slug": "sgg-11710", "name": "송파구", "parentSlug": "seoul-south", "sigunguCode": "11710", - "sortOrder": 23 + "sortOrder": 23, + "centerLat": "37.5145837", + "centerLng": "127.1059177" }, { "slug": "sgg-11740", "name": "강동구", "parentSlug": "seoul-south", "sigunguCode": "11740", - "sortOrder": 24 + "sortOrder": 24, + "centerLat": "37.5301933", + "centerLng": "127.1237925" }, { "slug": "sgg-28125", "name": "제물포구", "parentSlug": "incheon", "sigunguCode": "28125", - "sortOrder": 25 + "sortOrder": 25, + "centerLat": "37.4738610", + "centerLng": "126.6432819" }, { "slug": "sgg-28155", "name": "영종구", "parentSlug": "incheon", "sigunguCode": "28155", - "sortOrder": 26 + "sortOrder": 26, + "centerLat": "37.4897692", + "centerLng": "126.5599390" }, { "slug": "sgg-28177", "name": "미추홀구", "parentSlug": "incheon", "sigunguCode": "28177", - "sortOrder": 27 + "sortOrder": 27, + "centerLat": "37.4635861", + "centerLng": "126.6505649" }, { "slug": "sgg-28185", "name": "연수구", "parentSlug": "incheon", "sigunguCode": "28185", - "sortOrder": 28 + "sortOrder": 28, + "centerLat": "37.4101014", + "centerLng": "126.6782749" }, { "slug": "sgg-28200", "name": "남동구", "parentSlug": "incheon", "sigunguCode": "28200", - "sortOrder": 29 + "sortOrder": 29, + "centerLat": "37.4473488", + "centerLng": "126.7314877" }, { "slug": "sgg-28237", "name": "부평구", "parentSlug": "incheon", "sigunguCode": "28237", - "sortOrder": 30 + "sortOrder": 30, + "centerLat": "37.5070417", + "centerLng": "126.7218642" }, { "slug": "sgg-28245", "name": "계양구", "parentSlug": "incheon", "sigunguCode": "28245", - "sortOrder": 31 + "sortOrder": 31, + "centerLat": "37.5374957", + "centerLng": "126.7377047" }, { "slug": "sgg-28275", "name": "서해구", "parentSlug": "incheon", "sigunguCode": "28275", - "sortOrder": 32 + "sortOrder": 32, + "centerLat": "37.5454335", + "centerLng": "126.6760285" }, { "slug": "sgg-28290", "name": "검단구", "parentSlug": "incheon", "sigunguCode": "28290", - "sortOrder": 33 + "sortOrder": 33, + "centerLat": "37.5915361", + "centerLng": "126.6815716" }, { "slug": "sgg-28710", "name": "강화군", "parentSlug": "incheon", "sigunguCode": "28710", - "sortOrder": 34 + "sortOrder": 34, + "centerLat": "37.7465178", + "centerLng": "126.4880127" }, { "slug": "sgg-28720", "name": "옹진군", "parentSlug": "incheon", "sigunguCode": "28720", - "sortOrder": 35 + "sortOrder": 35, + "centerLat": "37.9490282", + "centerLng": "124.6727424" }, { "slug": "sgg-41111", "name": "수원시장안구", "parentSlug": "gyeonggi-south", "sigunguCode": "41111", - "sortOrder": 36 + "sortOrder": 36, + "centerLat": "37.3039218", + "centerLng": "127.0102734" }, { "slug": "sgg-41113", "name": "수원시권선구", "parentSlug": "gyeonggi-south", "sigunguCode": "41113", - "sortOrder": 37 + "sortOrder": 37, + "centerLat": "37.2576307", + "centerLng": "126.9718725" }, { "slug": "sgg-41115", "name": "수원시팔달구", "parentSlug": "gyeonggi-south", "sigunguCode": "41115", - "sortOrder": 38 + "sortOrder": 38, + "centerLat": "37.2825588", + "centerLng": "127.0201215" }, { "slug": "sgg-41117", "name": "수원시영통구", "parentSlug": "gyeonggi-south", "sigunguCode": "41117", - "sortOrder": 39 + "sortOrder": 39, + "centerLat": "37.2596036", + "centerLng": "127.0466248" }, { "slug": "sgg-41131", "name": "성남시수정구", "parentSlug": "gyeonggi-south", "sigunguCode": "41131", - "sortOrder": 40 + "sortOrder": 40, + "centerLat": "37.4503897", + "centerLng": "127.1456174" }, { "slug": "sgg-41133", "name": "성남시중원구", "parentSlug": "gyeonggi-south", "sigunguCode": "41133", - "sortOrder": 41 + "sortOrder": 41, + "centerLat": "37.4305990", + "centerLng": "127.1372317" }, { "slug": "sgg-41135", "name": "성남시분당구", "parentSlug": "gyeonggi-south", "sigunguCode": "41135", - "sortOrder": 42 + "sortOrder": 42, + "centerLat": "37.3827460", + "centerLng": "127.1188299" }, { "slug": "sgg-41150", "name": "의정부시", "parentSlug": "gyeonggi-north", "sigunguCode": "41150", - "sortOrder": 43 + "sortOrder": 43, + "centerLat": "37.7380566", + "centerLng": "127.0338991" }, { "slug": "sgg-41171", "name": "안양시만안구", "parentSlug": "gyeonggi-south", "sigunguCode": "41171", - "sortOrder": 44 + "sortOrder": 44, + "centerLat": "37.3867041", + "centerLng": "126.9325104" }, { "slug": "sgg-41173", "name": "안양시동안구", "parentSlug": "gyeonggi-south", "sigunguCode": "41173", - "sortOrder": 45 + "sortOrder": 45, + "centerLat": "37.3926060", + "centerLng": "126.9514229" }, { "slug": "sgg-41192", "name": "부천시원미구", "parentSlug": "gyeonggi-south", "sigunguCode": "41192", - "sortOrder": 46 + "sortOrder": 46, + "centerLat": "37.4965957", + "centerLng": "126.7870168" }, { "slug": "sgg-41194", "name": "부천시소사구", "parentSlug": "gyeonggi-south", "sigunguCode": "41194", - "sortOrder": 47 + "sortOrder": 47, + "centerLat": "37.4800578", + "centerLng": "126.7999525" }, { "slug": "sgg-41196", "name": "부천시오정구", "parentSlug": "gyeonggi-south", "sigunguCode": "41196", - "sortOrder": 48 + "sortOrder": 48, + "centerLat": "37.5283805", + "centerLng": "126.7957553" }, { "slug": "sgg-41210", "name": "광명시", "parentSlug": "gyeonggi-south", "sigunguCode": "41210", - "sortOrder": 49 + "sortOrder": 49, + "centerLat": "37.4785675", + "centerLng": "126.8646478" }, { "slug": "sgg-41220", "name": "평택시", "parentSlug": "gyeonggi-south", "sigunguCode": "41220", - "sortOrder": 50 + "sortOrder": 50, + "centerLat": "36.9922607", + "centerLng": "127.1126884" }, { "slug": "sgg-41250", "name": "동두천시", "parentSlug": "gyeonggi-north", "sigunguCode": "41250", - "sortOrder": 51 + "sortOrder": 51, + "centerLat": "37.9035986", + "centerLng": "127.0604407" }, { "slug": "sgg-41271", "name": "안산시상록구", "parentSlug": "gyeonggi-south", "sigunguCode": "41271", - "sortOrder": 52 + "sortOrder": 52, + "centerLat": "37.3009718", + "centerLng": "126.8464826" }, { "slug": "sgg-41273", "name": "안산시단원구", "parentSlug": "gyeonggi-south", "sigunguCode": "41273", - "sortOrder": 53 + "sortOrder": 53, + "centerLat": "37.3211465", + "centerLng": "126.8153255" }, { "slug": "sgg-41281", "name": "고양시덕양구", "parentSlug": "gyeonggi-north", "sigunguCode": "41281", - "sortOrder": 54 + "sortOrder": 54, + "centerLat": "37.6374796", + "centerLng": "126.8321881" }, { "slug": "sgg-41285", "name": "고양시일산동구", "parentSlug": "gyeonggi-north", "sigunguCode": "41285", - "sortOrder": 55 + "sortOrder": 55, + "centerLat": "37.6588746", + "centerLng": "126.7750080" }, { "slug": "sgg-41287", "name": "고양시일산서구", "parentSlug": "gyeonggi-north", "sigunguCode": "41287", - "sortOrder": 56 + "sortOrder": 56, + "centerLat": "37.6779514", + "centerLng": "126.7454771" }, { "slug": "sgg-41290", "name": "과천시", "parentSlug": "gyeonggi-south", "sigunguCode": "41290", - "sortOrder": 57 + "sortOrder": 57, + "centerLat": "37.4292328", + "centerLng": "126.9877210" }, { "slug": "sgg-41310", "name": "구리시", "parentSlug": "gyeonggi-north", "sigunguCode": "41310", - "sortOrder": 58 + "sortOrder": 58, + "centerLat": "37.5943524", + "centerLng": "127.1296418" }, { "slug": "sgg-41360", "name": "남양주시", "parentSlug": "gyeonggi-north", "sigunguCode": "41360", - "sortOrder": 59 + "sortOrder": 59, + "centerLat": "37.6360236", + "centerLng": "127.2164758" }, { "slug": "sgg-41370", "name": "오산시", "parentSlug": "gyeonggi-south", "sigunguCode": "41370", - "sortOrder": 60 + "sortOrder": 60, + "centerLat": "37.1498790", + "centerLng": "127.0775265" }, { "slug": "sgg-41390", "name": "시흥시", "parentSlug": "gyeonggi-south", "sigunguCode": "41390", - "sortOrder": 61 + "sortOrder": 61, + "centerLat": "37.3801151", + "centerLng": "126.8029778" }, { "slug": "sgg-41410", "name": "군포시", "parentSlug": "gyeonggi-south", "sigunguCode": "41410", - "sortOrder": 62 + "sortOrder": 62, + "centerLat": "37.3616318", + "centerLng": "126.9352059" }, { "slug": "sgg-41430", "name": "의왕시", "parentSlug": "gyeonggi-south", "sigunguCode": "41430", - "sortOrder": 63 + "sortOrder": 63, + "centerLat": "37.3447600", + "centerLng": "126.9682738" }, { "slug": "sgg-41450", "name": "하남시", "parentSlug": "gyeonggi-south", "sigunguCode": "41450", - "sortOrder": 64 + "sortOrder": 64, + "centerLat": "37.5392746", + "centerLng": "127.2148606" }, { "slug": "sgg-41461", "name": "용인시처인구", "parentSlug": "gyeonggi-south", "sigunguCode": "41461", - "sortOrder": 65 + "sortOrder": 65, + "centerLat": "37.2343060", + "centerLng": "127.2013571" }, { "slug": "sgg-41463", "name": "용인시기흥구", "parentSlug": "gyeonggi-south", "sigunguCode": "41463", - "sortOrder": 66 + "sortOrder": 66, + "centerLat": "37.2804109", + "centerLng": "127.1147132" }, { "slug": "sgg-41465", "name": "용인시수지구", "parentSlug": "gyeonggi-south", "sigunguCode": "41465", - "sortOrder": 67 + "sortOrder": 67, + "centerLat": "37.3221153", + "centerLng": "127.0976817" }, { "slug": "sgg-41480", "name": "파주시", "parentSlug": "gyeonggi-north", "sigunguCode": "41480", - "sortOrder": 68 + "sortOrder": 68, + "centerLat": "37.7600450", + "centerLng": "126.7798814" }, { "slug": "sgg-41500", "name": "이천시", "parentSlug": "gyeonggi-south", "sigunguCode": "41500", - "sortOrder": 69 + "sortOrder": 69, + "centerLat": "37.2722676", + "centerLng": "127.4350893" }, { "slug": "sgg-41550", "name": "안성시", "parentSlug": "gyeonggi-south", "sigunguCode": "41550", - "sortOrder": 70 + "sortOrder": 70, + "centerLat": "37.0080705", + "centerLng": "127.2797903" }, { "slug": "sgg-41570", "name": "김포시", "parentSlug": "gyeonggi-south", "sigunguCode": "41570", - "sortOrder": 71 + "sortOrder": 71, + "centerLat": "37.6152687", + "centerLng": "126.7156579" }, { "slug": "sgg-41591", "name": "화성시만세구", "parentSlug": "gyeonggi-south", "sigunguCode": "41591", - "sortOrder": 72 + "sortOrder": 72, + "centerLat": "37.1377095", + "centerLng": "126.9256732" }, { "slug": "sgg-41593", "name": "화성시효행구", "parentSlug": "gyeonggi-south", "sigunguCode": "41593", - "sortOrder": 73 + "sortOrder": 73, + "centerLat": "37.2091796", + "centerLng": "126.9591831" }, { "slug": "sgg-41595", "name": "화성시병점구", "parentSlug": "gyeonggi-south", "sigunguCode": "41595", - "sortOrder": 74 + "sortOrder": 74, + "centerLat": "37.2087486", + "centerLng": "127.0360897" }, { "slug": "sgg-41597", "name": "화성시동탄구", "parentSlug": "gyeonggi-south", "sigunguCode": "41597", - "sortOrder": 75 + "sortOrder": 75, + "centerLat": "37.1972782", + "centerLng": "127.0970070" }, { "slug": "sgg-41610", "name": "광주시", "parentSlug": "gyeonggi-south", "sigunguCode": "41610", - "sortOrder": 76 + "sortOrder": 76, + "centerLat": "37.4293942", + "centerLng": "127.2551387" }, { "slug": "sgg-41630", "name": "양주시", "parentSlug": "gyeonggi-north", "sigunguCode": "41630", - "sortOrder": 77 + "sortOrder": 77, + "centerLat": "37.7853137", + "centerLng": "127.0457861" }, { "slug": "sgg-41650", "name": "포천시", "parentSlug": "gyeonggi-north", "sigunguCode": "41650", - "sortOrder": 78 + "sortOrder": 78, + "centerLat": "37.8949928", + "centerLng": "127.2003329" }, { "slug": "sgg-41670", "name": "여주시", "parentSlug": "gyeonggi-south", "sigunguCode": "41670", - "sortOrder": 79 + "sortOrder": 79, + "centerLat": "37.2984234", + "centerLng": "127.6370588" }, { "slug": "sgg-41800", "name": "연천군", "parentSlug": "gyeonggi-north", "sigunguCode": "41800", - "sortOrder": 80 + "sortOrder": 80, + "centerLat": "38.0965223", + "centerLng": "127.0753558" }, { "slug": "sgg-41820", "name": "가평군", "parentSlug": "gyeonggi-north", "sigunguCode": "41820", - "sortOrder": 81 + "sortOrder": 81, + "centerLat": "37.8314413", + "centerLng": "127.5095702" }, { "slug": "sgg-41830", "name": "양평군", "parentSlug": "gyeonggi-south", "sigunguCode": "41830", - "sortOrder": 82 + "sortOrder": 82, + "centerLat": "37.4917883", + "centerLng": "127.4875785" } ] } diff --git a/prisma/seed/regions.ts b/prisma/seed/regions.ts index 0c474c7f..0ee5e67a 100644 --- a/prisma/seed/regions.ts +++ b/prisma/seed/regions.ts @@ -18,6 +18,8 @@ interface RegionSeedFile { parentSlug: string; sigunguCode: string; sortOrder: number; + centerLat?: string; + centerLng?: string; }[]; } @@ -57,6 +59,11 @@ export async function seedRegions(prisma: PrismaClient): Promise { `[seed] region parent not found: ${child.parentSlug} (child ${child.slug})`, ); } + // 좌표가 아직 없는 지역은 DB 값을 건드리지 않는다 + const center = + child.centerLat !== undefined && child.centerLng !== undefined + ? { center_lat: child.centerLat, center_lng: child.centerLng } + : {}; await prisma.region.upsert({ where: { slug: child.slug }, create: { @@ -65,6 +72,7 @@ export async function seedRegions(prisma: PrismaClient): Promise { slug: child.slug, sort_order: child.sortOrder, parent_id: parentId, + ...center, }, update: { level: 2, @@ -73,6 +81,7 @@ export async function seedRegions(prisma: PrismaClient): Promise { parent_id: parentId, is_active: true, deleted_at: null, + ...center, }, }); } diff --git a/scripts/fill-region-centers.ts b/scripts/fill-region-centers.ts new file mode 100644 index 00000000..7189ac44 --- /dev/null +++ b/scripts/fill-region-centers.ts @@ -0,0 +1,144 @@ +/** + * 2차 지역 중심 좌표 채우기. + * + * 입력·출력: prisma/seed/data/regions.generated.json — 좌표가 없는 2차 항목만 카카오 주소 검색의 시군구 대표점으로 채운다. + * - 받은 좌표를 coord2regioncode로 되돌려 같은 시군구인지 확인한다. 하나라도 어긋나면 파일을 쓰지 않고 멈춘다. + * - --sql <경로>: 이미 지역이 있는 DB를 채우는 데이터 마이그레이션 SQL을 JSON의 좌표 전체로 쓴다. + * + * 실행: OIDC_KAKAO_CLIENT_ID=<카카오 REST 키> npx ts-node scripts/fill-region-centers.ts [--sql ] + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const JSON_PATH = join( + process.cwd(), + 'prisma/seed/data/regions.generated.json', +); +const SEARCH_URL = 'https://dapi.kakao.com/v2/local/search/address.json'; +const REVERSE_URL = 'https://dapi.kakao.com/v2/local/geo/coord2regioncode.json'; + +const SIDO_BY_CODE: Record = { + '11': '서울특별시', + '28': '인천광역시', + '41': '경기도', +}; + +interface Level2 { + slug: string; + name: string; + parentSlug: string; + sigunguCode: string; + sortOrder: number; + centerLat?: string; + centerLng?: string; +} + +interface KakaoDocument { + address_type?: string; + region_type?: string; + code?: string; + x: string; + y: string; +} + +interface RegionSeedFile { + level1: unknown[]; + level2: Level2[]; +} + +async function kakao( + url: string, + params: Record, + key: string, +): Promise<{ documents: KakaoDocument[] }> { + const res = await fetch(`${url}?${new URLSearchParams(params)}`, { + headers: { Authorization: `KakaoAK ${key}` }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`[region-centers] 카카오 ${res.status}: ${url}`); + return (await res.json()) as { documents: KakaoDocument[] }; +} + +/** "수원시영통구"처럼 붙어 있는 일반시의 구는 띄워야 카카오가 시군구로 찾는다. */ +function queryOf(region: Level2): string { + const sido = SIDO_BY_CODE[region.sigunguCode.slice(0, 2)]; + if (!sido) throw new Error(`[region-centers] 시도 미상: ${region.slug}`); + return `${sido} ${region.name.replace(/^(.+시)(.+구)$/, '$1 $2')}`; +} + +async function centerOf( + region: Level2, + key: string, +): Promise<{ lat: string; lng: string } | string> { + const query = queryOf(region); + const { documents } = await kakao(SEARCH_URL, { query }, key); + if (documents.length !== 1 || documents[0].address_type !== 'REGION') { + return `'${query}' 검색 결과가 시군구 1건이 아니다(${documents.length}건)`; + } + const [{ x, y }] = documents; + const reverse = await kakao(REVERSE_URL, { x, y }, key); + const code = reverse.documents.find((d) => d.region_type === 'B')?.code; + if (code?.slice(0, 5) !== region.sigunguCode) { + return `'${query}' 좌표(${y}, ${x})가 다른 시군구(${code ?? '없음'})에 있다`; + } + // 컬럼이 DECIMAL(10,7)이다 + return { lat: Number(y).toFixed(7), lng: Number(x).toFixed(7) }; +} + +function migrationSql(regions: Level2[]): string { + const rows = regions + .filter((r) => r.centerLat !== undefined && r.centerLng !== undefined) + .map((r, i) => + i === 0 + ? ` SELECT '${r.slug}' AS \`slug\`, ${r.centerLat} AS \`lat\`, ${r.centerLng} AS \`lng\`` + : ` UNION ALL SELECT '${r.slug}', ${r.centerLat}, ${r.centerLng}`, + ); + return `-- 2차 지역 중심 좌표(카카오 주소 검색의 시군구 대표점). 시드(regions.generated.json)와 같은 값이며 scripts/fill-region-centers.ts가 만든다. +-- 좌표가 비어 있는 행만 채워 관리자가 넣은 값은 그대로 둔다. 지역이 없는 빈 DB에서는 아무 행도 건드리지 않는다. +UPDATE \`region\` r +JOIN ( +${rows.join('\n')} +) c ON c.\`slug\` = r.\`slug\` +SET r.\`center_lat\` = c.\`lat\`, r.\`center_lng\` = c.\`lng\`, r.\`updated_at\` = CURRENT_TIMESTAMP(3) +WHERE r.\`level\` = 2 AND r.\`center_lat\` IS NULL AND r.\`center_lng\` IS NULL; +`; +} + +async function main(): Promise { + const key = process.env.OIDC_KAKAO_CLIENT_ID; + if (!key) throw new Error('[region-centers] OIDC_KAKAO_CLIENT_ID가 필요하다'); + const sqlAt = process.argv.indexOf('--sql'); + const sqlPath = sqlAt >= 0 ? process.argv[sqlAt + 1] : undefined; + + const data = JSON.parse(readFileSync(JSON_PATH, 'utf8')) as RegionSeedFile; + const targets = data.level2.filter( + (r) => r.centerLat === undefined || r.centerLng === undefined, + ); + console.log(`2차 ${data.level2.length}건 중 채울 대상 ${targets.length}건`); + + const failures: string[] = []; + for (const region of targets) { + const center = await centerOf(region, key); + if (typeof center === 'string') { + failures.push(`${region.slug}: ${center}`); + continue; + } + region.centerLat = center.lat; + region.centerLng = center.lng; + } + if (failures.length > 0) { + console.error(failures.join('\n')); + throw new Error( + `[region-centers] ${failures.length}건 실패 — 파일을 쓰지 않았다`, + ); + } + console.log(`역검증 통과 ${targets.length}/${targets.length}`); + + writeFileSync(JSON_PATH, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + if (sqlPath) writeFileSync(sqlPath, migrationSql(data.level2), 'utf8'); +} + +main().catch((e: unknown) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/scripts/generate-region-seed.ts b/scripts/generate-region-seed.ts index 94dda4a2..100c3873 100644 --- a/scripts/generate-region-seed.ts +++ b/scripts/generate-region-seed.ts @@ -14,10 +14,11 @@ * 1차 그룹 경계는 운영 정의이므로, 변경 시 이 파일의 SEOUL_NORTH / GYEONGGI_NORTH_CITY 를 * 고치고 재실행한다. (figma spec에 2차 생활권 큐레이션이 확정되면 별도 반영) * 행정구역 개편으로 2차가 바뀌면 이미 데이터가 있는 DB를 위한 데이터 마이그레이션도 함께 낸다(예: region_incheon_reorg). + * 중심 좌표는 fill-region-centers.ts가 채운다. 다시 만들 때 기존 좌표를 slug로 이어받으므로 새 지역만 비게 된다. * * 실행: npx ts-node scripts/generate-region-seed.ts */ -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const INPUT_FILE = 'KIKcd_B.20260930'; @@ -80,6 +81,8 @@ interface Level2 { parentSlug: string; sigunguCode: string; sortOrder: number; + centerLat?: string; + centerLng?: string; } // figma spec 순서: 전국 / 서울 북부 / 서울 남부 / 경기 북부 / 경기 남부 / 인천 @@ -150,6 +153,21 @@ function splitLines(buf: Buffer): Buffer[] { return lines; } +function previousCenters(): Map< + string, + Pick +> { + if (!existsSync(OUT_PATH)) return new Map(); + const { level2 } = JSON.parse(readFileSync(OUT_PATH, 'utf8')) as { + level2: Level2[]; + }; + return new Map( + level2 + .filter((r) => r.centerLat !== undefined && r.centerLng !== undefined) + .map((r) => [r.slug, { centerLat: r.centerLat, centerLng: r.centerLng }]), + ); +} + function main(): void { // code5(시도2+시군구3) 기준 distinct. "동을 직접 보유한 시군구"만 채택. const seen = new Map(); @@ -181,12 +199,14 @@ function main(): void { a.code5.localeCompare(b.code5), ); + const centers = previousCenters(); 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, + ...centers.get(`sgg-${s.code5}`), })); const out = { diff --git a/src/features/region/repositories/region-center-coordinates.migration.spec.ts b/src/features/region/repositories/region-center-coordinates.migration.spec.ts new file mode 100644 index 00000000..b61bc94a --- /dev/null +++ b/src/features/region/repositories/region-center-coordinates.migration.spec.ts @@ -0,0 +1,137 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { PrismaClient } from '@/generated/prisma/client'; +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'; + +// 지역 중심 좌표 마이그레이션의 SQL을 그대로 실행해 이미 지역이 있는 DB가 시드와 같은 좌표로 채워지는지 본다. +// 마이그레이션은 빈 DB에 적용되므로 여기서만 검증된다. +const ROOT = join(__dirname, '../../../..'); +const MIGRATIONS = join(ROOT, 'prisma/migrations'); +const MIGRATION_SQL = readFileSync( + join( + MIGRATIONS, + readdirSync(MIGRATIONS).find((d) => + d.endsWith('_region_center_coordinates'), + )!, + 'migration.sql', + ), + 'utf8', +); +const SEED = ( + JSON.parse( + readFileSync(join(ROOT, 'prisma/seed/data/regions.generated.json'), 'utf8'), + ) as { level2: { slug: string; centerLat: string; centerLng: string }[] } +).level2; + +function statementOf(sql: string): string { + const stmts = sql + .split(';') + .map((stmt) => stmt.replace(/^\s*--[^\n]*$/gm, '').trim()) + .filter((stmt) => stmt.length > 0); + expect(stmts).toHaveLength(1); + return stmts[0]; +} + +describe('region_center_coordinates 마이그레이션 (real DB)', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + ({ prisma } = await createTestingModuleWithRealDb({ providers: [] })); + }); + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + beforeEach(async () => { + await truncateAll(); + }); + + async function run(sql = MIGRATION_SQL): Promise { + return prisma.$executeRawUnsafe(statementOf(sql)); + } + + /** 시드의 2차 지역이 좌표 없이 있는 운영 상태. */ + async function seedWithoutCenters(): Promise { + for (const { slug } of SEED) await createRegion(prisma, { level: 2, slug }); + } + + /** DB 좌표가 시드와 다른 slug. */ + async function mismatches(): Promise { + const rows = await prisma.region.findMany({ + select: { slug: true, center_lat: true, center_lng: true }, + }); + const bySlug = new Map(rows.map((row) => [row.slug, row])); + return SEED.filter(({ slug, centerLat, centerLng }) => { + const row = bySlug.get(slug); + return ( + row?.center_lat?.toFixed(7) !== centerLat || + row?.center_lng?.toFixed(7) !== centerLng + ); + }).map(({ slug }) => slug); + } + + it('좌표가 없는 2차 지역 83곳을 시드와 같은 좌표로 채운다', async () => { + expect(SEED).toHaveLength(83); + await seedWithoutCenters(); + expect(await mismatches()).toHaveLength(83); + + expect(await run()).toBe(83); + + expect(await mismatches()).toEqual([]); + }); + + it('시드와 다른 값이 SQL에 있으면 그 지역만 어긋난다', async () => { + const [target] = SEED; + const row = `'${target.slug}' AS \`slug\`, ${target.centerLat}`; + const mutated = MIGRATION_SQL.replace( + row, + `'${target.slug}' AS \`slug\`, 37.0000000`, + ); + expect(mutated).not.toBe(MIGRATION_SQL); + await seedWithoutCenters(); + + await run(mutated); + + expect(await mismatches()).toEqual([target.slug]); + }); + + it('이미 좌표가 있는 행은 덮어쓰지 않고, 시드에 없는 지역은 비워 둔다', async () => { + const [manual, empty] = SEED; + await createRegion(prisma, { + level: 2, + slug: manual.slug, + center_lat: 37.1, + center_lng: 127.1, + }); + await createRegion(prisma, { level: 2, slug: empty.slug }); + // 인천 개편으로 말소된 옛 중구 + await createRegion(prisma, { level: 2, slug: 'sgg-28110' }); + + expect(await run()).toBe(1); + + const rows = await prisma.region.findMany({ + select: { slug: true, center_lat: true, center_lng: true }, + orderBy: { slug: 'asc' }, + }); + expect( + rows.map((r) => [ + r.slug, + r.center_lat?.toFixed(7), + r.center_lng?.toFixed(7), + ]), + ).toEqual([ + [manual.slug, '37.1000000', '127.1000000'], + [empty.slug, empty.centerLat, empty.centerLng], + ['sgg-28110', undefined, undefined], + ]); + }); + + it('지역이 없는 빈 DB에서는 아무 행도 바꾸지 않는다', async () => { + expect(await run()).toBe(0); + expect(await prisma.region.count()).toBe(0); + }); +}); diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 0cbb07d8..1dd14f0d 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -36,6 +36,11 @@ const STORE_CANDIDATE_SELECT = { export type StoreSearchCandidateRow = StoreCandidateRow; +export interface RegionCenterRow { + center_lat: Prisma.Decimal | null; + center_lng: Prisma.Decimal | null; +} + export interface StoreSearchFilter { words: string[]; regionIds?: bigint[]; @@ -98,6 +103,14 @@ export class StoreRepository { }); } + /** 매장 후보의 지역 조건과 같게 지역 활성 여부는 보지 않는다. */ + async findRegionCenters(regionIds: bigint[]): Promise { + return this.prisma.region.findMany({ + where: { id: { in: regionIds }, level: 2 }, + select: { center_lat: true, center_lng: true }, + }); + } + /** 인기순 점수화가 메모리라 후보를 모두 로드한다(findActiveStoresForRanking과 동일 트레이드오프). */ async findStoreSearchCandidates( filter: StoreSearchFilter, diff --git a/src/features/store/resolvers/store-pickup-stores-query.resolver.spec.ts b/src/features/store/resolvers/store-pickup-stores-query.resolver.spec.ts index 1184a560..2d05e9f2 100644 --- a/src/features/store/resolvers/store-pickup-stores-query.resolver.spec.ts +++ b/src/features/store/resolvers/store-pickup-stores-query.resolver.spec.ts @@ -15,6 +15,7 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, + createRegion, createStore, createStoreWishlist, } from '@/test/factories'; @@ -108,4 +109,24 @@ describe('StorePickupStoresQueryResolver (real DB)', () => { expect(result.items[0].store.isWishlisted).toBe(false); }); + + it('pickupStores: 선택 지역의 지도 범위를 싣는다', async () => { + const region = await createRegion(prisma, { + level: 2, + center_lat: 37.574773, + center_lng: 126.9796145, + }); + + const result = await resolver.pickupStores(undefined, { + date: DATE, + regionIds: [region.id.toString()], + }); + + expect(result.regionBounds).toEqual({ + south: 37.574773, + west: 126.9796145, + north: 37.574773, + east: 126.9796145, + }); + }); }); diff --git a/src/features/store/services/store-pickup-stores.service.spec.ts b/src/features/store/services/store-pickup-stores.service.spec.ts index 9d48a05c..39a20d03 100644 --- a/src/features/store/services/store-pickup-stores.service.spec.ts +++ b/src/features/store/services/store-pickup-stores.service.spec.ts @@ -168,6 +168,7 @@ describe('StorePickupStoresService (real DB)', () => { items: [], totalCount: 0, hasMore: false, + regionBounds: null, }); }); }); @@ -279,4 +280,103 @@ describe('StorePickupStoresService (real DB)', () => { ]); }); }); + + describe('지도 범위(regionBounds)', () => { + const point = (lat: number, lng: number) => ({ + south: lat, + west: lng, + north: lat, + east: lng, + }); + + async function district( + lat: number | null, + lng: number | null, + overrides = {}, + ): Promise { + const region = await createRegion(prisma, { + level: 2, + center_lat: lat, + center_lng: lng, + ...overrides, + }); + return region.id.toString(); + } + + async function boundsOf( + regionIds: string[] | undefined, + input: Partial[0]> = {}, + ) { + const result = await service.pickupStores({ + date: DATE, + regionIds, + ...input, + }); + return result.regionBounds; + } + + it('지역이 하나면 그 중심 좌표 한 점이다', async () => { + const id = await district(37.574773, 126.9796145); + + expect(await boundsOf([id])).toEqual(point(37.574773, 126.9796145)); + }); + + it('여러 지역이면 위도·경도 각각의 최소·최대로 모두 담는다', async () => { + // 남쪽 끝과 서쪽 끝이 서로 다른 지역에 있다 + const ids = [ + await district(37.5, 127.1), + await district(37.7, 126.9), + await district(37.6, 127.0), + ]; + + expect(await boundsOf(ids)).toEqual({ + south: 37.5, + west: 126.9, + north: 37.7, + east: 127.1, + }); + }); + + it('좌표 없는 지역은 빼고 계산하고, 전부 없으면 null이다', async () => { + const withCenter = await district(37.5, 127.0); + const without = await district(null, null); + + expect(await boundsOf([withCenter, without])).toEqual(point(37.5, 127.0)); + expect(await boundsOf([without])).toBeNull(); + }); + + it('regionIds를 비우거나 생략하면(전국) null이다', async () => { + await district(37.5, 127.0); + + expect(await boundsOf([])).toBeNull(); + expect(await boundsOf(undefined)).toBeNull(); + }); + + it('비활성 지역은 담고, 1차 지역 ID와 삭제된 지역은 뺀다', async () => { + const inactive = await district(37.5, 127.0, { is_active: false }); + const group = await createRegion(prisma, { + level: 1, + center_lat: 38.0, + center_lng: 126.0, + }); + const deleted = await district(36.0, 128.0); + await prisma.region.update({ + where: { id: BigInt(deleted) }, + data: { deleted_at: NOW }, + }); + + expect(await boundsOf([inactive, group.id.toString(), deleted])).toEqual( + point(37.5, 127.0), + ); + }); + + it('매장이 0건이거나 예약 가능 기간 상한을 넘는 날짜여도 범위가 온다', async () => { + const id = await district(37.5, 127.0); + + expect(await boundsOf([id])).toEqual(point(37.5, 127.0)); + expect(await boundsOf([id], { date: '2027-08-20' })).toEqual( + point(37.5, 127.0), + ); + }); + }); }); diff --git a/src/features/store/services/store-pickup-stores.service.ts b/src/features/store/services/store-pickup-stores.service.ts index 1a31ea8f..f77cb5bd 100644 --- a/src/features/store/services/store-pickup-stores.service.ts +++ b/src/features/store/services/store-pickup-stores.service.ts @@ -9,15 +9,38 @@ import { DEFAULT_SEARCH_PAGE_LIMIT } from '@/features/store/constants/store-sear import { MAX_DAYS_AHEAD } from '@/features/store/constants/store-seller.constants'; import { DEFAULT_STORE_SORT } from '@/features/store/constants/store-sort.constants'; import type { PickupStoresInput } from '@/features/store/dto/inputs/pickup-stores.input'; -import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { + StoreRepository, + type RegionCenterRow, +} from '@/features/store/repositories/store.repository'; import { StoreCardService } from '@/features/store/services/store-card.service'; import { toCoordinate } from '@/features/store/services/store-detail-mappers.helper'; import { StoreListingService } from '@/features/store/services/store-listing.service'; import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service'; -import type { PickupStoreConnection } from '@/features/store/types/store-pickup-stores-output.type'; +import type { + MapBounds, + PickupStoreConnection, +} from '@/features/store/types/store-pickup-stores-output.type'; const PICKUP_TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; +function toRegionBounds(centers: RegionCenterRow[]): MapBounds | null { + const points = centers.flatMap((center) => { + const lat = toCoordinate(center.center_lat); + const lng = toCoordinate(center.center_lng); + return lat !== null && lng !== null ? [{ lat, lng }] : []; + }); + if (points.length === 0) return null; + const lats = points.map((p) => p.lat); + const lngs = points.map((p) => p.lng); + return { + south: Math.min(...lats), + west: Math.min(...lngs), + north: Math.max(...lats), + east: Math.max(...lngs), + }; +} + @Injectable() export class StorePickupStoresService { constructor( @@ -44,14 +67,16 @@ export class StorePickupStoresService { const offset = input.offset ?? 0; const limit = input.limit ?? DEFAULT_SEARCH_PAGE_LIMIT; + const regionIds = input.regionIds?.map((id) => parseId(id)); + const regionBounds = regionIds?.length + ? toRegionBounds(await this.repo.findRegionCenters(regionIds)) + : null; // 예약 가능 기간 상한을 넘으면 어느 매장도 열리지 않는다 if (daysAhead > MAX_DAYS_AHEAD) { - return { items: [], totalCount: 0, hasMore: false }; + return { items: [], totalCount: 0, hasMore: false, regionBounds }; } - const candidates = await this.repo.findActiveStoresForRanking( - input.regionIds?.map((id) => parseId(id)), - ); + const candidates = await this.repo.findActiveStoresForRanking(regionIds); const slotsByStore = await this.availability.openSlotsOn( candidates, day, @@ -82,6 +107,7 @@ export class StorePickupStoresService { })), totalCount: matched.length, hasMore: hasMoreByOffset(offset, limit, matched.length), + regionBounds, }; } } diff --git a/src/features/store/store-pickup-stores.graphql b/src/features/store/store-pickup-stores.graphql index 23adfae5..7a8319ab 100644 --- a/src/features/store/store-pickup-stores.graphql +++ b/src/features/store/store-pickup-stores.graphql @@ -27,6 +27,23 @@ type PickupStoreConnection { totalCount: Int! """다음 페이지 존재 여부.""" hasMore: Boolean! + """ + 선택한 지역들의 중심 좌표를 모두 담는 범위(지도 카메라용). 매장이 0건이어도 온다. 지역이 하나면 한 점(south=north, west=east)이다. + regionIds를 비웠거나(전국) 좌표가 등록된 2차 지역이 없으면 null. + """ + regionBounds: MapBounds +} + +"""지도 범위(WGS84).""" +type MapBounds { + """남쪽 끝 위도.""" + south: Float! + """서쪽 끝 경도.""" + west: Float! + """북쪽 끝 위도.""" + north: Float! + """동쪽 끝 경도.""" + east: Float! } """매장 선택 항목. 매장 카드와 지도 마커 좌표.""" diff --git a/src/features/store/types/store-pickup-stores-output.type.ts b/src/features/store/types/store-pickup-stores-output.type.ts index 51c10b83..777856b1 100644 --- a/src/features/store/types/store-pickup-stores-output.type.ts +++ b/src/features/store/types/store-pickup-stores-output.type.ts @@ -7,4 +7,13 @@ export interface PickupStoreItem { longitude: number | null; } -export type PickupStoreConnection = OffsetConnection; +export interface MapBounds { + south: number; + west: number; + north: number; + east: number; +} + +export type PickupStoreConnection = OffsetConnection & { + regionBounds: MapBounds | null; +}; diff --git a/src/global/rate-limit/index.ts b/src/global/rate-limit/index.ts index cd4c5ce1..8fc79316 100644 --- a/src/global/rate-limit/index.ts +++ b/src/global/rate-limit/index.ts @@ -1,5 +1,2 @@ export { RateLimit } from '@/global/rate-limit/rate-limit.decorator'; -export { - RateLimitGuard, - type RateLimitPolicy, -} from '@/global/rate-limit/rate-limit.guard'; +export { RateLimitGuard } from '@/global/rate-limit/rate-limit.guard'; diff --git a/src/test/factories/region.factory.ts b/src/test/factories/region.factory.ts index ab66f1cf..43574752 100644 --- a/src/test/factories/region.factory.ts +++ b/src/test/factories/region.factory.ts @@ -8,6 +8,8 @@ export interface RegionOverrides { slug?: string; sort_order?: number; is_active?: boolean; + center_lat?: number | null; + center_lng?: number | null; } export async function createRegion( @@ -23,6 +25,8 @@ export async function createRegion( slug: overrides.slug ?? `region-${seq}`, sort_order: overrides.sort_order ?? 0, is_active: overrides.is_active ?? true, + center_lat: overrides.center_lat ?? null, + center_lng: overrides.center_lng ?? null, }, }); }