feat: Tech Log Studio 백엔드 — 남은 17개 operation 구현 (슬라이스 2~5)

studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD,
검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다.

Plan 01이 Plan 02로 미룬 생성기 union 차단 요인
- 계약 원본은 그대로 두고 prepareStudioCodegenSpec이 생성 직전에 사본을 파생시킨다.
  oneOf+discriminator를 가진 스키마의 하위 타입에 x-implements를 주입하고, union 자체는
  생성을 억제한 뒤 같은 package에 Jackson 다형성 인터페이스를 계약에서 파생해 써 넣는다
- 파생 규칙을 계약의 oneOf/discriminator.mapping에서 읽으므로 union 목록을 손으로
  관리하지 않는다. 계약에 union이 늘면 따라온다
- openApiNullable=false. JsonNullable을 읽는 모듈은 Jackson 2용인데 이 앱의 HTTP
  변환기는 Jackson 3(tools.jackson)다 — 등록될 수 없어 직렬화가 POJO로 새고
  역직렬화가 깨진다. 해당 필드는 계약상 required라 "없음"과 "null"을 구분할 필요도 없다
- 모든 분기가 type:string인 이름 없는 oneOf는 접는다. 안 접으면 필드 0개 껍데기
  클래스가 나와 slug가 {}로 직렬화된다
- oneOf:[X,null]도 접는다. 그대로 두면 같은 모양의 래퍼 타입이 7벌 더 생긴다
- StudioContractUnionJacksonTest가 이 배선을 지킨다. 파생이 깨지면 컴파일이 깨진다

설계 스키마의 구멍 — V8__techlog_studio_working_copy.sql
V7(설계 패키지 database/V1__init.sql)은 유형마다 다른 물리 모델인데 계약은 네 유형을
공통 base + 유형별 확장이라는 하나의 편집 흐름으로 다룬다. 계약이 요구하는데 없던 것:
- document.summary / case_detail.environment,reproduction / reference_detail.rules,examples
- open_question.options,resolution_evidence_target_id,resolution_link_label
- project_decision.title,slug,summary,primary_topic_id
- problem/conclusion/scope_summary/statement가 varchar라 계약의 100000자를 담을 수 없어 text로 넓힘
- project_decision.project_id NOT NULL은 계약이 명시적으로 허용한 초안 저장을
  구조적으로 막고 있었다(게시 필수 여부는 검증이 판단한다) — 풀었다
- studio_relation: 계약의 relations[]는 네 유형 공통이고 항목마다 자체 id와 reason이
  있다. document_relation은 복합 PK라 둘 다 없고 문서끼리만 성립한다.
  (source_kind, source_id) 다형 참조는 studio_validation/studio_preview가 이미 쓰는 방식이다

영속은 JdbcClient
spec §8.3은 쓰기에 JPA @Version을 적었지만 이 네 aggregate는 Studio 저장 경로에서만
쓰이고 UPDATE ... WHERE version = :expectedVersion의 갱신 행 수가 정확히 같은 의미를
준다. 여덟 개 넘는 테이블에 엔티티를 세우는 비용에 상응하는 이득이 없다. 포트 계약이
같으므로 나중에 JPA가 필요하면 어댑터만 바뀐다.

nextAction/dependencyRevision 계산은 SQL 한 벌(StudioDocumentSql)
목록과 상세가 각자 계산하면 "목록에선 게시하라더니 열어보니 검증하라"가 된다.
계약의 nextAction 필터도 SQL이라야 페이지네이션을 깨지 않고 걸 수 있다.

렌더러 (ADR-005)
- commonmark + GFM 확장. 설계 05장 §16대로 라이브러리는 render 패키지 밖으로 안 나간다.
  프론트가 remark 계열로 같은 CommonMark+GFM 기준을 쓰므로 동등성이 유지된다
- ::: directive는 줄 단위 스캔이다. v1 문법에서 중첩이 없고 줄 맨 앞에서만 열린다.
  코드 펜스 안의 :::는 directive로 보지 않는다
- 컨테이너/leaf 판정은 닫는 줄이 실제로 있는지로 한다. 이름 목록으로 정하면 directive를
  더할 때마다 목록을 고쳐야 하고, "닫는 줄 없으면 문서 끝까지"면 닫기를 빠뜨린
  directive 하나가 뒤 내용을 통째로 삼킨다
- 계약이 표현 못 하는 것은 조용히 바꾸지 않고 경고로 남긴다 — 수평선, 머리글 없는 표,
  알 수 없는 directive, 미해결 asset key(경로를 지어내지 않고 버린다)
- RenderModelPort 구현이 inbound web에 있다. 렌더 모델은 계약 DTO이고 그 타입을 소유한
  모듈이 거기다. application에 같은 모양을 한 벌 더 두면 두 정의가 갈라진다

검증 체인
판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 PublicRenderModel을 만들 수 있는가.
각 규칙은 렌더 모델의 required/minLength/minItems에서 나온다. 다른 기준을 쓰면 검증을
통과한 문서가 렌더 단계에서 계약을 위반한다. 첫 오류에서 멈추지 않고 끝까지 모은다.

게시 (spec §7.5 20단계)
- Snapshot의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의
  것을 그대로 쓴다. 다시 렌더링하면 승인한 화면과 공개된 화면이 달라질 수 있다
- 단계별 실패가 서로 다른 계약 코드로 나간다. DOCUMENT_VALIDATION_FAILED(지금 검증하면
  실패)와 VALIDATION_STALE(통과했으나 전제가 바뀜)은 다른 사건이고 할 일도 다르다
- 게시 취소는 route도 Snapshot도 지우지 않는다. 지우면 공개된 링크가 끊긴다

Asset
- 확장자와 클라이언트 Content-Type을 신뢰하지 않고 파일 시작 바이트로 판정한다.
  모르는 형식은 저장하지 않고 415로 거절한다
- 바이너리는 기존 object storage 어댑터에 위임한다(spec §9). ObjectStoragePort는
  deprecated지만 이 저장소에서 실제 구현이 붙어 있는 유일한 포트다 — 선택을 브리지
  한 클래스에 가뒀다. 저장 백엔드가 없는 배포는 업로드·삭제만 503이고 나머지는 동작한다
- 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다

검증 — "통과하는데 동작 안 함"을 세 겹으로 막았다
- StudioContractDriftTest에 반대 방향(계약 → published)을 추가했다. 기존 한 방향은
  사라진 operation을 못 잡는다. 양방향 모두 실제로 RED가 되는 것을 확인했다
- postgresqlTechLogStudioPersistenceIntegrationTest 신규 11개. 이 저장소의 check는
  Testcontainers를 돌리지 않아 이 테스트가 없으면 SQL이 한 번도 실행되지 않는다.
  첫 실행에서 실제 결함을 잡았다: fk_publication_latest_event의 지연 검사는 트랜잭션
  끝에 일어나므로 autocommit이면 첫 INSERT에서 위반된다 → 어댑터가 진입 시 활성
  트랜잭션을 확인하고 아니면 원인을 그대로 말하며 실패한다
- 실제 앱 부팅으로 두 건을 더 잡았다. check에 전체 앱 부팅 테스트가 없어 생긴 구멍이다
  1. 생성자 모호성 — 프로덕션/테스트 두 생성자에 표시가 없어 기본 생성자를 찾다 실패
  2. final 클래스 + AOP — @RequiresPermission은 CGLIB 프록시를 쓰는데 final은 subclass
     불가. 템플릿의 NotificationDispatchUseCase가 final이면서 무사한 것은 그 능력이 꺼진
     배포에서 빈으로 등록되지 않아서다. Studio use case는 항상 등록된다

가드레일이 잡은 것
- MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION → studio:write 부여, role 매핑은
  프로파일에. application.yml의 role-permissions:{} 기준선은 SampleRemovalSmokeContractTest가 지킨다
- NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE 223건 → 어댑터 패키지를 persistence.techlog.studio.*로
  옮겼다. 규칙을 고치지 않았고, 그 이름이 우회가 아니라 더 정확하다
- verifyEnvKeys → 새 APP_ 키 4건 등록

범위
studio-v1의 19개 전부. public-v1(18) / studio-management-v1(79)은 spec §2.2가 선언한
out of scope다 — 전자는 소비자가 아직 없고 후자는 secondary capability 보존 계약이다.

검증: ./gradlew check BUILD SUCCESSFUL (245 task), 전체 3,721 테스트 실패 0,
techlog PostgreSQL 통합 테스트 3종 통과, 실제 앱 부팅 확인.

AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "전부 커밋하고 머지
진행하세요"로 명시적으로 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 23:21:43 +09:00
co-authored by Claude Opus 5
parent e615c24152
commit 48ff648112
163 changed files with 11807 additions and 10 deletions
@@ -0,0 +1,12 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
/**
* {@code createStudioDocument}의 입력.
*
* @param principal 감사 컬럼({@code created_by}/{@code updated_by})에 남길 주체
*/
public record CreateDocumentCommand(WorkingCopyInputView document, String principal)
implements Command {}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.UUID;
/** {@code createStudioPreview}의 입력. */
public record CreatePreviewCommand(
UUID documentId, long expectedVersion, UUID validationId, String principal)
implements Command {}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.UUID;
/** {@code deleteStudioAsset}의 입력. */
public record DeleteAssetCommand(UUID assetId, String principal) implements Command {}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.List;
import java.util.UUID;
/** {@code publishStudioDocument}의 입력. */
public record PublishDocumentCommand(
UUID documentId,
long expectedVersion,
UUID validationId,
UUID previewId,
List<String> acknowledgedWarningCodes,
String idempotencyKey,
String principal)
implements Command {
public PublishDocumentCommand {
acknowledgedWarningCodes =
acknowledgedWarningCodes == null ? List.of() : List.copyOf(acknowledgedWarningCodes);
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import java.util.UUID;
/** {@code saveStudioDocument}의 입력. */
public record SaveDocumentCommand(
UUID documentId, long expectedVersion, WorkingCopyInputView document, String principal)
implements Command {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.UUID;
/** {@code unpublishStudioPublication}의 입력. */
public record UnpublishPublicationCommand(
UUID publicationId, long expectedPublicationRevision, String principal) implements Command {}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import java.util.UUID;
/**
* {@code updateStudioAsset}의 입력. 계약이 허용하는 것은 {@code altText}, {@code decorative}, {@code kind}, 그리고
* {@code READY ↔ ARCHIVED} 전환뿐이다.
*
* @param altTextProvided {@code altText}가 요청에 실렸는지. null 로 지우는 것과 아예 안 보낸 것을 구분한다.
*/
public record UpdateAssetCommand(
UUID assetId,
long expectedVersion,
AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
AssetManagementStatusView managementStatus,
String principal)
implements Command {}
@@ -0,0 +1,74 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import java.util.Objects;
/**
* {@code uploadStudioAsset}의 입력.
*
* <p>record 가 아니라 class 인 이유는 바이트 배열 때문이다 — 배열을 record 구성요소로 두면 {@code equals} 가 내용이 아니라 참조를 비교해
* "같은 파일"을 다르다고 판정한다.
*/
public final class UploadAssetCommand implements Command {
private final String originalFilename;
private final String declaredMediaType;
private final byte[] content;
private final AssetKindView kind;
private final String altText;
private final boolean decorative;
private final String principal;
/**
* @param declaredMediaType 클라이언트가 말한 것. Backend 는 이 값을 신뢰하지 않고 내용으로 다시 판정한다 (계약 설명).
*/
public UploadAssetCommand(
String originalFilename,
String declaredMediaType,
byte[] content,
AssetKindView kind,
String altText,
boolean decorative,
String principal) {
this.originalFilename = originalFilename;
this.declaredMediaType = declaredMediaType;
this.content = Objects.requireNonNull(content, "content").clone();
this.kind = kind;
this.altText = altText;
this.decorative = decorative;
this.principal = principal;
}
public String originalFilename() {
return originalFilename;
}
public String declaredMediaType() {
return declaredMediaType;
}
public byte[] content() {
return content.clone();
}
public int byteSize() {
return content.length;
}
public AssetKindView kind() {
return kind;
}
public String altText() {
return altText;
}
public boolean decorative() {
return decorative;
}
public String principal() {
return principal;
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.UUID;
/** {@code validateStudioDocument}의 입력. */
public record ValidateDocumentCommand(UUID documentId, long expectedVersion, String principal)
implements Command {}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/**
* 계약 {@code AssetDetail}.
*
* @param hasPublicationHistory 참이면 hard delete 를 금지하고 {@code ARCHIVED} 전환만 허용한다
*/
public record AssetDetailView(
AssetView asset, List<AssetUsageView> usages, boolean hasPublicationHistory) {
public AssetDetailView {
usages = usages == null ? List.of() : List.copyOf(usages);
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code AssetKind}. */
public enum AssetKindView {
IMAGE,
DIAGRAM,
ATTACHMENT
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 계약 {@code AssetManagementStatus}.
*
* <p>{@link #REJECTED}/{@link #QUARANTINED}는 서버 검증 결과이며 클라이언트가 지정할 수 없다.
*/
public enum AssetManagementStatusView {
READY,
ARCHIVED,
REJECTED,
QUARANTINED
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/**
* 게시 시점에 고정하는 Asset descriptor 한 건({@code publication_snapshot.asset_manifest}).
*
* <p>이후 Asset 이 교체돼도 과거 Snapshot 의 표현이 변하지 않게 하는 장치다 — ADR-002 가 요구하는 역사적 불변성이며, ADR-005 가 인정한 "네
* 화면 중 Snapshot 만 다른 유일한 지점"이다.
*/
public record AssetManifestEntry(
UUID assetId,
String assetKey,
String mediaType,
String publicPath,
Integer width,
Integer height,
boolean decorative) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code AssetPage}. */
public record AssetPageView(List<AssetView> items, String nextCursor) {
public AssetPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code AssetUsage}. 이 Asset 을 쓰는 문서 한 건. */
public record AssetUsageView(
UUID documentId, RecordKind documentKind, String title, boolean published) {}
@@ -0,0 +1,28 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code Asset}.
*
* @param assetKey Public content 가 쓰는 안정적인 key. object storage key 도 raw URL 도 아니며 immutable 이다 —
* 공개 이력이 있는 key 의 재사용은 금지한다.
*/
public record AssetView(
UUID id,
String assetKey,
AssetKindView kind,
String mediaType,
String originalFilename,
long byteSize,
Integer width,
Integer height,
String altText,
boolean decorative,
AssetManagementStatusView managementStatus,
String publicPath,
int usageCount,
long version,
Instant createdAt,
Instant updatedAt) {}
@@ -0,0 +1,5 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code DashboardTotals}. */
public record DashboardTotalsView(
int documents, int needsValidation, int readyToPublish, int publications) {}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code StudioDashboard}. */
public record DashboardView(
List<DocumentSummaryView> continueWriting,
List<DocumentSummaryView> readyToPublish,
List<PublicationListItemView> recentPublications,
DashboardTotalsView totals) {
public DashboardView {
continueWriting = continueWriting == null ? List.of() : List.copyOf(continueWriting);
readyToPublish = readyToPublish == null ? List.of() : List.copyOf(readyToPublish);
recentPublications = recentPublications == null ? List.of() : List.copyOf(recentPublications);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약의 UI 용어(ADR-003). Domain의 {@code ACCEPTED}가 {@link #ADOPTED}로 보인다. */
public enum DecisionStatusView {
PROPOSED,
ADOPTED
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code DisplayTarget}. */
public record DisplayTargetView(UUID id, String label, String publicPath) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code DocumentPage}. */
public record DocumentPageView(List<DocumentSummaryView> items, String nextCursor) {
public DocumentPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 파라미터 {@code sort} (studio-v1.yaml components.parameters.DocumentSort). */
public enum DocumentSort {
UPDATED_DESC,
UPDATED_ASC,
TITLE_ASC
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code DocumentSummary}. 목록·대시보드가 쓰는 요약이다.
*
* @param publishedVersion 게시한 적이 없으면 null
* @param hasUnpublishedChanges {@code version != publishedVersion}. 게시 취소 상태에서도 과거
* publishedVersion과 비교한다(계약 주석).
*/
public record DocumentSummaryView(
UUID id,
String title,
RecordKind kind,
DisplayTargetView project,
Instant updatedAt,
PublicationStatusView publicationStatus,
Long publishedVersion,
boolean hasUnpublishedChanges,
NextAction nextAction) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code NextAction}. 서버가 조회 시점에 계산하는 Studio projection이며 domain 컬럼에 저장하지 않는다 (spec §6.3). */
public enum NextAction {
CONTINUE_EDITING,
VALIDATE,
FIX_VALIDATION,
CREATE_PREVIEW,
PUBLISH,
NONE
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code OrderedText}. */
public record OrderedTextView(UUID id, String text, int order) {}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code PreviewDetail}. */
public record PreviewDetailView(
PublicPreviewView preview,
PreviewState state,
long currentDocumentVersion,
UUID currentValidationId) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PreviewDetail.state}. 저장하지 않고 조회 시 계산한다(spec §7.3). */
public enum PreviewState {
CURRENT,
STALE,
EXPIRED
}
@@ -0,0 +1,33 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 공개 경로 규칙(설계 01장 §3, {@code public-v1.yaml}의 path).
*
* <p>한 곳에 둔다 — 렌더 모델의 {@code publicPath}, 카탈로그의 {@code publicPath}, 게시 시 만드는 {@code public_route}가
* 서로 다른 규칙으로 만들어지면 미리보기의 링크와 실제 공개 주소가 달라진다.
*/
public final class PublicPaths {
private PublicPaths() {}
/**
* 이 유형과 slug 의 공개 경로.
*
* @param projectSlug {@code PROJECT_DECISION}에만 쓰인다. 없으면 결정 경로를 만들 수 없어 null을 준다.
* @return slug가 비어 있으면 null — 아직 공개 주소가 없는 초안이다.
*/
public static String forKind(RecordKind kind, String slug, String projectSlug) {
if (slug == null || slug.isBlank()) {
return null;
}
return switch (kind) {
case CASE -> "/cases/" + slug;
case REFERENCE -> "/references/" + slug;
case QUESTION -> "/questions/" + slug;
case PROJECT_DECISION ->
(projectSlug == null || projectSlug.isBlank())
? null
: "/projects/" + projectSlug + "/decisions/" + slug;
};
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code PublicPreview}.
*
* <p>{@code renderModel}을 application 계층에서 구조화된 타입으로 다시 모델링하지 않고 직렬화된 JSON 문자열로 들고 다닌다. 이유: 이 값은
* {@code studio_preview.render_model}(jsonb)에 그대로 저장되고 웹 계층에서 계약 DTO로 그대로 나가는 통과 데이터이며, 중간에 한 번 더
* 도메인 타입으로 접었다 펴면 렌더러가 만든 모양과 계약 모양 사이에 조용한 손실이 생길 수 있다. 렌더링 자체의 타입 안전성은 렌더러가 계약 DTO를 직접 만들며 책임진다.
*/
public record PublicPreviewView(
UUID previewId,
UUID documentId,
long previewVersion,
UUID validationId,
String dependencyRevision,
Instant createdAt,
Instant expiresAt,
String renderModelJson) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublicationAction} (studio-v1.yaml:1661). */
public enum PublicationActionView {
VIEW_SNAPSHOT,
VIEW_SOURCE_SNAPSHOT,
UNPUBLISH
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublicationAggregate.status}. */
public enum PublicationAggregateStatus {
PUBLISHED,
UNPUBLISHED
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/** 계약 {@code PublicationAggregate}. 현재 게시 상태이며 게시 이력과 구분한다. */
public record PublicationAggregateView(
UUID publicationId,
UUID documentId,
PublicationAggregateStatus status,
long publishedVersion,
long publicationRevision,
UUID latestEventId,
String publicPath,
Instant updatedAt) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublicationEventType}. */
public enum PublicationEventTypeView {
PUBLISHED,
REPUBLISHED,
UNPUBLISHED
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code PublicationEvent}. 불변 이력이며 생성 후 수정하지 않는다.
*
* @param sourcePublishedEventId {@code UNPUBLISHED} 이벤트가 참조하는 마지막 공개 Snapshot의 Event id
*/
public record PublicationEventView(
UUID publicationEventId,
UUID publicationId,
UUID documentId,
PublicationEventTypeView type,
Instant occurredAt,
long publishedVersion,
UUID sourcePublishedEventId,
boolean snapshotAvailable) {}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code PublicationListItem}. */
public record PublicationListItemView(
PublicationEventView event,
PublicationAggregateView publication,
DocumentSummaryView document,
List<PublicationActionView> availableActions) {
public PublicationListItemView {
availableActions = availableActions == null ? List.of() : List.copyOf(availableActions);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code PublicationPage}. */
public record PublicationPageView(List<PublicationListItemView> items, String nextCursor) {
public PublicationPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 계약 {@code PublicationSnapshot}. 게시 시점의 불변 {@code PublicRenderModel} 이며 현재 source 로 다시 만들지 않는다.
*/
public record PublicationSnapshotView(
PublicationEventView event,
String renderModelJson,
String contentFormatVersion,
String rendererContractVersion) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublicationStatus}. */
public enum PublicationStatusView {
NEVER_PUBLISHED,
PUBLISHED,
UNPUBLISHED
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublishResult}. */
public record PublishResultView(PublicationAggregateView publication, PublicationEventView event) {}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code QuestionOption}. */
public record QuestionOptionView(UUID id, String title, String description, int order) {}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code QuestionResolution}. */
public record QuestionResolutionView(String summary, UUID evidenceTargetId, String linkLabel) {}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 계약의 축약 상태(ADR-003). Domain의 {@code OPEN}/{@code INVESTIGATING}/{@code PAUSED}가 모두 {@link #OPEN}으로
* 보이며, 그 역방향 변환은 Domain 상태를 덮어쓰지 않는다.
*/
public enum QuestionStatusView {
OPEN,
RESOLVED
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* Studio 편집 대상 유형. 계약({@code studio-v1.yaml} {@code RecordKind})의 discriminator이며 Domain Aggregate가
* 아니다 — ADR-003대로 네 유형은 각자 자기 Aggregate와 테이블을 그대로 소유한다.
*/
public enum RecordKind {
CASE,
REFERENCE,
QUESTION,
PROJECT_DECISION
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code ReferenceRule}. */
public record ReferenceRuleView(UUID id, String title, String body, int order) {}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/**
* 계약 {@code Relation} / {@code RelationInput}. {@code targetId}가 null일 수 있는 것은 아직 대상을 고르지 않은 관계 줄도
* 저장할 수 있어야 하기 때문이다 — 게시 가능 여부는 검증이 판단한다.
*/
public record RelationView(UUID id, UUID targetId, String reason, int order) {}
@@ -0,0 +1,29 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* 렌더러가 {@code PublicRenderModel}을 만드는 데 필요한 모든 것. 렌더러는 이 값 밖의 어떤 상태도 읽지 않는다 — 그래야
* Preview·공개·Snapshot 세 화면이 같은 입력에 같은 출력을 낸다(ADR-005).
*
* @param assetsByKey {@code asset_key} → 해석된 Asset. Snapshot은 게시 시점에 고정된 manifest를 넣고 나머지 화면은 현재
* 상태를 넣는다 — 그것이 ADR-002가 요구하는 유일하게 허용된 차이다.
*/
public record RenderInput(
WorkingCopyView document,
String publicPath,
DisplayTargetView topic,
DisplayTargetView project,
List<ResolvedRelationView> relations,
DisplayTargetView resolutionEvidenceTarget,
Map<String, ResolvedAssetView> assetsByKey,
String dependencyRevision,
Instant generatedAt) {
public RenderInput {
relations = relations == null ? List.of() : List.copyOf(relations);
assetsByKey = assetsByKey == null ? Map.of() : Map.copyOf(assetsByKey);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/**
* 계약 {@code ResolvedAsset}. 네 화면(즉시 미리보기 / Public Preview / 공개 / Snapshot)이 같은 resolver를 거쳐 같은 결과를
* 얻어야 한다(ADR-005).
*
* @param publicPath {@code asset_key}로 찾은 현재 승인된 전송 경로. 본문에는 이 값을 저장하지 않는다.
*/
public record ResolvedAssetView(
UUID assetId,
String assetKey,
String mediaType,
String publicPath,
Integer width,
Integer height,
boolean decorative) {}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/**
* 계약 {@code ResolvedRelation}. 편집본의 관계에 대상의 제목·공개 경로를 붙인 것이다.
*
* @param targetKind 대상이 무엇인지. {@code PROJECT}는 {@link RecordKind}에 없는 값이라 문자열로 둔다 — 계약({@code
* ResolvedRelation.targetKind})의 enum이 RecordKind보다 하나 넓다.
*/
public record ResolvedRelationView(
UUID id,
UUID targetId,
String targetKind,
String title,
String publicPath,
String reason,
int order) {}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 계약 {@code ValidationIssue}.
*
* @param path 영향을 받은 필드의 JSON Pointer
*/
public record ValidationIssueView(
String code, ValidationSeverity severity, String path, String message) {}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* 계약 {@code ValidationReport}. 일급 artifact이며 {@code studio_validation}에 영속한다 — 실행 결과를 그때그때 반환하고 버리지
* 않는다.
*/
public record ValidationReportView(
UUID validationId,
UUID documentId,
long validatedVersion,
ValidationStatus status,
List<ValidationIssueView> issues,
Instant validatedAt,
Instant validUntil,
String dependencyRevision) {
public ValidationReportView {
issues = issues == null ? List.of() : List.copyOf(issues);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code ValidationIssue.severity}. */
public enum ValidationSeverity {
ERROR,
WARNING
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code ValidationReport.status}. */
public enum ValidationStatus {
INVALID,
WARNINGS,
VALID
}
@@ -0,0 +1,26 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
import java.util.UUID;
/**
* 계약 {@code WorkingCopyInputBase}. 네 유형이 공유하는 편집 필드다.
*
* <p>불완전한 초안도 저장할 수 있어야 하므로 빈 문자열과 null을 허용한다 — 게시 가능 여부는 {@code validateStudioDocument}가 판단한다(계약
* 주석).
*
* @param slug 빈 문자열은 "아직 정하지 않음"이다. null이 아니다.
*/
public record WorkingCopyBaseInput(
RecordKind kind,
String title,
String slug,
String summary,
UUID topicId,
UUID projectId,
List<RelationView> relations) {
public WorkingCopyBaseInput {
relations = relations == null ? List.of() : List.copyOf(relations);
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 계약 {@code WorkingCopyDetail}. 편집본과 그 주변 상태를 한 번에 준다.
*
* <p>{@code currentValidation} / {@code latestPreview} / {@code currentPublication}은 계약상 nullable이며
* 각각 아직 검증·미리보기·게시하지 않은 상태를 뜻한다. {@code nextAction}은 저장하지 않고 조회 시점에 계산한다(spec §6.3).
*/
public record WorkingCopyDetailView(
WorkingCopyView document,
ValidationReportView currentValidation,
PublicPreviewView latestPreview,
PublicationAggregateView currentPublication,
String dependencyRevision,
NextAction nextAction) {}
@@ -0,0 +1,86 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.LocalDate;
import java.util.List;
/**
* 계약 {@code WorkingCopyInput} union. 저장 요청으로 들어오는 편집 내용이다.
*
* <p>sealed로 두는 이유는 use case의 dispatch가 네 유형을 빠짐없이 다루도록 컴파일러가 강제하게 하기 위해서다 — 유형이 늘면 switch가 컴파일 오류로
* 알려준다.
*/
public sealed interface WorkingCopyInputView {
WorkingCopyBaseInput base();
default RecordKind kind() {
return base().kind();
}
/** 계약 {@code CaseInput}. */
record CaseInputView(
WorkingCopyBaseInput base,
String problem,
String conclusion,
String environment,
String reproduction,
LocalDate lastVerifiedOn,
String bodyMarkdown)
implements WorkingCopyInputView {}
/** 계약 {@code ReferenceInput}. */
record ReferenceInputView(
WorkingCopyBaseInput base,
String purpose,
List<ReferenceRuleView> rules,
List<OrderedTextView> applyWhen,
List<OrderedTextView> exceptions,
List<OrderedTextView> examples,
LocalDate verifiedOn)
implements WorkingCopyInputView {
public ReferenceInputView {
rules = rules == null ? List.of() : List.copyOf(rules);
applyWhen = applyWhen == null ? List.of() : List.copyOf(applyWhen);
exceptions = exceptions == null ? List.of() : List.copyOf(exceptions);
examples = examples == null ? List.of() : List.copyOf(examples);
}
}
/** 계약 {@code QuestionInput}. */
record QuestionInputView(
WorkingCopyBaseInput base,
QuestionStatusView questionStatus,
List<OrderedTextView> facts,
List<OrderedTextView> assumptions,
List<OrderedTextView> unknowns,
List<OrderedTextView> constraints,
List<QuestionOptionView> options,
String nextValidation,
QuestionResolutionView resolution)
implements WorkingCopyInputView {
public QuestionInputView {
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);
options = options == null ? List.of() : List.copyOf(options);
}
}
/** 계약 {@code ProjectDecisionInput}. */
record ProjectDecisionInputView(
WorkingCopyBaseInput base,
DecisionStatusView decisionStatus,
LocalDate decidedOn,
String statement,
String rationale,
List<OrderedTextView> consequences)
implements WorkingCopyInputView {
public ProjectDecisionInputView {
consequences = consequences == null ? List.of() : List.copyOf(consequences);
}
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* 계약 {@code WorkingCopy} union. 저장된 편집본을 읽어 돌려줄 때의 모양이다.
*
* <p>{@code id}는 계약대로 source aggregate id를 그대로 쓴다 — 별도 Studio surrogate id를 만들지 않는다 (ADR-003).
*/
public sealed interface WorkingCopyView {
UUID id();
long version();
Instant updatedAt();
WorkingCopyBaseInput base();
default RecordKind kind() {
return base().kind();
}
/** 계약 {@code CaseWorkingCopy}. */
record CaseWorkingCopyView(
UUID id,
long version,
Instant updatedAt,
WorkingCopyBaseInput base,
String problem,
String conclusion,
String environment,
String reproduction,
LocalDate lastVerifiedOn,
String bodyMarkdown)
implements WorkingCopyView {}
/** 계약 {@code ReferenceWorkingCopy}. */
record ReferenceWorkingCopyView(
UUID id,
long version,
Instant updatedAt,
WorkingCopyBaseInput base,
String purpose,
List<ReferenceRuleView> rules,
List<OrderedTextView> applyWhen,
List<OrderedTextView> exceptions,
List<OrderedTextView> examples,
LocalDate verifiedOn)
implements WorkingCopyView {}
/** 계약 {@code QuestionWorkingCopy}. */
record QuestionWorkingCopyView(
UUID id,
long version,
Instant updatedAt,
WorkingCopyBaseInput base,
QuestionStatusView questionStatus,
List<OrderedTextView> facts,
List<OrderedTextView> assumptions,
List<OrderedTextView> unknowns,
List<OrderedTextView> constraints,
List<QuestionOptionView> options,
String nextValidation,
QuestionResolutionView resolution)
implements WorkingCopyView {}
/** 계약 {@code ProjectDecisionWorkingCopy}. */
record ProjectDecisionWorkingCopyView(
UUID id,
long version,
Instant updatedAt,
WorkingCopyBaseInput base,
DecisionStatusView decisionStatus,
LocalDate decidedOn,
String statement,
String rationale,
List<OrderedTextView> consequences)
implements WorkingCopyView {}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.techlog.studio.port.out;
/**
* Asset 바이너리 저장. spec §9 — Studio 는 새 저장 계층을 만들지 않고 기존 object storage 어댑터에 위임한다.
*
* <p>{@code assetKey}(안정적인 참조)와 {@code objectKey}(저장 위치)를 분리한다 — 본문에 저장소 경로가 새어 나가면 저장소를 바꿀 때 과거
* 문서가 전부 깨진다(설계 05장 §3.1).
*/
public interface AssetBinaryStoragePort {
/**
* 바이너리를 저장하고 저장 위치를 돌려준다.
*
* @throws dev.caskeleton.application.techlog.error.StudioException 저장 계층이 구성되지 않았으면 {@code
* STUDIO_UNAVAILABLE}
*/
String store(String objectKey, byte[] content, String mediaType);
void delete(String objectKey);
}
@@ -0,0 +1,63 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import java.util.Optional;
import java.util.UUID;
/** {@code asset} 메타데이터. 바이너리는 {@link AssetBinaryStoragePort} 가 소유한다(spec §9). */
public interface AssetRepositoryPort {
AssetPageView list(ListAssetsQuery query);
Optional<AssetView> find(UUID assetId);
Optional<AssetDetailView> findDetail(UUID assetId);
AssetView create(NewAsset asset, String principal);
/**
* 낙관적 잠금 갱신.
*
* @return {@code expectedVersion} 이 현재 버전과 다르면 {@link Optional#empty()}
*/
Optional<AssetView> update(
UUID assetId,
long expectedVersion,
AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
AssetManagementStatusView managementStatus,
String principal);
/**
* 바이너리를 실제로 저장한 위치. 계약이 노출하지 않는 값이라 {@code AssetView} 에는 없다.
*
* <p>삭제 경로가 이 값을 규칙으로 다시 계산하지 않고 저장된 것을 읽는 이유: 규칙이 두 곳에 있으면 업로드 규칙이 바뀐 순간 옛 Asset 의 바이너리를 못 찾아
* 조용히 남는다.
*/
Optional<String> findObjectKey(UUID assetId);
void delete(UUID assetId);
/** 새 Asset 의 메타데이터. {@code objectKey} 는 바이너리를 실제로 저장한 위치다. */
record NewAsset(
UUID id,
String assetKey,
AssetKindView kind,
String mediaType,
String objectKey,
String originalFilename,
long byteSize,
Integer width,
Integer height,
String checksumSha256,
String altText,
boolean decorative,
AssetManagementStatusView managementStatus) {}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.techlog.studio.port.out;
import java.util.List;
/**
* 본문 Markdown을 게시 검증에 필요한 만큼 분석한다(spec §7.5 7~8단계).
*
* <p>렌더러와 같은 파서를 쓴다 — 검증이 "이 문서가 참조하는 Asset"을 렌더러와 다르게 세면, 검증을 통과한 문서가 렌더 단계에서 없는 Asset을 참조하게 된다.
*/
@FunctionalInterface
public interface ContentAnalyzerPort {
ContentAnalysis analyze(String bodyMarkdown);
/**
* 본문 분석 결과.
*
* @param assetUsages 본문의 각 사용 위치. 같은 key가 여러 번 쓰이면 여러 항목이 된다 — alt는 사용 위치마다 다를 수 있어 Asset 한 행으로
* 판단할 수 없다(V7 asset 테이블 주석).
* @param unsupportedDirectives 지원하지 않는 directive 이름. 조용히 잘못 해석하지 않고 경고로 드러낸다 (설계 05장 §4).
*/
record ContentAnalysis(
List<AssetUsage> assetUsages, List<String> unsupportedDirectives, String plainText) {
public ContentAnalysis {
assetUsages = assetUsages == null ? List.of() : List.copyOf(assetUsages);
unsupportedDirectives =
unsupportedDirectives == null ? List.of() : List.copyOf(unsupportedDirectives);
plainText = plainText == null ? "" : plainText;
}
}
/** 본문 안의 Asset 사용 한 곳. */
record AssetUsage(String assetKey, String alt) {}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import java.util.UUID;
/**
* 검증 결과에 영향을 주는 외부 의존 상태를 정규화한 값을 계산한다(계약 {@code DependencyRevision}, spec §7.3).
*
* <p>전역 카운터가 아니다 — 이 문서가 실제로 의존하는 것들(topic/project, relation target, asset, slug/route, renderer
* contract version)의 identity+version만 모아 해시한다. Publish 시 다시 계산해 값이 다르면 {@code VALIDATION_STALE}로
* 거절한다.
*/
@FunctionalInterface
public interface DependencyRevisionPort {
String revisionFor(RecordKind kind, UUID documentId);
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import java.util.Optional;
import java.util.UUID;
/** {@code studio_preview} 영속 artifact 접근(spec §7.3). */
public interface PreviewArtifactPort {
Optional<PublicPreviewView> latestFor(RecordKind kind, UUID documentId);
Optional<PublicPreviewView> findById(UUID previewId);
PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal);
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import java.util.Optional;
import java.util.UUID;
/** 게시 이력·Snapshot 조회. */
public interface PublicationHistoryQueryPort {
PublicationPageView list(ListPublicationsQuery query);
Optional<PublicationSnapshotView> findSnapshot(UUID publicationEventId);
Optional<PublicationAggregateView> findById(UUID publicationId);
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import java.util.Optional;
import java.util.UUID;
/** 현재 게시 상태 조회. 게시 이력(Event) 조회와 쓰기는 슬라이스 4의 별도 포트가 담당한다. */
@FunctionalInterface
public interface PublicationQueryPort {
Optional<PublicationAggregateView> currentFor(UUID documentId);
}
@@ -0,0 +1,64 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* spec §7.5 의 게시 트랜잭션 10~19단계. 한 트랜잭션 안에서 전부 수행한다.
*
* <p>결정(무엇을 게시해도 되는가)은 use case 가 하고, 여기서는 결정된 것을 쓰기만 한다 — 그래야 "어떤 경로로 게시했느냐"에 따라 검사 항목이 달라지는 일이
* 생기지 않는다.
*/
public interface PublicationWriterPort {
/** spec §7.5 1단계. 같은 문서에 대한 동시 게시를 직렬화한다. */
Optional<PublicationAggregateView> lockCurrentPublication(RecordKind kind, UUID documentId);
PublishResultView publish(PublishRequest request);
PublishResultView unpublish(UnpublishRequest request);
/**
* 게시할 내용 전부. 값 하나하나가 이미 검증을 통과한 상태다.
*
* @param renderModelJson 사용자가 확인한 <b>미리보기의</b> 렌더 모델. 게시 시점에 다시 렌더링하지 않는다 — 다시 렌더링하면 승인한 화면과 공개된
* 화면이 달라질 수 있다(spec §7.5).
* @param stateCode 유형별 공개 상태({@code QUESTION} 은 OPEN/RESOLVED, {@code PROJECT_DECISION} 은
* PROPOSED/ADOPTED). 나머지는 null.
*/
record PublishRequest(
RecordKind kind,
UUID documentId,
long version,
String title,
String summary,
UUID topicId,
UUID projectId,
String publicPath,
String stateCode,
String renderModelJson,
String bodyPlainText,
List<AssetManifestEntry> assetManifest,
String contentFormatVersion,
String rendererContractVersion,
String idempotencyKey,
String principal) {
public PublishRequest {
assetManifest = assetManifest == null ? List.of() : List.copyOf(assetManifest);
}
}
/** 게시 취소. Snapshot 은 삭제하지 않는다 — 이력은 지우지 않는다(spec §7.5). */
record UnpublishRequest(
UUID publicationId,
RecordKind kind,
UUID documentId,
long expectedRevision,
String principal) {}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.RenderInput;
/**
* 편집본을 계약의 {@code PublicRenderModel} JSON으로 만든다.
*
* <p>결과를 구조화된 application 타입이 아니라 JSON 문자열로 주고받는 이유: 이 값은 {@code studio_preview.render_model}에 그대로
* 들어가고 게시 시 그대로 snapshot으로 옮겨진 뒤 그대로 응답으로 나간다. 중간에서 한 번 더 접었다 펴면 사용자가 확인한 화면과 공개된 화면이 달라질 수 있고, 그
* 차이는 아무도 검증하지 않는다(ADR-005가 막으려는 바로 그 사건이다).
*
* <p>구현이 inbound web 모듈에 있는 것은 의도적이다 — 렌더 모델은 계약 DTO이고 그 타입을 소유한 모듈이 거기다. application에 같은 모양을 한 벌 더
* 두면 두 정의가 갈라진다.
*/
@FunctionalInterface
public interface RenderModelPort {
String renderToJson(RenderInput input);
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.DashboardTotalsView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import java.util.List;
/** 대시보드 union query. 목록과 같은 {@code studio_document} 정의를 쓴다. */
public interface StudioDashboardQueryPort {
List<DocumentSummaryView> topByNextAction(List<NextAction> actions, int limit);
DashboardTotalsView totals();
}
@@ -0,0 +1,51 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* 편집본이 의존하는 바깥 상태를 한 번에 해석한다 — topic/project, 관계 대상, Asset, slug 소유권.
*
* <p>검증과 렌더링이 <b>같은</b> 해석 결과를 쓴다. 두 곳이 따로 조회하면 그 사이에 상태가 바뀌어 "검증은 통과했는데 렌더는 없는 Asset을 가리킨다" 같은 사건이
* 생긴다.
*/
@FunctionalInterface
public interface StudioDependencyResolverPort {
Resolved resolve(WorkingCopyView document, Set<String> referencedAssetKeys);
/**
* 해석 결과.
*
* @param topic 없으면 null. {@code topicMissing}이 참이면 topicId가 있는데 그 행이 없다는 뜻이다.
* @param assetStatusByKey key → {@code management_status}. 없는 key는 아예 담기지 않는다.
* @param slugOwnerId 같은 유형에서 이 slug를 이미 쓰는 <b>다른</b> 문서. 없으면 null.
*/
record Resolved(
DisplayTargetView topic,
boolean topicMissing,
DisplayTargetView project,
boolean projectMissing,
List<ResolvedRelationView> relations,
List<UUID> missingRelationTargets,
Map<String, ResolvedAssetView> assetsByKey,
Map<String, String> assetStatusByKey,
DisplayTargetView resolutionEvidenceTarget,
String publicPath,
UUID slugOwnerId) {
public Resolved {
relations = relations == null ? List.of() : List.copyOf(relations);
missingRelationTargets =
missingRelationTargets == null ? List.of() : List.copyOf(missingRelationTargets);
assetsByKey = assetsByKey == null ? Map.of() : Map.copyOf(assetsByKey);
assetStatusByKey = assetStatusByKey == null ? Map.of() : Map.copyOf(assetStatusByKey);
}
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
/**
* Studio 문서 목록. 네 유형이 서로 다른 테이블에 있으므로 공통 repository를 만들지 않고 query side에서 union projection을 구성한다(계약
* {@code listStudioDocuments} 설명, spec §8.3).
*/
@FunctionalInterface
public interface StudioDocumentQueryPort {
DocumentPageView list(ListDocumentsQuery query);
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import java.util.Optional;
import java.util.UUID;
/** {@code studio_validation} 영속 artifact 접근(spec §7.3). */
public interface ValidationArtifactPort {
/** 이 문서에 대한 가장 최근 검증 결과. 검증한 적이 없으면 비어 있다. */
Optional<ValidationReportView> latestFor(RecordKind kind, UUID documentId);
Optional<ValidationReportView> findById(UUID validationId);
ValidationReportView save(RecordKind kind, ValidationReportView report, String principal);
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.techlog.studio.port.out;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.Optional;
import java.util.UUID;
/**
* 네 source aggregate에 흩어진 편집본을 하나의 API projection으로 읽고 쓴다.
*
* <p>범용 CRUD repository가 아니다 — 구현이 {@code kind}에 따라 각자의 테이블로 dispatch한다(ADR-003).
*/
public interface WorkingCopyRepositoryPort {
/** {@code documentId}가 어느 유형인지 해소한다(spec §7.2 StudioDocumentLocator). */
Optional<RecordKind> findKind(UUID documentId);
Optional<WorkingCopyView> find(UUID documentId);
WorkingCopyView create(WorkingCopyInputView input, String principal);
/**
* 낙관적 잠금 저장.
*
* @return 저장된 편집본. {@code expectedVersion}이 현재 버전과 다르면 {@link Optional#empty()} — "없음"과 "충돌"을
* 호출자가 구분할 수 있도록 존재 확인은 {@link #find}가 따로 한다.
*/
Optional<WorkingCopyView> save(
UUID documentId, long expectedVersion, WorkingCopyInputView input, String principal);
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.studio.query;
import java.time.Instant;
import java.util.UUID;
/**
* 커서 페이지 위치. 정렬 키와 tie-breaker(id)를 함께 들고 다닌다 — {@code updatedAt}만으로는 같은 시각의 행들이 페이지 경계에서 중복되거나
* 누락된다.
*
* @param updatedAt {@code UPDATED_*} 정렬의 마지막 행 값
* @param title {@code TITLE_ASC} 정렬의 마지막 행 값
* @param id 마지막 행의 id
*/
public record DocumentCursorPosition(Instant updatedAt, String title, UUID id) {}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
import java.util.UUID;
/** {@code getStudioAsset}의 입력. */
public record GetAssetQuery(UUID assetId) implements Query {}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
/** {@code getStudioDashboard}의 입력. 파라미터가 없다. */
public record GetDashboardQuery() implements Query {}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
import java.util.UUID;
/** {@code getStudioDocument}의 입력. */
public record GetDocumentQuery(UUID documentId) implements Query {}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
import java.util.UUID;
/** {@code getCurrentStudioPreview}의 입력. */
public record GetPreviewQuery(UUID documentId) implements Query {}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
import java.util.UUID;
/** {@code getStudioPublicationSnapshot}의 입력. */
public record GetSnapshotQuery(UUID publicationEventId) implements Query {}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import java.time.Instant;
import java.util.UUID;
/** {@code listStudioAssets}의 입력. 정렬은 최신 등록순 고정이라 선택지가 없다. */
public record ListAssetsQuery(
String query,
AssetKindView kind,
AssetManagementStatusView managementStatus,
Instant beforeCreatedAt,
UUID beforeId,
int limit)
implements Query {}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import java.util.UUID;
/**
* {@code listStudioDocuments}의 입력.
*
* <p>{@code position}은 web 계층이 opaque cursor를 풀어 넘긴 페이지 위치다 — 서명·만료·필터 결합 검사는 전송 계층(CursorCodec)의
* 책임이고, 여기서는 이미 검증된 위치만 받는다.
*/
public record ListDocumentsQuery(
String query,
RecordKind kind,
PublicationStatusView publicationStatus,
NextAction nextAction,
UUID projectId,
DocumentSort sort,
DocumentCursorPosition position,
int limit)
implements Query {}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.studio.query;
import dev.caskeleton.application.query.Query;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import java.time.Instant;
import java.util.UUID;
/**
* {@code listStudioPublications}의 입력.
*
* @param beforeOccurredAt 커서 위치. 게시 이력은 최신순 고정이라 정렬 선택지가 없다.
*/
public record ListPublicationsQuery(
PublicationEventTypeView type, Instant beforeOccurredAt, UUID beforeEventId, int limit)
implements Query {}
@@ -0,0 +1,71 @@
package dev.caskeleton.application.techlog.studio.service;
import java.util.Map;
/**
* 업로드 내용으로 media type 을 판정한다. 확장자와 클라이언트가 보낸 {@code Content-Type} 은 신뢰하지 않는다(계약 {@code
* uploadStudioAsset} 설명) — 둘 다 보내는 쪽이 마음대로 정할 수 있는 값이다.
*
* <p>파일 시작 바이트(magic number)로만 판정하고, 아는 형식이 아니면 거절한다. 모르는 것을 통과시키면 그 파일이 무엇인지 아무도 모르는 채로 공개된다.
*/
public final class AssetMediaTypes {
/** 계약 {@code uploadStudioAsset} 의 {@code encoding.file.contentType} 목록. */
private static final Map<String, byte[]> SIGNATURES =
Map.of(
"image/png", new byte[] {(byte) 0x89, 'P', 'N', 'G'},
"image/jpeg", new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
"image/gif", new byte[] {'G', 'I', 'F', '8'},
"application/pdf", new byte[] {'%', 'P', 'D', 'F'});
private AssetMediaTypes() {}
/**
* 내용으로 판정한 media type.
*
* @return 아는 형식이 아니면 null
*/
public static String detect(byte[] content) {
for (Map.Entry<String, byte[]> signature : SIGNATURES.entrySet()) {
if (startsWith(content, signature.getValue())) {
return signature.getKey();
}
}
if (isWebp(content)) {
return "image/webp";
}
if (isSvg(content)) {
return "image/svg+xml";
}
return null;
}
private static boolean startsWith(byte[] content, byte[] prefix) {
if (content.length < prefix.length) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if (content[i] != prefix[i]) {
return false;
}
}
return true;
}
/** RIFF 컨테이너의 8~11 바이트가 {@code WEBP} 다. */
private static boolean isWebp(byte[] content) {
return content.length >= 12
&& startsWith(content, new byte[] {'R', 'I', 'F', 'F'})
&& content[8] == 'W'
&& content[9] == 'E'
&& content[10] == 'B'
&& content[11] == 'P';
}
/** SVG 는 텍스트라 서명이 없다. 앞부분에서 {@code <svg} 를 찾는다. */
private static boolean isSvg(byte[] content) {
int window = Math.min(content.length, 1024);
String head = new String(content, 0, window, java.nio.charset.StandardCharsets.UTF_8);
return head.contains("<svg");
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.CreateDocumentCommand;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.util.Objects;
/**
* {@code createStudioDocument}. 불완전한 초안도 만들 수 있고, 생성은 Public Projection을 바꾸지 않는다.
*
* <p>{@code Idempotency.KEYED} — 계약이 {@code Idempotency-Key}를 필수로 요구하는 mutation이다 (spec §8.2). 실제
* 재생은 웹 경계에서 {@code IdempotencyExecutor}가 수행한다.
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이
* 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다.
* 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서
* 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class CreateStudioDocumentUseCase
implements CommandUseCase<CreateDocumentCommand, WorkingCopyView> {
private final WorkingCopyRepositoryPort workingCopies;
private final TransactionPort transactions;
public CreateStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies, TransactionPort transactions) {
this.workingCopies = Objects.requireNonNull(workingCopies, "workingCopies");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public WorkingCopyView handle(CreateDocumentCommand input) {
WorkingCopyInputValidator.validate(input.document());
if (input.principal() == null || input.principal().isBlank()) {
throw StudioException.of(
StudioError.AUTHENTICATION_REQUIRED, "a principal is required to create a working copy");
}
return transactions.inWrite(() -> workingCopies.create(input.document(), input.principal()));
}
}
@@ -0,0 +1,171 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.CreatePreviewCommand;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.RenderInput;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
/**
* {@code createStudioPreview}. 저장된 working version + validationId + dependency revision 을 묶어 {@code
* PublicRenderModel} snapshot 을 만든다(계약 설명).
*
* <p>Preview 는 Public 과 <b>같은</b> renderer 와 Asset resolver 를 쓴다(ADR-005) — 그래야 작성자가 확인한 화면이 공개될
* 화면과 같다.
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이
* 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다.
* 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서
* 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class CreateStudioPreviewUseCase
implements CommandUseCase<CreatePreviewCommand, PublicPreviewView> {
private final StudioDocumentLoader documents;
private final StudioDependencyResolverPort dependencies;
private final ContentAnalyzerPort contentAnalyzer;
private final DependencyRevisionPort dependencyRevisions;
private final ValidationArtifactPort validations;
private final PreviewArtifactPort previews;
private final RenderModelPort renderer;
private final TransactionPort transactions;
private final Supplier<UUID> idGenerator;
private final Clock clock;
private final Duration previewTtl;
public CreateStudioPreviewUseCase(
StudioDocumentLoader documents,
StudioDependencyResolverPort dependencies,
ContentAnalyzerPort contentAnalyzer,
DependencyRevisionPort dependencyRevisions,
ValidationArtifactPort validations,
PreviewArtifactPort previews,
RenderModelPort renderer,
TransactionPort transactions,
Supplier<UUID> idGenerator,
Clock clock,
Duration previewTtl) {
this.documents = Objects.requireNonNull(documents, "documents");
this.dependencies = Objects.requireNonNull(dependencies, "dependencies");
this.contentAnalyzer = Objects.requireNonNull(contentAnalyzer, "contentAnalyzer");
this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions");
this.validations = Objects.requireNonNull(validations, "validations");
this.previews = Objects.requireNonNull(previews, "previews");
this.renderer = Objects.requireNonNull(renderer, "renderer");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator");
this.clock = Objects.requireNonNull(clock, "clock");
this.previewTtl = Objects.requireNonNull(previewTtl, "previewTtl");
}
@Override
public PublicPreviewView handle(CreatePreviewCommand input) {
return transactions.inWrite(
() -> {
WorkingCopyView document =
documents.requireAtVersion(input.documentId(), input.expectedVersion());
String currentRevision = dependencyRevisions.revisionFor(document.kind(), document.id());
ValidationReportView validation =
requireUsableValidation(input, document, currentRevision);
ContentAnalyzerPort.ContentAnalysis content =
contentAnalyzer.analyze(ValidateStudioDocumentUseCase.bodyMarkdownOf(document));
Set<String> assetKeys = new LinkedHashSet<>();
content.assetUsages().forEach(usage -> assetKeys.add(usage.assetKey()));
StudioDependencyResolverPort.Resolved resolved =
dependencies.resolve(document, assetKeys);
var now = clock.instant();
String renderModelJson =
renderer.renderToJson(
new RenderInput(
document,
resolved.publicPath(),
resolved.topic(),
resolved.project(),
resolved.relations(),
resolved.resolutionEvidenceTarget(),
resolved.assetsByKey(),
currentRevision,
now));
PublicPreviewView preview =
new PublicPreviewView(
idGenerator.get(),
document.id(),
document.version(),
validation.validationId(),
currentRevision,
now,
now.plus(previewTtl),
renderModelJson);
return previews.save(document.kind(), preview, input.principal());
});
}
/**
* spec §7.3 의 "Validation 유효" 조건. {@code VALIDATION_FAILED}(지금 검증하면 실패)와 {@code
* VALIDATION_STALE}(통과했으나 전제가 바뀜)은 다른 사건이며 코드도 다르다.
*/
private ValidationReportView requireUsableValidation(
CreatePreviewCommand input, WorkingCopyView document, String currentRevision) {
ValidationReportView validation =
validations
.findById(input.validationId())
.orElseThrow(
() ->
StudioException.of(
StudioError.VALIDATION_STALE,
"no validation " + input.validationId() + " to build a preview from"));
if (!validation.documentId().equals(document.id())) {
throw StudioException.of(
StudioError.VALIDATION_STALE,
"validation " + input.validationId() + " belongs to a different document");
}
if (validation.validatedVersion() != document.version()
|| !currentRevision.equals(validation.dependencyRevision())
|| !clock.instant().isBefore(validation.validUntil())) {
throw StudioException.of(
StudioError.VALIDATION_STALE,
"the validation no longer describes the current document or its dependencies");
}
if (validation.status() == ValidationStatus.INVALID) {
throw StudioException.of(
StudioError.DOCUMENT_VALIDATION_FAILED,
"a preview cannot be built from a document that failed validation");
}
return validation;
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.DeleteAssetCommand;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.util.Objects;
/**
* {@code deleteStudioAsset}. 공개 이력이 있거나 사용 중인 Asset 은 hard delete 하지 않는다 — 지우면 이미 공개된 문서의 그림이 사라진다.
* 두 경우 모두 {@code ASSET_IN_USE} 로 거절하고 {@code ARCHIVED} 전환을 쓰게 한다(계약 설명).
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이
* 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다.
* 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서
* 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class DeleteStudioAssetUseCase implements CommandUseCase<DeleteAssetCommand, Void> {
private final AssetRepositoryPort assets;
private final AssetBinaryStoragePort binaries;
private final TransactionPort transactions;
public DeleteStudioAssetUseCase(
AssetRepositoryPort assets, AssetBinaryStoragePort binaries, TransactionPort transactions) {
this.assets = Objects.requireNonNull(assets, "assets");
this.binaries = Objects.requireNonNull(binaries, "binaries");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public Void handle(DeleteAssetCommand input) {
return transactions.inWrite(
() -> {
AssetDetailView detail =
assets
.findDetail(input.assetId())
.orElseThrow(
() ->
StudioException.of(
StudioError.ASSET_NOT_FOUND, "no asset " + input.assetId()));
if (detail.hasPublicationHistory()) {
throw StudioException.of(
StudioError.ASSET_IN_USE,
"this asset has been published; archive it instead of deleting it");
}
if (!detail.usages().isEmpty()) {
throw StudioException.of(
StudioError.ASSET_IN_USE,
"this asset is used by " + detail.usages().size() + " record(s)");
}
String objectKey = assets.findObjectKey(input.assetId()).orElse(null);
assets.delete(input.assetId());
// 메타데이터를 지운 뒤에 바이너리를 지운다 — 순서를 뒤집으면 롤백 시 메타데이터는 남고
// 바이너리만 사라져 그림이 깨진 Asset 행이 생긴다.
if (objectKey != null) {
binaries.delete(objectKey);
}
return null;
});
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.PreviewDetailView;
import dev.caskeleton.application.techlog.studio.model.PreviewState;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.application.techlog.studio.query.GetPreviewQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.time.Clock;
import java.util.Objects;
/**
* {@code getCurrentStudioPreview}. Preview 상태({@code CURRENT}/{@code STALE}/{@code EXPIRED})는 서버가
* 계산한다(계약 설명, spec §7.3) — 프론트가 여러 값을 조합해 재추론하지 않는다.
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetCurrentStudioPreviewUseCase
implements QueryUseCase<GetPreviewQuery, PreviewDetailView> {
private final StudioDocumentLoader documents;
private final PreviewArtifactPort previews;
private final ValidationArtifactPort validations;
private final DependencyRevisionPort dependencyRevisions;
private final TransactionPort transactions;
private final Clock clock;
public GetCurrentStudioPreviewUseCase(
StudioDocumentLoader documents,
PreviewArtifactPort previews,
ValidationArtifactPort validations,
DependencyRevisionPort dependencyRevisions,
TransactionPort transactions,
Clock clock) {
this.documents = Objects.requireNonNull(documents, "documents");
this.previews = Objects.requireNonNull(previews, "previews");
this.validations = Objects.requireNonNull(validations, "validations");
this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public PreviewDetailView handle(GetPreviewQuery input) {
return transactions.inRead(
() -> {
WorkingCopyView document = documents.require(input.documentId());
PublicPreviewView preview =
previews
.latestFor(document.kind(), document.id())
.orElseThrow(
() ->
StudioException.of(
StudioError.PREVIEW_NOT_FOUND,
"no preview has been created for " + document.id()));
String currentRevision = dependencyRevisions.revisionFor(document.kind(), document.id());
return new PreviewDetailView(
preview,
stateOf(preview, document, currentRevision),
document.version(),
validations
.latestFor(document.kind(), document.id())
.map(report -> report.validationId())
.orElse(null));
});
}
/** 만료가 먼저다 — 만료된 미리보기는 내용이 최신이어도 다시 만들어야 하므로 {@code STALE} 보다 강하다. */
private PreviewState stateOf(
PublicPreviewView preview, WorkingCopyView document, String currentRevision) {
if (!clock.instant().isBefore(preview.expiresAt())) {
return PreviewState.EXPIRED;
}
if (preview.previewVersion() != document.version()
|| !Objects.equals(preview.dependencyRevision(), currentRevision)) {
return PreviewState.STALE;
}
return PreviewState.CURRENT;
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.query.GetAssetQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code getStudioAsset}. */
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetStudioAssetUseCase implements QueryUseCase<GetAssetQuery, AssetDetailView> {
private final AssetRepositoryPort assets;
private final TransactionPort transactions;
public GetStudioAssetUseCase(AssetRepositoryPort assets, TransactionPort transactions) {
this.assets = Objects.requireNonNull(assets, "assets");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public AssetDetailView handle(GetAssetQuery input) {
return transactions.inRead(
() ->
assets
.findDetail(input.assetId())
.orElseThrow(
() ->
StudioException.of(
StudioError.ASSET_NOT_FOUND, "no asset " + input.assetId())));
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.studio.model.DashboardView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
import dev.caskeleton.application.techlog.studio.query.GetDashboardQuery;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
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 getStudioDashboard}. {@code nextAction} 을 포함한 모든 workflow 상태는 서버가 계산한다 — 프론트가 여러 endpoint
* 를 조합해 재추론하지 않는다(계약 설명).
*/
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetStudioDashboardUseCase
implements QueryUseCase<GetDashboardQuery, DashboardView> {
/** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */
private static final int SECTION_SIZE = 5;
/** "이어서 쓰기"에 해당하는 상태들. 게시까지 갈 수 없는 것부터 보여준다. */
private static final List<NextAction> CONTINUE_WRITING =
List.of(NextAction.CONTINUE_EDITING, NextAction.FIX_VALIDATION, NextAction.VALIDATE);
private static final List<NextAction> READY_TO_PUBLISH = List.of(NextAction.PUBLISH);
private final StudioDashboardQueryPort dashboard;
private final PublicationHistoryQueryPort history;
private final TransactionPort transactions;
public GetStudioDashboardUseCase(
StudioDashboardQueryPort dashboard,
PublicationHistoryQueryPort history,
TransactionPort transactions) {
this.dashboard = Objects.requireNonNull(dashboard, "dashboard");
this.history = Objects.requireNonNull(history, "history");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public DashboardView handle(GetDashboardQuery input) {
return transactions.inRead(
() ->
new DashboardView(
dashboard.topByNextAction(CONTINUE_WRITING, SECTION_SIZE),
dashboard.topByNextAction(READY_TO_PUBLISH, SECTION_SIZE),
history.list(new ListPublicationsQuery(null, null, null, SECTION_SIZE)).items(),
dashboard.totals()));
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import dev.caskeleton.application.techlog.studio.query.GetDocumentQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code getStudioDocument}. */
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetStudioDocumentUseCase
implements QueryUseCase<GetDocumentQuery, WorkingCopyDetailView> {
private final WorkingCopyRepositoryPort workingCopies;
private final WorkingCopyDetailAssembler assembler;
private final TransactionPort transactions;
public GetStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies,
WorkingCopyDetailAssembler assembler,
TransactionPort transactions) {
this.workingCopies = Objects.requireNonNull(workingCopies, "workingCopies");
this.assembler = Objects.requireNonNull(assembler, "assembler");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public WorkingCopyDetailView handle(GetDocumentQuery input) {
if (input.documentId() == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "documentId is required");
}
// 조회 전체를 한 트랜잭션으로 묶는다 — 편집본과 그 validation/preview/publication을 따로 읽으면
// 그 사이에 저장이 끼어들어 서로 다른 버전을 가리키는 detail이 나갈 수 있다.
return transactions.inRead(
() ->
workingCopies
.find(input.documentId())
.map(assembler::assemble)
.orElseThrow(
() ->
StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"no working copy for documentId " + input.documentId())));
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.query.GetSnapshotQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code getStudioPublicationSnapshot}. 현재 source 에서 다시 만들지 않고 게시 시점에 고정된 것을 그대로 돌려준다(ADR-002). */
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetStudioPublicationSnapshotUseCase
implements QueryUseCase<GetSnapshotQuery, PublicationSnapshotView> {
private final PublicationHistoryQueryPort history;
private final TransactionPort transactions;
public GetStudioPublicationSnapshotUseCase(
PublicationHistoryQueryPort history, TransactionPort transactions) {
this.history = Objects.requireNonNull(history, "history");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public PublicationSnapshotView handle(GetSnapshotQuery input) {
return transactions.inRead(
() ->
history
.findSnapshot(input.publicationEventId())
.orElseThrow(
() ->
StudioException.of(
StudioError.PUBLICATION_SNAPSHOT_NOT_FOUND,
"no snapshot for publication event " + input.publicationEventId())));
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code listStudioAssets}. */
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListStudioAssetsUseCase implements QueryUseCase<ListAssetsQuery, AssetPageView> {
private static final int MAX_LIMIT = 100;
private static final int MAX_QUERY_LENGTH = 100;
private final AssetRepositoryPort assets;
private final TransactionPort transactions;
public ListStudioAssetsUseCase(AssetRepositoryPort assets, TransactionPort transactions) {
this.assets = Objects.requireNonNull(assets, "assets");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public AssetPageView handle(ListAssetsQuery input) {
if (input.limit() < 1 || input.limit() > MAX_LIMIT) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT);
}
if (input.query() != null && input.query().length() > MAX_QUERY_LENGTH) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"q must be at most " + MAX_QUERY_LENGTH + " characters");
}
return transactions.inRead(() -> assets.list(input));
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code listStudioDocuments}. */
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListStudioDocumentsUseCase
implements QueryUseCase<ListDocumentsQuery, DocumentPageView> {
/** 계약 {@code components.parameters.Limit}. */
private static final int MAX_LIMIT = 100;
/** 계약 {@code components.parameters.Query.schema.maxLength}. */
private static final int MAX_QUERY_LENGTH = 100;
private final StudioDocumentQueryPort documents;
private final TransactionPort transactions;
public ListStudioDocumentsUseCase(
StudioDocumentQueryPort documents, TransactionPort transactions) {
this.documents = Objects.requireNonNull(documents, "documents");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public DocumentPageView handle(ListDocumentsQuery input) {
if (input.limit() < 1 || input.limit() > MAX_LIMIT) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT);
}
if (input.query() != null && input.query().length() > MAX_QUERY_LENGTH) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"q must be at most " + MAX_QUERY_LENGTH + " characters");
}
if (input.sort() == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "sort is required");
}
return transactions.inRead(() -> documents.list(input));
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code listStudioPublications}. */
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListStudioPublicationsUseCase
implements QueryUseCase<ListPublicationsQuery, PublicationPageView> {
private static final int MAX_LIMIT = 100;
private final PublicationHistoryQueryPort history;
private final TransactionPort transactions;
public ListStudioPublicationsUseCase(
PublicationHistoryQueryPort history, TransactionPort transactions) {
this.history = Objects.requireNonNull(history, "history");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public PublicationPageView handle(ListPublicationsQuery input) {
if (input.limit() < 1 || input.limit() > MAX_LIMIT) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "limit must be between 1 and " + MAX_LIMIT);
}
return transactions.inRead(() -> history.list(input));
}
}
@@ -0,0 +1,88 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.time.Instant;
import java.util.Objects;
/**
* spec §7.4. 서버가 조회 시점에 계산하는 Studio projection이며 어떤 domain 컬럼에도 저장하지 않는다.
*
* <p>프론트가 여러 endpoint를 조합해 workflow 상태를 재추론하지 않도록 서버가 단일 값으로 답한다(계약 {@code getStudioDashboard} 설명).
*/
public final class NextActionCalculator {
private NextActionCalculator() {}
/**
* spec §7.4의 판정 순서를 그대로 따른다. 앞선 조건이 참이면 뒤는 보지 않는다.
*
* @param currentDependencyRevision 지금 계산한 값. artifact에 박제된 값과 다르면 그 artifact는 전제가 바뀐 것이라 더는 유효하지
* 않다.
*/
public static NextAction calculate(
WorkingCopyView document,
ValidationReportView validation,
PublicPreviewView preview,
PublicationAggregateView publication,
String currentDependencyRevision,
Instant now) {
if (!isWorthValidating(document)) {
return NextAction.CONTINUE_EDITING;
}
if (!isValidationCurrent(validation, document, currentDependencyRevision, now)) {
return NextAction.VALIDATE;
}
if (validation.status() == ValidationStatus.INVALID) {
return NextAction.FIX_VALIDATION;
}
if (!isPreviewCurrent(preview, document, currentDependencyRevision, now)) {
return NextAction.CREATE_PREVIEW;
}
if (publication == null
|| publication.status() != PublicationAggregateStatus.PUBLISHED
|| publication.publishedVersion() != document.version()) {
return NextAction.PUBLISH;
}
return NextAction.NONE;
}
/**
* spec의 "저장 가능한 형태조차 미달". 제목이 비어 있으면 어떤 유형이든 검증을 돌릴 의미가 없다 — 나머지 필수값 판정은 검증 자신의 일이고, 여기서 흉내 내면 두
* 곳이 서로 다른 답을 낼 수 있다.
*/
private static boolean isWorthValidating(WorkingCopyView document) {
String title = document.base().title();
return title != null && !title.isBlank();
}
/** spec §7.3의 "Validation 유효" 조건 세 가지를 모두 만족해야 한다. */
private static boolean isValidationCurrent(
ValidationReportView validation,
WorkingCopyView document,
String currentDependencyRevision,
Instant now) {
return validation != null
&& validation.validatedVersion() == document.version()
&& Objects.equals(validation.dependencyRevision(), currentDependencyRevision)
&& now.isBefore(validation.validUntil());
}
/** spec §7.3의 Preview {@code CURRENT} 조건. {@code STALE}/{@code EXPIRED}는 모두 재생성 대상이다. */
private static boolean isPreviewCurrent(
PublicPreviewView preview,
WorkingCopyView document,
String currentDependencyRevision,
Instant now) {
return preview != null
&& preview.previewVersion() == document.version()
&& Objects.equals(preview.dependencyRevision(), currentDependencyRevision)
&& now.isBefore(preview.expiresAt());
}
}
@@ -0,0 +1,249 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.PublishDocumentCommand;
import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.model.ValidationIssueView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationSeverity;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.application.techlog.studio.validation.StudioDocumentValidator;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* {@code publishStudioDocument} — spec §7.5 의 게시 트랜잭션.
*
* <p>단계별 실패가 서로 다른 계약 코드로 나가는 것이 이 use case 의 핵심이다. {@code DOCUMENT_VALIDATION_FAILED}(지금 검증하면 실패)와
* {@code VALIDATION_STALE}(통과했으나 전제가 바뀜)은 다른 사건이고, 작성자가 해야 할 일도 다르다 — 전자는 고치는 것이고 후자는 다시 검증하는 것이다.
*
* <p>Snapshot 의 렌더 모델은 <b>게시 시점에 다시 렌더링하지 않고</b> 사용자가 확인한 미리보기의 것을 그대로 쓴다(spec §7.5). 다시 렌더링하면 승인한
* 화면과 공개된 화면이 달라질 수 있다.
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이
* 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다.
* 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서
* 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class PublishStudioDocumentUseCase
implements CommandUseCase<PublishDocumentCommand, PublishResultView> {
/** 설계 05장 §14 의 content format 버전. */
private static final String CONTENT_FORMAT_VERSION = "1";
/** 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 — 기존 snapshot 이 어떤 규칙으로 만들어졌는지 남는다. */
private static final String RENDERER_CONTRACT_VERSION = "1";
private final StudioDocumentLoader documents;
private final StudioDependencyResolverPort dependencies;
private final ContentAnalyzerPort contentAnalyzer;
private final DependencyRevisionPort dependencyRevisions;
private final ValidationArtifactPort validations;
private final PreviewArtifactPort previews;
private final PublicationWriterPort publications;
private final TransactionPort transactions;
private final Clock clock;
public PublishStudioDocumentUseCase(
StudioDocumentLoader documents,
StudioDependencyResolverPort dependencies,
ContentAnalyzerPort contentAnalyzer,
DependencyRevisionPort dependencyRevisions,
ValidationArtifactPort validations,
PreviewArtifactPort previews,
PublicationWriterPort publications,
TransactionPort transactions,
Clock clock) {
this.documents = Objects.requireNonNull(documents, "documents");
this.dependencies = Objects.requireNonNull(dependencies, "dependencies");
this.contentAnalyzer = Objects.requireNonNull(contentAnalyzer, "contentAnalyzer");
this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions");
this.validations = Objects.requireNonNull(validations, "validations");
this.previews = Objects.requireNonNull(previews, "previews");
this.publications = Objects.requireNonNull(publications, "publications");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public PublishResultView handle(PublishDocumentCommand input) {
return transactions.inWrite(
() -> {
// 1~2. 잠금 + 버전 확인
WorkingCopyView document =
documents.requireAtVersion(input.documentId(), input.expectedVersion());
publications.lockCurrentPublication(document.kind(), document.id());
String currentRevision = dependencyRevisions.revisionFor(document.kind(), document.id());
// 3. validation 신선도
ValidationReportView validation =
requireFreshValidation(input, document, currentRevision);
// 4. preview 신선도
PublicPreviewView preview = requireFreshPreview(input, document, currentRevision);
// 5. 경고 승인
requireAcknowledgedWarnings(validation, input.acknowledgedWarningCodes());
// 6~8. 지금 다시 검증한다 — 저장된 결과를 믿고 건너뛰면 그 사이의 변화가 그대로 공개된다.
ContentAnalyzerPort.ContentAnalysis content =
contentAnalyzer.analyze(ValidateStudioDocumentUseCase.bodyMarkdownOf(document));
Set<String> assetKeys = new LinkedHashSet<>();
content.assetUsages().forEach(usage -> assetKeys.add(usage.assetKey()));
StudioDependencyResolverPort.Resolved resolved =
dependencies.resolve(document, assetKeys);
StudioDocumentValidator.Outcome outcome =
StudioDocumentValidator.validate(document, resolved, content);
if (outcome.status() == ValidationStatus.INVALID) {
throw StudioException.of(
StudioError.DOCUMENT_VALIDATION_FAILED,
"the document does not pass validation at publish time");
}
// 9~19. 쓰기
return publications.publish(
new PublicationWriterPort.PublishRequest(
document.kind(),
document.id(),
document.version(),
document.base().title(),
document.base().summary(),
document.base().topicId(),
document.base().projectId(),
resolved.publicPath(),
stateCodeOf(document),
preview.renderModelJson(),
content.plainText(),
manifestOf(resolved),
CONTENT_FORMAT_VERSION,
RENDERER_CONTRACT_VERSION,
input.idempotencyKey(),
input.principal()));
});
}
private ValidationReportView requireFreshValidation(
PublishDocumentCommand input, WorkingCopyView document, String currentRevision) {
ValidationReportView validation =
validations
.findById(input.validationId())
.orElseThrow(
() ->
StudioException.of(
StudioError.VALIDATION_STALE,
"no validation " + input.validationId() + " to publish against"));
if (!validation.documentId().equals(document.id())
|| validation.validatedVersion() != document.version()
|| !currentRevision.equals(validation.dependencyRevision())
|| !clock.instant().isBefore(validation.validUntil())) {
throw StudioException.of(
StudioError.VALIDATION_STALE,
"the validation no longer describes the current document or its dependencies");
}
return validation;
}
private PublicPreviewView requireFreshPreview(
PublishDocumentCommand input, WorkingCopyView document, String currentRevision) {
PublicPreviewView preview =
previews
.findById(input.previewId())
.orElseThrow(
() ->
StudioException.of(
StudioError.PREVIEW_STALE,
"no preview " + input.previewId() + " to publish"));
if (!clock.instant().isBefore(preview.expiresAt())) {
throw StudioException.of(
StudioError.PREVIEW_EXPIRED, "the preview expired; create a new one before publishing");
}
if (!preview.documentId().equals(document.id())
|| preview.previewVersion() != document.version()
|| !currentRevision.equals(preview.dependencyRevision())) {
throw StudioException.of(
StudioError.PREVIEW_STALE, "the preview does not describe the version being published");
}
return preview;
}
/**
* 계약: {@code acknowledgedWarningCodes} 가 현재 Validation 의 WARNING 집합을 모두 덮지 못하면 거절한다. 작성자가 보지 못한
* 경고를 안고 공개되는 일을 막는 장치다.
*/
private static void requireAcknowledgedWarnings(
ValidationReportView validation, List<String> acknowledged) {
Set<String> warnings =
validation.issues().stream()
.filter(issue -> issue.severity() == ValidationSeverity.WARNING)
.map(ValidationIssueView::code)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
warnings.removeAll(Set.copyOf(acknowledged));
if (!warnings.isEmpty()) {
throw StudioException.of(
StudioError.WARNING_ACKNOWLEDGEMENT_REQUIRED,
"these validation warnings were not acknowledged: " + warnings);
}
}
private static List<AssetManifestEntry> manifestOf(
StudioDependencyResolverPort.Resolved resolved) {
return resolved.assetsByKey().values().stream()
.map(PublishStudioDocumentUseCase::manifestEntry)
.toList();
}
private static AssetManifestEntry manifestEntry(ResolvedAssetView asset) {
return new AssetManifestEntry(
asset.assetId(),
asset.assetKey(),
asset.mediaType(),
asset.publicPath(),
asset.width(),
asset.height(),
asset.decorative());
}
/** 공개 projection 의 {@code state_code}. 목록 화면이 상태별로 걸러 볼 수 있게 하는 값이다. */
private static String stateCodeOf(WorkingCopyView document) {
return switch (document) {
case WorkingCopyView.QuestionWorkingCopyView value ->
value.questionStatus() == QuestionStatusView.RESOLVED ? "RESOLVED" : "OPEN";
case WorkingCopyView.ProjectDecisionWorkingCopyView value ->
value.decisionStatus() == DecisionStatusView.ADOPTED ? "ADOPTED" : "PROPOSED";
case WorkingCopyView.CaseWorkingCopyView ignored -> null;
case WorkingCopyView.ReferenceWorkingCopyView ignored -> null;
};
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
/**
* 저장 결과. 낙관적 잠금 충돌을 예외가 아니라 값으로 돌려준다.
*
* <p>계약의 {@code VersionConflictDetails.latestDocument}가 전체 {@code WorkingCopyDetail}이라, 충돌 응답을 만들려면
* 계약 DTO 조립이 필요하다. 그 조립은 웹 계층의 일이므로 application이 예외에 계약 모양을 실어 던지는 대신 현재 상태를 값으로 넘긴다.
*/
public sealed interface SaveOutcome {
/** 저장됨. */
record Saved(WorkingCopyDetailView detail) implements SaveOutcome {}
/** {@code expectedVersion}이 현재 버전과 다름. {@code latest}는 지금의 실제 상태다. */
record VersionConflict(WorkingCopyDetailView latest) implements SaveOutcome {}
}
@@ -0,0 +1,102 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.SaveDocumentCommand;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.util.Objects;
import java.util.Optional;
/**
* {@code saveStudioDocument}. 편집 가능한 content field만 저장하고 lifecycle 전이를 유발하지 않는다 (spec §6.2). 저장은
* Public Projection을 바꾸지 않는다.
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이
* 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다.
* 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서
* 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class SaveStudioDocumentUseCase implements CommandUseCase<SaveDocumentCommand, SaveOutcome> {
private final WorkingCopyRepositoryPort workingCopies;
private final WorkingCopyDetailAssembler assembler;
private final TransactionPort transactions;
public SaveStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies,
WorkingCopyDetailAssembler assembler,
TransactionPort transactions) {
this.workingCopies = Objects.requireNonNull(workingCopies, "workingCopies");
this.assembler = Objects.requireNonNull(assembler, "assembler");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public SaveOutcome handle(SaveDocumentCommand input) {
WorkingCopyInputValidator.validate(input.document());
if (input.expectedVersion() < 1) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "expectedVersion must be at least 1");
}
if (input.principal() == null || input.principal().isBlank()) {
throw StudioException.of(
StudioError.AUTHENTICATION_REQUIRED, "a principal is required to save a working copy");
}
return transactions.inWrite(
() -> {
RecordKind existing =
workingCopies
.findKind(input.documentId())
.orElseThrow(
() ->
StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"no working copy for documentId " + input.documentId()));
// 유형을 바꾸는 저장은 다른 aggregate로의 이동이지 편집이 아니다. 허용하면 원본 행이 남은 채
// 새 유형의 행이 생겨 같은 documentId가 두 테이블에 존재하게 된다.
if (existing != input.document().kind()) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"document.kind cannot change on save: stored "
+ existing
+ ", requested "
+ input.document().kind());
}
Optional<WorkingCopyView> saved =
workingCopies.save(
input.documentId(), input.expectedVersion(), input.document(), input.principal());
if (saved.isPresent()) {
return new SaveOutcome.Saved(assembler.assemble(saved.get()));
}
// 충돌 시점의 실제 상태를 다시 읽는다 — 저장 전에 읽어 둔 스냅숏을 돌려주면 계약이 약속한
// "현재 상태"가 아니라 이미 지난 상태를 주게 된다.
WorkingCopyView latest =
workingCopies
.find(input.documentId())
.orElseThrow(
() ->
StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"working copy " + input.documentId() + " disappeared during save"));
return new SaveOutcome.VersionConflict(assembler.assemble(latest));
});
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import java.util.Objects;
import java.util.UUID;
/**
* "존재하고, 클라이언트가 생각하는 그 버전인가"를 한 곳에서 확인한다.
*
* <p>validate·preview·publish 가 모두 같은 확인을 하는데 각자 구현하면 어떤 경로는 버전 검사를 빠뜨린 채로 지나간다 — 그러면 사용자가 화면에서 본
* 것과 다른 버전이 검증되거나 게시된다.
*/
public final class StudioDocumentLoader {
private final WorkingCopyRepositoryPort workingCopies;
public StudioDocumentLoader(WorkingCopyRepositoryPort workingCopies) {
this.workingCopies = Objects.requireNonNull(workingCopies, "workingCopies");
}
public WorkingCopyView requireAtVersion(UUID documentId, long expectedVersion) {
WorkingCopyView document = require(documentId);
if (document.version() != expectedVersion) {
throw StudioException.of(
StudioError.VERSION_CONFLICT,
"expectedVersion " + expectedVersion + " but stored version is " + document.version());
}
return document;
}
public WorkingCopyView require(UUID documentId) {
if (documentId == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "documentId is required");
}
return workingCopies
.find(documentId)
.orElseThrow(
() ->
StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"no working copy for documentId " + documentId));
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.studio.service;
/**
* Studio가 요구하는 권한 토큰.
*
* <p>배포는 {@code ca-skeleton.authz.role-permissions.<IdP role> = [studio:write]}로 자기 IdP의 역할 이름을 여기에
* 잇는다. 역할 이름을 코드에 박지 않는 이유는 그것이 배포마다 다른 값이기 때문이다 — 계약({@code StudioSession.roles})도 역할 이름을 고정하지
* 않는다.
*/
public final class StudioPermissions {
/** 편집본 생성·저장·검증·미리보기·게시가 요구하는 권한. */
public static final String WRITE = "studio:write";
private StudioPermissions() {}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.UnpublishPublicationCommand;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.util.Objects;
/** {@code unpublishStudioPublication}. Snapshot 은 삭제하지 않는다 — 과거에 무엇이 공개됐는지는 지우지 않는다(spec §7.5). */
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이
* 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다.
* 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서
* 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UnpublishStudioPublicationUseCase
implements CommandUseCase<UnpublishPublicationCommand, PublishResultView> {
private final PublicationHistoryQueryPort history;
private final PublicationWriterPort publications;
private final StudioDocumentLoader documents;
private final TransactionPort transactions;
public UnpublishStudioPublicationUseCase(
PublicationHistoryQueryPort history,
PublicationWriterPort publications,
StudioDocumentLoader documents,
TransactionPort transactions) {
this.history = Objects.requireNonNull(history, "history");
this.publications = Objects.requireNonNull(publications, "publications");
this.documents = Objects.requireNonNull(documents, "documents");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public PublishResultView handle(UnpublishPublicationCommand input) {
return transactions.inWrite(
() -> {
PublicationAggregateView publication =
history
.findById(input.publicationId())
.orElseThrow(
() ->
StudioException.of(
StudioError.PUBLICATION_NOT_FOUND,
"no publication " + input.publicationId()));
if (publication.status() != PublicationAggregateStatus.PUBLISHED) {
throw StudioException.of(
StudioError.PUBLICATION_CONFLICT, "this publication is already withdrawn");
}
if (publication.publicationRevision() != input.expectedPublicationRevision()) {
throw StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"expectedPublicationRevision "
+ input.expectedPublicationRevision()
+ " but stored revision is "
+ publication.publicationRevision());
}
WorkingCopyView document = documents.require(publication.documentId());
publications.lockCurrentPublication(document.kind(), document.id());
return publications.unpublish(
new PublicationWriterPort.UnpublishRequest(
publication.publicationId(),
document.kind(),
document.id(),
input.expectedPublicationRevision(),
input.principal()));
});
}
}
@@ -0,0 +1,97 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.UpdateAssetCommand;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.util.Objects;
/**
* {@code updateStudioAsset}. 계약이 허용하는 것은 {@code altText}, {@code decorative}, {@code kind}, 그리고
* {@code READY ↔ ARCHIVED} 전환뿐이다.
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이
* 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다.
* 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서
* 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UpdateStudioAssetUseCase implements CommandUseCase<UpdateAssetCommand, AssetView> {
private final AssetRepositoryPort assets;
private final TransactionPort transactions;
public UpdateStudioAssetUseCase(AssetRepositoryPort assets, TransactionPort transactions) {
this.assets = Objects.requireNonNull(assets, "assets");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
@Override
public AssetView handle(UpdateAssetCommand input) {
if (input.expectedVersion() < 1) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "expectedVersion must be at least 1");
}
if (input.managementStatus() != null
&& input.managementStatus() != AssetManagementStatusView.READY
&& input.managementStatus() != AssetManagementStatusView.ARCHIVED) {
// REJECTED/QUARANTINED 는 서버 검증 결과다. 클라이언트가 지정하게 두면 격리된 Asset 을
// 스스로 풀어 줄 수 있게 된다.
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"managementStatus can only be set to READY or ARCHIVED");
}
return transactions.inWrite(
() -> {
AssetView current =
assets
.find(input.assetId())
.orElseThrow(
() ->
StudioException.of(
StudioError.ASSET_NOT_FOUND, "no asset " + input.assetId()));
if (current.managementStatus() == AssetManagementStatusView.QUARANTINED) {
throw StudioException.of(
StudioError.ASSET_QUARANTINED, "a quarantined asset cannot be edited");
}
if (current.managementStatus() == AssetManagementStatusView.REJECTED) {
throw StudioException.of(
StudioError.ASSET_NOT_READY, "a rejected asset cannot be edited");
}
return assets
.update(
input.assetId(),
input.expectedVersion(),
input.kind(),
input.altText(),
input.altTextProvided(),
input.decorative(),
input.managementStatus(),
input.principal())
.orElseThrow(
() ->
StudioException.of(
StudioError.VERSION_CONFLICT,
"expectedVersion "
+ input.expectedVersion()
+ " but stored version is "
+ current.version()));
});
}
}
@@ -0,0 +1,140 @@
package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.UploadAssetCommand;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Supplier;
/**
* {@code uploadStudioAsset}. 확장자를 신뢰하지 않고 내용으로 media type 을 판정하며, 판정에 실패하면 {@code READY} 로 만들지
* 않는다(계약 설명).
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시(proxy-target-class=true)를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이
* 실패한다("Cannot subclass final class"). 실제 부팅 검증에서 그렇게 실패했다.
* 템플릿의 NotificationDispatchUseCase 는 final 이면서도 문제가 없는데, 그건 그 능력이 꺼진 배포에서
* 빈으로 등록되지 않아 프록시가 만들어지지 않기 때문이다. Studio 의 use case 는 항상 등록된다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UploadStudioAssetUseCase implements CommandUseCase<UploadAssetCommand, AssetView> {
/** 업로드 상한. 넘으면 계약의 413 {@code PAYLOAD_TOO_LARGE} 로 거절한다. */
private static final long MAX_BYTES = 20L * 1024 * 1024;
private final AssetRepositoryPort assets;
private final AssetBinaryStoragePort binaries;
private final TransactionPort transactions;
private final Supplier<UUID> idGenerator;
public UploadStudioAssetUseCase(
AssetRepositoryPort assets,
AssetBinaryStoragePort binaries,
TransactionPort transactions,
Supplier<UUID> idGenerator) {
this.assets = Objects.requireNonNull(assets, "assets");
this.binaries = Objects.requireNonNull(binaries, "binaries");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator");
}
@Override
public AssetView handle(UploadAssetCommand input) {
if (input.kind() == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "kind is required");
}
if (input.byteSize() == 0) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "the uploaded file is empty");
}
if (input.byteSize() > MAX_BYTES) {
throw StudioException.of(
StudioError.PAYLOAD_TOO_LARGE, "the upload exceeds " + MAX_BYTES + " bytes");
}
byte[] content = input.content();
String mediaType = AssetMediaTypes.detect(content);
if (mediaType == null) {
// 무엇인지 모르는 파일을 저장은 하되 사용할 수 없게 두는 것보다, 받지 않는 편이 낫다 —
// 저장하면 그 바이트의 소유·수명·정리 책임이 생긴다.
throw StudioException.of(
StudioError.UNSUPPORTED_MEDIA_TYPE,
"the uploaded content is not one of the media types this Studio accepts");
}
UUID assetId = idGenerator.get();
String assetKey = assetKeyFor(input.originalFilename(), assetId);
String objectKey = "techlog/assets/" + assetId;
return transactions.inWrite(
() -> {
String storedKey = binaries.store(objectKey, content, mediaType);
return assets.create(
new AssetRepositoryPort.NewAsset(
assetId,
assetKey,
input.kind(),
mediaType,
storedKey,
input.originalFilename(),
input.byteSize(),
null,
null,
sha256(content),
input.altText(),
input.decorative(),
// 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다.
AssetManagementStatusView.READY),
input.principal());
});
}
/**
* 안정적이고 사람이 읽을 수 있는 key. 파일명에서 만들되 뒤에 id 조각을 붙여 유일성을 보장한다 — 같은 이름의 파일을 두 번 올렸다고 key 가 충돌하면 두 번째
* 업로드가 실패한다.
*/
private static String assetKeyFor(String originalFilename, UUID assetId) {
String base = originalFilename == null ? "" : originalFilename;
int dot = base.lastIndexOf('.');
if (dot > 0) {
base = base.substring(0, dot);
}
String slug =
base.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", "");
if (slug.isBlank()) {
slug = "asset";
}
if (slug.length() > 150) {
slug = slug.substring(0, 150);
}
return slug + "-" + assetId.toString().substring(0, 8);
}
private static String sha256(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
}

Some files were not shown because too many files have changed in this diff Show More