fix: 오류 수정
This commit is contained in:
+1
@@ -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()));
|
||||
|
||||
+16
@@ -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());
|
||||
|
||||
+8
-1
@@ -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());
|
||||
}
|
||||
|
||||
+71
-3
@@ -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.
|
||||
*
|
||||
* <p>{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야
|
||||
* 하기 때문이다. {@code WORKING} scope 는 Studio 의 것이다.
|
||||
*
|
||||
* <p>{@code READY} 가 아닌 Asset 은 뺀다. {@code /media/{assetId}} 가 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를
|
||||
* 가리키게 된다.
|
||||
*/
|
||||
private List<BodyAssetView> 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다.
|
||||
*
|
||||
* <p>계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열
|
||||
* (`environment`, `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로
|
||||
* 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
|
||||
*/
|
||||
static List<String> environment(ResultSet rs) throws SQLException {
|
||||
List<String> 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
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.techlog.publicsite.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 계약 {@code BodyAsset}.
|
||||
*
|
||||
* <p>본문이 {@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) {}
|
||||
+3
@@ -28,6 +28,7 @@ public record PublishedDocumentView(
|
||||
List<TagSummaryView> tags,
|
||||
ProjectSummaryView primaryProject,
|
||||
AssetReferenceView coverAsset,
|
||||
List<BodyAssetView> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user