From 48ff648112b4ba88573b06cbe3ebd3ebeb3ea227 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 19 Aug 2026 23:21:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Tech=20Log=20Studio=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=E2=80=94=20=EB=82=A8=EC=9D=80=2017?= =?UTF-8?q?=EA=B0=9C=20operation=20=EA=B5=AC=ED=98=84=20(=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=8A=A4=202~5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/registries/env-keys.yaml | 63 ++ src/adapter/inbound/web/build.gradle | 247 +++++- src/adapter/inbound/web/gradle.lockfile | 5 + .../controller/StudioAssetController.java | 220 ++++++ .../controller/StudioDocumentController.java | 226 ++++++ .../controller/StudioPreviewController.java | 121 +++ .../StudioPublicationController.java | 177 +++++ .../studio/mapper/StudioDetailMapper.java | 262 +++++++ .../studio/mapper/StudioRequestMapper.java | 203 +++++ .../studio/mapper/StudioResponseMapper.java | 258 +++++++ .../techlog/studio/render/BlockRenderer.java | 236 ++++++ .../web/techlog/studio/render/HeadingIds.java | 71 ++ .../techlog/studio/render/InlineRenderer.java | 117 +++ .../studio/render/MarkdownSegments.java | 92 +++ .../render/PublicRenderModelFactory.java | 166 ++++ .../studio/render/RenderModelJsonAdapter.java | 47 ++ .../studio/render/StudioContentRenderer.java | 181 +++++ .../studio/render/StudioDirective.java | 47 ++ .../techlog/studio/support/StudioCursors.java | 122 +++ .../studio/support/StudioIdempotency.java | 90 +++ .../studio/support/StudioPrincipals.java | 23 + .../studio/support/StudioSettings.java | 44 ++ .../StudioContractUnionJacksonTest.java | 142 ++++ .../render/StudioContentRendererTest.java | 223 ++++++ .../outbound/persistence-jpa/build.gradle | 14 + .../outbound/persistence-jpa/gradle.lockfile | 10 +- .../artifact/JdbcPreviewArtifactAdapter.java | 91 +++ .../JdbcValidationArtifactAdapter.java | 143 ++++ .../query/JdbcDependencyRevisionAdapter.java | 41 + .../query/JdbcPublicationQueryAdapter.java | 42 + .../JdbcStudioDashboardQueryAdapter.java | 57 ++ .../JdbcStudioDependencyResolverAdapter.java | 225 ++++++ .../query/JdbcStudioDocumentQueryAdapter.java | 141 ++++ .../query/StudioDocumentRowMapper.java | 48 ++ .../techlog/query/StudioDocumentSql.java | 130 ++++ .../asset/JdbcAssetRepositoryAdapter.java | 244 ++++++ .../JdbcPublicationHistoryQueryAdapter.java | 192 +++++ .../JdbcPublicationWriterAdapter.java | 514 +++++++++++++ .../publication/PublicationRowMappers.java | 25 + .../workingcopy/DocumentWorkingCopyStore.java | 255 +++++++ .../JdbcWorkingCopyRepositoryAdapter.java | 128 ++++ .../ProjectDecisionWorkingCopyStore.java | 180 +++++ .../techlog/workingcopy/ProjectLinkStore.java | 81 ++ .../workingcopy/QuestionWorkingCopyStore.java | 296 ++++++++ .../techlog/workingcopy/StudioJson.java | 131 ++++ .../workingcopy/StudioRelationStore.java | 89 +++ .../techlog/workingcopy/StudioSqlSupport.java | 49 ++ .../V8__techlog_studio_working_copy.sql | 92 +++ .../StudioPersistenceIntegrationTest.java | 716 ++++++++++++++++++ src/app-bootstrap/gradle.lockfile | 5 + .../contract/StudioContractDriftTest.java | 517 ++++++++++++- .../ObjectStorageAssetBinaryAdapter.java | 51 ++ .../techlog/TechLogStudioConfig.java | 235 ++++++ .../src/main/resources/application-dev.yml | 11 + .../src/main/resources/application-local.yml | 19 + .../src/main/resources/application-prod.yml | 11 + .../src/main/resources/application.yml | 10 + .../architecture/TechLogBoundaryArchTest.java | 7 + .../studio/command/CreateDocumentCommand.java | 12 + .../studio/command/CreatePreviewCommand.java | 9 + .../studio/command/DeleteAssetCommand.java | 7 + .../command/PublishDocumentCommand.java | 22 + .../studio/command/SaveDocumentCommand.java | 10 + .../command/UnpublishPublicationCommand.java | 8 + .../studio/command/UpdateAssetCommand.java | 23 + .../studio/command/UploadAssetCommand.java | 74 ++ .../command/ValidateDocumentCommand.java | 8 + .../techlog/studio/model/AssetDetailView.java | 16 + .../techlog/studio/model/AssetKindView.java | 8 + .../model/AssetManagementStatusView.java | 13 + .../studio/model/AssetManifestEntry.java | 18 + .../techlog/studio/model/AssetPageView.java | 11 + .../techlog/studio/model/AssetUsageView.java | 7 + .../techlog/studio/model/AssetView.java | 28 + .../studio/model/DashboardTotalsView.java | 5 + .../techlog/studio/model/DashboardView.java | 17 + .../studio/model/DecisionStatusView.java | 7 + .../studio/model/DisplayTargetView.java | 6 + .../studio/model/DocumentPageView.java | 11 + .../techlog/studio/model/DocumentSort.java | 8 + .../studio/model/DocumentSummaryView.java | 22 + .../techlog/studio/model/NextAction.java | 11 + .../techlog/studio/model/OrderedTextView.java | 6 + .../studio/model/PreviewDetailView.java | 10 + .../techlog/studio/model/PreviewState.java | 8 + .../techlog/studio/model/PublicPaths.java | 33 + .../studio/model/PublicPreviewView.java | 21 + .../studio/model/PublicationActionView.java | 8 + .../model/PublicationAggregateStatus.java | 7 + .../model/PublicationAggregateView.java | 15 + .../model/PublicationEventTypeView.java | 8 + .../studio/model/PublicationEventView.java | 19 + .../studio/model/PublicationListItemView.java | 15 + .../studio/model/PublicationPageView.java | 11 + .../studio/model/PublicationSnapshotView.java | 10 + .../studio/model/PublicationStatusView.java | 8 + .../studio/model/PublishResultView.java | 4 + .../studio/model/QuestionOptionView.java | 6 + .../studio/model/QuestionResolutionView.java | 6 + .../studio/model/QuestionStatusView.java | 10 + .../techlog/studio/model/RecordKind.java | 12 + .../studio/model/ReferenceRuleView.java | 6 + .../techlog/studio/model/RelationView.java | 9 + .../techlog/studio/model/RenderInput.java | 29 + .../studio/model/ResolvedAssetView.java | 18 + .../studio/model/ResolvedRelationView.java | 18 + .../studio/model/ValidationIssueView.java | 9 + .../studio/model/ValidationReportView.java | 24 + .../studio/model/ValidationSeverity.java | 7 + .../studio/model/ValidationStatus.java | 8 + .../studio/model/WorkingCopyBaseInput.java | 26 + .../studio/model/WorkingCopyDetailView.java | 15 + .../studio/model/WorkingCopyInputView.java | 86 +++ .../techlog/studio/model/WorkingCopyView.java | 83 ++ .../port/out/AssetBinaryStoragePort.java | 20 + .../studio/port/out/AssetRepositoryPort.java | 63 ++ .../studio/port/out/ContentAnalyzerPort.java | 35 + .../port/out/DependencyRevisionPort.java | 17 + .../studio/port/out/PreviewArtifactPort.java | 16 + .../port/out/PublicationHistoryQueryPort.java | 18 + .../studio/port/out/PublicationQueryPort.java | 12 + .../port/out/PublicationWriterPort.java | 64 ++ .../studio/port/out/RenderModelPort.java | 19 + .../port/out/StudioDashboardQueryPort.java | 14 + .../out/StudioDependencyResolverPort.java | 51 ++ .../port/out/StudioDocumentQueryPort.java | 14 + .../port/out/ValidationArtifactPort.java | 17 + .../port/out/WorkingCopyRepositoryPort.java | 31 + .../studio/query/DocumentCursorPosition.java | 14 + .../techlog/studio/query/GetAssetQuery.java | 7 + .../studio/query/GetDashboardQuery.java | 6 + .../studio/query/GetDocumentQuery.java | 7 + .../techlog/studio/query/GetPreviewQuery.java | 7 + .../studio/query/GetSnapshotQuery.java | 7 + .../techlog/studio/query/ListAssetsQuery.java | 17 + .../studio/query/ListDocumentsQuery.java | 25 + .../studio/query/ListPublicationsQuery.java | 15 + .../studio/service/AssetMediaTypes.java | 71 ++ .../service/CreateStudioDocumentUseCase.java | 56 ++ .../service/CreateStudioPreviewUseCase.java | 171 +++++ .../service/DeleteStudioAssetUseCase.java | 78 ++ .../GetCurrentStudioPreviewUseCase.java | 93 +++ .../studio/service/GetStudioAssetUseCase.java | 42 + .../service/GetStudioDashboardUseCase.java | 61 ++ .../service/GetStudioDocumentUseCase.java | 55 ++ .../GetStudioPublicationSnapshotUseCase.java | 45 ++ .../service/ListStudioAssetsUseCase.java | 47 ++ .../service/ListStudioDocumentsUseCase.java | 55 ++ .../ListStudioPublicationsUseCase.java | 43 ++ .../studio/service/NextActionCalculator.java | 88 +++ .../service/PublishStudioDocumentUseCase.java | 249 ++++++ .../techlog/studio/service/SaveOutcome.java | 18 + .../service/SaveStudioDocumentUseCase.java | 102 +++ .../studio/service/StudioDocumentLoader.java | 46 ++ .../studio/service/StudioPermissions.java | 16 + .../UnpublishStudioPublicationUseCase.java | 90 +++ .../service/UpdateStudioAssetUseCase.java | 97 +++ .../service/UploadStudioAssetUseCase.java | 140 ++++ .../ValidateStudioDocumentUseCase.java | 116 +++ .../service/WorkingCopyDetailAssembler.java | 59 ++ .../service/WorkingCopyInputValidator.java | 76 ++ .../validation/StudioDocumentValidator.java | 262 +++++++ .../studio/validation/ValidationIssues.java | 46 ++ 163 files changed, 11807 insertions(+), 10 deletions(-) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioAssetController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioDocumentController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPreviewController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioDetailMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioRequestMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/HeadingIds.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/InlineRenderer.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/MarkdownSegments.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/PublicRenderModelFactory.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/RenderModelJsonAdapter.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRenderer.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioDirective.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioCursors.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioIdempotency.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioPrincipals.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioSettings.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/contract/StudioContractUnionJacksonTest.java create mode 100644 src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcPreviewArtifactAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcValidationArtifactAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcDependencyRevisionAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcPublicationQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDashboardQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDependencyResolverAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDocumentQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentRowMapper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentSql.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/asset/JdbcAssetRepositoryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationHistoryQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationWriterAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/PublicationRowMappers.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/DocumentWorkingCopyStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/JdbcWorkingCopyRepositoryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectDecisionWorkingCopyStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectLinkStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/QuestionWorkingCopyStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioJson.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioRelationStore.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioSqlSupport.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__techlog_studio_working_copy.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/StudioPersistenceIntegrationTest.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/ObjectStorageAssetBinaryAdapter.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreateDocumentCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreatePreviewCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/DeleteAssetCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/PublishDocumentCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/SaveDocumentCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UnpublishPublicationCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UpdateAssetCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UploadAssetCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/ValidateDocumentCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetKindView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManagementStatusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManifestEntry.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetUsageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardTotalsView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DecisionStatusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DisplayTargetView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSummaryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/NextAction.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/OrderedTextView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewState.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPreviewView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationActionView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateStatus.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventTypeView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationSnapshotView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationStatusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublishResultView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionOptionView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionResolutionView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionStatusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RecordKind.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ReferenceRuleView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RelationView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RenderInput.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedAssetView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedRelationView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationIssueView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationReportView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationSeverity.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationStatus.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyBaseInput.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyInputView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetBinaryStoragePort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetRepositoryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ContentAnalyzerPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/DependencyRevisionPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PreviewArtifactPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationHistoryQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationWriterPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/RenderModelPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDashboardQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDependencyResolverPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDocumentQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ValidationArtifactPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/WorkingCopyRepositoryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/DocumentCursorPosition.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetAssetQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDashboardQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDocumentQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetPreviewQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetSnapshotQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListAssetsQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListDocumentsQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListPublicationsQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/AssetMediaTypes.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioPreviewUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/DeleteStudioAssetUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/NextActionCalculator.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/PublishStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveOutcome.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioDocumentLoader.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UnpublishStudioPublicationUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UpdateStudioAssetUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ValidateStudioDocumentUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyDetailAssembler.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/ValidationIssues.java diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index bb941e7..4d66011 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -4249,3 +4249,66 @@ env_keys: validation: positive_int_bounded compatibility_impact: behavior-change required_test: async-contract:executor-queue-bounded + + # === Tech Log Studio (feature-techlog-studio-backend) === + + - name: APP_STUDIO_CURSOR_SIGNING_KEY + # source: studio-v1.yaml components.parameters.Cursor — "Opaque cursor bound to + # normalized filters and sort". 서명 키가 인스턴스마다 다르면 한 인스턴스가 발급한 + # 커서를 다른 인스턴스가 거부한다. 비어 있으면 StudioSettings가 경고하고 개발용 값으로 + # 대체한다(커서에 권한이 실리지 않으므로 부팅은 막지 않는다). + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: feature-techlog-studio-backend + validation: min_length_16_bytes + compatibility_impact: behavior-change + required_test: techlog-studio-contract:cursor-round-trip + + - name: APP_STUDIO_VALIDATION_TTL + # source: 백엔드 설계 §7.3 — "now() < validUntil" 이 Validation 유효 조건의 하나다. + # studio_validation.valid_until 을 채우는 값. + type: duration + default: 1h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-techlog-studio-backend + validation: spring_duration_shorthand + compatibility_impact: behavior-change + required_test: techlog-studio-contract:validation-window + + - name: APP_STUDIO_PREVIEW_TTL + # source: 백엔드 설계 §7.3 — Preview 가 EXPIRED 로 넘어가는 기준. + # studio_preview.expires_at 을 채우는 값. + type: duration + default: 24h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-techlog-studio-backend + validation: spring_duration_shorthand + compatibility_impact: behavior-change + required_test: techlog-studio-contract:preview-expiry + + - name: APP_STUDIO_AUTHOR_ROLE + # source: 백엔드 설계 §9 — 권한은 기존 RolePermissionPolicy 를 재사용한다. + # ca-skeleton.authz.role-permissions 의 키로 쓰이는 IdP RAW role 이름. 배포마다 다르다. + # application.yml 이 아니라 프로파일(application-{local,dev,prod}.yml)에 있다 — + # SampleRemovalSmokeContractTest 가 application.yml 의 `role-permissions: {}` 기준선을 + # 그대로 유지하도록 요구하기 때문이다. + type: string + default: studio-author + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-techlog-studio-backend + validation: non_blank + compatibility_impact: behavior-change + required_test: techlog-studio-contract:author-permission diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index fc6bf52..9bdc136 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -22,6 +22,11 @@ plugins { id 'org.openapi.generator' } sourceSets { generatedOpenapi { java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java')) + // 계약의 discriminator union을 Java interface로 파생한 소스(prepareStudioCodegenSpec). + // 생성 DTO와 같은 sourceSet이어야 한다 — 생성된 하위 타입이 `implements ` 하므로 + // 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다. + // main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다. + java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java')) } // main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation // Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을 @@ -64,6 +69,15 @@ dependencies { // never a hand-maintained stale schema). The release-blocking drift gate is // owned by feature-contract-verification-test-suite (planned). implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0' + // Studio Preview / Public Snapshot 의 CaseRenderBlock 을 만드는 Markdown 파서. + // 설계 05장 §16 이 "특정 라이브러리를 도메인 계약으로 만들지 않는다"고 정하므로 이 의존은 + // techlog/studio/render 패키지 안에서만 쓰고 바깥에는 계약 DTO 만 내보낸다. + // 프론트가 remark 계열로 같은 문법을 다루므로(ADR-005 동등성) CommonMark + GFM 확장이라는 + // 같은 기준을 쓴다 — 자체 파서를 쓰면 두 화면의 해석이 갈라진다. + implementation 'org.commonmark:commonmark:0.21.0' + implementation 'org.commonmark:commonmark-ext-gfm-tables:0.21.0' + implementation 'org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0' + implementation 'org.commonmark:commonmark-ext-autolink:0.21.0' // Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared — // deliberately not spring-boot-starter-webflux, which would put a second embedded server // (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's @@ -121,6 +135,225 @@ tasks.named('check') { dependsOn tasks.named('webSecurityBoundaryTest') } +// --------------------------------------------------------------------------- +// 계약 union -> 생성 코드 배선 (슬라이스 2 선행 / spec §5.2 보강). +// +// 문제: useOneOfInterfaces=false 는 컴파일은 통과시키지만 discriminator union이 Jackson +// 양방향 모두 계약을 위반한다(역직렬화 InvalidTypeIdException, 직렬화는 kind 대신 클래스 +// simple name). useOneOfInterfaces=true 는 SpringCodegen이 union interface의 discriminator +// getter를 String으로 고정해 컴파일이 깨진다 — 계약 쪽으로 우회 불가(Plan 01 task-4-report). +// +// 해법: 계약 원본은 그대로 두고, 생성 직전에 사본을 파생시킨다. +// (1) oneOf + discriminator 를 가진 스키마 S 의 각 하위 타입에 `x-implements: [S]` 를 주입한다 +// -> 생성된 하위 타입이 `implements S` 로 나온다(7.18.0에서 실측 확인). +// (2) S 자체의 생성은 ignore 시키고, 같은 package 에 Jackson 다형성 애너테이션을 단 +// Java interface 로 이 태스크가 직접 써 넣는다. +// (3) 모든 분기가 `type: string` 인 이름 없는 oneOf 는 `type: string` 으로 접는다. +// 접지 않으면 생성기가 필드가 하나도 없는 껍데기 클래스를 만든다 +// (WorkingCopyInputBase.slug -> WorkingCopyInputBaseSlug: 실측 확인). 그 결과 +// slug 가 문자열이 아니라 `{}` 로 직렬화되어 계약을 위반한다. +// +// 파생 규칙은 계약의 `oneOf`/`discriminator.mapping` 에서 전부 읽어낸다 — union 목록을 +// 손으로 관리하지 않으므로 계약에 union이 추가돼도 따라온다. +// +// snakeyaml 은 org.openapi.generator 플러그인이 buildscript classpath 로 이미 가져온다 +// (swagger-parser 경유, 2.4 — 실측 확인). +// --------------------------------------------------------------------------- +ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model' +ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml') +ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore') +ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java') + +tasks.register('prepareStudioCodegenSpec') { + description = '계약에서 discriminator union 배선을 파생시켜 생성기 입력을 만든다.' + def specSource = file("${rootDir}/config/openapi/studio-v1.yaml") + def specOut = studioCodegenSpecFile + def ignoreOut = studioCodegenIgnoreFile + def unionDir = studioUnionSrcDir + def modelPackage = studioModelPackage + inputs.file(specSource) + outputs.file(specOut) + outputs.file(ignoreOut) + outputs.dir(unionDir) + doLast { + def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8')) + def schemas = doc.components.schemas + + // (3) 전부 string 인 이름 없는 oneOf 접기 + int[] collapsed = [0] + def collapseStringOneOf + collapseStringOneOf = { Object node -> + if (node instanceof Map) { + for (Object key : new ArrayList(node.keySet())) { + def value = node.get(key) + if (value instanceof Map && value.get('oneOf') instanceof List + && !value.containsKey('discriminator')) { + def branches = value.get('oneOf') + if (!branches.isEmpty() + && branches.every { it instanceof Map && it.get('type') == 'string' }) { + def folded = new LinkedHashMap() + folded.put('type', 'string') + if (value.containsKey('description')) { + folded.put('description', value.get('description')) + } + node.put(key, folded) + collapsed[0]++ + continue + } + } + collapseStringOneOf(value) + } + } else if (node instanceof List) { + node.each { collapseStringOneOf(it) } + } + } + collapseStringOneOf(doc) + + // (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면 + // 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가 + // DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌 + // 생겨 매핑 코드가 그 사이를 오가게 된다. Java 참조는 어차피 nullable 이라 손실이 없으므로 + // null 분기를 지우고 남은 하나로 접는다. + int[] nullable = [0] + def collapseNullableOneOf + collapseNullableOneOf = { Object node -> + if (node instanceof Map) { + for (Object key : new ArrayList(node.keySet())) { + def value = node.get(key) + if (value instanceof Map && value.get('oneOf') instanceof List + && !value.containsKey('discriminator')) { + def branches = value.get('oneOf') + def nulls = branches.findAll { it instanceof Map && it.get('type') == 'null' } + def rest = branches - nulls + if (!nulls.isEmpty() && rest.size() == 1 && rest[0] instanceof Map) { + def folded = new LinkedHashMap(rest[0]) + if (value.containsKey('description') && !folded.containsKey('description')) { + folded.put('description', value.get('description')) + } + node.put(key, folded) + nullable[0]++ + continue + } + } + collapseNullableOneOf(value) + } + } else if (node instanceof List) { + node.each { collapseNullableOneOf(it) } + } + } + collapseNullableOneOf(doc) + + // (1) x-implements 주입 + union 목록 수집 + def unions = [:] + schemas.each { String name, Object schema -> + if (!(schema instanceof Map)) return + def disc = schema.get('discriminator') + if (!(schema.get('oneOf') instanceof List) || !(disc instanceof Map)) return + def property = disc.get('propertyName') + def mapping = disc.get('mapping') + if (!property || !(mapping instanceof Map) || mapping.isEmpty()) { + throw new GradleException( + "union ${name} 에 discriminator.propertyName 과 mapping 이 모두 있어야 한다 " + + "— 없으면 Jackson @JsonSubTypes 의 type id 를 계약에서 유도할 수 없다.") + } + def variants = new LinkedHashMap() + mapping.each { String typeId, String ref -> + def variant = ref.tokenize('/').last() + if (!schemas.containsKey(variant)) { + throw new GradleException("union ${name} 의 mapping 이 없는 스키마 ${variant} 를 가리킨다.") + } + variants.put(typeId, variant) + } + schema.get('oneOf').each { branch -> + if (branch instanceof Map && branch.get('$ref')) { + def variant = branch.get('$ref').tokenize('/').last() + if (!variants.containsValue(variant)) { + throw new GradleException( + "union ${name} 의 oneOf 분기 ${variant} 가 discriminator.mapping 에 없다 " + + "— type id 를 알 수 없어 Jackson 배선을 파생시킬 수 없다.") + } + } + } + variants.values().toSet().each { String variant -> + def target = schemas.get(variant) + def impls = target.get('x-implements') + if (impls == null) { + target.put('x-implements', [name]) + } else if (!impls.contains(name)) { + target.put('x-implements', impls + [name]) + } + } + unions.put(name, [property: property, variants: variants]) + } + if (unions.isEmpty()) { + throw new GradleException('계약에서 discriminator union 을 하나도 찾지 못했다 — 파생 규칙이 깨졌다.') + } + + // 파생 계약 쓰기 + def dumperOptions = new org.yaml.snakeyaml.DumperOptions() + dumperOptions.defaultFlowStyle = org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK + dumperOptions.width = 8192 + def specFile = specOut.get().asFile + specFile.parentFile.mkdirs() + specFile.setText(new org.yaml.snakeyaml.Yaml(dumperOptions).dump(doc), 'UTF-8') + + // union 클래스 생성 억제 + def ignoreFile = ignoreOut.get().asFile + ignoreFile.setText( + (['# prepareStudioCodegenSpec 가 생성한다 — 손으로 고치지 않는다.', + '# 이 파일들은 같은 package 의 Java interface 로 대체된다.'] + + unions.keySet().collect { "**/${it}.java" }).join('\n') + '\n', + 'UTF-8') + + // union interface 쓰기 + def packageDir = new File(unionDir.get().asFile, modelPackage.replace('.', '/')) + project.delete(unionDir.get().asFile) + packageDir.mkdirs() + unions.each { String name, Object spec -> + def subtypes = spec.variants.collect { String typeId, String variant -> + " @JsonSubTypes.Type(value = ${variant}.class, name = \"${typeId}\")" + }.join(',\n') + def source = """package ${modelPackage}; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * {@code ${name}} — 계약의 discriminator union. prepareStudioCodegenSpec 가 계약의 + * {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다. + * + *

{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로 + * 이미 직렬화하므로 Jackson 이 판별 필드를 한 번 더 쓰면 키가 중복된다. + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "${spec.property}", + visible = true) +@JsonSubTypes({ +${subtypes} +}) +public interface ${name} {} +""" + new File(packageDir, "${name}.java").setText(source, 'UTF-8') + } + + logger.lifecycle( + "prepareStudioCodegenSpec: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), " + + "string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음") + } +} + +// openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라 +// dependsOn 을 받지 못한다. 태스크 쪽에 건다. +tasks.named('openApiGenerate') { + dependsOn tasks.named('prepareStudioCodegenSpec') + // openapi-generator 는 outputDir 를 비우지 않는다 — 계약에서 스키마가 사라져도 직전 + // 실행의 .java 가 그대로 남아 컴파일에 성공하고, 그래서 드리프트가 아니라 정상으로 보인다 + // (이 배선을 넣는 과정에서 실제로 겪음: 삭제됐어야 할 union 6개가 남아 있었다). + doFirst { project.delete(layout.buildDirectory.dir('generated/openapi')) } +} + // --------------------------------------------------------------------------- // Studio 계약 DTO 생성 (ADR-004 / ADR-006). // @@ -155,16 +388,24 @@ tasks.named('check') { // --------------------------------------------------------------------------- openApiGenerate { generatorName = 'spring' - inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString() + // 원본이 아니라 prepareStudioCodegenSpec 가 파생한 사본을 먹인다 — 이유는 그 태스크의 주석 참고. + inputSpec = studioCodegenSpecFile.get().asFile.path + ignoreFileOverride = studioCodegenIgnoreFile.get().asFile.path outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path - modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model' + modelPackage = studioModelPackage globalProperties.set(['models': '']) generateModelTests = false generateModelDocumentation = false configOptions = [ useSpringBoot3: 'true', useJakartaEe: 'true', - openApiNullable: 'true', + // false 다. openApiNullable=true 는 nullable 필드를 JsonNullable 로 만드는데, + // 그 타입을 읽는 모듈(org.openapitools:jackson-databind-nullable)은 Jackson 2 용이고 + // 이 앱의 HTTP 변환기는 Jackson 3(tools.jackson, Spring Boot 4 기본)다 — 모듈이 + // 등록될 수 없어 JsonNullable 이 그냥 POJO 로 직렬화되고 역직렬화는 깨진다. + // 계약이 이 필드들을 required 로 두므로(예: questionStatus, decisionStatus) + // "없음"과 "null" 을 구분할 필요도 없다. 평범한 nullable 필드로 생성한다. + openApiNullable: 'false', useOneOfInterfaces: 'false', ] } diff --git a/src/adapter/inbound/web/gradle.lockfile b/src/adapter/inbound/web/gradle.lockfile index c0429f4..4dc2699 100644 --- a/src/adapter/inbound/web/gradle.lockfile +++ b/src/adapter/inbound/web/gradle.lockfile @@ -91,6 +91,10 @@ org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.commonmark:commonmark-ext-autolink:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.commonmark:commonmark-ext-gfm-tables:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.commonmark:commonmark:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -108,6 +112,7 @@ org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath +org.nibor.autolink:autolink:0.10.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=testRuntimeClasspath org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioAssetController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioAssetController.java new file mode 100644 index 0000000..10a665a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioAssetController.java @@ -0,0 +1,220 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UpdateAssetCommand; +import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals; +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.command.UploadAssetCommand; +import dev.caskeleton.application.techlog.studio.model.AssetKindView; +import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView; +import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition; +import dev.caskeleton.application.techlog.studio.query.GetAssetQuery; +import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery; +import dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase; +import dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase; +import dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase; +import dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase; +import dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * 계약 {@code studio-v1.yaml}의 Assets 다섯 operation. + * + *

메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다. + */ +@RestController +public class StudioAssetController { + + private final ListStudioAssetsUseCase listAssets; + private final UploadStudioAssetUseCase uploadAsset; + private final GetStudioAssetUseCase getAsset; + private final UpdateStudioAssetUseCase updateAsset; + private final DeleteStudioAssetUseCase deleteAsset; + private final StudioDetailMapper detailMapper; + private final StudioCursors cursors; + private final StudioIdempotency idempotency; + + public StudioAssetController( + ListStudioAssetsUseCase listAssets, + UploadStudioAssetUseCase uploadAsset, + GetStudioAssetUseCase getAsset, + UpdateStudioAssetUseCase updateAsset, + DeleteStudioAssetUseCase deleteAsset, + StudioDetailMapper detailMapper, + StudioCursors cursors, + StudioIdempotency idempotency) { + this.listAssets = listAssets; + this.uploadAsset = uploadAsset; + this.getAsset = getAsset; + this.updateAsset = updateAsset; + this.deleteAsset = deleteAsset; + this.detailMapper = detailMapper; + this.cursors = cursors; + this.idempotency = idempotency; + } + + @GetMapping("/v1/studio/assets") + public AssetPage listStudioAssets( + @RequestParam(value = "q", required = false) String query, + @RequestParam(value = "kind", required = false) AssetKind kind, + @RequestParam(value = "managementStatus", required = false) AssetManagementStatus status, + @RequestParam(value = "cursor", required = false) String cursor, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + + String fingerprint = + StudioCursors.fingerprint( + query, + kind == null ? null : kind.getValue(), + status == null ? null : status.getValue()); + DocumentCursorPosition position = + cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false); + + var page = + listAssets.handle( + new ListAssetsQuery( + query, + kind == null ? null : AssetKindView.valueOf(kind.getValue()), + status == null ? null : AssetManagementStatusView.valueOf(status.getValue()), + position == null ? null : position.updatedAt(), + position == null ? null : position.id(), + limit)); + + AssetPage body = detailMapper.toApi(page); + body.setNextCursor( + page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint)); + return body; + } + + @PostMapping(value = "/v1/studio/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadStudioAsset( + @RequestParam("file") MultipartFile file, + @RequestParam("kind") AssetKind kind, + @RequestParam(value = "altText", required = false) String altText, + @RequestParam(value = "decorative", defaultValue = "false") boolean decorative, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + byte[] content; + try { + content = file.getBytes(); + } catch (IOException e) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "the uploaded file could not be read"); + } + + StudioIdempotency.Outcome outcome = + idempotency.run( + request, + "uploadStudioAsset", + // 바이트 전체가 아니라 파일명·크기·종류로 지문을 만든다 — 20MB 를 다시 직렬화해 해시하면 + // 업로드마다 그만큼을 한 번 더 읽고 쓰는 셈이 된다. + java.util.List.of( + String.valueOf(file.getOriginalFilename()), + String.valueOf(file.getSize()), + kind.getValue()), + Asset.class, + () -> + detailMapper.toApi( + uploadAsset.handle( + new UploadAssetCommand( + file.getOriginalFilename(), + file.getContentType(), + content, + AssetKindView.valueOf(kind.getValue()), + altText, + decorative, + StudioPrincipals.require(principal))))); + + return ResponseEntity.status(HttpStatus.CREATED) + .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) + .body(outcome.result()); + } + + @GetMapping("/v1/studio/assets/{assetId}") + public AssetDetail getStudioAsset(@PathVariable("assetId") UUID assetId) { + return detailMapper.toApi(getAsset.handle(new GetAssetQuery(assetId))); + } + + @PutMapping("/v1/studio/assets/{assetId}") + public ResponseEntity updateStudioAsset( + @PathVariable("assetId") UUID assetId, + @Valid @RequestBody UpdateAssetCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome outcome = + idempotency.run( + request, + "updateStudioAsset", + command, + Asset.class, + () -> + detailMapper.toApi( + updateAsset.handle( + new dev.caskeleton.application.techlog.studio.command.UpdateAssetCommand( + assetId, + command.getExpectedVersion() == null + ? 0L + : command.getExpectedVersion(), + command.getKind() == null + ? null + : AssetKindView.valueOf(command.getKind().getValue()), + command.getAltText(), + command.getAltText() != null, + command.getDecorative(), + command.getManagementStatus() == null + ? null + : AssetManagementStatusView.valueOf( + command.getManagementStatus().getValue()), + StudioPrincipals.require(principal))))); + + return ResponseEntity.ok() + .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) + .body(outcome.result()); + } + + @DeleteMapping("/v1/studio/assets/{assetId}") + public ResponseEntity deleteStudioAsset( + @PathVariable("assetId") UUID assetId, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + idempotency.run( + request, + "deleteStudioAsset", + assetId.toString(), + Void.class, + () -> { + deleteAsset.handle(new DeleteAssetCommand(assetId, StudioPrincipals.require(principal))); + return null; + }); + // 계약은 204 다. 본문이 없으므로 EnvelopeBodyAdvice 도 감쌀 것이 없다. + return ResponseEntity.noContent().build(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioDocumentController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioDocumentController.java new file mode 100644 index 0000000..89b454a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioDocumentController.java @@ -0,0 +1,226 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.SaveDocumentCommand; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput; +import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper; +import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioRequestMapper; +import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals; +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.DocumentPageView; +import dev.caskeleton.application.techlog.studio.model.DocumentSort; +import dev.caskeleton.application.techlog.studio.model.PublicationStatusView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition; +import dev.caskeleton.application.techlog.studio.query.GetDocumentQuery; +import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery; +import dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase; +import dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase; +import dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase; +import dev.caskeleton.application.techlog.studio.service.SaveOutcome; +import dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 계약 {@code studio-v1.yaml}의 Documents 네 operation. + * + *

메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 springdoc이 게시한 이름과 계약을 대조한다. + * 이름을 바꾸면 그 게이트가 빨간불이 된다. + * + *

반환값을 봉투로 감싸지 않는다 — {@code EnvelopeBodyAdvice}가 감싼다(ADR-006). + * + *

경로에 {@code /api}를 쓰지 않는다 — {@code PresentationWebConfig}가 {@code + * ca-skeleton.presentation.api-base-path}를 모든 매핑에 붙인다. + */ +@RestController +public class StudioDocumentController { + + private final ListStudioDocumentsUseCase listDocuments; + private final CreateStudioDocumentUseCase createDocument; + private final GetStudioDocumentUseCase getDocument; + private final SaveStudioDocumentUseCase saveDocument; + private final StudioDetailMapper detailMapper; + private final StudioCursors cursors; + private final StudioIdempotency idempotency; + + public StudioDocumentController( + ListStudioDocumentsUseCase listDocuments, + CreateStudioDocumentUseCase createDocument, + GetStudioDocumentUseCase getDocument, + SaveStudioDocumentUseCase saveDocument, + StudioDetailMapper detailMapper, + StudioCursors cursors, + StudioIdempotency idempotency) { + this.listDocuments = listDocuments; + this.createDocument = createDocument; + this.getDocument = getDocument; + this.saveDocument = saveDocument; + this.detailMapper = detailMapper; + this.cursors = cursors; + this.idempotency = idempotency; + } + + @GetMapping("/v1/studio/documents") + public DocumentPage listStudioDocuments( + @RequestParam(value = "q", required = false) String query, + @RequestParam(value = "kind", required = false) RecordKind kind, + @RequestParam(value = "publicationStatus", required = false) + PublicationStatus publicationStatus, + @RequestParam(value = "nextAction", required = false) NextAction nextAction, + @RequestParam(value = "projectId", required = false) UUID projectId, + @RequestParam(value = "sort", defaultValue = "UPDATED_DESC") String sort, + @RequestParam(value = "cursor", required = false) String cursor, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + + DocumentSort documentSort = documentSort(sort); + // 지문에 정렬까지 넣는다 — 정렬만 바꾸고 커서를 재사용하면 커서가 가리키는 키의 의미가 달라진다. + String fingerprint = + StudioCursors.fingerprint( + query, + kind == null ? null : kind.getValue(), + publicationStatus == null ? null : publicationStatus.getValue(), + nextAction == null ? null : nextAction.getValue(), + projectId == null ? null : projectId.toString(), + documentSort.name()); + DocumentCursorPosition position = + cursor == null || cursor.isBlank() + ? null + : cursors.decode(cursor, fingerprint, documentSort == DocumentSort.TITLE_ASC); + + DocumentPageView page = + listDocuments.handle( + new ListDocumentsQuery( + query, + kind == null + ? null + : dev.caskeleton.application.techlog.studio.model.RecordKind.valueOf( + kind.getValue()), + publicationStatus == null + ? null + : PublicationStatusView.valueOf(publicationStatus.getValue()), + nextAction == null + ? null + : dev.caskeleton.application.techlog.studio.model.NextAction.valueOf( + nextAction.getValue()), + projectId, + documentSort, + position, + limit)); + + DocumentPage body = StudioResponseMapper.toApi(page); + // 어댑터가 준 것은 다음 쪽의 시작 위치일 뿐이다. 클라이언트에 나가는 것은 서명된 opaque 값이어야 한다. + body.setNextCursor( + page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint)); + return body; + } + + @PostMapping("/v1/studio/documents") + public ResponseEntity createStudioDocument( + @Valid @RequestBody WorkingCopyInput document, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome outcome = + idempotency.run( + request, + "createStudioDocument", + document, + WorkingCopy.class, + () -> { + WorkingCopyView created = + createDocument.handle( + new CreateDocumentCommand( + StudioRequestMapper.toApplication(document), + StudioPrincipals.require(principal))); + return StudioResponseMapper.toApi(created); + }); + + return ResponseEntity.status(HttpStatus.CREATED) + .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) + .body(outcome.result()); + } + + @GetMapping("/v1/studio/documents/{documentId}") + public WorkingCopyDetail getStudioDocument(@PathVariable("documentId") UUID documentId) { + return detailMapper.toApi(getDocument.handle(new GetDocumentQuery(documentId))); + } + + @PutMapping("/v1/studio/documents/{documentId}") + public ResponseEntity saveStudioDocument( + @PathVariable("documentId") UUID documentId, + @Valid @RequestBody SaveDocumentCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome outcome = + idempotency.run( + request, + "saveStudioDocument", + command, + WorkingCopyDetail.class, + () -> { + SaveOutcome saved = + saveDocument.handle( + new dev.caskeleton.application.techlog.studio.command.SaveDocumentCommand( + documentId, + command.getExpectedVersion() == null ? 0 : command.getExpectedVersion(), + StudioRequestMapper.toApplication(command.getDocument()), + StudioPrincipals.require(principal))); + return switch (saved) { + case SaveOutcome.Saved value -> detailMapper.toApi(value.detail()); + case SaveOutcome.VersionConflict value -> throw versionConflict(value.latest()); + }; + }); + + return ResponseEntity.ok() + .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) + .body(outcome.result()); + } + + /** + * 계약의 {@code VersionConflictDetails}는 {@code latestDocument}로 현재 상태 전체를 함께 준다 — 클라이언트가 다시 조회하지 + * 않고도 충돌 화면을 그릴 수 있어야 한다. + */ + private StudioException versionConflict(WorkingCopyDetailView latest) { + Map details = new LinkedHashMap<>(); + details.put("latestDocument", detailMapper.toApi(latest)); + return StudioException.withDetails( + StudioError.VERSION_CONFLICT, "expectedVersion does not match the stored version", details); + } + + private static DocumentSort documentSort(String sort) { + try { + return DocumentSort.valueOf(sort); + } catch (IllegalArgumentException e) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "sort must be one of UPDATED_DESC, UPDATED_ASC, TITLE_ASC"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPreviewController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPreviewController.java new file mode 100644 index 0000000..8ef1eb7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPreviewController.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CreatePreviewCommand; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidateDocumentCommand; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport; +import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals; +import dev.caskeleton.application.techlog.studio.query.GetPreviewQuery; +import dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase; +import dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase; +import dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * 계약 {@code studio-v1.yaml}의 Validation / Preview 세 operation. + * + *

메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다. + */ +@RestController +public class StudioPreviewController { + + private final ValidateStudioDocumentUseCase validateDocument; + private final CreateStudioPreviewUseCase createPreview; + private final GetCurrentStudioPreviewUseCase getPreview; + private final StudioDetailMapper detailMapper; + private final StudioIdempotency idempotency; + + public StudioPreviewController( + ValidateStudioDocumentUseCase validateDocument, + CreateStudioPreviewUseCase createPreview, + GetCurrentStudioPreviewUseCase getPreview, + StudioDetailMapper detailMapper, + StudioIdempotency idempotency) { + this.validateDocument = validateDocument; + this.createPreview = createPreview; + this.getPreview = getPreview; + this.detailMapper = detailMapper; + this.idempotency = idempotency; + } + + @PostMapping("/v1/studio/documents/{documentId}/validate") + public ResponseEntity validateStudioDocument( + @PathVariable("documentId") UUID documentId, + @Valid @RequestBody ValidateDocumentCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome outcome = + idempotency.run( + request, + "validateStudioDocument", + command, + ValidationReport.class, + () -> + detailMapper.toApi( + validateDocument.handle( + new dev.caskeleton.application.techlog.studio.command + .ValidateDocumentCommand( + documentId, + expectedVersion(command.getExpectedVersion()), + StudioPrincipals.require(principal))))); + + return ResponseEntity.ok() + .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) + .body(outcome.result()); + } + + @GetMapping("/v1/studio/documents/{documentId}/preview") + public PreviewDetail getCurrentStudioPreview(@PathVariable("documentId") UUID documentId) { + return detailMapper.toApi(getPreview.handle(new GetPreviewQuery(documentId))); + } + + @PostMapping("/v1/studio/documents/{documentId}/preview") + public ResponseEntity createStudioPreview( + @PathVariable("documentId") UUID documentId, + @Valid @RequestBody CreatePreviewCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome outcome = + idempotency.run( + request, + "createStudioPreview", + command, + PublicPreview.class, + () -> + detailMapper.toApi( + createPreview.handle( + new dev.caskeleton.application.techlog.studio.command.CreatePreviewCommand( + documentId, + expectedVersion(command.getExpectedVersion()), + command.getValidationId(), + StudioPrincipals.require(principal))))); + + return ResponseEntity.status(HttpStatus.CREATED) + .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) + .body(outcome.result()); + } + + /** + * 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다 — 0 은 어떤 저장된 버전과도 일치하지 않아 + * VERSION_CONFLICT 로 나간다. + */ + private static long expectedVersion(Integer value) { + return value == null ? 0L : value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java new file mode 100644 index 0000000..8984d54 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java @@ -0,0 +1,177 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishDocumentCommand; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnpublishCommand; +import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals; +import dev.caskeleton.application.techlog.studio.command.UnpublishPublicationCommand; +import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView; +import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition; +import dev.caskeleton.application.techlog.studio.query.GetDashboardQuery; +import dev.caskeleton.application.techlog.studio.query.GetSnapshotQuery; +import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery; +import dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase; +import dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase; +import dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase; +import dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase; +import dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 계약 {@code studio-v1.yaml}의 Publication / Dashboard 다섯 operation. + * + *

메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다. + */ +@RestController +public class StudioPublicationController { + + private final PublishStudioDocumentUseCase publishDocument; + private final UnpublishStudioPublicationUseCase unpublishPublication; + private final ListStudioPublicationsUseCase listPublications; + private final GetStudioPublicationSnapshotUseCase getSnapshot; + private final GetStudioDashboardUseCase getDashboard; + private final StudioDetailMapper detailMapper; + private final StudioCursors cursors; + private final StudioIdempotency idempotency; + + public StudioPublicationController( + PublishStudioDocumentUseCase publishDocument, + UnpublishStudioPublicationUseCase unpublishPublication, + ListStudioPublicationsUseCase listPublications, + GetStudioPublicationSnapshotUseCase getSnapshot, + GetStudioDashboardUseCase getDashboard, + StudioDetailMapper detailMapper, + StudioCursors cursors, + StudioIdempotency idempotency) { + this.publishDocument = publishDocument; + this.unpublishPublication = unpublishPublication; + this.listPublications = listPublications; + this.getSnapshot = getSnapshot; + this.getDashboard = getDashboard; + this.detailMapper = detailMapper; + this.cursors = cursors; + this.idempotency = idempotency; + } + + @GetMapping("/v1/studio/dashboard") + public StudioDashboard getStudioDashboard() { + return detailMapper.toApi(getDashboard.handle(new GetDashboardQuery())); + } + + @PostMapping("/v1/studio/documents/{documentId}/publish") + public ResponseEntity publishStudioDocument( + @PathVariable("documentId") UUID documentId, + @Valid @RequestBody PublishDocumentCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + // 게시 Event 에 최초 요청의 key 를 남긴다 — 재시도가 중복 Event 를 만들지 않았음을 이력에서 + // 되짚을 수 있어야 한다(V7 publication_event.idempotency_key 주석). + String idempotencyKey = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY); + + StudioIdempotency.Outcome outcome = + idempotency.run( + request, + "publishStudioDocument", + command, + PublishResult.class, + () -> + detailMapper.toApi( + publishDocument.handle( + new dev.caskeleton.application.techlog.studio.command + .PublishDocumentCommand( + documentId, + expectedVersion(command.getExpectedVersion()), + command.getValidationId(), + command.getPreviewId(), + List.copyOf(command.getAcknowledgedWarningCodes()), + idempotencyKey, + StudioPrincipals.require(principal))))); + + return ResponseEntity.status(HttpStatus.CREATED) + .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) + .body(outcome.result()); + } + + @GetMapping("/v1/studio/publications") + public PublicationPage listStudioPublications( + @RequestParam(value = "type", required = false) PublicationEventType type, + @RequestParam(value = "cursor", required = false) String cursor, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + + String fingerprint = StudioCursors.fingerprint(type == null ? null : type.getValue()); + DocumentCursorPosition position = + cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false); + + var page = + listPublications.handle( + new ListPublicationsQuery( + type == null ? null : PublicationEventTypeView.valueOf(type.getValue()), + position == null ? null : position.updatedAt(), + position == null ? null : position.id(), + limit)); + + PublicationPage body = detailMapper.toApi(page); + body.setNextCursor( + page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint)); + return body; + } + + @PostMapping("/v1/studio/publications/{publicationId}/unpublish") + public ResponseEntity unpublishStudioPublication( + @PathVariable("publicationId") UUID publicationId, + @Valid @RequestBody UnpublishCommand command, + @AuthenticationPrincipal AuthenticatedPrincipal principal, + HttpServletRequest request) { + + StudioIdempotency.Outcome outcome = + idempotency.run( + request, + "unpublishStudioPublication", + command, + PublishResult.class, + () -> + detailMapper.toApi( + unpublishPublication.handle( + new UnpublishPublicationCommand( + publicationId, + expectedVersion(command.getExpectedPublicationRevision()), + StudioPrincipals.require(principal))))); + + return ResponseEntity.ok() + .header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed())) + .body(outcome.result()); + } + + @GetMapping("/v1/studio/publications/{publicationEventId}/preview") + public PublicationSnapshot getStudioPublicationSnapshot( + @PathVariable("publicationEventId") UUID publicationEventId) { + return detailMapper.toApi(getSnapshot.handle(new GetSnapshotQuery(publicationEventId))); + } + + /** 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다. */ + private static long expectedVersion(Integer value) { + return value == null ? 0L : value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioDetailMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioDetailMapper.java new file mode 100644 index 0000000..7ebcd1a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioDetailMapper.java @@ -0,0 +1,262 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetUsage; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DashboardTotals; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAction; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAggregate; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEvent; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationListItem; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationIssue; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail; +import dev.caskeleton.application.techlog.studio.model.AssetDetailView; +import dev.caskeleton.application.techlog.studio.model.AssetPageView; +import dev.caskeleton.application.techlog.studio.model.AssetView; +import dev.caskeleton.application.techlog.studio.model.DashboardView; +import dev.caskeleton.application.techlog.studio.model.PreviewDetailView; +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.PublicationEventView; +import dev.caskeleton.application.techlog.studio.model.PublicationListItemView; +import dev.caskeleton.application.techlog.studio.model.PublicationPageView; +import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView; +import dev.caskeleton.application.techlog.studio.model.PublishResultView; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; +import dev.caskeleton.shared.error.MappingException; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** + * {@code WorkingCopyDetail} 조립. {@code getStudioDocument}, {@code saveStudioDocument}, 그리고 낙관적 잠금 + * 충돌의 {@code VersionConflictDetails.latestDocument}가 모두 이 결과를 쓴다. + * + *

Spring 컴포넌트인 이유는 하나뿐이다 — 미리보기의 {@code renderModel}이 DB에 문자열로 저장되어 있어 계약 DTO로 되살리려면 매퍼가 필요하다. + */ +@Component +public class StudioDetailMapper { + + private final ObjectMapper objectMapper; + + public StudioDetailMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public WorkingCopyDetail toApi(WorkingCopyDetailView view) { + WorkingCopyDetail detail = new WorkingCopyDetail(); + detail.setDocument(StudioResponseMapper.toApi(view.document())); + detail.setCurrentValidation(toApi(view.currentValidation())); + detail.setLatestPreview(toApi(view.latestPreview())); + detail.setCurrentPublication(toApi(view.currentPublication())); + detail.setDependencyRevision(view.dependencyRevision()); + detail.setNextAction(NextAction.fromValue(view.nextAction().name())); + return detail; + } + + public ValidationReport toApi(ValidationReportView view) { + if (view == null) { + return null; + } + ValidationReport report = new ValidationReport(); + report.setValidationId(view.validationId()); + report.setDocumentId(view.documentId()); + report.setValidatedVersion(Math.toIntExact(view.validatedVersion())); + report.setStatus(ValidationReport.StatusEnum.fromValue(view.status().name())); + report.setIssues( + view.issues().stream() + .map( + issue -> { + ValidationIssue dto = new ValidationIssue(); + dto.setCode(issue.code()); + dto.setSeverity(ValidationIssue.SeverityEnum.fromValue(issue.severity().name())); + dto.setPath(issue.path()); + dto.setMessage(issue.message()); + return dto; + }) + .toList()); + report.setValidatedAt(StudioResponseMapper.offsetDateTime(view.validatedAt())); + report.setValidUntil(StudioResponseMapper.offsetDateTime(view.validUntil())); + report.setDependencyRevision(view.dependencyRevision()); + return report; + } + + public PublicPreview toApi(PublicPreviewView view) { + if (view == null) { + return null; + } + PublicPreview preview = new PublicPreview(); + preview.setPreviewId(view.previewId()); + preview.setDocumentId(view.documentId()); + preview.setPreviewVersion(Math.toIntExact(view.previewVersion())); + preview.setValidationId(view.validationId()); + preview.setDependencyRevision(view.dependencyRevision()); + preview.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt())); + preview.setExpiresAt(StudioResponseMapper.offsetDateTime(view.expiresAt())); + preview.setRenderModel(renderModel(view.renderModelJson())); + return preview; + } + + public PublicationAggregate toApi(PublicationAggregateView view) { + if (view == null) { + return null; + } + PublicationAggregate aggregate = new PublicationAggregate(); + aggregate.setPublicationId(view.publicationId()); + aggregate.setDocumentId(view.documentId()); + aggregate.setStatus(PublicationAggregate.StatusEnum.fromValue(view.status().name())); + aggregate.setPublishedVersion(Math.toIntExact(view.publishedVersion())); + aggregate.setPublicationRevision(Math.toIntExact(view.publicationRevision())); + aggregate.setLatestEventId(view.latestEventId()); + aggregate.setPublicPath(view.publicPath()); + aggregate.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt())); + return aggregate; + } + + public PreviewDetail toApi(PreviewDetailView view) { + PreviewDetail detail = new PreviewDetail(); + detail.setPreview(toApi(view.preview())); + detail.setState(PreviewDetail.StateEnum.fromValue(view.state().name())); + detail.setCurrentDocumentVersion(Math.toIntExact(view.currentDocumentVersion())); + detail.setCurrentValidationId(view.currentValidationId()); + return detail; + } + + public PublicationEvent toApi(PublicationEventView view) { + PublicationEvent event = new PublicationEvent(); + event.setPublicationEventId(view.publicationEventId()); + event.setPublicationId(view.publicationId()); + event.setDocumentId(view.documentId()); + event.setType(PublicationEventType.fromValue(view.type().name())); + event.setOccurredAt(StudioResponseMapper.offsetDateTime(view.occurredAt())); + event.setPublishedVersion(Math.toIntExact(view.publishedVersion())); + event.setSourcePublishedEventId(view.sourcePublishedEventId()); + event.setSnapshotAvailable(view.snapshotAvailable()); + return event; + } + + public PublishResult toApi(PublishResultView view) { + PublishResult result = new PublishResult(); + result.setPublication(toApi(view.publication())); + result.setEvent(toApi(view.event())); + return result; + } + + public PublicationListItem toApi(PublicationListItemView view) { + PublicationListItem item = new PublicationListItem(); + item.setEvent(toApi(view.event())); + item.setPublication(toApi(view.publication())); + item.setDocument(view.document() == null ? null : StudioResponseMapper.toApi(view.document())); + item.setAvailableActions( + view.availableActions().stream() + .map(action -> PublicationAction.fromValue(action.name())) + .collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new))); + return item; + } + + public PublicationPage toApi(PublicationPageView view) { + PublicationPage page = new PublicationPage(); + page.setItems(view.items().stream().map(this::toApi).toList()); + page.setNextCursor(view.nextCursor()); + return page; + } + + public PublicationSnapshot toApi(PublicationSnapshotView view) { + PublicationSnapshot snapshot = new PublicationSnapshot(); + snapshot.setEvent(toApi(view.event())); + snapshot.setRenderModel(renderModel(view.renderModelJson())); + snapshot.setContentFormatVersion(view.contentFormatVersion()); + snapshot.setRendererContractVersion(view.rendererContractVersion()); + return snapshot; + } + + public StudioDashboard toApi(DashboardView view) { + StudioDashboard dashboard = new StudioDashboard(); + dashboard.setContinueWriting( + view.continueWriting().stream().map(StudioResponseMapper::toApi).toList()); + dashboard.setReadyToPublish( + view.readyToPublish().stream().map(StudioResponseMapper::toApi).toList()); + dashboard.setRecentPublications(view.recentPublications().stream().map(this::toApi).toList()); + DashboardTotals totals = new DashboardTotals(); + totals.setDocuments(view.totals().documents()); + totals.setNeedsValidation(view.totals().needsValidation()); + totals.setReadyToPublish(view.totals().readyToPublish()); + totals.setPublications(view.totals().publications()); + dashboard.setTotals(totals); + return dashboard; + } + + public Asset toApi(AssetView view) { + Asset asset = new Asset(); + asset.setId(view.id()); + asset.setAssetKey(view.assetKey()); + asset.setKind(AssetKind.fromValue(view.kind().name())); + asset.setMediaType(view.mediaType()); + asset.setOriginalFilename(view.originalFilename()); + asset.setByteSize(Math.toIntExact(view.byteSize())); + asset.setWidth(view.width()); + asset.setHeight(view.height()); + asset.setAltText(view.altText()); + asset.setDecorative(view.decorative()); + asset.setManagementStatus(AssetManagementStatus.fromValue(view.managementStatus().name())); + asset.setPublicPath(view.publicPath()); + asset.setUsageCount(view.usageCount()); + asset.setVersion(Math.toIntExact(view.version())); + asset.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt())); + asset.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt())); + return asset; + } + + public AssetPage toApi(AssetPageView view) { + AssetPage page = new AssetPage(); + page.setItems(view.items().stream().map(this::toApi).toList()); + page.setNextCursor(view.nextCursor()); + return page; + } + + public AssetDetail toApi(AssetDetailView view) { + AssetDetail detail = new AssetDetail(); + detail.setAsset(toApi(view.asset())); + detail.setUsages( + view.usages().stream() + .map( + usage -> { + AssetUsage dto = new AssetUsage(); + dto.setDocumentId(usage.documentId()); + dto.setDocumentKind(RecordKind.fromValue(usage.documentKind().name())); + dto.setTitle(usage.title()); + dto.setPublished(usage.published()); + return dto; + }) + .toList()); + detail.setHasPublicationHistory(view.hasPublicationHistory()); + return detail; + } + + /** 저장된 렌더 모델 JSON을 계약 union 타입으로 되살린다. */ + private PublicRenderModel renderModel(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readValue(json, PublicRenderModel.class); + } catch (JacksonException e) { + throw new MappingException("a stored preview render model no longer matches the contract", e); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioRequestMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioRequestMapper.java new file mode 100644 index 0000000..a6ca6ee --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioRequestMapper.java @@ -0,0 +1,203 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseInput; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionInput; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceInput; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RelationInput; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.DecisionStatusView; +import dev.caskeleton.application.techlog.studio.model.OrderedTextView; +import dev.caskeleton.application.techlog.studio.model.QuestionOptionView; +import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView; +import dev.caskeleton.application.techlog.studio.model.QuestionStatusView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView; +import dev.caskeleton.application.techlog.studio.model.RelationView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView; +import java.util.List; +import java.util.UUID; + +/** + * 계약 DTO({@code WorkingCopyInput} union) → application 입력 모델. + * + *

계약 union 의 네 분기를 {@code switch} 로 남김없이 다룬다 — 계약에 다섯 번째 유형이 생기면 생성 DTO 가 늘어나고 여기서 {@code + * default} 가 없는 채로 컴파일이 깨져 알려준다. + */ +public final class StudioRequestMapper { + + private StudioRequestMapper() {} + + public static WorkingCopyInputView toApplication(WorkingCopyInput input) { + if (input == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document is required"); + } + return switch (input) { + case CaseInput value -> + new WorkingCopyInputView.CaseInputView( + base( + RecordKind.CASE, + value.getTitle(), + value.getSlug(), + value.getSummary(), + value.getTopicId(), + value.getProjectId(), + value.getRelations()), + value.getProblem(), + value.getConclusion(), + value.getEnvironment(), + value.getReproduction(), + value.getLastVerifiedOn(), + value.getBodyMarkdown()); + case ReferenceInput value -> + new WorkingCopyInputView.ReferenceInputView( + base( + RecordKind.REFERENCE, + value.getTitle(), + value.getSlug(), + value.getSummary(), + value.getTopicId(), + value.getProjectId(), + value.getRelations()), + value.getPurpose(), + rules(value.getRules()), + orderedText(value.getApplyWhen()), + orderedText(value.getExceptions()), + orderedText(value.getExamples()), + value.getVerifiedOn()); + case QuestionInput value -> + new WorkingCopyInputView.QuestionInputView( + base( + RecordKind.QUESTION, + value.getTitle(), + value.getSlug(), + value.getSummary(), + value.getTopicId(), + value.getProjectId(), + value.getRelations()), + questionStatus(value.getQuestionStatus()), + orderedText(value.getFacts()), + orderedText(value.getAssumptions()), + orderedText(value.getUnknowns()), + orderedText(value.getConstraints()), + options(value.getOptions()), + value.getNextValidation(), + resolution(value.getResolution())); + case ProjectDecisionInput value -> + new WorkingCopyInputView.ProjectDecisionInputView( + base( + RecordKind.PROJECT_DECISION, + value.getTitle(), + value.getSlug(), + value.getSummary(), + value.getTopicId(), + value.getProjectId(), + value.getRelations()), + decisionStatus(value.getDecisionStatus()), + value.getDecidedOn(), + value.getStatement(), + value.getRationale(), + orderedText(value.getConsequences())); + default -> + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "unsupported document kind: " + input.getClass().getSimpleName()); + }; + } + + private static WorkingCopyBaseInput base( + RecordKind kind, + String title, + String slug, + String summary, + UUID topicId, + UUID projectId, + List relations) { + return new WorkingCopyBaseInput( + kind, title, slug, summary, topicId, projectId, relations(relations)); + } + + private static List relations(List relations) { + return relations == null + ? List.of() + : relations.stream() + .map( + relation -> + new RelationView( + relation.getId(), + relation.getTargetId(), + relation.getReason(), + order(relation.getOrder()))) + .toList(); + } + + private static List orderedText(List items) { + return items == null + ? List.of() + : items.stream() + .map(item -> new OrderedTextView(item.getId(), item.getText(), order(item.getOrder()))) + .toList(); + } + + private static List rules(List items) { + return items == null + ? List.of() + : items.stream() + .map( + item -> + new ReferenceRuleView( + item.getId(), item.getTitle(), item.getBody(), order(item.getOrder()))) + .toList(); + } + + private static List options(List items) { + return items == null + ? List.of() + : items.stream() + .map( + item -> + new QuestionOptionView( + item.getId(), + item.getTitle(), + item.getDescription(), + order(item.getOrder()))) + .toList(); + } + + private static QuestionResolutionView resolution(QuestionResolution resolution) { + return resolution == null + ? null + : new QuestionResolutionView( + resolution.getSummary(), resolution.getEvidenceTargetId(), resolution.getLinkLabel()); + } + + private static QuestionStatusView questionStatus(QuestionInput.QuestionStatusEnum status) { + if (status == null) { + return null; + } + return status == QuestionInput.QuestionStatusEnum.RESOLVED + ? QuestionStatusView.RESOLVED + : QuestionStatusView.OPEN; + } + + private static DecisionStatusView decisionStatus(ProjectDecisionInput.DecisionStatusEnum status) { + if (status == null) { + return null; + } + return status == ProjectDecisionInput.DecisionStatusEnum.ADOPTED + ? DecisionStatusView.ADOPTED + : DecisionStatusView.PROPOSED; + } + + /** 계약상 {@code order}는 required지만 null 을 실어 보내는 클라이언트를 500으로 떨어뜨리지 않는다. */ + private static int order(Integer order) { + return order == null ? 0 : order; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioResponseMapper.java new file mode 100644 index 0000000..4d7d971 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/mapper/StudioResponseMapper.java @@ -0,0 +1,258 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentSummary; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionWorkingCopy; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionWorkingCopy; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceWorkingCopy; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Relation; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy; +import dev.caskeleton.application.techlog.studio.model.DisplayTargetView; +import dev.caskeleton.application.techlog.studio.model.DocumentPageView; +import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView; +import dev.caskeleton.application.techlog.studio.model.OrderedTextView; +import dev.caskeleton.application.techlog.studio.model.QuestionOptionView; +import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView; +import dev.caskeleton.application.techlog.studio.model.QuestionStatusView; +import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView; +import dev.caskeleton.application.techlog.studio.model.RelationView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.function.Function; + +/** application 모델 → 계약 DTO. */ +public final class StudioResponseMapper { + + private StudioResponseMapper() {} + + public static WorkingCopy toApi(WorkingCopyView view) { + return switch (view) { + case WorkingCopyView.CaseWorkingCopyView value -> { + CaseWorkingCopy dto = new CaseWorkingCopy(); + dto.setKind(CaseWorkingCopy.KindEnum.CASE); + applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary); + dto.setTopicId(value.base().topicId()); + dto.setProjectId(value.base().projectId()); + dto.setRelations(relations(value.base().relations())); + dto.setId(value.id()); + dto.setVersion(version(value.version())); + dto.setUpdatedAt(offsetDateTime(value.updatedAt())); + dto.setProblem(value.problem()); + dto.setConclusion(value.conclusion()); + dto.setEnvironment(value.environment()); + dto.setReproduction(value.reproduction()); + dto.setLastVerifiedOn(value.lastVerifiedOn()); + dto.setBodyMarkdown(value.bodyMarkdown()); + yield dto; + } + case WorkingCopyView.ReferenceWorkingCopyView value -> { + ReferenceWorkingCopy dto = new ReferenceWorkingCopy(); + dto.setKind(ReferenceWorkingCopy.KindEnum.REFERENCE); + applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary); + dto.setTopicId(value.base().topicId()); + dto.setProjectId(value.base().projectId()); + dto.setRelations(relations(value.base().relations())); + dto.setId(value.id()); + dto.setVersion(version(value.version())); + dto.setUpdatedAt(offsetDateTime(value.updatedAt())); + dto.setPurpose(value.purpose()); + dto.setRules(rulesToApi(value.rules())); + dto.setApplyWhen(orderedTextToApi(value.applyWhen())); + dto.setExceptions(orderedTextToApi(value.exceptions())); + dto.setExamples(orderedTextToApi(value.examples())); + dto.setVerifiedOn(value.verifiedOn()); + yield dto; + } + case WorkingCopyView.QuestionWorkingCopyView value -> { + QuestionWorkingCopy dto = new QuestionWorkingCopy(); + dto.setKind(QuestionWorkingCopy.KindEnum.QUESTION); + applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary); + dto.setTopicId(value.base().topicId()); + dto.setProjectId(value.base().projectId()); + dto.setRelations(relations(value.base().relations())); + dto.setId(value.id()); + dto.setVersion(version(value.version())); + dto.setUpdatedAt(offsetDateTime(value.updatedAt())); + dto.setQuestionStatus(questionStatus(value.questionStatus())); + dto.setFacts(orderedTextToApi(value.facts())); + dto.setAssumptions(orderedTextToApi(value.assumptions())); + dto.setUnknowns(orderedTextToApi(value.unknowns())); + dto.setConstraints(orderedTextToApi(value.constraints())); + dto.setOptions(optionsToApi(value.options())); + dto.setNextValidation(value.nextValidation()); + dto.setResolution(resolution(value.resolution())); + yield dto; + } + case WorkingCopyView.ProjectDecisionWorkingCopyView value -> { + ProjectDecisionWorkingCopy dto = new ProjectDecisionWorkingCopy(); + dto.setKind(ProjectDecisionWorkingCopy.KindEnum.PROJECT_DECISION); + applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary); + dto.setTopicId(value.base().topicId()); + dto.setProjectId(value.base().projectId()); + dto.setRelations(relations(value.base().relations())); + dto.setId(value.id()); + dto.setVersion(version(value.version())); + dto.setUpdatedAt(offsetDateTime(value.updatedAt())); + dto.setDecisionStatus(decisionStatus(value.decisionStatus())); + dto.setDecidedOn(value.decidedOn()); + dto.setStatement(value.statement()); + dto.setRationale(value.rationale()); + dto.setConsequences(orderedTextToApi(value.consequences())); + yield dto; + } + }; + } + + public static DocumentPage toApi(DocumentPageView view) { + DocumentPage page = new DocumentPage(); + page.setItems(view.items().stream().map(StudioResponseMapper::toApi).toList()); + page.setNextCursor(view.nextCursor()); + return page; + } + + public static DocumentSummary toApi(DocumentSummaryView view) { + DocumentSummary summary = new DocumentSummary(); + summary.setId(view.id()); + summary.setTitle(view.title()); + summary.setKind(RecordKind.fromValue(view.kind().name())); + summary.setProject(displayTarget(view.project())); + summary.setUpdatedAt(offsetDateTime(view.updatedAt())); + summary.setPublicationStatus(PublicationStatus.fromValue(view.publicationStatus().name())); + summary.setPublishedVersion( + view.publishedVersion() == null ? null : Math.toIntExact(view.publishedVersion())); + summary.setHasUnpublishedChanges(view.hasUnpublishedChanges()); + summary.setNextAction(NextAction.fromValue(view.nextAction().name())); + return summary; + } + + public static DisplayTarget displayTarget(DisplayTargetView view) { + if (view == null) { + return null; + } + DisplayTarget target = new DisplayTarget(); + target.setId(view.id()); + target.setLabel(view.label()); + target.setPublicPath(view.publicPath()); + return target; + } + + public static OffsetDateTime offsetDateTime(Instant instant) { + return instant == null ? null : instant.atOffset(ZoneOffset.UTC); + } + + /** + * 계약의 {@code version}은 {@code integer}이고 컬럼은 {@code bigint}다. 넘치는 값을 조용히 잘라내면 클라이언트가 보내는 {@code + * expectedVersion}이 영영 맞지 않게 되므로 예외로 드러낸다. + */ + private static Integer version(long version) { + return Math.toIntExact(version); + } + + private static void applyBase( + WorkingCopyBaseInput base, + java.util.function.Consumer title, + java.util.function.Consumer slug, + java.util.function.Consumer summary) { + title.accept(base.title()); + slug.accept(base.slug()); + summary.accept(base.summary()); + } + + private static List relations(List views) { + return map( + views, + view -> { + Relation relation = new Relation(); + relation.setId(view.id()); + relation.setTargetId(view.targetId()); + relation.setReason(view.reason()); + relation.setOrder(view.order()); + return relation; + }); + } + + public static List orderedTextToApi(List views) { + return map( + views, + view -> { + OrderedText text = new OrderedText(); + text.setId(view.id()); + text.setText(view.text()); + text.setOrder(view.order()); + return text; + }); + } + + public static List rulesToApi(List views) { + return map( + views, + view -> { + ReferenceRule rule = new ReferenceRule(); + rule.setId(view.id()); + rule.setTitle(view.title()); + rule.setBody(view.body()); + rule.setOrder(view.order()); + return rule; + }); + } + + public static List optionsToApi(List views) { + return map( + views, + view -> { + QuestionOption option = new QuestionOption(); + option.setId(view.id()); + option.setTitle(view.title()); + option.setDescription(view.description()); + option.setOrder(view.order()); + return option; + }); + } + + private static QuestionResolution resolution(QuestionResolutionView view) { + if (view == null) { + return null; + } + QuestionResolution resolution = new QuestionResolution(); + resolution.setSummary(view.summary()); + resolution.setEvidenceTargetId(view.evidenceTargetId()); + resolution.setLinkLabel(view.linkLabel()); + return resolution; + } + + private static QuestionWorkingCopy.QuestionStatusEnum questionStatus(QuestionStatusView status) { + if (status == null) { + return null; + } + return status == QuestionStatusView.RESOLVED + ? QuestionWorkingCopy.QuestionStatusEnum.RESOLVED + : QuestionWorkingCopy.QuestionStatusEnum.OPEN; + } + + private static ProjectDecisionWorkingCopy.DecisionStatusEnum decisionStatus( + dev.caskeleton.application.techlog.studio.model.DecisionStatusView status) { + if (status == null) { + return null; + } + return status == dev.caskeleton.application.techlog.studio.model.DecisionStatusView.ADOPTED + ? ProjectDecisionWorkingCopy.DecisionStatusEnum.ADOPTED + : ProjectDecisionWorkingCopy.DecisionStatusEnum.PROPOSED; + } + + private static List map(List source, Function mapper) { + return source == null ? List.of() : source.stream().map(mapper).toList(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java new file mode 100644 index 0000000..e06d45e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java @@ -0,0 +1,236 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock; +import java.util.ArrayList; +import java.util.List; +import org.commonmark.ext.gfm.tables.TableBlock; +import org.commonmark.ext.gfm.tables.TableBody; +import org.commonmark.ext.gfm.tables.TableCell; +import org.commonmark.ext.gfm.tables.TableHead; +import org.commonmark.ext.gfm.tables.TableRow; +import org.commonmark.node.BlockQuote; +import org.commonmark.node.BulletList; +import org.commonmark.node.FencedCodeBlock; +import org.commonmark.node.Heading; +import org.commonmark.node.IndentedCodeBlock; +import org.commonmark.node.Node; +import org.commonmark.node.OrderedList; +import org.commonmark.node.Paragraph; +import org.commonmark.node.ThematicBreak; + +/** + * commonmark 블록 노드를 계약의 {@code CaseRenderBlock} union 으로 옮긴다. + * + *

계약이 표현할 수 없는 것은 조용히 다른 것으로 바꾸지 않고 경고로 남긴다(설계 05장 §12) — 렌더러가 지원하지 않는 문법을 그럴듯하게 잘못 해석하면 작성자는 게시 + * 결과를 신뢰할 수 없다. + */ +final class BlockRenderer { + + /** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */ + private static final int MIN_HEADING_LEVEL = 2; + + private static final int MAX_HEADING_LEVEL = 4; + + private final HeadingIds headingIds; + private final List warnings; + private int tableSequence; + private int listItemSequence; + + BlockRenderer(HeadingIds headingIds, List warnings) { + this.headingIds = headingIds; + this.warnings = warnings; + } + + List render(Node document) { + List blocks = new ArrayList<>(); + for (Node node = document.getFirstChild(); node != null; node = node.getNext()) { + CaseRenderBlock block = renderBlock(node); + if (block != null) { + blocks.add(block); + } + } + return blocks; + } + + private CaseRenderBlock renderBlock(Node node) { + return switch (node) { + case Heading value -> heading(value); + case Paragraph value -> paragraph(InlineRenderer.render(value)); + case BlockQuote value -> blockquote(value); + case BulletList value -> bulletList(value); + case OrderedList value -> orderedList(value); + case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo()); + case IndentedCodeBlock value -> code(value.getLiteral(), null); + case TableBlock value -> table(value); + case ThematicBreak ignored -> { + // 계약의 CaseRenderBlock 에 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던 + // 구조가 생기므로 버리고 경고한다. + warnings.add("THEMATIC_BREAK_NOT_RENDERABLE"); + yield null; + } + default -> { + List content = InlineRenderer.render(node); + yield content.isEmpty() ? null : paragraph(content); + } + }; + } + + private CaseRenderBlock heading(Heading value) { + HeadingBlock block = new HeadingBlock(); + block.setType(HeadingBlock.TypeEnum.HEADING); + block.setId(headingIds.nextFor(InlineRenderer.plainText(value))); + block.setLevel(Math.clamp(value.getLevel(), MIN_HEADING_LEVEL, MAX_HEADING_LEVEL)); + block.setContent(InlineRenderer.render(value)); + return block; + } + + private static CaseRenderBlock paragraph(List content) { + ParagraphBlock block = new ParagraphBlock(); + block.setType(ParagraphBlock.TypeEnum.PARAGRAPH); + block.setContent(content); + return block; + } + + /** 계약의 {@code BlockquoteBlock.content} 는 블록이 아니라 inline 배열이라 안쪽 문단을 이어 붙인다. */ + private CaseRenderBlock blockquote(BlockQuote value) { + BlockquoteBlock block = new BlockquoteBlock(); + block.setType(BlockquoteBlock.TypeEnum.BLOCKQUOTE); + List content = new ArrayList<>(); + for (Node child = value.getFirstChild(); child != null; child = child.getNext()) { + List rendered = InlineRenderer.render(child); + if (rendered.isEmpty()) { + continue; + } + if (!content.isEmpty()) { + // 인용 안의 문단 경계. 이어 붙이기만 하면 앞 문단의 마지막 낱말과 다음 문단의 첫 낱말이 + // 한 낱말로 붙어 읽힌다. + content.add(InlineRenderer.spacer()); + } + content.addAll(rendered); + } + block.setContent(content); + return block; + } + + private CaseRenderBlock bulletList(BulletList value) { + UnorderedListBlock block = new UnorderedListBlock(); + block.setType(UnorderedListBlock.TypeEnum.UNORDERED_LIST); + block.setItems(listItems(value)); + return block; + } + + private CaseRenderBlock orderedList(OrderedList value) { + OrderedListBlock block = new OrderedListBlock(); + block.setType(OrderedListBlock.TypeEnum.ORDERED_LIST); + block.setItems(listItems(value)); + return block; + } + + private List listItems(Node list) { + List items = new ArrayList<>(); + for (Node child = list.getFirstChild(); child != null; child = child.getNext()) { + ListItem item = new ListItem(); + listItemSequence++; + item.setId("li-" + listItemSequence); + List content = new ArrayList<>(); + for (Node paragraph = child.getFirstChild(); + paragraph != null; + paragraph = paragraph.getNext()) { + content.addAll(InlineRenderer.render(paragraph)); + } + item.setContent(content); + items.add(item); + } + return items; + } + + private static CaseRenderBlock code(String literal, String info) { + CodeBlock block = new CodeBlock(); + block.setType(CodeBlock.TypeEnum.CODE_BLOCK); + block.setCode(literal == null ? "" : literal); + block.setLanguage(info == null || info.isBlank() ? null : info.strip()); + block.setLabel(null); + return block; + } + + private CaseRenderBlock table(TableBlock value) { + DataTableBlock block = new DataTableBlock(); + block.setType(DataTableBlock.TypeEnum.DATA_TABLE); + tableSequence++; + block.setId("table-" + tableSequence); + block.setCaption(""); + // 계약은 rowHeaderColumn 을 1-based 로 정의한다. GFM 표에는 행 머리글 개념이 없으므로 비운다. + block.setRowHeaderColumn(null); + + List columns = new ArrayList<>(); + List rows = new ArrayList<>(); + int rowSequence = 0; + + for (Node section = value.getFirstChild(); section != null; section = section.getNext()) { + for (Node row = section.getFirstChild(); row != null; row = row.getNext()) { + if (!(row instanceof TableRow tableRow)) { + continue; + } + if (section instanceof TableHead) { + int index = 0; + for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) { + DataTableColumn column = new DataTableColumn(); + index++; + column.setId("col-" + index); + String label = InlineRenderer.plainText(cell); + // 계약의 label 은 minLength 1 이다. 빈 머리글 칸은 열 번호로 대신한다. + column.setLabel(label.isBlank() ? "col-" + index : label); + column.setAlignment(alignment(cell)); + columns.add(column); + } + } else if (section instanceof TableBody) { + DataTableRow dataRow = new DataTableRow(); + rowSequence++; + dataRow.setId("row-" + rowSequence); + List cells = new ArrayList<>(); + int index = 0; + for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) { + DataTableCell dataCell = new DataTableCell(); + index++; + dataCell.setColumnId("col-" + index); + dataCell.setContent(InlineRenderer.render(cell)); + cells.add(dataCell); + } + dataRow.setCells(cells); + rows.add(dataRow); + } + } + } + if (columns.isEmpty()) { + // 계약의 columns 는 minItems 1 이다. 머리글 없는 표는 계약상 표현할 수 없다. + warnings.add("DATA_TABLE_WITHOUT_HEADER_NOT_RENDERABLE"); + return null; + } + block.setColumns(columns); + block.setRows(rows); + return block; + } + + private static DataTableColumn.AlignmentEnum alignment(Node cell) { + if (!(cell instanceof TableCell tableCell) || tableCell.getAlignment() == null) { + return DataTableColumn.AlignmentEnum.LEFT; + } + return switch (tableCell.getAlignment()) { + case CENTER -> DataTableColumn.AlignmentEnum.CENTER; + case RIGHT -> DataTableColumn.AlignmentEnum.RIGHT; + case LEFT -> DataTableColumn.AlignmentEnum.LEFT; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/HeadingIds.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/HeadingIds.java new file mode 100644 index 0000000..bd087db --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/HeadingIds.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import java.text.Normalizer; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * 설계 05장 §6의 heading ID 알고리즘. TOC와 anchor가 Public과 Studio에서 같아야 하므로 구현은 한 곳에만 둔다. + * + *

{@code
+ * "Authorization Code Flow" -> authorization-code-flow
+ * "JPA N+1 문제"             -> jpa-n-1-문제
+ * "결론"                     -> 결론
+ * "결론" (두 번째)           -> 결론-2
+ * }
+ */ +final class HeadingIds { + + private static final String FALLBACK = "section"; + + private final Map used = new HashMap<>(); + + /** 같은 문서 안에서 중복되면 {@code -2}, {@code -3} 순으로 suffix 를 붙인다. */ + String nextFor(String headingPlainText) { + String base = slugify(headingPlainText); + int seen = used.merge(base, 1, Integer::sum); + return seen == 1 ? base : base + "-" + seen; + } + + private static String slugify(String text) { + String normalized = + Normalizer.normalize(text == null ? "" : text, Normalizer.Form.NFKC) + .trim() + .toLowerCase(Locale.ROOT); + + StringBuilder out = new StringBuilder(normalized.length()); + boolean pendingSeparator = false; + for (int i = 0; i < normalized.length(); i++) { + char ch = normalized.charAt(i); + if (isKept(ch)) { + // 구분자는 실제로 유지 문자가 뒤따를 때만 쓴다 — 그래야 끝에 하이픈이 남지 않는다. + if (pendingSeparator && !out.isEmpty()) { + out.append('-'); + } + pendingSeparator = false; + out.append(ch); + } else { + pendingSeparator = true; + } + } + return out.isEmpty() ? FALLBACK : out.toString(); + } + + /** 한글·영문·숫자·하이픈만 남긴다(설계 05장 §6 6단계). */ + private static boolean isKept(char ch) { + if (ch == '-') { + return true; + } + if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) { + return true; + } + return isHangul(ch); + } + + private static boolean isHangul(char ch) { + return (ch >= 0xAC00 && ch <= 0xD7A3) // 완성형 음절 + || (ch >= 0x1100 && ch <= 0x11FF) // 초·중·종성 자모 + || (ch >= 0x3130 && ch <= 0x318F); // 호환용 자모 + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/InlineRenderer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/InlineRenderer.java new file mode 100644 index 0000000..03e23f2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/InlineRenderer.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineEmphasis; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineLink; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText; +import java.util.ArrayList; +import java.util.List; +import org.commonmark.node.Code; +import org.commonmark.node.Emphasis; +import org.commonmark.node.HardLineBreak; +import org.commonmark.node.Image; +import org.commonmark.node.Link; +import org.commonmark.node.Node; +import org.commonmark.node.SoftLineBreak; +import org.commonmark.node.StrongEmphasis; +import org.commonmark.node.Text; + +/** commonmark inline 노드를 계약의 {@code Inline} union 으로 옮긴다. */ +final class InlineRenderer { + + private InlineRenderer() {} + + static List render(Node parent) { + List out = new ArrayList<>(); + for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) { + Inline rendered = renderNode(child); + if (rendered != null) { + out.add(rendered); + } + } + return out; + } + + /** heading id 계산과 alt 추출에 쓰는 평문. */ + static String plainText(Node parent) { + StringBuilder text = new StringBuilder(); + appendPlainText(parent, text); + return text.toString(); + } + + private static void appendPlainText(Node parent, StringBuilder text) { + for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) { + switch (child) { + case Text value -> text.append(value.getLiteral()); + case Code value -> text.append(value.getLiteral()); + case SoftLineBreak ignored -> text.append(' '); + case HardLineBreak ignored -> text.append(' '); + default -> appendPlainText(child, text); + } + } + } + + private static Inline renderNode(Node node) { + return switch (node) { + case Text value -> text(value.getLiteral()); + case Code value -> { + // 계약의 InlineCode.code 는 minLength 1 이다. 빈 백틱은 보낼 값이 없으므로 버린다. + if (value.getLiteral().isEmpty()) { + yield null; + } + InlineCode code = new InlineCode(); + code.setType(InlineCode.TypeEnum.INLINE_CODE); + code.setCode(value.getLiteral()); + yield code; + } + case Emphasis value -> { + InlineEmphasis emphasis = new InlineEmphasis(); + emphasis.setType(InlineEmphasis.TypeEnum.EMPHASIS); + emphasis.setChildren(render(value)); + yield emphasis; + } + case StrongEmphasis value -> { + InlineStrong strong = new InlineStrong(); + strong.setType(InlineStrong.TypeEnum.STRONG); + strong.setChildren(render(value)); + yield strong; + } + case Link value -> { + InlineLink link = new InlineLink(); + link.setType(InlineLink.TypeEnum.LINK); + String label = plainText(value); + // 계약의 label 은 minLength 1 이다. 라벨 없는 링크는 주소 자체를 라벨로 쓴다 — + // 버리면 사용자가 쓴 링크가 통째로 사라진다. + link.setLabel(label.isBlank() ? value.getDestination() : label); + link.setHref(java.net.URI.create(value.getDestination())); + yield link; + } + // 이미지는 EvidenceFigure 로만 다룬다(설계 05장 §3). 인라인 이미지는 계약의 Inline union 에 + // 대응 타입이 없으므로 alt 를 글자로 남긴다 — 조용히 사라지게 두지 않는다. + case Image value -> text(plainText(value)); + case SoftLineBreak ignored -> text(" "); + case HardLineBreak ignored -> text(" "); + default -> { + String plain = plainText(node); + yield plain.isEmpty() ? null : text(plain); + } + }; + } + + /** 블록 경계를 한 칸 띄우는 조각. 계약의 Inline union 에 줄바꿈 타입이 없어 공백으로 표현한다. */ + static Inline spacer() { + return text(" "); + } + + private static Inline text(String literal) { + if (literal == null || literal.isEmpty()) { + return null; + } + InlineText inlineText = new InlineText(); + inlineText.setType(InlineText.TypeEnum.TEXT); + inlineText.setText(literal); + return inlineText; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/MarkdownSegments.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/MarkdownSegments.java new file mode 100644 index 0000000..4633036 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/MarkdownSegments.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import java.util.ArrayList; +import java.util.List; + +/** + * 본문을 "평범한 Markdown" 조각과 "directive" 조각으로 순서대로 자른다. + * + *

fenced code block 안의 {@code :::} 는 directive 가 아니다 — 코드 예시로 directive 문법 자체를 적는 문서가 그 자리에서 잘리면 + * 안 된다. 그래서 코드 펜스 안에 있는 동안은 directive 를 찾지 않는다. + */ +final class MarkdownSegments { + + private MarkdownSegments() {} + + /** 조각 하나. {@code directive} 가 null 이면 평범한 Markdown 이다. */ + record Segment(String markdown, StudioDirective directive, String directiveBody) {} + + static List split(String source) { + List segments = new ArrayList<>(); + if (source == null || source.isBlank()) { + return segments; + } + String[] lines = source.split("\n", -1); + StringBuilder markdown = new StringBuilder(); + String codeFence = null; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + String trimmed = line.strip(); + + if (codeFence != null) { + markdown.append(line).append('\n'); + if (trimmed.startsWith(codeFence)) { + codeFence = null; + } + continue; + } + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + codeFence = trimmed.startsWith("```") ? "```" : "~~~"; + markdown.append(line).append('\n'); + continue; + } + + StudioDirective directive = StudioDirective.parse(line); + if (directive == null || directive.name().isEmpty()) { + markdown.append(line).append('\n'); + continue; + } + + flush(segments, markdown); + + // 컨테이너형(:::name ... 내용 ... :::)인지 leaf형(:::evidence ...)인지는 닫는 줄이 + // 실제로 있는지로 정한다. 이름으로 정하면 새 directive 를 더할 때마다 목록을 고쳐야 하고, + // "닫는 줄이 없으면 문서 끝까지 본문"으로 정하면 닫기를 빠뜨린 directive 하나가 뒤 내용을 + // 통째로 삼킨다. 닫는 줄은 다음 directive 가 열리기 전까지만 찾는다. + int closing = -1; + for (int j = i + 1; j < lines.length; j++) { + String candidate = lines[j].strip(); + if (":::".equals(candidate)) { + closing = j; + break; + } + if (StudioDirective.parse(lines[j]) != null) { + break; + } + } + if (closing < 0) { + segments.add(new Segment(null, directive, "")); + } else { + StringBuilder body = new StringBuilder(); + for (int j = i + 1; j < closing; j++) { + body.append(lines[j]).append('\n'); + } + segments.add(new Segment(null, directive, body.toString())); + i = closing; + } + } + flush(segments, markdown); + return segments; + } + + private static void flush(List segments, StringBuilder markdown) { + if (!markdown.isEmpty()) { + String text = markdown.toString(); + if (!text.isBlank()) { + segments.add(new Segment(text, null, null)); + } + markdown.setLength(0); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/PublicRenderModelFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/PublicRenderModelFactory.java new file mode 100644 index 0000000..2d57bf1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/PublicRenderModelFactory.java @@ -0,0 +1,166 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionPublicRenderModel; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionPublicRenderModel; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferencePublicRenderModel; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RenderContext; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedQuestionResolution; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedRelation; +import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper; +import dev.caskeleton.application.techlog.studio.model.DecisionStatusView; +import dev.caskeleton.application.techlog.studio.model.QuestionStatusView; +import dev.caskeleton.application.techlog.studio.model.RenderInput; +import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import java.util.List; +import org.springframework.stereotype.Component; + +/** + * 편집본 + 해석된 의존 상태 → 계약 {@code PublicRenderModel}. + * + *

본문 Markdown 을 블록으로 바꾸는 것은 {@code CASE} 뿐이다 — 나머지 세 유형의 공개 모델은 구조화된 필드로만 이루어져 있다(계약 {@code + * *PublicRenderModel}). + */ +@Component +public class PublicRenderModelFactory { + + private final StudioContentRenderer contentRenderer; + + public PublicRenderModelFactory(StudioContentRenderer contentRenderer) { + this.contentRenderer = contentRenderer; + } + + /** + * 렌더 결과와 그 과정에서 생긴 경고. + * + * @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들(설계 05장 §12) + */ + public record Rendered(PublicRenderModel model, List warnings) {} + + public Rendered create(RenderInput input) { + RenderContext context = new RenderContext(); + context.setGeneratedAt(StudioResponseMapper.offsetDateTime(input.generatedAt())); + context.setDependencyRevision(input.dependencyRevision()); + + List relations = + input.relations().stream().map(PublicRenderModelFactory::relation).toList(); + DisplayTarget topic = StudioResponseMapper.displayTarget(input.topic()); + DisplayTarget project = StudioResponseMapper.displayTarget(input.project()); + + return switch (input.document()) { + case WorkingCopyView.CaseWorkingCopyView value -> { + StudioContentRenderer.RenderedContent body = + contentRenderer.render(value.bodyMarkdown(), input.assetsByKey()); + CasePublicRenderModel model = new CasePublicRenderModel(); + model.setKind(CasePublicRenderModel.KindEnum.CASE); + model.setSlug(value.base().slug()); + model.setTitle(value.base().title()); + model.setSummary(value.base().summary()); + model.setPublicPath(input.publicPath()); + model.setTopic(topic); + model.setProject(project); + model.setRelations(relations); + model.setRenderContext(context); + model.setProblem(value.problem()); + model.setConclusion(value.conclusion()); + model.setEnvironment(value.environment()); + model.setReproduction(value.reproduction()); + model.setLastVerifiedOn(value.lastVerifiedOn()); + model.setBodyBlocks(body.blocks()); + yield new Rendered(model, body.warnings()); + } + case WorkingCopyView.ReferenceWorkingCopyView value -> { + ReferencePublicRenderModel model = new ReferencePublicRenderModel(); + model.setKind(ReferencePublicRenderModel.KindEnum.REFERENCE); + model.setSlug(value.base().slug()); + model.setTitle(value.base().title()); + model.setSummary(value.base().summary()); + model.setPublicPath(input.publicPath()); + model.setTopic(topic); + model.setProject(project); + model.setRelations(relations); + model.setRenderContext(context); + model.setPurpose(value.purpose()); + model.setRules(StudioResponseMapper.rulesToApi(value.rules())); + model.setApplyWhen(StudioResponseMapper.orderedTextToApi(value.applyWhen())); + model.setExceptions(StudioResponseMapper.orderedTextToApi(value.exceptions())); + model.setExamples(StudioResponseMapper.orderedTextToApi(value.examples())); + model.setVerifiedOn(value.verifiedOn()); + yield new Rendered(model, List.of()); + } + case WorkingCopyView.QuestionWorkingCopyView value -> { + QuestionPublicRenderModel model = new QuestionPublicRenderModel(); + model.setKind(QuestionPublicRenderModel.KindEnum.QUESTION); + model.setSlug(value.base().slug()); + model.setTitle(value.base().title()); + model.setSummary(value.base().summary()); + model.setPublicPath(input.publicPath()); + model.setTopic(topic); + model.setProject(project); + model.setRelations(relations); + model.setRenderContext(context); + model.setStatus( + value.questionStatus() == QuestionStatusView.RESOLVED + ? QuestionPublicRenderModel.StatusEnum.RESOLVED + : QuestionPublicRenderModel.StatusEnum.OPEN); + model.setFacts(StudioResponseMapper.orderedTextToApi(value.facts())); + model.setAssumptions(StudioResponseMapper.orderedTextToApi(value.assumptions())); + model.setUnknowns(StudioResponseMapper.orderedTextToApi(value.unknowns())); + model.setConstraints(StudioResponseMapper.orderedTextToApi(value.constraints())); + model.setOptions(StudioResponseMapper.optionsToApi(value.options())); + model.setNextValidation(value.nextValidation()); + model.setResolution(resolution(value, input)); + yield new Rendered(model, List.of()); + } + case WorkingCopyView.ProjectDecisionWorkingCopyView value -> { + ProjectDecisionPublicRenderModel model = new ProjectDecisionPublicRenderModel(); + model.setKind(ProjectDecisionPublicRenderModel.KindEnum.PROJECT_DECISION); + model.setSlug(value.base().slug()); + model.setTitle(value.base().title()); + model.setSummary(value.base().summary()); + model.setPublicPath(input.publicPath()); + model.setTopic(topic); + model.setProject(project); + model.setRelations(relations); + model.setRenderContext(context); + model.setStatus( + value.decisionStatus() == DecisionStatusView.ADOPTED + ? ProjectDecisionPublicRenderModel.StatusEnum.ADOPTED + : ProjectDecisionPublicRenderModel.StatusEnum.PROPOSED); + model.setDecidedOn(value.decidedOn()); + model.setStatement(value.statement()); + model.setRationale(value.rationale()); + model.setConsequences(StudioResponseMapper.orderedTextToApi(value.consequences())); + yield new Rendered(model, List.of()); + } + }; + } + + private static ResolvedRelation relation(ResolvedRelationView view) { + ResolvedRelation relation = new ResolvedRelation(); + relation.setId(view.id()); + relation.setTargetId(view.targetId()); + relation.setTargetKind(ResolvedRelation.TargetKindEnum.fromValue(view.targetKind())); + relation.setTitle(view.title()); + relation.setPublicPath(view.publicPath()); + relation.setReason(view.reason()); + relation.setOrder(view.order()); + return relation; + } + + private static ResolvedQuestionResolution resolution( + WorkingCopyView.QuestionWorkingCopyView value, RenderInput input) { + if (value.resolution() == null) { + return null; + } + ResolvedQuestionResolution resolution = new ResolvedQuestionResolution(); + resolution.setSummary(value.resolution().summary()); + resolution.setEvidenceTarget( + StudioResponseMapper.displayTarget(input.resolutionEvidenceTarget())); + resolution.setLinkLabel(value.resolution().linkLabel()); + return resolution; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/RenderModelJsonAdapter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/RenderModelJsonAdapter.java new file mode 100644 index 0000000..ba3980f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/RenderModelJsonAdapter.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import dev.caskeleton.application.techlog.studio.model.RenderInput; +import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort; +import dev.caskeleton.shared.error.MappingException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** + * 렌더 결과를 {@code studio_preview.render_model} 에 그대로 들어갈 JSON 으로 만든다. + * + *

렌더 경고는 여기서 버리지 않고 로그로 남긴다 — 계약의 {@code PublicPreview} 에 경고를 실을 자리가 없지만, 경고가 생겼다는 사실 자체가 "작성자가 + * 쓴 문법 일부가 계약으로 표현되지 못했다"는 신호라 흔적 없이 사라지면 안 된다. 게시를 막아야 하는 종류(미해결 Asset 등)는 검증이 별도로 잡는다. + */ +@Component +public class RenderModelJsonAdapter implements RenderModelPort { + + private static final Logger log = LoggerFactory.getLogger(RenderModelJsonAdapter.class); + + private final PublicRenderModelFactory factory; + private final ObjectMapper objectMapper; + + public RenderModelJsonAdapter(PublicRenderModelFactory factory, ObjectMapper objectMapper) { + this.factory = factory; + this.objectMapper = objectMapper; + } + + @Override + public String renderToJson(RenderInput input) { + PublicRenderModelFactory.Rendered rendered = factory.create(input); + if (!rendered.warnings().isEmpty()) { + log.warn( + "studio render produced {} warning(s) for document {}: {}", + rendered.warnings().size(), + input.document().id(), + rendered.warnings()); + } + try { + return objectMapper.writeValueAsString(rendered.model()); + } catch (JacksonException e) { + throw new MappingException("failed to serialise a Studio render model", e); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRenderer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRenderer.java new file mode 100644 index 0000000..0eae476 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRenderer.java @@ -0,0 +1,181 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedAsset; +import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView; +import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.commonmark.ext.autolink.AutolinkExtension; +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.parser.Parser; +import org.springframework.stereotype.Component; + +/** + * 본문 Markdown → 계약의 {@code CaseRenderBlock} 목록. Preview·공개·Snapshot 세 화면이 이 한 구현을 공유한다(ADR-005) — + * 화면마다 다른 경로를 두면 작성자가 확인한 것과 공개된 것이 달라진다. + * + *

Asset 해석은 렌더러가 직접 조회하지 않고 {@code assetsByKey} 로 주입받는다. Snapshot 만 게시 시점에 고정된 manifest 를 넣고 + * 나머지는 현재 상태를 넣으며, 그것이 ADR-002 가 요구하는 유일하게 허용된 차이다. + */ +@Component +public class StudioContentRenderer implements ContentAnalyzerPort { + + /** 설계 05장 §4가 허용한 callout 종류. 그 밖의 이름은 경고를 만들고 일반 인용으로 처리한다. */ + private static final Set INFO_CALLOUTS = Set.of("note", "tip"); + + private static final Set WARNING_CALLOUTS = Set.of("warning", "danger"); + + private static final String EVIDENCE = "evidence"; + + private final Parser parser = + Parser.builder() + .extensions( + List.of( + TablesExtension.create(), + StrikethroughExtension.create(), + AutolinkExtension.create())) + .build(); + + /** + * 렌더 결과. + * + * @param blocks 계약 모양의 본문 블록 + * @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들의 코드 + */ + public record RenderedContent(List blocks, List warnings) {} + + public RenderedContent render(String bodyMarkdown, Map assetsByKey) { + List blocks = new ArrayList<>(); + List warnings = new ArrayList<>(); + HeadingIds headingIds = new HeadingIds(); + + for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) { + if (segment.directive() == null) { + blocks.addAll( + new BlockRenderer(headingIds, warnings).render(parser.parse(segment.markdown()))); + continue; + } + CaseRenderBlock block = renderDirective(segment, assetsByKey, headingIds, warnings); + if (block != null) { + blocks.add(block); + } + } + return new RenderedContent(blocks, warnings); + } + + @Override + public ContentAnalysis analyze(String bodyMarkdown) { + List usages = new ArrayList<>(); + Set unsupported = new LinkedHashSet<>(); + + for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) { + StudioDirective directive = segment.directive(); + if (directive == null) { + continue; + } + if (EVIDENCE.equals(directive.name())) { + usages.add(new AssetUsage(directive.attribute("key", ""), directive.attribute("alt", ""))); + } else if (!INFO_CALLOUTS.contains(directive.name()) + && !WARNING_CALLOUTS.contains(directive.name())) { + unsupported.add(directive.name()); + } + } + return new ContentAnalysis(usages, List.copyOf(unsupported), plainText(bodyMarkdown)); + } + + /** 검색 색인용 평문. 렌더러가 이미 파싱한 것을 다시 쓴다 — 정규식으로 마크업을 지우는 별도 구현을 두면 두 해석이 갈라져 색인이 본문과 어긋난다. */ + private String plainText(String bodyMarkdown) { + StringBuilder text = new StringBuilder(); + for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) { + String source = segment.directive() == null ? segment.markdown() : segment.directiveBody(); + if (source == null || source.isBlank()) { + continue; + } + String plain = InlineRenderer.plainText(parser.parse(source)); + if (!plain.isBlank()) { + if (!text.isEmpty()) { + text.append(' '); + } + text.append(plain.strip()); + } + } + return text.toString(); + } + + private CaseRenderBlock renderDirective( + MarkdownSegments.Segment segment, + Map assetsByKey, + HeadingIds headingIds, + List warnings) { + + StudioDirective directive = segment.directive(); + if (EVIDENCE.equals(directive.name())) { + return evidence(directive, assetsByKey, warnings); + } + if (INFO_CALLOUTS.contains(directive.name()) || WARNING_CALLOUTS.contains(directive.name())) { + CalloutBlock callout = new CalloutBlock(); + callout.setType(CalloutBlock.TypeEnum.CALLOUT); + callout.setTone( + WARNING_CALLOUTS.contains(directive.name()) + ? CalloutBlock.ToneEnum.WARNING + : CalloutBlock.ToneEnum.INFO); + callout.setLabel(directive.argument()); + callout.setContent( + InlineRenderer.render( + parser.parse(segment.directiveBody()).getFirstChild() == null + ? parser.parse("") + : parser.parse(segment.directiveBody()).getFirstChild())); + return callout; + } + // 설계 05장 §4: 알 수 없는 종류는 경고를 만들고 일반 blockquote 로 안전하게 처리한다. + warnings.add("UNSUPPORTED_DIRECTIVE:" + directive.name()); + List fallback = + new BlockRenderer(headingIds, warnings) + .render(parser.parse("> " + segment.directiveBody().replace("\n", "\n> "))); + return fallback.isEmpty() ? null : fallback.getFirst(); + } + + private static CaseRenderBlock evidence( + StudioDirective directive, + Map assetsByKey, + List warnings) { + + String key = directive.attribute("key", ""); + if (key.isBlank()) { + warnings.add("EVIDENCE_WITHOUT_KEY"); + return null; + } + ResolvedAssetView resolved = assetsByKey.get(key); + if (resolved == null) { + // 미해결 key 를 임의 경로로 채워 넣지 않는다 — 렌더 결과가 존재하지 않는 파일을 가리키게 된다. + // 게시는 검증이 막고, 미리보기에서는 이 경고가 사용자에게 무엇이 빠졌는지 알려준다. + warnings.add("UNRESOLVED_ASSET_KEY:" + key); + return null; + } + + EvidenceFigureBlock block = new EvidenceFigureBlock(); + block.setType(EvidenceFigureBlock.TypeEnum.EVIDENCE_FIGURE); + block.setKey(key); + block.setAlt(directive.attribute("alt", "")); + block.setCaption(directive.attribute("caption", "")); + block.setZoom(directive.booleanAttribute("zoom")); + + ResolvedAsset asset = new ResolvedAsset(); + asset.setAssetId(resolved.assetId()); + asset.setAssetKey(resolved.assetKey()); + asset.setMediaType(resolved.mediaType()); + asset.setPublicPath(resolved.publicPath()); + asset.setWidth(resolved.width()); + asset.setHeight(resolved.height()); + asset.setDecorative(resolved.decorative()); + block.setAsset(asset); + return block; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioDirective.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioDirective.java new file mode 100644 index 0000000..09b11b6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioDirective.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * {@code :::name key="value" ...} 한 줄을 이름과 속성으로 나눈다. + * + *

directive 를 commonmark 확장이 아니라 줄 단위 스캔으로 다루는 이유: v1 문법에서 directive 는 중첩이 없고 줄 맨 앞에서만 열린다(설계 + * 05장 §3.2, §4). 줄 스캔이면 동작이 눈으로 확인되고, 지원하지 않는 directive 를 "조용히 다른 것으로 해석"하는 일이 구조적으로 생기지 않는다. + */ +record StudioDirective(String name, String argument, Map attributes) { + + private static final Pattern OPENING = Pattern.compile("^:::([A-Za-z][A-Za-z0-9_-]*)\\s*(.*)$"); + private static final Pattern ATTRIBUTE = Pattern.compile("([A-Za-z][A-Za-z0-9_-]*)=\"([^\"]*)\""); + + static StudioDirective parse(String line) { + Matcher opening = OPENING.matcher(line.strip()); + if (!opening.matches()) { + return null; + } + String rest = opening.group(2).strip(); + + Map attributes = new LinkedHashMap<>(); + Matcher attribute = ATTRIBUTE.matcher(rest); + int firstAttributeStart = rest.length(); + while (attribute.find()) { + if (attribute.start() < firstAttributeStart) { + firstAttributeStart = attribute.start(); + } + attributes.put(attribute.group(1), attribute.group(2)); + } + String argument = rest.substring(0, firstAttributeStart).strip(); + return new StudioDirective(opening.group(1), argument, attributes); + } + + String attribute(String name, String fallback) { + String value = attributes.get(name); + return value == null ? fallback : value; + } + + boolean booleanAttribute(String name) { + return "true".equalsIgnoreCase(attributes.get(name)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioCursors.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioCursors.java new file mode 100644 index 0000000..3dfb3a6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioCursors.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.support; + +import dev.caskeleton.adapter.inbound.web.cursor.CursorCodec; +import dev.caskeleton.adapter.inbound.web.cursor.CursorException; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Base64; +import java.util.HexFormat; +import java.util.UUID; +import org.springframework.stereotype.Component; + +/** + * 계약이 "opaque 하고 정규화된 필터·정렬에 결합된 커서"라고 정한 것을 실제로 그렇게 만든다. + * + *

필터 지문을 커서 안에 함께 서명한다 — 그래야 필터를 바꾼 뒤 옛 커서를 재사용하는 요청을 거절할 수 있다. 거절하지 않으면 정렬 키의 의미가 달라진 채로 페이지가 + * 이어져 사용자에게는 항목이 조용히 사라지거나 중복돼 보인다. + */ +@Component +public class StudioCursors { + + private static final String SEPARATOR = "~"; + + /** + * 지문 입력의 필드 구분자(ASCII unit separator). 사용자 입력에 나타나지 않는 제어문자라 인접한 필드가 서로 섞여 같은 지문을 만드는 일이 없다 — 예를 + * 들어 구분자가 없으면 (kind="A", q="B")와 (kind="AB", q="")가 같은 값이 된다. + */ + private static final char FIELD_SEPARATOR = (char) 0x1f; + + private final CursorCodec codec; + private final Clock clock; + + public StudioCursors(StudioSettings settings, Clock clock) { + this.codec = + new CursorCodec( + settings.cursorSigningKey().getBytes(StandardCharsets.UTF_8), CursorCodec.DEFAULT_TTL); + this.clock = clock; + } + + /** 이 페이지 요청의 필터·정렬을 대표하는 값. 커서에 함께 실린다. */ + public static String fingerprint(String... normalizedFilterParts) { + StringBuilder joined = new StringBuilder(); + for (String part : normalizedFilterParts) { + joined.append(part == null ? "" : part).append(FIELD_SEPARATOR); + } + try { + byte[] digest = + MessageDigest.getInstance("SHA-256") + .digest(joined.toString().getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest, 0, 8); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 must be available on every supported JVM", e); + } + } + + public String encode(String payload, String fingerprint) { + String body = + fingerprint + + SEPARATOR + + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + return codec.encode(body, clock.instant()); + } + + /** + * 커서를 풀어 페이지 위치로 돌려준다. + * + * @throws StudioException 서명·만료·필터 지문 중 하나라도 맞지 않으면 {@code REQUEST_VALIDATION_FAILED} + */ + public DocumentCursorPosition decode(String cursor, String fingerprint, boolean titleSort) { + String body; + try { + body = codec.decode(cursor, clock.instant()); + } catch (CursorException e) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "cursor is not usable: " + e.getMessage()); + } + int separator = body.indexOf(SEPARATOR); + if (separator <= 0) { + throw malformed(); + } + if (!fingerprint.equals(body.substring(0, separator))) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "cursor was issued for a different filter or sort; start from the first page"); + } + String payload = + new String( + Base64.getUrlDecoder().decode(body.substring(separator + SEPARATOR.length())), + StandardCharsets.UTF_8); + int pipe = payload.lastIndexOf('|'); + if (pipe <= 0) { + throw malformed(); + } + String head = payload.substring(0, pipe); + UUID id; + try { + id = UUID.fromString(payload.substring(pipe + 1)); + } catch (IllegalArgumentException e) { + throw malformed(); + } + if (titleSort) { + return new DocumentCursorPosition(null, head, id); + } + try { + return new DocumentCursorPosition(Instant.parse(head), null, id); + } catch (DateTimeParseException e) { + throw malformed(); + } + } + + private static StudioException malformed() { + return StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "cursor is malformed"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioIdempotency.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioIdempotency.java new file mode 100644 index 0000000..0a24690 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioIdempotency.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.support; + +import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport; +import dev.caskeleton.application.idempotency.IdempotencyContext; +import dev.caskeleton.application.idempotency.IdempotencyExecutor; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +/** + * 계약이 모든 mutation 에 요구하는 {@code Idempotency-Key} 처리(spec §8.2). + * + *

재생 여부를 스스로 판단하지 않고 동작이 실제로 실행됐는지로 안다 — 저장소를 미리 들여다보고 판정하면 그 사이에 다른 요청이 끼어들 수 있어 헤더가 + * 거짓말을 하게 된다. 실행되지 않았다면 결과는 재생된 것이다. + * + *

{@code IdempotencyExecutor} 는 provider 가 {@code disabled} 인 배포에는 빈이 없다. 그때는 키의 존재만 계약대로 강제하고 + * 실행은 그대로 통과시킨다 — 여기서 빈을 필수로 요구하면 그런 배포는 Studio 컨트롤러 때문에 부팅 자체가 실패한다. + */ +@Component +public class StudioIdempotency { + + /** + * 계약 {@code components.headers.IdempotencyReplayed}. {@code ApiHeaders}에 두지 않는 이유는 그 파일이 템플릿 + * SSOT({@code wiki/projects/ca-tmpl/registries/headers.yaml}) 소유라 이 기능이 손대면 다음 동기화에서 충돌하기 때문이다. + */ + public static final String IDEMPOTENCY_REPLAYED = "Idempotency-Replayed"; + + /** 계약 {@code components.parameters.IdempotencyKey.schema.maxLength}. */ + private static final int MAX_KEY_LENGTH = 200; + + private final ObjectProvider executors; + private final IdempotencyKeySupport keys; + + public StudioIdempotency( + ObjectProvider executors, IdempotencyKeySupport keys) { + this.executors = executors; + this.keys = keys; + } + + /** + * 결과와 그 결과가 재생된 것인지 여부. + * + * @param 동작의 결과 타입 + */ + public record Outcome(R result, boolean replayed) {} + + public Outcome run( + HttpServletRequest request, + String operationId, + Object requestPayload, + Class responseType, + Supplier action) { + + String key = requireKey(request); + IdempotencyExecutor executor = executors.getIfAvailable(); + if (executor == null) { + return new Outcome<>(action.get(), false); + } + + AtomicBoolean executed = new AtomicBoolean(false); + R result = + executor.execute( + IdempotencyContext.of(keys.scope(key, operationId), keys.fingerprint(requestPayload)), + () -> { + executed.set(true); + return action.get(); + }, + keys.codec(responseType)); + return new Outcome<>(result, !executed.get()); + } + + private String requireKey(HttpServletRequest request) { + Optional key = keys.idempotencyKey(request); + if (key.isEmpty()) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, "the Idempotency-Key header is required"); + } + if (key.get().length() > MAX_KEY_LENGTH) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "the Idempotency-Key header must be at most " + MAX_KEY_LENGTH + " characters"); + } + return key.get(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioPrincipals.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioPrincipals.java new file mode 100644 index 0000000..a136ca6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioPrincipals.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.support; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; + +/** + * 감사 컬럼({@code created_by}/{@code updated_by})에 남길 주체를 뽑는다. + * + *

{@code idpUserId} 를 쓴다 — 이메일이나 표시 이름은 사용자가 바꿀 수 있어 과거 기록의 주체를 되짚을 수 없게 된다. + */ +public final class StudioPrincipals { + + private StudioPrincipals() {} + + public static String require(AuthenticatedPrincipal principal) { + if (principal == null || principal.idpUserId() == null || principal.idpUserId().isBlank()) { + throw StudioException.of( + StudioError.AUTHENTICATION_REQUIRED, "the request has no usable authenticated principal"); + } + return principal.idpUserId(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioSettings.java new file mode 100644 index 0000000..e913875 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/support/StudioSettings.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.support; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * {@code ca-skeleton.techlog.studio.*}. Studio 고유 설정을 템플릿 소유 파일({@code PresentationSettings} 등)에 섞지 + * 않고 여기 모은다 — 그 파일들은 template sync 대상이라 이 기능이 손대면 다음 동기화에서 충돌한다. + * + * @param cursorSigningKey 목록 커서 서명 키. 비어 있으면 개발용 값으로 대체하고 경고한다 — 커서에는 권한이 실리지 않으므로 부팅을 막을 사유는 아니지만, + * 인스턴스마다 값이 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다. + * @param validationTtl 검증 결과가 유효한 기간({@code studio_validation.valid_until}) + * @param previewTtl 미리보기가 유효한 기간({@code studio_preview.expires_at}) + */ +@ConfigurationProperties(prefix = "ca-skeleton.techlog.studio") +public record StudioSettings(String cursorSigningKey, Duration validationTtl, Duration previewTtl) { + + private static final Logger log = LoggerFactory.getLogger(StudioSettings.class); + private static final String DEV_CURSOR_KEY = "__LOCAL_DEV_techlog_studio_cursor_signing_key"; + private static final int MIN_KEY_BYTES = 16; + private static final Duration DEFAULT_VALIDATION_TTL = Duration.ofHours(1); + private static final Duration DEFAULT_PREVIEW_TTL = Duration.ofHours(24); + + public StudioSettings { + if (cursorSigningKey == null + || cursorSigningKey.getBytes(StandardCharsets.UTF_8).length < MIN_KEY_BYTES) { + log.warn( + "APP_STUDIO_CURSOR_SIGNING_KEY is missing or shorter than {} bytes; using a development" + + " key. Cursors issued by one instance verify on another only while every instance" + + " falls back to the same value.", + MIN_KEY_BYTES); + cursorSigningKey = DEV_CURSOR_KEY; + } + if (validationTtl == null || validationTtl.isZero() || validationTtl.isNegative()) { + validationTtl = DEFAULT_VALIDATION_TTL; + } + if (previewTtl == null || previewTtl.isZero() || previewTtl.isNegative()) { + previewTtl = DEFAULT_PREVIEW_TTL; + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/contract/StudioContractUnionJacksonTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/contract/StudioContractUnionJacksonTest.java new file mode 100644 index 0000000..3421e0c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/contract/StudioContractUnionJacksonTest.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +/** + * 계약의 discriminator union이 Jackson 양방향으로 계약대로 동작하는지 고정한다. + * + *

이 게이트가 필요한 이유는 컴파일이 이걸 못 잡기 때문이다. Plan 01에서 {@code useOneOfInterfaces=false}로 생성한 + * union은 컴파일 오류 0개였지만 런타임에는 양방향 모두 계약을 위반했다 — 역직렬화는 {@code InvalidTypeIdException}("CaseInput not + * subtype of WorkingCopyInput"), 직렬화는 판별 필드에 {@code kind} 값 대신 클래스 simple name. 지금은 {@code + * prepareStudioCodegenSpec}이 계약에서 {@code x-implements}와 union interface를 파생시켜 고쳤고, 이 테스트가 그 파생 배선이 + * 살아 있는지를 지킨다. 파생이 깨지면 여기서 빨간불이 난다. + * + *

{@code new ObjectMapper()}는 이 모듈의 다른 테스트와 같은 관용구다 — Jackson 3({@code tools.jackson})이며 앱의 HTTP + * 변환기와 같은 계열이다. Jackson 2 ({@code com.fasterxml.jackson.databind})로 검증하면 프로덕션에서 실제로 쓰이지 않는 경로를 재는 + * 셈이라 의미가 없다. + */ +class StudioContractUnionJacksonTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private static CaseWorkingCopy caseWorkingCopy() { + CaseWorkingCopy document = new CaseWorkingCopy(); + document.setKind(CaseWorkingCopy.KindEnum.CASE); + document.setId(UUID.fromString("00000000-0000-4000-8000-000000000001")); + document.setVersion(1); + document.setTitle("제목"); + document.setSlug("some-slug"); + document.setSummary("요약"); + document.setProblem("문제"); + document.setConclusion("결론"); + document.setEnvironment("환경"); + document.setReproduction("재현"); + document.setBodyMarkdown("본문"); + return document; + } + + @Test + void workingCopyUnionSerializesTheContractDiscriminatorAndRoundTrips() { + WorkingCopyDetail detail = new WorkingCopyDetail(); + detail.setDocument(caseWorkingCopy()); + detail.setDependencyRevision("rev-1"); + detail.setNextAction(NextAction.VALIDATE); + + String json = mapper.writeValueAsString(detail); + + // 판별 필드는 계약이 정한 값이어야 한다. 클래스 이름("CaseWorkingCopy")이 나가면 프론트가 깨진다. + assertThat(json).contains("\"kind\":\"CASE\""); + assertThat(json).doesNotContain("CaseWorkingCopy"); + // 판별 필드가 두 번 나가면 안 된다 — union interface가 As.EXISTING_PROPERTY인 이유다. + assertThat(json.split("\"kind\":", -1)).hasSize(2); + // slug는 문자열이다. 계약의 문자열 oneOf를 접지 않으면 여기서 {} 가 나간다. + assertThat(json).contains("\"slug\":\"some-slug\""); + + WorkingCopyDetail back = mapper.readValue(json, WorkingCopyDetail.class); + + assertThat(back.getDocument()).isInstanceOf(CaseWorkingCopy.class); + assertThat(((CaseWorkingCopy) back.getDocument()).getProblem()).isEqualTo("문제"); + } + + @Test + void workingCopyInputUnionRoundTripsThroughTheDeclaredUnionType() { + QuestionInput input = new QuestionInput(); + input.setKind(QuestionInput.KindEnum.QUESTION); + input.setTitle("질문"); + input.setSlug(""); + input.setSummary("요약"); + input.setNextValidation("다음 검증"); + + WorkingCopyInput declared = input; + String json = mapper.writeValueAsString(declared); + assertThat(json).contains("\"kind\":\"QUESTION\""); + + WorkingCopyInput back = mapper.readValue(json, WorkingCopyInput.class); + assertThat(back).isInstanceOf(QuestionInput.class); + assertThat(((QuestionInput) back).getNextValidation()).isEqualTo("다음 검증"); + } + + @Test + void renderBlockAndInlineUnionsRoundTripInsideCollections() { + InlineText text = new InlineText(); + text.setType(InlineText.TypeEnum.TEXT); + text.setText("본문 조각"); + + HeadingBlock heading = new HeadingBlock(); + heading.setType(HeadingBlock.TypeEnum.HEADING); + heading.setId("h-1"); + heading.setLevel(2); + heading.setContent(List.of(text)); + + CasePublicRenderModel model = new CasePublicRenderModel(); + model.setKind(CasePublicRenderModel.KindEnum.CASE); + model.setSlug("some-slug"); + model.setTitle("제목"); + model.setSummary("요약"); + model.setPublicPath("/case/some-slug"); + model.setBodyBlocks(List.of(heading)); + + PublicRenderModel declared = model; + String json = mapper.writeValueAsString(declared); + assertThat(json).contains("\"kind\":\"CASE\""); + assertThat(json).contains("\"type\":\"HEADING\""); + assertThat(json).contains("\"type\":\"TEXT\""); + + PublicRenderModel back = mapper.readValue(json, PublicRenderModel.class); + assertThat(back).isInstanceOf(CasePublicRenderModel.class); + + List blocks = ((CasePublicRenderModel) back).getBodyBlocks(); + assertThat(blocks).hasSize(1).first().isInstanceOf(HeadingBlock.class); + + List content = ((HeadingBlock) blocks.get(0)).getContent(); + assertThat(content).hasSize(1).first().isInstanceOf(InlineText.class); + assertThat(((InlineText) content.get(0)).getText()).isEqualTo("본문 조각"); + } + + @Test + void unionDeserializationRejectsAnUnknownDiscriminatorInsteadOfSilentlyDroppingIt() { + String json = "{\"kind\":\"NOT_A_KIND\",\"title\":\"제목\"}"; + + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> mapper.readValue(json, WorkingCopy.class))) + .isNotNull(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java new file mode 100644 index 0000000..f9a58ee --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java @@ -0,0 +1,223 @@ +package dev.caskeleton.adapter.inbound.web.techlog.studio.render; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock; +import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView; +import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +/** + * 렌더러가 설계 05장이 정한 문법을 계약 블록으로 옮기는지 고정한다. + * + *

여기서 지키는 것은 "그럴듯하게 렌더링된다"가 아니라 계약 제약을 어기지 않는다이다 — heading level 범위, 표의 최소 열 수, 미해결 Asset + * 을 지어내지 않는 것. 이것들이 깨지면 미리보기는 화면에 나오지만 게시된 문서가 계약을 위반한다. + */ +class StudioContentRendererTest { + + private final StudioContentRenderer renderer = new StudioContentRenderer(); + + private static ResolvedAssetView asset(String key, boolean decorative) { + return new ResolvedAssetView( + UUID.fromString("00000000-0000-4000-8000-000000000009"), + key, + "image/png", + "/media/00000000-0000-4000-8000-000000000009", + 800, + 600, + decorative); + } + + @Test + void headingsGetContractLevelsAndStableIds() { + var rendered = + renderer.render( + """ + # Authorization Code Flow + ###### 아주 깊은 제목 + ## 결론 + ## 결론 + """, + Map.of()); + + List blocks = rendered.blocks(); + assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance); + + // 계약의 HeadingBlock.level 은 2..4 다. h1 과 h6 를 그대로 내보내면 계약 위반이다. + assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2); + assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4); + + assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow"); + assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론"); + // 같은 제목이 두 번이면 두 번째부터 suffix 가 붙는다(설계 05장 §6 8단계). + assertThat(((HeadingBlock) blocks.get(3)).getId()).isEqualTo("결론-2"); + } + + @Test + void headingIdFollowsTheDesignedNormalisation() { + var rendered = renderer.render("## JPA N+1 문제\n", Map.of()); + assertThat(((HeadingBlock) rendered.blocks().getFirst()).getId()).isEqualTo("jpa-n-1-문제"); + } + + @Test + void inlineMarkupBecomesTheContractInlineUnion() { + var rendered = renderer.render("본문 **강조** 와 `code` 조각\n", Map.of()); + + ParagraphBlock paragraph = (ParagraphBlock) rendered.blocks().getFirst(); + assertThat(paragraph.getContent()).hasSize(5); + assertThat(paragraph.getContent().get(0)).isInstanceOf(InlineText.class); + assertThat(paragraph.getContent().get(1)).isInstanceOf(InlineStrong.class); + assertThat(paragraph.getContent().get(3)).isInstanceOf(InlineCode.class); + assertThat(((InlineCode) paragraph.getContent().get(3)).getCode()).isEqualTo("code"); + } + + @Test + void listsCodeAndTablesBecomeTheirContractBlocks() { + var rendered = + renderer.render( + """ + - 첫째 + - 둘째 + + ```java + int x = 1; + ``` + + | 이름 | 값 | + | --- | ---: | + | a | 1 | + """, + Map.of()); + + List blocks = rendered.blocks(); + assertThat(blocks.get(0)).isInstanceOf(UnorderedListBlock.class); + assertThat(((UnorderedListBlock) blocks.get(0)).getItems()).hasSize(2); + + CodeBlock code = (CodeBlock) blocks.get(1); + assertThat(code.getLanguage()).isEqualTo("java"); + assertThat(code.getCode()).contains("int x = 1;"); + + DataTableBlock table = (DataTableBlock) blocks.get(2); + assertThat(table.getColumns()).hasSize(2); + assertThat(table.getColumns().get(1).getAlignment()) + .isEqualTo( + dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn + .AlignmentEnum.RIGHT); + assertThat(table.getRows()).hasSize(1); + assertThat(table.getRows().getFirst().getCells().getFirst().getColumnId()).isEqualTo("col-1"); + } + + @Test + void calloutDirectivesBecomeCalloutBlocksWithTheContractTone() { + var rendered = + renderer.render( + """ + :::warning 주의 + Presigned URL 을 본문에 저장하지 않습니다. + ::: + + :::note + 확인했습니다. + ::: + """, + Map.of()); + + CalloutBlock warning = (CalloutBlock) rendered.blocks().get(0); + assertThat(warning.getTone()).isEqualTo(CalloutBlock.ToneEnum.WARNING); + assertThat(warning.getLabel()).isEqualTo("주의"); + + CalloutBlock note = (CalloutBlock) rendered.blocks().get(1); + // 설계는 note/tip/warning/danger 네 가지를 허용하지만 계약의 tone 은 두 가지다. + assertThat(note.getTone()).isEqualTo(CalloutBlock.ToneEnum.INFO); + } + + @Test + void anUnknownDirectiveDegradesToAQuoteAndWarnsInsteadOfBeingSilentlyReinterpreted() { + var rendered = + renderer.render( + """ + :::mermaid + graph TD; + ::: + """, + Map.of()); + + assertThat(rendered.blocks().getFirst()).isInstanceOf(BlockquoteBlock.class); + assertThat(rendered.warnings()).contains("UNSUPPORTED_DIRECTIVE:mermaid"); + } + + @Test + void evidenceDirectiveResolvesThroughTheInjectedAssetManifest() { + var rendered = + renderer.render( + ":::evidence key=\"flow\" alt=\"요청 흐름\" caption=\"흐름도\" zoom=\"true\"\n", + Map.of("flow", asset("flow", false))); + + EvidenceFigureBlock figure = (EvidenceFigureBlock) rendered.blocks().getFirst(); + assertThat(figure.getKey()).isEqualTo("flow"); + assertThat(figure.getAlt()).isEqualTo("요청 흐름"); + assertThat(figure.getCaption()).isEqualTo("흐름도"); + assertThat(figure.getZoom()).isTrue(); + assertThat(figure.getAsset().getPublicPath()) + .isEqualTo("/media/00000000-0000-4000-8000-000000000009"); + } + + @Test + void anUnresolvedAssetKeyIsDroppedWithAWarningRatherThanPointedAtNothing() { + var rendered = renderer.render(":::evidence key=\"missing\" alt=\"x\"\n", Map.of()); + + // 임의 경로를 지어내면 렌더 결과가 존재하지 않는 파일을 가리킨다. + assertThat(rendered.blocks()).isEmpty(); + assertThat(rendered.warnings()).contains("UNRESOLVED_ASSET_KEY:missing"); + } + + @Test + void directiveSyntaxInsideACodeFenceIsNotADirective() { + var rendered = + renderer.render( + """ + ```markdown + :::evidence key="example" + ``` + """, + Map.of()); + + assertThat(rendered.blocks().getFirst()).isInstanceOf(CodeBlock.class); + assertThat(rendered.warnings()).isEmpty(); + } + + @Test + void analysisReportsEveryUsageSiteSeparatelyBecauseAltIsPerUsage() { + ContentAnalyzerPort.ContentAnalysis analysis = + renderer.analyze( + """ + :::evidence key="flow" alt="첫 번째" + + :::evidence key="flow" alt="" + + :::mermaid + ::: + """); + + assertThat(analysis.assetUsages()) + .extracting(ContentAnalyzerPort.AssetUsage::assetKey) + .containsExactly("flow", "flow"); + assertThat(analysis.assetUsages().get(0).alt()).isEqualTo("첫 번째"); + assertThat(analysis.assetUsages().get(1).alt()).isEmpty(); + assertThat(analysis.unsupportedDirectives()).containsExactly("mermaid"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 151578b..a718442 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -26,6 +26,12 @@ dependencies { implementation project(':shared-contract') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Studio 편집본의 jsonb 컬럼(reference_detail.rules/examples, open_question.options, + // project_decision.consequences, applies_to/excluded_scope)을 읽고 쓰려면 이 모듈에 JSON 매퍼가 + // 필요하다. Jackson 2 databind 가 data-jpa 경유로 이미 classpath 에 딸려오지만 그건 선언하지 않은 + // 우연한 가용성이고, 이 저장소는 dependency locking 을 쓴다 — 앱의 다른 계층과 같은 + // Jackson 3(tools.jackson)을 명시적으로 선언한다. + implementation 'org.springframework.boot:spring-boot-starter-jackson' // feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the // multi-instance distributedLockProvider. Version managed by Spring Boot BOM. implementation 'org.springframework.integration:spring-integration-jdbc' @@ -117,6 +123,14 @@ def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTe 'postgresqlTechLogCatalogQueryIntegrationTest', 'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest') +// 슬라이스 2~5: Studio 영속 경로 전체(편집본 4종 왕복, 낙관적 잠금, union 목록, 의존 해석, +// validation/preview artifact, 게시 20단계, 게시 취소, Asset)를 실제 PostgreSQL 위에서 돌린다. +// 표준 check 는 Testcontainers 를 돌리지 않으므로, 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 +// 않은 채로 빌드가 통과한다. +def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlTechLogStudioPersistenceIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest') + def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') { group = 'verification' description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.' diff --git a/src/adapter/outbound/persistence-jpa/gradle.lockfile b/src/adapter/outbound/persistence-jpa/gradle.lockfile index 0e04cce..3155196 100644 --- a/src/adapter/outbound/persistence-jpa/gradle.lockfile +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -152,7 +152,7 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlInt org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -163,7 +163,7 @@ org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegr org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -205,7 +205,7 @@ org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspat org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcPreviewArtifactAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcPreviewArtifactAdapter.java new file mode 100644 index 0000000..53f4219 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcPreviewArtifactAdapter.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.artifact; + +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * {@code studio_preview} 접근. + * + *

{@code render_model}은 렌더러가 만든 계약 모양 그대로를 문자열로 저장하고 그대로 돌려준다 — 중간에서 파싱했다 다시 직렬화하면 사용자가 확인한 화면과 + * 저장된 화면이 미묘하게 달라질 수 있고, 게시 시점에 그대로 snapshot 으로 옮겨야 하는 값이라 그 차이가 공개 결과까지 간다. + */ +@Repository +public class JdbcPreviewArtifactAdapter implements PreviewArtifactPort { + + private final JdbcClient jdbcClient; + + public JdbcPreviewArtifactAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public Optional latestFor(RecordKind kind, UUID documentId) { + return jdbcClient + .sql( + selectColumns() + + " WHERE source_kind = :kind AND source_id = :id" + + " ORDER BY created_at DESC LIMIT 1") + .param("kind", kind.name()) + .param("id", documentId) + .query(JdbcPreviewArtifactAdapter::mapRow) + .optional(); + } + + @Override + public Optional findById(UUID previewId) { + return jdbcClient + .sql(selectColumns() + " WHERE preview_id = :id") + .param("id", previewId) + .query(JdbcPreviewArtifactAdapter::mapRow) + .optional(); + } + + @Override + public PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal) { + jdbcClient + .sql( + "INSERT INTO studio_preview (preview_id, source_kind, source_id, source_version," + + " validation_id, dependency_revision, render_model, created_at, expires_at," + + " created_by)" + + " VALUES (:previewId, :kind, :sourceId, :sourceVersion, :validationId," + + " :dependencyRevision, CAST(:renderModel AS jsonb), :createdAt, :expiresAt," + + " :principal)") + .param("previewId", preview.previewId()) + .param("kind", kind.name()) + .param("sourceId", preview.documentId()) + .param("sourceVersion", preview.previewVersion()) + .param("validationId", preview.validationId()) + .param("dependencyRevision", preview.dependencyRevision()) + .param("renderModel", preview.renderModelJson()) + .param("createdAt", Timestamp.from(preview.createdAt())) + .param("expiresAt", Timestamp.from(preview.expiresAt())) + .param("principal", principal) + .update(); + return preview; + } + + private static String selectColumns() { + return "SELECT preview_id, source_id, source_version, validation_id, dependency_revision," + + " render_model, created_at, expires_at FROM studio_preview"; + } + + private static PublicPreviewView mapRow(ResultSet rs, int rowNum) throws SQLException { + return new PublicPreviewView( + rs.getObject("preview_id", UUID.class), + rs.getObject("source_id", UUID.class), + rs.getLong("source_version"), + rs.getObject("validation_id", UUID.class), + rs.getString("dependency_revision"), + rs.getTimestamp("created_at").toInstant(), + rs.getTimestamp("expires_at").toInstant(), + rs.getString("render_model")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcValidationArtifactAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcValidationArtifactAdapter.java new file mode 100644 index 0000000..137f9eb --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/artifact/JdbcValidationArtifactAdapter.java @@ -0,0 +1,143 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.artifact; + +import dev.caskeleton.application.techlog.studio.model.RecordKind; +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.port.out.ValidationArtifactPort; +import dev.caskeleton.shared.error.MappingException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * {@code studio_validation} 접근. 검증 결과는 일급 artifact이며 실행 후 버리지 않는다(spec §7.3). + * + *

{@code studio_validation}에는 UPDATE가 없다. 재검증은 새 행이며, 이전 결과는 "그때 이 버전은 이런 상태였다"는 사실로 남는다 — 덮어쓰면 + * 게시 시점에 어떤 근거로 통과했는지 되짚을 수 없다. + */ +@Repository +public class JdbcValidationArtifactAdapter implements ValidationArtifactPort { + + private final JdbcClient jdbcClient; + private final ObjectMapper objectMapper; + + public JdbcValidationArtifactAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.objectMapper = objectMapper; + } + + @Override + public Optional latestFor(RecordKind kind, UUID documentId) { + return jdbcClient + .sql( + selectColumns() + + " WHERE source_kind = :kind AND source_id = :id" + + " ORDER BY validated_at DESC LIMIT 1") + .param("kind", kind.name()) + .param("id", documentId) + .query(this::mapRow) + .optional(); + } + + @Override + public Optional findById(UUID validationId) { + return jdbcClient + .sql(selectColumns() + " WHERE validation_id = :id") + .param("id", validationId) + .query(this::mapRow) + .optional(); + } + + @Override + public ValidationReportView save(RecordKind kind, ValidationReportView report, String principal) { + jdbcClient + .sql( + "INSERT INTO studio_validation (validation_id, source_kind, source_id," + + " validated_version, status, issues, dependency_revision, validated_at," + + " valid_until, created_by)" + + " VALUES (:validationId, :kind, :sourceId, :validatedVersion, :status," + + " CAST(:issues AS jsonb), :dependencyRevision, :validatedAt, :validUntil," + + " :principal)") + .param("validationId", report.validationId()) + .param("kind", kind.name()) + .param("sourceId", report.documentId()) + .param("validatedVersion", report.validatedVersion()) + .param("status", report.status().name()) + .param("issues", issuesToJson(report.issues())) + .param("dependencyRevision", report.dependencyRevision()) + .param("validatedAt", Timestamp.from(report.validatedAt())) + .param("validUntil", Timestamp.from(report.validUntil())) + .param("principal", principal) + .update(); + return report; + } + + private static String selectColumns() { + return "SELECT validation_id, source_id, validated_version, status, issues," + + " dependency_revision, validated_at, valid_until FROM studio_validation"; + } + + private ValidationReportView mapRow(ResultSet rs, int rowNum) throws SQLException { + return new ValidationReportView( + rs.getObject("validation_id", UUID.class), + rs.getObject("source_id", UUID.class), + rs.getLong("validated_version"), + ValidationStatus.valueOf(rs.getString("status")), + issuesFromJson(rs.getString("issues")), + rs.getTimestamp("validated_at").toInstant(), + rs.getTimestamp("valid_until").toInstant(), + rs.getString("dependency_revision")); + } + + private String issuesToJson(List issues) { + ArrayNode array = objectMapper.createArrayNode(); + for (ValidationIssueView issue : issues) { + ObjectNode node = array.addObject(); + node.put("code", issue.code()); + node.put("severity", issue.severity().name()); + node.put("path", issue.path()); + node.put("message", issue.message()); + } + try { + return objectMapper.writeValueAsString(array); + } catch (JacksonException e) { + throw new MappingException("failed to serialise validation issues", e); + } + } + + private List issuesFromJson(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + JsonNode array = objectMapper.readTree(json); + if (!array.isArray()) { + return List.of(); + } + return array + .valueStream() + .map( + node -> + new ValidationIssueView( + node.path("code").asString(""), + ValidationSeverity.valueOf(node.path("severity").asString("ERROR")), + node.path("path").asString(""), + node.path("message").asString(""))) + .toList(); + } catch (JacksonException e) { + throw new MappingException("failed to read validation issues", e); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcDependencyRevisionAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcDependencyRevisionAdapter.java new file mode 100644 index 0000000..ecb3d14 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcDependencyRevisionAdapter.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 의존 상태 해시를 {@link StudioDocumentSql}의 정의로 계산한다. + * + *

목록이 쓰는 SQL과 같은 식을 쓴다. 여기서 다른 식을 쓰면 상세 화면이 계산한 값과 목록이 계산한 값이 달라져, 검증을 막 통과한 문서가 목록에서는 + * "다시 검증하라"로 보인다. + */ +@Repository +public class JdbcDependencyRevisionAdapter implements DependencyRevisionPort { + + private final JdbcClient jdbcClient; + + public JdbcDependencyRevisionAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public String revisionFor(RecordKind kind, UUID documentId) { + return jdbcClient + .sql( + StudioDocumentSql.documentProjectionCte() + + " SELECT dependency_revision FROM studio_document WHERE id = :id") + .param("id", documentId) + .query(String.class) + .optional() + .orElseThrow( + () -> + StudioException.of( + StudioError.DOCUMENT_NOT_FOUND, + "cannot compute a dependency revision for unknown document " + documentId)); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcPublicationQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcPublicationQueryAdapter.java new file mode 100644 index 0000000..52bc70a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcPublicationQueryAdapter.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** 현재 게시 상태 조회. {@code publication}은 (source_kind, source_id)당 한 행이다. */ +@Repository +public class JdbcPublicationQueryAdapter implements PublicationQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcPublicationQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public Optional currentFor(UUID documentId) { + return jdbcClient + .sql( + "SELECT publication_id, source_id, status, published_version, publication_revision," + + " latest_event_id, public_path, updated_at" + + " FROM publication WHERE source_id = :id") + .param("id", documentId) + .query( + (rs, rowNum) -> + new PublicationAggregateView( + rs.getObject("publication_id", UUID.class), + rs.getObject("source_id", UUID.class), + PublicationAggregateStatus.valueOf(rs.getString("status")), + rs.getLong("published_version"), + rs.getLong("publication_revision"), + rs.getObject("latest_event_id", UUID.class), + rs.getString("public_path"), + rs.getTimestamp("updated_at").toInstant())) + .optional(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDashboardQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDashboardQueryAdapter.java new file mode 100644 index 0000000..51be086 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDashboardQueryAdapter.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +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 dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort; +import java.util.List; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 대시보드 집계. 목록과 같은 {@code studio_document} 정의를 쓴다 — 대시보드가 "게시 준비됨"이라고 센 문서와 목록에서 그 필터로 나오는 + * 문서가 달라지면 숫자를 믿을 수 없다. + */ +@Repository +public class JdbcStudioDashboardQueryAdapter implements StudioDashboardQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcStudioDashboardQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public List topByNextAction(List actions, int limit) { + return jdbcClient + .sql( + StudioDocumentSql.documentProjectionCte() + + " SELECT * FROM studio_document WHERE next_action IN (:actions)" + + " ORDER BY updated_at DESC, id DESC LIMIT :limit") + .param("actions", actions.stream().map(Enum::name).toList()) + .param("limit", limit) + .query((rs, rowNum) -> StudioDocumentRowMapper.read(rs)) + .list(); + } + + @Override + public DashboardTotalsView totals() { + return jdbcClient + .sql( + StudioDocumentSql.documentProjectionCte() + + " SELECT count(*) AS documents," + + " count(*) FILTER (WHERE next_action IN ('VALIDATE', 'FIX_VALIDATION'))" + + " AS needs_validation," + + " count(*) FILTER (WHERE next_action = 'PUBLISH') AS ready_to_publish," + + " (SELECT count(*) FROM publication WHERE status = 'PUBLISHED') AS publications" + + " FROM studio_document") + .query( + (rs, rowNum) -> + new DashboardTotalsView( + rs.getInt("documents"), + rs.getInt("needs_validation"), + rs.getInt("ready_to_publish"), + rs.getInt("publications"))) + .single(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDependencyResolverAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDependencyResolverAdapter.java new file mode 100644 index 0000000..8e571c7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDependencyResolverAdapter.java @@ -0,0 +1,225 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +import dev.caskeleton.application.techlog.studio.model.DisplayTargetView; +import dev.caskeleton.application.techlog.studio.model.PublicPaths; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.RelationView; +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 dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** 편집본이 의존하는 바깥 상태를 한 번에 읽는다. 검증과 렌더링이 같은 결과를 공유하도록 조회는 이 한 곳에서만 한다. */ +@Repository +public class JdbcStudioDependencyResolverAdapter implements StudioDependencyResolverPort { + + private final JdbcClient jdbcClient; + + public JdbcStudioDependencyResolverAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public Resolved resolve(WorkingCopyView document, Set referencedAssetKeys) { + UUID topicId = document.base().topicId(); + UUID projectId = document.base().projectId(); + + DisplayTargetView topic = topicId == null ? null : findTopic(topicId); + DisplayTargetView project = projectId == null ? null : findProject(projectId); + + List relations = new ArrayList<>(); + List missingTargets = new ArrayList<>(); + resolveRelations(document, relations, missingTargets); + + Map assetsByKey = new LinkedHashMap<>(); + Map assetStatusByKey = new LinkedHashMap<>(); + resolveAssets(referencedAssetKeys, assetsByKey, assetStatusByKey); + + String projectSlug = projectId == null ? null : findProjectSlug(projectId); + String publicPath = PublicPaths.forKind(document.kind(), document.base().slug(), projectSlug); + + return new Resolved( + topic, + topicId != null && topic == null, + project, + projectId != null && project == null, + relations, + missingTargets, + assetsByKey, + assetStatusByKey, + resolveEvidenceTarget(document), + publicPath, + findSlugOwner(document)); + } + + private DisplayTargetView findTopic(UUID topicId) { + return jdbcClient + .sql("SELECT id, name, slug FROM topic WHERE id = :id") + .param("id", topicId) + .query( + (rs, rowNum) -> + new DisplayTargetView( + rs.getObject("id", UUID.class), + rs.getString("name"), + "/topics/" + rs.getString("slug"))) + .optional() + .orElse(null); + } + + private DisplayTargetView findProject(UUID projectId) { + return jdbcClient + .sql("SELECT id, name, slug FROM project WHERE id = :id") + .param("id", projectId) + .query( + (rs, rowNum) -> + new DisplayTargetView( + rs.getObject("id", UUID.class), + rs.getString("name"), + rs.getString("slug") == null ? null : "/projects/" + rs.getString("slug"))) + .optional() + .orElse(null); + } + + private String findProjectSlug(UUID projectId) { + return jdbcClient + .sql("SELECT slug FROM project WHERE id = :id") + .param("id", projectId) + .query(String.class) + .optional() + .orElse(null); + } + + /** + * 관계 대상은 네 유형 어디에도 있을 수 있고 프로젝트일 수도 있다(계약 {@code ResolvedRelation.targetKind} 가 {@code + * RecordKind} 보다 하나 넓다). UNION 으로 한 번에 찾는다. + */ + private void resolveRelations( + WorkingCopyView document, List resolved, List missing) { + + for (RelationView relation : document.base().relations()) { + if (relation.targetId() == null) { + continue; + } + TargetRow row = findTarget(relation.targetId()); + if (row == null) { + missing.add(relation.targetId()); + continue; + } + resolved.add( + new ResolvedRelationView( + relation.id(), + relation.targetId(), + row.kind(), + row.title(), + row.publicPath(), + relation.reason() == null ? "" : relation.reason(), + relation.order())); + } + } + + private record TargetRow(String kind, String title, String publicPath) {} + + private TargetRow findTarget(UUID targetId) { + return jdbcClient + .sql( + "SELECT document_type AS kind, title, slug, NULL::text AS project_slug FROM document" + + " WHERE id = :id" + + " UNION ALL SELECT 'QUESTION', question, slug, NULL::text FROM open_question" + + " WHERE id = :id" + + " UNION ALL SELECT 'PROJECT', name, slug, NULL::text FROM project" + + " WHERE id = :id" + + " UNION ALL SELECT 'PROJECT_DECISION', pd.title, pd.slug, p.slug" + + " FROM project_decision pd LEFT JOIN project p ON p.id = pd.project_id" + + " WHERE pd.id = :id") + .param("id", targetId) + .query( + (rs, rowNum) -> { + String kind = rs.getString("kind"); + String slug = rs.getString("slug"); + String publicPath = + "PROJECT".equals(kind) + ? (slug == null ? null : "/projects/" + slug) + : PublicPaths.forKind( + RecordKind.valueOf(kind), slug, rs.getString("project_slug")); + return new TargetRow(kind, rs.getString("title"), publicPath); + }) + .optional() + .orElse(null); + } + + private void resolveAssets( + Set keys, Map assets, Map statuses) { + if (keys.isEmpty()) { + return; + } + jdbcClient + .sql( + "SELECT id, asset_key, content_type, object_key, width, height, decorative," + + " management_status FROM asset WHERE asset_key IN (:keys)") + .param("keys", keys) + .query( + (rs, rowNum) -> { + String key = rs.getString("asset_key"); + statuses.put(key, rs.getString("management_status")); + assets.put( + key, + new ResolvedAssetView( + rs.getObject("id", UUID.class), + key, + rs.getString("content_type"), + // 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다 + // (설계 05장 §3.1). + "/media/" + rs.getString("id"), + (Integer) rs.getObject("width"), + (Integer) rs.getObject("height"), + rs.getBoolean("decorative"))); + return key; + }) + .list(); + } + + private DisplayTargetView resolveEvidenceTarget(WorkingCopyView document) { + if (!(document instanceof WorkingCopyView.QuestionWorkingCopyView value) + || value.resolution() == null + || value.resolution().evidenceTargetId() == null) { + return null; + } + TargetRow row = findTarget(value.resolution().evidenceTargetId()); + return row == null + ? null + : new DisplayTargetView( + value.resolution().evidenceTargetId(), row.title(), row.publicPath()); + } + + /** + * 같은 공개 경로 이름공간(= 같은 유형)에서 이 slug 를 이미 쓰는 다른 기록. 유형이 다르면 경로 접두사가 달라 충돌하지 않는다({@code /cases/x} 와 + * {@code /questions/x} 는 다른 주소다). + */ + private UUID findSlugOwner(WorkingCopyView document) { + String slug = document.base().slug(); + if (slug == null || slug.isBlank()) { + return null; + } + String sql = + switch (document.kind()) { + case CASE, REFERENCE -> + "SELECT id FROM document WHERE slug = :slug AND document_type = :kind AND id <> :id"; + case QUESTION -> "SELECT id FROM open_question WHERE slug = :slug AND id <> :id"; + case PROJECT_DECISION -> + "SELECT id FROM project_decision WHERE slug = :slug AND id <> :id"; + }; + var spec = jdbcClient.sql(sql).param("slug", slug).param("id", document.id()); + if (document.kind() == RecordKind.CASE || document.kind() == RecordKind.REFERENCE) { + spec = spec.param("kind", document.kind().name()); + } + return spec.query(UUID.class).optional().orElse(null); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDocumentQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDocumentQueryAdapter.java new file mode 100644 index 0000000..e385e05 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcStudioDocumentQueryAdapter.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +import dev.caskeleton.application.techlog.studio.model.DocumentPageView; +import dev.caskeleton.application.techlog.studio.model.DocumentSort; +import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView; +import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort; +import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition; +import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * {@code listStudioDocuments}. 네 유형이 서로 다른 테이블에 살기 때문에 공통 repository 대신 union projection을 돌린다(계약 + * 설명, spec §8.3). + * + *

정렬 키에 항상 {@code id}를 붙인다. {@code updated_at}만으로 자르면 같은 시각의 행들이 페이지 경계에서 중복되거나 누락된다 — 대량 저장 직후에 + * 실제로 일어나는 일이다. + */ +@Repository +public class JdbcStudioDocumentQueryAdapter implements StudioDocumentQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcStudioDocumentQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public DocumentPageView list(ListDocumentsQuery query) { + StringBuilder sql = new StringBuilder(StudioDocumentSql.documentProjectionCte()); + sql.append(" SELECT * FROM studio_document WHERE 1 = 1"); + Map params = new java.util.HashMap<>(); + + if (query.kind() != null) { + sql.append(" AND kind = :kind"); + params.put("kind", query.kind().name()); + } + if (query.projectId() != null) { + sql.append(" AND project_id = :projectId"); + params.put("projectId", query.projectId()); + } + if (query.nextAction() != null) { + sql.append(" AND next_action = :nextAction"); + params.put("nextAction", query.nextAction().name()); + } + if (query.publicationStatus() != null) { + sql.append(publicationStatusPredicate()); + params.put("publicationStatus", query.publicationStatus().name()); + } + if (query.query() != null && !query.query().isBlank()) { + sql.append(" AND lower(title) LIKE :titlePattern"); + params.put("titlePattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%"); + } + appendCursorPredicate(sql, params, query); + sql.append(orderBy(query.sort())); + // limit + 1 을 읽어 "다음 쪽이 있는가"를 별도 count 없이 판정한다. + sql.append(" LIMIT :limitPlusOne"); + params.put("limitPlusOne", query.limit() + 1); + + var spec = jdbcClient.sql(sql.toString()); + for (Map.Entry param : params.entrySet()) { + spec = spec.param(param.getKey(), param.getValue()); + } + + List rows = spec.query((rs, rowNum) -> readRow(rs)).list(); + boolean hasMore = rows.size() > query.limit(); + List page = hasMore ? rows.subList(0, query.limit()) : rows; + + List items = new ArrayList<>(page.size()); + for (Row row : page) { + items.add(row.summary()); + } + return new DocumentPageView( + items, hasMore ? cursorPayload(page.getLast(), query.sort()) : null); + } + + /** + * 게시 이력이 없는 문서는 {@code publication} 행 자체가 없다 — {@code NEVER_PUBLISHED}는 "행이 없음"이지 특정 status 값이 + * 아니다. + */ + private static String publicationStatusPredicate() { + return " AND ((:publicationStatus = 'NEVER_PUBLISHED' AND publication_status IS NULL)" + + " OR publication_status = :publicationStatus)"; + } + + private static String orderBy(DocumentSort sort) { + return switch (sort) { + case UPDATED_DESC -> " ORDER BY updated_at DESC, id DESC"; + case UPDATED_ASC -> " ORDER BY updated_at ASC, id ASC"; + case TITLE_ASC -> " ORDER BY title ASC, id ASC"; + }; + } + + private static void appendCursorPredicate( + StringBuilder sql, Map params, ListDocumentsQuery query) { + DocumentCursorPosition position = query.position(); + if (position == null) { + return; + } + // switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 정렬이 늘면 컴파일러가 + // 여기서 막아 준다(문이면 커서 조건 없이 조용히 첫 페이지를 다시 준다). + String predicate = + switch (query.sort()) { + case UPDATED_DESC -> { + params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt())); + yield " AND (updated_at, id) < (:cursorUpdatedAt, :cursorId)"; + } + case UPDATED_ASC -> { + params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt())); + yield " AND (updated_at, id) > (:cursorUpdatedAt, :cursorId)"; + } + case TITLE_ASC -> { + params.put("cursorTitle", position.title()); + yield " AND (title, id) > (:cursorTitle, :cursorId)"; + } + }; + params.put("cursorId", position.id()); + sql.append(predicate); + } + + /** 다음 쪽의 시작 위치. web 계층이 이 값을 서명해 opaque cursor 로 만든다. */ + private static String cursorPayload(Row last, DocumentSort sort) { + return switch (sort) { + case UPDATED_DESC, UPDATED_ASC -> last.updatedAt().toInstant() + "|" + last.summary().id(); + case TITLE_ASC -> last.title() + "|" + last.summary().id(); + }; + } + + /** 커서 계산에 필요한 정렬 키만 요약과 함께 들고 다닌다. */ + private record Row(DocumentSummaryView summary, java.sql.Timestamp updatedAt, String title) {} + + private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException { + return new Row( + StudioDocumentRowMapper.read(rs), rs.getTimestamp("updated_at"), rs.getString("title")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentRowMapper.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentRowMapper.java new file mode 100644 index 0000000..c6d8342 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentRowMapper.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +import dev.caskeleton.application.techlog.studio.model.DisplayTargetView; +import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView; +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.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +/** + * {@code studio_document} 한 행 → 계약 {@code DocumentSummary}. + * + *

목록·대시보드·게시 이력이 모두 이 매퍼를 쓴다. 화면마다 따로 만들면 같은 문서가 화면마다 다른 {@code nextAction} 이나 {@code + * hasUnpublishedChanges} 로 보인다. + */ +public final class StudioDocumentRowMapper { + + private StudioDocumentRowMapper() {} + + public static DocumentSummaryView read(ResultSet rs) throws SQLException { + UUID projectId = rs.getObject("project_id", UUID.class); + String projectSlug = rs.getString("project_slug"); + Long publishedVersion = (Long) rs.getObject("published_version"); + long version = rs.getLong("version"); + String publicationStatus = rs.getString("publication_status"); + + return new DocumentSummaryView( + rs.getObject("id", UUID.class), + rs.getString("title"), + RecordKind.valueOf(rs.getString("kind")), + projectId == null + ? null + : new DisplayTargetView( + projectId, + rs.getString("project_name"), + projectSlug == null ? null : "/projects/" + projectSlug), + rs.getTimestamp("updated_at").toInstant(), + publicationStatus == null + ? PublicationStatusView.NEVER_PUBLISHED + : PublicationStatusView.valueOf(publicationStatus), + publishedVersion, + // 계약: "게시 취소 상태에서도 과거 publishedVersion 과 비교한다." + publishedVersion != null && publishedVersion != version, + NextAction.valueOf(rs.getString("next_action"))); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentSql.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentSql.java new file mode 100644 index 0000000..e0cd077 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/StudioDocumentSql.java @@ -0,0 +1,130 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.query; + +/** + * Studio 문서 union projection의 SQL 정의. 목록·대시보드·단건 조회가 같은 정의를 쓴다. + * + *

{@code dependencyRevision}과 {@code nextAction}은 저장하지 않고 계산하는 값이다(spec §6.3, §7.3). 계산식이 SQL 한 + * 곳과 Java 한 곳에 따로 있으면 목록의 {@code nextAction}과 상세의 {@code nextAction}이 조용히 갈라진다 — 사용자에게는 "목록에서는 + * 게시하라더니 열어 보니 검증하라"는 모순으로 보인다. 그래서 계산은 여기 SQL 한 벌만 둔다. + */ +public final class StudioDocumentSql { + + /** + * 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 — 올리는 순간 기존 validation/preview가 전부 stale이 되어 다시 검증·미리보기를 거치게 된다. + */ + public static final String RENDERER_CONTRACT_VERSION = "1"; + + private StudioDocumentSql() {} + + /** + * 네 유형을 하나의 행 모양으로 모으고, 그 위에 의존 상태 해시와 최신 artifact를 붙인 CTE 묶음. + * + *

마지막 CTE {@code studio_document}가 최종 결과이며 컬럼은 다음과 같다. + * + *

{@code
+   * id kind title version updated_at topic_id project_id project_name project_slug
+   * dependency_revision
+   * validation_version validation_status validation_valid_until validation_revision
+   * preview_version preview_expires_at preview_revision
+   * publication_status published_version
+   * next_action
+   * }
+ */ + public static String documentProjectionCte() { + return """ + WITH studio_source AS ( + SELECT d.id, + d.document_type AS kind, + d.title, + d.version, + d.updated_at, + d.primary_topic_id AS topic_id, + (SELECT l.project_id FROM project_document_link l + WHERE l.document_id = d.id AND l.relation_type = 'PRIMARY') AS project_id + FROM document d + UNION ALL + SELECT q.id, 'QUESTION', q.question, q.version, q.updated_at, q.primary_topic_id, + (SELECT l.project_id FROM project_question_link l + WHERE l.question_id = q.id AND l.relation_type = 'PRIMARY') + FROM open_question q + UNION ALL + SELECT pd.id, 'PROJECT_DECISION', pd.title, pd.version, pd.updated_at, + pd.primary_topic_id, pd.project_id + FROM project_decision pd + ), + studio_dependency AS ( + SELECT s.id, + md5(concat_ws('|', + coalesce(t.id::text || ':' || t.version::text, '-'), + coalesce(p.id::text || ':' || p.version::text, '-'), + coalesce(( + SELECT md5(string_agg(rel.sig, ',' ORDER BY rel.sig)) + FROM ( + SELECT r.target_id::text || ':' + || coalesce(rd.version, rq.version, rp.version, 0)::text AS sig + FROM studio_relation r + LEFT JOIN document rd ON rd.id = r.target_id + LEFT JOIN open_question rq ON rq.id = r.target_id + LEFT JOIN project_decision rp ON rp.id = r.target_id + WHERE r.source_kind = s.kind AND r.source_id = s.id + ) rel + ), '-'), + 'renderer:%s' + )) AS dependency_revision + FROM studio_source s + LEFT JOIN topic t ON t.id = s.topic_id + LEFT JOIN project p ON p.id = s.project_id + ), + studio_latest_validation AS ( + SELECT DISTINCT ON (v.source_id) + v.source_id, v.validation_id, v.validated_version, v.status, + v.valid_until, v.dependency_revision + FROM studio_validation v + ORDER BY v.source_id, v.validated_at DESC + ), + studio_latest_preview AS ( + SELECT DISTINCT ON (pv.source_id) + pv.source_id, pv.preview_id, pv.source_version, pv.expires_at, + pv.dependency_revision + FROM studio_preview pv + ORDER BY pv.source_id, pv.created_at DESC + ), + studio_document AS ( + SELECT s.id, s.kind, s.title, s.version, s.updated_at, s.topic_id, s.project_id, + pr.name AS project_name, pr.slug AS project_slug, + dep.dependency_revision, + val.validated_version AS validation_version, + val.status AS validation_status, + val.valid_until AS validation_valid_until, + val.dependency_revision AS validation_revision, + prev.source_version AS preview_version, + prev.expires_at AS preview_expires_at, + prev.dependency_revision AS preview_revision, + pub.status AS publication_status, + pub.published_version, + CASE + WHEN s.title IS NULL OR btrim(s.title) = '' THEN 'CONTINUE_EDITING' + WHEN val.validated_version IS NULL + OR val.validated_version <> s.version + OR val.dependency_revision IS DISTINCT FROM dep.dependency_revision + OR val.valid_until <= now() THEN 'VALIDATE' + WHEN val.status = 'INVALID' THEN 'FIX_VALIDATION' + WHEN prev.source_version IS NULL + OR prev.source_version <> s.version + OR prev.dependency_revision IS DISTINCT FROM dep.dependency_revision + OR prev.expires_at <= now() THEN 'CREATE_PREVIEW' + WHEN pub.status IS DISTINCT FROM 'PUBLISHED' + OR pub.published_version <> s.version THEN 'PUBLISH' + ELSE 'NONE' + END AS next_action + FROM studio_source s + JOIN studio_dependency dep ON dep.id = s.id + LEFT JOIN project pr ON pr.id = s.project_id + LEFT JOIN studio_latest_validation val ON val.source_id = s.id + LEFT JOIN studio_latest_preview prev ON prev.source_id = s.id + LEFT JOIN publication pub ON pub.source_id = s.id AND pub.source_kind = s.kind + ) + """ + .formatted(RENDERER_CONTRACT_VERSION); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/asset/JdbcAssetRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/asset/JdbcAssetRepositoryAdapter.java new file mode 100644 index 0000000..ea2b867 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/asset/JdbcAssetRepositoryAdapter.java @@ -0,0 +1,244 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset; + +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.AssetUsageView; +import dev.caskeleton.application.techlog.studio.model.AssetView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort; +import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * {@code asset} 메타데이터 접근. + * + *

{@code usageCount} 는 {@code asset_reference} 에서 센다 — Asset 행에 캐시해 두면 참조가 바뀔 때마다 두 곳을 맞춰야 하고, + * 어긋나면 "쓰이고 있는데 삭제 가능"으로 보인다. + */ +@Repository +public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort { + + private final JdbcClient jdbcClient; + + public JdbcAssetRepositoryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public AssetPageView list(ListAssetsQuery query) { + StringBuilder sql = new StringBuilder(selectColumns() + " WHERE 1 = 1"); + Map params = new HashMap<>(); + if (query.kind() != null) { + sql.append(" AND a.asset_kind = :kind"); + params.put("kind", query.kind().name()); + } + if (query.managementStatus() != null) { + sql.append(" AND a.management_status = :status"); + params.put("status", query.managementStatus().name()); + } + if (query.query() != null && !query.query().isBlank()) { + sql.append(" AND (lower(a.asset_key) LIKE :pattern OR lower(a.original_name) LIKE :pattern)"); + params.put("pattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%"); + } + if (query.beforeCreatedAt() != null && query.beforeId() != null) { + sql.append(" AND (a.created_at, a.id) < (:before, :beforeId)"); + params.put("before", Timestamp.from(query.beforeCreatedAt())); + params.put("beforeId", query.beforeId()); + } + sql.append(" ORDER BY a.created_at DESC, a.id DESC LIMIT :limitPlusOne"); + params.put("limitPlusOne", query.limit() + 1); + + var spec = jdbcClient.sql(sql.toString()); + for (Map.Entry param : params.entrySet()) { + spec = spec.param(param.getKey(), param.getValue()); + } + List rows = spec.query(JdbcAssetRepositoryAdapter::mapAsset).list(); + + boolean hasMore = rows.size() > query.limit(); + List page = hasMore ? rows.subList(0, query.limit()) : rows; + String nextCursor = hasMore ? page.getLast().createdAt() + "|" + page.getLast().id() : null; + return new AssetPageView(page, nextCursor); + } + + @Override + public Optional find(UUID assetId) { + return jdbcClient + .sql(selectColumns() + " WHERE a.id = :id") + .param("id", assetId) + .query(JdbcAssetRepositoryAdapter::mapAsset) + .optional(); + } + + @Override + public Optional findDetail(UUID assetId) { + return find(assetId) + .map( + asset -> new AssetDetailView(asset, usagesOf(assetId), hasPublicationHistory(assetId))); + } + + @Override + public AssetView create(NewAsset asset, String principal) { + jdbcClient + .sql( + "INSERT INTO asset (id, asset_key, asset_kind, management_status, object_key," + + " original_name, display_name, content_type, size_bytes, width, height," + + " checksum_sha256, alt_text, decorative, version, created_by, updated_by)" + + " VALUES (:id, :assetKey, :kind, :status, :objectKey, :originalName," + + " :originalName, :contentType, :size, :width, :height, :checksum, :altText," + // 계약의 Asset.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면 생성 직후 + // 응답이 계약을 위반하고, 클라이언트가 보내는 expectedVersion 도 맞출 수 없다. + + " :decorative, 1, :principal, :principal)") + .param("id", asset.id()) + .param("assetKey", asset.assetKey()) + .param("kind", asset.kind().name()) + .param("status", asset.managementStatus().name()) + .param("objectKey", asset.objectKey()) + .param("originalName", asset.originalFilename()) + .param("contentType", asset.mediaType()) + .param("size", asset.byteSize()) + .param("width", asset.width()) + .param("height", asset.height()) + .param("checksum", asset.checksumSha256()) + .param("altText", asset.altText()) + .param("decorative", asset.decorative()) + .param("principal", principal) + .update(); + return find(asset.id()).orElseThrow(); + } + + @Override + public Optional update( + UUID assetId, + long expectedVersion, + AssetKindView kind, + String altText, + boolean altTextProvided, + Boolean decorative, + AssetManagementStatusView managementStatus, + String principal) { + + int updated = + jdbcClient + .sql( + "UPDATE asset SET" + // 보내지 않은 필드는 그대로 둔다 — PUT 이지만 계약의 UpdateAssetCommand 는 + // expectedVersion 외 전부 optional 이라 부분 갱신 의미다. + + " asset_kind = COALESCE(:kind, asset_kind)," + + " alt_text = CASE WHEN :altTextProvided THEN :altText ELSE alt_text END," + + " decorative = COALESCE(:decorative, decorative)," + + " management_status = COALESCE(:status, management_status)," + + " version = version + 1, updated_at = now(), updated_by = :principal" + + " WHERE id = :id AND version = :expectedVersion") + .param("kind", kind == null ? null : kind.name()) + .param("altTextProvided", altTextProvided) + .param("altText", altText) + .param("decorative", decorative) + .param("status", managementStatus == null ? null : managementStatus.name()) + .param("principal", principal) + .param("id", assetId) + .param("expectedVersion", expectedVersion) + .update(); + return updated == 0 ? Optional.empty() : find(assetId); + } + + @Override + public Optional findObjectKey(UUID assetId) { + return jdbcClient + .sql("SELECT object_key FROM asset WHERE id = :id") + .param("id", assetId) + .query(String.class) + .optional(); + } + + @Override + public void delete(UUID assetId) { + jdbcClient.sql("DELETE FROM asset WHERE id = :id").param("id", assetId).update(); + } + + private static String selectColumns() { + return "SELECT a.id, a.asset_key, a.asset_kind, a.management_status, a.original_name," + + " a.content_type, a.size_bytes, a.width, a.height, a.alt_text, a.decorative," + + " a.version, a.created_at, a.updated_at, a.first_published_at," + + " (SELECT count(*) FROM asset_reference r WHERE r.asset_id = a.id) AS usage_count" + + " FROM asset a"; + } + + private static AssetView mapAsset(ResultSet rs, int rowNum) throws SQLException { + return new AssetView( + rs.getObject("id", UUID.class), + rs.getString("asset_key"), + AssetKindView.valueOf(rs.getString("asset_kind")), + rs.getString("content_type"), + rs.getString("original_name"), + rs.getLong("size_bytes"), + (Integer) rs.getObject("width"), + (Integer) rs.getObject("height"), + rs.getString("alt_text"), + rs.getBoolean("decorative"), + AssetManagementStatusView.valueOf(rs.getString("management_status")), + // 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다. + "/media/" + rs.getString("id"), + rs.getInt("usage_count"), + rs.getLong("version"), + rs.getTimestamp("created_at").toInstant(), + rs.getTimestamp("updated_at").toInstant()); + } + + private List usagesOf(UUID assetId) { + return jdbcClient + .sql( + "SELECT r.owner_id, r.owner_type, r.reference_scope," + + " COALESCE(d.title, q.question, pd.title) AS title," + + " COALESCE(d.document_type, 'QUESTION') AS document_kind" + + " FROM asset_reference r" + + " LEFT JOIN document d ON d.id = r.owner_id" + + " LEFT JOIN open_question q ON q.id = r.owner_id" + + " LEFT JOIN project_decision pd ON pd.id = r.owner_id" + + " WHERE r.asset_id = :id") + .param("id", assetId) + .query( + (rs, rowNum) -> + new AssetUsageView( + rs.getObject("owner_id", UUID.class), + kindOf(rs.getString("owner_type"), rs.getString("document_kind")), + rs.getString("title") == null ? "(제목 없음)" : rs.getString("title"), + "PUBLISHED".equals(rs.getString("reference_scope")))) + .list(); + } + + /** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */ + private static RecordKind kindOf(String ownerType, String documentKind) { + return switch (ownerType) { + case "DOCUMENT" -> RecordKind.valueOf(documentKind); + case "DECISION" -> RecordKind.PROJECT_DECISION; + default -> RecordKind.QUESTION; + }; + } + + /** 한 번이라도 공개된 적이 있으면 hard delete 를 금지한다(계약 {@code AssetDetail} 설명). */ + private boolean hasPublicationHistory(UUID assetId) { + return Boolean.TRUE.equals( + jdbcClient + .sql( + "SELECT (first_published_at IS NOT NULL" + + " OR EXISTS (SELECT 1 FROM asset_reference r" + + " WHERE r.asset_id = :id AND r.reference_scope = 'PUBLISHED'))" + + " FROM asset WHERE id = :id") + .param("id", assetId) + .query(Boolean.class) + .optional() + .orElse(Boolean.FALSE)); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationHistoryQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationHistoryQueryAdapter.java new file mode 100644 index 0000000..878c785 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationHistoryQueryAdapter.java @@ -0,0 +1,192 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication; + +import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentRowMapper; +import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentSql; +import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView; +import dev.caskeleton.application.techlog.studio.model.PublicationActionView; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView; +import dev.caskeleton.application.techlog.studio.model.PublicationEventView; +import dev.caskeleton.application.techlog.studio.model.PublicationListItemView; +import dev.caskeleton.application.techlog.studio.model.PublicationPageView; +import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView; +import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort; +import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 게시 이력 조회. 이력은 항상 최신순이며 정렬 선택지가 없다 — 계약에도 정렬 파라미터가 없다. + * + *

목록의 {@code document} 요약은 {@link StudioDocumentSql} 의 같은 정의에서 가져온다. 여기서 따로 만들면 목록 화면과 이력 화면의 + * {@code nextAction} 이 갈라진다. + */ +@Repository +public class JdbcPublicationHistoryQueryAdapter implements PublicationHistoryQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcPublicationHistoryQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public PublicationPageView list(ListPublicationsQuery query) { + StringBuilder sql = + new StringBuilder( + "SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type," + + " e.occurred_at, e.published_version, e.source_published_event_id," + + " (s.publication_event_id IS NOT NULL) AS snapshot_available," + + " p.status, p.publication_revision, p.latest_event_id, p.public_path," + + " p.updated_at AS publication_updated_at, p.published_version AS current_version" + + " FROM publication_event e" + + " JOIN publication p ON p.publication_id = e.publication_id" + + " LEFT JOIN publication_snapshot s" + + " ON s.publication_event_id = e.publication_event_id" + + " WHERE 1 = 1"); + Map params = new HashMap<>(); + if (query.type() != null) { + sql.append(" AND e.event_type = :type"); + params.put("type", query.type().name()); + } + if (query.beforeOccurredAt() != null && query.beforeEventId() != null) { + sql.append(" AND (e.occurred_at, e.publication_event_id) < (:before, :beforeId)"); + params.put("before", Timestamp.from(query.beforeOccurredAt())); + params.put("beforeId", query.beforeEventId()); + } + sql.append(" ORDER BY e.occurred_at DESC, e.publication_event_id DESC LIMIT :limitPlusOne"); + params.put("limitPlusOne", query.limit() + 1); + + var spec = jdbcClient.sql(sql.toString()); + for (Map.Entry param : params.entrySet()) { + spec = spec.param(param.getKey(), param.getValue()); + } + + List rows = spec.query((rs, rowNum) -> readRow(rs)).list(); + boolean hasMore = rows.size() > query.limit(); + List page = hasMore ? rows.subList(0, query.limit()) : rows; + + Map summaries = summariesFor(page); + List items = new ArrayList<>(page.size()); + for (Row row : page) { + items.add( + new PublicationListItemView( + row.event(), + row.publication(), + summaries.get(row.event().documentId()), + actionsFor(row))); + } + String nextCursor = + hasMore + ? page.getLast().event().occurredAt() + + "|" + + page.getLast().event().publicationEventId() + : null; + return new PublicationPageView(items, nextCursor); + } + + @Override + public Optional findSnapshot(UUID publicationEventId) { + return jdbcClient + .sql( + "SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type," + + " e.occurred_at, e.published_version, e.source_published_event_id," + + " true AS snapshot_available," + + " s.render_model, s.content_format_version, s.renderer_contract_version" + + " FROM publication_snapshot s" + + " JOIN publication_event e" + + " ON e.publication_event_id = s.publication_event_id" + + " WHERE s.publication_event_id = :id") + .param("id", publicationEventId) + .query( + (rs, rowNum) -> + new PublicationSnapshotView( + PublicationRowMappers.mapEvent(rs, rowNum), + rs.getString("render_model"), + rs.getString("content_format_version"), + rs.getString("renderer_contract_version"))) + .optional(); + } + + @Override + public Optional findById(UUID publicationId) { + return jdbcClient + .sql( + "SELECT publication_id, source_id, status, published_version, publication_revision," + + " latest_event_id, public_path, updated_at FROM publication" + + " WHERE publication_id = :id") + .param("id", publicationId) + .query(JdbcPublicationWriterAdapter::mapAggregate) + .optional(); + } + + private Map summariesFor(List rows) { + if (rows.isEmpty()) { + return Map.of(); + } + List ids = rows.stream().map(row -> row.event().documentId()).distinct().toList(); + Map summaries = new HashMap<>(); + jdbcClient + .sql( + StudioDocumentSql.documentProjectionCte() + + " SELECT * FROM studio_document WHERE id IN (:ids)") + .param("ids", ids) + .query((rs, rowNum) -> StudioDocumentRowMapper.read(rs)) + .list() + .forEach(summary -> summaries.put(summary.id(), summary)); + return summaries; + } + + /** + * 계약 {@code PublicationListItem.availableActions}. 지금 상태에서 실제로 할 수 있는 것만 담는다 — 화면이 눌러도 실패할 버튼을 + * 그리지 않게 하려는 값이다. + */ + private static List actionsFor(Row row) { + List actions = new ArrayList<>(); + if (row.event().snapshotAvailable()) { + actions.add(PublicationActionView.VIEW_SNAPSHOT); + } + if (row.event().sourcePublishedEventId() != null) { + actions.add(PublicationActionView.VIEW_SOURCE_SNAPSHOT); + } + if (row.publication().status() == PublicationAggregateStatus.PUBLISHED + && row.publication().latestEventId().equals(row.event().publicationEventId())) { + actions.add(PublicationActionView.UNPUBLISH); + } + return actions; + } + + private record Row(PublicationEventView event, PublicationAggregateView publication) {} + + private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException { + PublicationEventView event = + new PublicationEventView( + rs.getObject("publication_event_id", UUID.class), + rs.getObject("publication_id", UUID.class), + rs.getObject("source_id", UUID.class), + PublicationEventTypeView.valueOf(rs.getString("event_type")), + rs.getTimestamp("occurred_at").toInstant(), + rs.getLong("published_version"), + rs.getObject("source_published_event_id", UUID.class), + rs.getBoolean("snapshot_available")); + PublicationAggregateView publication = + new PublicationAggregateView( + rs.getObject("publication_id", UUID.class), + rs.getObject("source_id", UUID.class), + PublicationAggregateStatus.valueOf(rs.getString("status")), + rs.getLong("current_version"), + rs.getLong("publication_revision"), + rs.getObject("latest_event_id", UUID.class), + rs.getString("public_path"), + rs.getTimestamp("publication_updated_at").toInstant()); + return new Row(event, publication); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationWriterAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationWriterAdapter.java new file mode 100644 index 0000000..6d7f64f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/JdbcPublicationWriterAdapter.java @@ -0,0 +1,514 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView; +import dev.caskeleton.application.techlog.studio.model.PublicationEventView; +import dev.caskeleton.application.techlog.studio.model.PublishResultView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort; +import dev.caskeleton.shared.error.MappingException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * spec §7.5 의 게시 트랜잭션 10~19단계. + * + *

{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 는 서로를 가리킨다. 첫 + * 게시는 publication INSERT → event INSERT → publication UPDATE 순서로 한 트랜잭션 안에서 끝나며, 그 순환을 허용하는 것이 V7 의 + * {@code DEFERRABLE INITIALLY DEFERRED} 다. 즉시 검사로 바꾸면 첫 게시가 구조적으로 불가능해진다. + */ +@Repository +public class JdbcPublicationWriterAdapter implements PublicationWriterPort { + + /** 공개 projection payload 의 스키마 버전. 모양이 바뀌면 올린다. */ + private static final short PAYLOAD_SCHEMA_VERSION = 1; + + private final JdbcClient jdbcClient; + private final ObjectMapper objectMapper; + private final Supplier idGenerator; + + /** + * 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게 + * 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다. + */ + @Autowired + public JdbcPublicationWriterAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this(jdbcClient, objectMapper, UUID::randomUUID); + } + + JdbcPublicationWriterAdapter( + JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier idGenerator) { + this.jdbcClient = jdbcClient; + this.objectMapper = objectMapper; + this.idGenerator = idGenerator; + } + + @Override + public Optional lockCurrentPublication( + RecordKind kind, UUID documentId) { + return jdbcClient + .sql( + "SELECT publication_id, source_id, status, published_version, publication_revision," + + " latest_event_id, public_path, updated_at FROM publication" + + " WHERE source_kind = :kind AND source_id = :id FOR UPDATE") + .param("kind", kind.name()) + .param("id", documentId) + .query(JdbcPublicationWriterAdapter::mapAggregate) + .optional(); + } + + @Override + public PublishResultView publish(PublishRequest request) { + requireTransaction("publish"); + Optional existing = + lockCurrentPublication(request.kind(), request.documentId()); + + UUID publicationId = + existing.map(PublicationAggregateView::publicationId).orElseGet(idGenerator); + UUID eventId = idGenerator.get(); + PublicationEventTypeView eventType = + existing.isEmpty() + ? PublicationEventTypeView.PUBLISHED + : PublicationEventTypeView.REPUBLISHED; + + if (existing.isEmpty()) { + // 10 이전: aggregate 를 먼저 만든다. latest_event_id 는 아직 없는 event 를 가리키지만 + // 지연 검사라 커밋 시점에만 확인된다. + jdbcClient + .sql( + "INSERT INTO publication (publication_id, source_kind, source_id, status," + + " published_version, publication_revision, latest_event_id, public_path)" + + " VALUES (:publicationId, :kind, :id, 'PUBLISHED', :version, 1, :eventId," + + " :publicPath)") + .param("publicationId", publicationId) + .param("kind", request.kind().name()) + .param("id", request.documentId()) + .param("version", request.version()) + .param("eventId", eventId) + .param("publicPath", request.publicPath()) + .update(); + } + + // 10. Event + jdbcClient + .sql( + "INSERT INTO publication_event (publication_event_id, publication_id, source_kind," + + " source_id, event_type, published_version, occurred_at, idempotency_key," + + " created_by)" + + " VALUES (:eventId, :publicationId, :kind, :id, :type, :version, now()," + + " :idempotencyKey, :principal)") + .param("eventId", eventId) + .param("publicationId", publicationId) + .param("kind", request.kind().name()) + .param("id", request.documentId()) + .param("type", eventType.name()) + .param("version", request.version()) + .param("idempotencyKey", request.idempotencyKey()) + .param("principal", request.principal()) + .update(); + + // 11. Snapshot — 게시 시점의 렌더 모델을 그대로 고정한다. 다시 렌더링하지 않는다. + jdbcClient + .sql( + "INSERT INTO publication_snapshot (publication_event_id, render_model," + + " content_format_version, renderer_contract_version, asset_manifest)" + + " VALUES (:eventId, CAST(:renderModel AS jsonb), :contentFormatVersion," + + " :rendererContractVersion, CAST(:assetManifest AS jsonb))") + .param("eventId", eventId) + .param("renderModel", request.renderModelJson()) + .param("contentFormatVersion", request.contentFormatVersion()) + .param("rendererContractVersion", request.rendererContractVersion()) + .param("assetManifest", manifestJson(request.assetManifest())) + .update(); + + upsertProjection(request); + replaceRoute(request); + replaceProjectLink(request); + replacePublishedAssetReferences(request); + markAssetsFirstPublished(request); + + // 17. aggregate 갱신 + jdbcClient + .sql( + "UPDATE publication SET status = 'PUBLISHED', published_version = :version," + + " publication_revision = publication_revision + :bump," + + " latest_event_id = :eventId, public_path = :publicPath, updated_at = now()" + + " WHERE publication_id = :publicationId") + .param("version", request.version()) + // 첫 게시는 INSERT 가 이미 revision 1 을 넣었다. 여기서 또 올리면 클라이언트가 받은 값과 + // 다음 unpublish 가 요구하는 값이 어긋난다. + .param("bump", existing.isEmpty() ? 0 : 1) + .param("eventId", eventId) + .param("publicPath", request.publicPath()) + .param("publicationId", publicationId) + .update(); + + // 18. Document publish metadata + markSourcePublished(request); + + return result(publicationId, eventId); + } + + @Override + public PublishResultView unpublish(UnpublishRequest request) { + requireTransaction("unpublish"); + UUID lastPublishedEventId = + jdbcClient + .sql( + "SELECT publication_event_id FROM publication_event" + + " WHERE publication_id = :publicationId" + + " AND event_type IN ('PUBLISHED', 'REPUBLISHED')" + + " ORDER BY occurred_at DESC LIMIT 1") + .param("publicationId", request.publicationId()) + .query(UUID.class) + .optional() + .orElseThrow( + () -> + StudioException.of( + StudioError.PUBLICATION_CONFLICT, + "this publication has no published event to withdraw")); + + UUID eventId = idGenerator.get(); + // UNPUBLISHED Event 는 자체 snapshot 을 만들지 않고 마지막 공개 Snapshot 을 참조한다(V7 주석). + jdbcClient + .sql( + "INSERT INTO publication_event (publication_event_id, publication_id, source_kind," + + " source_id, event_type, published_version, source_published_event_id," + + " occurred_at, created_by)" + + " SELECT :eventId, p.publication_id, p.source_kind, p.source_id, 'UNPUBLISHED'," + + " p.published_version, :sourceEventId, now(), :principal" + + " FROM publication p WHERE p.publication_id = :publicationId") + .param("eventId", eventId) + .param("sourceEventId", lastPublishedEventId) + .param("principal", request.principal()) + .param("publicationId", request.publicationId()) + .update(); + + int updated = + jdbcClient + .sql( + "UPDATE publication SET status = 'UNPUBLISHED'," + + " publication_revision = publication_revision + 1," + + " latest_event_id = :eventId, updated_at = now()" + + " WHERE publication_id = :publicationId" + + " AND publication_revision = :expectedRevision") + .param("eventId", eventId) + .param("publicationId", request.publicationId()) + .param("expectedRevision", request.expectedRevision()) + .update(); + if (updated == 0) { + throw StudioException.of( + StudioError.PUBLICATION_CONFLICT, + "the publication revision changed while withdrawing it"); + } + + // Projection ACTIVE -> WITHDRAWN. route 는 유지한다 — 주소가 사라지면 링크가 끊긴다. + jdbcClient + .sql( + "UPDATE public_resource_projection SET publication_state = 'WITHDRAWN'," + + " updated_at = now() WHERE resource_type = :type AND resource_id = :id") + .param("type", request.kind().name()) + .param("id", request.documentId()) + .update(); + + // Working copy 는 다시 초안으로 돌아간다. + if (request.kind() == RecordKind.CASE || request.kind() == RecordKind.REFERENCE) { + jdbcClient + .sql("UPDATE document SET workflow_status = 'DRAFT' WHERE id = :id") + .param("id", request.documentId()) + .update(); + } + return result(request.publicationId(), eventId); + } + + /** 12. 공개 projection upsert. */ + private void upsertProjection(PublishRequest request) { + jdbcClient + .sql( + "INSERT INTO public_resource_projection (resource_type, resource_id, source_version," + + " publication_state, visibility, title, summary, state_code, primary_topic_id," + + " payload_schema_version, payload, body_plain_text, search_text, content_hash," + + " published_at, updated_at, navigation_path)" + + " VALUES (:type, :id, :version, 'ACTIVE', 'PUBLIC', :title, :summary," + + " :stateCode, :topicId, :schemaVersion, CAST(:payload AS jsonb), :bodyPlainText," + + " :searchText, :contentHash, now(), now(), :navigationPath)" + + " ON CONFLICT (resource_type, resource_id) DO UPDATE SET" + + " source_version = EXCLUDED.source_version," + + " publication_state = 'ACTIVE'," + + " visibility = EXCLUDED.visibility," + + " title = EXCLUDED.title," + + " summary = EXCLUDED.summary," + + " state_code = EXCLUDED.state_code," + + " primary_topic_id = EXCLUDED.primary_topic_id," + + " payload = EXCLUDED.payload," + + " body_plain_text = EXCLUDED.body_plain_text," + + " search_text = EXCLUDED.search_text," + + " content_hash = EXCLUDED.content_hash," + + " updated_at = now()," + + " navigation_path = EXCLUDED.navigation_path") + .param("type", request.kind().name()) + .param("id", request.documentId()) + .param("version", request.version()) + .param("title", request.title()) + .param("summary", request.summary()) + .param("stateCode", request.stateCode()) + .param("topicId", request.topicId()) + .param("schemaVersion", PAYLOAD_SCHEMA_VERSION) + .param("payload", request.renderModelJson()) + .param("bodyPlainText", request.bodyPlainText()) + .param( + "searchText", + String.join( + " ", + nullToEmpty(request.title()), + nullToEmpty(request.summary()), + nullToEmpty(request.bodyPlainText()))) + .param("contentHash", sha256(request.renderModelJson())) + .param("navigationPath", request.publicPath()) + .update(); + } + + /** 13. canonical route. 이전 slug 의 route 는 alias 로 남긴다 — 지우면 공개된 링크가 끊긴다. */ + private void replaceRoute(PublishRequest request) { + jdbcClient + .sql( + "UPDATE public_route SET route_role = 'ALIAS'" + + " WHERE resource_type = :type AND resource_id = :id AND slug <> :slug") + .param("type", request.kind().name()) + .param("id", request.documentId()) + .param("slug", slugOf(request.publicPath())) + .update(); + jdbcClient + .sql( + "INSERT INTO public_route (resource_type, slug, resource_id, route_role)" + + " VALUES (:type, :slug, :id, 'CANONICAL')" + + " ON CONFLICT (resource_type, slug) DO UPDATE SET" + + " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'") + .param("type", request.kind().name()) + .param("slug", slugOf(request.publicPath())) + .param("id", request.documentId()) + .update(); + } + + /** 14. 공개 projection 의 프로젝트 링크. Studio 는 PRIMARY 하나만 소유한다. */ + private void replaceProjectLink(PublishRequest request) { + jdbcClient + .sql( + "DELETE FROM public_resource_project_link" + + " WHERE resource_type = :type AND resource_id = :id AND relation_type = 'PRIMARY'") + .param("type", request.kind().name()) + .param("id", request.documentId()) + .update(); + if (request.projectId() == null) { + return; + } + jdbcClient + .sql( + "INSERT INTO public_resource_project_link (resource_type, resource_id, project_id," + + " relation_type) VALUES (:type, :id, :projectId, 'PRIMARY')" + + " ON CONFLICT (resource_type, resource_id, project_id)" + + " DO UPDATE SET relation_type = 'PRIMARY'") + .param("type", request.kind().name()) + .param("id", request.documentId()) + .param("projectId", request.projectId()) + .update(); + } + + /** 15. PUBLISHED scope 의 asset_reference 교체. WORKING scope 는 건드리지 않는다. */ + private void replacePublishedAssetReferences(PublishRequest request) { + String ownerType = ownerTypeOf(request.kind()); + jdbcClient + .sql( + "DELETE FROM asset_reference WHERE owner_type = :ownerType AND owner_id = :id" + + " AND reference_scope = 'PUBLISHED'") + .param("ownerType", ownerType) + .param("id", request.documentId()) + .update(); + for (AssetManifestEntry entry : request.assetManifest()) { + jdbcClient + .sql( + "INSERT INTO asset_reference (asset_id, owner_type, owner_id, reference_scope," + + " reference_role) VALUES (:assetId, :ownerType, :id, 'PUBLISHED', 'BODY')" + + " ON CONFLICT DO NOTHING") + .param("assetId", entry.assetId()) + .param("ownerType", ownerType) + .param("id", request.documentId()) + .update(); + } + } + + /** 16. 최초 공개 시각. 이미 값이 있으면 덮지 않는다 — "처음"은 한 번뿐이다. */ + private void markAssetsFirstPublished(PublishRequest request) { + for (AssetManifestEntry entry : request.assetManifest()) { + jdbcClient + .sql( + "UPDATE asset SET first_published_at = now()" + + " WHERE id = :assetId AND first_published_at IS NULL") + .param("assetId", entry.assetId()) + .update(); + } + } + + /** 18. source 쪽 게시 메타데이터. */ + private void markSourcePublished(PublishRequest request) { + // switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 유형이 늘면 컴파일러가 + // 여기서 막아 준다(문이면 조용히 아무것도 안 하고 지나간다). + int updated = + switch (request.kind()) { + case CASE, REFERENCE -> + jdbcClient + .sql( + "UPDATE document SET workflow_status = 'PUBLISHED'," + + " first_published_at = COALESCE(first_published_at, now())," + + " last_published_at = now() WHERE id = :id") + .param("id", request.documentId()) + .update(); + case QUESTION -> + jdbcClient + .sql( + "UPDATE open_question SET" + + " first_published_at = COALESCE(first_published_at, now())," + + " last_published_at = now() WHERE id = :id") + .param("id", request.documentId()) + .update(); + // project_decision 에는 게시 시각 컬럼이 없다. 게시 사실은 publication 이 소유하므로 + // 여기서 억지로 컬럼을 만들지 않는다. + case PROJECT_DECISION -> 0; + }; + if (updated == 0 && request.kind() != RecordKind.PROJECT_DECISION) { + throw StudioException.of( + StudioError.DOCUMENT_NOT_FOUND, + "the source record disappeared while publishing " + request.documentId()); + } + } + + private PublishResultView result(UUID publicationId, UUID eventId) { + PublicationAggregateView aggregate = + jdbcClient + .sql( + "SELECT publication_id, source_id, status, published_version," + + " publication_revision, latest_event_id, public_path, updated_at" + + " FROM publication WHERE publication_id = :id") + .param("id", publicationId) + .query(JdbcPublicationWriterAdapter::mapAggregate) + .single(); + PublicationEventView event = + jdbcClient + .sql( + "SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type," + + " e.occurred_at, e.published_version, e.source_published_event_id," + + " (s.publication_event_id IS NOT NULL) AS snapshot_available" + + " FROM publication_event e" + + " LEFT JOIN publication_snapshot s" + + " ON s.publication_event_id = e.publication_event_id" + + " WHERE e.publication_event_id = :id") + .param("id", eventId) + .query(PublicationRowMappers::mapEvent) + .single(); + return new PublishResultView(aggregate, event); + } + + /** + * 이 어댑터는 열린 트랜잭션 안에서만 올바르게 동작한다. + * + *

{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 가 서로를 가리키고, 그 + * 순환은 {@code fk_publication_latest_event} 의 {@code DEFERRABLE INITIALLY DEFERRED} 로만 성립한다. 지연 검사는 + * 트랜잭션 끝에 일어나므로, autocommit 이면 각 구문이 곧 트랜잭션이라 첫 INSERT 에서 바로 위반이 된다. + * + *

이 사실을 주석으로만 남기면 트랜잭션 없이 호출한 코드가 "외래 키 위반"이라는, 원인과 한참 떨어진 오류를 만난다. 통합 테스트를 처음 돌렸을 때 실제로 그렇게 + * 실패했다. 그래서 전제를 여기서 확인하고 무엇이 잘못됐는지 그대로 말한다. + */ + private static void requireTransaction(String operation) { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException( + "publication " + + operation + + " must run inside an active transaction: publication and publication_event" + + " reference each other, and that cycle only resolves at commit through" + + " fk_publication_latest_event's deferred check"); + } + } + + static PublicationAggregateView mapAggregate(java.sql.ResultSet rs, int rowNum) + throws java.sql.SQLException { + return new PublicationAggregateView( + rs.getObject("publication_id", UUID.class), + rs.getObject("source_id", UUID.class), + PublicationAggregateStatus.valueOf(rs.getString("status")), + rs.getLong("published_version"), + rs.getLong("publication_revision"), + rs.getObject("latest_event_id", UUID.class), + rs.getString("public_path"), + rs.getTimestamp("updated_at").toInstant()); + } + + private String manifestJson(List manifest) { + ArrayNode array = objectMapper.createArrayNode(); + for (AssetManifestEntry entry : manifest) { + ObjectNode node = array.addObject(); + node.put("assetId", entry.assetId() == null ? null : entry.assetId().toString()); + node.put("assetKey", entry.assetKey()); + node.put("mediaType", entry.mediaType()); + node.put("publicPath", entry.publicPath()); + node.put("width", entry.width()); + node.put("height", entry.height()); + node.put("decorative", entry.decorative()); + } + try { + return objectMapper.writeValueAsString(array); + } catch (JacksonException e) { + throw new MappingException("failed to serialise a publication asset manifest", e); + } + } + + /** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */ + private static String ownerTypeOf(RecordKind kind) { + return switch (kind) { + case CASE, REFERENCE -> "DOCUMENT"; + case QUESTION -> "QUESTION"; + case PROJECT_DECISION -> "DECISION"; + }; + } + + /** {@code public_route.slug} 는 경로가 아니라 마지막 조각이다. */ + private static String slugOf(String publicPath) { + if (publicPath == null || publicPath.isBlank()) { + throw StudioException.of( + StudioError.DOCUMENT_VALIDATION_FAILED, "a published record needs a public path"); + } + return publicPath.substring(publicPath.lastIndexOf('/') + 1); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private static String sha256(String value) { + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 must be available on every supported JVM", e); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/PublicationRowMappers.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/PublicationRowMappers.java new file mode 100644 index 0000000..97a3851 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/publication/PublicationRowMappers.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication; + +import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView; +import dev.caskeleton.application.techlog.studio.model.PublicationEventView; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +/** {@code publication_event} 행 매핑. writer 와 조회 어댑터가 같은 모양을 쓰도록 한 곳에 둔다. */ +final class PublicationRowMappers { + + private PublicationRowMappers() {} + + static PublicationEventView mapEvent(ResultSet rs, int rowNum) throws SQLException { + return new PublicationEventView( + rs.getObject("publication_event_id", UUID.class), + rs.getObject("publication_id", UUID.class), + rs.getObject("source_id", UUID.class), + PublicationEventTypeView.valueOf(rs.getString("event_type")), + rs.getTimestamp("occurred_at").toInstant(), + rs.getLong("published_version"), + rs.getObject("source_published_event_id", UUID.class), + rs.getBoolean("snapshot_available")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/DocumentWorkingCopyStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/DocumentWorkingCopyStore.java new file mode 100644 index 0000000..e85d4a6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/DocumentWorkingCopyStore.java @@ -0,0 +1,255 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy; + +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn; + +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.RelationView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * {@code CASE} / {@code REFERENCE} 편집본. 둘 다 {@code document} 루트 + 유형별 detail 테이블이다 (ADR-003, 설계 + * 07장). + */ +final class DocumentWorkingCopyStore { + + private final JdbcClient jdbcClient; + private final StudioRelationStore relations; + private final StudioJson json; + private final Supplier idGenerator; + private final ProjectLinkStore projectLinks; + + DocumentWorkingCopyStore( + JdbcClient jdbcClient, + StudioRelationStore relations, + StudioJson json, + Supplier idGenerator, + ProjectLinkStore projectLinks) { + this.jdbcClient = jdbcClient; + this.relations = relations; + this.json = json; + this.idGenerator = idGenerator; + this.projectLinks = projectLinks; + } + + Optional find(RecordKind kind, UUID id) { + return jdbcClient + .sql( + "SELECT d.id, d.version, d.updated_at, d.title, d.slug, d.summary, d.primary_topic_id," + + " d.body_markdown, d.last_verified_at," + + " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction," + + " r.scope_summary, r.rules, r.applies_to, r.excluded_scope, r.examples" + + " FROM document d" + + " LEFT JOIN case_detail c ON c.document_id = d.id" + + " LEFT JOIN reference_detail r ON r.document_id = d.id" + + " WHERE d.id = :id AND d.document_type = :type") + .param("id", id) + .param("type", kind.name()) + .query( + (rs, rowNum) -> { + UUID documentId = rs.getObject("id", UUID.class); + WorkingCopyBaseInput base = + new WorkingCopyBaseInput( + kind, + rs.getString("title"), + slugFromColumn(rs.getString("slug")), + orEmpty(rs.getString("summary")), + rs.getObject("primary_topic_id", UUID.class), + projectLinks.findPrimaryProjectForDocument(documentId).orElse(null), + relations.findBySource(kind, documentId)); + long version = rs.getLong("version"); + var updatedAt = instant(rs, "updated_at"); + if (kind == RecordKind.CASE) { + return (WorkingCopyView) + new WorkingCopyView.CaseWorkingCopyView( + documentId, + version, + updatedAt, + base, + orEmpty(rs.getString("problem_summary")), + orEmpty(rs.getString("conclusion_summary")), + orEmpty(rs.getString("environment")), + orEmpty(rs.getString("reproduction")), + dateFromTimestamp(rs, "last_verified_at"), + orEmpty(rs.getString("body_markdown"))); + } + return (WorkingCopyView) + new WorkingCopyView.ReferenceWorkingCopyView( + documentId, + version, + updatedAt, + base, + orEmpty(rs.getString("scope_summary")), + json.rulesFromJson(rs.getString("rules")), + json.orderedTextFromJson(rs.getString("applies_to")), + json.orderedTextFromJson(rs.getString("excluded_scope")), + json.orderedTextFromJson(rs.getString("examples")), + dateFromTimestamp(rs, "last_verified_at")); + }) + .optional(); + } + + UUID create(WorkingCopyInputView input, String principal) { + RecordKind kind = input.kind(); + UUID id = idGenerator.get(); + WorkingCopyBaseInput base = input.base(); + + jdbcClient + .sql( + "INSERT INTO document (id, document_type, slug, title, summary, body_markdown," + + " primary_topic_id, last_verified_at, version, created_by, updated_by)" + + " VALUES (:id, :type, :slug, :title, :summary, :body, :topicId, :verifiedAt," + // 계약의 WorkingCopyBase.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면 + // 생성 직후 응답이 계약을 위반한다. + + " 1, :principal, :principal)") + .param("id", id) + .param("type", kind.name()) + .param("slug", slugToColumn(base.slug())) + .param("title", orEmpty(base.title())) + .param("summary", orEmpty(base.summary())) + .param("body", bodyMarkdownOf(input)) + .param("topicId", base.topicId()) + .param("verifiedAt", dateToTimestamp(verifiedOnOf(input))) + .param("principal", principal) + .update(); + + insertDetail(id, input); + relations.replace(kind, id, base.relations()); + projectLinks.setPrimaryProjectForDocument(id, base.projectId()); + return id; + } + + /** + * 낙관적 잠금 저장. + * + * @return 갱신된 행이 없으면(= {@code expectedVersion} 불일치) {@code false} + */ + boolean save(UUID id, long expectedVersion, WorkingCopyInputView input, String principal) { + RecordKind kind = input.kind(); + WorkingCopyBaseInput base = input.base(); + int updated = + jdbcClient + .sql( + "UPDATE document SET slug = :slug, title = :title, summary = :summary," + + " body_markdown = :body, primary_topic_id = :topicId," + + " last_verified_at = :verifiedAt, version = version + 1," + + " updated_at = now(), updated_by = :principal" + + " WHERE id = :id AND document_type = :type AND version = :expectedVersion") + .param("slug", slugToColumn(base.slug())) + .param("title", orEmpty(base.title())) + .param("summary", orEmpty(base.summary())) + .param("body", bodyMarkdownOf(input)) + .param("topicId", base.topicId()) + .param("verifiedAt", dateToTimestamp(verifiedOnOf(input))) + .param("principal", principal) + .param("id", id) + .param("type", kind.name()) + .param("expectedVersion", expectedVersion) + .update(); + if (updated == 0) { + return false; + } + updateDetail(id, input); + relations.replace(kind, id, base.relations()); + projectLinks.setPrimaryProjectForDocument(id, base.projectId()); + return true; + } + + private void insertDetail(UUID id, WorkingCopyInputView input) { + switch (input) { + case WorkingCopyInputView.CaseInputView caseInput -> + jdbcClient + .sql( + "INSERT INTO case_detail (document_id, document_type, problem_summary," + + " conclusion_summary, environment, reproduction)" + + " VALUES (:id, 'CASE', :problem, :conclusion, :environment, :reproduction)") + .param("id", id) + .param("problem", orEmpty(caseInput.problem())) + .param("conclusion", orEmpty(caseInput.conclusion())) + .param("environment", orEmpty(caseInput.environment())) + .param("reproduction", orEmpty(caseInput.reproduction())) + .update(); + case WorkingCopyInputView.ReferenceInputView reference -> + jdbcClient + .sql( + "INSERT INTO reference_detail (document_id, document_type, scope_summary," + + " rules, applies_to, excluded_scope, examples)" + + " VALUES (:id, 'REFERENCE', :purpose, CAST(:rules AS jsonb)," + + " CAST(:applyWhen AS jsonb), CAST(:exceptions AS jsonb)," + + " CAST(:examples AS jsonb))") + .param("id", id) + .param("purpose", orEmpty(reference.purpose())) + .param("rules", json.rulesToJson(reference.rules())) + .param("applyWhen", json.orderedTextToJson(reference.applyWhen())) + .param("exceptions", json.orderedTextToJson(reference.exceptions())) + .param("examples", json.orderedTextToJson(reference.examples())) + .update(); + default -> + throw new IllegalArgumentException("not a document-backed working copy: " + input.kind()); + } + } + + private void updateDetail(UUID id, WorkingCopyInputView input) { + switch (input) { + case WorkingCopyInputView.CaseInputView caseInput -> + jdbcClient + .sql( + "UPDATE case_detail SET problem_summary = :problem," + + " conclusion_summary = :conclusion, environment = :environment," + + " reproduction = :reproduction WHERE document_id = :id") + .param("id", id) + .param("problem", orEmpty(caseInput.problem())) + .param("conclusion", orEmpty(caseInput.conclusion())) + .param("environment", orEmpty(caseInput.environment())) + .param("reproduction", orEmpty(caseInput.reproduction())) + .update(); + case WorkingCopyInputView.ReferenceInputView reference -> + jdbcClient + .sql( + "UPDATE reference_detail SET scope_summary = :purpose," + + " rules = CAST(:rules AS jsonb), applies_to = CAST(:applyWhen AS jsonb)," + + " excluded_scope = CAST(:exceptions AS jsonb)," + + " examples = CAST(:examples AS jsonb) WHERE document_id = :id") + .param("id", id) + .param("purpose", orEmpty(reference.purpose())) + .param("rules", json.rulesToJson(reference.rules())) + .param("applyWhen", json.orderedTextToJson(reference.applyWhen())) + .param("exceptions", json.orderedTextToJson(reference.exceptions())) + .param("examples", json.orderedTextToJson(reference.examples())) + .update(); + default -> + throw new IllegalArgumentException("not a document-backed working copy: " + input.kind()); + } + } + + /** {@code REFERENCE}는 계약에 본문이 없다 — 컬럼이 NOT NULL이므로 빈 문자열을 유지한다. */ + private static String bodyMarkdownOf(WorkingCopyInputView input) { + return input instanceof WorkingCopyInputView.CaseInputView caseInput + ? orEmpty(caseInput.bodyMarkdown()) + : ""; + } + + /** 계약의 {@code lastVerifiedOn}(CASE) / {@code verifiedOn}(REFERENCE)은 같은 컬럼에 담긴다. */ + private static java.time.LocalDate verifiedOnOf(WorkingCopyInputView input) { + return switch (input) { + case WorkingCopyInputView.CaseInputView caseInput -> caseInput.lastVerifiedOn(); + case WorkingCopyInputView.ReferenceInputView reference -> reference.verifiedOn(); + default -> null; + }; + } + + List relationsOf(RecordKind kind, UUID id) { + return relations.findBySource(kind, id); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/JdbcWorkingCopyRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/JdbcWorkingCopyRepositoryAdapter.java new file mode 100644 index 0000000..3f97925 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/JdbcWorkingCopyRepositoryAdapter.java @@ -0,0 +1,128 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +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 dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** + * 계약의 통합 {@code WorkingCopy}를 네 source aggregate로 dispatch한다. + * + *

공통 CRUD repository가 아니다 — {@code kind}마다 소유 테이블이 다르고, 그 구분을 유지하는 것이 ADR-003의 결정이다. 여기서 하는 일은 + * "어느 저장소로 보낼지" 뿐이다. + * + *

JPA 엔티티가 아니라 {@code JdbcClient}를 쓴다. 이 네 aggregate는 Studio 저장 경로에서만 쓰이고, 낙관적 잠금은 {@code UPDATE + * ... WHERE version = :expectedVersion}의 갱신 행 수로 정확히 같은 의미를 얻는다 — 여덟 개 넘는 테이블에 엔티티와 매핑을 세우는 비용에 + * 상응하는 이득이 없다. 포트 계약이 같으므로 나중에 JPA 가 필요해지면 이 어댑터만 바뀐다. + */ +@Repository +public class JdbcWorkingCopyRepositoryAdapter implements WorkingCopyRepositoryPort { + + private final JdbcClient jdbcClient; + private final DocumentWorkingCopyStore documents; + private final QuestionWorkingCopyStore questions; + private final ProjectDecisionWorkingCopyStore decisions; + + /** + * 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게 + * 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다. + */ + @Autowired + public JdbcWorkingCopyRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this(jdbcClient, objectMapper, UUID::randomUUID); + } + + JdbcWorkingCopyRepositoryAdapter( + JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier idGenerator) { + this.jdbcClient = jdbcClient; + StudioJson json = new StudioJson(objectMapper); + StudioRelationStore relations = new StudioRelationStore(jdbcClient, idGenerator); + ProjectLinkStore projectLinks = new ProjectLinkStore(jdbcClient); + this.documents = + new DocumentWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks); + this.questions = + new QuestionWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks); + this.decisions = new ProjectDecisionWorkingCopyStore(jdbcClient, relations, json, idGenerator); + } + + /** + * 계약상 {@code documentId}는 source aggregate id 그대로다 — 어느 테이블에 있는지 먼저 찾아야 한다 (spec §7.2 + * StudioDocumentLocator). 세 테이블을 UNION 으로 한 번에 본다. + */ + @Override + public Optional findKind(UUID documentId) { + if (documentId == null) { + return Optional.empty(); + } + return jdbcClient + .sql( + "SELECT document_type AS kind FROM document WHERE id = :id" + + " UNION ALL SELECT 'QUESTION' FROM open_question WHERE id = :id" + + " UNION ALL SELECT 'PROJECT_DECISION' FROM project_decision WHERE id = :id") + .param("id", documentId) + .query(String.class) + .optional() + .map(RecordKind::valueOf); + } + + @Override + public Optional find(UUID documentId) { + return findKind(documentId).flatMap(kind -> load(kind, documentId)); + } + + @Override + public WorkingCopyView create(WorkingCopyInputView input, String principal) { + UUID id = + switch (input) { + case WorkingCopyInputView.CaseInputView ignored -> documents.create(input, principal); + case WorkingCopyInputView.ReferenceInputView ignored -> + documents.create(input, principal); + case WorkingCopyInputView.QuestionInputView question -> + questions.create(question, principal); + case WorkingCopyInputView.ProjectDecisionInputView decision -> + decisions.create(decision, principal); + }; + return load(input.kind(), id) + .orElseThrow( + () -> + StudioException.of( + StudioError.STUDIO_UNAVAILABLE, + "the working copy " + + id + + " could not be read back right after it was created")); + } + + @Override + public Optional save( + UUID documentId, long expectedVersion, WorkingCopyInputView input, String principal) { + boolean saved = + switch (input) { + case WorkingCopyInputView.CaseInputView ignored -> + documents.save(documentId, expectedVersion, input, principal); + case WorkingCopyInputView.ReferenceInputView ignored -> + documents.save(documentId, expectedVersion, input, principal); + case WorkingCopyInputView.QuestionInputView question -> + questions.save(documentId, expectedVersion, question, principal); + case WorkingCopyInputView.ProjectDecisionInputView decision -> + decisions.save(documentId, expectedVersion, decision, principal); + }; + return saved ? load(input.kind(), documentId) : Optional.empty(); + } + + private Optional load(RecordKind kind, UUID id) { + return switch (kind) { + case CASE, REFERENCE -> documents.find(kind, id); + case QUESTION -> questions.find(id); + case PROJECT_DECISION -> decisions.find(id); + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectDecisionWorkingCopyStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectDecisionWorkingCopyStore.java new file mode 100644 index 0000000..0721f1b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectDecisionWorkingCopyStore.java @@ -0,0 +1,180 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy; + +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.DecisionStatusView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * {@code PROJECT_DECISION} 편집본 ({@code project_decision}). + * + *

계약의 {@code ADOPTED}는 Domain의 {@code ACCEPTED}다(ADR-003) — UI 용어 때문에 Domain enum을 바꾸지 않고 여기서 + * 변환한다. {@code supersede}/{@code reject}는 secondary management 계약이 소유하므로 이 저장 경로가 그 두 상태를 만들지도, + * 건드리지도 않는다. + */ +final class ProjectDecisionWorkingCopyStore { + + private final JdbcClient jdbcClient; + private final StudioRelationStore relations; + private final StudioJson json; + private final Supplier idGenerator; + + ProjectDecisionWorkingCopyStore( + JdbcClient jdbcClient, + StudioRelationStore relations, + StudioJson json, + Supplier idGenerator) { + this.jdbcClient = jdbcClient; + this.relations = relations; + this.json = json; + this.idGenerator = idGenerator; + } + + Optional find(UUID id) { + return jdbcClient + .sql( + "SELECT id, version, updated_at, title, slug, summary, primary_topic_id, project_id," + + " decision_status, decided_at, statement, rationale_markdown, consequences" + + " FROM project_decision WHERE id = :id") + .param("id", id) + .query( + (rs, rowNum) -> { + UUID decisionId = rs.getObject("id", UUID.class); + WorkingCopyBaseInput base = + new WorkingCopyBaseInput( + RecordKind.PROJECT_DECISION, + orEmpty(rs.getString("title")), + slugFromColumn(rs.getString("slug")), + orEmpty(rs.getString("summary")), + rs.getObject("primary_topic_id", UUID.class), + rs.getObject("project_id", UUID.class), + relations.findBySource(RecordKind.PROJECT_DECISION, decisionId)); + return (WorkingCopyView) + new WorkingCopyView.ProjectDecisionWorkingCopyView( + decisionId, + rs.getLong("version"), + instant(rs, "updated_at"), + base, + toContractStatus(rs.getString("decision_status")), + dateFromTimestamp(rs, "decided_at"), + orEmpty(rs.getString("statement")), + orEmpty(rs.getString("rationale_markdown")), + json.orderedTextFromJson(rs.getString("consequences"))); + }) + .optional(); + } + + UUID create(WorkingCopyInputView.ProjectDecisionInputView input, String principal) { + requireDecidedOnWhenAdopted(input); + UUID id = idGenerator.get(); + WorkingCopyBaseInput base = input.base(); + + jdbcClient + .sql( + "INSERT INTO project_decision (id, project_id, title, slug, summary," + + " primary_topic_id, statement, rationale_markdown, consequences," + + " decision_status, decided_at, version, created_by, updated_by)" + + " VALUES (:id, :projectId, :title, :slug, :summary, :topicId, :statement," + + " :rationale, CAST(:consequences AS jsonb), :status, :decidedAt, 1," + + " :principal, :principal)") + .param("id", id) + .param("projectId", base.projectId()) + .param("title", orEmpty(base.title())) + .param("slug", slugToColumn(base.slug())) + .param("summary", orEmpty(base.summary())) + .param("topicId", base.topicId()) + .param("statement", orEmpty(input.statement())) + .param("rationale", orEmpty(input.rationale())) + .param("consequences", json.orderedTextToJson(input.consequences())) + .param("status", toDomainStatus(input.decisionStatus())) + .param("decidedAt", dateToTimestamp(input.decidedOn())) + .param("principal", principal) + .update(); + + relations.replace(RecordKind.PROJECT_DECISION, id, base.relations()); + return id; + } + + boolean save( + UUID id, + long expectedVersion, + WorkingCopyInputView.ProjectDecisionInputView input, + String principal) { + requireDecidedOnWhenAdopted(input); + WorkingCopyBaseInput base = input.base(); + int updated = + jdbcClient + .sql( + "UPDATE project_decision SET project_id = :projectId, title = :title," + + " slug = :slug, summary = :summary, primary_topic_id = :topicId," + + " statement = :statement, rationale_markdown = :rationale," + + " consequences = CAST(:consequences AS jsonb)," + // SUPERSEDED/REJECTED 는 secondary management 계약이 소유한다. Studio 저장이 + // 그 상태를 PROPOSED/ACCEPTED 로 되돌리면 그쪽 lifecycle 이 조용히 무효화된다. + + " decision_status = CASE WHEN decision_status IN ('PROPOSED', 'ACCEPTED')" + + " THEN :status ELSE decision_status END," + + " decided_at = :decidedAt," + + " version = version + 1, updated_at = now(), updated_by = :principal" + + " WHERE id = :id AND version = :expectedVersion") + .param("projectId", base.projectId()) + .param("title", orEmpty(base.title())) + .param("slug", slugToColumn(base.slug())) + .param("summary", orEmpty(base.summary())) + .param("topicId", base.topicId()) + .param("statement", orEmpty(input.statement())) + .param("rationale", orEmpty(input.rationale())) + .param("consequences", json.orderedTextToJson(input.consequences())) + .param("status", toDomainStatus(input.decisionStatus())) + .param("decidedAt", dateToTimestamp(input.decidedOn())) + .param("principal", principal) + .param("id", id) + .param("expectedVersion", expectedVersion) + .update(); + if (updated == 0) { + return false; + } + relations.replace(RecordKind.PROJECT_DECISION, id, base.relations()); + return true; + } + + /** + * {@code ck_project_decision_status_fields}는 {@code ACCEPTED}에 {@code decided_at}을 요구한다. 값을 지어내 + * 채우면 사용자가 정하지 않은 날짜가 기록되고, 그냥 보내면 DB 제약 위반이 500으로 나간다 — 무엇이 빠졌는지 알려주고 거절한다. + */ + private static void requireDecidedOnWhenAdopted( + WorkingCopyInputView.ProjectDecisionInputView input) { + if (input.decisionStatus() == DecisionStatusView.ADOPTED && input.decidedOn() == null) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.decidedOn is required when decisionStatus is ADOPTED"); + } + } + + private static String toDomainStatus(DecisionStatusView status) { + return status == DecisionStatusView.ADOPTED ? "ACCEPTED" : "PROPOSED"; + } + + private static DecisionStatusView toContractStatus(String domainStatus) { + return switch (domainStatus) { + case "ACCEPTED" -> DecisionStatusView.ADOPTED; + case "PROPOSED" -> DecisionStatusView.PROPOSED; + // SUPERSEDED / REJECTED 는 계약의 두 값 어디에도 대응하지 않는다. 계약은 null 을 허용하므로 + // 억지로 가장 가까운 값으로 접지 않고 "이 축약 view 로는 표현할 수 없음"을 null 로 알린다. + default -> null; + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectLinkStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectLinkStore.java new file mode 100644 index 0000000..5853d71 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/ProjectLinkStore.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * 계약의 단일 {@code projectId}를 설계 스키마의 링크 테이블({@code project_document_link} / {@code + * project_question_link})에 옮긴다. + * + *

링크 테이블은 한 문서가 여러 프로젝트에 붙는 것을 허용하지만 Studio 편집기는 프로젝트 하나만 다룬다. Studio가 소유하는 것은 {@code PRIMARY} + * 링크 하나뿐이며, {@code RELATED} 링크는 건드리지 않는다 — 그건 다른 화면의 데이터이고 Studio 저장이 지워도 되는 것이 아니다. + */ +final class ProjectLinkStore { + + private static final String PRIMARY = "PRIMARY"; + + private final JdbcClient jdbcClient; + + ProjectLinkStore(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + Optional findPrimaryProjectForDocument(UUID documentId) { + return findPrimary("project_document_link", "document_id", documentId); + } + + Optional findPrimaryProjectForQuestion(UUID questionId) { + return findPrimary("project_question_link", "question_id", questionId); + } + + void setPrimaryProjectForDocument(UUID documentId, UUID projectId) { + setPrimary("project_document_link", "document_id", documentId, projectId); + } + + void setPrimaryProjectForQuestion(UUID questionId, UUID projectId) { + setPrimary("project_question_link", "question_id", questionId, projectId); + } + + private Optional findPrimary(String table, String column, UUID id) { + return jdbcClient + .sql( + "SELECT project_id FROM " + + table + + " WHERE " + + column + + " = :id AND relation_type = :type") + .param("id", id) + .param("type", PRIMARY) + .query(UUID.class) + .optional(); + } + + private void setPrimary(String table, String column, UUID id, UUID projectId) { + jdbcClient + .sql("DELETE FROM " + table + " WHERE " + column + " = :id AND relation_type = :type") + .param("id", id) + .param("type", PRIMARY) + .update(); + if (projectId == null) { + return; + } + jdbcClient + .sql( + "INSERT INTO " + + table + + " (project_id, " + + column + + ", relation_type)" + + " VALUES (:projectId, :id, :type)" + // PK 는 (project_id, ) 이라 같은 쌍이 RELATED 로 이미 있으면 INSERT 가 깨진다. + // Studio 가 소유하는 것은 PRIMARY 이므로 그 경우 관계 종류를 올려준다. + + " ON CONFLICT (project_id, " + + column + + ") DO UPDATE SET relation_type = :type") + .param("projectId", projectId) + .param("id", id) + .param("type", PRIMARY) + .update(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/QuestionWorkingCopyStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/QuestionWorkingCopyStore.java new file mode 100644 index 0000000..3bcb19a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/QuestionWorkingCopyStore.java @@ -0,0 +1,296 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy; + +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn; +import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn; + +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.model.OrderedTextView; +import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView; +import dev.caskeleton.application.techlog.studio.model.QuestionStatusView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * {@code QUESTION} 편집본 ({@code open_question} + {@code question_point}). + * + *

계약의 {@code questionStatus}는 Domain lifecycle의 축약 view다(ADR-003) — {@code OPEN}을 받았다고 Domain의 + * {@code INVESTIGATING}/{@code PAUSED}를 덮어쓰지 않는다. 그렇게 하면 조사 이력이 사라진다. + */ +final class QuestionWorkingCopyStore { + + /** + * 계약의 {@code QuestionResolution}에는 resolution_type이 없는데 {@code + * ck_question_resolution_consistency}는 RESOLVED에 그 값을 요구한다. Studio 편집으로 해결 처리된 질문은 "판단을 내렸다"로 + * 기록한다 — 나머지 세 유형(가정 기각/질문 재정의/무의미해짐)은 secondary management 계약의 명시적 action이 소유한다. + */ + private static final String DEFAULT_RESOLUTION_TYPE = "DECISION_MADE"; + + private final JdbcClient jdbcClient; + private final StudioRelationStore relations; + private final StudioJson json; + private final Supplier idGenerator; + private final ProjectLinkStore projectLinks; + + QuestionWorkingCopyStore( + JdbcClient jdbcClient, + StudioRelationStore relations, + StudioJson json, + Supplier idGenerator, + ProjectLinkStore projectLinks) { + this.jdbcClient = jdbcClient; + this.relations = relations; + this.json = json; + this.idGenerator = idGenerator; + this.projectLinks = projectLinks; + } + + Optional find(UUID id) { + return jdbcClient + .sql( + "SELECT id, version, updated_at, question, slug, summary, primary_topic_id," + + " question_status, next_verification, options, resolution_summary," + + " resolution_evidence_target_id, resolution_link_label" + + " FROM open_question WHERE id = :id") + .param("id", id) + .query( + (rs, rowNum) -> { + UUID questionId = rs.getObject("id", UUID.class); + WorkingCopyBaseInput base = + new WorkingCopyBaseInput( + RecordKind.QUESTION, + rs.getString("question"), + slugFromColumn(rs.getString("slug")), + orEmpty(rs.getString("summary")), + rs.getObject("primary_topic_id", UUID.class), + projectLinks.findPrimaryProjectForQuestion(questionId).orElse(null), + relations.findBySource(RecordKind.QUESTION, questionId)); + String domainStatus = rs.getString("question_status"); + return (WorkingCopyView) + new WorkingCopyView.QuestionWorkingCopyView( + questionId, + rs.getLong("version"), + instant(rs, "updated_at"), + base, + toContractStatus(domainStatus), + points(questionId, "FACT"), + points(questionId, "ASSUMPTION"), + points(questionId, "UNKNOWN"), + points(questionId, "CONSTRAINT"), + json.optionsFromJson(rs.getString("options")), + orEmpty(rs.getString("next_verification")), + resolutionOf( + domainStatus, + rs.getString("resolution_summary"), + rs.getObject("resolution_evidence_target_id", UUID.class), + rs.getString("resolution_link_label"))); + }) + .optional(); + } + + UUID create(WorkingCopyInputView.QuestionInputView input, String principal) { + UUID id = idGenerator.get(); + WorkingCopyBaseInput base = input.base(); + boolean resolved = input.questionStatus() == QuestionStatusView.RESOLVED; + requireUsableResolution(input, resolved); + + jdbcClient + .sql( + "INSERT INTO open_question (id, slug, question, summary, primary_topic_id," + + " next_verification, options, question_status, resolution_type," + + " resolution_summary, resolution_evidence_target_id, resolution_link_label," + + " resolved_at, version, created_by, updated_by)" + + " VALUES (:id, :slug, :question, :summary, :topicId, :nextValidation," + + " CAST(:options AS jsonb), :status, :resolutionType, :resolutionSummary," + + " :evidenceTargetId, :linkLabel," + // 해결 시각은 애플리케이션 시계가 아니라 DB 시계로 찍는다 — 같은 트랜잭션의 + // 다른 타임스탬프(created_at/updated_at)와 기준이 같아야 순서가 뒤집히지 않는다. + + " CASE WHEN :resolved THEN now() ELSE NULL END, 1, :principal, :principal)") + .param("id", id) + .param("slug", slugToColumn(base.slug())) + .param("question", orEmpty(base.title())) + .param("summary", orEmpty(base.summary())) + .param("topicId", base.topicId()) + .param("nextValidation", orEmpty(input.nextValidation())) + .param("options", json.optionsToJson(input.options())) + .param("status", resolved ? "RESOLVED" : "OPEN") + .param("resolutionType", resolved ? DEFAULT_RESOLUTION_TYPE : null) + .param("resolutionSummary", resolved ? input.resolution().summary() : null) + .param("evidenceTargetId", resolved ? input.resolution().evidenceTargetId() : null) + .param("linkLabel", resolved ? orEmpty(input.resolution().linkLabel()) : "") + .param("resolved", resolved) + .param("principal", principal) + .update(); + + replacePoints(id, input); + relations.replace(RecordKind.QUESTION, id, base.relations()); + projectLinks.setPrimaryProjectForQuestion(id, base.projectId()); + return id; + } + + boolean save( + UUID id, + long expectedVersion, + WorkingCopyInputView.QuestionInputView input, + String principal) { + WorkingCopyBaseInput base = input.base(); + String storedStatus = + jdbcClient + .sql("SELECT question_status FROM open_question WHERE id = :id") + .param("id", id) + .query(String.class) + .optional() + .orElse("OPEN"); + + boolean resolveNow = + input.questionStatus() == QuestionStatusView.RESOLVED && !"RESOLVED".equals(storedStatus); + requireUsableResolution(input, input.questionStatus() == QuestionStatusView.RESOLVED); + + // ADR-003: 축약 상태가 Domain lifecycle 을 덮어쓰지 않는다. OPEN 계열 안에서의 값 변화는 무시하고, + // 이미 RESOLVED 인 질문을 OPEN 으로 되돌리는 것도 저장이 할 일이 아니다 — reopen 은 secondary + // management 계약의 명시적 action 이다. + String nextStatus = resolveNow ? "RESOLVED" : storedStatus; + boolean resolvedAfter = "RESOLVED".equals(nextStatus); + + int updated = + jdbcClient + .sql( + "UPDATE open_question SET slug = :slug, question = :question, summary = :summary," + + " primary_topic_id = :topicId, next_verification = :nextValidation," + + " options = CAST(:options AS jsonb), question_status = :status," + + " resolution_type = CASE WHEN :resolved THEN" + + " COALESCE(resolution_type, :resolutionType) ELSE resolution_type END," + + " resolution_summary = CASE WHEN :resolved THEN :resolutionSummary" + + " ELSE resolution_summary END," + + " resolution_evidence_target_id = CASE WHEN :resolved THEN :evidenceTargetId" + + " ELSE resolution_evidence_target_id END," + + " resolution_link_label = CASE WHEN :resolved THEN :linkLabel" + + " ELSE resolution_link_label END," + + " resolved_at = CASE WHEN :resolved THEN COALESCE(resolved_at, now())" + + " ELSE resolved_at END," + + " version = version + 1, updated_at = now(), updated_by = :principal" + + " WHERE id = :id AND version = :expectedVersion") + .param("slug", slugToColumn(base.slug())) + .param("question", orEmpty(base.title())) + .param("summary", orEmpty(base.summary())) + .param("topicId", base.topicId()) + .param("nextValidation", orEmpty(input.nextValidation())) + .param("options", json.optionsToJson(input.options())) + .param("status", nextStatus) + .param("resolved", resolvedAfter) + .param("resolutionType", DEFAULT_RESOLUTION_TYPE) + .param("resolutionSummary", resolvedAfter ? input.resolution().summary() : null) + .param("evidenceTargetId", resolvedAfter ? input.resolution().evidenceTargetId() : null) + .param("linkLabel", resolvedAfter ? orEmpty(input.resolution().linkLabel()) : "") + .param("principal", principal) + .param("id", id) + .param("expectedVersion", expectedVersion) + .update(); + if (updated == 0) { + return false; + } + replacePoints(id, input); + relations.replace(RecordKind.QUESTION, id, base.relations()); + projectLinks.setPrimaryProjectForQuestion(id, base.projectId()); + return true; + } + + /** + * {@code RESOLVED}는 {@code ck_question_resolution_consistency}가 요약을 요구한다. 요약 없이 해결 처리된 질문은 "왜 + * 끝났는지 모르는 종료"라 저장을 거부한다 — 여기서 거부하지 않으면 DB 제약 위반이 500으로 나가 클라이언트가 무엇이 잘못됐는지 알 수 없다. + */ + private static void requireUsableResolution( + WorkingCopyInputView.QuestionInputView input, boolean resolved) { + if (!resolved) { + return; + } + QuestionResolutionView resolution = input.resolution(); + if (resolution == null || resolution.summary() == null || resolution.summary().isBlank()) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.resolution.summary is required when questionStatus is RESOLVED"); + } + } + + private static QuestionStatusView toContractStatus(String domainStatus) { + return "RESOLVED".equals(domainStatus) ? QuestionStatusView.RESOLVED : QuestionStatusView.OPEN; + } + + private static QuestionResolutionView resolutionOf( + String domainStatus, String summary, UUID evidenceTargetId, String linkLabel) { + if (!"RESOLVED".equals(domainStatus)) { + return null; + } + return new QuestionResolutionView(orEmpty(summary), evidenceTargetId, orEmpty(linkLabel)); + } + + private List points(UUID questionId, String pointKind) { + return jdbcClient + .sql( + "SELECT id, content, display_order FROM question_point" + + " WHERE question_id = :id AND point_kind = :kind ORDER BY display_order") + .param("id", questionId) + .param("kind", pointKind) + .query( + (rs, rowNum) -> + new OrderedTextView( + rs.getObject("id", UUID.class), + rs.getString("content"), + rs.getInt("display_order"))) + .list(); + } + + private void replacePoints(UUID questionId, WorkingCopyInputView.QuestionInputView input) { + // 이 질문이 지금 소유한 point id. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다 — 남의 질문 것을 + // 그대로 쓰면 PK 가 충돌하고, 매번 새로 부여하면 편집기의 줄 식별자가 저장마다 바뀐다. + Set owned = + Set.copyOf( + jdbcClient + .sql("SELECT id FROM question_point WHERE question_id = :id") + .param("id", questionId) + .query(UUID.class) + .list()); + jdbcClient + .sql("DELETE FROM question_point WHERE question_id = :id") + .param("id", questionId) + .update(); + insertPoints(questionId, "FACT", input.facts(), owned); + insertPoints(questionId, "ASSUMPTION", input.assumptions(), owned); + insertPoints(questionId, "UNKNOWN", input.unknowns(), owned); + insertPoints(questionId, "CONSTRAINT", input.constraints(), owned); + } + + private void insertPoints( + UUID questionId, String pointKind, List items, Set owned) { + int order = 0; + for (OrderedTextView item : items) { + // content 는 CHECK (length(trim(content)) > 0) 다. 빈 줄은 편집 중 흔한 상태이므로 거부하는 대신 + // 저장하지 않는다 — 계약도 OrderedText.text 에 minLength 1 을 두어 빈 항목을 보내지 말라고 한다. + if (item.text() == null || item.text().isBlank()) { + continue; + } + jdbcClient + .sql( + "INSERT INTO question_point (id, question_id, point_kind, content, display_order)" + + " VALUES (:id, :questionId, :kind, :content, :order)") + .param( + "id", + (item.id() != null && owned.contains(item.id())) ? item.id() : idGenerator.get()) + .param("questionId", questionId) + .param("kind", pointKind) + .param("content", item.text()) + .param("order", order++) + .update(); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioJson.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioJson.java new file mode 100644 index 0000000..f0faab3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioJson.java @@ -0,0 +1,131 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy; + +import dev.caskeleton.application.techlog.studio.model.OrderedTextView; +import dev.caskeleton.application.techlog.studio.model.QuestionOptionView; +import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView; +import dev.caskeleton.shared.error.MappingException; +import java.util.List; +import java.util.UUID; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * 설계 스키마가 jsonb 배열로 정한 순서 있는 항목들을 읽고 쓴다. + * + *

Jackson의 자동 POJO 바인딩을 쓰지 않고 필드를 직접 읽고 쓴다. 이 값들은 DB에 영속되는 형태라 application record의 필드 이름이 + * 바뀌면 이미 저장된 행을 읽지 못하게 된다 — 그 결합을 만들지 않으려고 컬럼 안의 key 이름을 여기서 명시적으로 고정한다. + */ +final class StudioJson { + + private final ObjectMapper mapper; + + StudioJson(ObjectMapper mapper) { + this.mapper = mapper; + } + + String orderedTextToJson(List items) { + ArrayNode array = mapper.createArrayNode(); + for (OrderedTextView item : items) { + ObjectNode node = array.addObject(); + node.put("id", item.id() == null ? null : item.id().toString()); + node.put("text", item.text()); + node.put("order", item.order()); + } + return write(array); + } + + List orderedTextFromJson(String json) { + return read(json) + .valueStream() + .map( + node -> + new OrderedTextView( + uuid(node, "id"), text(node, "text"), node.path("order").asInt(0))) + .sorted(java.util.Comparator.comparingInt(OrderedTextView::order)) + .toList(); + } + + String rulesToJson(List items) { + ArrayNode array = mapper.createArrayNode(); + for (ReferenceRuleView item : items) { + ObjectNode node = array.addObject(); + node.put("id", item.id() == null ? null : item.id().toString()); + node.put("title", item.title()); + node.put("body", item.body()); + node.put("order", item.order()); + } + return write(array); + } + + List rulesFromJson(String json) { + return read(json) + .valueStream() + .map( + node -> + new ReferenceRuleView( + uuid(node, "id"), + text(node, "title"), + text(node, "body"), + node.path("order").asInt(0))) + .sorted(java.util.Comparator.comparingInt(ReferenceRuleView::order)) + .toList(); + } + + String optionsToJson(List items) { + ArrayNode array = mapper.createArrayNode(); + for (QuestionOptionView item : items) { + ObjectNode node = array.addObject(); + node.put("id", item.id() == null ? null : item.id().toString()); + node.put("title", item.title()); + node.put("description", item.description()); + node.put("order", item.order()); + } + return write(array); + } + + List optionsFromJson(String json) { + return read(json) + .valueStream() + .map( + node -> + new QuestionOptionView( + uuid(node, "id"), + text(node, "title"), + text(node, "description"), + node.path("order").asInt(0))) + .sorted(java.util.Comparator.comparingInt(QuestionOptionView::order)) + .toList(); + } + + private String write(ArrayNode array) { + try { + return mapper.writeValueAsString(array); + } catch (JacksonException e) { + throw new MappingException("failed to serialise a Studio jsonb column", e); + } + } + + private JsonNode read(String json) { + if (json == null || json.isBlank()) { + return mapper.createArrayNode(); + } + try { + JsonNode node = mapper.readTree(json); + return node.isArray() ? node : mapper.createArrayNode(); + } catch (JacksonException e) { + throw new MappingException("failed to read a Studio jsonb column", e); + } + } + + private static UUID uuid(JsonNode node, String field) { + String value = node.path(field).asString(null); + return (value == null || value.isBlank()) ? null : UUID.fromString(value); + } + + private static String text(JsonNode node, String field) { + return node.path(field).asString(""); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioRelationStore.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioRelationStore.java new file mode 100644 index 0000000..e379cb8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioRelationStore.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy; + +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.RelationView; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * 네 유형이 공유하는 편집용 관계({@code studio_relation})를 읽고 통째로 교체한다. + * + *

부분 갱신이 아니라 교체인 이유: 계약의 {@code relations}는 배열 전체가 편집 대상이고, 클라이언트가 보낸 배열이 곧 최종 상태다. 지운 줄을 알아내려고 + * diff를 뜨면 순서 재배열과 삭제를 구분하지 못한다. + */ +final class StudioRelationStore { + + private final JdbcClient jdbcClient; + private final Supplier idGenerator; + + StudioRelationStore(JdbcClient jdbcClient, Supplier idGenerator) { + this.jdbcClient = jdbcClient; + this.idGenerator = idGenerator; + } + + List findBySource(RecordKind kind, UUID sourceId) { + return jdbcClient + .sql( + "SELECT id, target_id, reason, display_order FROM studio_relation " + + "WHERE source_kind = :kind AND source_id = :sourceId ORDER BY display_order") + .param("kind", kind.name()) + .param("sourceId", sourceId) + .query( + (rs, rowNum) -> + new RelationView( + rs.getObject("id", UUID.class), + rs.getObject("target_id", UUID.class), + rs.getString("reason"), + rs.getInt("display_order"))) + .list(); + } + + void replace(RecordKind kind, UUID sourceId, List relations) { + // 이 source 가 지금 소유한 관계 id 집합. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다. + Set owned = + Set.copyOf( + jdbcClient + .sql( + "SELECT id FROM studio_relation " + + "WHERE source_kind = :kind AND source_id = :sourceId") + .param("kind", kind.name()) + .param("sourceId", sourceId) + .query(UUID.class) + .list()); + + jdbcClient + .sql("DELETE FROM studio_relation WHERE source_kind = :kind AND source_id = :sourceId") + .param("kind", kind.name()) + .param("sourceId", sourceId) + .update(); + + for (RelationView relation : relations) { + // 이 source 가 원래 갖고 있던 id 는 그대로 둔다 — 편집기가 줄을 식별하는 키라 매 저장마다 바뀌면 + // 화면의 줄이 통째로 갈아엎어진 것처럼 보인다. 그 밖의 id(남의 문서 것이거나 클라이언트가 지어낸 + // 것)는 신뢰하지 않고 새로 부여한다 — 그대로 쓰면 다른 문서의 관계 행과 PK 가 충돌한다. + UUID id = + (relation.id() != null && owned.contains(relation.id())) + ? relation.id() + : idGenerator.get(); + jdbcClient + .sql( + "INSERT INTO studio_relation " + + "(id, source_kind, source_id, target_id, reason, display_order) " + + "VALUES (:id, :kind, :sourceId, :targetId, :reason, :order)") + .param("id", id) + .param("kind", kind.name()) + .param("sourceId", sourceId) + .param("targetId", relation.targetId()) + .param("reason", relation.reason() == null ? "" : relation.reason()) + .param("order", relation.order()) + .update(); + } + } + + void deleteBySource(RecordKind kind, UUID sourceId) { + replace(kind, sourceId, List.of()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioSqlSupport.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioSqlSupport.java new file mode 100644 index 0000000..288d170 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/workingcopy/StudioSqlSupport.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; + +/** 계약 타입과 컬럼 타입 사이의 되풀이되는 변환. */ +final class StudioSqlSupport { + + private StudioSqlSupport() {} + + /** + * 계약의 빈 slug("아직 정하지 않음")를 NULL로 옮긴다. 빈 문자열을 그대로 넣으면 {@code ck_document_slug_non_blank} 계열 제약에 + * 걸리고, 무엇보다 여러 초안이 같은 빈 slug를 갖는 순간 slug UNIQUE 제약이 두 번째 초안을 거부한다. + */ + static String slugToColumn(String slug) { + return (slug == null || slug.isBlank()) ? null : slug; + } + + /** 반대 방향. 계약은 slug가 null이 아니라 빈 문자열이어야 한다고 정한다. */ + static String slugFromColumn(String slug) { + return slug == null ? "" : slug; + } + + static String orEmpty(String value) { + return value == null ? "" : value; + } + + /** + * 계약의 {@code format: date}를 {@code timestamptz} 컬럼에 담는다. 자정 UTC로 고정한다 — 저장 시각의 로컬 타임존을 쓰면 같은 날짜가 + * 서버 위치에 따라 다른 순간이 되고, 다시 읽을 때 하루가 밀린다. + */ + static Timestamp dateToTimestamp(LocalDate date) { + return date == null ? null : Timestamp.from(date.atStartOfDay(ZoneOffset.UTC).toInstant()); + } + + static LocalDate dateFromTimestamp(ResultSet rs, String column) throws SQLException { + Timestamp value = rs.getTimestamp(column); + return value == null ? null : value.toInstant().atZone(ZoneOffset.UTC).toLocalDate(); + } + + static Instant instant(ResultSet rs, String column) throws SQLException { + Timestamp value = rs.getTimestamp(column); + return value == null ? null : value.toInstant(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__techlog_studio_working_copy.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__techlog_studio_working_copy.sql new file mode 100644 index 0000000..c979203 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__techlog_studio_working_copy.sql @@ -0,0 +1,92 @@ +-- Studio WorkingCopy 계약(studio-v1.yaml)이 요구하는 편집 필드를 네 source aggregate에 채운다. +-- +-- 배경: V7(= 설계 패키지 database/V1__init.sql)의 물리 스키마는 유형마다 다른 모델을 갖는데, +-- Studio 계약은 네 유형을 공통 base(WorkingCopyInputBase) + 유형별 확장이라는 하나의 편집 +-- 흐름으로 다룬다. 그 공통 base와 유형별 필드 중 V7에 대응 컬럼이 없는 것만 여기서 채운다. +-- 계약에 없는 V7 컬럼(environment_items, alternatives_markdown, freshness_status 등)은 +-- 건드리지 않는다 — 설계 패키지의 정본 스키마이고 public-v1 구현이 쓸 수 있다. +-- +-- ADR-003과 충돌하지 않는다. ADR-003이 금지한 것은 네 Aggregate를 하나의 범용 +-- `working_copy` 테이블 + 통짜 JSON으로 뭉개는 것이다. 여기서는 각 Aggregate가 자기 +-- 테이블을 그대로 소유한 채 필요한 컬럼만 얻는다. + +-- ── 공통 base ─────────────────────────────────────────────────────────────── +-- 계약 WorkingCopyInputBase.summary (maxLength 300). open_question.summary(varchar 600)과 +-- project_decision(아래)에는 각각 대응 컬럼이 있거나 새로 만든다. +ALTER TABLE document ADD COLUMN summary varchar(300) NOT NULL DEFAULT ''; + +-- ── CASE ──────────────────────────────────────────────────────────────────── +-- 계약의 problem/conclusion은 maxLength 100000인 본문이다. V7의 *_summary는 varchar(600)이라 +-- 그대로 쓰면 잘린다 — text로 넓힌다(폭을 늘리는 변경이라 기존 행에 무손실). +ALTER TABLE case_detail ALTER COLUMN problem_summary TYPE text; +ALTER TABLE case_detail ALTER COLUMN conclusion_summary TYPE text; +-- environment_items(jsonb 배열)는 계약의 environment(단일 문자열)와 다른 모양이다. +-- 계약 쪽을 담을 컬럼을 따로 둔다. 기존 컬럼은 그대로 남긴다. +ALTER TABLE case_detail ADD COLUMN environment text NOT NULL DEFAULT ''; +ALTER TABLE case_detail ADD COLUMN reproduction text NOT NULL DEFAULT ''; + +-- ── REFERENCE ─────────────────────────────────────────────────────────────── +ALTER TABLE reference_detail ALTER COLUMN scope_summary TYPE text; +-- rules[] = ReferenceRule{id,title,body,order}, examples[] = OrderedText{id,text,order}. +-- applyWhen[]/exceptions[]는 기존 applies_to/excluded_scope를 그대로 쓴다. +ALTER TABLE reference_detail ADD COLUMN rules jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(rules) = 'array'); +ALTER TABLE reference_detail ADD COLUMN examples jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(examples) = 'array'); + +-- ── QUESTION ──────────────────────────────────────────────────────────────── +-- options[] = QuestionOption{id,title,description,order}. +-- facts/assumptions/unknowns/constraints는 기존 question_point(point_kind)로 간다. +ALTER TABLE open_question ADD COLUMN options jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(options) = 'array'); +-- QuestionResolution{summary, evidenceTargetId, linkLabel} 중 summary만 V7에 있다. +ALTER TABLE open_question ADD COLUMN resolution_evidence_target_id uuid; +ALTER TABLE open_question ADD COLUMN resolution_link_label varchar(120) NOT NULL DEFAULT ''; + +-- ── PROJECT_DECISION ──────────────────────────────────────────────────────── +-- 계약은 PROJECT_DECISION도 다른 셋과 같은 공통 base(title/slug/summary/topicId)로 편집한다. +-- V7의 project_decision에는 statement/rationale/consequences만 있다. +ALTER TABLE project_decision ADD COLUMN title varchar(120) NOT NULL DEFAULT ''; +ALTER TABLE project_decision ADD COLUMN slug varchar(180); +ALTER TABLE project_decision ADD COLUMN summary varchar(300) NOT NULL DEFAULT ''; +ALTER TABLE project_decision ADD COLUMN primary_topic_id uuid REFERENCES topic(id); +-- 계약 statement는 maxLength 100000이다. varchar(1000)이면 잘린다. +ALTER TABLE project_decision ALTER COLUMN statement TYPE text; +-- 계약은 PROJECT_DECISION 의 projectId 를 "게시 시점에 non-null, 저장 시점에는 강제하지 않음"으로 +-- 정한다(studio-v1.yaml WorkingCopyInputBase.projectId). V7 의 NOT NULL 은 그 초안 저장을 +-- 구조적으로 불가능하게 만든다 — 게시 필수 여부는 검증이 판단하도록 컬럼 제약을 푼다. +ALTER TABLE project_decision ALTER COLUMN project_id DROP NOT NULL; +ALTER TABLE project_decision ADD CONSTRAINT uq_project_decision_slug UNIQUE (slug); +ALTER TABLE project_decision ADD CONSTRAINT ck_project_decision_slug_non_blank + CHECK (slug IS NULL OR length(trim(slug)) > 0); + +-- ── relations ─────────────────────────────────────────────────────────────── +-- 계약의 relations[]는 네 유형 공통 base에 있고 항목이 {id, targetId, reason, order}다. +-- V7의 document_relation은 (source, target, relation_type) 복합 PK라 항목 자체의 id도 +-- reason도 없고 document끼리만 성립한다 — QUESTION/PROJECT_DECISION의 relations를 담을 수 +-- 없다. 그래서 Studio 편집용 관계는 전용 테이블에 둔다. document_relation은 설계의 공개 +-- 렌더링용 유형 관계로 그대로 남는다. +-- +-- (source_kind, source_id) 다형 참조는 이 스키마가 studio_validation/studio_preview에서 +-- 이미 쓰는 방식과 같다. 다형 참조라 FK를 걸 수 없으므로 부모 삭제 시 정리는 application이 +-- 책임진다. +-- +-- target_id가 nullable인 것은 계약(RelationInput.targetId: [string,"null"])을 따른 것이다 — +-- 아직 대상을 고르지 않은 관계 줄도 저장할 수 있어야 한다. 게시 가능 여부는 검증이 판단한다. +CREATE TABLE studio_relation ( + id uuid PRIMARY KEY, + source_kind varchar(30) NOT NULL + CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')), + source_id uuid NOT NULL, + target_id uuid, + reason text NOT NULL DEFAULT '', + display_order integer NOT NULL CHECK (display_order >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_studio_relation_order UNIQUE (source_kind, source_id, display_order), + CONSTRAINT ck_studio_relation_not_self CHECK (target_id IS NULL OR target_id <> source_id) +); + +CREATE INDEX idx_studio_relation_source ON studio_relation (source_kind, source_id); +CREATE INDEX idx_studio_relation_target ON studio_relation (target_id) + WHERE target_id IS NOT NULL; diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/StudioPersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/StudioPersistenceIntegrationTest.java new file mode 100644 index 0000000..2a1de36 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/studio/StudioPersistenceIntegrationTest.java @@ -0,0 +1,716 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.studio; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcPreviewArtifactAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcValidationArtifactAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcDependencyRevisionAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDashboardQueryAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDependencyResolverAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDocumentQueryAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset.JdbcAssetRepositoryAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationHistoryQueryAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationWriterAdapter; +import dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.JdbcWorkingCopyRepositoryAdapter; +import dev.caskeleton.application.techlog.studio.model.AssetKindView; +import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView; +import dev.caskeleton.application.techlog.studio.model.AssetView; +import dev.caskeleton.application.techlog.studio.model.DecisionStatusView; +import dev.caskeleton.application.techlog.studio.model.DocumentSort; +import dev.caskeleton.application.techlog.studio.model.NextAction; +import dev.caskeleton.application.techlog.studio.model.OrderedTextView; +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus; +import dev.caskeleton.application.techlog.studio.model.PublishResultView; +import dev.caskeleton.application.techlog.studio.model.QuestionStatusView; +import dev.caskeleton.application.techlog.studio.model.RecordKind; +import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView; +import dev.caskeleton.application.techlog.studio.model.RelationView; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +import dev.caskeleton.application.techlog.studio.model.ValidationStatus; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyView; +import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort; +import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort; +import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort; +import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery; +import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery; +import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.ObjectMapper; + +/** + * Studio 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다. + * + *

이 테스트가 필요한 이유는 분명하다 — 이 저장소의 표준 {@code check} 는 Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이 + * 테스트 없이는 한 번도 실행되지 않은 채 통과한다. 컴파일과 단위 테스트는 컬럼 이름 오타도, jsonb 캐스팅 누락도, 순환 FK 의 지연 검사도 검증하지 + * 못한다. + * + *

{@code JdbcCatalogQueryAdapterTest} 와 같은 형제 패턴을 쓴다 — 이 모듈에는 {@code @SpringBootConfiguration} 이 + * 없으므로 Testcontainers + 순수 Flyway + 직접 조립이다. + */ +class StudioPersistenceIntegrationTest { + + private static final String IMAGE = + System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine"); + + private static PostgreSQLContainer postgres; + private static HikariDataSource dataSource; + private static JdbcClient jdbcClient; + + private static JdbcWorkingCopyRepositoryAdapter workingCopies; + private static JdbcStudioDocumentQueryAdapter documents; + private static JdbcStudioDependencyResolverAdapter dependencies; + private static JdbcDependencyRevisionAdapter dependencyRevisions; + private static JdbcValidationArtifactAdapter validations; + private static JdbcPreviewArtifactAdapter previews; + private static JdbcPublicationWriterAdapter publicationWriter; + private static JdbcPublicationHistoryQueryAdapter publicationHistory; + private static JdbcStudioDashboardQueryAdapter dashboard; + private static JdbcAssetRepositoryAdapter assets; + private static org.springframework.transaction.support.TransactionTemplate transactions; + + private static UUID topicId; + private static UUID projectId; + + @BeforeAll + static void migrateFreshDatabase() { + if (!DockerClientFactory.instance().isDockerAvailable()) { + throw new IllegalStateException( + "Docker is required for the Studio persistence integration test; skipping is forbidden"); + } + postgres = new PostgreSQLContainer(IMAGE).withReuse(false); + postgres.start(); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(postgres.getJdbcUrl()); + config.setUsername(postgres.getUsername()); + config.setPassword(postgres.getPassword()); + config.setMaximumPoolSize(5); + config.setMinimumIdle(1); + dataSource = new HikariDataSource(config); + + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration/postgresql") + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + + jdbcClient = JdbcClient.create(dataSource); + ObjectMapper objectMapper = new ObjectMapper(); + + workingCopies = new JdbcWorkingCopyRepositoryAdapter(jdbcClient, objectMapper); + documents = new JdbcStudioDocumentQueryAdapter(jdbcClient); + dependencies = new JdbcStudioDependencyResolverAdapter(jdbcClient); + dependencyRevisions = new JdbcDependencyRevisionAdapter(jdbcClient); + validations = new JdbcValidationArtifactAdapter(jdbcClient, objectMapper); + previews = new JdbcPreviewArtifactAdapter(jdbcClient); + publicationWriter = new JdbcPublicationWriterAdapter(jdbcClient, objectMapper); + publicationHistory = new JdbcPublicationHistoryQueryAdapter(jdbcClient); + dashboard = new JdbcStudioDashboardQueryAdapter(jdbcClient); + assets = new JdbcAssetRepositoryAdapter(jdbcClient); + // 게시는 프로덕션에서 TransactionPort.inWrite 안에서 돈다. 순환 FK 의 지연 검사가 성립하려면 + // 트랜잭션이 반드시 있어야 하므로 테스트도 같은 조건에서 호출한다. + transactions = + new org.springframework.transaction.support.TransactionTemplate( + new org.springframework.jdbc.support.JdbcTransactionManager(dataSource)); + + topicId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)" + + " VALUES (:id, 'Kafka', 'kafka', 'kafka', 'test', 'test')") + .param("id", topicId) + .update(); + + projectId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO project (id, slug, name, created_by, updated_by)" + + " VALUES (:id, 'tech-log', 'Tech Log', 'test', 'test')") + .param("id", projectId) + .update(); + } + + @AfterAll + static void stopPostgreSql() { + if (dataSource != null) { + dataSource.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + private static WorkingCopyBaseInput base(RecordKind kind, String title, String slug) { + return new WorkingCopyBaseInput(kind, title, slug, "요약", topicId, projectId, List.of()); + } + + @Test + void v8AddsEveryColumnTheStudioContractNeeds() { + List caseColumns = columnsOf("case_detail"); + assertThat(caseColumns).contains("environment", "reproduction"); + + assertThat(columnsOf("reference_detail")).contains("rules", "examples"); + assertThat(columnsOf("open_question")) + .contains("options", "resolution_evidence_target_id", "resolution_link_label"); + assertThat(columnsOf("project_decision")) + .contains("title", "slug", "summary", "primary_topic_id"); + assertThat(columnsOf("document")).contains("summary"); + assertThat(columnsOf("studio_relation")).contains("source_kind", "source_id", "target_id"); + + // 계약이 허용한 "프로젝트 없는 결정 초안" 저장이 가능해야 한다. + assertThat(isNullable("project_decision", "project_id")).isTrue(); + // 계약의 본문 길이는 100000 이라 varchar(600)/varchar(1000) 로는 담을 수 없다. + assertThat(typeOf("case_detail", "problem_summary")).isEqualTo("text"); + assertThat(typeOf("project_decision", "statement")).isEqualTo("text"); + } + + @Test + void aCaseWorkingCopyRoundTripsThroughEveryColumnItTouches() { + WorkingCopyView created = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "장애 사례", "outage-case"), + "문제", + "결론", + "환경", + "재현", + LocalDate.of(2026, 8, 1), + "본문 :::evidence key=\"x\""), + "tester"); + + assertThat(created.version()).as("계약의 version 은 minimum 1 이다").isEqualTo(1L); + + WorkingCopyView loaded = workingCopies.find(created.id()).orElseThrow(); + assertThat(loaded).isInstanceOf(WorkingCopyView.CaseWorkingCopyView.class); + WorkingCopyView.CaseWorkingCopyView value = (WorkingCopyView.CaseWorkingCopyView) loaded; + assertThat(value.problem()).isEqualTo("문제"); + assertThat(value.reproduction()).isEqualTo("재현"); + assertThat(value.lastVerifiedOn()).isEqualTo(LocalDate.of(2026, 8, 1)); + assertThat(value.base().topicId()).isEqualTo(topicId); + assertThat(value.base().projectId()).as("프로젝트 링크 테이블 왕복").isEqualTo(projectId); + + assertThat(workingCopies.findKind(created.id())).contains(RecordKind.CASE); + } + + @Test + void savingWithTheWrongVersionChangesNothing() { + WorkingCopyView created = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "낙관적 잠금", "optimistic-lock"), "", "", "", "", null, ""), + "tester"); + + Optional conflict = + workingCopies.save( + created.id(), + created.version() + 99, + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "덮어쓰기 시도", "optimistic-lock"), "", "", "", "", null, ""), + "tester"); + assertThat(conflict).isEmpty(); + assertThat(workingCopies.find(created.id()).orElseThrow().base().title()).isEqualTo("낙관적 잠금"); + + WorkingCopyView saved = + workingCopies + .save( + created.id(), + created.version(), + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "정상 저장", "optimistic-lock"), "", "", "", "", null, ""), + "tester") + .orElseThrow(); + assertThat(saved.version()).isEqualTo(created.version() + 1); + assertThat(saved.base().title()).isEqualTo("정상 저장"); + } + + @Test + void theOtherThreeKindsRoundTripThroughTheirOwnTables() { + WorkingCopyView reference = + workingCopies.create( + new WorkingCopyInputView.ReferenceInputView( + base(RecordKind.REFERENCE, "기준 문서", "reference-doc"), + "목적", + List.of(new ReferenceRuleView(UUID.randomUUID(), "규칙", "본문", 0)), + List.of(new OrderedTextView(UUID.randomUUID(), "적용", 0)), + List.of(), + List.of(new OrderedTextView(UUID.randomUUID(), "예시", 0)), + LocalDate.of(2026, 7, 1)), + "tester"); + WorkingCopyView.ReferenceWorkingCopyView loadedReference = + (WorkingCopyView.ReferenceWorkingCopyView) workingCopies.find(reference.id()).orElseThrow(); + assertThat(loadedReference.rules()) + .singleElement() + .extracting(ReferenceRuleView::title) + .isEqualTo("규칙"); + assertThat(loadedReference.examples()) + .singleElement() + .extracting(OrderedTextView::text) + .isEqualTo("예시"); + + WorkingCopyView question = + workingCopies.create( + new WorkingCopyInputView.QuestionInputView( + base(RecordKind.QUESTION, "미해결 질문", "open-question"), + QuestionStatusView.OPEN, + List.of(new OrderedTextView(UUID.randomUUID(), "사실", 0)), + List.of(), + List.of(new OrderedTextView(UUID.randomUUID(), "모르는 것", 0)), + List.of(), + List.of(), + "다음 검증", + null), + "tester"); + WorkingCopyView.QuestionWorkingCopyView loadedQuestion = + (WorkingCopyView.QuestionWorkingCopyView) workingCopies.find(question.id()).orElseThrow(); + assertThat(loadedQuestion.facts()) + .singleElement() + .extracting(OrderedTextView::text) + .isEqualTo("사실"); + assertThat(loadedQuestion.unknowns()).hasSize(1); + assertThat(loadedQuestion.nextValidation()).isEqualTo("다음 검증"); + assertThat(loadedQuestion.base().projectId()).isEqualTo(projectId); + + WorkingCopyView decision = + workingCopies.create( + new WorkingCopyInputView.ProjectDecisionInputView( + base(RecordKind.PROJECT_DECISION, "결정", "a-decision"), + DecisionStatusView.ADOPTED, + LocalDate.of(2026, 6, 1), + "결정문", + "근거", + List.of(new OrderedTextView(UUID.randomUUID(), "결과", 0))), + "tester"); + WorkingCopyView.ProjectDecisionWorkingCopyView loadedDecision = + (WorkingCopyView.ProjectDecisionWorkingCopyView) + workingCopies.find(decision.id()).orElseThrow(); + // 계약의 ADOPTED 는 Domain 의 ACCEPTED 다(ADR-003). + assertThat(loadedDecision.decisionStatus()).isEqualTo(DecisionStatusView.ADOPTED); + assertThat(storedDecisionStatus(decision.id())).isEqualTo("ACCEPTED"); + assertThat(loadedDecision.consequences()).hasSize(1); + } + + @Test + void relationsSurviveAReplaceAndKeepTheirIdentity() { + WorkingCopyView target = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "관계 대상", "relation-target"), "", "", "", "", null, ""), + "tester"); + + RelationView relation = new RelationView(null, target.id(), "왜 관련 있는지", 0); + WorkingCopyView source = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + new WorkingCopyBaseInput( + RecordKind.CASE, + "관계 원본", + "relation-source", + "요약", + topicId, + projectId, + List.of(relation)), + "", + "", + "", + "", + null, + ""), + "tester"); + + List stored = workingCopies.find(source.id()).orElseThrow().base().relations(); + assertThat(stored).singleElement().extracting(RelationView::reason).isEqualTo("왜 관련 있는지"); + UUID relationId = stored.getFirst().id(); + assertThat(relationId).isNotNull(); + + WorkingCopyView saved = + workingCopies + .save( + source.id(), + source.version(), + new WorkingCopyInputView.CaseInputView( + new WorkingCopyBaseInput( + RecordKind.CASE, + "관계 원본", + "relation-source", + "요약", + topicId, + projectId, + List.of(new RelationView(relationId, target.id(), "이유 수정", 0))), + "", + "", + "", + "", + null, + ""), + "tester") + .orElseThrow(); + List afterSave = saved.base().relations(); + assertThat(afterSave).singleElement().extracting(RelationView::reason).isEqualTo("이유 수정"); + assertThat(afterSave.getFirst().id()).as("이 문서가 소유하던 관계 id 는 저장해도 유지된다").isEqualTo(relationId); + } + + @Test + void theUnionQueryComputesDependencyRevisionAndNextAction() { + WorkingCopyView document = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "목록 대상", "list-target"), "", "", "", "", null, ""), + "tester"); + + var page = + documents.list( + new ListDocumentsQuery( + "목록", RecordKind.CASE, null, null, projectId, DocumentSort.UPDATED_DESC, null, 20)); + assertThat(page.items()).extracting(item -> item.id()).contains(document.id()); + assertThat(page.items().getFirst().nextAction()) + .as("검증한 적이 없으면 다음 행동은 VALIDATE 다") + .isEqualTo(NextAction.VALIDATE); + + String revision = dependencyRevisions.revisionFor(RecordKind.CASE, document.id()); + assertThat(revision).isNotBlank(); + assertThat(dependencyRevisions.revisionFor(RecordKind.CASE, document.id())) + .as("같은 상태면 같은 값이어야 한다") + .isEqualTo(revision); + + // 제목이 비면 검증할 의미가 없으므로 CONTINUE_EDITING 이다. + workingCopies.save( + document.id(), + document.version(), + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "", "list-target"), "", "", "", "", null, ""), + "tester"); + var blankTitlePage = + documents.list( + new ListDocumentsQuery( + null, + RecordKind.CASE, + null, + NextAction.CONTINUE_EDITING, + projectId, + DocumentSort.UPDATED_DESC, + null, + 20)); + assertThat(blankTitlePage.items()).extracting(item -> item.id()).contains(document.id()); + } + + @Test + void dependencyResolutionFindsTopicProjectRelationsAndSlugOwners() { + WorkingCopyView first = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "슬러그 주인", "shared-slug"), "", "", "", "", null, ""), + "tester"); + WorkingCopyView second = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "슬러그 경쟁", ""), "", "", "", "", null, ""), + "tester"); + + StudioDependencyResolverPort.Resolved resolved = + dependencies.resolve(workingCopies.find(first.id()).orElseThrow(), Set.of()); + assertThat(resolved.topic()).isNotNull(); + assertThat(resolved.topic().publicPath()).isEqualTo("/topics/kafka"); + assertThat(resolved.project().publicPath()).isEqualTo("/projects/tech-log"); + assertThat(resolved.publicPath()).isEqualTo("/cases/shared-slug"); + assertThat(resolved.topicMissing()).isFalse(); + assertThat(resolved.slugOwnerId()).as("자기 자신은 slug 충돌이 아니다").isNull(); + + // 빈 slug 는 NULL 로 저장되므로 UNIQUE 제약을 여러 초안이 함께 지날 수 있다. + assertThat(workingCopies.find(second.id()).orElseThrow().base().slug()).isEmpty(); + } + + @Test + void validationAndPreviewArtifactsPersistAndReadBack() { + WorkingCopyView document = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "artifact", "artifact-case"), "문제", "결론", "", "", null, ""), + "tester"); + + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + ValidationReportView report = + new ValidationReportView( + UUID.randomUUID(), + document.id(), + document.version(), + ValidationStatus.WARNINGS, + List.of( + new dev.caskeleton.application.techlog.studio.model.ValidationIssueView( + "BODY_EMPTY", + dev.caskeleton.application.techlog.studio.model.ValidationSeverity.WARNING, + "/bodyMarkdown", + "본문이 비어 있다")), + now, + now.plusSeconds(3600), + "rev-1"); + validations.save(RecordKind.CASE, report, "tester"); + + ValidationReportView loaded = validations.findById(report.validationId()).orElseThrow(); + assertThat(loaded.status()).isEqualTo(ValidationStatus.WARNINGS); + assertThat(loaded.issues()) + .singleElement() + .extracting(issue -> issue.code()) + .isEqualTo("BODY_EMPTY"); + assertThat(validations.latestFor(RecordKind.CASE, document.id())).isPresent(); + + PublicPreviewView preview = + new PublicPreviewView( + UUID.randomUUID(), + document.id(), + document.version(), + report.validationId(), + "rev-1", + now, + now.plusSeconds(3600), + "{\"kind\":\"CASE\"}"); + previews.save(RecordKind.CASE, preview, "tester"); + // jsonb 컬럼은 PostgreSQL 이 정규화해 되돌려준다(공백·키 순서가 입력과 같지 않다). 계약 DTO 로 + // 다시 파싱해 쓰는 값이라 의미는 보존되지만, 바이트 동일성을 기대하면 안 된다. + String storedRenderModel = + previews.findById(preview.previewId()).orElseThrow().renderModelJson(); + assertThat(new ObjectMapper().readTree(storedRenderModel).path("kind").asString("")) + .isEqualTo("CASE"); + } + + @Test + void publishWritesTheWholeAggregateAndUnpublishWithdrawsIt() { + WorkingCopyView document = + workingCopies.create( + new WorkingCopyInputView.CaseInputView( + base(RecordKind.CASE, "게시 대상", "publish-target"), "문제", "결론", "", "", null, "본문"), + "tester"); + + PublishResultView published = + transactions.execute( + status -> + publicationWriter.publish( + new PublicationWriterPort.PublishRequest( + RecordKind.CASE, + document.id(), + document.version(), + "게시 대상", + "요약", + topicId, + projectId, + "/cases/publish-target", + null, + "{\"kind\":\"CASE\"}", + "본문 평문", + List.of(), + "1", + "1", + "idem-1", + "tester"))); + + assertThat(published.publication().status()).isEqualTo(PublicationAggregateStatus.PUBLISHED); + assertThat(published.publication().publicationRevision()) + .as("첫 게시의 revision 은 1 이다 — INSERT 가 넣은 값을 UPDATE 가 또 올리면 안 된다") + .isEqualTo(1L); + assertThat(published.event().snapshotAvailable()).isTrue(); + + // 순환 FK(publication.latest_event_id <-> publication_event)가 지연 검사로 통과해야 한다. + assertThat(published.publication().latestEventId()) + .isEqualTo(published.event().publicationEventId()); + + assertThat(count("public_resource_projection", "resource_id", document.id())).isEqualTo(1); + assertThat(count("public_route", "resource_id", document.id())).isEqualTo(1); + assertThat( + jdbcClient + .sql("SELECT workflow_status FROM document WHERE id = :id") + .param("id", document.id()) + .query(String.class) + .single()) + .isEqualTo("PUBLISHED"); + + // 재게시는 REPUBLISHED 이벤트와 revision 증가를 만든다. + PublishResultView republished = + transactions.execute( + status -> + publicationWriter.publish( + new PublicationWriterPort.PublishRequest( + RecordKind.CASE, + document.id(), + document.version(), + "게시 대상", + "요약", + topicId, + projectId, + "/cases/publish-target", + null, + "{\"kind\":\"CASE\"}", + "본문 평문", + List.of(), + "1", + "1", + "idem-2", + "tester"))); + assertThat(republished.event().type()) + .isEqualTo( + dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView.REPUBLISHED); + assertThat(republished.publication().publicationRevision()).isEqualTo(2L); + + var page = publicationHistory.list(new ListPublicationsQuery(null, null, null, 20)); + assertThat(page.items()).isNotEmpty(); + assertThat(page.items().getFirst().document()).isNotNull(); + assertThat(publicationHistory.findSnapshot(published.event().publicationEventId())).isPresent(); + + PublishResultView withdrawn = + transactions.execute( + status -> + publicationWriter.unpublish( + new PublicationWriterPort.UnpublishRequest( + published.publication().publicationId(), + RecordKind.CASE, + document.id(), + republished.publication().publicationRevision(), + "tester"))); + assertThat(withdrawn.publication().status()).isEqualTo(PublicationAggregateStatus.UNPUBLISHED); + assertThat(withdrawn.event().sourcePublishedEventId()) + .as("UNPUBLISHED 이벤트는 마지막 공개 Snapshot 을 반드시 참조한다") + .isNotNull(); + assertThat( + jdbcClient + .sql( + "SELECT publication_state FROM public_resource_projection" + + " WHERE resource_id = :id") + .param("id", document.id()) + .query(String.class) + .single()) + .isEqualTo("WITHDRAWN"); + assertThat(count("public_route", "resource_id", document.id())) + .as("게시 취소는 주소를 지우지 않는다 — 지우면 공개된 링크가 끊긴다") + .isEqualTo(1); + } + + @Test + void dashboardCountsUseTheSameProjectionAsTheList() { + var totals = dashboard.totals(); + assertThat(totals.documents()).isPositive(); + assertThat(dashboard.topByNextAction(List.of(NextAction.VALIDATE), 5)).isNotNull(); + } + + @Test + void assetsRoundTripAndReportTheirUsage() { + UUID assetId = UUID.randomUUID(); + AssetView created = + assets.create( + new AssetRepositoryPort.NewAsset( + assetId, + "diagram-key", + AssetKindView.DIAGRAM, + "image/png", + "techlog/assets/" + assetId, + "diagram.png", + 1024L, + 800, + 600, + "a".repeat(64), + "설명", + false, + AssetManagementStatusView.READY), + "tester"); + + assertThat(created.version()).as("계약의 Asset.version 은 minimum 1 이다").isEqualTo(1L); + assertThat(created.publicPath()).isEqualTo("/media/" + assetId); + assertThat(created.usageCount()).isZero(); + + var page = + assets.list(new ListAssetsQuery("diagram", AssetKindView.DIAGRAM, null, null, null, 20)); + assertThat(page.items()).extracting(AssetView::id).contains(assetId); + + AssetView updated = + assets + .update( + assetId, + created.version(), + null, + null, + true, + Boolean.TRUE, + AssetManagementStatusView.ARCHIVED, + "tester") + .orElseThrow(); + assertThat(updated.altText()).as("altTextProvided=true 는 null 로 지우는 것을 뜻한다").isNull(); + assertThat(updated.decorative()).isTrue(); + assertThat(updated.managementStatus()).isEqualTo(AssetManagementStatusView.ARCHIVED); + assertThat(updated.version()).isEqualTo(2L); + + assertThat(assets.update(assetId, 99L, null, null, false, null, null, "tester")).isEmpty(); + assertThat(assets.findObjectKey(assetId)).contains("techlog/assets/" + assetId); + + var detail = assets.findDetail(assetId).orElseThrow(); + assertThat(detail.hasPublicationHistory()).isFalse(); + assertThat(detail.usages()).isEmpty(); + + assets.delete(assetId); + assertThat(assets.find(assetId)).isEmpty(); + } + + private static List columnsOf(String table) { + return jdbcClient + .sql("SELECT column_name FROM information_schema.columns WHERE table_name = :table") + .param("table", table) + .query(String.class) + .list(); + } + + private static boolean isNullable(String table, String column) { + return "YES" + .equals( + jdbcClient + .sql( + "SELECT is_nullable FROM information_schema.columns" + + " WHERE table_name = :table AND column_name = :column") + .param("table", table) + .param("column", column) + .query(String.class) + .single()); + } + + private static String typeOf(String table, String column) { + return jdbcClient + .sql( + "SELECT data_type FROM information_schema.columns" + + " WHERE table_name = :table AND column_name = :column") + .param("table", table) + .param("column", column) + .query(String.class) + .single(); + } + + private static String storedDecisionStatus(UUID id) { + return jdbcClient + .sql("SELECT decision_status FROM project_decision WHERE id = :id") + .param("id", id) + .query(String.class) + .single(); + } + + private static int count(String table, String column, UUID id) { + return jdbcClient + .sql("SELECT count(*) FROM " + table + " WHERE " + column + " = :id") + .param("id", id) + .query(Integer.class) + .single(); + } +} diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index ccddc13..be9cf56 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -210,6 +210,10 @@ org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.commonmark:commonmark-ext-autolink:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.commonmark:commonmark-ext-gfm-tables:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.commonmark:commonmark:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.dom4j:dom4j:2.2.0=spotbugs org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.eclipse.jetty.compression:jetty-compression-common:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -248,6 +252,7 @@ org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.nibor.autolink:autolink:0.10.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.openapitools:jackson-databind-nullable:0.2.6=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java index 74b5f1e..32e857f 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java @@ -200,6 +200,39 @@ class StudioContractDriftTest { .isNotEmpty(); } + /** + * 반대 방향 — 계약의 operation 이 전부 published 표면에 있는가. + * + *

슬라이스 2~5 가 끝나 19개 operation 이 모두 구현됐으므로 이제 "published ⊆ 계약" 한 방향만으로는 부족하다. 그 방향은 + * 사라진 operation 을 잡지 못한다 — 컨트롤러를 지우거나 매핑을 잘못 옮겨도 남은 것들이 계약과 맞으면 통과한다. 양방향이 되어야 게이트가 + * 완성된다. + * + *

새 operation 을 계약에 추가하면 이 테스트가 먼저 빨간불이 된다. 그게 의도다 — 계약이 약속한 것을 서버가 아직 제공하지 않는다는 사실이 배포 전에 + * 드러나야 한다. + */ + @Test + void everyContractOperationIsPublished() throws Exception { + JsonNode contract = readContract(); + JsonNode published = readPublishedApiDocs(); + + List missing = new ArrayList<>(); + for (Map.Entry path : contract.path("paths").properties()) { + for (Map.Entry method : path.getValue().properties()) { + JsonNode operationId = method.getValue().path("operationId"); + if (operationId.isMissingNode()) { + continue; + } + JsonNode publishedOperation = + published.path("paths").path(path.getKey()).path(method.getKey()); + if (publishedOperation.isMissingNode() + || !operationId.asText().equals(publishedOperation.path("operationId").asText(""))) { + missing.add(operationId.asText() + " (" + method.getKey() + " " + path.getKey() + ")"); + } + } + } + assertThat(missing).as("계약이 약속했는데 서버가 제공하지 않는 operation").isEmpty(); + } + /** * SnakeYaml(이미 {@code StudioErrorRegistryTest}가 error-codes.yaml에 쓰는 라이브러리)로 읽은 뒤 {@code * ObjectMapper#valueToTree}로 {@link JsonNode}로 옮긴다 — {@code jackson-dataformat-yaml}을 새 컴파일 @@ -245,7 +278,7 @@ class StudioContractDriftTest { @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") - @Import(PresentationWebConfig.class) + @Import({PresentationWebConfig.class, StudioContractDriftTest.StudioDocumentTestBeans.class}) static class ContractSurfaceApp { /** @@ -301,7 +334,11 @@ class StudioContractDriftTest { @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") - @Import({EnvelopeBodyAdvice.class, PresentationWebConfig.class}) + @Import({ + EnvelopeBodyAdvice.class, + PresentationWebConfig.class, + StudioContractDriftTest.StudioDocumentTestBeans.class + }) static class EnvelopeApp { /** @@ -350,6 +387,482 @@ class StudioContractDriftTest { return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort()); } + /** + * 슬라이스 2의 {@code StudioDocumentController}가 {@code @ComponentScan}에 걸리면서 필요해진 협력자들. + * + *

이 비용은 이 게이트가 "패키지를 스캔한다"는 성질의 뒷면이다 — 새 컨트롤러가 자동으로 감시 대상이 되는 대신, 그 컨트롤러의 협력자를 여기에 채워야 컨텍스트가 + * 뜬다. 채우지 않으면 게이트가 통과가 아니라 실패로 알려준다. + * + *

포트 구현은 전부 빈 stub 이다. 첫 번째 테스트는 springdoc 리플렉션이라 컨트롤러 메서드를 아예 호출하지 않고, 두 번째 테스트는 catalog + * 엔드포인트만 두드린다. + */ + @org.springframework.context.annotation.Configuration(proxyBeanMethods = false) + static class StudioDocumentTestBeans { + + @Bean + java.time.Clock studioTestClock() { + return java.time.Clock.systemUTC(); + } + + @Bean + dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings studioSettings() { + return new dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings( + "functional-test-cursor-signing-key", null, null); + } + + @Bean + dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport idempotencyKeySupport( + tools.jackson.databind.ObjectMapper objectMapper) { + return new dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport(objectMapper); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase + listStudioDocumentsUseCase() { + return new dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase( + query -> + new dev.caskeleton.application.techlog.studio.model.DocumentPageView(List.of(), null), + new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase + createStudioDocumentUseCase() { + return new dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase( + new StubWorkingCopyRepositoryPort(), new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase + getStudioDocumentUseCase() { + return new dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase( + new StubWorkingCopyRepositoryPort(), assembler(), new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase + saveStudioDocumentUseCase() { + return new dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase( + new StubWorkingCopyRepositoryPort(), assembler(), new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader studioDocumentLoader() { + return new dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader( + new StubWorkingCopyRepositoryPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort + studioDependencyResolverPort() { + return (document, keys) -> + new dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort + .Resolved( + null, + false, + null, + false, + List.of(), + List.of(), + java.util.Map.of(), + java.util.Map.of(), + null, + null, + null); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase + validateStudioDocumentUseCase( + dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents, + dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort + dependencies, + dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort + contentAnalyzer) { + return new dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase( + documents, + dependencies, + contentAnalyzer, + (kind, id) -> "test-dependency-revision", + new StubValidationArtifactPort(), + new PassThroughTransactionPort(), + java.util.UUID::randomUUID, + java.time.Clock.systemUTC(), + java.time.Duration.ofHours(1)); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase + createStudioPreviewUseCase( + dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents, + dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort + dependencies, + dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort contentAnalyzer, + dev.caskeleton.application.techlog.studio.port.out.RenderModelPort renderer) { + return new dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase( + documents, + dependencies, + contentAnalyzer, + (kind, id) -> "test-dependency-revision", + new StubValidationArtifactPort(), + new StubPreviewArtifactPort(), + renderer, + new PassThroughTransactionPort(), + java.util.UUID::randomUUID, + java.time.Clock.systemUTC(), + java.time.Duration.ofHours(24)); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase + getCurrentStudioPreviewUseCase( + dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents) { + return new dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase( + documents, + new StubPreviewArtifactPort(), + new StubValidationArtifactPort(), + (kind, id) -> "test-dependency-revision", + new PassThroughTransactionPort(), + java.time.Clock.systemUTC()); + } + + @Bean + dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort + publicationHistoryQueryPort() { + return new StubPublicationHistoryQueryPort(); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase + publishStudioDocumentUseCase( + dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents, + dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort + dependencies, + dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort + contentAnalyzer) { + return new dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase( + documents, + dependencies, + contentAnalyzer, + (kind, id) -> "test-dependency-revision", + new StubValidationArtifactPort(), + new StubPreviewArtifactPort(), + new StubPublicationWriterPort(), + new PassThroughTransactionPort(), + java.time.Clock.systemUTC()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase + unpublishStudioPublicationUseCase( + dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort history, + dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents) { + return new dev.caskeleton.application.techlog.studio.service + .UnpublishStudioPublicationUseCase( + history, new StubPublicationWriterPort(), documents, new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase + listStudioPublicationsUseCase( + dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort + history) { + return new dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase( + history, new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase + getStudioPublicationSnapshotUseCase( + dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort + history) { + return new dev.caskeleton.application.techlog.studio.service + .GetStudioPublicationSnapshotUseCase(history, new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase + getStudioDashboardUseCase( + dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort + history) { + return new dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase( + new StubStudioDashboardQueryPort(), history, new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assetRepositoryPort() { + return new StubAssetRepositoryPort(); + } + + @Bean + dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort + assetBinaryStoragePort() { + return new dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort() { + @Override + public String store(String objectKey, byte[] content, String mediaType) { + return objectKey; + } + + @Override + public void delete(String objectKey) { + // 이 게이트는 바이너리를 다루지 않는다. + } + }; + } + + @Bean + dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase + listStudioAssetsUseCase( + dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) { + return new dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase( + assets, new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase + uploadStudioAssetUseCase( + dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets, + dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries) { + return new dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase( + assets, binaries, new PassThroughTransactionPort(), java.util.UUID::randomUUID); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase getStudioAssetUseCase( + dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) { + return new dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase( + assets, new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase + updateStudioAssetUseCase( + dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) { + return new dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase( + assets, new PassThroughTransactionPort()); + } + + @Bean + dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase + deleteStudioAssetUseCase( + dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets, + dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries) { + return new dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase( + assets, binaries, new PassThroughTransactionPort()); + } + + private static dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler + assembler() { + return new dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler( + new StubValidationArtifactPort(), + new StubPreviewArtifactPort(), + documentId -> java.util.Optional.empty(), + (kind, documentId) -> "test-dependency-revision", + java.time.Clock.systemUTC()); + } + } + + private static final class StubWorkingCopyRepositoryPort + implements dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort { + + @Override + public java.util.Optional findKind( + java.util.UUID documentId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional find( + java.util.UUID documentId) { + return java.util.Optional.empty(); + } + + @Override + public dev.caskeleton.application.techlog.studio.model.WorkingCopyView create( + dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView input, + String principal) { + throw new UnsupportedOperationException("this contract-shape gate never creates a document"); + } + + @Override + public java.util.Optional save( + java.util.UUID documentId, + long expectedVersion, + dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView input, + String principal) { + return java.util.Optional.empty(); + } + } + + private static final class StubValidationArtifactPort + implements dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort { + + @Override + public java.util.Optional + latestFor( + dev.caskeleton.application.techlog.studio.model.RecordKind kind, + java.util.UUID documentId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional + findById(java.util.UUID validationId) { + return java.util.Optional.empty(); + } + + @Override + public dev.caskeleton.application.techlog.studio.model.ValidationReportView save( + dev.caskeleton.application.techlog.studio.model.RecordKind kind, + dev.caskeleton.application.techlog.studio.model.ValidationReportView report, + String principal) { + return report; + } + } + + private static final class StubPreviewArtifactPort + implements dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort { + + @Override + public java.util.Optional + latestFor( + dev.caskeleton.application.techlog.studio.model.RecordKind kind, + java.util.UUID documentId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional + findById(java.util.UUID previewId) { + return java.util.Optional.empty(); + } + + @Override + public dev.caskeleton.application.techlog.studio.model.PublicPreviewView save( + dev.caskeleton.application.techlog.studio.model.RecordKind kind, + dev.caskeleton.application.techlog.studio.model.PublicPreviewView preview, + String principal) { + return preview; + } + } + + private static final class StubPublicationWriterPort + implements dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort { + + @Override + public java.util.Optional< + dev.caskeleton.application.techlog.studio.model.PublicationAggregateView> + lockCurrentPublication( + dev.caskeleton.application.techlog.studio.model.RecordKind kind, + java.util.UUID documentId) { + return java.util.Optional.empty(); + } + + @Override + public dev.caskeleton.application.techlog.studio.model.PublishResultView publish( + PublishRequest request) { + throw new UnsupportedOperationException("this contract-shape gate never publishes"); + } + + @Override + public dev.caskeleton.application.techlog.studio.model.PublishResultView unpublish( + UnpublishRequest request) { + throw new UnsupportedOperationException("this contract-shape gate never unpublishes"); + } + } + + private static final class StubPublicationHistoryQueryPort + implements dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort { + + @Override + public dev.caskeleton.application.techlog.studio.model.PublicationPageView list( + dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery query) { + return new dev.caskeleton.application.techlog.studio.model.PublicationPageView( + List.of(), null); + } + + @Override + public java.util.Optional< + dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView> + findSnapshot(java.util.UUID publicationEventId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional< + dev.caskeleton.application.techlog.studio.model.PublicationAggregateView> + findById(java.util.UUID publicationId) { + return java.util.Optional.empty(); + } + } + + private static final class StubStudioDashboardQueryPort + implements dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort { + + @Override + public List + topByNextAction( + List actions, int limit) { + return List.of(); + } + + @Override + public dev.caskeleton.application.techlog.studio.model.DashboardTotalsView totals() { + return new dev.caskeleton.application.techlog.studio.model.DashboardTotalsView(0, 0, 0, 0); + } + } + + private static final class StubAssetRepositoryPort + implements dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort { + + @Override + public dev.caskeleton.application.techlog.studio.model.AssetPageView list( + dev.caskeleton.application.techlog.studio.query.ListAssetsQuery query) { + return new dev.caskeleton.application.techlog.studio.model.AssetPageView(List.of(), null); + } + + @Override + public java.util.Optional find( + java.util.UUID assetId) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional + findDetail(java.util.UUID assetId) { + return java.util.Optional.empty(); + } + + @Override + public dev.caskeleton.application.techlog.studio.model.AssetView create( + NewAsset asset, String principal) { + throw new UnsupportedOperationException("this contract-shape gate never stores an asset"); + } + + @Override + public java.util.Optional update( + java.util.UUID assetId, + long expectedVersion, + dev.caskeleton.application.techlog.studio.model.AssetKindView kind, + String altText, + boolean altTextProvided, + Boolean decorative, + dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView managementStatus, + String principal) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional findObjectKey(java.util.UUID assetId) { + return java.util.Optional.empty(); + } + + @Override + public void delete(java.util.UUID assetId) { + // 이 게이트는 삭제하지 않는다. + } + } + private static final class StubCatalogQueryPort implements CatalogQueryPort { @Override public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) { diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/ObjectStorageAssetBinaryAdapter.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/ObjectStorageAssetBinaryAdapter.java new file mode 100644 index 0000000..82ac561 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/ObjectStorageAssetBinaryAdapter.java @@ -0,0 +1,51 @@ +package dev.caskeleton.bootstrap.techlog; + +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.techlog.error.StudioError; +import dev.caskeleton.application.techlog.error.StudioException; +import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort; +import org.springframework.beans.factory.ObjectProvider; + +/** + * Studio Asset 바이너리를 기존 object storage 어댑터에 위임한다(spec §9 — 새 저장 계층을 만들지 않는다). + * + *

이 브리지가 app-bootstrap 에 있는 이유: 두 기존 포트를 잇는 구성이라 어느 한쪽 어댑터 모듈의 소유가 아니다. objectstorage 모듈은 + * techlog 를 모르고, techlog 영속 모듈은 저장 백엔드를 모른다. + * + *

{@code ObjectStoragePort} 는 {@code @Deprecated(forRemoval = true)} 다. 그럼에도 쓰는 이유는 이 저장소에서 실제로 + * 동작하는 어댑터(filesystem/S3)가 붙어 있는 유일한 포트이기 때문이다 — 후속 {@code objectstorage.port.*} 계열에는 아직 구현이 + * 없다(실측). 선택을 이 한 클래스에 가둬 두었으므로 새 API 로 옮길 때 바뀌는 것은 여기뿐이다. + * + *

저장 백엔드가 구성되지 않은 배포에서는 빈이 없다. 그때는 업로드·삭제만 {@code STUDIO_UNAVAILABLE} 로 거절하고 목록·조회·메타데이터 수정은 그대로 + * 동작한다 — 없는 기능 때문에 있는 기능까지 막지 않는다. + */ +@SuppressWarnings("removal") +final class ObjectStorageAssetBinaryAdapter implements AssetBinaryStoragePort { + + private final ObjectProvider objectStorage; + + ObjectStorageAssetBinaryAdapter(ObjectProvider objectStorage) { + this.objectStorage = objectStorage; + } + + @Override + public String store(String objectKey, byte[] content, String mediaType) { + return require().put(objectKey, content, mediaType).key(); + } + + @Override + public void delete(String objectKey) { + require().delete(objectKey); + } + + private ObjectStoragePort require() { + ObjectStoragePort port = objectStorage.getIfAvailable(); + if (port == null) { + throw StudioException.of( + StudioError.STUDIO_UNAVAILABLE, + "no object storage backend is configured; set ca-skeleton.objectstorage.* to enable" + + " Studio asset uploads"); + } + return port; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java index 6256934..884fcf8 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogStudioConfig.java @@ -1,8 +1,46 @@ package dev.caskeleton.bootstrap.techlog; +import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings; +import dev.caskeleton.application.storage.ObjectStoragePort; +import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort; +import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; +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.PublicationHistoryQueryPort; +import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort; +import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort; +import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort; +import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort; +import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort; +import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort; +import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort; +import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort; +import dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase; +import dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase; +import dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase; +import dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase; +import dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase; +import dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase; +import dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase; +import dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase; import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; +import dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase; +import dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase; +import dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase; +import dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase; +import dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase; +import dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader; +import dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase; +import dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase; +import dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase; +import dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase; +import dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler; import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.util.UUID; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,4 +53,201 @@ public class TechLogStudioConfig { CatalogQueryPort catalogQueryPort, TransactionPort transactionPort) { return new ListCatalogUseCase(catalogQueryPort, transactionPort); } + + @Bean + WorkingCopyDetailAssembler workingCopyDetailAssembler( + ValidationArtifactPort validations, + PreviewArtifactPort previews, + PublicationQueryPort publications, + DependencyRevisionPort dependencyRevisions, + Clock clock) { + return new WorkingCopyDetailAssembler( + validations, previews, publications, dependencyRevisions, clock); + } + + @Bean + ListStudioDocumentsUseCase listStudioDocumentsUseCase( + StudioDocumentQueryPort documents, TransactionPort transactionPort) { + return new ListStudioDocumentsUseCase(documents, transactionPort); + } + + @Bean + GetStudioDocumentUseCase getStudioDocumentUseCase( + WorkingCopyRepositoryPort workingCopies, + WorkingCopyDetailAssembler assembler, + TransactionPort transactionPort) { + return new GetStudioDocumentUseCase(workingCopies, assembler, transactionPort); + } + + @Bean + CreateStudioDocumentUseCase createStudioDocumentUseCase( + WorkingCopyRepositoryPort workingCopies, TransactionPort transactionPort) { + return new CreateStudioDocumentUseCase(workingCopies, transactionPort); + } + + @Bean + StudioDocumentLoader studioDocumentLoader(WorkingCopyRepositoryPort workingCopies) { + return new StudioDocumentLoader(workingCopies); + } + + @Bean + ValidateStudioDocumentUseCase validateStudioDocumentUseCase( + StudioDocumentLoader documents, + StudioDependencyResolverPort dependencies, + ContentAnalyzerPort contentAnalyzer, + DependencyRevisionPort dependencyRevisions, + ValidationArtifactPort validations, + TransactionPort transactionPort, + Clock clock, + StudioSettings settings) { + return new ValidateStudioDocumentUseCase( + documents, + dependencies, + contentAnalyzer, + dependencyRevisions, + validations, + transactionPort, + UUID::randomUUID, + clock, + settings.validationTtl()); + } + + @Bean + CreateStudioPreviewUseCase createStudioPreviewUseCase( + StudioDocumentLoader documents, + StudioDependencyResolverPort dependencies, + ContentAnalyzerPort contentAnalyzer, + DependencyRevisionPort dependencyRevisions, + ValidationArtifactPort validations, + PreviewArtifactPort previews, + RenderModelPort renderer, + TransactionPort transactionPort, + Clock clock, + StudioSettings settings) { + return new CreateStudioPreviewUseCase( + documents, + dependencies, + contentAnalyzer, + dependencyRevisions, + validations, + previews, + renderer, + transactionPort, + UUID::randomUUID, + clock, + settings.previewTtl()); + } + + @Bean + GetCurrentStudioPreviewUseCase getCurrentStudioPreviewUseCase( + StudioDocumentLoader documents, + PreviewArtifactPort previews, + ValidationArtifactPort validations, + DependencyRevisionPort dependencyRevisions, + TransactionPort transactionPort, + Clock clock) { + return new GetCurrentStudioPreviewUseCase( + documents, previews, validations, dependencyRevisions, transactionPort, clock); + } + + @Bean + SaveStudioDocumentUseCase saveStudioDocumentUseCase( + WorkingCopyRepositoryPort workingCopies, + WorkingCopyDetailAssembler assembler, + TransactionPort transactionPort) { + return new SaveStudioDocumentUseCase(workingCopies, assembler, transactionPort); + } + + @Bean + PublishStudioDocumentUseCase publishStudioDocumentUseCase( + StudioDocumentLoader documents, + StudioDependencyResolverPort dependencies, + ContentAnalyzerPort contentAnalyzer, + DependencyRevisionPort dependencyRevisions, + ValidationArtifactPort validations, + PreviewArtifactPort previews, + PublicationWriterPort publications, + TransactionPort transactionPort, + Clock clock) { + return new PublishStudioDocumentUseCase( + documents, + dependencies, + contentAnalyzer, + dependencyRevisions, + validations, + previews, + publications, + transactionPort, + clock); + } + + @Bean + UnpublishStudioPublicationUseCase unpublishStudioPublicationUseCase( + PublicationHistoryQueryPort history, + PublicationWriterPort publications, + StudioDocumentLoader documents, + TransactionPort transactionPort) { + return new UnpublishStudioPublicationUseCase(history, publications, documents, transactionPort); + } + + @Bean + ListStudioPublicationsUseCase listStudioPublicationsUseCase( + PublicationHistoryQueryPort history, TransactionPort transactionPort) { + return new ListStudioPublicationsUseCase(history, transactionPort); + } + + @Bean + GetStudioPublicationSnapshotUseCase getStudioPublicationSnapshotUseCase( + PublicationHistoryQueryPort history, TransactionPort transactionPort) { + return new GetStudioPublicationSnapshotUseCase(history, transactionPort); + } + + @Bean + GetStudioDashboardUseCase getStudioDashboardUseCase( + StudioDashboardQueryPort dashboard, + PublicationHistoryQueryPort history, + TransactionPort transactionPort) { + return new GetStudioDashboardUseCase(dashboard, history, transactionPort); + } + + /** spec §9 — Asset 은 새 저장 계층을 만들지 않고 기존 object storage 어댑터를 재사용한다. */ + @Bean + @SuppressWarnings("removal") + AssetBinaryStoragePort assetBinaryStoragePort(ObjectProvider objectStorage) { + return new ObjectStorageAssetBinaryAdapter(objectStorage); + } + + @Bean + UploadStudioAssetUseCase uploadStudioAssetUseCase( + AssetRepositoryPort assets, + AssetBinaryStoragePort binaries, + TransactionPort transactionPort) { + return new UploadStudioAssetUseCase(assets, binaries, transactionPort, UUID::randomUUID); + } + + @Bean + ListStudioAssetsUseCase listStudioAssetsUseCase( + AssetRepositoryPort assets, TransactionPort transactionPort) { + return new ListStudioAssetsUseCase(assets, transactionPort); + } + + @Bean + GetStudioAssetUseCase getStudioAssetUseCase( + AssetRepositoryPort assets, TransactionPort transactionPort) { + return new GetStudioAssetUseCase(assets, transactionPort); + } + + @Bean + UpdateStudioAssetUseCase updateStudioAssetUseCase( + AssetRepositoryPort assets, TransactionPort transactionPort) { + return new UpdateStudioAssetUseCase(assets, transactionPort); + } + + @Bean + DeleteStudioAssetUseCase deleteStudioAssetUseCase( + AssetRepositoryPort assets, + AssetBinaryStoragePort binaries, + TransactionPort transactionPort) { + return new DeleteStudioAssetUseCase(assets, binaries, transactionPort); + } } diff --git a/src/app-bootstrap/src/main/resources/application-dev.yml b/src/app-bootstrap/src/main/resources/application-dev.yml index b14cd2a..b5d7378 100644 --- a/src/app-bootstrap/src/main/resources/application-dev.yml +++ b/src/app-bootstrap/src/main/resources/application-dev.yml @@ -22,6 +22,17 @@ spring: ca-skeleton: persistence: vendor: postgresql + authz: + role-permissions: + # Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라 + # 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다. + # + # application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿 + # 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을 + # 흔들지 않고 프로파일에서 더한다. + ${APP_STUDIO_AUTHOR_ROLE:studio-author}: + - studio:write + security: # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a # `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match. diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml index 07818f2..2e8a117 100644 --- a/src/app-bootstrap/src/main/resources/application-local.yml +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -65,6 +65,25 @@ spring: properties: hibernate: format_sql: false + objectstorage: + # Studio Asset 바이너리 저장소. ObjectStorageConfig 는 이 prefix 의 property 가 하나라도 있어야 + # 활성화된다(LegacyObjectStorageActivationGuard) — application.yml 이 아니라 프로파일에 두는 이유는, + # 저장 백엔드 선택이 배포마다 다른 결정이라 템플릿 기준선이 그것을 대신 정해서는 안 되기 때문이다. + # 이 값이 없는 배포에서는 업로드·삭제만 STUDIO_UNAVAILABLE 로 거절되고 나머지 Asset operation 은 + # 그대로 동작한다. + backend: filesystem + + authz: + role-permissions: + # Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라 + # 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다. + # + # application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿 + # 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을 + # 흔들지 않고 프로파일에서 더한다. + ${APP_STUDIO_AUTHOR_ROLE:studio-author}: + - studio:write + security: oauth2: resourceserver: diff --git a/src/app-bootstrap/src/main/resources/application-prod.yml b/src/app-bootstrap/src/main/resources/application-prod.yml index efa917e..9cde678 100644 --- a/src/app-bootstrap/src/main/resources/application-prod.yml +++ b/src/app-bootstrap/src/main/resources/application-prod.yml @@ -26,6 +26,17 @@ spring: ca-skeleton: persistence: vendor: postgresql + authz: + role-permissions: + # Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라 + # 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다. + # + # application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿 + # 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을 + # 흔들지 않고 프로파일에서 더한다. + ${APP_STUDIO_AUTHOR_ROLE:studio-author}: + - studio:write + security: # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a # `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match. diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index 39dbe93..42a6d32 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -446,6 +446,16 @@ ca-skeleton: # prefix "/v1" (major-version path, AIP-185); override via env, or set "" for # no prefix. The supplemental "X-Api-Version" header never overrides the path. api-base-path: ${PRESENTATION_API_BASE_PATH:/v1} + techlog: + studio: + # Studio 문서 목록 커서 서명 키. 값이 없거나 16바이트 미만이면 StudioSettings가 경고하고 개발용 + # 값으로 대체한다 — 커서에 권한이 실리지 않아 부팅을 막을 사유는 아니지만, 인스턴스마다 값이 + # 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다. + cursor-signing-key: ${APP_STUDIO_CURSOR_SIGNING_KEY:} + # 검증 결과가 유효한 기간(studio_validation.valid_until). + validation-ttl: ${APP_STUDIO_VALIDATION_TTL:1h} + # 미리보기가 유효한 기간(studio_preview.expires_at). + preview-ttl: ${APP_STUDIO_PREVIEW_TTL:24h} idempotency: # feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h, # validated in IdempotencyProperties); reaper-interval is literal operational tuning. diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java index 7d8b223..aeaa90c 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java @@ -28,6 +28,13 @@ class TechLogBoundaryArchTest { // (publication과 같은 성격) NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN과 같은 모양의 // 전용 규칙도 검토한다. + // 이 규칙들의 패키지 패턴(`..techlog...`)은 계층을 가리지 않는다 — application 뿐 아니라 + // adapter 쪽 패키지도 같은 이름을 쓰면 걸린다. 그래서 어댑터 패키지를 bounded context 이름으로 + // 짓지 않는다: Studio 의 outbound 포트를 구현하는 영속 어댑터는 + // `...persistence.techlog.studio.` 에 둔다. 그 이름이 규칙을 피하려는 우회가 아니라 실제로 더 + // 정확하다 — 그 어댑터들은 asset/publication context 의 소유물이 아니라 Studio 포트의 구현이다. + // (슬라이스 4~5 에서 `...persistence.techlog.asset` 로 지었다가 이 규칙이 223건을 잡아냈다.) + @ArchTest static final ArchRule CONTENT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS = noClasses() diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreateDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreateDocumentCommand.java new file mode 100644 index 0000000..35d48b3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreateDocumentCommand.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreatePreviewCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreatePreviewCommand.java new file mode 100644 index 0000000..a3770a6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/CreatePreviewCommand.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/DeleteAssetCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/DeleteAssetCommand.java new file mode 100644 index 0000000..2395c14 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/DeleteAssetCommand.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/PublishDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/PublishDocumentCommand.java new file mode 100644 index 0000000..5a2c58a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/PublishDocumentCommand.java @@ -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 acknowledgedWarningCodes, + String idempotencyKey, + String principal) + implements Command { + + public PublishDocumentCommand { + acknowledgedWarningCodes = + acknowledgedWarningCodes == null ? List.of() : List.copyOf(acknowledgedWarningCodes); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/SaveDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/SaveDocumentCommand.java new file mode 100644 index 0000000..0a2efef --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/SaveDocumentCommand.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UnpublishPublicationCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UnpublishPublicationCommand.java new file mode 100644 index 0000000..9044d1f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UnpublishPublicationCommand.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UpdateAssetCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UpdateAssetCommand.java new file mode 100644 index 0000000..8f99f6d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UpdateAssetCommand.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UploadAssetCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UploadAssetCommand.java new file mode 100644 index 0000000..7d07e9d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/UploadAssetCommand.java @@ -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}의 입력. + * + *

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; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/ValidateDocumentCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/ValidateDocumentCommand.java new file mode 100644 index 0000000..d871e98 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/command/ValidateDocumentCommand.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetDetailView.java new file mode 100644 index 0000000..dd7c1cc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetDetailView.java @@ -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 usages, boolean hasPublicationHistory) { + + public AssetDetailView { + usages = usages == null ? List.of() : List.copyOf(usages); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetKindView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetKindView.java new file mode 100644 index 0000000..cdc8340 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetKindView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code AssetKind}. */ +public enum AssetKindView { + IMAGE, + DIAGRAM, + ATTACHMENT +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManagementStatusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManagementStatusView.java new file mode 100644 index 0000000..b2f4ff8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManagementStatusView.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 계약 {@code AssetManagementStatus}. + * + *

{@link #REJECTED}/{@link #QUARANTINED}는 서버 검증 결과이며 클라이언트가 지정할 수 없다. + */ +public enum AssetManagementStatusView { + READY, + ARCHIVED, + REJECTED, + QUARANTINED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManifestEntry.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManifestEntry.java new file mode 100644 index 0000000..cb9a6aa --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetManifestEntry.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.UUID; + +/** + * 게시 시점에 고정하는 Asset descriptor 한 건({@code publication_snapshot.asset_manifest}). + * + *

이후 Asset 이 교체돼도 과거 Snapshot 의 표현이 변하지 않게 하는 장치다 — ADR-002 가 요구하는 역사적 불변성이며, ADR-005 가 인정한 "네 + * 화면 중 Snapshot 만 다른 유일한 지점"이다. + */ +public record AssetManifestEntry( + UUID assetId, + String assetKey, + String mediaType, + String publicPath, + Integer width, + Integer height, + boolean decorative) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetPageView.java new file mode 100644 index 0000000..17b9948 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code AssetPage}. */ +public record AssetPageView(List items, String nextCursor) { + + public AssetPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetUsageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetUsageView.java new file mode 100644 index 0000000..526b621 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetUsageView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetView.java new file mode 100644 index 0000000..a445182 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/AssetView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardTotalsView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardTotalsView.java new file mode 100644 index 0000000..deb651d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardTotalsView.java @@ -0,0 +1,5 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code DashboardTotals}. */ +public record DashboardTotalsView( + int documents, int needsValidation, int readyToPublish, int publications) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardView.java new file mode 100644 index 0000000..9a69946 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DashboardView.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code StudioDashboard}. */ +public record DashboardView( + List continueWriting, + List readyToPublish, + List 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); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DecisionStatusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DecisionStatusView.java new file mode 100644 index 0000000..62b73fd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DecisionStatusView.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약의 UI 용어(ADR-003). Domain의 {@code ACCEPTED}가 {@link #ADOPTED}로 보인다. */ +public enum DecisionStatusView { + PROPOSED, + ADOPTED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DisplayTargetView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DisplayTargetView.java new file mode 100644 index 0000000..82814d8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DisplayTargetView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentPageView.java new file mode 100644 index 0000000..407bd7a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code DocumentPage}. */ +public record DocumentPageView(List items, String nextCursor) { + + public DocumentPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSort.java new file mode 100644 index 0000000..ada29f4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSort.java @@ -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 +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSummaryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSummaryView.java new file mode 100644 index 0000000..38c3631 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/DocumentSummaryView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/NextAction.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/NextAction.java new file mode 100644 index 0000000..3ef89d0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/NextAction.java @@ -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 +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/OrderedTextView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/OrderedTextView.java new file mode 100644 index 0000000..582ddce --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/OrderedTextView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewDetailView.java new file mode 100644 index 0000000..79b9074 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewDetailView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewState.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewState.java new file mode 100644 index 0000000..95e4ac8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PreviewState.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PreviewDetail.state}. 저장하지 않고 조회 시 계산한다(spec §7.3). */ +public enum PreviewState { + CURRENT, + STALE, + EXPIRED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java new file mode 100644 index 0000000..50ea669 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPaths.java @@ -0,0 +1,33 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 공개 경로 규칙(설계 01장 §3, {@code public-v1.yaml}의 path). + * + *

한 곳에 둔다 — 렌더 모델의 {@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; + }; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPreviewView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPreviewView.java new file mode 100644 index 0000000..938b150 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicPreviewView.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.Instant; +import java.util.UUID; + +/** + * 계약 {@code PublicPreview}. + * + *

{@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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationActionView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationActionView.java new file mode 100644 index 0000000..3c5565d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationActionView.java @@ -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 +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateStatus.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateStatus.java new file mode 100644 index 0000000..0288e08 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateStatus.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublicationAggregate.status}. */ +public enum PublicationAggregateStatus { + PUBLISHED, + UNPUBLISHED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateView.java new file mode 100644 index 0000000..1eaa80a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationAggregateView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventTypeView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventTypeView.java new file mode 100644 index 0000000..7c9acb8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventTypeView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublicationEventType}. */ +public enum PublicationEventTypeView { + PUBLISHED, + REPUBLISHED, + UNPUBLISHED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventView.java new file mode 100644 index 0000000..51b2806 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationEventView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationListItemView.java new file mode 100644 index 0000000..2e2f0d7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationListItemView.java @@ -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 availableActions) { + + public PublicationListItemView { + availableActions = availableActions == null ? List.of() : List.copyOf(availableActions); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationPageView.java new file mode 100644 index 0000000..8d5a354 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; + +/** 계약 {@code PublicationPage}. */ +public record PublicationPageView(List items, String nextCursor) { + + public PublicationPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationSnapshotView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationSnapshotView.java new file mode 100644 index 0000000..b57d54d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationSnapshotView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationStatusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationStatusView.java new file mode 100644 index 0000000..a6addc6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublicationStatusView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublicationStatus}. */ +public enum PublicationStatusView { + NEVER_PUBLISHED, + PUBLISHED, + UNPUBLISHED +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublishResultView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublishResultView.java new file mode 100644 index 0000000..992c29d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/PublishResultView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code PublishResult}. */ +public record PublishResultView(PublicationAggregateView publication, PublicationEventView event) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionOptionView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionOptionView.java new file mode 100644 index 0000000..6fba33e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionOptionView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionResolutionView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionResolutionView.java new file mode 100644 index 0000000..51de31e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionResolutionView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionStatusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionStatusView.java new file mode 100644 index 0000000..e28fccd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/QuestionStatusView.java @@ -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 +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RecordKind.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RecordKind.java new file mode 100644 index 0000000..4659176 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RecordKind.java @@ -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 +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ReferenceRuleView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ReferenceRuleView.java new file mode 100644 index 0000000..3d93f39 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ReferenceRuleView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RelationView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RelationView.java new file mode 100644 index 0000000..f739532 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RelationView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RenderInput.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RenderInput.java new file mode 100644 index 0000000..2ea07d4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/RenderInput.java @@ -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 relations, + DisplayTargetView resolutionEvidenceTarget, + Map assetsByKey, + String dependencyRevision, + Instant generatedAt) { + + public RenderInput { + relations = relations == null ? List.of() : List.copyOf(relations); + assetsByKey = assetsByKey == null ? Map.of() : Map.copyOf(assetsByKey); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedAssetView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedAssetView.java new file mode 100644 index 0000000..a6fb5fd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedAssetView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedRelationView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedRelationView.java new file mode 100644 index 0000000..e64c2d4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ResolvedRelationView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationIssueView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationIssueView.java new file mode 100644 index 0000000..e4e2016 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationIssueView.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationReportView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationReportView.java new file mode 100644 index 0000000..6fc16f9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationReportView.java @@ -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 issues, + Instant validatedAt, + Instant validUntil, + String dependencyRevision) { + + public ValidationReportView { + issues = issues == null ? List.of() : List.copyOf(issues); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationSeverity.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationSeverity.java new file mode 100644 index 0000000..8671912 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationSeverity.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code ValidationIssue.severity}. */ +public enum ValidationSeverity { + ERROR, + WARNING +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationStatus.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationStatus.java new file mode 100644 index 0000000..439edb8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/ValidationStatus.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** 계약 {@code ValidationReport.status}. */ +public enum ValidationStatus { + INVALID, + WARNINGS, + VALID +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyBaseInput.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyBaseInput.java new file mode 100644 index 0000000..9d7e4b2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyBaseInput.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.util.List; +import java.util.UUID; + +/** + * 계약 {@code WorkingCopyInputBase}. 네 유형이 공유하는 편집 필드다. + * + *

불완전한 초안도 저장할 수 있어야 하므로 빈 문자열과 null을 허용한다 — 게시 가능 여부는 {@code validateStudioDocument}가 판단한다(계약 + * 주석). + * + * @param slug 빈 문자열은 "아직 정하지 않음"이다. null이 아니다. + */ +public record WorkingCopyBaseInput( + RecordKind kind, + String title, + String slug, + String summary, + UUID topicId, + UUID projectId, + List relations) { + + public WorkingCopyBaseInput { + relations = relations == null ? List.of() : List.copyOf(relations); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyDetailView.java new file mode 100644 index 0000000..43407f1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyDetailView.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.studio.model; + +/** + * 계약 {@code WorkingCopyDetail}. 편집본과 그 주변 상태를 한 번에 준다. + * + *

{@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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyInputView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyInputView.java new file mode 100644 index 0000000..7aebd04 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyInputView.java @@ -0,0 +1,86 @@ +package dev.caskeleton.application.techlog.studio.model; + +import java.time.LocalDate; +import java.util.List; + +/** + * 계약 {@code WorkingCopyInput} union. 저장 요청으로 들어오는 편집 내용이다. + * + *

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 rules, + List applyWhen, + List exceptions, + List 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 facts, + List assumptions, + List unknowns, + List constraints, + List 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 consequences) + implements WorkingCopyInputView { + + public ProjectDecisionInputView { + consequences = consequences == null ? List.of() : List.copyOf(consequences); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyView.java new file mode 100644 index 0000000..d0097d7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/model/WorkingCopyView.java @@ -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. 저장된 편집본을 읽어 돌려줄 때의 모양이다. + * + *

{@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 rules, + List applyWhen, + List exceptions, + List examples, + LocalDate verifiedOn) + implements WorkingCopyView {} + + /** 계약 {@code QuestionWorkingCopy}. */ + record QuestionWorkingCopyView( + UUID id, + long version, + Instant updatedAt, + WorkingCopyBaseInput base, + QuestionStatusView questionStatus, + List facts, + List assumptions, + List unknowns, + List constraints, + List 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 consequences) + implements WorkingCopyView {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetBinaryStoragePort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetBinaryStoragePort.java new file mode 100644 index 0000000..c58b255 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetBinaryStoragePort.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +/** + * Asset 바이너리 저장. spec §9 — Studio 는 새 저장 계층을 만들지 않고 기존 object storage 어댑터에 위임한다. + * + *

{@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); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetRepositoryPort.java new file mode 100644 index 0000000..7bebb7d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/AssetRepositoryPort.java @@ -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 find(UUID assetId); + + Optional findDetail(UUID assetId); + + AssetView create(NewAsset asset, String principal); + + /** + * 낙관적 잠금 갱신. + * + * @return {@code expectedVersion} 이 현재 버전과 다르면 {@link Optional#empty()} + */ + Optional update( + UUID assetId, + long expectedVersion, + AssetKindView kind, + String altText, + boolean altTextProvided, + Boolean decorative, + AssetManagementStatusView managementStatus, + String principal); + + /** + * 바이너리를 실제로 저장한 위치. 계약이 노출하지 않는 값이라 {@code AssetView} 에는 없다. + * + *

삭제 경로가 이 값을 규칙으로 다시 계산하지 않고 저장된 것을 읽는 이유: 규칙이 두 곳에 있으면 업로드 규칙이 바뀐 순간 옛 Asset 의 바이너리를 못 찾아 + * 조용히 남는다. + */ + Optional 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) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ContentAnalyzerPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ContentAnalyzerPort.java new file mode 100644 index 0000000..1c98ded --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ContentAnalyzerPort.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import java.util.List; + +/** + * 본문 Markdown을 게시 검증에 필요한 만큼 분석한다(spec §7.5 7~8단계). + * + *

렌더러와 같은 파서를 쓴다 — 검증이 "이 문서가 참조하는 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 assetUsages, List 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) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/DependencyRevisionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/DependencyRevisionPort.java new file mode 100644 index 0000000..7c4cdd4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/DependencyRevisionPort.java @@ -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). + * + *

전역 카운터가 아니다 — 이 문서가 실제로 의존하는 것들(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); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PreviewArtifactPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PreviewArtifactPort.java new file mode 100644 index 0000000..430f350 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PreviewArtifactPort.java @@ -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 latestFor(RecordKind kind, UUID documentId); + + Optional findById(UUID previewId); + + PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationHistoryQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationHistoryQueryPort.java new file mode 100644 index 0000000..5bd2686 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationHistoryQueryPort.java @@ -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 findSnapshot(UUID publicationEventId); + + Optional findById(UUID publicationId); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationQueryPort.java new file mode 100644 index 0000000..5c444dd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationQueryPort.java @@ -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 currentFor(UUID documentId); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationWriterPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationWriterPort.java new file mode 100644 index 0000000..29c0f6f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/PublicationWriterPort.java @@ -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단계. 한 트랜잭션 안에서 전부 수행한다. + * + *

결정(무엇을 게시해도 되는가)은 use case 가 하고, 여기서는 결정된 것을 쓰기만 한다 — 그래야 "어떤 경로로 게시했느냐"에 따라 검사 항목이 달라지는 일이 + * 생기지 않는다. + */ +public interface PublicationWriterPort { + + /** spec §7.5 1단계. 같은 문서에 대한 동시 게시를 직렬화한다. */ + Optional lockCurrentPublication(RecordKind kind, UUID documentId); + + PublishResultView publish(PublishRequest request); + + PublishResultView unpublish(UnpublishRequest request); + + /** + * 게시할 내용 전부. 값 하나하나가 이미 검증을 통과한 상태다. + * + * @param renderModelJson 사용자가 확인한 미리보기의 렌더 모델. 게시 시점에 다시 렌더링하지 않는다 — 다시 렌더링하면 승인한 화면과 공개된 + * 화면이 달라질 수 있다(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 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) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/RenderModelPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/RenderModelPort.java new file mode 100644 index 0000000..6fa9c7c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/RenderModelPort.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.techlog.studio.port.out; + +import dev.caskeleton.application.techlog.studio.model.RenderInput; + +/** + * 편집본을 계약의 {@code PublicRenderModel} JSON으로 만든다. + * + *

결과를 구조화된 application 타입이 아니라 JSON 문자열로 주고받는 이유: 이 값은 {@code studio_preview.render_model}에 그대로 + * 들어가고 게시 시 그대로 snapshot으로 옮겨진 뒤 그대로 응답으로 나간다. 중간에서 한 번 더 접었다 펴면 사용자가 확인한 화면과 공개된 화면이 달라질 수 있고, 그 + * 차이는 아무도 검증하지 않는다(ADR-005가 막으려는 바로 그 사건이다). + * + *

구현이 inbound web 모듈에 있는 것은 의도적이다 — 렌더 모델은 계약 DTO이고 그 타입을 소유한 모듈이 거기다. application에 같은 모양을 한 벌 더 + * 두면 두 정의가 갈라진다. + */ +@FunctionalInterface +public interface RenderModelPort { + + String renderToJson(RenderInput input); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDashboardQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDashboardQueryPort.java new file mode 100644 index 0000000..1245201 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDashboardQueryPort.java @@ -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 topByNextAction(List actions, int limit); + + DashboardTotalsView totals(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDependencyResolverPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDependencyResolverPort.java new file mode 100644 index 0000000..9e10e2e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDependencyResolverPort.java @@ -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 소유권. + * + *

검증과 렌더링이 같은 해석 결과를 쓴다. 두 곳이 따로 조회하면 그 사이에 상태가 바뀌어 "검증은 통과했는데 렌더는 없는 Asset을 가리킨다" 같은 사건이 + * 생긴다. + */ +@FunctionalInterface +public interface StudioDependencyResolverPort { + + Resolved resolve(WorkingCopyView document, Set referencedAssetKeys); + + /** + * 해석 결과. + * + * @param topic 없으면 null. {@code topicMissing}이 참이면 topicId가 있는데 그 행이 없다는 뜻이다. + * @param assetStatusByKey key → {@code management_status}. 없는 key는 아예 담기지 않는다. + * @param slugOwnerId 같은 유형에서 이 slug를 이미 쓰는 다른 문서. 없으면 null. + */ + record Resolved( + DisplayTargetView topic, + boolean topicMissing, + DisplayTargetView project, + boolean projectMissing, + List relations, + List missingRelationTargets, + Map assetsByKey, + Map 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); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDocumentQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDocumentQueryPort.java new file mode 100644 index 0000000..bc8b879 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/StudioDocumentQueryPort.java @@ -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); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ValidationArtifactPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ValidationArtifactPort.java new file mode 100644 index 0000000..d424bc2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/ValidationArtifactPort.java @@ -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 latestFor(RecordKind kind, UUID documentId); + + Optional findById(UUID validationId); + + ValidationReportView save(RecordKind kind, ValidationReportView report, String principal); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/WorkingCopyRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/WorkingCopyRepositoryPort.java new file mode 100644 index 0000000..c41f841 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/port/out/WorkingCopyRepositoryPort.java @@ -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으로 읽고 쓴다. + * + *

범용 CRUD repository가 아니다 — 구현이 {@code kind}에 따라 각자의 테이블로 dispatch한다(ADR-003). + */ +public interface WorkingCopyRepositoryPort { + + /** {@code documentId}가 어느 유형인지 해소한다(spec §7.2 StudioDocumentLocator). */ + Optional findKind(UUID documentId); + + Optional find(UUID documentId); + + WorkingCopyView create(WorkingCopyInputView input, String principal); + + /** + * 낙관적 잠금 저장. + * + * @return 저장된 편집본. {@code expectedVersion}이 현재 버전과 다르면 {@link Optional#empty()} — "없음"과 "충돌"을 + * 호출자가 구분할 수 있도록 존재 확인은 {@link #find}가 따로 한다. + */ + Optional save( + UUID documentId, long expectedVersion, WorkingCopyInputView input, String principal); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/DocumentCursorPosition.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/DocumentCursorPosition.java new file mode 100644 index 0000000..9de5b7f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/DocumentCursorPosition.java @@ -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) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetAssetQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetAssetQuery.java new file mode 100644 index 0000000..f854a5c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetAssetQuery.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDashboardQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDashboardQuery.java new file mode 100644 index 0000000..1e3a934 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDashboardQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.studio.query; + +import dev.caskeleton.application.query.Query; + +/** {@code getStudioDashboard}의 입력. 파라미터가 없다. */ +public record GetDashboardQuery() implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDocumentQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDocumentQuery.java new file mode 100644 index 0000000..dcaaced --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetDocumentQuery.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetPreviewQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetPreviewQuery.java new file mode 100644 index 0000000..ede62da --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetPreviewQuery.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetSnapshotQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetSnapshotQuery.java new file mode 100644 index 0000000..b545585 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/GetSnapshotQuery.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListAssetsQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListAssetsQuery.java new file mode 100644 index 0000000..d10b747 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListAssetsQuery.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListDocumentsQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListDocumentsQuery.java new file mode 100644 index 0000000..bc9d781 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListDocumentsQuery.java @@ -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}의 입력. + * + *

{@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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListPublicationsQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListPublicationsQuery.java new file mode 100644 index 0000000..d76cedc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/query/ListPublicationsQuery.java @@ -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 {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/AssetMediaTypes.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/AssetMediaTypes.java new file mode 100644 index 0000000..4baa0f3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/AssetMediaTypes.java @@ -0,0 +1,71 @@ +package dev.caskeleton.application.techlog.studio.service; + +import java.util.Map; + +/** + * 업로드 내용으로 media type 을 판정한다. 확장자와 클라이언트가 보낸 {@code Content-Type} 은 신뢰하지 않는다(계약 {@code + * uploadStudioAsset} 설명) — 둘 다 보내는 쪽이 마음대로 정할 수 있는 값이다. + * + *

파일 시작 바이트(magic number)로만 판정하고, 아는 형식이 아니면 거절한다. 모르는 것을 통과시키면 그 파일이 무엇인지 아무도 모르는 채로 공개된다. + */ +public final class AssetMediaTypes { + + /** 계약 {@code uploadStudioAsset} 의 {@code encoding.file.contentType} 목록. */ + private static final Map 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 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 {@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 { + + 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())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioPreviewUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioPreviewUseCase.java new file mode 100644 index 0000000..52cd8fe --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/CreateStudioPreviewUseCase.java @@ -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 을 만든다(계약 설명). + * + *

Preview 는 Public 과 같은 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 { + + 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 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 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 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; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/DeleteStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/DeleteStudioAssetUseCase.java new file mode 100644 index 0000000..fbd9e81 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/DeleteStudioAssetUseCase.java @@ -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 { + + 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; + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java new file mode 100644 index 0000000..e93120f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java @@ -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 { + + 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; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java new file mode 100644 index 0000000..76e1d73 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java @@ -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 { + + 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()))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java new file mode 100644 index 0000000..93eb9de --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java @@ -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 { + + /** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */ + private static final int SECTION_SIZE = 5; + + /** "이어서 쓰기"에 해당하는 상태들. 게시까지 갈 수 없는 것부터 보여준다. */ + private static final List CONTINUE_WRITING = + List.of(NextAction.CONTINUE_EDITING, NextAction.FIX_VALIDATION, NextAction.VALIDATE); + + private static final List 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())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java new file mode 100644 index 0000000..cfcea80 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java @@ -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 { + + 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()))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java new file mode 100644 index 0000000..8e26722 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java @@ -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 { + + 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()))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java new file mode 100644 index 0000000..a864f66 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java @@ -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 { + + 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)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java new file mode 100644 index 0000000..66b2e25 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java @@ -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 { + + /** 계약 {@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)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java new file mode 100644 index 0000000..eb7fbbb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java @@ -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 { + + 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)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/NextActionCalculator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/NextActionCalculator.java new file mode 100644 index 0000000..881931f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/NextActionCalculator.java @@ -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 컬럼에도 저장하지 않는다. + * + *

프론트가 여러 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()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/PublishStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/PublishStudioDocumentUseCase.java new file mode 100644 index 0000000..5ac0891 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/PublishStudioDocumentUseCase.java @@ -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 의 게시 트랜잭션. + * + *

단계별 실패가 서로 다른 계약 코드로 나가는 것이 이 use case 의 핵심이다. {@code DOCUMENT_VALIDATION_FAILED}(지금 검증하면 실패)와 + * {@code VALIDATION_STALE}(통과했으나 전제가 바뀜)은 다른 사건이고, 작성자가 해야 할 일도 다르다 — 전자는 고치는 것이고 후자는 다시 검증하는 것이다. + * + *

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 PublishStudioDocumentUseCase + implements CommandUseCase { + + /** 설계 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 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 acknowledged) { + Set 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 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; + }; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveOutcome.java new file mode 100644 index 0000000..c57c579 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveOutcome.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; + +/** + * 저장 결과. 낙관적 잠금 충돌을 예외가 아니라 값으로 돌려준다. + * + *

계약의 {@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 {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveStudioDocumentUseCase.java new file mode 100644 index 0000000..82b050a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/SaveStudioDocumentUseCase.java @@ -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 { + + 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 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)); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioDocumentLoader.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioDocumentLoader.java new file mode 100644 index 0000000..ba974e3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioDocumentLoader.java @@ -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; + +/** + * "존재하고, 클라이언트가 생각하는 그 버전인가"를 한 곳에서 확인한다. + * + *

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)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java new file mode 100644 index 0000000..3718b96 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.studio.service; + +/** + * Studio가 요구하는 권한 토큰. + * + *

배포는 {@code ca-skeleton.authz.role-permissions. = [studio:write]}로 자기 IdP의 역할 이름을 여기에 + * 잇는다. 역할 이름을 코드에 박지 않는 이유는 그것이 배포마다 다른 값이기 때문이다 — 계약({@code StudioSession.roles})도 역할 이름을 고정하지 + * 않는다. + */ +public final class StudioPermissions { + + /** 편집본 생성·저장·검증·미리보기·게시가 요구하는 권한. */ + public static final String WRITE = "studio:write"; + + private StudioPermissions() {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UnpublishStudioPublicationUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UnpublishStudioPublicationUseCase.java new file mode 100644 index 0000000..b9cc180 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UnpublishStudioPublicationUseCase.java @@ -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 { + + 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())); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UpdateStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UpdateStudioAssetUseCase.java new file mode 100644 index 0000000..1248f22 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UpdateStudioAssetUseCase.java @@ -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 { + + 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())); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java new file mode 100644 index 0000000..8fdad45 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java @@ -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 { + + /** 업로드 상한. 넘으면 계약의 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 idGenerator; + + public UploadStudioAssetUseCase( + AssetRepositoryPort assets, + AssetBinaryStoragePort binaries, + TransactionPort transactions, + Supplier 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); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ValidateStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ValidateStudioDocumentUseCase.java new file mode 100644 index 0000000..9246d58 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ValidateStudioDocumentUseCase.java @@ -0,0 +1,116 @@ +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.studio.command.ValidateDocumentCommand; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +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.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.time.Duration; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * {@code validateStudioDocument}. 단순 request validation 이 아니라 계약이 정한 체인 전체를 수행하고, 결과를 일급 artifact 로 + * 영속한다(계약 설명, spec §7.3). + * + *

결과를 버리지 않는 이유는 {@code createStudioPreview} 와 {@code publishStudioDocument} 가 {@code + * validationId} 로 "무엇을 근거로 통과했는지"를 참조하기 때문이다. + */ +/* + * 이 클래스가 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 ValidateStudioDocumentUseCase + implements CommandUseCase { + + private final StudioDocumentLoader documents; + private final StudioDependencyResolverPort dependencies; + private final ContentAnalyzerPort contentAnalyzer; + private final DependencyRevisionPort dependencyRevisions; + private final ValidationArtifactPort validations; + private final TransactionPort transactions; + private final Supplier idGenerator; + private final Clock clock; + private final Duration validationTtl; + + public ValidateStudioDocumentUseCase( + StudioDocumentLoader documents, + StudioDependencyResolverPort dependencies, + ContentAnalyzerPort contentAnalyzer, + DependencyRevisionPort dependencyRevisions, + ValidationArtifactPort validations, + TransactionPort transactions, + Supplier idGenerator, + Clock clock, + Duration validationTtl) { + 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.transactions = Objects.requireNonNull(transactions, "transactions"); + this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.validationTtl = Objects.requireNonNull(validationTtl, "validationTtl"); + } + + @Override + public ValidationReportView handle(ValidateDocumentCommand input) { + return transactions.inWrite( + () -> { + WorkingCopyView document = + documents.requireAtVersion(input.documentId(), input.expectedVersion()); + ContentAnalyzerPort.ContentAnalysis content = + contentAnalyzer.analyze(bodyMarkdownOf(document)); + Set 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); + + var now = clock.instant(); + ValidationReportView report = + new ValidationReportView( + idGenerator.get(), + document.id(), + document.version(), + outcome.status(), + outcome.issues(), + now, + now.plus(validationTtl), + dependencyRevisions.revisionFor(document.kind(), document.id())); + return validations.save(document.kind(), report, input.principal()); + }); + } + + /** {@code CASE} 만 본문을 갖는다 — 나머지 세 유형의 공개 모델은 구조화된 필드로만 이루어진다. */ + static String bodyMarkdownOf(WorkingCopyView document) { + return document instanceof WorkingCopyView.CaseWorkingCopyView value + ? value.bodyMarkdown() + : ""; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyDetailAssembler.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyDetailAssembler.java new file mode 100644 index 0000000..63c7dcd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyDetailAssembler.java @@ -0,0 +1,59 @@ +package dev.caskeleton.application.techlog.studio.service; + +import dev.caskeleton.application.techlog.studio.model.PublicPreviewView; +import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView; +import dev.caskeleton.application.techlog.studio.model.ValidationReportView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; +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.PublicationQueryPort; +import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort; +import java.time.Clock; +import java.util.Objects; + +/** + * 편집본 하나를 계약의 {@code WorkingCopyDetail} 모양으로 모은다. + * + *

{@code getStudioDocument}와 {@code saveStudioDocument}가 같은 응답 스키마를 쓰고, 낙관적 잠금 충돌의 {@code + * VersionConflictDetails.latestDocument}도 같은 모양이라 세 곳이 같은 조립기를 공유한다 — 세 곳이 제각기 조립하면 {@code + * nextAction} 계산이 갈라진다. + */ +public final class WorkingCopyDetailAssembler { + + private final ValidationArtifactPort validations; + private final PreviewArtifactPort previews; + private final PublicationQueryPort publications; + private final DependencyRevisionPort dependencyRevisions; + private final Clock clock; + + public WorkingCopyDetailAssembler( + ValidationArtifactPort validations, + PreviewArtifactPort previews, + PublicationQueryPort publications, + DependencyRevisionPort dependencyRevisions, + Clock clock) { + this.validations = Objects.requireNonNull(validations, "validations"); + this.previews = Objects.requireNonNull(previews, "previews"); + this.publications = Objects.requireNonNull(publications, "publications"); + this.dependencyRevisions = Objects.requireNonNull(dependencyRevisions, "dependencyRevisions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public WorkingCopyDetailView assemble(WorkingCopyView document) { + String dependencyRevision = dependencyRevisions.revisionFor(document.kind(), document.id()); + ValidationReportView validation = + validations.latestFor(document.kind(), document.id()).orElse(null); + PublicPreviewView preview = previews.latestFor(document.kind(), document.id()).orElse(null); + PublicationAggregateView publication = publications.currentFor(document.id()).orElse(null); + + return new WorkingCopyDetailView( + document, + validation, + preview, + publication, + dependencyRevision, + NextActionCalculator.calculate( + document, validation, preview, publication, dependencyRevision, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java new file mode 100644 index 0000000..07c007e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java @@ -0,0 +1,76 @@ +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.RelationView; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 저장 요청이 형태로서 성립하는지만 본다. 게시 가능 여부는 여기서 판단하지 않는다 — 그건 {@code validateStudioDocument}의 일이고, + * 계약이 불완전한 초안의 저장을 명시적으로 허용한다. + * + *

필드 길이·필수 여부 같은 계약 제약은 생성 DTO의 jakarta 애너테이션이 웹 경계에서 이미 잡는다. 여기서 다시 확인하는 것은 그 애너테이션으로 표현할 수 없는 + * 것들뿐이다 — slug 패턴의 "빈 문자열 또는 패턴" 양자택일, 관계 순서의 중복, 자기 자신 참조. + */ +public final class WorkingCopyInputValidator { + + /** 계약 {@code WorkingCopyInputBase.slug}의 두 번째 분기. */ + private static final Pattern SLUG = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + + private static final int MAX_RELATIONS = 20; + + private WorkingCopyInputValidator() {} + + public static void validate(WorkingCopyInputView input) { + if (input == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document is required"); + } + WorkingCopyBaseInput base = input.base(); + if (base == null || base.kind() == null) { + throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document.kind is required"); + } + validateSlug(base.slug()); + validateRelations(base.relations()); + } + + private static void validateSlug(String slug) { + if (slug == null) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.slug must be present; use \"\" when it is not decided yet"); + } + if (!slug.isEmpty() && !SLUG.matcher(slug).matches()) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.slug must be empty or match " + SLUG.pattern()); + } + } + + private static void validateRelations(List relations) { + if (relations.size() > MAX_RELATIONS) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations must hold at most " + MAX_RELATIONS + " items"); + } + Set orders = new HashSet<>(); + for (RelationView relation : relations) { + if (relation.order() < 0) { + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations[].order must not be negative"); + } + if (!orders.add(relation.order())) { + // 순서가 겹치면 저장은 되지만 다시 읽을 때 줄 순서가 비결정적이 된다 — 사용자가 쓴 순서와 + // 다른 순서로 돌아오는 편집기는 데이터가 조용히 뒤바뀐 것과 같다. + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations[].order must be unique; " + relation.order() + " is repeated"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java new file mode 100644 index 0000000..d6cb3e7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/StudioDocumentValidator.java @@ -0,0 +1,262 @@ +package dev.caskeleton.application.techlog.studio.validation; + +import dev.caskeleton.application.techlog.studio.model.OrderedTextView; +import dev.caskeleton.application.techlog.studio.model.QuestionStatusView; +import dev.caskeleton.application.techlog.studio.model.RelationView; +import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView; +import dev.caskeleton.application.techlog.studio.model.ValidationStatus; +import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput; +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.StudioDependencyResolverPort; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 계약 {@code validateStudioDocument}의 검증 체인(계약 설명, spec §7.5 6~8단계). + * + *

{@code
+ * Schema/Input -> 유형별 Domain -> 관계/Project/Topic 존재 -> Asset READY
+ *   -> Slug/Route 충돌 -> Publication
+ * }
+ * + *

판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 {@code PublicRenderModel}을 만들 수 있는가. 그래서 각 규칙은 렌더 모델의 + * required/minLength/minItems에서 나온다. 검증이 그것과 다른 기준을 쓰면 검증을 통과한 문서가 렌더 단계에서 계약을 위반한다. + */ +public final class StudioDocumentValidator { + + private StudioDocumentValidator() {} + + /** + * 검증 결과. + * + * @param status ERROR가 하나라도 있으면 {@code INVALID}, WARNING만 있으면 {@code WARNINGS} + */ + public record Outcome( + ValidationStatus status, + List issues) {} + + public static Outcome validate( + WorkingCopyView document, + StudioDependencyResolverPort.Resolved resolved, + ContentAnalyzerPort.ContentAnalysis content) { + + ValidationIssues issues = new ValidationIssues(); + validateBase(document.base(), issues); + validateDependencies(document, resolved, issues); + validateKindSpecific(document, issues); + validateContent(document, resolved, content, issues); + + ValidationStatus status = + issues.hasError() + ? ValidationStatus.INVALID + : (issues.hasWarning() ? ValidationStatus.WARNINGS : ValidationStatus.VALID); + return new Outcome(status, issues.toList()); + } + + private static void validateBase(WorkingCopyBaseInput base, ValidationIssues issues) { + requireText(issues, base.title(), "TITLE_REQUIRED", "/title", "a title is required to publish"); + // 렌더 모델의 slug 는 minLength 3 + 패턴이다. 저장은 빈 slug 를 허용하지만 게시는 못 한다. + if (base.slug() == null || base.slug().isBlank()) { + issues.error("SLUG_REQUIRED", "/slug", "a slug is required to publish"); + } else if (base.slug().length() < 3) { + issues.error("SLUG_TOO_SHORT", "/slug", "a slug must be at least 3 characters"); + } + requireText( + issues, base.summary(), "SUMMARY_REQUIRED", "/summary", "a summary is required to publish"); + if (base.topicId() == null) { + issues.error("TOPIC_REQUIRED", "/topicId", "a topic is required to publish"); + } + } + + private static void validateDependencies( + WorkingCopyView document, + StudioDependencyResolverPort.Resolved resolved, + ValidationIssues issues) { + + if (resolved.topicMissing()) { + issues.error("TOPIC_NOT_FOUND", "/topicId", "the referenced topic no longer exists"); + } + if (resolved.projectMissing()) { + issues.error("PROJECT_NOT_FOUND", "/projectId", "the referenced project no longer exists"); + } + if (document.kind() + == dev.caskeleton.application.techlog.studio.model.RecordKind.PROJECT_DECISION + && document.base().projectId() == null) { + // 계약: "kind=PROJECT_DECISION 은 게시 시점에 non-null 이어야 한다." + issues.error("PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project"); + } + + List relations = document.base().relations(); + for (int i = 0; i < relations.size(); i++) { + RelationView relation = relations.get(i); + String path = "/relations/" + i + "/targetId"; + if (relation.targetId() == null) { + issues.error("RELATION_TARGET_REQUIRED", path, "a relation must point at something"); + } else if (resolved.missingRelationTargets().contains(relation.targetId())) { + issues.error("RELATION_TARGET_NOT_FOUND", path, "the related record no longer exists"); + } + } + + UUID slugOwner = resolved.slugOwnerId(); + if (slugOwner != null && !slugOwner.equals(document.id())) { + issues.error("SLUG_CONFLICT", "/slug", "another record already publishes this slug"); + } + if (resolved.publicPath() == null) { + issues.error( + "PUBLIC_PATH_UNRESOLVED", "/slug", "a public path cannot be derived for this record"); + } + } + + private static void validateKindSpecific(WorkingCopyView document, ValidationIssues issues) { + switch (document) { + case WorkingCopyView.CaseWorkingCopyView value -> { + requireText( + issues, value.problem(), "PROBLEM_REQUIRED", "/problem", "a problem is required"); + requireText( + issues, + value.conclusion(), + "CONCLUSION_REQUIRED", + "/conclusion", + "a conclusion is required"); + if (value.lastVerifiedOn() == null) { + issues.error( + "LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "a verification date is required"); + } + } + case WorkingCopyView.ReferenceWorkingCopyView value -> { + requireText( + issues, value.purpose(), "PURPOSE_REQUIRED", "/purpose", "a purpose is required"); + if (value.rules().isEmpty()) { + issues.error("RULES_REQUIRED", "/rules", "at least one rule is required"); + } + if (value.applyWhen().isEmpty()) { + issues.error( + "APPLY_WHEN_REQUIRED", + "/applyWhen", + "at least one application condition is required"); + } + if (value.verifiedOn() == null) { + issues.error("VERIFIED_ON_REQUIRED", "/verifiedOn", "a verification date is required"); + } + requireOrderedText(issues, value.applyWhen(), "/applyWhen"); + requireOrderedText(issues, value.exceptions(), "/exceptions"); + requireOrderedText(issues, value.examples(), "/examples"); + } + case WorkingCopyView.QuestionWorkingCopyView value -> { + if (value.questionStatus() == null) { + issues.error("QUESTION_STATUS_REQUIRED", "/questionStatus", "a status is required"); + } + if (value.facts().isEmpty()) { + issues.error("FACTS_REQUIRED", "/facts", "at least one established fact is required"); + } + requireText( + issues, + value.nextValidation(), + "NEXT_VALIDATION_REQUIRED", + "/nextValidation", + "the next validation step is required"); + if (value.questionStatus() == QuestionStatusView.RESOLVED + && (value.resolution() == null || isBlank(value.resolution().summary()))) { + issues.error( + "RESOLUTION_REQUIRED", "/resolution", "a resolved question needs its resolution"); + } + requireOrderedText(issues, value.facts(), "/facts"); + requireOrderedText(issues, value.assumptions(), "/assumptions"); + requireOrderedText(issues, value.unknowns(), "/unknowns"); + requireOrderedText(issues, value.constraints(), "/constraints"); + } + case WorkingCopyView.ProjectDecisionWorkingCopyView value -> { + if (value.decisionStatus() == null) { + issues.error("DECISION_STATUS_REQUIRED", "/decisionStatus", "a status is required"); + } + if (value.decidedOn() == null) { + issues.error("DECIDED_ON_REQUIRED", "/decidedOn", "a decision date is required"); + } + requireText( + issues, + value.statement(), + "STATEMENT_REQUIRED", + "/statement", + "a statement is required"); + requireText( + issues, + value.rationale(), + "RATIONALE_REQUIRED", + "/rationale", + "a rationale is required"); + requireOrderedText(issues, value.consequences(), "/consequences"); + } + } + } + + private static void validateContent( + WorkingCopyView document, + StudioDependencyResolverPort.Resolved resolved, + ContentAnalyzerPort.ContentAnalysis content, + ValidationIssues issues) { + + Map assets = resolved.assetsByKey(); + Map statuses = resolved.assetStatusByKey(); + + for (int i = 0; i < content.assetUsages().size(); i++) { + ContentAnalyzerPort.AssetUsage usage = content.assetUsages().get(i); + String path = "/bodyMarkdown/" + i; + if (isBlank(usage.assetKey())) { + issues.error("ASSET_KEY_REQUIRED", path, "an evidence figure needs a key"); + continue; + } + ResolvedAssetView asset = assets.get(usage.assetKey()); + if (asset == null) { + issues.error("ASSET_NOT_FOUND", path, "no asset is registered for key " + usage.assetKey()); + continue; + } + String status = statuses.get(usage.assetKey()); + if ("QUARANTINED".equals(status)) { + issues.error("ASSET_QUARANTINED", path, "a quarantined asset must not be published"); + } else if (!"READY".equals(status)) { + issues.error("ASSET_NOT_READY", path, "only a READY asset can be published"); + } + // 판단 대상은 asset 행의 alt 가 아니라 이 사용 위치의 alt 다 — 같은 Asset 이 문서마다 다른 + // alt 로 쓰인다(설계 05장 §3.4). + if (!asset.decorative() && isBlank(usage.alt())) { + issues.error( + "ASSET_ALT_REQUIRED", path, "a non-decorative asset needs alt text at this usage"); + } + } + + for (String directive : content.unsupportedDirectives()) { + issues.warning( + "UNSUPPORTED_DIRECTIVE", + "/bodyMarkdown", + "the ':::" + directive + "' directive is rendered as a plain quote"); + } + if (document instanceof WorkingCopyView.CaseWorkingCopyView value + && isBlank(value.bodyMarkdown())) { + issues.warning("BODY_EMPTY", "/bodyMarkdown", "this case will publish without a body"); + } + } + + private static void requireText( + ValidationIssues issues, String value, String code, String path, String message) { + if (isBlank(value)) { + issues.error(code, path, message); + } + } + + /** 계약의 {@code OrderedText.text} 는 minLength 1 이다 — 빈 항목이 있으면 렌더 모델이 계약을 어긴다. */ + private static void requireOrderedText( + ValidationIssues issues, List items, String path) { + for (int i = 0; i < items.size(); i++) { + if (isBlank(items.get(i).text())) { + issues.error( + "ORDERED_TEXT_EMPTY", path + "/" + i + "/text", "an empty item cannot publish"); + } + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/ValidationIssues.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/ValidationIssues.java new file mode 100644 index 0000000..8c669a7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/validation/ValidationIssues.java @@ -0,0 +1,46 @@ +package dev.caskeleton.application.techlog.studio.validation; + +import dev.caskeleton.application.techlog.studio.model.ValidationIssueView; +import dev.caskeleton.application.techlog.studio.model.ValidationSeverity; +import java.util.ArrayList; +import java.util.List; + +/** + * 검증 결과를 모으는 수집기. + * + *

첫 오류에서 멈추지 않고 끝까지 모은다 — 작성자가 고칠 것을 한 번에 보게 하기 위해서다. 하나씩 알려주면 저장·검증을 오류 개수만큼 반복하게 된다. + */ +public final class ValidationIssues { + + /** 계약 {@code ValidationReport.issues} 의 maxItems. 넘치면 뒤는 버린다. */ + private static final int MAX_ISSUES = 200; + + private final List issues = new ArrayList<>(); + + public void error(String code, String path, String message) { + add(code, ValidationSeverity.ERROR, path, message); + } + + public void warning(String code, String path, String message) { + add(code, ValidationSeverity.WARNING, path, message); + } + + private void add(String code, ValidationSeverity severity, String path, String message) { + if (issues.size() >= MAX_ISSUES) { + return; + } + issues.add(new ValidationIssueView(code, severity, path, message)); + } + + public List toList() { + return List.copyOf(issues); + } + + public boolean hasError() { + return issues.stream().anyMatch(issue -> issue.severity() == ValidationSeverity.ERROR); + } + + public boolean hasWarning() { + return issues.stream().anyMatch(issue -> issue.severity() == ValidationSeverity.WARNING); + } +}