From 23d82bd250eabe28dee72b31d41c2b229ff2afbc Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 22 Aug 2026 14:41:19 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/DocumentResponseMapper.java | 1 + .../mapper/PublicResponseMapper.java | 16 ++++ .../StudioPublicationController.java | 9 ++- .../JdbcPublicDocumentQueryAdapter.java | 74 ++++++++++++++++++- .../publicsite/model/BodyAssetView.java | 24 ++++++ .../model/PublishedDocumentView.java | 3 + src/config/openapi/public-v1.yaml | 50 ++++++++++++- 7 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/BodyAssetView.java diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java index c592c9f..9c9988b 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java @@ -46,6 +46,7 @@ public final class DocumentResponseMapper { body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag)); body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject())); body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset())); + body.setBodyAssets(PublicResponseMapper.map(doc.bodyAssets(), PublicResponseMapper::bodyAsset)); body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt())); body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt())); body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt())); diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java index 72251b9..dcfae2c 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java @@ -1,6 +1,7 @@ package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.AssetReference; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.BodyAsset; import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ContactLink; import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.LatestEntry; import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.PageMetadata; @@ -9,6 +10,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RelatedEnt import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TagSummary; import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary; import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView; +import dev.caskeleton.application.techlog.publicsite.model.BodyAssetView; import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView; import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView; import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; @@ -86,6 +88,20 @@ public final class PublicResponseMapper { return dto; } + /** 본문이 {@code :::evidence key="..."} 로 가리키는 Asset. key 에서 주소를 만들 수 없어 함께 내려보낸다. */ + public static BodyAsset bodyAsset(BodyAssetView view) { + BodyAsset dto = new BodyAsset(); + dto.setAssetKey(view.assetKey()); + dto.setAssetId(view.assetId()); + dto.setUrl(view.url()); + dto.setContentType(view.contentType()); + dto.setAltText(view.altText()); + dto.setWidth(view.width()); + dto.setHeight(view.height()); + dto.setDecorative(view.decorative()); + return dto; + } + public static ContactLink contact(ContactLinkView view) { ContactLink dto = new ContactLink(); dto.setType(view.type()); diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java index 8984d54..3b4b39e 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java @@ -109,7 +109,14 @@ public class StudioPublicationController { idempotencyKey, StudioPrincipals.require(principal))))); - return ResponseEntity.status(HttpStatus.CREATED) + // 계약이 200 으로 선언한 응답이다 (studio-v1.yaml `publishStudioDocument` responses). + // 여기서 201 을 돌려주는 동안 클라이언트는 게시가 끝난 요청을 계약 위반으로 거절했다 — + // 서버는 Publication 을 만들고 공개 경로까지 내줬는데 화면에는 실패로 보였다. + // + // 계약 드리프트 테스트가 이것을 잡지 못한 이유는 `ResponseEntity.status(...)` 가 런타임 + // 값이라 springdoc 이 읽는 published 문서에는 나타나지 않기 때문이다. 상태를 계약과 함께 + // 두려면 애노테이션이나 반환 타입으로 드러나야 한다. + return ResponseEntity.ok() .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) .body(outcome.result()); } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java index 09b5e03..c1644bf 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java @@ -1,6 +1,7 @@ package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView; +import dev.caskeleton.application.techlog.publicsite.model.BodyAssetView; import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView; import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; @@ -18,6 +19,7 @@ import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQuer import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -84,9 +86,16 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { .sql( "SELECT d.id, d.title, d.body_markdown, d.content_format," + " d.content_format_version, d.cover_asset_id," - + " c.problem_summary, c.conclusion_summary, c.environment_items," + // `environment_items` 는 V7 이 만든 jsonb 배열이고, Studio 가 쓰는 것은 V8 이 + // 따로 만든 `environment`/`reproduction` 문자열이다(V8__techlog_studio_working_copy.sql:23-26). + // 공개 조회가 배열 쪽을 읽는 동안 그 컬럼을 채우는 코드는 어디에도 없었고 — + // 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 두 칸은 늘 비어 있었다. + + " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction," + " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status," - + " p.navigation_path, p.published_at, p.updated_at, p.last_verified_at," + // 검증일은 원본 `document.last_verified_at` 에서 읽는다. 투영 테이블의 같은 이름 + // 컬럼(`public_resource_projection.last_verified_at`)은 `upsertProjection` 이 + // 채우지 않아 언제나 null 이었다. + + " p.navigation_path, p.published_at, p.updated_at, d.last_verified_at," + " t.name AS topic_name, t.slug AS topic_slug," + " pr.name AS project_name, pr.slug AS project_slug," + " a.content_type AS cover_content_type, a.alt_text AS cover_alt," @@ -119,7 +128,7 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { // Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다. isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"), isCase ? rs.getString("conclusion_summary") : null, - isCase ? json.strings(rs.getString("environment_items")) : List.of(), + isCase ? environment(rs) : List.of(), isCase ? List.of() : json.strings(rs.getString("applies_to")), isCase ? List.of() : json.strings(rs.getString("excluded_scope")), isCase ? null : rs.getString("freshness_status"), @@ -130,6 +139,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { tags(id), project(rs), cover(rs), + // 본문에 evidence 를 담는 것은 CASE 뿐이다. + isCase ? bodyAssets(id) : List.of(), instant(rs, "published_at"), instant(rs, "updated_at"), instant(rs, "last_verified_at"))); @@ -215,11 +226,68 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { .list(); } + /** + * 본문이 {@code :::evidence key="..."} 로 가리키는 Asset. + * + *

{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 + * 하기 때문이다. {@code WORKING} scope 는 Studio 의 것이다. + * + *

{@code READY} 가 아닌 Asset 은 뺀다. {@code /media/{assetId}} 가 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를 + * 가리키게 된다. + */ + private List bodyAssets(UUID documentId) { + return jdbcClient + .sql( + "SELECT a.id, a.asset_key, a.content_type, a.alt_text, a.width, a.height, a.decorative" + + " FROM asset_reference r" + + " JOIN asset a ON a.id = r.asset_id" + + " WHERE r.owner_type = 'DOCUMENT' AND r.owner_id = :id" + + " AND r.reference_scope = 'PUBLISHED' AND r.reference_role = 'BODY'" + + " AND a.management_status = 'READY'" + + " ORDER BY a.asset_key") + .param("id", documentId) + .query( + (rs, rowNum) -> { + UUID assetId = rs.getObject("id", UUID.class); + return new BodyAssetView( + rs.getString("asset_key"), + assetId, + "/api/v1/public/media/" + assetId, + rs.getString("content_type"), + rs.getString("alt_text"), + integer(rs, "width"), + integer(rs, "height"), + rs.getBoolean("decorative")); + }) + .list(); + } + + static Integer integer(ResultSet rs, String column) throws SQLException { + int value = rs.getInt(column); + return rs.wasNull() ? null : value; + } + static Instant instant(ResultSet rs, String column) throws SQLException { var value = rs.getTimestamp(column); return value == null ? null : value.toInstant(); } + /** + * 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다. + * + *

계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열 + * (`environment`, `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로 + * 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다. + */ + static List environment(ResultSet rs) throws SQLException { + List items = new ArrayList<>(2); + for (String column : List.of("environment", "reproduction")) { + String value = rs.getString(column); + if (value != null && !value.isBlank()) items.add(value); + } + return List.copyOf(items); + } + static TopicSummaryView topic(ResultSet rs) throws SQLException { return rs.getString("topic_slug") == null ? null diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/BodyAssetView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/BodyAssetView.java new file mode 100644 index 0000000..5f27983 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/BodyAssetView.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.UUID; + +/** + * 계약 {@code BodyAsset}. + * + *

본문이 {@code :::evidence key="..."} 로 가리키는 Asset 이다. 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데 + * {@code /media/{assetId}} 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이 의도된 성질이다 — key 에서 주소를 만들 수 없다. 공개 화면이 key 를 + * 해석할 수 있도록 게시된 기록이 실제로 참조하는 Asset 을 함께 준다. + * + * @param url 검증된 전송 경로다. object storage URL 이 아니다(설계 05장 §3.1). + * @param decorative 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개 화면도 같은 값을 보고 {@code alt} 를 정해야 + * 판정과 표시가 어긋나지 않는다. + */ +public record BodyAssetView( + String assetKey, + UUID assetId, + String url, + String contentType, + String altText, + Integer width, + Integer height, + boolean decorative) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java index 27bdf31..5eb9068 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java @@ -28,6 +28,7 @@ public record PublishedDocumentView( List tags, ProjectSummaryView primaryProject, AssetReferenceView coverAsset, + List bodyAssets, Instant publishedAt, Instant updatedAt, Instant lastVerifiedAt) { @@ -37,5 +38,7 @@ public record PublishedDocumentView( appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo); excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope); tags = tags == null ? List.of() : List.copyOf(tags); + // Reference 에는 evidence 를 담는 본문이 없다. 빈 목록이 정상이며 없음과 구분하지 않는다. + bodyAssets = bodyAssets == null ? List.of() : List.copyOf(bodyAssets); } } diff --git a/src/config/openapi/public-v1.yaml b/src/config/openapi/public-v1.yaml index 42935c1..1c7cf2e 100644 --- a/src/config/openapi/public-v1.yaml +++ b/src/config/openapi/public-v1.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Tech Log Public API - version: 2.0.0 + version: 2.1.0 description: | Tech Log 공개 조회 계약이다. 인증이 필요하지 않다. @@ -815,6 +815,47 @@ components: type: integer contentType: type: string + BodyAsset: + type: object + description: | + 본문이 `:::evidence key="..."` 로 가리키는 Asset 이다. + + 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID + 로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수 + 없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을 + 함께 준다. + + 목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset + 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다. + required: + - assetKey + - assetId + - url + - contentType + - decorative + properties: + assetKey: + type: string + minLength: 1 + maxLength: 200 + assetId: + type: string + format: uuid + url: + type: string + contentType: + type: string + altText: + type: string + width: + type: integer + height: + type: integer + decorative: + type: boolean + description: | + 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개 + 화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다. RelatedEntry: type: object required: @@ -1224,6 +1265,13 @@ components: $ref: '#/components/schemas/ProjectSummary' coverAsset: $ref: '#/components/schemas/AssetReference' + bodyAssets: + type: array + description: | + 본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한 + Asset 이 더 이상 서빙되지 않는 경우다. + items: + $ref: '#/components/schemas/BodyAsset' publishedAt: *id003 updatedAt: *id003 lastVerifiedAt: *id003