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:
DongHyeonka
2026-08-20 18:34:18 +09:00
co-authored by Claude Opus 5
parent e3254def57
commit 365560efb6
118 changed files with 9160 additions and 44 deletions
@@ -0,0 +1,48 @@
package dev.caskeleton.application.techlog.publicsite.error;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/**
* 공개 조회 계약(`public-v1.yaml`)의 `ApiError.code`.
*
* <p>계약의 enum 은 세 값인데 여기엔 둘뿐이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤의 공통 예외 처리기가 내는 코드이므로
* 여기서 다시 선언하지 않는다 — 같은 코드를 두 enum 이 각자 status 와 함께 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다.
*
* <p>Studio 의 코드와 이름을 겹치지 않게 한 것도 같은 이유다. 레지스트리는 코드 하나에 status 하나만 담을 수 있어서 public 의 400 과 studio 의
* 422 를 같은 이름으로 쓸 수 없다.
*/
public enum PublicError implements ApiErrorCode {
PUBLIC_REQUEST_INVALID(Category.VALIDATION, 400, false),
PUBLIC_RESOURCE_NOT_FOUND(Category.NOT_FOUND, 404, false);
private final Category category;
private final int httpStatus;
private final boolean retryable;
PublicError(Category category, int httpStatus, boolean retryable) {
this.category = category;
this.httpStatus = httpStatus;
this.retryable = retryable;
}
@Override
public String code() {
return name();
}
@Override
public Category category() {
return category;
}
@Override
public int httpStatus() {
return httpStatus;
}
@Override
public boolean retryable() {
return retryable;
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.techlog.publicsite.error;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
/**
* 공개 조회 실패. {@link #getMessage()} 는 진단용이며 클라이언트에게 그대로 나가지 않는다 — 응답 문구는 레지스트리의 client-safe message 를
* 쓴다({@code ApiErrorCarrier} javadoc).
*/
public final class PublicException extends RuntimeException implements ApiErrorCarrier {
private final transient PublicError error;
private PublicException(PublicError error, String message) {
super(message);
this.error = error;
}
public static PublicException of(PublicError error, String message) {
return new PublicException(error, message);
}
@Override
public ApiErrorCode errorCode() {
return error;
}
public PublicError publicError() {
return error;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.UUID;
/**
* 계약 {@code AssetReference}.
*
* @param url 검증된 전송 경로다. object storage URL 이 아니다(설계 05장 §3.1).
*/
public record AssetReferenceView(
UUID assetId, String url, String altText, Integer width, Integer height, String contentType) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code CaseDetailResponse}. */
public record CaseDetailView(
String canonicalPath,
boolean indexable,
PublishedDocumentView document,
CaseRelationsView relations) {}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code CaseDetailResponse.relations}. */
public record CaseRelationsView(
RelatedEntryView originQuestion,
List<RelatedEntryView> projectDecisions,
List<RelatedEntryView> derivedReferences,
List<RelatedEntryView> relatedCases) {
public CaseRelationsView {
projectDecisions = projectDecisions == null ? List.of() : List.copyOf(projectDecisions);
derivedReferences = derivedReferences == null ? List.of() : List.copyOf(derivedReferences);
relatedCases = relatedCases == null ? List.of() : List.copyOf(relatedCases);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code ContactLink}. */
public record ContactLinkView(String type, String label, String url) {}
@@ -0,0 +1,106 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/**
* 계약 {@code HomeResponse.focus}. {@code defaultType} 이 어느 갈래를 보여줄지 정하고 세 갈래는 전부 optional 이다 — 계약이
* 그렇게 정했다. 설정된 갈래가 비어 있을 수 있으므로(예: 지목한 질문이 비공개가 되었을 때) 타입으로 "반드시 하나는 있다"를 강제하지 않는다.
*/
public record HomeFocusView(
String defaultType,
CurrentWork currentWork,
OpenQuestion openQuestion,
RecentDecision recentDecision) {
/**
* 설정된 값이 없거나 그 갈래가 비었을 때 쓸 순서. 계약이 {@code defaultType} 을 required 로 선언했으므로 "정해진 게 없다"를 null 로 표현할
* 수 없다.
*/
private static final String[] FALLBACK_ORDER = {
"CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION"
};
/**
* 계약이 {@code focus.defaultType} 을 required 로 선언하고 값 셋만 허용한다. 그런데 설정 테이블은 갓 마이그레이션한 상태에서 {@code
* default_focus_type} 이 NULL 이고, 지목한 갈래가 비공개로 바뀌어 비는 경우도 있다. 그대로 내보내면 응답 매퍼가 계약 밖 값을 만나 홈 화면 전체가
* 실패한다 — 실제로 배포 직후 첫 요청이 그렇게 깨졌다.
*
* <p>그래서 이 자리에서 반드시 유효한 값 하나를 정한다.
*
* <ol>
* <li>설정된 값이 유효하고 그 갈래에 내용이 있으면 그대로 쓴다.
* <li>아니면 내용이 있는 갈래를 {@link #FALLBACK_ORDER} 순으로 고른다.
* <li>셋 다 비었으면 첫 값을 쓴다 — 세 갈래는 전부 optional 이므로 비어 있어도 계약을 만족한다.
* </ol>
*/
public static HomeFocusView resolve(
String configuredType,
CurrentWork currentWork,
OpenQuestion openQuestion,
RecentDecision recentDecision) {
if (configuredType != null
&& hasContent(configuredType, currentWork, openQuestion, recentDecision)) {
return new HomeFocusView(configuredType, currentWork, openQuestion, recentDecision);
}
for (String candidate : FALLBACK_ORDER) {
if (hasContent(candidate, currentWork, openQuestion, recentDecision)) {
return new HomeFocusView(candidate, currentWork, openQuestion, recentDecision);
}
}
return new HomeFocusView(FALLBACK_ORDER[0], currentWork, openQuestion, recentDecision);
}
private static boolean hasContent(
String type,
CurrentWork currentWork,
OpenQuestion openQuestion,
RecentDecision recentDecision) {
return switch (type) {
case "CURRENT_WORK" -> currentWork != null;
case "OPEN_QUESTION" -> openQuestion != null;
case "RECENT_DECISION" -> recentDecision != null;
// 계약 밖 값이 설정에 들어 있는 경우다. 그대로 쓰면 응답이 깨지므로 없는 것으로 친다.
default -> false;
};
}
/** 계약 {@code CurrentWorkFocus}. */
public record CurrentWork(
String projectName,
String projectPath,
String purpose,
String phase,
String currentObjective,
String nextStep,
Instant updatedAt) {}
/** 계약 {@code OpenQuestionFocus}. */
public record OpenQuestion(
String question,
String questionPath,
String summary,
List<String> knownFacts,
List<String> unresolvedPoints,
String nextVerification,
Instant updatedAt) {
public OpenQuestion {
knownFacts = knownFacts == null ? List.of() : List.copyOf(knownFacts);
unresolvedPoints = unresolvedPoints == null ? List.of() : List.copyOf(unresolvedPoints);
}
}
/** 계약 {@code RecentDecisionFocus}. */
public record RecentDecision(
String statement,
String decisionPath,
String rationale,
List<String> consequences,
Instant decidedAt) {
public RecentDecision {
consequences = consequences == null ? List.of() : List.copyOf(consequences);
}
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code HomeResponse}. */
public record HomeView(HomeFocusView focus, List<LatestEntryView> latestEntries) {
public HomeView {
latestEntries = latestEntries == null ? List.of() : List.copyOf(latestEntries);
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code KnowledgeListItem}. */
public record KnowledgeListItemView(
String type,
String title,
String path,
String primarySummary,
String secondarySummary,
TopicSummaryView primaryTopic,
ProjectSummaryView primaryProject,
Instant publishedAt,
Instant lastVerifiedAt,
String freshnessStatus) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code KnowledgePage}. */
public record KnowledgePageView(List<KnowledgeListItemView> items, PageMetadataView page) {
public KnowledgePageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code LatestEntry}. */
public record LatestEntryView(
String entryType,
String title,
String summary,
String path,
TopicSummaryView primaryTopic,
ProjectSummaryView primaryProject,
Instant publishedAt) {}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.techlog.publicsite.model;
/**
* 계약 {@code PageMetadata}. 공개 조회는 studio 와 달리 offset 페이지네이션이다 — 계약이 그렇게 정했고, 공개 목록은 "3페이지로 바로 가기"가
* 필요한 화면이라 cursor 로 대체할 수 없다.
*/
public record PageMetadataView(
int number,
int size,
long totalElements,
int totalPages,
boolean hasPrevious,
boolean hasNext) {
public static PageMetadataView of(int page, int size, long total) {
int totalPages = size <= 0 ? 0 : (int) Math.ceil((double) total / size);
return new PageMetadataView(page, size, total, totalPages, page > 1, page < totalPages);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProfileResponse}. */
public record ProfileView(
String headline,
String description,
List<NamedDescription> workingModel,
List<Territory> territories,
List<RelatedEntryView> selectedEvidence,
List<NamedDescription> trajectory,
List<ContactLinkView> contacts) {
public ProfileView {
workingModel = workingModel == null ? List.of() : List.copyOf(workingModel);
territories = territories == null ? List.of() : List.copyOf(territories);
selectedEvidence = selectedEvidence == null ? List.of() : List.copyOf(selectedEvidence);
trajectory = trajectory == null ? List.of() : List.copyOf(trajectory);
contacts = contacts == null ? List.of() : List.copyOf(contacts);
}
/** {@code workingModel[]} 과 {@code trajectory[]} 가 같은 모양이라 하나로 쓴다. */
public record NamedDescription(String name, String description) {}
/** {@code territories[]}. */
public record Territory(String name, String currentQuestion, String topicPath) {}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code ProjectActivityItem}. */
public record ProjectActivityItemView(
String type, String title, String summary, Instant occurredAt, String relatedPath) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProjectActivityPage}. */
public record ProjectActivityPageView(List<ProjectActivityItemView> items, PageMetadataView page) {
public ProjectActivityPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.UUID;
/** 계약 {@code ProjectDecisionItem}. */
public record ProjectDecisionItemView(
UUID id,
String statement,
String status,
String rationaleSummary,
Instant decidedAt,
RelatedEntryView sourceQuestion,
RelatedEntryView sourceCase) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProjectDecisionPage}. */
public record ProjectDecisionPageView(List<ProjectDecisionItemView> items, PageMetadataView page) {
public ProjectDecisionPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProjectDetailResponse}. */
public record ProjectDetailView(
String canonicalPath,
boolean indexable,
PublishedProjectView project,
RelatedEntryView featuredDecision,
RelatedEntryView activeQuestion,
List<RelatedEntryView> selectedRecords) {
public ProjectDetailView {
selectedRecords = selectedRecords == null ? List.of() : List.copyOf(selectedRecords);
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code ProjectListItem}. */
public record ProjectListItemView(
String name,
String slug,
String path,
String oneLinePurpose,
String phase,
String currentObjective,
String nextStep,
Instant updatedAt) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProjectRecordPage}. */
public record ProjectRecordPageView(List<RelatedEntryView> items, PageMetadataView page) {
public ProjectRecordPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code ProjectSummary}. */
public record ProjectSummaryView(String name, String slug, String path) {}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/**
* 공개된 Case / Reference 의 본문과 메타데이터.
*
* <p>계약의 {@code CaseDetailResponse.case} 와 {@code ReferenceDetailResponse.reference} 는 담는 필드가
* 다르지만(문제/결론 vs 범위/적용), 원천이 같은 {@code document} + 유형별 detail 이라 하나의 레코드로 읽고 웹 계층에서 유형별 모양으로 나눈다.
*
* @param content Markdown 원문이다. studio 의 렌더 블록이 아니다 — 공개 계약은 {@code contentFormat} 과 함께 원문을 준다.
*/
public record PublishedDocumentView(
String type,
String canonicalPath,
String title,
String primarySummary,
String secondarySummary,
List<String> environmentSummary,
List<String> appliesTo,
List<String> excludedScope,
String freshnessStatus,
String content,
String contentFormat,
int contentFormatVersion,
TopicSummaryView primaryTopic,
List<TagSummaryView> tags,
ProjectSummaryView primaryProject,
AssetReferenceView coverAsset,
Instant publishedAt,
Instant updatedAt,
Instant lastVerifiedAt) {
public PublishedDocumentView {
environmentSummary = environmentSummary == null ? List.of() : List.copyOf(environmentSummary);
appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo);
excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope);
tags = tags == null ? List.of() : List.copyOf(tags);
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/** 계약 {@code ProjectDetailResponse.project}. */
public record PublishedProjectView(
String name,
String slug,
String oneLinePurpose,
String purpose,
String boundary,
String phase,
String currentObjective,
String nextStep,
String systemOverviewMarkdown,
List<String> technologies,
Instant updatedAt) {
public PublishedProjectView {
technologies = technologies == null ? List.of() : List.copyOf(technologies);
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/** 계약 {@code QuestionDetailResponse.question}. */
public record PublishedQuestionView(
String question,
String summary,
String context,
String importance,
String status,
String nextVerification,
QuestionPointGroupView points,
List<QuestionUpdateView> updates,
String resolutionType,
String resolutionSummary,
Instant resolvedAt,
Instant openedAt,
Instant updatedAt) {
public PublishedQuestionView {
updates = updates == null ? List.of() : List.copyOf(updates);
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code QuestionDetailResponse}. */
public record QuestionDetailView(
String canonicalPath,
boolean indexable,
PublishedQuestionView question,
QuestionRelationsView relations) {}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code QuestionListItem}. */
public record QuestionListItemView(
String question,
String path,
String status,
String summary,
String currentUnderstanding,
String nextVerification,
ProjectSummaryView primaryProject,
Instant updatedAt) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code QuestionPage}. */
public record QuestionPageView(List<QuestionListItemView> items, PageMetadataView page) {
public QuestionPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code QuestionPointGroup}. */
public record QuestionPointGroupView(
List<String> facts, List<String> assumptions, List<String> unknowns, List<String> constraints) {
public QuestionPointGroupView {
facts = facts == null ? List.of() : List.copyOf(facts);
assumptions = assumptions == null ? List.of() : List.copyOf(assumptions);
unknowns = unknowns == null ? List.of() : List.copyOf(unknowns);
constraints = constraints == null ? List.of() : List.copyOf(constraints);
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code QuestionDetailResponse.relations}. */
public record QuestionRelationsView(
RelatedEntryView primaryProject,
RelatedEntryView resultCase,
RelatedEntryView producedDecision,
List<RelatedEntryView> derivedReferences) {
public QuestionRelationsView {
derivedReferences = derivedReferences == null ? List.of() : List.copyOf(derivedReferences);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code QuestionUpdatePublic}. 공개된 조사 기록 한 건이다. */
public record QuestionUpdateView(
String type, String title, String bodyMarkdown, Instant occurredAt) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code ReferenceDetailResponse}. */
public record ReferenceDetailView(
String canonicalPath,
boolean indexable,
PublishedDocumentView document,
ReferenceRelationsView relations) {}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ReferenceDetailResponse.relations}. Case 의 관계와 이름·구성이 다르다. */
public record ReferenceRelationsView(
List<RelatedEntryView> supportingCases,
List<RelatedEntryView> relatedDecisions,
List<RelatedEntryView> relatedReferences) {
public ReferenceRelationsView {
supportingCases = supportingCases == null ? List.of() : List.copyOf(supportingCases);
relatedDecisions = relatedDecisions == null ? List.of() : List.copyOf(relatedDecisions);
relatedReferences = relatedReferences == null ? List.of() : List.copyOf(relatedReferences);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code RelatedEntry}. */
public record RelatedEntryView(String type, String title, String summary, String path) {}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.LocalDate;
import java.util.List;
/** 계약 {@code ReleaseDetailResponse}. */
public record ReleaseDetailView(
String version,
String title,
String summary,
LocalDate releasedOn,
List<String> changeTypes,
String reasonMarkdown,
String changesMarkdown,
String userImpactMarkdown,
String implementationImpactMarkdown,
String verificationMarkdown,
String knownLimitationsMarkdown,
List<RelatedEntryView> relatedRecords) {
public ReleaseDetailView {
changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes);
relatedRecords = relatedRecords == null ? List.of() : List.copyOf(relatedRecords);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.LocalDate;
import java.util.List;
/** 계약 {@code ReleaseListItem}. */
public record ReleaseListItemView(
String version,
String title,
String summary,
LocalDate releasedOn,
List<String> changeTypes,
String path) {
public ReleaseListItemView {
changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes);
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/** 계약 {@code SearchResultItem}. */
public record SearchResultItemView(
String contentType,
String title,
String path,
String snippet,
List<String> matchedFields,
TopicSummaryView primaryTopic,
ProjectSummaryView primaryProject,
Instant publishedAt,
Instant updatedAt) {
public SearchResultItemView {
matchedFields = matchedFields == null ? List.of() : List.copyOf(matchedFields);
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code SearchResultPage}. */
public record SearchResultPageView(
String query, List<SearchResultItemView> items, PageMetadataView page) {
public SearchResultPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code SiteResponse}. */
public record SiteView(
String brandTitle,
String identityStatement,
String operatorDisplayName,
String operatorShortIdentity,
AssetReferenceView operatorAvatar,
String operatorProfilePath,
List<ContactLinkView> contacts) {
public SiteView {
contacts = contacts == null ? List.of() : List.copyOf(contacts);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code TagSummary}. */
public record TagSummaryView(String name, String slug) {}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code TopicDetailResponse}. */
public record TopicDetailView(
String name,
String slug,
String description,
String scope,
RelatedEntryView featuredReference,
List<RelatedEntryView> featuredCases,
List<RelatedEntryView> activeQuestions,
List<RelatedEntryView> relatedProjects,
List<LatestEntryView> latestRecords) {
public TopicDetailView {
featuredCases = featuredCases == null ? List.of() : List.copyOf(featuredCases);
activeQuestions = activeQuestions == null ? List.of() : List.copyOf(activeQuestions);
relatedProjects = relatedProjects == null ? List.of() : List.copyOf(relatedProjects);
latestRecords = latestRecords == null ? List.of() : List.copyOf(latestRecords);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code TopicListItem}. */
public record TopicListItemView(String name, String slug, String description, int recordCount) {}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code TopicSummary}. */
public record TopicSummaryView(String name, String slug) {}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import java.util.Optional;
/**
* 공개된 기록 상세.
*
* <p>본문은 {@code public_resource_projection.payload}(studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약의 상세 모양은
* 렌더 모델과 다르다(본문이 블록 배열이 아니라 Markdown 원문이고, environmentSummary 가 배열이며, tags/coverAsset 과 유형별 관계가 따로
* 있다). projection 은 "무엇이 공개됐는가"와 게시 시각을 정하는 데 쓴다.
*/
public interface PublicDocumentQueryPort {
Optional<CaseDetailView> findCase(String slug);
Optional<ReferenceDetailView> findReference(String slug);
Optional<QuestionDetailView> findQuestion(String slug);
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
/** 탐색 목록. 공개된 것만 본다 — {@code public_resource_projection.publication_state = 'ACTIVE'}. */
public interface PublicExploreQueryPort {
KnowledgePageView knowledge(ExploreKnowledgeQuery query);
QuestionPageView questions(ExploreQuestionsQuery query);
}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
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.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;
/** 프로젝트 목록·상세와 그 하위 목록. */
public interface PublicProjectQueryPort {
List<ProjectListItemView> list();
Optional<ProjectDetailView> findBySlug(String slug);
/** 프로젝트가 없으면 {@link Optional#empty()} — 빈 페이지와 404 를 호출자가 구분해야 한다. */
Optional<ProjectDecisionPageView> decisions(ProjectDecisionPageQuery query);
Optional<ProjectRecordPageView> records(ProjectRecordPageQuery query);
Optional<ProjectActivityPageView> activities(ProjectPageQuery query);
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import java.util.List;
import java.util.Optional;
/** 릴리스 목록·상세. 공개된 것({@code workflow_status = 'PUBLISHED'})만 본다. */
public interface PublicReleaseQueryPort {
List<ReleaseListItemView> list();
Optional<ReleaseDetailView> findByVersion(String version);
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
/** 공개 검색. {@code public_resource_projection.search_text} 를 본다. */
@FunctionalInterface
public interface PublicSearchQueryPort {
SearchResultPageView search(SearchQuery query);
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import java.util.Optional;
/**
* 사이트 정체성 · 홈 · 프로필.
*
* <p>이 셋의 원천은 단일 행 테이블({@code site_config} / {@code home_focus_config} / {@code profile_page})이고 편집
* API 는 {@code studio-management-v1}(범위 밖)이 소유한다. 지금은 V9 의 시딩이 유일한 공급원이며, 프로필은 공개로 전환되기 전까지 비어 있을 수
* 있어 {@link Optional} 이다.
*/
public interface PublicSiteQueryPort {
Optional<SiteView> site();
HomeView home(int latestEntryLimit);
Optional<ProfileView> profile();
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import java.util.List;
import java.util.Optional;
/** 주제 목록·상세. */
public interface PublicTopicQueryPort {
List<TopicListItemView> list();
Optional<TopicDetailView> findBySlug(String slug);
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.query.Query;
/** 파라미터가 없는 조회({@code getPublicSite} / {@code getPublicHome} / 목록 전체 등). */
public record EmptyQuery() implements Query {}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.query.Query;
/**
* {@code exploreKnowledge} 의 입력.
*
* @param type CASE / REFERENCE. null 이면 둘 다
* @param sort PUBLISHED_DESC / UPDATED_DESC / VERIFIED_DESC
*/
public record ExploreKnowledgeQuery(
String type,
String topicSlug,
String projectSlug,
String tagSlug,
Integer year,
String sort,
PublicPageRequest page)
implements Query {}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.query.Query;
/**
* {@code exploreQuestions} 의 입력.
*
* @param status OPEN / INVESTIGATING / PAUSED / RESOLVED. null 이면 전부
* @param sort UPDATED_DESC / OPENED_DESC / RESOLVED_DESC
*/
public record ExploreQuestionsQuery(
String status,
String topicSlug,
String projectSlug,
String tagSlug,
String sort,
PublicPageRequest page)
implements Query {}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.query.Query;
/**
* {@code listPublicProjectDecisions} 의 입력.
*
* <p>계약이 이 operation 에만 주는 {@code status} 필터 때문에 {@link ProjectPageQuery} 와 나눈다 — 하나의 record 에 세
* operation 의 필터를 다 담으면 어느 필드가 어느 operation 에서 무시되는지 타입으로 알 수 없다.
*
* @param status 계약이 enum 을 두지 않은 자유 문자열이다 — 도메인의 decision lifecycle 값이 그대로 들어온다
*/
public record ProjectDecisionPageQuery(String projectSlug, String status, PublicPageRequest page)
implements Query {}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.query.Query;
/** {@code listPublicProjectActivities} 의 입력. 계약이 이 operation 에는 필터를 두지 않았다. */
public record ProjectPageQuery(String projectSlug, PublicPageRequest page) implements Query {}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.query.Query;
/**
* {@code listPublicProjectRecords} 의 입력.
*
* @param type CASE / REFERENCE / QUESTION. null 이면 셋 다
* @param relation PRIMARY / RELATED. null 이면 둘 다
*/
public record ProjectRecordPageQuery(
String projectSlug, String type, String relation, PublicPageRequest page) implements Query {}
@@ -0,0 +1,28 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
import dev.caskeleton.application.techlog.publicsite.error.PublicException;
/**
* 계약이 모든 목록에 쓰는 {@code page}/{@code size}. 1-based 다.
*
* <p>검증을 한 곳에 모은 이유는 여섯 개 목록 operation 이 같은 규칙을 쓰기 때문이다 — 각자 검사하면 어느 하나가 한계를 빠뜨려도 드러나지 않는다.
*/
public record PublicPageRequest(int page, int size) {
private static final int MAX_SIZE = 100;
public PublicPageRequest {
if (page < 1) {
throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "page must be at least 1");
}
if (size < 1 || size > MAX_SIZE) {
throw PublicException.of(
PublicError.PUBLIC_REQUEST_INVALID, "size must be between 1 and " + MAX_SIZE);
}
}
public int offset() {
return (page - 1) * size;
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.query.Query;
/** {@code searchPublicResources} 의 입력. */
public record SearchQuery(String query, String type, String topicSlug, PublicPageRequest page)
implements Query {}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.publicsite.query;
import dev.caskeleton.application.query.Query;
/** slug 하나로 조회하는 operation 들의 공통 입력. */
public record SlugQuery(String slug) implements Query {}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code exploreKnowledge}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ExploreKnowledgeUseCase
implements QueryUseCase<ExploreKnowledgeQuery, KnowledgePageView> {
private final PublicExploreQueryPort port;
private final TransactionPort transactions;
public ExploreKnowledgeUseCase(PublicExploreQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public KnowledgePageView handle(ExploreKnowledgeQuery input) {
return transactions.inRead(() -> port.knowledge(input));
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code exploreQuestions}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ExploreQuestionsUseCase
implements QueryUseCase<ExploreQuestionsQuery, QuestionPageView> {
private final PublicExploreQueryPort port;
private final TransactionPort transactions;
public ExploreQuestionsUseCase(PublicExploreQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public QuestionPageView handle(ExploreQuestionsQuery input) {
return transactions.inRead(() -> port.questions(input));
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicCase}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicCaseUseCase implements QueryUseCase<SlugQuery, CaseDetailView> {
private final PublicDocumentQueryPort port;
private final TransactionPort transactions;
public GetPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public CaseDetailView handle(SlugQuery input) {
String slug = PublicReadUseCases.requireSlug(input.slug());
return transactions.inRead(
() -> port.findCase(slug).orElseThrow(() -> PublicReadUseCases.notFound("case " + slug)));
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicHome}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicHomeUseCase implements QueryUseCase<EmptyQuery, HomeView> {
/** 계약 {@code HomeResponse.latestEntries} 는 최신 목록이며 화면이 한 화면에 담는 개수다. */
private static final int LATEST_ENTRY_LIMIT = 10;
private final PublicSiteQueryPort port;
private final TransactionPort transactions;
public GetPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public HomeView handle(EmptyQuery input) {
return transactions.inRead(() -> port.home(LATEST_ENTRY_LIMIT));
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicProfile}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicProfileUseCase implements QueryUseCase<EmptyQuery, ProfileView> {
private final PublicSiteQueryPort port;
private final TransactionPort transactions;
public GetPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public ProfileView handle(EmptyQuery input) {
return transactions.inRead(
() -> port.profile().orElseThrow(() -> PublicReadUseCases.notFound("the profile page")));
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicProject}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicProjectUseCase implements QueryUseCase<SlugQuery, ProjectDetailView> {
private final PublicProjectQueryPort port;
private final TransactionPort transactions;
public GetPublicProjectUseCase(PublicProjectQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public ProjectDetailView handle(SlugQuery input) {
String slug = PublicReadUseCases.requireSlug(input.slug());
return transactions.inRead(
() ->
port.findBySlug(slug)
.orElseThrow(() -> PublicReadUseCases.notFound("project " + slug)));
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicQuestion}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicQuestionUseCase implements QueryUseCase<SlugQuery, QuestionDetailView> {
private final PublicDocumentQueryPort port;
private final TransactionPort transactions;
public GetPublicQuestionUseCase(PublicDocumentQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public QuestionDetailView handle(SlugQuery input) {
String slug = PublicReadUseCases.requireSlug(input.slug());
return transactions.inRead(
() ->
port.findQuestion(slug)
.orElseThrow(() -> PublicReadUseCases.notFound("question " + slug)));
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicReference}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicReferenceUseCase
implements QueryUseCase<SlugQuery, ReferenceDetailView> {
private final PublicDocumentQueryPort port;
private final TransactionPort transactions;
public GetPublicReferenceUseCase(PublicDocumentQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public ReferenceDetailView handle(SlugQuery input) {
String slug = PublicReadUseCases.requireSlug(input.slug());
return transactions.inRead(
() ->
port.findReference(slug)
.orElseThrow(() -> PublicReadUseCases.notFound("reference " + slug)));
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicRelease}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicReleaseUseCase implements QueryUseCase<SlugQuery, ReleaseDetailView> {
private final PublicReleaseQueryPort port;
private final TransactionPort transactions;
public GetPublicReleaseUseCase(PublicReleaseQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public ReleaseDetailView handle(SlugQuery input) {
String version = PublicReadUseCases.requireSlug(input.slug());
return transactions.inRead(
() ->
port.findByVersion(version)
.orElseThrow(() -> PublicReadUseCases.notFound("release " + version)));
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicSite}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicSiteUseCase implements QueryUseCase<EmptyQuery, SiteView> {
private final PublicSiteQueryPort port;
private final TransactionPort transactions;
public GetPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public SiteView handle(EmptyQuery input) {
return transactions.inRead(
() -> port.site().orElseThrow(() -> PublicReadUseCases.notFound("the site profile")));
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code getPublicTopic}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetPublicTopicUseCase implements QueryUseCase<SlugQuery, TopicDetailView> {
private final PublicTopicQueryPort port;
private final TransactionPort transactions;
public GetPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public TopicDetailView handle(SlugQuery input) {
String slug = PublicReadUseCases.requireSlug(input.slug());
return transactions.inRead(
() ->
port.findBySlug(slug).orElseThrow(() -> PublicReadUseCases.notFound("topic " + slug)));
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code listPublicProjectActivities}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListPublicProjectActivitiesUseCase
implements QueryUseCase<ProjectPageQuery, ProjectActivityPageView> {
private final PublicProjectQueryPort port;
private final TransactionPort transactions;
public ListPublicProjectActivitiesUseCase(
PublicProjectQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public ProjectActivityPageView handle(ProjectPageQuery input) {
String slug = PublicReadUseCases.requireSlug(input.projectSlug());
return transactions.inRead(
() ->
port.activities(input)
.orElseThrow(() -> PublicReadUseCases.notFound("project " + slug)));
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code listPublicProjectDecisions}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListPublicProjectDecisionsUseCase
implements QueryUseCase<ProjectDecisionPageQuery, ProjectDecisionPageView> {
private final PublicProjectQueryPort port;
private final TransactionPort transactions;
public ListPublicProjectDecisionsUseCase(
PublicProjectQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public ProjectDecisionPageView handle(ProjectDecisionPageQuery input) {
String slug = PublicReadUseCases.requireSlug(input.projectSlug());
return transactions.inRead(
() ->
port.decisions(input)
.orElseThrow(() -> PublicReadUseCases.notFound("project " + slug)));
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code listPublicProjectRecords}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListPublicProjectRecordsUseCase
implements QueryUseCase<ProjectRecordPageQuery, ProjectRecordPageView> {
private final PublicProjectQueryPort port;
private final TransactionPort transactions;
public ListPublicProjectRecordsUseCase(
PublicProjectQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public ProjectRecordPageView handle(ProjectRecordPageQuery input) {
String slug = PublicReadUseCases.requireSlug(input.projectSlug());
return transactions.inRead(
() ->
port.records(input).orElseThrow(() -> PublicReadUseCases.notFound("project " + slug)));
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.List;
import java.util.Objects;
/**
* 계약 {@code listPublicProjects}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListPublicProjectsUseCase
implements QueryUseCase<EmptyQuery, List<ProjectListItemView>> {
private final PublicProjectQueryPort port;
private final TransactionPort transactions;
public ListPublicProjectsUseCase(PublicProjectQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public List<ProjectListItemView> handle(EmptyQuery input) {
return transactions.inRead(port::list);
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.List;
import java.util.Objects;
/**
* 계약 {@code listPublicReleases}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListPublicReleasesUseCase
implements QueryUseCase<EmptyQuery, List<ReleaseListItemView>> {
private final PublicReleaseQueryPort port;
private final TransactionPort transactions;
public ListPublicReleasesUseCase(PublicReleaseQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public List<ReleaseListItemView> handle(EmptyQuery input) {
return transactions.inRead(port::list);
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.List;
import java.util.Objects;
/**
* 계약 {@code listPublicTopics}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListPublicTopicsUseCase
implements QueryUseCase<EmptyQuery, List<TopicListItemView>> {
private final PublicTopicQueryPort port;
private final TransactionPort transactions;
public ListPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public List<TopicListItemView> handle(EmptyQuery input) {
return transactions.inRead(port::list);
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
import dev.caskeleton.application.techlog.publicsite.error.PublicException;
/**
* 공개 조회 use case 들이 공유하는 조각.
*
* <p>18개 operation 이 전부 읽기 전용이고 도메인 규칙이 없다. 각자 "없으면 404" 를 따로 쓰면 문구와 코드가 갈라지므로 한 곳에 둔다.
*/
final class PublicReadUseCases {
private PublicReadUseCases() {}
static PublicException notFound(String what) {
return PublicException.of(PublicError.PUBLIC_RESOURCE_NOT_FOUND, what + " is not published");
}
static String requireSlug(String slug) {
if (slug == null || slug.isBlank()) {
throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "a slug is required");
}
return slug;
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.techlog.publicsite.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/**
* 계약 {@code searchPublicResources}. 공개 조회이며 인증이 없다.
*
* <p>공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것").
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class SearchPublicResourcesUseCase
implements QueryUseCase<SearchQuery, SearchResultPageView> {
private final PublicSearchQueryPort port;
private final TransactionPort transactions;
public SearchPublicResourcesUseCase(PublicSearchQueryPort port, TransactionPort transactions) {
this.port = Objects.requireNonNull(port, "port");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public SearchResultPageView handle(SearchQuery input) {
return transactions.inRead(() -> port.search(input));
}
}