fix: 오류 수정

This commit is contained in:
DongHyeonka
2026-08-22 14:41:19 +09:00
parent 857e6a9c08
commit 23d82bd250
7 changed files with 172 additions and 5 deletions
@@ -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()));
@@ -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());
@@ -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());
}
@@ -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
@@ -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) {}
@@ -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);
}
}
+49 -1
View File
@@ -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