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 계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열
+ * (`environment`, `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로
+ * 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
+ */
+ static List 본문이 {@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