Compare commits

..
6 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 edb0890dc8 feat: 홈과 주제의 최근 기록이 게시된 Open Question 을 담는다
질문을 게시해도 홈 최근 기록에 나오지 않았다. 백엔드가 담지 않은 것이 아니라 담을 수
없었다 — 계약의 `LatestEntry.entryType` 이 CASE/REFERENCE/PROJECT_ACTIVITY/RELEASE 넷만
허용했고, 계약 밖 값을 응답 매퍼에 넘기면 500 이 되어 홈 화면 전체를 못 쓰게 만들기 때문에
질의가 먼저 걸러 내고 있었다.

계약을 넓혔으므로(design-package ef49d3a) 걸러 낼 이유가 사라졌다. `LATEST_ENTRY_TYPES` 에
`QUESTION` 을 더한다. `pathOf` 는 이미 `/questions/{slug}` 를 만들고 있었고, projection 에도
질문 행이 `ACTIVE`/`PUBLIC` 으로 `navigation_path` 까지 채워진 채 들어 있었다 — 막고 있던
것은 이 `IN` 목록 하나였다.

`LATEST_ENTRY_TYPES` 는 홈과 주제 상세가 함께 쓴다. 두 목록의 의미가 같으므로 한 곳만
넓히면 둘 다 따라오고, 그것이 의도다. 두 화면 각각에 "게시한 Open Question 이 목록에
나온다"는 단언을 세워 둔다 — 유형 허용 목록만 검사하면 담기지 않아도 통과한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:43:56 +09:00
DongHyeonkaandClaude Opus 5 c6d9d2d675 feat: 공개 Case·Reference 응답에 문서 요약을 싣는다
문서가 스스로 밝히는 한 줄 요약(`document.summary`)을 공개 응답이 내보내지 않았다.
화면은 제목 바로 아래에 그것을 그려야 하는데 자리가 없어 유형별 요약을 대신 썼고, 그러면
머리말이 바로 아래의 "문제"나 "이 기준을 쓰는 이유"와 같은 글을 두 번 말한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 21:12:40 +09:00
DongHyeonkaandClaude Opus 5 a5f93b9b75 feat: 공개 Reference 응답에 판단 기준과 예시를 싣는다
Reference 의 본문은 `body_markdown` 이 아니라 `reference_detail.rules` 와 `examples` 에
있다. Studio 의 Reference 편집기가 규칙(제목+본문)과 예시를 따로 받고 마크다운 본문은
비워 두기 때문이다.

공개 조회는 그 두 칸을 읽지 않고 `body_markdown` 만 봤다. 그래서 `content: ""` 를
내보냈고, 공개 화면의 "판단 기준"과 "예시"가 통째로 비었다 — Studio 에서는 다 보이는데
공개 쪽만 빈 이유가 이것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 19:11:01 +09:00
DongHyeonka f1fd56fcb5 chore: decision 미리보기기 계약 수정 2026-08-24 18:12:38 +09:00
DongHyeonkaandClaude Opus 5 bd66fb3610 fix: Decision 미리보기가 열리도록 프로젝트 공개 경로를 catalog 에 싣는다
Decision 문서는 즉시 미리보기가 어떤 문서에서도 열리지 않았다.

Decision 의 공개 주소는 자기 slug 가 아니라 `<프로젝트 경로>/decisions#<slug>` 다.
그래서 렌더 모델이 프로젝트의 공개 경로를 요구하는데, PROJECT catalog 는 그 자리를
언제나 null 로 돌려주고 있었다 — 게시된 프로젝트인지 아닌지와 무관하게 상수 null
이었다. 미리보기는 "PROJECT public path is required" 로 멈췄고, 화면에는 무엇이
모자란지 나오지 않았다.

게시된 프로젝트만 경로를 싣는다. 게시되지 않았으면 공개 주소가 실제로 없고, 없는
주소를 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 17:25:31 +09:00
DongHyeonkaandClaude Opus 5 a7e2b7d7fe feat: 게시가 프로젝트 활동을 남기게 한다
프로젝트 활동은 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린
사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 게시가 곧 사건이므로
게시가 기록한다 (publish 19단계).

문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로
타임라인에 다시 나타나지 않아야 한다 — `operation_key` 를
`publication:<documentId>` 로 두고 `uq_project_activity_operation_key` 충돌을 무시한다.

`origin` 은 `AUTO` 다. 손으로 적은 줄과 구분해 두면, 삭제 금지 규칙이 실제 사건의
흔적만 지킨다.

V10 은 이 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는 상태를
남겨 두면 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다. `occurred_at` 은 최초
PUBLISHED 사건의 시각이다 — now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼 보인다.

함께 고친 것: 마이그레이션 버전 목록이 7 에서 멈춰 있었다. TechLog 가 들어오며 8·9 가
붙었는데 이 테스트를 같이 고치지 않아, 그 빨간색은 "스키마가 잘못됐다" 가 아니라
"목록을 안 고쳤다" 를 뜻하는 상태로 두 번 지나갔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 15:45:36 +09:00
16 changed files with 261 additions and 18 deletions
@@ -11,6 +11,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPo
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReferenceRulesInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
@@ -36,6 +37,7 @@ public final class DocumentResponseMapper {
PublishedDocumentView doc = view.document();
CaseDetailResponseCase body = new CaseDetailResponseCase();
body.setTitle(doc.title());
body.setSummary(doc.summary());
body.setProblemSummary(doc.primarySummary());
body.setConclusionSummary(doc.secondarySummary());
body.setEnvironmentSummary(doc.environmentSummary());
@@ -71,9 +73,17 @@ public final class DocumentResponseMapper {
PublishedDocumentView doc = view.document();
ReferenceDetailResponseReference body = new ReferenceDetailResponseReference();
body.setTitle(doc.title());
body.setSummary(doc.summary());
body.setScopeSummary(doc.primarySummary());
body.setAppliesTo(doc.appliesTo());
body.setExcludedScope(doc.excludedScope());
// Reference 의 본문은 `content` 가 아니라 여기 있다 — 이것을 빼면 공개 화면에 판단 기준과
// 예시가 통째로 빠진다.
body.setRules(
doc.rules().stream()
.map(rule -> new ReferenceDetailResponseReferenceRulesInner(rule.title(), rule.body()))
.toList());
body.setExamples(doc.examples());
body.setFreshnessStatus(
ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus()));
body.setContent(doc.content());
@@ -97,6 +97,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
// 컬럼(`public_resource_projection.last_verified_at`)은 `upsertProjection` 이
// 채우지 않아 언제나 null 이었다.
+ " p.navigation_path, p.published_at, p.updated_at, d.last_verified_at,"
+ " d.summary,"
+ " r.rules, r.examples,"
+ " 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,"
@@ -126,12 +128,16 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
type,
rs.getString("navigation_path"),
rs.getString("title"),
rs.getString("summary"),
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
isCase ? rs.getString("conclusion_summary") : null,
isCase ? environment(rs) : List.of(),
isCase ? List.of() : json.strings(rs.getString("applies_to")),
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
// Reference 의 본문은 body_markdown 이 아니라 규칙과 예시에 있다.
isCase ? List.of() : json.referenceRules(rs.getString("rules")),
isCase ? List.of() : json.strings(rs.getString("examples")),
isCase ? null : rs.getString("freshness_status"),
rs.getString("body_markdown"),
rs.getString("content_format"),
@@ -180,8 +180,8 @@ public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort {
}
/**
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / QUESTION / PROJECT_ACTIVITY /
* RELEASE} 다섯 값을 허용한다. projection 에는 {@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
*
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRuleView;
import dev.caskeleton.shared.error.MappingException;
import java.util.ArrayList;
import java.util.List;
@@ -32,6 +33,16 @@ final class PublicJson {
return out;
}
/** Reference 의 판단 기준. 설계의 컬럼은 {@code {title, body, order}} 배열이다. */
List<ReferenceRuleView> referenceRules(String json) {
List<ReferenceRuleView> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ReferenceRuleView(node.path("title").asString(""), node.path("body").asString("")));
}
return out;
}
List<ContactLinkView> contacts(String json) {
List<ContactLinkView> out = new ArrayList<>();
for (JsonNode node : array(json)) {
@@ -16,7 +16,7 @@ final class PublicSql {
* 조건도 한 곳에서 정의한다.
*/
static final String LATEST_ENTRY_TYPES =
" p.resource_type IN ('CASE', 'REFERENCE', 'PROJECT_ACTIVITY') ";
" p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_ACTIVITY') ";
private PublicSql() {}
@@ -68,11 +68,25 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
.list();
}
/**
* 프로젝트의 공개 경로를 함께 싣는다.
*
* <p>여기서 {@code publicPath} 를 늘 null 로 두었더니 Decision 의 즉시 미리보기가 어떤 문서에서도 열리지 않았다. Decision 의 공개
* 주소는 자기 slug 가 아니라 {@code <프로젝트 경로>/decisions#<slug>} 라, 렌더 모델이 프로젝트 경로를 요구한다 — 그것이 비어 있으면
* "PROJECT public path is required" 로 미리보기 전체가 멈춘다. 화면에는 무엇이 모자란지 나오지 않는다.
*
* <p>게시되지 않은 프로젝트는 여전히 null 이다. 그때는 공개 주소가 실제로 없고, 없는 주소를 지어내면 미리보기가 보여 주는 링크가 게시 뒤에 달라진다.
*/
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM project "
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
"SELECT pr.id, pr.name, pr.updated_at, p.navigation_path"
+ " FROM project pr"
+ " LEFT JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " AND "
+ PUBLICLY_VISIBLE
+ " WHERE lower(pr.name) LIKE :pattern ORDER BY pr.name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
@@ -82,7 +96,7 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
CatalogEntryType.PROJECT,
rs.getString("name"),
"PROJECT",
null,
rs.getString("navigation_path"),
"project:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
@@ -164,9 +164,56 @@ public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
// 18. Document publish metadata
markSourcePublished(request);
// 19. 프로젝트 활동 로그
recordProjectActivity(request);
return result(publicationId, eventId);
}
/**
* 프로젝트 활동은 손으로 적는 것이 아니라 게시가 남기는 로그다.
*
* <p>한동안 이 줄을 Studio 에서 직접 써야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다.
* 게시가 곧 사건이므로 게시가 기록한다.
*
* <p>{@code operation_key} 로 문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로 타임라인에 다시 나타나지 않아야 한다
* — {@code uq_project_activity_operation_key} 가 그것을 보장하고, 여기서는 충돌을 무시한다.
*
* <p>프로젝트에 매달리지 않은 기록은 남길 자리가 없다. 그때는 아무것도 하지 않는다.
*/
private void recordProjectActivity(PublishRequest request) {
if (request.projectId() == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
+ " operation_key, created_by, updated_by)"
+ " VALUES (:id, :projectId, :type, :title, '', 'PUBLIC', 'AUTO',"
+ " :resourceType, :resourceId, now(), :operationKey, :actor, :actor)"
+ " ON CONFLICT (project_id, operation_key) DO NOTHING")
.param("id", idGenerator.get())
.param("projectId", request.projectId())
.param("type", activityTypeOf(request.kind()))
.param("title", request.title())
.param("resourceType", request.kind().name())
.param("resourceId", request.documentId())
.param("operationKey", "publication:" + request.documentId())
.param("actor", request.principal())
.update();
}
/** {@code project_activity_activity_type_check} 가 허용하는 값으로 옮긴다. */
private static String activityTypeOf(RecordKind kind) {
return switch (kind) {
case CASE -> "CASE_PUBLISHED";
case REFERENCE -> "REFERENCE_PUBLISHED";
case QUESTION -> "QUESTION_OPENED";
case PROJECT_DECISION -> "DECISION_ACCEPTED";
};
}
@Override
public PublishResultView unpublish(UnpublishRequest request) {
requireTransaction("unpublish");
@@ -0,0 +1,53 @@
-- 프로젝트 활동을 게시 로그로 되돌린다.
--
-- 이 표는 원래 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로
-- 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 이제 게시가 이 줄을 남긴다
-- (JdbcPublicationWriterAdapter 19단계).
--
-- 이 마이그레이션은 그 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는
-- 상태를 남겨 두면, 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다.
--
-- occurred_at 은 최초 PUBLISHED 사건의 시각이다 -- now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼
-- 보인다. operation_key 는 애플리케이션이 쓰는 것과 같은 규칙이라, 나중에 같은 문서를 재게시해도
-- 줄이 늘지 않는다.
INSERT INTO project_activity (
id, project_id, activity_type, title, summary, visibility, origin,
related_resource_type, related_resource_id, occurred_at,
operation_key, created_by, updated_by
)
SELECT
gen_random_uuid(),
link.project_id,
CASE publication.source_kind
WHEN 'CASE' THEN 'CASE_PUBLISHED'
WHEN 'REFERENCE' THEN 'REFERENCE_PUBLISHED'
WHEN 'QUESTION' THEN 'QUESTION_OPENED'
ELSE 'DECISION_ACCEPTED'
END,
projection.title,
'',
'PUBLIC',
'AUTO',
publication.source_kind,
publication.source_id,
first_published.occurred_at,
'publication:' || publication.source_id,
'system:migration',
'system:migration'
FROM publication
JOIN public_resource_project_link link
ON link.resource_type = publication.source_kind
AND link.resource_id = publication.source_id
AND link.relation_type = 'PRIMARY'
JOIN public_resource_projection projection
ON projection.resource_type = publication.source_kind
AND projection.resource_id = publication.source_id
JOIN LATERAL (
SELECT min(event.occurred_at) AS occurred_at
FROM publication_event event
WHERE event.publication_id = publication.publication_id
AND event.event_type = 'PUBLISHED'
) first_published ON true
WHERE publication.status = 'PUBLISHED'
AND first_published.occurred_at IS NOT NULL
ON CONFLICT (project_id, operation_key) DO NOTHING;
@@ -43,8 +43,16 @@ class PostgreSqlMigrationIntegrationTest {
.load()
.migrate();
/*
TechLog 가 들어오면서 8·9·10 이 붙었는데 이 목록은 7 에서 멈춰 있었다. 마이그레이션을 더한
사람이 여기를 같이 고치지 않으면 이 테스트만 빨개지고, 그 빨간색은 "스키마가 잘못됐다" 가
아니라 "목록을 안 고쳤다" 를 뜻한다 — 정확히 그 상태로 두 번 지나갔다.
목록을 고정해 두는 이유는 남아 있다: 마이그레이션이 순서대로, 빠짐없이 적용되는지 확인한다.
그래서 개수를 세는 것이 아니라 버전을 그대로 적는다.
*/
assertThat(appliedVersions(postgres, "flyway_schema_history"))
.containsExactly("1", "3", "4", "5", "6", "7");
.containsExactly("1", "3", "4", "5", "6", "7", "8", "9", "10");
Flyway coreStream =
Flyway.configure()
@@ -785,4 +785,34 @@ class ManagementPersistenceIntegrationTest {
.single())
.isZero();
}
/**
* Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 편집기가 Decision 을 미리 그리려면 이 목록이 프로젝트의 공개 경로를
* 알고 있어야 한다 — 한동안 늘 null 이었고, 그동안 Decision 은 어떤 문서에서도 즉시 미리보기가 열리지 않았다.
*/
@Test
void projectCatalogCarriesThePublicPathOfPublishedProjectsOnly() {
ProjectEditView open = projects.create(new CreateProjectCommand("Catalog Published", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'catalog-published' WHERE id = :id")
.param("id", open.id())
.update();
ProjectEditView loaded = projects.find(open.id()).orElseThrow();
assertThat(projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test")).isPresent();
ProjectEditView hidden = projects.create(new CreateProjectCommand("Catalog Hidden", "test"));
var entries = catalog.search(CatalogEntryType.PROJECT, "catalog", null, 50).items();
assertThat(entries)
.filteredOn(entry -> entry.id().equals(open.id()))
.singleElement()
.satisfies(
entry -> assertThat(entry.publicPath()).isEqualTo("/projects/catalog-published"));
// 게시되지 않은 프로젝트는 공개 주소가 실제로 없다. 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.
assertThat(entries)
.filteredOn(entry -> entry.id().equals(hidden.id()))
.singleElement()
.satisfies(entry -> assertThat(entry.publicPath()).isNull());
}
}
@@ -206,12 +206,21 @@ class PublicSitePersistenceIntegrationTest {
.noneMatch(entry -> entry.title().contains("숨김"));
assertThat(view.latestEntries())
.as(
"계약 LatestEntry.entryType 은 네 값만 허용한다 — projection 의 QUESTION/PROJECT 등이 섞이면"
+ " 응답 매퍼가 계약 밖 값을 만나 500 이 ")
"계약 LatestEntry.entryType 이 허용하는 값만 나와야 한다 — projection 의 PROJECT/PROJECT_DECISION"
+ " 등이 섞이면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든")
.extracting("entryType")
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
.allSatisfy(
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
type ->
assertThat(type)
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
assertThat(view.latestEntries())
.as("게시한 Open Question 도 최근 기록에 나와야 한다 — 목록에서 빠지면 게시한 사실이 어디에도 보이지 않는다")
.anySatisfy(
entry -> {
assertThat(entry.entryType()).isEqualTo("QUESTION");
assertThat(entry.path()).isEqualTo("/questions/reprocessing-latency");
});
}
/**
@@ -397,10 +406,16 @@ class PublicSitePersistenceIntegrationTest {
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
assertThat(view.latestRecords()).isNotEmpty();
assertThat(view.latestRecords())
.as("주제 상세의 최신 기록도 계약의 entryType 네 값을 벗어나면 안 된다")
.as("주제 상세의 최신 기록도 계약의 entryType 을 벗어나면 안 된다")
.extracting("entryType")
.allSatisfy(
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
type ->
assertThat(type)
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
assertThat(view.latestRecords())
.as("주제와 홈은 같은 목록 의미를 쓴다 — 홈에 나오는 Open Question 이 여기서 빠지면 두 화면이 어긋난다")
.extracting("entryType")
.contains("QUESTION");
}
@Test
@@ -9,17 +9,23 @@ import java.util.List;
* <p>계약의 {@code CaseDetailResponse.case} 와 {@code ReferenceDetailResponse.reference} 는 담는 필드가
* 다르지만(문제/결론 vs 범위/적용), 원천이 같은 {@code document} + 유형별 detail 이라 하나의 레코드로 읽고 웹 계층에서 유형별 모양으로 나눈다.
*
* @param summary 문서가 스스로 밝히는 한 줄 요약. 제목 바로 아래에 온다 — 유형별 요약({@code primarySummary}: Case 는 문제,
* Reference 는 범위)과 다르다. 이 자리가 없던 동안 화면은 유형별 요약을 대신 썼고, 그러면 머리말이 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와
* 같은 글을 두 번 말했다.
* @param content Markdown 원문이다. studio 의 렌더 블록이 아니다 — 공개 계약은 {@code contentFormat} 과 함께 원문을 준다.
*/
public record PublishedDocumentView(
String type,
String canonicalPath,
String title,
String summary,
String primarySummary,
String secondarySummary,
List<String> environmentSummary,
List<String> appliesTo,
List<String> excludedScope,
List<ReferenceRuleView> rules,
List<String> examples,
String freshnessStatus,
String content,
String contentFormat,
@@ -37,6 +43,8 @@ public record PublishedDocumentView(
environmentSummary = environmentSummary == null ? List.of() : List.copyOf(environmentSummary);
appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo);
excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope);
rules = rules == null ? List.of() : List.copyOf(rules);
examples = examples == null ? List.of() : List.copyOf(examples);
tags = tags == null ? List.of() : List.copyOf(tags);
// Reference 에는 evidence 를 담는 본문이 없다. 빈 목록이 정상이며 없음과 구분하지 않는다.
bodyAssets = bodyAssets == null ? List.of() : List.copyOf(bodyAssets);
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.publicsite.model;
/**
* Reference 의 판단 기준 한 줄. 계약 {@code ReferenceDetailResponse.reference.rules[]}.
*
* <p>Reference 의 본문은 마크다운 한 덩어리가 아니라 제목이 붙은 규칙의 목록이다 — Studio 의 편집기가 그렇게 받고, 공개 화면도 그렇게 그린다.
*/
public record ReferenceRuleView(String title, String body) {}
+5 -5
View File
@@ -1,6 +1,6 @@
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 06ae075 (master)
6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f studio-v1.yaml
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 06ae075 (master)
702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c public-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 06ae075 (master)
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 0ffbc28 (master)
18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd studio-v1.yaml
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ ef49d3a (master)
7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e public-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 0ffbc28 (master)
72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55 studio-management-v1.yaml
+30
View File
@@ -1022,6 +1022,7 @@ components:
enum:
- CASE
- REFERENCE
- QUESTION
- PROJECT_ACTIVITY
- RELEASE
title:
@@ -1239,6 +1240,12 @@ components:
properties:
title:
type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
problemSummary:
type: string
conclusionSummary:
@@ -1325,6 +1332,12 @@ components:
properties:
title:
type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
scopeSummary:
type: string
appliesTo:
@@ -1335,6 +1348,23 @@ components:
type: array
items:
type: string
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
rules:
type: array
items:
type: object
required: [title, body]
properties:
title:
type: string
body:
type: string
examples:
type: array
items:
type: string
freshnessStatus:
type: string
enum:
+4 -1
View File
@@ -1572,7 +1572,10 @@ components:
properties:
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }