feat: Tech Log 공개 조회 백엔드 — public-v1 18개 operation 구현
public-v1.yaml의 18개 operation 전부를 구현한다. 사이트·홈·프로필, 탐색 2종, 주제
2종, 문서 상세 3종, 프로젝트 5종, 릴리스 2종, 검색. studio-v1(19/19)에 이어
public-v1도 18/18이다.
생성기가 계약 필드를 조용히 빠뜨리고 있었다 — 근본 원인은 파생 단계의 YAML alias
swagger-parser가 이 문서의 스키마 15개를 "is not of type `object`"로 거절했다.
거절당한 스키마들은 전부 type: object를 명시하고 있어서 계약 결함처럼 보이지 않았고,
validateSpec을 끄면 생성은 성공했다. 그런데 그렇게 만든 모델에서 LatestEntry.publishedAt,
ProjectListItem.updatedAt, SearchResultItem.matchedFields, ReleaseListItem.changeTypes가
사라져 있었다. 컴파일은 통과한다 — 아직 아무도 그 필드를 안 쓰니까.
원인은 prepare 단계였다. 변환들이 같은 Map 인스턴스를 여러 property에 재사용했고
snakeyaml이 그 지점을 anchor/alias(&id001 / *id001)로 덤프했다. swagger-parser는
alias 노드를 해석하지 못해 그 스키마 전체를 거절하고, generator는 검증을 끄면 문서를
받아들이되 alias였던 property를 말없이 버린다. 파생 스펙에 alias가 34곳 있었다.
- 덤프 직전 deep copy로 노드 identity를 끊어 alias를 원천 차단하고, 남으면 빌드가
실패하도록 fail-closed 게이트를 뒀다. validateSpec은 다시 켰다
- verifyPublicGeneratedModels를 schema 이름 대조에서 property 대조로 강화했다.
이번 누락을 이 게이트가 통과시켰기 때문이다. 지금은 schema 62개 · property 250개를 센다
계약이 선언했는데 서버가 무시하던 필터를 채웠다
지정해도 오류가 아니라 "결과 0건"으로 보여서 소비자가 자기 요청이 틀렸다는 걸 알 수 없었다.
- exploreQuestions: tag 필터 없음, sort 3값이 SQL에 반영되지 않음
- listPublicProjectDecisions: status 필터 없음
- listPublicProjectRecords: type/relation 필터 없음, QUESTION이 대상에서 빠져 있었음
- 필터는 목록과 총계 두 쿼리에 같이 걸린다. 갈라지면 마지막 페이지가 비어 보인다
- enum 파라미터는 요청 경계에서 검사해 PUBLIC_REQUEST_INVALID로 거절한다
응답 봉투와 오류 경계
- 컨트롤러는 봉투를 반환하지 않는다. EnvelopeBodyAdvice가 감싼다(ADR-006)
- PublicExceptionHandler를 publicapi 스코프로 두고, StudioExceptionHandler의 스코프를
...web.techlog → ...web.techlog.studio로 좁혔다. 좁히지 않으면 공개 조회의 파라미터
오류가 Studio 계약 코드(REQUEST_VALIDATION_FAILED, 422)로 나가는데, 그 코드는
public-v1의 ApiError.code enum에 없어 프론트엔드의 응답 파싱 자체가 깨진다
- FieldError 모양이 studio({path,message})와 public({field,code,message})이 다르다
실행이 잡아낸 결함
컴파일과 단위 테스트로는 드러나지 않았고 실제 PostgreSQL과 실제 기동이 잡았다.
- profile()의 selectedEvidence가 List.of() 하드코딩이었다. 계약 필드가 항상 비어 있었다
- latestEntries/latestRecords가 projection의 모든 resource_type을 흘렸다. 계약의
LatestEntry.entryType은 4값뿐이라 QUESTION이 섞이면 매퍼가 500을 낸다
- home_focus_config.default_focus_type은 마이그레이션 직후 NULL인데 계약은 이 필드를
required + enum 3값으로 선언한다. 배포 직후 첫 요청부터 /home이 깨졌다.
HomeFocusView.resolve가 반드시 유효한 값 하나를 정하도록 고쳤다
V9__techlog_public_surface.sql
설계 패키지 database/V1__init.sql이 정의한 공개 표면 6종(release, site_config,
profile_page, home_focus_config, project_topic, topic_featured_document)과 단일 행
시딩. 릴리스는 Publication 파이프라인을 거치지 않고 자체 workflow_status로 공개된다.
게이트
- PublicContractDriftTest: springdoc이 게시하는 표면과 계약을 양방향 대조한다.
계약의 servers(/api/v1/public)를 경로에 더해 비교하며, operation 수 18을 함께 고정해
"비교 대상이 0건이라 통과"를 실패로 만든다. 봉투 래핑도 확인한다
- PublicErrorRegistryTest: PublicError ↔ error-codes.yaml ↔ 계약 enum 3자 대조.
INTERNAL_ERROR는 스켈레톤 소유라 재선언하지 않으므로 "계약 = public 소유 ∪ 그 하나"로
고정한다. vendored 계약의 MANIFEST 해시도 확인한다
- postgresqlTechLogPublicPersistenceIntegrationTest: 어댑터 7종과 V9를 실제
PostgreSQL에서 돌린다. 표준 check는 Testcontainers를 돌리지 않으므로 이 태스크가
없으면 이 SQL은 한 번도 실행되지 않은 채 빌드가 통과한다. 게시 취소·비공개 자료를
함께 심어 어느 경로로도 새지 않는지 확인한다
검증
./gradlew check BUILD SUCCESSFUL (248 task). 공개 조회 통합 테스트 24/24.
실제 PostgreSQL로 앱을 띄워 18개 operation 전부 실호출 — 5xx 0건, 파라미터 검증 5종
전부 계약 코드. 한때 사라졌던 publishedAt/matchedFields/changeTypes가 실응답에 있다.
알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다.
기저 커밋 743fee3에서도 동일하게 재현되며, 원인은 redis가 호스트 포트에 노출되지 않아
헬스가 DOWN인 환경 문제다. 이 커밋과 무관하다.
AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을
전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3254def57
commit
365560efb6
@@ -131,6 +131,13 @@ def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadin
|
||||
'postgresqlTechLogStudioPersistenceIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
|
||||
|
||||
// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종,
|
||||
// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를
|
||||
// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다.
|
||||
def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlTechLogPublicPersistenceIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest')
|
||||
|
||||
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
|
||||
group = 'verification'
|
||||
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionRelationsView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRelationsView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 공개된 Case / Reference / Question 상세.
|
||||
*
|
||||
* <p>본문은 {@code public_resource_projection.payload}(Studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약은 블록 배열이
|
||||
* 아니라 Markdown 원문과 {@code contentFormat} 을 준다. projection 은 "공개됐는가"와 게시 시각을 정하는 데만 쓴다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final PublicJson json;
|
||||
private final PublicRelationLookup relations;
|
||||
|
||||
public JdbcPublicDocumentQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.json = new PublicJson(objectMapper);
|
||||
this.relations = new PublicRelationLookup(jdbcClient);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<CaseDetailView> findCase(String slug) {
|
||||
return document("CASE", slug)
|
||||
.map(
|
||||
row ->
|
||||
new CaseDetailView(
|
||||
row.view().canonicalPath(),
|
||||
true,
|
||||
row.view(),
|
||||
new CaseRelationsView(
|
||||
relations.firstTargetOfType("CASE", row.id(), "QUESTION"),
|
||||
relations.targetsOfType("CASE", row.id(), "PROJECT_DECISION"),
|
||||
// 이 Case 에서 파생된 Reference 는 역방향이다 — Reference 쪽이 Case 를 가리킨다.
|
||||
relations.sourcesOfType(row.id(), "REFERENCE"),
|
||||
relations.targetsOfType("CASE", row.id(), "CASE"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ReferenceDetailView> findReference(String slug) {
|
||||
return document("REFERENCE", slug)
|
||||
.map(
|
||||
row ->
|
||||
new ReferenceDetailView(
|
||||
row.view().canonicalPath(),
|
||||
true,
|
||||
row.view(),
|
||||
new ReferenceRelationsView(
|
||||
relations.targetsOfType("REFERENCE", row.id(), "CASE"),
|
||||
relations.targetsOfType("REFERENCE", row.id(), "PROJECT_DECISION"),
|
||||
relations.targetsOfType("REFERENCE", row.id(), "REFERENCE"))));
|
||||
}
|
||||
|
||||
/** 관계 조회에 문서 id 가 필요한데 계약의 응답에는 id 가 없다. 뷰 밖으로 id 를 새로 노출하지 않고 이 안에서만 함께 나른다. */
|
||||
private record DocumentRow(UUID id, PublishedDocumentView view) {}
|
||||
|
||||
private Optional<DocumentRow> document(String type, String slug) {
|
||||
return jdbcClient
|
||||
.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,"
|
||||
+ " 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,"
|
||||
+ " 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,"
|
||||
+ " a.width AS cover_width, a.height AS cover_height"
|
||||
+ " FROM document d"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
|
||||
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
|
||||
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
|
||||
+ " LEFT JOIN topic t ON t.id = d.primary_topic_id"
|
||||
+ " LEFT JOIN public_resource_project_link l"
|
||||
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
|
||||
+ " AND l.relation_type = 'PRIMARY'"
|
||||
+ " LEFT JOIN project pr ON pr.id = l.project_id"
|
||||
+ " LEFT JOIN asset a ON a.id = d.cover_asset_id"
|
||||
+ " WHERE d.document_type = :type AND d.slug = :slug AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("type", type)
|
||||
.param("slug", slug)
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
UUID id = rs.getObject("id", UUID.class);
|
||||
boolean isCase = "CASE".equals(type);
|
||||
return new DocumentRow(
|
||||
id,
|
||||
new PublishedDocumentView(
|
||||
type,
|
||||
rs.getString("navigation_path"),
|
||||
rs.getString("title"),
|
||||
// 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 ? List.of() : json.strings(rs.getString("applies_to")),
|
||||
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
|
||||
isCase ? null : rs.getString("freshness_status"),
|
||||
rs.getString("body_markdown"),
|
||||
rs.getString("content_format"),
|
||||
rs.getInt("content_format_version"),
|
||||
topic(rs),
|
||||
tags(id),
|
||||
project(rs),
|
||||
cover(rs),
|
||||
instant(rs, "published_at"),
|
||||
instant(rs, "updated_at"),
|
||||
instant(rs, "last_verified_at")));
|
||||
})
|
||||
.optional();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<QuestionDetailView> findQuestion(String slug) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT q.id, q.question, q.slug, q.summary, q.context_markdown,"
|
||||
+ " q.importance_markdown, q.question_status, q.next_verification,"
|
||||
+ " q.resolution_type, q.resolution_summary, q.resolved_at, q.opened_at,"
|
||||
+ " p.navigation_path, p.updated_at"
|
||||
+ " FROM open_question q"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
|
||||
+ " WHERE q.slug = :slug AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("slug", slug)
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
UUID id = rs.getObject("id", UUID.class);
|
||||
PublishedQuestionView question =
|
||||
new PublishedQuestionView(
|
||||
rs.getString("question"),
|
||||
rs.getString("summary"),
|
||||
rs.getString("context_markdown"),
|
||||
rs.getString("importance_markdown"),
|
||||
rs.getString("question_status"),
|
||||
rs.getString("next_verification"),
|
||||
new QuestionPointGroupView(
|
||||
points(id, "FACT"),
|
||||
points(id, "ASSUMPTION"),
|
||||
points(id, "UNKNOWN"),
|
||||
points(id, "CONSTRAINT")),
|
||||
updates(id),
|
||||
rs.getString("resolution_type"),
|
||||
rs.getString("resolution_summary"),
|
||||
instant(rs, "resolved_at"),
|
||||
instant(rs, "opened_at"),
|
||||
instant(rs, "updated_at"));
|
||||
return new QuestionDetailView(
|
||||
rs.getString("navigation_path"),
|
||||
true,
|
||||
question,
|
||||
new QuestionRelationsView(
|
||||
relations.primaryProject("project_question_link", "question_id", id),
|
||||
relations.firstTargetOfType("QUESTION", id, "CASE"),
|
||||
relations.firstTargetOfType("QUESTION", id, "PROJECT_DECISION"),
|
||||
relations.targetsOfType("QUESTION", id, "REFERENCE")));
|
||||
})
|
||||
.optional();
|
||||
}
|
||||
|
||||
private List<String> points(UUID questionId, String pointKind) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind"
|
||||
+ " ORDER BY display_order")
|
||||
.param("id", questionId)
|
||||
.param("kind", pointKind)
|
||||
.query(String.class)
|
||||
.list();
|
||||
}
|
||||
|
||||
/** 공개된 조사 기록만 보여준다 — {@code PRIVATE} 기록은 Studio 안에만 있다. */
|
||||
private List<QuestionUpdateView> updates(UUID questionId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT update_type, title, body_markdown, occurred_at FROM question_update"
|
||||
+ " WHERE question_id = :id AND update_visibility = 'PUBLIC'"
|
||||
+ " ORDER BY sequence_no")
|
||||
.param("id", questionId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new QuestionUpdateView(
|
||||
rs.getString("update_type"),
|
||||
rs.getString("title"),
|
||||
rs.getString("body_markdown"),
|
||||
instant(rs, "occurred_at")))
|
||||
.list();
|
||||
}
|
||||
|
||||
static Instant instant(ResultSet rs, String column) throws SQLException {
|
||||
var value = rs.getTimestamp(column);
|
||||
return value == null ? null : value.toInstant();
|
||||
}
|
||||
|
||||
static TopicSummaryView topic(ResultSet rs) throws SQLException {
|
||||
return rs.getString("topic_slug") == null
|
||||
? null
|
||||
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug"));
|
||||
}
|
||||
|
||||
static ProjectSummaryView project(ResultSet rs) throws SQLException {
|
||||
return rs.getString("project_slug") == null
|
||||
? null
|
||||
: new ProjectSummaryView(
|
||||
rs.getString("project_name"),
|
||||
rs.getString("project_slug"),
|
||||
"/projects/" + rs.getString("project_slug"));
|
||||
}
|
||||
|
||||
static AssetReferenceView cover(ResultSet rs) throws SQLException {
|
||||
UUID assetId = rs.getObject("cover_asset_id", UUID.class);
|
||||
return assetId == null
|
||||
? null
|
||||
: new AssetReferenceView(
|
||||
assetId,
|
||||
"/media/" + assetId,
|
||||
rs.getString("cover_alt"),
|
||||
(Integer) rs.getObject("cover_width"),
|
||||
(Integer) rs.getObject("cover_height"),
|
||||
rs.getString("cover_content_type"));
|
||||
}
|
||||
|
||||
List<TagSummaryView> tags(UUID documentId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT g.name, g.slug FROM document_tag dt JOIN tag g ON g.id = dt.tag_id"
|
||||
+ " WHERE dt.document_id = :id ORDER BY dt.display_order")
|
||||
.param("id", documentId)
|
||||
.query((rs, rowNum) -> new TagSummaryView(rs.getString("name"), rs.getString("slug")))
|
||||
.list();
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 탐색 목록.
|
||||
*
|
||||
* <p>필터와 정렬을 SQL 로 처리하고 페이지 총계를 같은 조건으로 센다 — 목록과 총계가 다른 조건을 쓰면 마지막 페이지가 비어 보이거나 있지도 않은 페이지 번호가 생긴다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcPublicExploreQueryAdapter implements PublicExploreQueryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcPublicExploreQueryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KnowledgePageView knowledge(ExploreKnowledgeQuery query) {
|
||||
StringBuilder where =
|
||||
new StringBuilder(
|
||||
" WHERE " + PublicSql.ACTIVE + " AND p.resource_type IN ('CASE', 'REFERENCE')");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
if (query.type() != null) {
|
||||
where.append(" AND p.resource_type = :type");
|
||||
params.put("type", query.type());
|
||||
}
|
||||
if (query.topicSlug() != null) {
|
||||
where.append(" AND t.slug = :topicSlug");
|
||||
params.put("topicSlug", query.topicSlug());
|
||||
}
|
||||
if (query.projectSlug() != null) {
|
||||
where.append(" AND pr.slug = :projectSlug");
|
||||
params.put("projectSlug", query.projectSlug());
|
||||
}
|
||||
if (query.tagSlug() != null) {
|
||||
where.append(
|
||||
" AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id"
|
||||
+ " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id"
|
||||
+ " AND g.slug = :tagSlug)");
|
||||
params.put("tagSlug", query.tagSlug());
|
||||
}
|
||||
if (query.year() != null) {
|
||||
where.append(" AND date_part('year', p.published_at) = :year");
|
||||
params.put("year", query.year());
|
||||
}
|
||||
|
||||
String joins =
|
||||
" FROM public_resource_projection p"
|
||||
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
|
||||
+ " LEFT JOIN public_resource_project_link l"
|
||||
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
|
||||
+ " AND l.relation_type = 'PRIMARY'"
|
||||
+ " LEFT JOIN project pr ON pr.id = l.project_id";
|
||||
|
||||
long total = count(joins + where, params);
|
||||
List<KnowledgeListItemView> items =
|
||||
page(
|
||||
"SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.state_code,"
|
||||
+ " p.published_at, p.last_verified_at,"
|
||||
+ " t.name AS topic_name, t.slug AS topic_slug,"
|
||||
+ " pr.name AS project_name, pr.slug AS project_slug"
|
||||
+ joins
|
||||
+ where
|
||||
+ knowledgeOrder(query.sort()),
|
||||
params,
|
||||
query.page().size(),
|
||||
query.page().offset(),
|
||||
JdbcPublicExploreQueryAdapter::readKnowledge);
|
||||
|
||||
return new KnowledgePageView(
|
||||
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
|
||||
}
|
||||
|
||||
/** 계약의 정렬 세 값. 같은 시각이 여럿일 때 페이지 경계가 흔들리지 않도록 id 를 tie-breaker 로 둔다. */
|
||||
private static String knowledgeOrder(String sort) {
|
||||
String key =
|
||||
switch (sort == null ? "PUBLISHED_DESC" : sort) {
|
||||
case "UPDATED_DESC" -> "p.updated_at DESC";
|
||||
case "VERIFIED_DESC" -> "p.last_verified_at DESC NULLS LAST";
|
||||
default -> "p.published_at DESC";
|
||||
};
|
||||
return " ORDER BY " + key + ", p.resource_id DESC";
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 {@code exploreQuestions.sort} 의 세 값. {@code RESOLVED_DESC} 는 미해결 질문에 값이 없으므로 NULLS LAST 로 밀어
|
||||
* 낸다 — 그러지 않으면 PostgreSQL 의 DESC 기본값 NULLS FIRST 때문에 미해결 질문이 "가장 최근에 해결된 것" 자리에 올라온다.
|
||||
*/
|
||||
private static String questionOrder(String sort) {
|
||||
String key =
|
||||
switch (sort == null ? "UPDATED_DESC" : sort) {
|
||||
case "OPENED_DESC" -> "q.opened_at DESC NULLS LAST";
|
||||
case "RESOLVED_DESC" -> "q.resolved_at DESC NULLS LAST";
|
||||
default -> "p.updated_at DESC";
|
||||
};
|
||||
return " ORDER BY " + key + ", p.resource_id DESC";
|
||||
}
|
||||
|
||||
private static KnowledgeListItemView readKnowledge(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new KnowledgeListItemView(
|
||||
rs.getString("resource_type"),
|
||||
rs.getString("title"),
|
||||
rs.getString("navigation_path"),
|
||||
rs.getString("summary"),
|
||||
null,
|
||||
rs.getString("topic_slug") == null
|
||||
? null
|
||||
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug")),
|
||||
rs.getString("project_slug") == null
|
||||
? null
|
||||
: new ProjectSummaryView(
|
||||
rs.getString("project_name"),
|
||||
rs.getString("project_slug"),
|
||||
"/projects/" + rs.getString("project_slug")),
|
||||
rs.getTimestamp("published_at").toInstant(),
|
||||
rs.getTimestamp("last_verified_at") == null
|
||||
? null
|
||||
: rs.getTimestamp("last_verified_at").toInstant(),
|
||||
rs.getString("state_code"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuestionPageView questions(ExploreQuestionsQuery query) {
|
||||
StringBuilder where =
|
||||
new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND p.resource_type = 'QUESTION'");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
if (query.status() != null) {
|
||||
where.append(" AND p.state_code = :status");
|
||||
params.put("status", query.status());
|
||||
}
|
||||
if (query.topicSlug() != null) {
|
||||
where.append(" AND t.slug = :topicSlug");
|
||||
params.put("topicSlug", query.topicSlug());
|
||||
}
|
||||
if (query.projectSlug() != null) {
|
||||
where.append(" AND pr.slug = :projectSlug");
|
||||
params.put("projectSlug", query.projectSlug());
|
||||
}
|
||||
if (query.tagSlug() != null) {
|
||||
where.append(
|
||||
" AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id"
|
||||
+ " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id"
|
||||
+ " AND g.slug = :tagSlug)");
|
||||
params.put("tagSlug", query.tagSlug());
|
||||
}
|
||||
|
||||
String joins =
|
||||
" FROM public_resource_projection p"
|
||||
+ " JOIN open_question q ON q.id = p.resource_id"
|
||||
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
|
||||
+ " LEFT JOIN public_resource_project_link l"
|
||||
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
|
||||
+ " AND l.relation_type = 'PRIMARY'"
|
||||
+ " LEFT JOIN project pr ON pr.id = l.project_id";
|
||||
|
||||
long total = count(joins + where, params);
|
||||
List<QuestionListItemView> items =
|
||||
page(
|
||||
"SELECT q.question, p.navigation_path, q.question_status, p.summary,"
|
||||
+ " q.next_verification, p.updated_at,"
|
||||
+ " pr.name AS project_name, pr.slug AS project_slug"
|
||||
+ joins
|
||||
+ where
|
||||
+ questionOrder(query.sort()),
|
||||
params,
|
||||
query.page().size(),
|
||||
query.page().offset(),
|
||||
(rs, rowNum) ->
|
||||
new QuestionListItemView(
|
||||
rs.getString("question"),
|
||||
rs.getString("navigation_path"),
|
||||
rs.getString("question_status"),
|
||||
rs.getString("summary"),
|
||||
null,
|
||||
rs.getString("next_verification"),
|
||||
rs.getString("project_slug") == null
|
||||
? null
|
||||
: new ProjectSummaryView(
|
||||
rs.getString("project_name"),
|
||||
rs.getString("project_slug"),
|
||||
"/projects/" + rs.getString("project_slug")),
|
||||
rs.getTimestamp("updated_at").toInstant()));
|
||||
|
||||
return new QuestionPageView(
|
||||
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
|
||||
}
|
||||
|
||||
private long count(String fromAndWhere, Map<String, Object> params) {
|
||||
var spec = jdbcClient.sql("SELECT count(*)" + fromAndWhere);
|
||||
for (Map.Entry<String, Object> e : params.entrySet()) {
|
||||
spec = spec.param(e.getKey(), e.getValue());
|
||||
}
|
||||
return spec.query(Long.class).single();
|
||||
}
|
||||
|
||||
private <T> List<T> page(
|
||||
String sql,
|
||||
Map<String, Object> params,
|
||||
int size,
|
||||
int offset,
|
||||
org.springframework.jdbc.core.RowMapper<T> mapper) {
|
||||
var spec = jdbcClient.sql(sql + " LIMIT :size OFFSET :offset");
|
||||
for (Map.Entry<String, Object> e : params.entrySet()) {
|
||||
spec = spec.param(e.getKey(), e.getValue());
|
||||
}
|
||||
return spec.param("size", size).param("offset", offset).query(mapper).list();
|
||||
}
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** 프로젝트 목록·상세와 그 하위 목록. */
|
||||
@Repository
|
||||
public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
|
||||
|
||||
private static final int SECTION_LIMIT = 10;
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final PublicJson json;
|
||||
|
||||
public JdbcPublicProjectQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.json = new PublicJson(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProjectListItemView> list() {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective,"
|
||||
+ " pr.next_step, p.updated_at FROM project pr"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
|
||||
+ " WHERE "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY pr.featured_order NULLS LAST, p.updated_at DESC")
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new ProjectListItemView(
|
||||
rs.getString("name"),
|
||||
rs.getString("slug"),
|
||||
"/projects/" + rs.getString("slug"),
|
||||
rs.getString("one_line_purpose"),
|
||||
rs.getString("phase"),
|
||||
rs.getString("current_objective"),
|
||||
rs.getString("next_step"),
|
||||
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at")))
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectDetailView> findBySlug(String slug) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT pr.id, pr.name, pr.slug, pr.one_line_purpose, pr.purpose_markdown,"
|
||||
+ " pr.boundary_markdown, pr.phase, pr.current_objective, pr.next_step,"
|
||||
+ " pr.system_overview_markdown, pr.technology_labels,"
|
||||
+ " p.navigation_path, p.updated_at FROM project pr"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
|
||||
+ " WHERE pr.slug = :slug AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("slug", slug)
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
UUID projectId = rs.getObject("id", UUID.class);
|
||||
PublishedProjectView project =
|
||||
new PublishedProjectView(
|
||||
rs.getString("name"),
|
||||
rs.getString("slug"),
|
||||
rs.getString("one_line_purpose"),
|
||||
rs.getString("purpose_markdown"),
|
||||
rs.getString("boundary_markdown"),
|
||||
rs.getString("phase"),
|
||||
rs.getString("current_objective"),
|
||||
rs.getString("next_step"),
|
||||
rs.getString("system_overview_markdown"),
|
||||
json.strings(rs.getString("technology_labels")),
|
||||
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"));
|
||||
return new ProjectDetailView(
|
||||
rs.getString("navigation_path"),
|
||||
true,
|
||||
project,
|
||||
featuredDecision(projectId),
|
||||
activeQuestion(projectId),
|
||||
selectedRecords(projectId));
|
||||
})
|
||||
.optional();
|
||||
}
|
||||
|
||||
private RelatedEntryView featuredDecision(UUID projectId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM project_decision d"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
|
||||
+ " WHERE d.project_id = :projectId AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY d.is_featured DESC, d.decided_at DESC NULLS LAST LIMIT 1")
|
||||
.param("projectId", projectId)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private RelatedEntryView activeQuestion(UUID projectId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM project_question_link l"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = l.question_id"
|
||||
+ " WHERE l.project_id = :projectId AND p.state_code <> 'RESOLVED'"
|
||||
+ " AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY p.updated_at DESC LIMIT 1")
|
||||
.param("projectId", projectId)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private List<RelatedEntryView> selectedRecords(UUID projectId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM public_resource_project_link l"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id"
|
||||
+ " WHERE l.project_id = :projectId AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY l.featured_order NULLS LAST, p.published_at DESC LIMIT :limit")
|
||||
.param("projectId", projectId)
|
||||
.param("limit", SECTION_LIMIT)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectDecisionPageView> decisions(ProjectDecisionPageQuery query) {
|
||||
return projectId(query.projectSlug())
|
||||
.map(
|
||||
projectId -> {
|
||||
// 계약의 status 필터. 총계와 목록이 반드시 같은 조건을 써야 마지막 페이지가 비어 보이지 않는다.
|
||||
String from =
|
||||
" FROM project_decision d"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
|
||||
+ " WHERE d.project_id = :projectId AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ (query.status() == null ? "" : " AND d.decision_status = :status");
|
||||
long total =
|
||||
bind(jdbcClient.sql("SELECT count(*)" + from), projectId, query.status())
|
||||
.query(Long.class)
|
||||
.single();
|
||||
List<ProjectDecisionItemView> items =
|
||||
bind(
|
||||
jdbcClient.sql(
|
||||
"SELECT d.id, d.statement, d.decision_status, d.rationale_markdown,"
|
||||
+ " d.decided_at, d.source_question_id, d.source_case_id"
|
||||
+ from
|
||||
+ " ORDER BY d.decided_at DESC NULLS LAST, d.id DESC"
|
||||
+ " LIMIT :size OFFSET :offset"),
|
||||
projectId,
|
||||
query.status())
|
||||
.param("size", query.page().size())
|
||||
.param("offset", query.page().offset())
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new ProjectDecisionItemView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("statement"),
|
||||
rs.getString("decision_status"),
|
||||
rs.getString("rationale_markdown"),
|
||||
JdbcPublicDocumentQueryAdapter.instant(rs, "decided_at"),
|
||||
publishedEntry(rs.getObject("source_question_id", UUID.class)),
|
||||
publishedEntry(rs.getObject("source_case_id", UUID.class))))
|
||||
.list();
|
||||
return new ProjectDecisionPageView(
|
||||
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
|
||||
});
|
||||
}
|
||||
|
||||
/** 지목된 원천이 비공개면 링크를 만들지 않는다 — 404 로 이어지는 링크를 내보내지 않는다. */
|
||||
private RelatedEntryView publishedEntry(UUID resourceId) {
|
||||
if (resourceId == null) {
|
||||
return null;
|
||||
}
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM public_resource_projection p"
|
||||
+ " WHERE p.resource_id = :id AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("id", resourceId)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectRecordPageView> records(ProjectRecordPageQuery query) {
|
||||
return projectId(query.projectSlug())
|
||||
.map(
|
||||
projectId -> {
|
||||
// 계약이 세는 record 는 CASE/REFERENCE/QUESTION 세 종류다. type 이 없으면 셋 다 센다.
|
||||
String from =
|
||||
" FROM public_resource_project_link l"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id"
|
||||
+ " WHERE l.project_id = :projectId"
|
||||
+ " AND p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION')"
|
||||
+ " AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ (query.type() == null ? "" : " AND p.resource_type = :type")
|
||||
+ (query.relation() == null ? "" : " AND l.relation_type = :relation");
|
||||
long total =
|
||||
bindRecord(jdbcClient.sql("SELECT count(*)" + from), projectId, query)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
List<RelatedEntryView> items =
|
||||
bindRecord(
|
||||
jdbcClient.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ from
|
||||
+ " ORDER BY p.published_at DESC, p.resource_id DESC"
|
||||
+ " LIMIT :size OFFSET :offset"),
|
||||
projectId,
|
||||
query)
|
||||
.param("size", query.page().size())
|
||||
.param("offset", query.page().offset())
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
return new ProjectRecordPageView(
|
||||
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectActivityPageView> activities(ProjectPageQuery query) {
|
||||
return projectId(query.projectSlug())
|
||||
.map(
|
||||
projectId -> {
|
||||
String from =
|
||||
" FROM project_activity a"
|
||||
+ " WHERE a.project_id = :projectId AND a.visibility = 'PUBLIC'";
|
||||
long total =
|
||||
jdbcClient
|
||||
.sql("SELECT count(*)" + from)
|
||||
.param("projectId", projectId)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
List<ProjectActivityItemView> items =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT a.activity_type, a.title, a.summary, a.occurred_at,"
|
||||
+ " a.related_resource_id"
|
||||
+ from
|
||||
+ " ORDER BY a.occurred_at DESC, a.id DESC"
|
||||
+ " LIMIT :size OFFSET :offset")
|
||||
.param("projectId", projectId)
|
||||
.param("size", query.page().size())
|
||||
.param("offset", query.page().offset())
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
RelatedEntryView related =
|
||||
publishedEntry(rs.getObject("related_resource_id", UUID.class));
|
||||
return new ProjectActivityItemView(
|
||||
rs.getString("activity_type"),
|
||||
rs.getString("title"),
|
||||
rs.getString("summary"),
|
||||
JdbcPublicDocumentQueryAdapter.instant(rs, "occurred_at"),
|
||||
related == null ? null : related.path());
|
||||
})
|
||||
.list();
|
||||
return new ProjectActivityPageView(
|
||||
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* optional 필터는 SQL 조각과 파라미터 바인딩을 함께 켜고 꺼야 한다. 조각만 빼고 바인딩을 남기면 JdbcClient 가 "쓰이지 않은 파라미터"로 실패하고,
|
||||
* 반대면 파라미터 미해결로 실패한다 — 총계와 목록 두 쿼리에서 같은 실수를 두 번 하지 않도록 한 곳에 모은다.
|
||||
*/
|
||||
private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bind(
|
||||
org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec,
|
||||
UUID projectId,
|
||||
String status) {
|
||||
spec = spec.param("projectId", projectId);
|
||||
return status == null ? spec : spec.param("status", status);
|
||||
}
|
||||
|
||||
private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bindRecord(
|
||||
org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec,
|
||||
UUID projectId,
|
||||
ProjectRecordPageQuery query) {
|
||||
spec = spec.param("projectId", projectId);
|
||||
if (query.type() != null) {
|
||||
spec = spec.param("type", query.type());
|
||||
}
|
||||
return query.relation() == null ? spec : spec.param("relation", query.relation());
|
||||
}
|
||||
|
||||
/** 공개된 프로젝트만 하위 목록을 연다 — 비공개 프로젝트의 결정 목록이 새어 나가면 안 된다. */
|
||||
private Optional<UUID> projectId(String slug) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT pr.id FROM project pr"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
|
||||
+ " WHERE pr.slug = :slug AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("slug", slug)
|
||||
.query(UUID.class)
|
||||
.optional();
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 릴리스 목록·상세.
|
||||
*
|
||||
* <p>릴리스는 {@code public_resource_projection} 을 거치지 않는다 — 설계상 Publication 파이프라인의 대상이 아니라 자체 {@code
|
||||
* workflow_status} 로 공개 여부를 정하는 기록이다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcPublicReleaseQueryAdapter implements PublicReleaseQueryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final PublicJson json;
|
||||
|
||||
public JdbcPublicReleaseQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.json = new PublicJson(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ReleaseListItemView> list() {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT version_label, title, summary, released_on, change_types FROM release"
|
||||
+ " WHERE workflow_status = 'PUBLISHED'"
|
||||
+ " ORDER BY released_on DESC NULLS LAST, version_label DESC")
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new ReleaseListItemView(
|
||||
rs.getString("version_label"),
|
||||
rs.getString("title"),
|
||||
rs.getString("summary"),
|
||||
rs.getDate("released_on") == null
|
||||
? null
|
||||
: rs.getDate("released_on").toLocalDate(),
|
||||
json.strings(rs.getString("change_types")),
|
||||
"/releases/" + rs.getString("version_label")))
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ReleaseDetailView> findByVersion(String version) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT version_label, title, summary, released_on, change_types, reason_markdown,"
|
||||
+ " changes_markdown, user_impact_markdown, implementation_impact_markdown,"
|
||||
+ " verification_markdown, known_limitations_markdown, related_resources"
|
||||
+ " FROM release WHERE version_label = :version AND workflow_status = 'PUBLISHED'")
|
||||
.param("version", version)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new ReleaseDetailView(
|
||||
rs.getString("version_label"),
|
||||
rs.getString("title"),
|
||||
rs.getString("summary"),
|
||||
rs.getDate("released_on") == null
|
||||
? null
|
||||
: rs.getDate("released_on").toLocalDate(),
|
||||
json.strings(rs.getString("change_types")),
|
||||
rs.getString("reason_markdown"),
|
||||
rs.getString("changes_markdown"),
|
||||
rs.getString("user_impact_markdown"),
|
||||
rs.getString("implementation_impact_markdown"),
|
||||
rs.getString("verification_markdown"),
|
||||
rs.getString("known_limitations_markdown"),
|
||||
relatedRecords(rs.getString("related_resources"))))
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code related_resources} 는 resource id 배열이다. 그중 <b>공개된 것만</b> 되살린다 — 릴리스가 지목한 기록이 비공개로 바뀌었을 수
|
||||
* 있고, 그 링크를 그대로 내보내면 404 로 이어진다.
|
||||
*/
|
||||
private List<dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView> relatedRecords(
|
||||
String relatedResourcesJson) {
|
||||
List<String> ids = json.strings(relatedResourcesJson);
|
||||
if (ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM public_resource_projection p"
|
||||
+ " WHERE p.resource_id::text IN (:ids) AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY p.published_at DESC")
|
||||
.param("ids", ids)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 공개 검색.
|
||||
*
|
||||
* <p>게시 시 만들어 둔 {@code search_text}(제목 + 요약 + 본문 평문)를 본다. 검색 때 본문을 다시 훑지 않는 이유는 그 평문이 게시 시점에 확정된
|
||||
* 값이기 때문이다 — 나중에 초안이 바뀌어도 공개 검색 결과는 공개된 내용을 따라야 한다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcPublicSearchQueryAdapter implements PublicSearchQueryPort {
|
||||
|
||||
/** 스니펫 길이. 너무 길면 목록이 읽히지 않고, 너무 짧으면 왜 걸렸는지 알 수 없다. */
|
||||
private static final int SNIPPET_LENGTH = 200;
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcPublicSearchQueryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResultPageView search(SearchQuery query) {
|
||||
String pattern = "%" + query.query().toLowerCase(Locale.ROOT) + "%";
|
||||
StringBuilder where =
|
||||
new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND lower(p.search_text) LIKE :pattern");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("pattern", pattern);
|
||||
if (query.type() != null) {
|
||||
where.append(" AND p.resource_type = :type");
|
||||
params.put("type", query.type());
|
||||
}
|
||||
if (query.topicSlug() != null) {
|
||||
where.append(" AND t.slug = :topicSlug");
|
||||
params.put("topicSlug", query.topicSlug());
|
||||
}
|
||||
|
||||
String joins =
|
||||
" FROM public_resource_projection p"
|
||||
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
|
||||
+ " LEFT JOIN public_resource_project_link l"
|
||||
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
|
||||
+ " AND l.relation_type = 'PRIMARY'"
|
||||
+ " LEFT JOIN project pr ON pr.id = l.project_id";
|
||||
|
||||
var countSpec = jdbcClient.sql("SELECT count(*)" + joins + where);
|
||||
for (Map.Entry<String, Object> e : params.entrySet()) {
|
||||
countSpec = countSpec.param(e.getKey(), e.getValue());
|
||||
}
|
||||
long total = countSpec.query(Long.class).single();
|
||||
|
||||
var spec =
|
||||
jdbcClient.sql(
|
||||
"SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.body_plain_text,"
|
||||
+ " p.published_at, p.updated_at,"
|
||||
+ " t.name AS topic_name, t.slug AS topic_slug,"
|
||||
+ " pr.name AS project_name, pr.slug AS project_slug"
|
||||
+ joins
|
||||
+ where
|
||||
+ " ORDER BY p.published_at DESC, p.resource_id DESC"
|
||||
+ " LIMIT :size OFFSET :offset");
|
||||
for (Map.Entry<String, Object> e : params.entrySet()) {
|
||||
spec = spec.param(e.getKey(), e.getValue());
|
||||
}
|
||||
List<SearchResultItemView> items =
|
||||
spec.param("size", query.page().size())
|
||||
.param("offset", query.page().offset())
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new SearchResultItemView(
|
||||
rs.getString("resource_type"),
|
||||
rs.getString("title"),
|
||||
rs.getString("navigation_path"),
|
||||
snippet(
|
||||
rs.getString("body_plain_text"),
|
||||
rs.getString("summary"),
|
||||
query.query()),
|
||||
matchedFields(
|
||||
query.query(),
|
||||
rs.getString("title"),
|
||||
rs.getString("summary"),
|
||||
rs.getString("body_plain_text")),
|
||||
rs.getString("topic_slug") == null
|
||||
? null
|
||||
: new TopicSummaryView(
|
||||
rs.getString("topic_name"), rs.getString("topic_slug")),
|
||||
rs.getString("project_slug") == null
|
||||
? null
|
||||
: new ProjectSummaryView(
|
||||
rs.getString("project_name"),
|
||||
rs.getString("project_slug"),
|
||||
"/projects/" + rs.getString("project_slug")),
|
||||
rs.getTimestamp("published_at").toInstant(),
|
||||
rs.getTimestamp("updated_at").toInstant()))
|
||||
.list();
|
||||
|
||||
return new SearchResultPageView(
|
||||
query.query(), items, PageMetadataView.of(query.page().page(), query.page().size(), total));
|
||||
}
|
||||
|
||||
/** 검색어가 나온 자리를 중심으로 잘라 준다. 없으면 요약을 쓴다. */
|
||||
private static String snippet(String body, String summary, String term) {
|
||||
String source = (body == null || body.isBlank()) ? summary : body;
|
||||
if (source == null || source.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
int at = source.toLowerCase(Locale.ROOT).indexOf(term.toLowerCase(Locale.ROOT));
|
||||
if (at < 0) {
|
||||
return source.length() <= SNIPPET_LENGTH ? source : source.substring(0, SNIPPET_LENGTH);
|
||||
}
|
||||
int from = Math.max(0, at - SNIPPET_LENGTH / 2);
|
||||
int to = Math.min(source.length(), from + SNIPPET_LENGTH);
|
||||
return source.substring(from, to);
|
||||
}
|
||||
|
||||
/** 어느 필드에서 걸렸는지. 사용자가 왜 이 결과가 나왔는지 알 수 있어야 한다. */
|
||||
private static List<String> matchedFields(
|
||||
String term, String title, String summary, String body) {
|
||||
String needle = term.toLowerCase(Locale.ROOT);
|
||||
List<String> fields = new ArrayList<>();
|
||||
if (title != null && title.toLowerCase(Locale.ROOT).contains(needle)) {
|
||||
fields.add("title");
|
||||
}
|
||||
if (summary != null && summary.toLowerCase(Locale.ROOT).contains(needle)) {
|
||||
fields.add("summary");
|
||||
}
|
||||
if (body != null && body.toLowerCase(Locale.ROOT).contains(needle)) {
|
||||
fields.add("content");
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** 사이트 · 홈 · 프로필. 셋 다 단일 행 테이블이 원천이다. */
|
||||
@Repository
|
||||
public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
private final PublicJson json;
|
||||
|
||||
public JdbcPublicSiteQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
this.json = new PublicJson(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<SiteView> site() {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT s.brand_title, s.identity_statement, s.operator_display_name,"
|
||||
+ " s.short_identity, s.contacts, s.avatar_asset_id,"
|
||||
+ " a.content_type, a.alt_text, a.width, a.height"
|
||||
+ " FROM site_config s LEFT JOIN asset a ON a.id = s.avatar_asset_id")
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new SiteView(
|
||||
rs.getString("brand_title"),
|
||||
rs.getString("identity_statement"),
|
||||
rs.getString("operator_display_name"),
|
||||
rs.getString("short_identity"),
|
||||
avatar(rs),
|
||||
"/profile",
|
||||
json.contacts(rs.getString("contacts"))))
|
||||
.optional();
|
||||
}
|
||||
|
||||
private static AssetReferenceView avatar(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
UUID assetId = rs.getObject("avatar_asset_id", UUID.class);
|
||||
if (assetId == null) {
|
||||
return null;
|
||||
}
|
||||
return new AssetReferenceView(
|
||||
assetId,
|
||||
// 본문과 마찬가지로 저장소 경로가 아니라 안정적인 전송 경로를 노출한다(설계 05장 §3.1).
|
||||
"/media/" + assetId,
|
||||
rs.getString("alt_text"),
|
||||
(Integer) rs.getObject("width"),
|
||||
(Integer) rs.getObject("height"),
|
||||
rs.getString("content_type"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HomeView home(int latestEntryLimit) {
|
||||
HomeFocusView focus =
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT default_focus_type, current_project_id, open_question_id,"
|
||||
+ " recent_decision_id FROM home_focus_config")
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
HomeFocusView.resolve(
|
||||
rs.getString("default_focus_type"),
|
||||
currentWork(rs.getObject("current_project_id", UUID.class)),
|
||||
openQuestion(rs.getObject("open_question_id", UUID.class)),
|
||||
recentDecision(rs.getObject("recent_decision_id", UUID.class))))
|
||||
.optional()
|
||||
.orElseGet(() -> HomeFocusView.resolve(null, null, null, null));
|
||||
return new HomeView(focus, latestEntries(latestEntryLimit));
|
||||
}
|
||||
|
||||
/** 지목한 프로젝트가 지워졌거나 비공개면 focus 는 비운다 — 없는 것을 억지로 채우지 않는다. */
|
||||
private HomeFocusView.CurrentWork currentWork(UUID projectId) {
|
||||
if (projectId == null) {
|
||||
return null;
|
||||
}
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective,"
|
||||
+ " pr.next_step, pr.updated_at FROM project pr"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
|
||||
+ " WHERE pr.id = :id AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("id", projectId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new HomeFocusView.CurrentWork(
|
||||
rs.getString("name"),
|
||||
"/projects/" + rs.getString("slug"),
|
||||
rs.getString("one_line_purpose"),
|
||||
rs.getString("phase"),
|
||||
rs.getString("current_objective"),
|
||||
rs.getString("next_step"),
|
||||
rs.getTimestamp("updated_at").toInstant()))
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private HomeFocusView.OpenQuestion openQuestion(UUID questionId) {
|
||||
if (questionId == null) {
|
||||
return null;
|
||||
}
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT q.id, q.question, q.slug, q.summary, q.next_verification, q.updated_at"
|
||||
+ " FROM open_question q"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
|
||||
+ " WHERE q.id = :id AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("id", questionId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new HomeFocusView.OpenQuestion(
|
||||
rs.getString("question"),
|
||||
"/questions/" + rs.getString("slug"),
|
||||
rs.getString("summary"),
|
||||
points(questionId, "FACT"),
|
||||
points(questionId, "UNKNOWN"),
|
||||
rs.getString("next_verification"),
|
||||
rs.getTimestamp("updated_at").toInstant()))
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private List<String> points(UUID questionId, String pointKind) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind"
|
||||
+ " ORDER BY display_order")
|
||||
.param("id", questionId)
|
||||
.param("kind", pointKind)
|
||||
.query(String.class)
|
||||
.list();
|
||||
}
|
||||
|
||||
private HomeFocusView.RecentDecision recentDecision(UUID decisionId) {
|
||||
if (decisionId == null) {
|
||||
return null;
|
||||
}
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT d.statement, d.slug, d.rationale_markdown, d.consequences, d.decided_at,"
|
||||
+ " pr.slug AS project_slug FROM project_decision d"
|
||||
+ " LEFT JOIN project pr ON pr.id = d.project_id"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
|
||||
+ " WHERE d.id = :id AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("id", decisionId)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new HomeFocusView.RecentDecision(
|
||||
rs.getString("statement"),
|
||||
PublicSql.pathOf(
|
||||
"PROJECT_DECISION", rs.getString("slug"), rs.getString("project_slug")),
|
||||
rs.getString("rationale_markdown"),
|
||||
json.strings(rs.getString("consequences")),
|
||||
rs.getTimestamp("decided_at") == null
|
||||
? null
|
||||
: rs.getTimestamp("decided_at").toInstant()))
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
|
||||
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
|
||||
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
|
||||
*
|
||||
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
|
||||
* workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
|
||||
* 계약은 그 값을 <b>허용</b>할 뿐 매번 포함하라고 요구하지 않는다.
|
||||
*/
|
||||
private List<LatestEntryView> latestEntries(int limit) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at,"
|
||||
+ " t.name AS topic_name, t.slug AS topic_slug,"
|
||||
+ " pr.name AS project_name, pr.slug AS project_slug"
|
||||
+ " FROM public_resource_projection p"
|
||||
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
|
||||
+ " LEFT JOIN public_resource_project_link l"
|
||||
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
|
||||
+ " AND l.relation_type = 'PRIMARY'"
|
||||
+ " LEFT JOIN project pr ON pr.id = l.project_id"
|
||||
+ " WHERE "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " AND "
|
||||
+ PublicSql.LATEST_ENTRY_TYPES
|
||||
+ " ORDER BY p.published_at DESC LIMIT :limit")
|
||||
.param("limit", limit)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new LatestEntryView(
|
||||
rs.getString("resource_type"),
|
||||
rs.getString("title"),
|
||||
rs.getString("summary"),
|
||||
rs.getString("navigation_path"),
|
||||
rs.getString("topic_slug") == null
|
||||
? null
|
||||
: new TopicSummaryView(
|
||||
rs.getString("topic_name"), rs.getString("topic_slug")),
|
||||
rs.getString("project_slug") == null
|
||||
? null
|
||||
: new ProjectSummaryView(
|
||||
rs.getString("project_name"),
|
||||
rs.getString("project_slug"),
|
||||
"/projects/" + rs.getString("project_slug")),
|
||||
rs.getTimestamp("published_at").toInstant()))
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProfileView> profile() {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT headline, introduction_markdown, working_model, territories,"
|
||||
+ " selected_evidence, trajectory, contacts FROM profile_page"
|
||||
+ " WHERE target_visibility = 'PUBLIC'")
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new ProfileView(
|
||||
rs.getString("headline"),
|
||||
rs.getString("introduction_markdown"),
|
||||
json.namedDescriptions(rs.getString("working_model")),
|
||||
json.territories(rs.getString("territories")),
|
||||
selectedEvidence(rs.getString("selected_evidence")),
|
||||
json.namedDescriptions(rs.getString("trajectory")),
|
||||
json.contacts(rs.getString("contacts"))))
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code selected_evidence} 는 resource id 배열이다. 그중 <b>공개된 것만</b> 되살린다 — 프로필이 지목한 기록이 비공개로 바뀌었을 수
|
||||
* 있고, 그 링크를 그대로 내보내면 404 로 이어진다({@code JdbcPublicReleaseQueryAdapter} 의 {@code related_resources}
|
||||
* 와 같은 규칙).
|
||||
*/
|
||||
private List<RelatedEntryView> selectedEvidence(String selectedEvidenceJson) {
|
||||
List<String> ids = json.strings(selectedEvidenceJson);
|
||||
if (ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM public_resource_projection p"
|
||||
+ " WHERE p.resource_id::text IN (:ids) AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY p.published_at DESC")
|
||||
.param("ids", ids)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 주제 목록·상세. 개수와 목록 모두 공개된 것만 센다. */
|
||||
@Repository
|
||||
public class JdbcPublicTopicQueryAdapter implements PublicTopicQueryPort {
|
||||
|
||||
/** 상세 화면이 한 화면에 담는 개수. */
|
||||
private static final int SECTION_LIMIT = 10;
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcPublicTopicQueryAdapter(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TopicListItemView> list() {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT t.name, t.slug, t.description,"
|
||||
+ " (SELECT count(*) FROM public_resource_projection p"
|
||||
+ " WHERE p.primary_topic_id = t.id AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ ") AS record_count"
|
||||
+ " FROM topic t WHERE t.status = 'ACTIVE' ORDER BY t.name")
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new TopicListItemView(
|
||||
rs.getString("name"),
|
||||
rs.getString("slug"),
|
||||
rs.getString("description"),
|
||||
rs.getInt("record_count")))
|
||||
.list();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TopicDetailView> findBySlug(String slug) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT id, name, slug, description, scope FROM topic WHERE slug = :slug AND status = 'ACTIVE'")
|
||||
.param("slug", slug)
|
||||
.query(
|
||||
(rs, rowNum) -> {
|
||||
UUID topicId = rs.getObject("id", UUID.class);
|
||||
return new TopicDetailView(
|
||||
rs.getString("name"),
|
||||
rs.getString("slug"),
|
||||
rs.getString("description"),
|
||||
rs.getString("scope"),
|
||||
featured(topicId, "START_HERE").stream().findFirst().orElse(null),
|
||||
featured(topicId, "FEATURED_CASE"),
|
||||
activeQuestions(topicId),
|
||||
relatedProjects(topicId),
|
||||
latestRecords(topicId));
|
||||
})
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code topic_featured_document} 가 지목한 문서 중 <b>공개된 것만</b> 보여준다 — 지목은 Studio 의 편집 행위이고 공개 여부와
|
||||
* 별개다.
|
||||
*/
|
||||
private List<RelatedEntryView> featured(UUID topicId, String role) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM topic_featured_document f"
|
||||
+ " JOIN public_resource_projection p ON p.resource_id = f.document_id"
|
||||
+ " WHERE f.topic_id = :topicId AND f.feature_role = :role AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY f.display_order")
|
||||
.param("topicId", topicId)
|
||||
.param("role", role)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
}
|
||||
|
||||
private List<RelatedEntryView> activeQuestions(UUID topicId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM public_resource_projection p"
|
||||
+ " WHERE p.resource_type = 'QUESTION' AND p.primary_topic_id = :topicId"
|
||||
+ " AND p.state_code <> 'RESOLVED' AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY p.updated_at DESC LIMIT :limit")
|
||||
.param("topicId", topicId)
|
||||
.param("limit", SECTION_LIMIT)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
}
|
||||
|
||||
private List<RelatedEntryView> relatedProjects(UUID topicId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM project_topic pt"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pt.project_id"
|
||||
+ " WHERE pt.topic_id = :topicId AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY pt.display_order")
|
||||
.param("topicId", topicId)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
|
||||
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
|
||||
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
|
||||
*
|
||||
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
|
||||
* workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
|
||||
* 계약은 그 값을 <b>허용</b>할 뿐 매번 포함하라고 요구하지 않는다.
|
||||
*/
|
||||
private List<LatestEntryView> latestRecords(UUID topicId) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at,"
|
||||
+ " t.name AS topic_name, t.slug AS topic_slug,"
|
||||
+ " pr.name AS project_name, pr.slug AS project_slug"
|
||||
+ " FROM public_resource_projection p"
|
||||
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
|
||||
+ " LEFT JOIN public_resource_project_link l"
|
||||
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
|
||||
+ " AND l.relation_type = 'PRIMARY'"
|
||||
+ " LEFT JOIN project pr ON pr.id = l.project_id"
|
||||
+ " WHERE p.primary_topic_id = :topicId AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " AND "
|
||||
+ PublicSql.LATEST_ENTRY_TYPES
|
||||
+ " ORDER BY p.published_at DESC LIMIT :limit")
|
||||
.param("topicId", topicId)
|
||||
.param("limit", SECTION_LIMIT)
|
||||
.query(
|
||||
(rs, rowNum) ->
|
||||
new LatestEntryView(
|
||||
rs.getString("resource_type"),
|
||||
rs.getString("title"),
|
||||
rs.getString("summary"),
|
||||
rs.getString("navigation_path"),
|
||||
rs.getString("topic_slug") == null
|
||||
? null
|
||||
: new TopicSummaryView(
|
||||
rs.getString("topic_name"), rs.getString("topic_slug")),
|
||||
rs.getString("project_slug") == null
|
||||
? null
|
||||
: new ProjectSummaryView(
|
||||
rs.getString("project_name"),
|
||||
rs.getString("project_slug"),
|
||||
"/projects/" + rs.getString("project_slug")),
|
||||
rs.getTimestamp("published_at").toInstant()))
|
||||
.list();
|
||||
}
|
||||
|
||||
static RelatedEntryView relatedEntry(java.sql.ResultSet rs, int rowNum)
|
||||
throws java.sql.SQLException {
|
||||
return new RelatedEntryView(
|
||||
rs.getString("resource_type"),
|
||||
rs.getString("title"),
|
||||
rs.getString("summary"),
|
||||
rs.getString("navigation_path"));
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
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.shared.error.MappingException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 공개 조회가 읽는 jsonb 컬럼을 푼다.
|
||||
*
|
||||
* <p>Jackson 의 POJO 바인딩을 쓰지 않고 key 를 명시적으로 읽는다 — 이 값들은 DB 에 영속된 모양이라 application record 의 필드 이름이
|
||||
* 바뀌면 이미 저장된 행을 못 읽게 된다.
|
||||
*/
|
||||
final class PublicJson {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
PublicJson(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
List<String> strings(String json) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (JsonNode node : array(json)) {
|
||||
// 설계의 배열 컬럼은 문자열이거나 {text: ...} 모양일 수 있다. 둘 다 받는다.
|
||||
out.add(node.isString() ? node.asString("") : node.path("text").asString(node.toString()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<ContactLinkView> contacts(String json) {
|
||||
List<ContactLinkView> out = new ArrayList<>();
|
||||
for (JsonNode node : array(json)) {
|
||||
out.add(
|
||||
new ContactLinkView(
|
||||
node.path("type").asString(""),
|
||||
node.path("label").asString(""),
|
||||
node.path("url").asString("")));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<ProfileView.NamedDescription> namedDescriptions(String json) {
|
||||
List<ProfileView.NamedDescription> out = new ArrayList<>();
|
||||
for (JsonNode node : array(json)) {
|
||||
out.add(
|
||||
new ProfileView.NamedDescription(
|
||||
node.path("name").asString(node.path("title").asString("")),
|
||||
node.path("description").asString("")));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<ProfileView.Territory> territories(String json) {
|
||||
List<ProfileView.Territory> out = new ArrayList<>();
|
||||
for (JsonNode node : array(json)) {
|
||||
out.add(
|
||||
new ProfileView.Territory(
|
||||
node.path("name").asString(""),
|
||||
node.path("currentQuestion").asString(null),
|
||||
node.path("topicPath").asString(null)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Iterable<JsonNode> array(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
JsonNode node = mapper.readTree(json);
|
||||
return node.isArray() ? node : List.of();
|
||||
} catch (JacksonException e) {
|
||||
throw new MappingException("failed to read a public jsonb column", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
/**
|
||||
* 공개 상세가 보여주는 관계.
|
||||
*
|
||||
* <p><b>어디서 읽는지가 중요하다.</b> 설계 스키마에는 유형별 링크 테이블({@code document_relation}, {@code
|
||||
* question_document_link})이 있지만 <b>그 테이블들에 쓰는 경로가 없다</b> — Studio 편집기가 만드는 관계는 전부 {@code
|
||||
* studio_relation} 에 들어간다(계약의 relations[] 가 네 유형 공통이라 그렇게 설계했다). 그래서 공개도 같은 곳에서 읽는다. 링크 테이블을 읽으면
|
||||
* 관계가 항상 비어 보인다.
|
||||
*
|
||||
* <p>관계의 종류는 저장돼 있지 않으므로 <b>대상의 유형</b>으로 나눈다 — 계약이 관계를 유형별 묶음 (relatedCases / derivedReferences /
|
||||
* projectDecisions / originQuestion)으로 요구하기 때문이다. 공개되지 않은 대상은 제외한다.
|
||||
*/
|
||||
final class PublicRelationLookup {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
PublicRelationLookup(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
/** {@code sourceKind} 문서가 가리키는 관계 중 대상이 {@code targetType} 이고 공개된 것들. */
|
||||
List<RelatedEntryView> targetsOfType(String sourceKind, UUID sourceId, String targetType) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM studio_relation r"
|
||||
+ " JOIN public_resource_projection p ON p.resource_id = r.target_id"
|
||||
+ " WHERE r.source_kind = :sourceKind AND r.source_id = :sourceId"
|
||||
+ " AND p.resource_type = :targetType AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY r.display_order")
|
||||
.param("sourceKind", sourceKind)
|
||||
.param("sourceId", sourceId)
|
||||
.param("targetType", targetType)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
}
|
||||
|
||||
/** 같은 조회의 단수형. 계약이 하나만 받는 자리(originQuestion 등)에 쓴다. */
|
||||
RelatedEntryView firstTargetOfType(String sourceKind, UUID sourceId, String targetType) {
|
||||
return targetsOfType(sourceKind, sourceId, targetType).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/** 이 기록을 가리키는 <b>역방향</b> 관계. "이 Reference 를 적용한 Case" 같은 자리에 쓴다. */
|
||||
List<RelatedEntryView> sourcesOfType(UUID targetId, String sourceType) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM studio_relation r"
|
||||
+ " JOIN public_resource_projection p ON p.resource_id = r.source_id"
|
||||
+ " WHERE r.target_id = :targetId AND p.resource_type = :sourceType"
|
||||
+ " AND "
|
||||
+ PublicSql.ACTIVE
|
||||
+ " ORDER BY p.published_at DESC")
|
||||
.param("targetId", targetId)
|
||||
.param("sourceType", sourceType)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.list();
|
||||
}
|
||||
|
||||
/** 이 기록이 속한 프로젝트. {@code project_*_link} 의 PRIMARY 를 따른다. */
|
||||
RelatedEntryView primaryProject(String linkTable, String idColumn, UUID id) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
|
||||
+ " FROM "
|
||||
+ linkTable
|
||||
+ " l"
|
||||
+ " JOIN public_resource_projection p"
|
||||
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = l.project_id"
|
||||
+ " WHERE l."
|
||||
+ idColumn
|
||||
+ " = :id AND l.relation_type = 'PRIMARY'"
|
||||
+ " AND "
|
||||
+ PublicSql.ACTIVE)
|
||||
.param("id", id)
|
||||
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
/**
|
||||
* 공개 조회가 공유하는 SQL 조각.
|
||||
*
|
||||
* <p>"무엇이 공개인가"의 정의를 한 곳에 둔다. 각 쿼리가 조건을 따로 쓰면 어느 하나가 {@code publication_state} 를 빠뜨려도 드러나지 않고, 그
|
||||
* 결과는 게시 취소한 문서가 계속 보이는 사고다.
|
||||
*/
|
||||
final class PublicSql {
|
||||
|
||||
/** 공개 노출 조건. 게시 취소({@code WITHDRAWN})와 비공개({@code UNLISTED})를 함께 배제한다. */
|
||||
static final String ACTIVE = " p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
|
||||
|
||||
/**
|
||||
* 계약 {@code LatestEntry.entryType} 이 허용하는 값 중 이 projection 에 실제로 담기는 것들. 홈과 주제 상세가 같은 목록 의미를 쓰므로
|
||||
* 조건도 한 곳에서 정의한다.
|
||||
*/
|
||||
static final String LATEST_ENTRY_TYPES =
|
||||
" p.resource_type IN ('CASE', 'REFERENCE', 'PROJECT_ACTIVITY') ";
|
||||
|
||||
private PublicSql() {}
|
||||
|
||||
/** 유형별 공개 경로. 게시 시 {@code navigation_path} 에 저장된 값을 그대로 쓴다. */
|
||||
static String pathOf(String resourceType, String slug, String projectSlug) {
|
||||
return switch (resourceType) {
|
||||
case "CASE" -> "/cases/" + slug;
|
||||
case "REFERENCE" -> "/references/" + slug;
|
||||
case "QUESTION" -> "/questions/" + slug;
|
||||
case "PROJECT" -> "/projects/" + slug;
|
||||
case "PROJECT_DECISION" ->
|
||||
projectSlug == null ? null : "/projects/" + projectSlug + "/decisions/" + slug;
|
||||
case "RELEASE" -> "/releases/" + slug;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
-- public-v1 계약이 요구하는 나머지 테이블.
|
||||
--
|
||||
-- 원본: tech-log-design-package/database/V1__init.sql (설계 패키지 커밋 55a9599 기준)
|
||||
--
|
||||
-- V7 이 이 여섯을 제외하며 남긴 이유는 "이번 범위 밖(spec §2.2)" 이었다. 그 §2.2 가
|
||||
-- 미룬 것이 바로 public-v1 이고, 여섯 테이블은 전부 public-v1 전용이다.
|
||||
--
|
||||
-- release -> listPublicReleases / getPublicRelease
|
||||
-- site_config -> getPublicSite
|
||||
-- profile_page -> getPublicProfile
|
||||
-- home_focus_config -> getPublicHome (focus)
|
||||
-- project_topic -> getPublicTopic (relatedProjects)
|
||||
-- topic_featured_document -> getPublicTopic (featuredReference / featuredCases)
|
||||
--
|
||||
-- 원본 DDL 을 그대로 옮긴다. V7 이 tech_log 전용 스키마를 쓰지 않고 public 스키마에
|
||||
-- 만들기로 한 결정만 이어받는다(원본의 CREATE SCHEMA / SET search_path 는 V7 이 이미 제외했다).
|
||||
--
|
||||
-- 시딩 INSERT 3건도 원본 그대로 가져온다. site_config / profile_page /
|
||||
-- home_focus_config 는 단일 행 테이블이고(PK 가 고정 UUID 로 CHECK 되어 있다) 그 행이
|
||||
-- 없으면 getPublicSite / getPublicProfile / getPublicHome 이 줄 것이 없다. 이 세 값을
|
||||
-- 편집하는 API 는 studio-management-v1 이 소유하며 아직 구현 범위 밖이라, 지금은 이
|
||||
-- 시딩이 유일한 공급원이다.
|
||||
|
||||
|
||||
CREATE TABLE release (
|
||||
id uuid PRIMARY KEY,
|
||||
version_label varchar(32) NOT NULL,
|
||||
title varchar(180) NOT NULL,
|
||||
summary varchar(600) NOT NULL DEFAULT '',
|
||||
released_on date,
|
||||
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
|
||||
CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
|
||||
change_types jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(change_types) = 'array'),
|
||||
reason_markdown text NOT NULL DEFAULT '',
|
||||
changes_markdown text NOT NULL DEFAULT '',
|
||||
user_impact_markdown text NOT NULL DEFAULT '',
|
||||
implementation_impact_markdown text NOT NULL DEFAULT '',
|
||||
verification_markdown text NOT NULL DEFAULT '',
|
||||
known_limitations_markdown text NOT NULL DEFAULT '',
|
||||
related_resources jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(related_resources) = 'array'),
|
||||
first_published_at timestamptz,
|
||||
last_published_at timestamptz,
|
||||
version bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by varchar(255) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by varchar(255) NOT NULL,
|
||||
CONSTRAINT uq_release_version_label UNIQUE (version_label)
|
||||
);
|
||||
|
||||
CREATE TABLE site_config (
|
||||
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000001'::uuid),
|
||||
brand_title varchar(80) NOT NULL DEFAULT 'Tech Log',
|
||||
identity_statement varchar(600) NOT NULL DEFAULT '',
|
||||
operator_display_name varchar(80) NOT NULL DEFAULT '',
|
||||
short_identity varchar(120),
|
||||
avatar_asset_id uuid REFERENCES asset(id),
|
||||
contacts jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(contacts) = 'array'),
|
||||
version bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by varchar(255) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by varchar(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE profile_page (
|
||||
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000002'::uuid),
|
||||
headline varchar(300) NOT NULL DEFAULT '',
|
||||
introduction_markdown text NOT NULL DEFAULT '',
|
||||
working_model jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(working_model) = 'array'),
|
||||
territories jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(territories) = 'array'),
|
||||
selected_evidence jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(selected_evidence) = 'array'),
|
||||
trajectory jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(trajectory) = 'array'),
|
||||
contacts jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(contacts) = 'array'),
|
||||
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
|
||||
CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')),
|
||||
first_published_at timestamptz,
|
||||
last_published_at timestamptz,
|
||||
version bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by varchar(255) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by varchar(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE home_focus_config (
|
||||
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000003'::uuid),
|
||||
default_focus_type varchar(30)
|
||||
CHECK (default_focus_type IS NULL OR default_focus_type IN (
|
||||
'CURRENT_WORK', 'OPEN_QUESTION', 'RECENT_DECISION'
|
||||
)),
|
||||
current_project_id uuid REFERENCES project(id),
|
||||
open_question_id uuid REFERENCES open_question(id),
|
||||
recent_decision_id uuid REFERENCES project_decision(id),
|
||||
version bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by varchar(255) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by varchar(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE project_topic (
|
||||
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
topic_id uuid NOT NULL REFERENCES topic(id),
|
||||
display_order integer NOT NULL CHECK (display_order >= 0),
|
||||
PRIMARY KEY (project_id, topic_id),
|
||||
CONSTRAINT uq_project_topic_order UNIQUE (project_id, display_order)
|
||||
);
|
||||
|
||||
CREATE TABLE topic_featured_document (
|
||||
topic_id uuid NOT NULL REFERENCES topic(id) ON DELETE CASCADE,
|
||||
document_id uuid NOT NULL REFERENCES document(id),
|
||||
feature_role varchar(30) NOT NULL
|
||||
CHECK (feature_role IN ('START_HERE', 'FEATURED_CASE')),
|
||||
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
|
||||
PRIMARY KEY (topic_id, document_id, feature_role)
|
||||
);
|
||||
|
||||
-- 한 Topic 의 START_HERE 는 하나뿐이다.
|
||||
CREATE UNIQUE INDEX uq_topic_start_here
|
||||
ON topic_featured_document(topic_id)
|
||||
WHERE feature_role = 'START_HERE';
|
||||
|
||||
-- 단일 행 시딩. 이미 있으면 건드리지 않는다.
|
||||
|
||||
INSERT INTO site_config (
|
||||
id,
|
||||
brand_title,
|
||||
identity_statement,
|
||||
operator_display_name,
|
||||
short_identity,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'Tech Log',
|
||||
'문제를 재현하고 검증하여 운영 가능한 시스템 설계로 연결합니다.',
|
||||
'동현',
|
||||
'Backend · Platform',
|
||||
'system:migration',
|
||||
'system:migration'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO profile_page (
|
||||
id,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000002'::uuid,
|
||||
'system:migration',
|
||||
'system:migration'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO home_focus_config (
|
||||
id,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000003'::uuid,
|
||||
'system:migration',
|
||||
'system:migration'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
+965
@@ -0,0 +1,965 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 공개 조회 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
|
||||
*
|
||||
* <p>{@code StudioPersistenceIntegrationTest} 와 같은 이유로 존재한다 — 이 저장소의 표준 {@code check} 는
|
||||
* Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이 테스트 없이는 <b>한 번도 실행되지 않은 채</b> 통과한다. 컴파일도 단위 테스트도
|
||||
* 컬럼 이름 오타, jsonb 캐스팅, {@code EXISTS} 서브쿼리의 상관 조건을 검증하지 못한다.
|
||||
*
|
||||
* <p>특히 두 가지를 겨냥한다.
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>공개 조건</b>({@code PublicSql#ACTIVE}) 이 모든 경로에 걸려 있는가 — 게시 취소({@code WITHDRAWN})나
|
||||
* 비공개({@code UNLISTED}) 자료가 어느 한 쿼리에서라도 새면 사고다. 그래서 모든 목록/상세 테스트에 "새면 안 되는 행"을 함께 심는다.
|
||||
* <li><b>총계와 목록이 같은 조건을 쓰는가</b> — 페이지네이션이 있는 여섯 operation 은 count 쿼리와 목록 쿼리를 따로 만든다. 조건이 갈라지면 마지막
|
||||
* 페이지가 비어 보이거나 없는 페이지 번호가 생긴다.
|
||||
* </ol>
|
||||
*/
|
||||
class PublicSitePersistenceIntegrationTest {
|
||||
|
||||
private static final String IMAGE =
|
||||
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
|
||||
|
||||
private static final UUID SITE_CONFIG_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID PROFILE_PAGE_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
private static final UUID HOME_FOCUS_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||
|
||||
private static PostgreSQLContainer postgres;
|
||||
private static HikariDataSource dataSource;
|
||||
private static JdbcClient jdbcClient;
|
||||
|
||||
private static JdbcPublicSiteQueryAdapter site;
|
||||
private static JdbcPublicExploreQueryAdapter explore;
|
||||
private static JdbcPublicTopicQueryAdapter topics;
|
||||
private static JdbcPublicDocumentQueryAdapter documents;
|
||||
private static JdbcPublicProjectQueryAdapter projects;
|
||||
private static JdbcPublicReleaseQueryAdapter releases;
|
||||
private static JdbcPublicSearchQueryAdapter search;
|
||||
|
||||
private static UUID topicId;
|
||||
private static UUID projectId;
|
||||
private static UUID tagId;
|
||||
private static UUID caseId;
|
||||
private static UUID referenceId;
|
||||
private static UUID questionId;
|
||||
private static UUID decisionId;
|
||||
private static UUID hiddenCaseId;
|
||||
|
||||
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
|
||||
@BeforeAll
|
||||
static void migrateAndSeed() {
|
||||
if (!DockerClientFactory.instance().isDockerAvailable()) {
|
||||
throw new IllegalStateException(
|
||||
"Docker is required for the public-site persistence integration test;"
|
||||
+ " skipping is forbidden");
|
||||
}
|
||||
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
|
||||
postgres.start();
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(postgres.getJdbcUrl());
|
||||
config.setUsername(postgres.getUsername());
|
||||
config.setPassword(postgres.getPassword());
|
||||
config.setMaximumPoolSize(5);
|
||||
config.setMinimumIdle(1);
|
||||
dataSource = new HikariDataSource(config);
|
||||
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration/postgresql")
|
||||
.baselineOnMigrate(false)
|
||||
.outOfOrder(false)
|
||||
.load()
|
||||
.migrate();
|
||||
|
||||
jdbcClient = JdbcClient.create(dataSource);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
site = new JdbcPublicSiteQueryAdapter(jdbcClient, objectMapper);
|
||||
explore = new JdbcPublicExploreQueryAdapter(jdbcClient);
|
||||
topics = new JdbcPublicTopicQueryAdapter(jdbcClient);
|
||||
documents = new JdbcPublicDocumentQueryAdapter(jdbcClient, objectMapper);
|
||||
projects = new JdbcPublicProjectQueryAdapter(jdbcClient, objectMapper);
|
||||
releases = new JdbcPublicReleaseQueryAdapter(jdbcClient, objectMapper);
|
||||
search = new JdbcPublicSearchQueryAdapter(jdbcClient);
|
||||
|
||||
seed();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopPostgreSql() {
|
||||
if (dataSource != null) {
|
||||
dataSource.close();
|
||||
}
|
||||
if (postgres != null) {
|
||||
postgres.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- V9 스키마
|
||||
|
||||
@Test
|
||||
void v9CreatesEveryTableThePublicContractReads() {
|
||||
assertThat(tableExists("release")).isTrue();
|
||||
assertThat(tableExists("site_config")).isTrue();
|
||||
assertThat(tableExists("profile_page")).isTrue();
|
||||
assertThat(tableExists("home_focus_config")).isTrue();
|
||||
assertThat(tableExists("project_topic")).isTrue();
|
||||
assertThat(tableExists("topic_featured_document")).isTrue();
|
||||
}
|
||||
|
||||
/** 한 Topic 의 {@code START_HERE} 는 하나뿐이라는 부분 유니크 인덱스가 실제로 강제되는지. */
|
||||
@Test
|
||||
void aTopicCanOnlyHaveOneStartHereDocument() {
|
||||
UUID scratchTopic = insertTopic("start-here-probe", "Start Here Probe");
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
|
||||
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
|
||||
.param("t", scratchTopic)
|
||||
.param("d", referenceId)
|
||||
.update();
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(
|
||||
() ->
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
|
||||
+ " display_order) VALUES (:t, :d, 'START_HERE', 1)")
|
||||
.param("t", scratchTopic)
|
||||
.param("d", caseId)
|
||||
.update())
|
||||
.as("uq_topic_start_here 가 한 주제의 두 번째 START_HERE 를 막아야 한다")
|
||||
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
|
||||
|
||||
jdbcClient
|
||||
.sql("DELETE FROM topic_featured_document WHERE topic_id = :t")
|
||||
.param("t", scratchTopic)
|
||||
.update();
|
||||
jdbcClient.sql("DELETE FROM topic WHERE id = :id").param("id", scratchTopic).update();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 사이트 · 홈 · 프로필
|
||||
|
||||
@Test
|
||||
void siteReadsTheSingleRowConfigWithItsContacts() {
|
||||
SiteView view = site.site().orElseThrow();
|
||||
|
||||
assertThat(view.brandTitle()).isEqualTo("Tech Log");
|
||||
assertThat(view.operatorDisplayName()).isEqualTo("동현");
|
||||
assertThat(view.operatorProfilePath()).isEqualTo("/profile");
|
||||
assertThat(view.contacts()).hasSize(1);
|
||||
assertThat(view.contacts().getFirst().type()).isEqualTo("GITHUB");
|
||||
assertThat(view.contacts().getFirst().url()).isEqualTo("https://github.com/example");
|
||||
}
|
||||
|
||||
@Test
|
||||
void homeResolvesTheConfiguredFocusAndTheLatestEntries() {
|
||||
HomeView view = site.home(10);
|
||||
|
||||
assertThat(view.focus().defaultType()).isEqualTo("CURRENT_WORK");
|
||||
assertThat(view.focus().currentWork()).isNotNull();
|
||||
assertThat(view.focus().currentWork().projectPath()).isEqualTo("/projects/tech-log");
|
||||
assertThat(view.latestEntries()).isNotEmpty();
|
||||
assertThat(view.latestEntries())
|
||||
.as("게시 취소된 자료는 최신 목록에 없어야 한다")
|
||||
.noneMatch(entry -> entry.title().contains("숨김"));
|
||||
assertThat(view.latestEntries())
|
||||
.as(
|
||||
"계약 LatestEntry.entryType 은 네 값만 허용한다 — projection 의 QUESTION/PROJECT 등이 섞이면"
|
||||
+ " 응답 매퍼가 계약 밖 값을 만나 500 이 된다")
|
||||
.extracting("entryType")
|
||||
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
|
||||
.allSatisfy(
|
||||
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 갓 마이그레이션한 상태에서 {@code default_focus_type} 은 NULL 이다. 계약은 이 필드를 required 로 선언하고 값 셋만 허용하므로, NULL
|
||||
* 이 그대로 나가면 홈 화면 전체가 500 이 된다 — 실제 앱 기동 후 첫 요청에서 그렇게 깨졌다. 설정이 비어도 계약이 아는 값 하나로 정해져야 한다.
|
||||
*/
|
||||
@Test
|
||||
void homeFocusFallsBackToAContractValueWhenNothingIsConfigured() {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE home_focus_config SET default_focus_type = NULL,"
|
||||
+ " current_project_id = NULL, open_question_id = NULL,"
|
||||
+ " recent_decision_id = NULL WHERE id = :id")
|
||||
.param("id", HOME_FOCUS_ID)
|
||||
.update();
|
||||
try {
|
||||
HomeView view = site.home(10);
|
||||
assertThat(view.focus().defaultType())
|
||||
.isIn("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION");
|
||||
assertThat(view.focus().currentWork()).isNull();
|
||||
assertThat(view.focus().openQuestion()).isNull();
|
||||
assertThat(view.focus().recentDecision()).isNull();
|
||||
|
||||
// 설정은 비어 있지만 내용이 있는 갈래가 있으면 그쪽을 고른다.
|
||||
jdbcClient
|
||||
.sql("UPDATE home_focus_config SET open_question_id = :q WHERE id = :id")
|
||||
.param("q", questionId)
|
||||
.param("id", HOME_FOCUS_ID)
|
||||
.update();
|
||||
assertThat(site.home(10).focus().defaultType()).isEqualTo("OPEN_QUESTION");
|
||||
} finally {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
|
||||
+ " current_project_id = :project, open_question_id = :question,"
|
||||
+ " recent_decision_id = :decision WHERE id = :id")
|
||||
.param("id", HOME_FOCUS_ID)
|
||||
.param("project", projectId)
|
||||
.param("question", questionId)
|
||||
.param("decision", decisionId)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileReadsItsJsonbColumnsIntoTypedViews() {
|
||||
ProfileView view = site.profile().orElseThrow();
|
||||
|
||||
assertThat(view.headline()).isEqualTo("문제를 재현해 검증한다");
|
||||
assertThat(view.workingModel()).extracting(ProfileView.NamedDescription::name).contains("재현");
|
||||
assertThat(view.territories()).extracting(ProfileView.Territory::name).contains("Kafka");
|
||||
assertThat(view.contacts()).hasSize(1);
|
||||
assertThat(view.selectedEvidence())
|
||||
.as("selected_evidence 는 공개된 것만 되살린다")
|
||||
.extracting("title")
|
||||
.containsExactly("Kafka 재처리");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 탐색
|
||||
|
||||
@Test
|
||||
void knowledgeListsOnlyPublishedCasesAndReferences() {
|
||||
KnowledgePageView page =
|
||||
explore.knowledge(
|
||||
new ExploreKnowledgeQuery(null, null, null, null, null, null, page(1, 20)));
|
||||
|
||||
assertThat(page.items()).extracting("title").contains("Kafka 재처리", "Kafka 운영 기준");
|
||||
assertThat(page.items()).extracting("title").doesNotContain("숨김 Case");
|
||||
assertThat(page.page().totalElements())
|
||||
.as("총계와 목록이 같은 조건을 써야 한다")
|
||||
.isEqualTo(page.items().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void knowledgeAppliesEveryContractFilter() {
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery("CASE", null, null, null, null, null, page(1, 20)))
|
||||
.items())
|
||||
.extracting("type")
|
||||
.containsOnly("CASE");
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(null, "kafka", null, null, null, null, page(1, 20)))
|
||||
.items())
|
||||
.isNotEmpty();
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(
|
||||
null, "no-such-topic", null, null, null, null, page(1, 20)))
|
||||
.items())
|
||||
.isEmpty();
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(
|
||||
null, null, null, "reprocessing", null, null, page(1, 20)))
|
||||
.items())
|
||||
.as("tag 필터의 상관 EXISTS 서브쿼리")
|
||||
.extracting("title")
|
||||
.containsExactly("Kafka 재처리");
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(null, null, null, null, 1999, null, page(1, 20)))
|
||||
.items())
|
||||
.as("year 필터는 date_part 로 건다")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/** 계약의 정렬 세 값이 전부 유효한 SQL 이어야 한다 — 오타는 문법 오류로만 드러난다. */
|
||||
@Test
|
||||
void knowledgeAcceptsEveryContractSort() {
|
||||
for (String sort : List.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC")) {
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(null, null, null, null, null, sort, page(1, 20)))
|
||||
.items())
|
||||
.as("sort=%s", sort)
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void questionsListAppliesStatusTagAndEverySort() {
|
||||
QuestionPageView all =
|
||||
explore.questions(new ExploreQuestionsQuery(null, null, null, null, null, page(1, 20)));
|
||||
assertThat(all.items()).extracting("question").contains("재처리 지연을 어떻게 줄일까");
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.questions(
|
||||
new ExploreQuestionsQuery("RESOLVED", null, null, null, null, page(1, 20)))
|
||||
.items())
|
||||
.isEmpty();
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.questions(
|
||||
new ExploreQuestionsQuery(null, null, null, "reprocessing", null, page(1, 20)))
|
||||
.items())
|
||||
.as("질문에도 tag 필터가 걸려야 한다")
|
||||
.isNotEmpty();
|
||||
|
||||
for (String sort : List.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC")) {
|
||||
assertThat(
|
||||
explore
|
||||
.questions(new ExploreQuestionsQuery(null, null, null, null, sort, page(1, 20)))
|
||||
.items())
|
||||
.as("sort=%s", sort)
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 주제
|
||||
|
||||
@Test
|
||||
void topicListCountsOnlyPublishedRecords() {
|
||||
assertThat(topics.list()).extracting("slug").contains("kafka");
|
||||
|
||||
var kafka =
|
||||
topics.list().stream().filter(t -> t.slug().equals("kafka")).findFirst().orElseThrow();
|
||||
// Case · Reference · Question 셋만 이 주제를 primary 로 가지며, 게시 취소된 Case 는 세지 않는다.
|
||||
assertThat(kafka.recordCount()).as("게시 취소된 자료는 세지 않는다").isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void topicDetailResolvesEverySection() {
|
||||
TopicDetailView view = topics.findBySlug("kafka").orElseThrow();
|
||||
|
||||
assertThat(view.name()).isEqualTo("Kafka");
|
||||
assertThat(view.featuredReference()).isNotNull();
|
||||
assertThat(view.featuredReference().title()).isEqualTo("Kafka 운영 기준");
|
||||
assertThat(view.activeQuestions()).isNotEmpty();
|
||||
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
|
||||
assertThat(view.latestRecords()).isNotEmpty();
|
||||
assertThat(view.latestRecords())
|
||||
.as("주제 상세의 최신 기록도 계약의 entryType 네 값을 벗어나면 안 된다")
|
||||
.extracting("entryType")
|
||||
.allSatisfy(
|
||||
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void topicDetailIsAbsentForAnUnknownSlug() {
|
||||
assertThat(topics.findBySlug("no-such-topic")).isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 문서 상세
|
||||
|
||||
@Test
|
||||
void caseDetailReadsTheOriginalTableNotTheProjectionPayload() {
|
||||
CaseDetailView view = documents.findCase("kafka-reprocessing").orElseThrow();
|
||||
|
||||
assertThat(view.canonicalPath()).isEqualTo("/cases/kafka-reprocessing");
|
||||
assertThat(view.document().title()).isEqualTo("Kafka 재처리");
|
||||
assertThat(view.document().primarySummary()).isEqualTo("재처리가 지연된다");
|
||||
assertThat(view.document().secondarySummary()).isEqualTo("컨슈머 랙을 먼저 본다");
|
||||
assertThat(view.document().content()).contains("# 재처리");
|
||||
assertThat(view.document().contentFormat()).isEqualTo("MARKDOWN");
|
||||
assertThat(view.document().environmentSummary()).containsExactly("Kafka 3.7");
|
||||
assertThat(view.document().primaryTopic().slug()).isEqualTo("kafka");
|
||||
assertThat(view.document().primaryProject().slug()).isEqualTo("tech-log");
|
||||
assertThat(view.document().tags()).extracting("slug").containsExactly("reprocessing");
|
||||
}
|
||||
|
||||
@Test
|
||||
void referenceDetailReadsItsOwnScopeColumns() {
|
||||
ReferenceDetailView view = documents.findReference("kafka-operations").orElseThrow();
|
||||
|
||||
assertThat(view.document().primarySummary()).isEqualTo("운영 기준을 정한다");
|
||||
assertThat(view.document().appliesTo()).containsExactly("Kafka 3.x");
|
||||
assertThat(view.document().excludedScope()).containsExactly("Kinesis");
|
||||
assertThat(view.document().freshnessStatus()).isEqualTo("CURRENT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void questionDetailReadsItsTimelineAndResolutionColumns() {
|
||||
QuestionDetailView view = documents.findQuestion("reprocessing-latency").orElseThrow();
|
||||
|
||||
assertThat(view.question().question()).isEqualTo("재처리 지연을 어떻게 줄일까");
|
||||
assertThat(view.question().status()).isEqualTo("OPEN");
|
||||
assertThat(view.question().resolvedAt()).isNull();
|
||||
assertThat(view.question().points()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWithdrawnDocumentIsNotReadable() {
|
||||
assertThat(documents.findCase("hidden-case")).as("게시 취소된 문서는 상세로도 열리면 안 된다").isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 프로젝트
|
||||
|
||||
@Test
|
||||
void projectListAndDetailReadEveryPublishedColumn() {
|
||||
assertThat(projects.list()).extracting("slug").containsExactly("tech-log");
|
||||
|
||||
ProjectDetailView view = projects.findBySlug("tech-log").orElseThrow();
|
||||
assertThat(view.project().name()).isEqualTo("Tech Log");
|
||||
assertThat(view.project().technologies()).contains("Spring Boot");
|
||||
assertThat(view.canonicalPath()).isEqualTo("/projects/tech-log");
|
||||
assertThat(view.featuredDecision()).isNotNull();
|
||||
assertThat(view.selectedRecords()).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectSubListsReturnEmptyOptionalForAnUnknownProject() {
|
||||
assertThat(projects.decisions(new ProjectDecisionPageQuery("nope", null, page(1, 20))))
|
||||
.isEmpty();
|
||||
assertThat(projects.records(new ProjectRecordPageQuery("nope", null, null, page(1, 20))))
|
||||
.isEmpty();
|
||||
assertThat(projects.activities(new ProjectPageQuery("nope", page(1, 20)))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectDecisionsApplyTheStatusFilterToBothCountAndPage() {
|
||||
var all =
|
||||
projects
|
||||
.decisions(new ProjectDecisionPageQuery("tech-log", null, page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(all.items()).hasSize(1);
|
||||
assertThat(all.page().totalElements()).isEqualTo(1);
|
||||
|
||||
var accepted =
|
||||
projects
|
||||
.decisions(new ProjectDecisionPageQuery("tech-log", "ACCEPTED", page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(accepted.items()).hasSize(1);
|
||||
assertThat(accepted.page().totalElements()).isEqualTo(1);
|
||||
|
||||
var proposed =
|
||||
projects
|
||||
.decisions(new ProjectDecisionPageQuery("tech-log", "PROPOSED", page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(proposed.items()).isEmpty();
|
||||
assertThat(proposed.page().totalElements()).as("필터가 목록에만 걸리고 총계에 안 걸리면 여기서 드러난다").isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectRecordsApplyTypeAndRelationFilters() {
|
||||
var all =
|
||||
projects
|
||||
.records(new ProjectRecordPageQuery("tech-log", null, null, page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(all.items()).isNotEmpty();
|
||||
assertThat(all.page().totalElements()).isEqualTo(all.items().size());
|
||||
|
||||
var cases =
|
||||
projects
|
||||
.records(new ProjectRecordPageQuery("tech-log", "CASE", null, page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(cases.items()).extracting("type").containsOnly("CASE");
|
||||
|
||||
var related =
|
||||
projects
|
||||
.records(new ProjectRecordPageQuery("tech-log", null, "RELATED", page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(related.page().totalElements()).isEqualTo(related.items().size());
|
||||
|
||||
var none =
|
||||
projects
|
||||
.records(new ProjectRecordPageQuery("tech-log", "QUESTION", "RELATED", page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(none.page().totalElements()).isEqualTo(none.items().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectActivitiesListOnlyPublicOnes() {
|
||||
var activities =
|
||||
projects.activities(new ProjectPageQuery("tech-log", page(1, 20))).orElseThrow();
|
||||
|
||||
assertThat(activities.items()).extracting("title").containsExactly("첫 게시");
|
||||
assertThat(activities.page().totalElements()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 릴리스
|
||||
|
||||
@Test
|
||||
void releasesListOnlyPublishedOnesAndResolveRelatedRecords() {
|
||||
assertThat(releases.list()).extracting("version").containsExactly("1.0.0");
|
||||
|
||||
ReleaseDetailView detail = releases.findByVersion("1.0.0").orElseThrow();
|
||||
assertThat(detail.title()).isEqualTo("첫 공개");
|
||||
assertThat(detail.changeTypes()).containsExactly("ADDED");
|
||||
assertThat(detail.relatedRecords())
|
||||
.as("related_resources 는 공개된 것만 되살린다")
|
||||
.extracting("title")
|
||||
.containsExactly("Kafka 재처리");
|
||||
|
||||
assertThat(releases.findByVersion("0.9.0")).as("DRAFT 릴리스는 열리면 안 된다").isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 검색
|
||||
|
||||
@Test
|
||||
void searchMatchesOnSearchTextAndAppliesFilters() {
|
||||
SearchResultPageView hits = search.search(new SearchQuery("재처리", null, null, page(1, 20)));
|
||||
|
||||
assertThat(hits.query()).isEqualTo("재처리");
|
||||
assertThat(hits.items()).isNotEmpty();
|
||||
assertThat(hits.page().totalElements()).isEqualTo(hits.items().size());
|
||||
assertThat(hits.items()).extracting("title").doesNotContain("숨김 Case");
|
||||
|
||||
assertThat(search.search(new SearchQuery("기준", "REFERENCE", null, page(1, 20))).items())
|
||||
.extracting("contentType")
|
||||
.containsOnly("REFERENCE");
|
||||
assertThat(search.search(new SearchQuery("기준", "CASE", null, page(1, 20))).items())
|
||||
.as("type 필터가 실제로 걸려야 한다")
|
||||
.isEmpty();
|
||||
|
||||
assertThat(search.search(new SearchQuery("존재하지않는단어", null, null, page(1, 20))).items())
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 시딩
|
||||
|
||||
/**
|
||||
* 삽입 순서가 곧 제약이다. {@code public_resource_project_link}/{@code public_resource_tag} 는 {@code
|
||||
* public_resource_projection} 을 복합 FK 로 참조하므로 원본 테이블 → projection → 링크/태그 순서를 지킨다.
|
||||
*/
|
||||
private static void seed() {
|
||||
topicId = insertTopic("kafka", "Kafka");
|
||||
|
||||
projectId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project (id, slug, name, one_line_purpose, purpose_markdown,"
|
||||
+ " boundary_markdown, system_overview_markdown, phase, current_objective,"
|
||||
+ " next_step, technology_labels, workflow_status, target_visibility,"
|
||||
+ " created_by, updated_by)"
|
||||
+ " VALUES (:id, 'tech-log', 'Tech Log', '기록을 남긴다', '목적', '경계', '개요',"
|
||||
+ " 'IMPLEMENTATION', '공개 API 완성', '통합 테스트',"
|
||||
+ " '[\"Spring Boot\", \"PostgreSQL\"]'::jsonb, 'PUBLISHED', 'PUBLIC',"
|
||||
+ " 'test', 'test')")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
|
||||
tagId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO tag (id, name, normalized_name, slug, created_by, updated_by)"
|
||||
+ " VALUES (:id, 'reprocessing', 'reprocessing', 'reprocessing', 'test', 'test')")
|
||||
.param("id", tagId)
|
||||
.update();
|
||||
|
||||
// --- Case (공개) ---
|
||||
caseId = UUID.randomUUID();
|
||||
insertDocument(caseId, "CASE", "kafka-reprocessing", "Kafka 재처리", topicId);
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO case_detail (document_id, problem_summary, conclusion_summary,"
|
||||
+ " environment_items) VALUES (:id, '재처리가 지연된다', '컨슈머 랙을 먼저 본다',"
|
||||
+ " '[\"Kafka 3.7\"]'::jsonb)")
|
||||
.param("id", caseId)
|
||||
.update();
|
||||
publish(
|
||||
"CASE",
|
||||
caseId,
|
||||
"Kafka 재처리",
|
||||
"재처리가 지연된다",
|
||||
"/cases/kafka-reprocessing",
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
topicId);
|
||||
link(caseId, "CASE", "PRIMARY", 0);
|
||||
tag(caseId, "CASE");
|
||||
// 목록의 tag 필터는 projection(public_resource_tag)을, 상세는 원본(document_tag)을 읽는다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO document_tag (document_id, tag_id, display_order)" + " VALUES (:d, :t, 0)")
|
||||
.param("d", caseId)
|
||||
.param("t", tagId)
|
||||
.update();
|
||||
|
||||
// --- Reference (공개) ---
|
||||
referenceId = UUID.randomUUID();
|
||||
insertDocument(referenceId, "REFERENCE", "kafka-operations", "Kafka 운영 기준", topicId);
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO reference_detail (document_id, scope_summary, applies_to,"
|
||||
+ " excluded_scope, freshness_status) VALUES (:id, '운영 기준을 정한다',"
|
||||
+ " '[\"Kafka 3.x\"]'::jsonb, '[\"Kinesis\"]'::jsonb, 'CURRENT')")
|
||||
.param("id", referenceId)
|
||||
.update();
|
||||
publish(
|
||||
"REFERENCE",
|
||||
referenceId,
|
||||
"Kafka 운영 기준",
|
||||
"운영 기준을 정한다",
|
||||
"/references/kafka-operations",
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
topicId);
|
||||
link(referenceId, "REFERENCE", "RELATED", null);
|
||||
|
||||
// --- Case (게시 취소) — 어느 경로로도 새면 안 된다 ---
|
||||
hiddenCaseId = UUID.randomUUID();
|
||||
insertDocument(hiddenCaseId, "CASE", "hidden-case", "숨김 Case", topicId);
|
||||
jdbcClient
|
||||
.sql("INSERT INTO case_detail (document_id, problem_summary) VALUES (:id, '재처리 비밀')")
|
||||
.param("id", hiddenCaseId)
|
||||
.update();
|
||||
publish(
|
||||
"CASE",
|
||||
hiddenCaseId,
|
||||
"숨김 Case",
|
||||
"재처리 비밀",
|
||||
"/cases/hidden-case",
|
||||
"WITHDRAWN",
|
||||
"PUBLIC",
|
||||
topicId);
|
||||
|
||||
// --- OpenQuestion (공개) ---
|
||||
questionId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO open_question (id, slug, question, summary, context_markdown,"
|
||||
+ " importance_markdown, next_verification, question_status, target_visibility,"
|
||||
+ " primary_topic_id, opened_at, created_by, updated_by)"
|
||||
+ " VALUES (:id, 'reprocessing-latency', '재처리 지연을 어떻게 줄일까',"
|
||||
+ " '지연 원인을 좁힌다', '맥락', '중요도', '컨슈머 랙 측정', 'OPEN', 'PUBLIC', :topic,"
|
||||
+ " :openedAt, 'test', 'test')")
|
||||
.param("id", questionId)
|
||||
.param("topic", topicId)
|
||||
.param("openedAt", java.sql.Timestamp.from(NOW.minus(10, ChronoUnit.DAYS)))
|
||||
.update();
|
||||
publish(
|
||||
"QUESTION",
|
||||
questionId,
|
||||
"재처리 지연을 어떻게 줄일까",
|
||||
"지연 원인을 좁힌다",
|
||||
"/questions/reprocessing-latency",
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
topicId);
|
||||
// 질문 목록의 status 필터는 projection 의 state_code 를 본다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE public_resource_projection SET state_code = 'OPEN'"
|
||||
+ " WHERE resource_type = 'QUESTION' AND resource_id = :id")
|
||||
.param("id", questionId)
|
||||
.update();
|
||||
link(questionId, "QUESTION", "PRIMARY", 1);
|
||||
tag(questionId, "QUESTION");
|
||||
|
||||
// --- ProjectDecision (공개) ---
|
||||
decisionId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project_decision (id, project_id, statement, rationale_markdown,"
|
||||
+ " consequences, decision_status, target_visibility, source_question_id,"
|
||||
+ " source_case_id, is_featured, decided_at, created_by, updated_by)"
|
||||
+ " VALUES (:id, :project, '재처리는 별도 토픽으로 분리한다', '격리해야 관측이 쉬워진다',"
|
||||
+ " '[\"운영 토픽 증가\"]'::jsonb, 'ACCEPTED', 'PUBLIC', :question, :sourceCase,"
|
||||
+ " true, :decidedAt, 'test', 'test')")
|
||||
.param("id", decisionId)
|
||||
.param("project", projectId)
|
||||
.param("question", questionId)
|
||||
.param("sourceCase", caseId)
|
||||
.param("decidedAt", java.sql.Timestamp.from(NOW.minus(2, ChronoUnit.DAYS)))
|
||||
.update();
|
||||
publish(
|
||||
"PROJECT_DECISION",
|
||||
decisionId,
|
||||
"재처리는 별도 토픽으로 분리한다",
|
||||
"격리해야 관측이 쉬워진다",
|
||||
"/projects/tech-log/decisions/" + decisionId,
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
null);
|
||||
|
||||
// --- Project (공개) ---
|
||||
publish(
|
||||
"PROJECT",
|
||||
projectId,
|
||||
"Tech Log",
|
||||
"기록을 남긴다",
|
||||
"/projects/tech-log",
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
null);
|
||||
|
||||
// --- 활동: 공개 하나 · 비공개 하나 ---
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
|
||||
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
|
||||
+ " created_by, updated_by)"
|
||||
+ " VALUES (gen_random_uuid(), :project, 'CASE_PUBLISHED', '첫 게시',"
|
||||
+ " '첫 문서를 공개했다', 'PUBLIC', 'AUTO', 'CASE', :relatedCase, :at,"
|
||||
+ " 'test', 'test')")
|
||||
.param("project", projectId)
|
||||
.param("relatedCase", caseId)
|
||||
.param("at", java.sql.Timestamp.from(NOW.minus(1, ChronoUnit.DAYS)))
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project_activity (id, project_id, activity_type, title, visibility,"
|
||||
+ " origin, occurred_at, created_by, updated_by)"
|
||||
+ " VALUES (gen_random_uuid(), :project, 'MILESTONE_REACHED', '비공개 메모', 'PRIVATE',"
|
||||
+ " 'MANUAL', :at, 'test', 'test')")
|
||||
.param("project", projectId)
|
||||
.param("at", java.sql.Timestamp.from(NOW))
|
||||
.update();
|
||||
|
||||
// --- V9 연결 테이블 ---
|
||||
jdbcClient
|
||||
.sql("INSERT INTO project_topic (project_id, topic_id, display_order) VALUES (:p, :t, 0)")
|
||||
.param("p", projectId)
|
||||
.param("t", topicId)
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
|
||||
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
|
||||
.param("t", topicId)
|
||||
.param("d", referenceId)
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
|
||||
+ " display_order) VALUES (:t, :d, 'FEATURED_CASE', 0)")
|
||||
.param("t", topicId)
|
||||
.param("d", caseId)
|
||||
.update();
|
||||
|
||||
// --- 단일 행 설정 ---
|
||||
jdbcClient
|
||||
.sql("UPDATE site_config SET contacts = :contacts::jsonb WHERE id = :id")
|
||||
.param("id", SITE_CONFIG_ID)
|
||||
.param(
|
||||
"contacts",
|
||||
"[{\"type\":\"GITHUB\",\"label\":\"GitHub\","
|
||||
+ "\"url\":\"https://github.com/example\"}]")
|
||||
.update();
|
||||
|
||||
// V9 가 단일 행을 이미 시딩했으므로(INSERT ... ON CONFLICT DO NOTHING) 값 채우기는 UPDATE 다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE profile_page SET headline = '문제를 재현해 검증한다',"
|
||||
+ " introduction_markdown = '소개', working_model = :workingModel::jsonb,"
|
||||
+ " territories = :territories::jsonb, selected_evidence = :evidence::jsonb,"
|
||||
+ " trajectory = :trajectory::jsonb, contacts = :contacts::jsonb,"
|
||||
+ " target_visibility = 'PUBLIC' WHERE id = :id")
|
||||
.param("id", PROFILE_PAGE_ID)
|
||||
.param("workingModel", "[{\"name\":\"재현\",\"description\":\"먼저 재현한다\"}]")
|
||||
.param(
|
||||
"territories",
|
||||
"[{\"name\":\"Kafka\",\"currentQuestion\":\"재처리 지연\","
|
||||
+ "\"topicPath\":\"/topics/kafka\"}]")
|
||||
// 게시 취소된 자료 id 를 함께 넣는다 — 공개된 것만 되살아나야 한다.
|
||||
.param("evidence", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
|
||||
.param("trajectory", "[{\"title\":\"2026\",\"description\":\"Tech Log 시작\"}]")
|
||||
.param(
|
||||
"contacts",
|
||||
"[{\"type\":\"EMAIL\",\"label\":\"Email\"," + "\"url\":\"mailto:a@example.com\"}]")
|
||||
.update();
|
||||
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
|
||||
+ " current_project_id = :project, open_question_id = :question,"
|
||||
+ " recent_decision_id = :decision WHERE id = :id")
|
||||
.param("id", HOME_FOCUS_ID)
|
||||
.param("project", projectId)
|
||||
.param("question", questionId)
|
||||
.param("decision", decisionId)
|
||||
.update();
|
||||
|
||||
// --- 릴리스: 공개 하나 · 초안 하나 ---
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO release (id, version_label, title, summary, released_on,"
|
||||
+ " workflow_status, change_types, changes_markdown, verification_markdown,"
|
||||
+ " related_resources, created_by, updated_by)"
|
||||
+ " VALUES (gen_random_uuid(), '1.0.0', '첫 공개', '공개 API 를 열었다',"
|
||||
+ " DATE '2026-08-01', 'PUBLISHED', '[\"ADDED\"]'::jsonb, '변경', '검증',"
|
||||
+ " :related::jsonb, 'test', 'test')")
|
||||
// 게시 취소된 자료 id 를 일부러 함께 넣는다 — 공개된 것만 되살아나야 한다.
|
||||
.param("related", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO release (id, version_label, title, summary, released_on,"
|
||||
+ " workflow_status, created_by, updated_by)"
|
||||
+ " VALUES (gen_random_uuid(), '0.9.0', '초안', '아직 공개 전', DATE '2026-07-01',"
|
||||
+ " 'DRAFT', 'test', 'test')")
|
||||
.update();
|
||||
}
|
||||
|
||||
private static UUID insertTopic(String slug, String name) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic (id, name, normalized_name, slug, description, scope,"
|
||||
+ " status, created_by, updated_by)"
|
||||
+ " VALUES (:id, :name, lower(:name), :slug, :name || ' 설명', '범위',"
|
||||
+ " 'ACTIVE', 'test', 'test')")
|
||||
.param("id", id)
|
||||
.param("name", name)
|
||||
.param("slug", slug)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static void insertDocument(UUID id, String type, String slug, String title, UUID topic) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO document (id, document_type, slug, title, body_markdown,"
|
||||
+ " content_format, content_format_version, workflow_status, target_visibility,"
|
||||
+ " primary_topic_id, last_verified_at, created_by, updated_by)"
|
||||
+ " VALUES (:id, :type, :slug, :title, '# 재처리\n본문', 'MARKDOWN', 1,"
|
||||
+ " 'PUBLISHED', 'PUBLIC', :topic, :verifiedAt, 'test', 'test')")
|
||||
.param("id", id)
|
||||
.param("type", type)
|
||||
.param("slug", slug)
|
||||
.param("title", title)
|
||||
.param("topic", topic)
|
||||
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
|
||||
.update();
|
||||
}
|
||||
|
||||
private static void link(UUID resourceId, String type, String relation, Integer order) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
|
||||
+ " relation_type, featured_order)"
|
||||
+ " VALUES (:type, :id, :project, :relation, :order)")
|
||||
.param("type", type)
|
||||
.param("id", resourceId)
|
||||
.param("project", projectId)
|
||||
.param("relation", relation)
|
||||
.param("order", order)
|
||||
.update();
|
||||
}
|
||||
|
||||
private static void tag(UUID resourceId, String type) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_tag (resource_type, resource_id, tag_id, display_order)"
|
||||
+ " VALUES (:type, :id, :tag, 0)")
|
||||
.param("type", type)
|
||||
.param("id", resourceId)
|
||||
.param("tag", tagId)
|
||||
.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* projection 행을 만든다. {@code public_resource_project_link} 와 {@code public_resource_tag} 가 이 행을
|
||||
* (resource_type, resource_id) 복합 FK 로 참조하므로 <b>반드시 링크·태그보다 먼저</b> 삽입해야 한다.
|
||||
*
|
||||
* <p>{@code topic} 을 인자로 받는 이유는 주제별 record 수를 세는 쿼리가 {@code primary_topic_id} 를 보기 때문이다. 모든
|
||||
* projection 에 같은 주제를 박아 두면 Project 나 Decision 까지 그 주제의 기록으로 세어져, 실제 값과 다른 숫자에 테스트를 맞추게 된다.
|
||||
*/
|
||||
private static void publish(
|
||||
String type,
|
||||
UUID id,
|
||||
String title,
|
||||
String summary,
|
||||
String path,
|
||||
String state,
|
||||
String visibility,
|
||||
UUID topic) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
|
||||
+ " publication_state, visibility, title, summary, primary_topic_id,"
|
||||
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
|
||||
+ " published_at, updated_at, last_verified_at, navigation_path)"
|
||||
+ " VALUES (:type, :id, 1, :state, :visibility, :title, :summary, :topic, 1,"
|
||||
+ " '{}'::jsonb, :body, :search, repeat('a', 64), :publishedAt, :updatedAt,"
|
||||
+ " :verifiedAt, :path)")
|
||||
.param("type", type)
|
||||
.param("id", id)
|
||||
.param("state", state)
|
||||
.param("visibility", visibility)
|
||||
.param("title", title)
|
||||
.param("summary", summary)
|
||||
.param("topic", topic)
|
||||
.param("body", title + " " + summary)
|
||||
.param("search", title + " " + summary)
|
||||
.param("publishedAt", java.sql.Timestamp.from(NOW.minus(5, ChronoUnit.DAYS)))
|
||||
.param("updatedAt", java.sql.Timestamp.from(NOW.minus(4, ChronoUnit.DAYS)))
|
||||
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
|
||||
.param("path", path)
|
||||
.update();
|
||||
}
|
||||
|
||||
private static PublicPageRequest page(int page, int size) {
|
||||
return new PublicPageRequest(page, size);
|
||||
}
|
||||
|
||||
private static boolean tableExists(String table) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables"
|
||||
+ " WHERE table_schema = 'public' AND table_name = :t)")
|
||||
.param("t", table)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user