From a6c2ec308e058395def3c17e298f0bb338c21d0d Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:26:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EA=B2=80=EC=83=89=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CurationPayloadValidator.java | 35 +++++++++++++++++ .../resources/openapi/curation/program.json | 38 +++++++++++++++---- .../curation/whisky_tasting_event.json | 20 ++++++---- .../CurationPlaceFieldContractTest.java | 31 +++++++++------ .../CurationSpecMetadataValidatorTest.java | 35 +++++++++++++++++ 5 files changed, 134 insertions(+), 25 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationPayloadValidator.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationPayloadValidator.java index e4eb11c9d..bb29a4c68 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationPayloadValidator.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationPayloadValidator.java @@ -80,6 +80,14 @@ private void validateSpecNode( if (dependsOn != null) { validateDependsOn(rootPath, path, siblingKeys, siblingProperties, dependsOn, errors); } + JsonNode placeSearchTargets = schema.get("x-place-search-targets"); + if (placeSearchTargets != null) { + validatePlaceSearchTargets(rootPath, path, schema, siblingKeys, placeSearchTargets, errors); + } + JsonNode readOnly = schema.get("x-read-only"); + if (readOnly != null && !readOnly.isBoolean()) { + errors.add(fieldPath(rootPath, path) + " x-read-only는 boolean이어야 합니다."); + } JsonNode properties = schema.path("properties"); if (properties.isObject()) { @@ -107,6 +115,33 @@ private void validateSpecNode( } } + private void validatePlaceSearchTargets( + String rootPath, + ArrayDeque path, + JsonNode schema, + Set siblingKeys, + JsonNode placeSearchTargets, + List errors) { + String fieldPath = fieldPath(rootPath, path); + if (!"address-search".equals(schema.path("x-field-style").asText())) { + errors.add(fieldPath + " x-place-search-targets는 address-search 필드에만 지정할 수 있습니다."); + } + if (!placeSearchTargets.isObject() || placeSearchTargets.isEmpty()) { + errors.add(fieldPath + " x-place-search-targets는 비어있지 않은 object여야 합니다."); + return; + } + for (Map.Entry target : placeSearchTargets.properties()) { + if (!siblingKeys.contains(target.getKey())) { + errors.add(fieldPath + " 장소검색 대상 key가 같은 scope에 없습니다: " + target.getKey()); + } + if (!target.getValue().isTextual() + || !Set.of("placeName", "id", "address").contains(target.getValue().asText())) { + errors.add( + fieldPath + " 장소검색 source는 placeName, id, address 중 하나여야 합니다: " + target.getKey()); + } + } + } + private void validateDependsOn( String rootPath, ArrayDeque path, diff --git a/bottlenote-mono/src/main/resources/openapi/curation/program.json b/bottlenote-mono/src/main/resources/openapi/curation/program.json index 4c5c90b8d..b6e2a91dd 100644 --- a/bottlenote-mono/src/main/resources/openapi/curation/program.json +++ b/bottlenote-mono/src/main/resources/openapi/curation/program.json @@ -3,7 +3,7 @@ "info": { "title": "프로그램", "description": "행사 기간과 장소, 여러 프로그램 및 프로그램별 위스키 라인업을 함께 발행하는 행사형 큐레이션.", - "version": "3.0.0" + "version": "3.0.1" }, "x-curation": { "code": "PROGRAM", @@ -35,30 +35,45 @@ }, "placeName": { "type": "string", - "description": "행사 대표 장소명", + "description": "행사 대표 장소명. 장소검색으로 선택한다.", "example": "코엑스", "maxLength": 100, "x-field-style": "address-search", - "x-display-name": "장소명" + "x-display-name": "장소명", + "x-place-search-targets": { + "placeName": "placeName", + "kakaoPlaceId": "id", + "address": "address" + } }, "kakaoPlaceId": { "type": "string", - "description": "Kakao Places 장소 ID", + "description": "Kakao Places 장소 ID. 장소검색 선택값으로 자동 저장한다.", "example": "27288225", "minLength": 1, "maxLength": 20, "pattern": "^\\d+$", - "x-field-style": "address-search", + "x-field-style": "hidden", "x-display-name": "Kakao 장소 ID" }, "address": { "type": "string", - "description": "행사 대표 주소", + "description": "행사 대표 주소. 장소검색 선택값으로 자동 설정되며 직접 수정할 수 없다.", "example": "서울 강남구 영동대로 513", "maxLength": 200, - "x-field-style": "address-search", + "x-field-style": "plain-text", + "x-read-only": true, "x-display-name": "장소 및 주소" }, + "detailAddress": { + "type": "string", + "description": "행사 상세 주소. 건물, 층, 호 등 주소를 보완해 직접 입력한다.", + "example": "B홀 2층", + "maxLength": 200, + "nullable": true, + "x-field-style": "plain-text", + "x-display-name": "상세 주소" + }, "detailLocation": { "type": "string", "description": "행사 상세 위치", @@ -426,6 +441,15 @@ "x-field-style": "plain-text", "x-display-name": "장소 및 주소" }, + "detailAddress": { + "type": "string", + "description": "행사 상세 주소. 건물, 층, 호 등 주소를 보완해 직접 입력한다.", + "example": "B홀 2층", + "maxLength": 200, + "nullable": true, + "x-field-style": "plain-text", + "x-display-name": "상세 주소" + }, "detailLocation": { "type": "string", "description": "행사 상세 위치", diff --git a/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json b/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json index df5da76ac..9f1e11d48 100644 --- a/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json +++ b/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json @@ -3,7 +3,7 @@ "info": { "title": "위스키 시음회", "description": "시음회 날짜, 장소, 참가 정보와 시음 위스키 라인업을 함께 발행하는 이벤트형 큐레이션.", - "version": "3.0.0" + "version": "3.0.1" }, "x-curation": { "code": "WHISKY_TASTING_EVENT", @@ -36,28 +36,34 @@ }, "placeName": { "type": "string", - "description": "시음회 장소명", + "description": "시음회 장소명. 장소검색으로 선택한다.", "example": "보틀노트 테이스팅룸", "maxLength": 100, "x-field-style": "address-search", - "x-display-name": "장소명" + "x-display-name": "장소명", + "x-place-search-targets": { + "placeName": "placeName", + "kakaoPlaceId": "id", + "barAddress": "address" + } }, "kakaoPlaceId": { "type": "string", - "description": "Kakao Places 장소 ID", + "description": "Kakao Places 장소 ID. 장소검색 선택값으로 자동 저장한다.", "example": "27288225", "minLength": 1, "maxLength": 20, "pattern": "^\\d+$", - "x-field-style": "address-search", + "x-field-style": "hidden", "x-display-name": "Kakao 장소 ID" }, "barAddress": { "type": "string", - "description": "장소 및 바 주소", + "description": "장소 및 바 주소. 장소검색 선택값으로 자동 설정되며 직접 수정할 수 없다.", "example": "서울 강남구 테헤란로 123", "maxLength": 200, "x-field-style": "plain-text", + "x-read-only": true, "x-display-name": "장소 및 바(bar) 주소" }, "detailAddress": { @@ -319,7 +325,7 @@ "minLength": 1, "maxLength": 20, "pattern": "^\\d+$", - "x-field-style": "address-search", + "x-field-style": "hidden", "x-display-name": "Kakao 장소 ID" }, "barAddress": { diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPlaceFieldContractTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPlaceFieldContractTest.java index 36573fc77..6cdc29dc6 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPlaceFieldContractTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPlaceFieldContractTest.java @@ -29,20 +29,16 @@ class CurationPlaceFieldContractTest { private final CurationFeedProjector projector = new CurationFeedProjector(OBJECT_MAPPER); @Test - @DisplayName("장소검색이 필요한 필드는 address-search 렌더링 계약을 쓴다") - void requestSpec_placeSearchFields_useAddressSearchStyle() throws IOException { + @DisplayName("장소검색은 장소명 하나에서 수행하고 선택값을 자동 대상 필드에 매핑한다") + void requestSpec_placeSearch_usesPlaceNameAsTheOnlySearchInput() throws IOException { JsonNode tasting = OBJECT_MAPPER.valueToTree(schema(TASTING, "Request")).path("properties"); - assertThat(tasting.path("placeName").path("x-field-style").asText()) - .isEqualTo("address-search"); - assertThat(tasting.path("kakaoPlaceId").path("x-field-style").asText()) - .isEqualTo("address-search"); + assertPlaceSearchContract(tasting, "barAddress"); JsonNode program = OBJECT_MAPPER.valueToTree(schema(PROGRAM, "Request")).path("properties"); - assertThat(program.path("placeName").path("x-field-style").asText()) - .isEqualTo("address-search"); - assertThat(program.path("address").path("x-field-style").asText()).isEqualTo("address-search"); - assertThat(program.path("kakaoPlaceId").path("x-field-style").asText()) - .isEqualTo("address-search"); + assertPlaceSearchContract(program, "address"); + assertThat(program.path("detailAddress").path("x-field-style").asText()) + .isEqualTo("plain-text"); + assertThat(program.path("detailAddress").path("nullable").asBoolean()).isTrue(); } @Test @@ -171,6 +167,19 @@ void validateSpec_allSchemas_areValid() throws IOException { } } + private static void assertPlaceSearchContract(JsonNode properties, String addressKey) { + JsonNode placeName = properties.path("placeName"); + assertThat(placeName.path("x-field-style").asText()).isEqualTo("address-search"); + JsonNode targets = placeName.path("x-place-search-targets"); + assertThat(targets.path("placeName").asText()).isEqualTo("placeName"); + assertThat(targets.path("kakaoPlaceId").asText()).isEqualTo("id"); + assertThat(targets.path(addressKey).asText()).isEqualTo("address"); + + assertThat(properties.path("kakaoPlaceId").path("x-field-style").asText()).isEqualTo("hidden"); + assertThat(properties.path(addressKey).path("x-field-style").asText()).isEqualTo("plain-text"); + assertThat(properties.path(addressKey).path("x-read-only").asBoolean()).isTrue(); + } + private static Map legacyPayload() { Map payload = new LinkedHashMap<>(); payload.put("eventDate", "2026-06-21"); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecMetadataValidatorTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecMetadataValidatorTest.java index e2f225d23..e9405bc8e 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecMetadataValidatorTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecMetadataValidatorTest.java @@ -18,6 +18,41 @@ class CurationSpecMetadataValidatorTest { private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private final CurationPayloadValidator validator = new CurationPayloadValidator(OBJECT_MAPPER); + @Test + @DisplayName("x-place-search-target 대상 key가 같은 properties scope에 없으면 스펙 검증에 실패한다") + void validateSpec_존재하지_않는_placeSearchTarget_key_실패() throws Exception { + Map schema = + OBJECT_MAPPER.readValue( + """ + { + "type": "object", + "properties": { + "placeName": { + "type": "string", + "x-field-style": "address-search", + "x-place-search-targets": { + "placeName": "placeName", + "address": "address", + "missingPlaceId": "id" + } + }, + "address": { + "type": "string" + } + } + } + """, + MAP_TYPE); + + assertThat(validator.validateSpec("test#Request", new MapBackedSchema(schema))) + .anySatisfy( + error -> + assertThat(error) + .contains("장소검색 대상 key가 같은 scope에 없습니다") + .contains("test#Request.placeName") + .contains("missingPlaceId")); + } + @Test @DisplayName("x-depends-on 대상 key가 같은 properties scope에 없으면 스펙 검증에 실패한다") void validateSpec_존재하지_않는_dependency_key_실패() throws Exception {