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:
co-authored by
Claude Opus 5
parent
e615c24152
commit
48ff648112
+12
@@ -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 {}
|
||||
+9
@@ -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 {}
|
||||
+7
@@ -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 {}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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 {}
|
||||
+8
@@ -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 {}
|
||||
+23
@@ -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 {}
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
@@ -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 {}
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code AssetKind}. */
|
||||
public enum AssetKindView {
|
||||
IMAGE,
|
||||
DIAGRAM,
|
||||
ATTACHMENT
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+18
@@ -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) {}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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) {}
|
||||
+28
@@ -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) {}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code DashboardTotals}. */
|
||||
public record DashboardTotalsView(
|
||||
int documents, int needsValidation, int readyToPublish, int publications) {}
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약의 UI 용어(ADR-003). Domain의 {@code ACCEPTED}가 {@link #ADOPTED}로 보인다. */
|
||||
public enum DecisionStatusView {
|
||||
PROPOSED,
|
||||
ADOPTED
|
||||
}
|
||||
+6
@@ -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) {}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+22
@@ -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) {}
|
||||
+11
@@ -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
|
||||
}
|
||||
+6
@@ -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) {}
|
||||
+10
@@ -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) {}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code PreviewDetail.state}. 저장하지 않고 조회 시 계산한다(spec §7.3). */
|
||||
public enum PreviewState {
|
||||
CURRENT,
|
||||
STALE,
|
||||
EXPIRED
|
||||
}
|
||||
+33
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+21
@@ -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) {}
|
||||
+8
@@ -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
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code PublicationAggregate.status}. */
|
||||
public enum PublicationAggregateStatus {
|
||||
PUBLISHED,
|
||||
UNPUBLISHED
|
||||
}
|
||||
+15
@@ -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) {}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code PublicationEventType}. */
|
||||
public enum PublicationEventTypeView {
|
||||
PUBLISHED,
|
||||
REPUBLISHED,
|
||||
UNPUBLISHED
|
||||
}
|
||||
+19
@@ -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) {}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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) {}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code PublicationStatus}. */
|
||||
public enum PublicationStatusView {
|
||||
NEVER_PUBLISHED,
|
||||
PUBLISHED,
|
||||
UNPUBLISHED
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code PublishResult}. */
|
||||
public record PublishResultView(PublicationAggregateView publication, PublicationEventView event) {}
|
||||
+6
@@ -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) {}
|
||||
+6
@@ -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) {}
|
||||
+10
@@ -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
|
||||
}
|
||||
+12
@@ -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
|
||||
}
|
||||
+6
@@ -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) {}
|
||||
+9
@@ -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) {}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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) {}
|
||||
+18
@@ -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) {}
|
||||
+9
@@ -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) {}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code ValidationIssue.severity}. */
|
||||
public enum ValidationSeverity {
|
||||
ERROR,
|
||||
WARNING
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.application.techlog.studio.model;
|
||||
|
||||
/** 계약 {@code ValidationReport.status}. */
|
||||
public enum ValidationStatus {
|
||||
INVALID,
|
||||
WARNINGS,
|
||||
VALID
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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) {}
|
||||
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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 {}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+63
@@ -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) {}
|
||||
}
|
||||
+35
@@ -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) {}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
+64
@@ -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) {}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+14
@@ -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();
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+14
@@ -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) {}
|
||||
+7
@@ -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 {}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package dev.caskeleton.application.techlog.studio.query;
|
||||
|
||||
import dev.caskeleton.application.query.Query;
|
||||
|
||||
/** {@code getStudioDashboard}의 입력. 파라미터가 없다. */
|
||||
public record GetDashboardQuery() implements Query {}
|
||||
+7
@@ -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 {}
|
||||
+7
@@ -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 {}
|
||||
+7
@@ -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 {}
|
||||
+17
@@ -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 {}
|
||||
+25
@@ -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 {}
|
||||
+15
@@ -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 {}
|
||||
+71
@@ -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");
|
||||
}
|
||||
}
|
||||
+56
@@ -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()));
|
||||
}
|
||||
}
|
||||
+171
@@ -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;
|
||||
}
|
||||
}
|
||||
+78
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+93
@@ -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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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())));
|
||||
}
|
||||
}
|
||||
+61
@@ -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()));
|
||||
}
|
||||
}
|
||||
+55
@@ -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())));
|
||||
}
|
||||
}
|
||||
+45
@@ -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())));
|
||||
}
|
||||
}
|
||||
+47
@@ -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));
|
||||
}
|
||||
}
|
||||
+55
@@ -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));
|
||||
}
|
||||
}
|
||||
+43
@@ -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));
|
||||
}
|
||||
}
|
||||
+88
@@ -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());
|
||||
}
|
||||
}
|
||||
+249
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+18
@@ -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 {}
|
||||
}
|
||||
+102
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -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));
|
||||
}
|
||||
}
|
||||
+16
@@ -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() {}
|
||||
}
|
||||
+90
@@ -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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
+97
@@ -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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
+140
@@ -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
Reference in New Issue
Block a user