From 8c3ce6d4c8158be2759cb4a97d40b7a123b0e743 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Mon, 27 Jul 2026 00:29:57 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20curation=5Fextension=20feed=5Fpaylo?= =?UTF-8?q?ad=20=EC=BB=AC=EB=9F=BC=20=EB=B0=8F=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서브모듈 V4 마이그레이션: curation_extension.feed_payload json nullable 컬럼 추가 - CurationExtension에 feedPayload 필드 매핑 --- .../app/bottlenote/curation/domain/CurationExtension.java | 5 +++++ git.environment-variables | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java index 9d04615cf..30e6ac962 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java @@ -37,6 +37,11 @@ public class CurationExtension extends BaseEntity { @Type(JsonType.class) private Object payload; + @Comment("x-feed 기준 피드 조회용 파생 payload") + @Column(name = "feed_payload", columnDefinition = "json") + @Type(JsonType.class) + private Object feedPayload; + public void update(Long specId, Object payload) { this.specId = specId; this.payload = payload; diff --git a/git.environment-variables b/git.environment-variables index 9af09dec8..b1eeffbf5 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 9af09dec8160b3e809fa72a6e2dd6c9b2c8cf584 +Subproject commit b1eeffbf5ac563959e6b62e0f77a9967fecb88e6 From 20fbb54145aa56e0628e6a9ab3343cfad44ccc5d Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Mon, 27 Jul 2026 00:42:19 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20CurationFeedProjector=EC=97=90=20fe?= =?UTF-8?q?ed=20payload=20=EC=B6=94=EC=B6=9C=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractFeedPayload: x-feed 필드 투영 + 피드 교차 x-graphql의 숨은 입력값(argFrom) 병합 - GraphQL 실행 없이 원본 payload에서만 추출 - 4개 스펙 리소스 및 숨은 입력값 시나리오 단위 테스트 5건 --- .../service/CurationFeedProjector.java | 123 ++++++++++ .../service/CurationFeedProjectorTest.java | 232 ++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedProjectorTest.java diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedProjector.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedProjector.java index ae62a7629..0a8ba70e5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedProjector.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedProjector.java @@ -7,8 +7,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -17,6 +19,7 @@ public class CurationFeedProjector { private static final String FEED_META = "x-feed"; + private static final String GRAPHQL_META = "x-graphql"; private static final String PROPERTIES = "properties"; private static final String ITEMS = "items"; private static final String JSON_PATH_ROOT = "$"; @@ -43,6 +46,126 @@ public Object projectPayload(Map responseSpec, Object payload) { return objectMapper.convertValue(projected, Object.class); } + // 저장용 feed payload: x-feed 필드 투영 결과에 피드 교차 x-graphql의 숨은 입력값(argFrom)을 병합한다. + public Object extractFeedPayload(Map responseSpec, Object payload) { + JsonNode specNode = objectMapper.valueToTree(responseSpec); + JsonNode payloadNode = objectMapper.valueToTree(payload); + JsonNode rootSchema = rootSchema(specNode); + Set feedPaths = new HashSet<>(); + collectFeedPaths(rootSchema, "", feedPaths); + List inputPaths = new ArrayList<>(); + collectGraphQLInputPaths(rootSchema, "", feedPaths, inputPaths); + if (payloadNode != null && payloadNode.isArray()) { + ArrayNode extracted = objectMapper.createArrayNode(); + payloadNode.forEach( + item -> { + JsonNode child = extractObject(rootSchema, item, inputPaths); + if (child != null) { + extracted.add(child); + } + }); + return objectMapper.convertValue(extracted.isEmpty() ? null : extracted, Object.class); + } + return objectMapper.convertValue( + extractObject(rootSchema, payloadNode, inputPaths), Object.class); + } + + private JsonNode extractObject(JsonNode schema, JsonNode payload, List inputPaths) { + JsonNode projected = projectNode(schema, payload); + if (projected == null || !projected.isObject() || payload == null || !payload.isObject()) { + return projected; + } + for (String inputPath : inputPaths) { + JsonNode value = GraphQLCurationQueryBuilder.navigate(payload, inputPath); + if (value != null) { + setAtPath((ObjectNode) projected, inputPath, value); + } + } + return projected; + } + + private void collectFeedPaths(JsonNode schema, String path, Set paths) { + if (schema == null || !schema.isObject()) { + return; + } + if (isEnabled(schema.get(FEED_META))) { + paths.add(path); + return; + } + JsonNode properties = schema.get(PROPERTIES); + if (properties != null && properties.isObject()) { + properties + .properties() + .forEach( + entry -> collectFeedPaths(entry.getValue(), append(path, entry.getKey()), paths)); + } + JsonNode items = schema.get(ITEMS); + if (items != null) { + collectFeedPaths(items, path, paths); + } + } + + private void collectGraphQLInputPaths( + JsonNode schema, String path, Set feedPaths, List inputPaths) { + if (schema == null || !schema.isObject()) { + return; + } + JsonNode meta = schema.get(GRAPHQL_META); + if (meta != null && meta.isObject() && meta.has("query")) { + String argFrom = normalizePath(meta.path("argFrom").asText(JSON_PATH_ROOT)); + boolean feedQuery = feedPaths.stream().anyMatch(feedPath -> intersects(feedPath, path)); + if (feedQuery && !argFrom.isBlank()) { + inputPaths.add(argFrom); + } + return; + } + JsonNode properties = schema.get(PROPERTIES); + if (properties != null && properties.isObject()) { + properties + .properties() + .forEach( + entry -> + collectGraphQLInputPaths( + entry.getValue(), append(path, entry.getKey()), feedPaths, inputPaths)); + } + JsonNode items = schema.get(ITEMS); + if (items != null) { + collectGraphQLInputPaths(items, path, feedPaths, inputPaths); + } + } + + private boolean intersects(String feedPath, String targetPath) { + if (feedPath.isBlank() || targetPath.isBlank()) { + return true; + } + return targetPath.equals(feedPath) + || targetPath.startsWith(feedPath + ".") + || feedPath.startsWith(targetPath + "."); + } + + private String normalizePath(String path) { + if (path == null || JSON_PATH_ROOT.equals(path)) { + return ""; + } + return path.startsWith("$.") ? path.substring(2) : path; + } + + private void setAtPath(ObjectNode target, String path, JsonNode value) { + String[] segments = path.split("\\."); + ObjectNode current = target; + for (int i = 0; i < segments.length - 1; i++) { + JsonNode next = current.get(segments[i]); + if (next instanceof ObjectNode objectNode) { + current = objectNode; + } else { + ObjectNode created = objectMapper.createObjectNode(); + current.set(segments[i], created); + current = created; + } + } + current.set(segments[segments.length - 1], value); + } + private JsonNode rootSchema(JsonNode specNode) { if ("array".equals(specNode.path("x-container").asText()) && specNode.has(ITEMS)) { return specNode.get(ITEMS); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedProjectorTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedProjectorTest.java new file mode 100644 index 000000000..d72480871 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedProjectorTest.java @@ -0,0 +1,232 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +@Tag("unit") +@DisplayName("CurationFeedProjector feed payload 추출 단위 테스트") +class CurationFeedProjectorTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private final CurationFeedProjector projector = new CurationFeedProjector(OBJECT_MAPPER); + + @Test + @DisplayName("RECOMMENDED_WHISKY 추출 시 x-feed 필드(alcohol, comment)만 남고 source와 stats는 제외된다") + void extractFeedPayload_whenRecommendedWhisky_keepsOnlyFeedFields() throws IOException { + Map responseSpec = schema("recommended_whisky.json", "Response"); + Object payload = + List.of( + map( + "source", "BOTTLE_NOTE", + "alcohol", map("alcoholId", 1, "korName", "테스트", "selectedTags", List.of("셰리")), + "comment", "추천 코멘트"), + map( + "source", + "MANUAL", + "alcohol", + map("alcoholId", null, "korName", "수동", "selectedTags", List.of("오크")), + "comment", + null)); + + JsonNode result = + OBJECT_MAPPER.valueToTree(projector.extractFeedPayload(responseSpec, payload)); + + assertThat(result).hasSize(2); + JsonNode first = result.get(0); + assertThat(first.size()).isEqualTo(2); + assertThat(first.has("source")).isFalse(); + assertThat(first.has("stats")).isFalse(); + assertThat(first.path("alcohol").path("alcoholId").asLong()).isEqualTo(1L); + assertThat(first.path("alcohol").path("korName").asText()).isEqualTo("테스트"); + assertThat(first.path("comment").asText()).isEqualTo("추천 코멘트"); + assertThat(result.get(1).path("alcohol").has("alcoholId")).isTrue(); + assertThat(result.get(1).path("comment").isNull()).isTrue(); + } + + @Test + @DisplayName("WHISKY_PAIRING 추출 시 alcohol, comment, pairings만 남고 stats는 제외된다") + void extractFeedPayload_whenWhiskyPairing_keepsPairingsAndDropsStats() throws IOException { + Map responseSpec = schema("whisky_pairing.json", "Response"); + Object payload = + List.of( + map( + "source", + "BOTTLE_NOTE", + "alcohol", + map("alcoholId", 7, "korName", "페어링 위스키", "selectedTags", List.of("셰리")), + "comment", + "페어링 코멘트", + "pairings", + List.of( + map( + "itemName", "티라미수", + "itemImageUrl", "https://img.example.com/t.png", + "pairingNote", "단맛과 어울림")))); + + JsonNode result = + OBJECT_MAPPER.valueToTree(projector.extractFeedPayload(responseSpec, payload)); + + assertThat(result).hasSize(1); + JsonNode first = result.get(0); + assertThat(first.size()).isEqualTo(3); + assertThat(first.has("source")).isFalse(); + assertThat(first.has("stats")).isFalse(); + assertThat(first.path("alcohol").path("alcoholId").asLong()).isEqualTo(7L); + assertThat(first.path("pairings").get(0).path("itemName").asText()).isEqualTo("티라미수"); + } + + @Test + @DisplayName("WHISKY_TASTING_EVENT 추출 시 x-feed가 없는 alcohols 라인업은 완전히 제외된다") + void extractFeedPayload_whenTastingEvent_excludesAlcoholsLineup() throws IOException { + Map responseSpec = schema("whisky_tasting_event.json", "Response"); + Object payload = + map( + "eventDate", "2026-06-21", + "eventTime", "19:00", + "barAddress", "서울시 강남구 테스트로 1", + "detailAddress", "2층", + "isRecruiting", true, + "entryFee", 50000, + "capacity", 12, + "applicationLink", "https://example.com/apply", + "guideText", "시음회 안내", + "alcohols", + List.of( + map( + "source", + "BOTTLE_NOTE", + "alcohol", + map("alcoholId", 1, "korName", "글렌드로낙 오리지널 12년")))); + + JsonNode result = + OBJECT_MAPPER.valueToTree(projector.extractFeedPayload(responseSpec, payload)); + + assertThat(result.has("alcohols")).isFalse(); + assertThat(result.path("eventDate").asText()).isEqualTo("2026-06-21"); + assertThat(result.path("isRecruiting").asBoolean()).isTrue(); + assertThat(result.path("capacity").asInt()).isEqualTo(12); + } + + @Test + @DisplayName("PROGRAM 추출 시 배열 안 중첩 객체는 x-feed 리프 필드(name, type, programDate, startTime)만 남는다") + void extractFeedPayload_whenProgram_projectsNestedArrayLeafFields() throws IOException { + Map responseSpec = schema("program.json", "Response"); + Object payload = + map( + "eventStartDate", "2026-07-24", + "eventEndDate", "2026-07-26", + "placeName", "코엑스", + "address", "서울 강남구 영동대로 513", + "entryFee", 30000, + "officialUrl", "https://example.com", + "programTags", List.of("위스키", "마스터클스"), + "programs", + List.of( + map( + "name", "오프닝", + "type", "TALK", + "programDate", "2026-07-24", + "startTime", "13:00", + "endTime", "14:00", + "venue", "A홀"), + map( + "name", "시음회", + "type", "TASTING", + "programDate", "2026-07-25", + "startTime", "15:00", + "whiskies", List.of(1, 2)))); + + JsonNode result = + OBJECT_MAPPER.valueToTree(projector.extractFeedPayload(responseSpec, payload)); + + assertThat(result.has("address")).isFalse(); + assertThat(result.has("officialUrl")).isFalse(); + assertThat(result.path("placeName").asText()).isEqualTo("코엑스"); + assertThat(result.path("programTags")).hasSize(2); + JsonNode programs = result.path("programs"); + assertThat(programs).hasSize(2); + assertThat(programs.get(0).size()).isEqualTo(4); + assertThat(programs.get(0).path("name").asText()).isEqualTo("오프닝"); + assertThat(programs.get(0).has("endTime")).isFalse(); + assertThat(programs.get(0).has("venue")).isFalse(); + assertThat(programs.get(1).has("whiskies")).isFalse(); + } + + @Test + @DisplayName("x-feed 경로와 교차하는 x-graphql의 argFrom 값은 x-feed가 아니어도 숨은 입력값으로 보존한다") + void extractFeedPayload_whenGraphQLInputIsHidden_preservesArgValue() { + Map responseSpec = + map( + "type", + "object", + "properties", + map( + "title", map("type", "string", "x-feed", map("enabled", true, "role", "title")), + "alcoholId", map("type", "integer"), + "internalNote", map("type", "string"), + "stats", + map( + "type", "object", + "nullable", true, + "x-feed", map("enabled", true, "role", "stats"), + "x-graphql", + map( + "query", "alcohols", + "argFrom", "$.alcoholId", + "argName", "ids", + "argType", "[ID!]!", + "writeTo", "stats", + "resultKey", "alcoholId"), + "properties", map("rating", map("type", "number", "x-graphql", true))), + "extra", + map( + "type", "object", + "x-graphql", map("query", "alcohols", "argFrom", "$.internalNote"), + "properties", map("rating", map("type", "number", "x-graphql", true))))); + Object payload = map("title", "제목", "alcoholId", 42, "internalNote", "노출금지"); + + JsonNode result = + OBJECT_MAPPER.valueToTree(projector.extractFeedPayload(responseSpec, payload)); + + assertThat(result.path("title").asText()).isEqualTo("제목"); + assertThat(result.path("alcoholId").asLong()).isEqualTo(42L); + assertThat(result.has("internalNote")).isFalse(); + assertThat(result.has("extra")).isFalse(); + assertThat(result.has("stats")).isFalse(); + } + + private static Map schema(String resourceName, String suffix) throws IOException { + JsonNode root = + OBJECT_MAPPER.readTree( + new ClassPathResource("openapi/curation/" + resourceName).getInputStream()); + JsonNode schemas = root.path("components").path("schemas"); + JsonNode schema = + schemas.properties().stream() + .filter(entry -> entry.getKey().endsWith(suffix)) + .findFirst() + .map(Map.Entry::getValue) + .orElseThrow(); + return OBJECT_MAPPER.convertValue(schema, MAP_TYPE); + } + + private static Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put((String) values[i], values[i + 1]); + } + return map; + } +} From 6dc5bea4a416392fe50db6a61e938bbcac2b8370 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Mon, 27 Jul 2026 00:51:27 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=83=9D=EC=84=B1=C2=B7=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=8B=9C=20feed=5Fpayload=20=EC=9D=B4=EC=A4=91=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminSpecBasedCurationService create/update에서 추출 메서드로 feed_payload 저장·갱신 - CurationExtension.update 시그니처에 feedPayload 추가 - mono 단위 테스트 2건, admin 통합 테스트 2건 추가 --- .../AdminSpecBasedCurationIntegrationTest.kt | 53 +++++++++++++++++++ .../curation/domain/CurationExtension.java | 3 +- .../AdminSpecBasedCurationService.java | 8 ++- .../AdminSpecBasedCurationServiceTest.java | 42 +++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt index 3a00415e7..7dd0e7981 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt @@ -1,6 +1,7 @@ package app.integration.curation import app.IntegrationTestSupport +import app.bottlenote.curation.domain.CurationExtensionRepository import app.bottlenote.curation.domain.CurationSpecRepository import app.bottlenote.curation.service.CurationSpecResourceSyncService import org.assertj.core.api.Assertions.assertThat @@ -22,6 +23,9 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var curationSpecRepository: CurationSpecRepository + @Autowired + private lateinit var curationExtensionRepository: CurationExtensionRepository + private lateinit var accessToken: String @BeforeEach @@ -110,6 +114,28 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() { .isEqualTo("CURATION_CREATED") } + @Test + @DisplayName("큐레이션 생성 시 x-feed 기준 feed_payload가 원본 payload와 함께 저장된다") + fun create_persistsFeedPayloadWithSourcePayload() { + val result = mockMvcTester + .post() + .uri("/v2/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest(validPayload()))) + .exchange() + + assertThat(result).hasStatusOk() + val curationId = dataNode(result).path("targetId").asLong() + val extension = curationExtensionRepository.findByCurationId(curationId).orElseThrow() + val feedPayload = mapper.valueToTree(extension.feedPayload) + assertThat(feedPayload).hasSize(1) + assertThat(feedPayload[0].path("alcohol").path("korName").asText()).isEqualTo("검증 위스키") + assertThat(feedPayload[0].path("comment").asText()).isEqualTo("테스트 코멘트") + assertThat(feedPayload[0].has("source")).isFalse() + assertThat(feedPayload[0].has("stats")).isFalse() + } + @Test @DisplayName("Admin v2에서 존재하지 않는 specId로 생성할 경우 404를 반환한다") fun create_whenSpecIdDoesNotExist_returnsNotFound() { @@ -250,6 +276,33 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() { @Nested @DisplayName("spec 기반 큐레이션 수정 API") inner class UpdateSpecBasedCuration { + @Test + @DisplayName("큐레이션 수정 시 feed_payload도 새 payload 기준으로 함께 갱신된다") + fun update_refreshesFeedPayload() { + val created = mockMvcTester + .post() + .uri("/v2/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest(validPayload()))) + .exchange() + val curationId = dataNode(created).path("targetId").asLong() + + val updated = mockMvcTester + .put() + .uri("/v2/curations/{curationId}", curationId) + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(updateRequest(validPayload(selectedTags = listOf("오크")) + ("comment" to "수정된 코멘트")))) + .exchange() + + assertThat(updated).hasStatusOk() + val extension = curationExtensionRepository.findByCurationId(curationId).orElseThrow() + val feedPayload = mapper.valueToTree(extension.feedPayload) + assertThat(feedPayload[0].path("comment").asText()).isEqualTo("수정된 코멘트") + assertThat(feedPayload[0].path("alcohol").path("selectedTags")).hasSize(1) + } + @Test @DisplayName("Admin v2에서 존재하지 않는 curationId로 수정할 경우 404를 반환한다") fun update_whenCurationIdDoesNotExist_returnsNotFound() { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java index 30e6ac962..12dd82c42 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java @@ -42,8 +42,9 @@ public class CurationExtension extends BaseEntity { @Type(JsonType.class) private Object feedPayload; - public void update(Long specId, Object payload) { + public void update(Long specId, Object payload, Object feedPayload) { this.specId = specId; this.payload = payload; + this.feedPayload = feedPayload; } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java index 1a3b8bd8a..0cb6cb2ea 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java @@ -126,6 +126,8 @@ public AdminResultResponse create(CurationCreateRequest request) { .curationId(saved.getId()) .specId(spec.getId()) .payload(request.payload()) + .feedPayload( + feedProjector.extractFeedPayload(spec.getResponseSpec(), request.payload())) .build()); return AdminResultResponse.of(CURATION_CREATED, saved.getId()); } @@ -148,7 +150,11 @@ public AdminResultResponse update(Long curationId, CurationUpdateRequest request request.exposureEndDate(), request.displayOrder(), request.isActive()); - getExtension(curationId).update(spec.getId(), request.payload()); + getExtension(curationId) + .update( + spec.getId(), + request.payload(), + feedProjector.extractFeedPayload(spec.getResponseSpec(), request.payload())); return AdminResultResponse.of(CURATION_UPDATED, curationId); } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java index 32ac33dfa..a8e4bfc30 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import app.bottlenote.curation.domain.CurationExtension; import app.bottlenote.curation.domain.CurationSpec; import app.bottlenote.curation.dto.request.CurationCreateRequest; import app.bottlenote.curation.dto.request.CurationSearchRequest; @@ -16,6 +17,7 @@ import app.bottlenote.curation.fixture.InMemoryCurationRepository; import app.bottlenote.curation.fixture.InMemoryCurationSpecRepository; import app.bottlenote.global.data.response.GlobalResponse; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDate; import java.util.List; @@ -76,6 +78,46 @@ void setUp() { .isEqualTo("BOTTLE_NOTE"); } + @Test + @DisplayName("큐레이션을 생성하면 x-feed 기준으로 추출한 feedPayload를 원본과 함께 저장한다") + void create_feedPayload_함께_저장() { + CurationSpec spec = createSpec(); + + var result = adminSpecBasedCurationService.create(createRequest(spec.getId())); + + CurationExtension extension = + curationExtensionRepository.findByCurationId(result.targetId()).orElseThrow(); + JsonNode feedPayload = new ObjectMapper().valueToTree(extension.getFeedPayload()); + assertThat(feedPayload.path("alcohol").path("korName").asText()).isEqualTo("테스트 위스키"); + assertThat(feedPayload.has("source")).isFalse(); + } + + @Test + @DisplayName("큐레이션을 수정하면 feedPayload도 새 payload 기준으로 함께 갱신된다") + void update_feedPayload_함께_갱신() { + CurationSpec spec = createSpec(); + Long curationId = adminSpecBasedCurationService.create(createRequest(spec.getId())).targetId(); + + adminSpecBasedCurationService.update( + curationId, + new CurationUpdateRequest( + spec.getId(), + "수정된 큐레이션", + "수정된 설명", + List.of("https://cdn.example.com/updated.jpg"), + LocalDate.now().minusDays(1), + LocalDate.now().plusDays(30), + 5, + false, + Map.of("source", "MANUAL", "alcohol", Map.of("korName", "수동 입력")))); + + CurationExtension extension = + curationExtensionRepository.findByCurationId(curationId).orElseThrow(); + JsonNode feedPayload = new ObjectMapper().valueToTree(extension.getFeedPayload()); + assertThat(feedPayload.path("alcohol").path("korName").asText()).isEqualTo("수동 입력"); + assertThat(feedPayload.has("source")).isFalse(); + } + @Test @DisplayName("큐레이션 스펙 상세를 조회할 수 있다") void getSpecDetail_스펙_상세_조회() { From ad58b6e87f5044e8b03ef78d96a0bc2e3a1377f5 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Mon, 27 Jul 2026 01:06:44 +0900 Subject: [PATCH 4/7] =?UTF-8?q?docs:=20curation-feed-payload=20plan=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=99=84=EB=A3=8C=20=EC=8A=A4=ED=83=AC?= =?UTF-8?q?=ED=94=84=20=EB=B0=8F=20complete=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan/complete/curation-feed-payload.md | 164 +++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 plan/complete/curation-feed-payload.md diff --git a/plan/complete/curation-feed-payload.md b/plan/complete/curation-feed-payload.md new file mode 100644 index 000000000..358395ca0 --- /dev/null +++ b/plan/complete/curation-feed-payload.md @@ -0,0 +1,164 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-07-27 + +** Core Achievements ** +- curation_extension.feed_payload JSON nullable 컬럼 추가 (서브모듈 V4, 환경변수 저장소 b1eeffb) +- CurationFeedProjector.extractFeedPayload: x-feed 투영 + 숨은 입력값(argFrom) 병합 추출 +- 어드민 큐레이션 생성·수정 시 원본 payload와 feed_payload 이중 쓰기 +- /verify full(L3) 전 단계 PASS (unit 34건, product 통합, admin 통합 17건 포함) + +** Key Components ** +- git.environment-variables/storage/db/migration/V4__add_feed_payload_to_curation_extension.sql +- bottlenote-mono curation/domain/CurationExtension.java (feedPayload 필드) +- bottlenote-mono curation/service/CurationFeedProjector.java (extractFeedPayload) +- bottlenote-mono curation/service/AdminSpecBasedCurationService.java (이중 쓰기) + +** Deferred Items ** +- Product 피드 읽기 경로 feed_payload 전환 (+NULL fallback): 후속 과제 +- 기존 데이터 backfill: 어드민 재저장으로 자연 반영, 일괄 작업 없음 +- 스펙 버전 관리·x-feed 변경 시 재생성 정책: 후속 과제 +- Admin 피드 프리뷰 N+1, GraphQL 배치 보강: 후속 과제 +================================================================================ +``` + +# Plan: 큐레이션 feed_payload 컬럼 추가 및 쓰기 경로 저장 + +## Overview + +이슈 bottle-note/workspace#322 (큐레이션 피드 Read Model 분리 ADR)의 쓰기 경로를 +구현한다. `curation_extension` 테이블에 피드 전용 파생 Read Model 컬럼 +`feed_payload`를 추가하고, 어드민 큐레이션 생성·수정 시 원본 `payload`에서 +피드용 값만 추출해 같은 트랜잭션으로 함께 저장한다. + +원본 `payload`는 건당 최대 128KB로, 피드 조회 시 전체를 전송·투영하는 현행 +방식의 최적화 기반이 되는 데이터다. 이번 범위는 어디까지나 쓰기 경로 +확보이며, 피드 조회가 `feed_payload`를 사용하도록 전환하는 것은 후속 +과제로 남긴다. + +### Assumptions + +- `curation_extension`에 `feed_payload` JSON nullable 컬럼을 추가한다. + 별도 테이블은 만들지 않는다. +- 마이그레이션은 서브모듈 `git.environment-variables/storage/db/migration/`의 + `V4__` 신규 파일로 작성한다. 서브모듈 저장소에 먼저 main으로 커밋·푸시한 + 뒤 본 저장소에서 서브모듈 포인터를 갱신한다. 현재 PR이 본 저장소 main에 + 배포되기 전이므로 V4 파일 선점 충돌은 없다. +- JSON nullable 컬럼은 조회 조건에 쓰이지 않으므로 인덱스를 추가하지 + 않는다. (MySQL은 JSON 직접 인덱스 불가) +- 어드민 큐레이션 생성·수정 시 원본 payload 검증 후 피드 값을 추출해 + 원본과 같은 트랜잭션으로 저장한다. +- 추출 내용은 Response Spec의 `x-feed.enabled=true` 필드와, 피드 경로와 + 교차하는 `x-graphql`의 `argFrom` 값(숨은 입력값, 예: `alcoholId`)이다. +- 추출 로직은 신규 클래스를 만들지 않고 기존 클래스에 메서드로 추가한다. + x-feed 투영을 소유하는 `CurationFeedProjector`를 우선 후보로 하며, + 자잘한 함수 단위로 과도하게 분해하지 않는다. +- 추출 시 GraphQL을 실행하지 않는다. 통계값(`stats.rating`, + `stats.reviewCount` 등 조회 시점 보강 값)은 저장하지 않고 입력값만 + 저장한다. +- Product 피드 API의 읽기 경로 전환(`feed_payload` 사용 및 NULL fallback)은 + 이번 범위에서 제외하고 후속 과제로 미룬다. +- 기존 데이터 backfill은 하지 않는다. 기존 큐레이션은 어드민 대시보드에서 + 재저장 시 자연스럽게 `feed_payload`가 채워진다. +- 원본 `payload`는 SSOT로 유지하며, 어드민 상세 조회와 상세 API는 계속 + 원본을 사용한다. +- 스펙 버전 관리와 `x-feed` 변경 시 Read Model 재생성 정책은 후속 과제로 + 남긴다. +- Admin 피드 프리뷰(`AdminSpecBasedCurationService.searchFeed`)는 기존 + 온디맨드 방식을 유지한다. +- 피드 응답 구조·필드·정렬·커서 동작을 변경하지 않는다. + +### Success Criteria + +- V4 마이그레이션으로 `curation_extension.feed_payload` (JSON, NULL) + 컬럼이 생성된다. +- 어드민 큐레이션 생성 시 `payload`와 `feed_payload`가 함께 저장된다. +- 어드민 큐레이션 수정 시 `feed_payload`도 함께 갱신된다. +- 저장된 `feed_payload`에는 x-feed 필드와 숨은 입력값만 있고 GraphQL + 통계값은 포함되지 않는다. +- 추출 로직은 4개 스펙(`RECOMMENDED_WHISKY`, `WHISKY_PAIRING`, + `WHISKY_TASTING_EVENT`, `PROGRAM`)의 대표 payload에 대해 단위 테스트로 + 검증된다. +- 기존 피드·상세 API의 응답이 변경 전과 동일하다 (회귀). + +### Impact Scope + +- **서브모듈 (git.environment-variables)**: `V4__` 마이그레이션 SQL 신규. + 본 저장소는 서브모듈 포인터 갱신. +- **mono**: `CurationExtension` 엔티티 `feedPayload` 필드 추가, + `CurationFeedProjector`에 피드 값 추출 메서드 추가 (신규 클래스 없음), + `AdminSpecBasedCurationService` 생성·수정에 추출·저장 로직 추가. +- **test-support**: `InMemoryCurationExtensionRepository`, + `CurationFixtureFactory` 갱신. +- **product-api / admin-api**: 코드 변경 없음 (읽기 경로·API 계약 불변). + +## Execution Mode + +- mode: delegated +- scope: plan, implement, test, verify, commit, push, pr +- 서브모듈(git.environment-variables) main 직접 커밋·푸시도 위임에 포함한다. + V4 SQL을 서브모듈 main에 먼저 푸시한 뒤 본 저장소에서 포인터를 갱신한다. +- stop-conditions: 기본 3종 (① 가정 붕괴 시 재개봉 프로토콜, ② /verify 3회 + 실패 시 /debug 보고 후 정지, ③ scope 밖 되돌리기 어려운 행동 전 확인) + +## Tasks + +### Task 1: V4 마이그레이션 및 CurationExtension feedPayload 필드 추가 +- Acceptance: 서브모듈에 `V4__` 마이그레이션으로 `curation_extension.feed_payload` + (JSON, NULL) 컬럼이 추가되고, 서브모듈 main에 푸시된다. 본 저장소는 서브모듈 + 포인터가 갱신되고, `CurationExtension`에 `feedPayload` 필드가 매핑되어 + Flyway validate를 통과한다. +- Verification: `./gradlew :bottlenote-mono:compileJava` 및 TestContainers + 기반 테스트 1건 이상이 스키마 검증과 함께 통과 +- Files (advisory): `git.environment-variables/storage/db/migration/V4__*.sql`, + 서브모듈 포인터, `CurationExtension.java` +- Depends: 없음 +- Size: S +- Status: [x] done + +### Task 2: CurationFeedProjector에 feed payload 추출 메서드 추가 +- Acceptance: 원본 payload와 response spec으로부터 x-feed.enabled=true 필드와 + 피드 경로 교차 x-graphql의 argFrom 값(숨은 입력값)만 추출하는 메서드가 + `CurationFeedProjector`에 추가된다. GraphQL은 실행하지 않는다. 신규 클래스 + 없이 메서드로 추가하며, 4개 스펙 리소스 기준 단위 테스트가 통과한다. +- Verification: `./gradlew :bottlenote-mono:test --tests '*CurationFeedProjector*'` +- Files (advisory): `CurationFeedProjector.java`, 신규 단위 테스트 +- Depends: 없음 (Task 1과 병렬 가능) +- Size: S +- Status: [x] done + +### Checkpoint: after Tasks 1-2 +- [ ] 컴파일 통과 / 단위 테스트 통과 / ArchUnit 룰 통과 + +### Task 3: 큐레이션 생성·수정 시 feed_payload 이중 쓰기 +- Acceptance: `AdminSpecBasedCurationService`의 create/update가 원본 payload + 검증 후 추출 메서드로 만든 feed_payload를 같은 트랜잭션으로 저장·갱신한다. + test-support의 InMemory 레포지토리·픽스처가 갱신되고, 생성·수정 시 + feed_payload 저장을 검증하는 테스트가 통과한다. +- Verification: `./gradlew :bottlenote-mono:test --tests '*AdminSpecBasedCuration*'` + 및 관련 통합 테스트 +- Files (advisory): `AdminSpecBasedCurationService.java`, + `InMemoryCurationExtensionRepository.java`, `CurationFixtureFactory.java`, + 관련 테스트 +- Depends: 1, 2 +- Size: M +- Status: [x] done + +## Progress Log + +- Task 1 완료 (8c3ce6d4): 서브모듈 main에 V4 마이그레이션 푸시(환경변수 저장소 + b1eeffb), 본 저장소 포인터 갱신 + `CurationExtension.feedPayload` 매핑. + ProductSpecBasedCurationIntegrationTest 14건 통과로 Flyway V4 적용 및 + Hibernate validate 확인. +- Task 2 완료 (20fbb541): `CurationFeedProjector.extractFeedPayload` 추가. + x-feed 투영 + 피드 경로 교차 x-graphql의 argFrom 숨은 입력값 병합, + GraphQL 미실행. 피드 경로 수집은 items 재귀 포함(materializer와 동일 의미). + 단위 테스트 5건 통과(4개 스펙 리소스 + 숨은 입력값 시나리오). +- Checkpoint(1-2) 통과: unit_test, check_rule_test 전체 그린. +- Task 3 완료 (6dc5bea4): create/update 이중 쓰기. 레포지토리 인터페이스 + 변경이 없어 InMemory 레포지토리·픽스처 갱신은 불필요로 판명(acceptance + advisory 조정). mono 단위 34건, admin 통합 17건 통과(실제 DB JSON + 라운드트립 포함). From faecccb6b065699cbc922b990e3f59bcff78fdb3 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 10:34:39 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20feed=5Fpayload=20=EC=B6=94=EC=B6=9C?= =?UTF-8?q?=EC=9D=98=20payloadPath=20=EA=B2=BD=EB=A1=9C=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit argFrom은 payloadPath로 내려간 노드(배열이면 그 원소) 기준 상대 경로인데 루트 기준으로 탐색해 숨은 입력값이 조용히 누락됐다. 남길 스키마 경로를 미리 모아 투영 재귀에 넘기는 방식으로 바꿔 배열 원소에서도 경로가 어긋나지 않게 한다. 남길 값이 없을 때는 null 대신 빈 컨테이너를 저장해 backfill 이전 레거시 행과 구분한다. 피드 경로 해석 규칙은 CurationFeedPaths로 추출해 저장 시 추출과 조회 시 보강이 같은 기준을 쓰도록 한다. Co-Authored-By: Claude Opus 5 --- .../curation/service/CurationFeedPaths.java | 96 ++++++++++ .../service/CurationFeedProjector.java | 176 ++++++------------ .../service/CurationResponseMaterializer.java | 73 +------- .../service/CurationFeedProjectorTest.java | 93 +++++++++ 4 files changed, 254 insertions(+), 184 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPaths.java diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPaths.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPaths.java new file mode 100644 index 000000000..5d97f60ba --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPaths.java @@ -0,0 +1,96 @@ +package app.bottlenote.curation.service; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.HashSet; +import java.util.Set; + +// x-feed 경로 해석 규칙. 저장 시 추출과 조회 시 보강이 같은 기준을 쓰도록 한 곳에 모은다. +final class CurationFeedPaths { + + private static final String FEED_META = "x-feed"; + private static final String PROPERTIES = "properties"; + private static final String ITEMS = "items"; + private static final String CONTAINER = "x-container"; + private static final String ARRAY = "array"; + private static final String JSON_PATH_ROOT = "$"; + private static final String JSON_PATH_PREFIX = "$."; + + private CurationFeedPaths() {} + + // x-container=array 스펙은 items가 단건 스키마다. + static JsonNode rootSchema(JsonNode specNode) { + if (isArrayContainer(specNode) && specNode.has(ITEMS)) { + return specNode.get(ITEMS); + } + return specNode; + } + + static boolean isArrayContainer(JsonNode specNode) { + return specNode != null && ARRAY.equals(specNode.path(CONTAINER).asText()); + } + + static boolean isEnabled(JsonNode feedMeta) { + return feedMeta != null && feedMeta.isObject() && feedMeta.path("enabled").asBoolean(false); + } + + // 배열 items는 경로를 늘리지 않는다. 피드 경로는 인덱스가 아니라 스키마 기준이다. + static Set collect(JsonNode rootSchema) { + Set paths = new HashSet<>(); + collect(rootSchema, "", paths); + return paths; + } + + private static void collect(JsonNode schema, String path, Set paths) { + if (schema == null || !schema.isObject()) { + return; + } + if (isEnabled(schema.get(FEED_META))) { + paths.add(path); + return; + } + JsonNode properties = schema.get(PROPERTIES); + if (properties != null && properties.isObject()) { + properties + .properties() + .forEach(entry -> collect(entry.getValue(), join(path, entry.getKey()), paths)); + } + JsonNode items = schema.get(ITEMS); + if (items != null) { + collect(items, path, paths); + } + } + + // 조상·자손 관계면 교차로 본다. 해당 경로가 피드 필드를 채우는 데 관여하는지 판단하는 기준이다. + static boolean intersectsFeed(Set feedPaths, String targetPath) { + String target = normalize(targetPath); + return feedPaths.stream() + .map(CurationFeedPaths::normalize) + .anyMatch(feedPath -> intersects(feedPath, target)); + } + + private static boolean intersects(String feedPath, String targetPath) { + if (feedPath.isBlank() || targetPath.isBlank()) { + return true; + } + return targetPath.equals(feedPath) + || targetPath.startsWith(feedPath + ".") + || feedPath.startsWith(targetPath + "."); + } + + static String normalize(String path) { + if (path == null || JSON_PATH_ROOT.equals(path)) { + return ""; + } + return path.startsWith(JSON_PATH_PREFIX) ? path.substring(2) : path; + } + + static String join(String prefix, String suffix) { + if (prefix == null || prefix.isBlank()) { + return suffix == null ? "" : suffix; + } + if (suffix == null || suffix.isBlank()) { + return prefix; + } + return prefix + "." + suffix; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedProjector.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedProjector.java index 0a8ba70e5..8a7be7a7a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedProjector.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedProjector.java @@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.ArrayList; import java.util.Comparator; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -29,9 +29,8 @@ public class CurationFeedProjector { public List project(Map responseSpec, Object payload) { JsonNode specNode = objectMapper.valueToTree(responseSpec); JsonNode payloadNode = objectMapper.valueToTree(payload); - JsonNode rootSchema = rootSchema(specNode); List fields = new ArrayList<>(); - collect(rootSchema, payloadNode, "", fields); + collect(CurationFeedPaths.rootSchema(specNode), payloadNode, "", fields); return fields.stream() .sorted( Comparator.comparing( @@ -39,83 +38,50 @@ public List project(Map responseSpec, .toList(); } + // 조회 응답용: 보강까지 끝난 payload에서 x-feed 필드만 남긴다. public Object projectPayload(Map responseSpec, Object payload) { JsonNode specNode = objectMapper.valueToTree(responseSpec); JsonNode payloadNode = objectMapper.valueToTree(payload); - JsonNode projected = projectNode(rootSchema(specNode), payloadNode); + JsonNode projected = + projectNode(CurationFeedPaths.rootSchema(specNode), payloadNode, "", Set.of()); return objectMapper.convertValue(projected, Object.class); } - // 저장용 feed payload: x-feed 필드 투영 결과에 피드 교차 x-graphql의 숨은 입력값(argFrom)을 병합한다. + // 저장용 feed payload: x-feed 필드에 더해 피드에 필요한 x-graphql의 입력값까지 남긴다. GraphQL은 실행하지 않는다. public Object extractFeedPayload(Map responseSpec, Object payload) { JsonNode specNode = objectMapper.valueToTree(responseSpec); JsonNode payloadNode = objectMapper.valueToTree(payload); - JsonNode rootSchema = rootSchema(specNode); - Set feedPaths = new HashSet<>(); - collectFeedPaths(rootSchema, "", feedPaths); - List inputPaths = new ArrayList<>(); - collectGraphQLInputPaths(rootSchema, "", feedPaths, inputPaths); - if (payloadNode != null && payloadNode.isArray()) { - ArrayNode extracted = objectMapper.createArrayNode(); - payloadNode.forEach( - item -> { - JsonNode child = extractObject(rootSchema, item, inputPaths); - if (child != null) { - extracted.add(child); - } - }); - return objectMapper.convertValue(extracted.isEmpty() ? null : extracted, Object.class); - } + JsonNode rootSchema = CurationFeedPaths.rootSchema(specNode); + JsonNode extracted = projectNode(rootSchema, payloadNode, "", feedInputPaths(rootSchema)); + // 남길 것이 없어도 null이 아니라 빈 컨테이너를 저장한다. null은 backfill 이전 레거시 행의 표시로만 쓴다. return objectMapper.convertValue( - extractObject(rootSchema, payloadNode, inputPaths), Object.class); + extracted != null ? extracted : emptyContainer(specNode, payloadNode), Object.class); } - private JsonNode extractObject(JsonNode schema, JsonNode payload, List inputPaths) { - JsonNode projected = projectNode(schema, payload); - if (projected == null || !projected.isObject() || payload == null || !payload.isObject()) { - return projected; - } - for (String inputPath : inputPaths) { - JsonNode value = GraphQLCurationQueryBuilder.navigate(payload, inputPath); - if (value != null) { - setAtPath((ObjectNode) projected, inputPath, value); - } - } - return projected; + private JsonNode emptyContainer(JsonNode specNode, JsonNode payloadNode) { + return CurationFeedPaths.isArrayContainer(specNode) + || (payloadNode != null && payloadNode.isArray()) + ? objectMapper.createArrayNode() + : objectMapper.createObjectNode(); } - private void collectFeedPaths(JsonNode schema, String path, Set paths) { - if (schema == null || !schema.isObject()) { - return; - } - if (isEnabled(schema.get(FEED_META))) { - paths.add(path); - return; - } - JsonNode properties = schema.get(PROPERTIES); - if (properties != null && properties.isObject()) { - properties - .properties() - .forEach( - entry -> collectFeedPaths(entry.getValue(), append(path, entry.getKey()), paths)); - } - JsonNode items = schema.get(ITEMS); - if (items != null) { - collectFeedPaths(items, path, paths); - } + // 피드 경로와 교차하는 x-graphql 엔트리의 입력값 경로. 조회 시 보강에 필요하지만 x-feed는 아닌 숨은 값이다. + private Set feedInputPaths(JsonNode rootSchema) { + Set feedPaths = CurationFeedPaths.collect(rootSchema); + Set inputPaths = new LinkedHashSet<>(); + collectFeedInputPaths(rootSchema, "", feedPaths, inputPaths); + return inputPaths; } - private void collectGraphQLInputPaths( - JsonNode schema, String path, Set feedPaths, List inputPaths) { + private void collectFeedInputPaths( + JsonNode schema, String path, Set feedPaths, Set inputPaths) { if (schema == null || !schema.isObject()) { return; } JsonNode meta = schema.get(GRAPHQL_META); if (meta != null && meta.isObject() && meta.has("query")) { - String argFrom = normalizePath(meta.path("argFrom").asText(JSON_PATH_ROOT)); - boolean feedQuery = feedPaths.stream().anyMatch(feedPath -> intersects(feedPath, path)); - if (feedQuery && !argFrom.isBlank()) { - inputPaths.add(argFrom); + if (CurationFeedPaths.intersectsFeed(feedPaths, path)) { + addFeedInputPath(meta, inputPaths); } return; } @@ -125,52 +91,27 @@ private void collectGraphQLInputPaths( .properties() .forEach( entry -> - collectGraphQLInputPaths( - entry.getValue(), append(path, entry.getKey()), feedPaths, inputPaths)); + collectFeedInputPaths( + entry.getValue(), + CurationFeedPaths.join(path, entry.getKey()), + feedPaths, + inputPaths)); } JsonNode items = schema.get(ITEMS); if (items != null) { - collectGraphQLInputPaths(items, path, feedPaths, inputPaths); - } - } - - private boolean intersects(String feedPath, String targetPath) { - if (feedPath.isBlank() || targetPath.isBlank()) { - return true; - } - return targetPath.equals(feedPath) - || targetPath.startsWith(feedPath + ".") - || feedPath.startsWith(targetPath + "."); - } - - private String normalizePath(String path) { - if (path == null || JSON_PATH_ROOT.equals(path)) { - return ""; - } - return path.startsWith("$.") ? path.substring(2) : path; - } - - private void setAtPath(ObjectNode target, String path, JsonNode value) { - String[] segments = path.split("\\."); - ObjectNode current = target; - for (int i = 0; i < segments.length - 1; i++) { - JsonNode next = current.get(segments[i]); - if (next instanceof ObjectNode objectNode) { - current = objectNode; - } else { - ObjectNode created = objectMapper.createObjectNode(); - current.set(segments[i], created); - current = created; - } + collectFeedInputPaths(items, path, feedPaths, inputPaths); } - current.set(segments[segments.length - 1], value); } - private JsonNode rootSchema(JsonNode specNode) { - if ("array".equals(specNode.path("x-container").asText()) && specNode.has(ITEMS)) { - return specNode.get(ITEMS); + // argFrom은 payloadPath로 내려간 노드(배열이면 그 원소) 기준 상대 경로이므로 둘을 이어 스키마 경로로 만든다. + private void addFeedInputPath(JsonNode meta, Set inputPaths) { + String inputPath = + CurationFeedPaths.join( + CurationFeedPaths.normalize(meta.path("payloadPath").asText(JSON_PATH_ROOT)), + CurationFeedPaths.normalize(meta.path("argFrom").asText(JSON_PATH_ROOT))); + if (!inputPath.isBlank()) { + inputPaths.add(inputPath); } - return specNode; } private void collect( @@ -179,7 +120,7 @@ private void collect( return; } JsonNode meta = schema.get(FEED_META); - if (isEnabled(meta)) { + if (CurationFeedPaths.isEnabled(meta)) { fields.add(toField(path, meta, valueAt(payload, path))); return; } @@ -190,18 +131,26 @@ private void collect( } properties .properties() - .forEach(entry -> collect(entry.getValue(), payload, append(path, entry.getKey()), fields)); + .forEach( + entry -> + collect( + entry.getValue(), + payload, + CurationFeedPaths.join(path, entry.getKey()), + fields)); } - private JsonNode projectNode(JsonNode schema, JsonNode payload) { + // keepPaths는 x-feed가 아니어도 남겨야 하는 스키마 경로다. 배열은 items 스키마로 재귀하므로 경로가 원소마다 어긋나지 않는다. + private JsonNode projectNode( + JsonNode schema, JsonNode payload, String path, Set keepPaths) { if (schema == null || !schema.isObject() || payload == null || payload.isMissingNode()) { return null; } - if (isEnabled(schema.get(FEED_META))) { + if (CurationFeedPaths.isEnabled(schema.get(FEED_META)) || keepPaths.contains(path)) { return payload; } if (payload.isArray()) { - return projectArray(schema, payload); + return projectArray(schema, payload, path, keepPaths); } if (!payload.isObject()) { return null; @@ -216,8 +165,12 @@ private JsonNode projectNode(JsonNode schema, JsonNode payload) { .properties() .forEach( entry -> { - JsonNode childPayload = payload.get(entry.getKey()); - JsonNode child = projectNode(entry.getValue(), childPayload); + JsonNode child = + projectNode( + entry.getValue(), + payload.get(entry.getKey()), + CurationFeedPaths.join(path, entry.getKey()), + keepPaths); if (child != null) { projected.set(entry.getKey(), child); } @@ -225,12 +178,13 @@ private JsonNode projectNode(JsonNode schema, JsonNode payload) { return projected.isEmpty() ? null : projected; } - private JsonNode projectArray(JsonNode schema, JsonNode payload) { + private JsonNode projectArray( + JsonNode schema, JsonNode payload, String path, Set keepPaths) { JsonNode itemSchema = schema.has(ITEMS) ? schema.get(ITEMS) : schema; ArrayNode projected = objectMapper.createArrayNode(); payload.forEach( item -> { - JsonNode child = projectNode(itemSchema, item); + JsonNode child = projectNode(itemSchema, item, path, keepPaths); if (child != null) { projected.add(child); } @@ -238,10 +192,6 @@ private JsonNode projectArray(JsonNode schema, JsonNode payload) { return projected.isEmpty() ? null : projected; } - private boolean isEnabled(JsonNode meta) { - return meta != null && meta.isObject() && meta.path("enabled").asBoolean(false); - } - private CurationFeedFieldResponse toField(String path, JsonNode meta, JsonNode value) { return new CurationFeedFieldResponse( path, @@ -270,8 +220,4 @@ private JsonNode valuesFromArray(JsonNode payload, String path) { }); return array; } - - private String append(String path, String key) { - return path == null || path.isBlank() ? key : path + "." + key; - } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationResponseMaterializer.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationResponseMaterializer.java index 1fe0e57ba..30f422c49 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationResponseMaterializer.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationResponseMaterializer.java @@ -9,10 +9,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -83,65 +81,9 @@ private Object materialize( } private boolean isFeedQuery(GraphQLCurationQueryBuilder.Result query, JsonNode responseSpecNode) { - String targetPath = normalizePath(query.targetPath()); - return collectFeedPaths(responseSpecNode).stream() - .map(this::normalizePath) - .anyMatch(feedPath -> intersects(feedPath, targetPath)); - } - - private Set collectFeedPaths(JsonNode responseSpecNode) { - Set paths = new HashSet<>(); - collectFeedPaths(rootSchema(responseSpecNode), "", paths); - return paths; - } - - private JsonNode rootSchema(JsonNode specNode) { - if ("array".equals(specNode.path("x-container").asText()) && specNode.has("items")) { - return specNode.get("items"); - } - return specNode; - } - - private void collectFeedPaths(JsonNode schema, String path, Set paths) { - if (schema == null || !schema.isObject()) { - return; - } - JsonNode feedMeta = schema.get("x-feed"); - if (feedMeta != null && feedMeta.isObject() && feedMeta.path("enabled").asBoolean(false)) { - paths.add(path); - return; - } - JsonNode properties = schema.get("properties"); - if (properties != null && properties.isObject()) { - properties - .properties() - .forEach( - entry -> collectFeedPaths(entry.getValue(), append(path, entry.getKey()), paths)); - } - JsonNode items = schema.get("items"); - if (items != null) { - collectFeedPaths(items, path, paths); - } - } - - private boolean intersects(String feedPath, String targetPath) { - if (feedPath.isBlank() || targetPath.isBlank()) { - return true; - } - return targetPath.equals(feedPath) - || targetPath.startsWith(feedPath + ".") - || feedPath.startsWith(targetPath + "."); - } - - private String append(String path, String key) { - return path == null || path.isBlank() ? key : path + "." + key; - } - - private String normalizePath(String path) { - if (path == null || JSON_PATH_ROOT.equals(path)) { - return ""; - } - return path.startsWith("$.") ? path.substring(2) : path; + return CurationFeedPaths.intersectsFeed( + CurationFeedPaths.collect(CurationFeedPaths.rootSchema(responseSpecNode)), + query.targetPath()); } private boolean hasNoArgumentValues(GraphQLCurationQueryBuilder.Result query) { @@ -313,7 +255,7 @@ private Map withoutResultKey(Map hit, String res } private void setAtPath(JsonNode root, String path, JsonNode value) { - String trimmed = stripPathPrefix(path); + String trimmed = CurationFeedPaths.normalize(path); if (trimmed.isEmpty()) { return; } @@ -330,13 +272,6 @@ private void setAtPath(JsonNode root, String path, JsonNode value) { } } - private String stripPathPrefix(String path) { - if (path.startsWith("$.")) { - return path.substring(2); - } - return JSON_PATH_ROOT.equals(path) ? "" : path; - } - private Object normalizeKey(Object value) { if (value instanceof Number number) { return number.longValue(); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedProjectorTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedProjectorTest.java index d72480871..2b5b54e02 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedProjectorTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedProjectorTest.java @@ -208,6 +208,99 @@ void extractFeedPayload_whenGraphQLInputIsHidden_preservesArgValue() { assertThat(result.has("stats")).isFalse(); } + @Test + @DisplayName("payloadPath가 지정된 x-graphql의 argFrom은 그 하위 배열 원소 기준으로 숨은 입력값을 보존한다") + void extractFeedPayload_whenGraphQLInputIsUnderPayloadPath_preservesArgValuePerElement() { + Map responseSpec = + map( + "type", + "object", + "properties", + map( + "eventDate", map("type", "string", "x-feed", map("enabled", true, "role", "date")), + "alcohols", + map( + "type", + "array", + "items", + map( + "type", + "object", + "properties", + map( + "source", map("type", "string"), + "alcohol", + map( + "type", + "object", + "properties", + map( + "alcoholId", map("type", "integer"), + "korName", map("type", "string"))), + "stats", + map( + "type", "object", + "nullable", true, + "x-feed", map("enabled", true, "role", "stats"), + "x-graphql", + map( + "query", "alcohols", + "argFrom", "$.alcohol.alcoholId", + "argName", "ids", + "argType", "[ID!]!", + "writeTo", "stats", + "resultKey", "alcoholId", + "payloadPath", "$.alcohols"), + "properties", + map( + "rating", + map("type", "number", "x-graphql", true)))))))); + Object payload = + map( + "eventDate", + "2026-06-21", + "alcohols", + List.of( + map("source", "BOTTLE_NOTE", "alcohol", map("alcoholId", 1, "korName", "글렌드로낙")), + map("source", "MANUAL", "alcohol", map("alcoholId", 2, "korName", "아드벡")))); + + JsonNode result = + OBJECT_MAPPER.valueToTree(projector.extractFeedPayload(responseSpec, payload)); + + assertThat(result.path("eventDate").asText()).isEqualTo("2026-06-21"); + JsonNode alcohols = result.path("alcohols"); + assertThat(alcohols).hasSize(2); + assertThat(alcohols.get(0).path("alcohol").path("alcoholId").asLong()).isEqualTo(1L); + assertThat(alcohols.get(1).path("alcohol").path("alcoholId").asLong()).isEqualTo(2L); + assertThat(alcohols.get(0).path("alcohol").has("korName")).isFalse(); + assertThat(alcohols.get(0).has("source")).isFalse(); + assertThat(alcohols.get(0).has("stats")).isFalse(); + } + + @Test + @DisplayName("남길 x-feed 필드가 없으면 null이 아니라 스펙 컨테이너에 맞는 빈 값을 반환한다") + void extractFeedPayload_whenNothingToKeep_returnsEmptyContainer() { + Map arraySpec = + map( + "x-container", + "array", + "items", + map("type", "object", "properties", map("memo", map("type", "string")))); + Map objectSpec = + map("type", "object", "properties", map("memo", map("type", "string"))); + + JsonNode arrayResult = + OBJECT_MAPPER.valueToTree( + projector.extractFeedPayload(arraySpec, List.of(map("memo", "내부 메모")))); + JsonNode objectResult = + OBJECT_MAPPER.valueToTree(projector.extractFeedPayload(objectSpec, map("memo", "내부 메모"))); + + assertThat(arrayResult.isArray()).isTrue(); + assertThat(arrayResult).isEmpty(); + assertThat(objectResult.isObject()).isTrue(); + assertThat(objectResult).isEmpty(); + } + private static Map schema(String resourceName, String suffix) throws IOException { JsonNode root = OBJECT_MAPPER.readTree( From cf58b518964019cb26128d0a4828629195e66b4a Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 10:34:46 +0900 Subject: [PATCH 6/7] =?UTF-8?q?test:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=ED=94=BD=EC=8A=A4=EC=B2=98=EA=B0=80=20feed=5Fpaylo?= =?UTF-8?q?ad=EB=A5=BC=20=EC=B1=84=EC=9A=B0=EB=8F=84=EB=A1=9D=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 픽스처가 feed_payload를 비워두면 후속 읽기 경로 테스트가 NULL fallback 분기만 타게 된다. 운영과 같은 추출 결과를 남긴다. Co-Authored-By: Claude Opus 5 --- .../fixture/CurationFixtureFactory.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java b/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java index 647258a5a..5b7b7b81c 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java @@ -7,6 +7,8 @@ import app.bottlenote.curation.domain.CurationSpec; import app.bottlenote.curation.domain.CurationSpecRepository; import app.bottlenote.curation.dto.request.CurationCreateRequest; +import app.bottlenote.curation.service.CurationFeedProjector; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.LinkedHashMap; import java.util.Map; @@ -15,14 +17,28 @@ public class CurationFixtureFactory { private final CurationSpecRepository curationSpecRepository; private final CurationRepository curationRepository; private final CurationExtensionRepository curationExtensionRepository; + private final CurationFeedProjector curationFeedProjector; public CurationFixtureFactory( CurationSpecRepository curationSpecRepository, CurationRepository curationRepository, CurationExtensionRepository curationExtensionRepository) { + this( + curationSpecRepository, + curationRepository, + curationExtensionRepository, + new CurationFeedProjector(new ObjectMapper())); + } + + public CurationFixtureFactory( + CurationSpecRepository curationSpecRepository, + CurationRepository curationRepository, + CurationExtensionRepository curationExtensionRepository, + CurationFeedProjector curationFeedProjector) { this.curationSpecRepository = curationSpecRepository; this.curationRepository = curationRepository; this.curationExtensionRepository = curationExtensionRepository; + this.curationFeedProjector = curationFeedProjector; } public CurationSpec saveSpec( @@ -66,10 +82,19 @@ public Curation saveCuration(CurationCreateRequest request) { .curationId(saved.getId()) .specId(request.specId()) .payload(request.payload()) + .feedPayload(feedPayloadOf(request)) .build()); return saved; } + // 픽스처도 운영과 같은 추출 결과를 남긴다. 그러지 않으면 읽기 경로 테스트가 fallback 분기만 타게 된다. + private Object feedPayloadOf(CurationCreateRequest request) { + return curationSpecRepository + .findById(request.specId()) + .map(spec -> curationFeedProjector.extractFeedPayload(spec.getResponseSpec(), request.payload())) + .orElse(null); + } + private static Map copyOf(Map value) { return value != null ? new LinkedHashMap<>(value) : new LinkedHashMap<>(); } From 47defff2dac3f1779e8adfe74c2665f68a54d2a7 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 10:38:08 +0900 Subject: [PATCH 7/7] =?UTF-8?q?chore:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=ED=8F=AC=EC=9D=B8=ED=84=B0=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20=EB=82=B4=EC=97=AD=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서브모듈 신규 커밋은 개발 환경 이미지 태그 범프 2건으로 storage/ 변경은 없다. Task 3의 "픽스처 갱신 불필요" 판단을 정정한 내용도 함께 남긴다. Co-Authored-By: Claude Opus 5 --- git.environment-variables | 2 +- plan/complete/curation-feed-payload.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/git.environment-variables b/git.environment-variables index b1eeffbf5..6611431cb 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit b1eeffbf5ac563959e6b62e0f77a9967fecb88e6 +Subproject commit 6611431cbdcd2f0a5a0d730cb1ec45b39c656dcb diff --git a/plan/complete/curation-feed-payload.md b/plan/complete/curation-feed-payload.md index 358395ca0..b48c2d325 100644 --- a/plan/complete/curation-feed-payload.md +++ b/plan/complete/curation-feed-payload.md @@ -162,3 +162,20 @@ Completion Date: 2026-07-27 변경이 없어 InMemory 레포지토리·픽스처 갱신은 불필요로 판명(acceptance advisory 조정). mono 단위 34건, admin 통합 17건 통과(실제 DB JSON 라운드트립 포함). +- PR #677 리뷰 반영 (925ddfeb): `argFrom`을 루트 기준으로 탐색해 + `payloadPath` 하위(`whisky_tasting_event`)의 숨은 입력값이 누락되던 결함 + 수정. 남길 스키마 경로를 미리 모아 투영 재귀에 넘기는 방식으로 재작성해 + 배열 원소에서도 경로가 어긋나지 않게 했고, `setAtPath`·`extractObject`가 + 제거됐다. 남길 값이 없으면 null 대신 빈 컨테이너를 저장해 backfill 이전 + 레거시 행과 구분한다. 피드 경로 해석 규칙은 `CurationFeedPaths`로 추출해 + materializer와 공유한다. 신규 단위 테스트 2건은 수정 전 코드에서 실패를 + 확인했다. +- PR #677 리뷰 반영 (1ef1a777): Task 3의 "픽스처 갱신 불필요" 판단을 + 뒤집는다. `CurationFixtureFactory`가 feed_payload를 비워두면 후속 읽기 + 경로 테스트가 NULL fallback 분기만 타게 되므로 운영과 같은 추출 결과를 + 남기도록 했다. +- 리뷰 반영 후 검증: unit_test 459건(mono 233 + product 205 + + observability 21), integration_test 254건, admin_integration_test 206건 + 전부 통과. check_rule_test·spotlessCheck 그린. +- 서브모듈 포인터 6611431로 최신화. 개발 환경 이미지 태그 범프 2건이며 + `storage/` 변경은 없다(V4 이후 신규 마이그레이션 없음).