diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index 22f655b..821265d 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -1467,4 +1467,30 @@ errors: log_level: INFO runbook_link: null compatibility_impact: additive + required_test: ManagementErrorRegistryTest + # source: studio-management-v1.yaml ApiError.code — DECISION_NOT_FOUND (ManagementError.DECISION_NOT_FOUND) + - code: DECISION_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-management-v1 + owner_layer: application + client_safe_message: "결정을 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: ManagementErrorRegistryTest + # source: studio-management-v1.yaml ApiError.code — DECISION_IN_USE (ManagementError.DECISION_IN_USE) + - code: DECISION_IN_USE + category: CONFLICT + http_status: 409 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-management-v1 + owner_layer: application + client_safe_message: "이 결정을 참조하는 곳이 있어 삭제할 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive required_test: ManagementErrorRegistryTest \ No newline at end of file diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java index 547e63b..ea0ae49 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/ManagementClientSafeMessages.java @@ -31,6 +31,8 @@ public final class ManagementClientSafeMessages { case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다"; case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다"; case QUESTION_IN_USE -> "이 질문을 참조하는 곳이 있어 삭제할 수 없습니다"; + case DECISION_NOT_FOUND -> "결정을 찾을 수 없습니다"; + case DECISION_IN_USE -> "이 결정을 참조하는 곳이 있어 삭제할 수 없습니다"; }; } } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementDocumentController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementDocumentController.java index 68283e7..d74b1db 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementDocumentController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementDocumentController.java @@ -5,6 +5,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipal import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest; import dev.caskeleton.application.techlog.management.command.DeleteDocumentCommand; import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase; +import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase; import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase; import java.util.UUID; import org.springframework.http.HttpStatus; @@ -26,11 +27,15 @@ public class ManagementDocumentController { private final DeleteDocumentDraftUseCase deleteDocument; private final DeleteQuestionUseCase deleteQuestion; + private final DeleteProjectDecisionUseCase deleteDecision; public ManagementDocumentController( - DeleteDocumentDraftUseCase deleteDocument, DeleteQuestionUseCase deleteQuestion) { + DeleteDocumentDraftUseCase deleteDocument, + DeleteQuestionUseCase deleteQuestion, + DeleteProjectDecisionUseCase deleteDecision) { this.deleteDocument = deleteDocument; this.deleteQuestion = deleteQuestion; + this.deleteDecision = deleteDecision; } @DeleteMapping("/v1/studio/cases/{id}") @@ -60,6 +65,15 @@ public class ManagementDocumentController { deleteQuestion.handle(command(id, body, principal)); } + @DeleteMapping("/v1/studio/projects/{id}/decisions/{decisionId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteProjectDecision( + @PathVariable("id") UUID id, + @PathVariable("decisionId") UUID decisionId, + @RequestBody ExpectedVersionRequest body) { + deleteDecision.handle(id, decisionId, body.getExpectedVersion()); + } + private static DeleteDocumentCommand command( UUID id, ExpectedVersionRequest body, AuthenticatedPrincipal principal) { return new DeleteDocumentCommand( diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicMediaController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicMediaController.java new file mode 100644 index 0000000..e4b476e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicMediaController.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.application.techlog.publicsite.model.PublicMediaView; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase; +import java.util.UUID; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * 업로드한 이미지를 실제로 내보내는 곳. + * + *

이 경로가 없어서 Asset 은 저장은 되지만 어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 asset 주소를 가리키는데 그 주소를 서빙하는 코드가 없었다. + * + *

{@code /api/v1/public/} 아래에 두는 이유는 그 접두사가 이미 익명 허용({@code security.public-paths})이고 엣지가 이미 + * 백엔드로 보내기 때문이다. 별도 접두사를 쓰면 nginx 를 함께 고쳐야 하고, 그 사이에 이미지는 계속 깨진다. + * + *

이 표면은 봉투를 쓰지 않는다. 바이트를 반환하므로 {@code EnvelopeBodyAdvice} 가 감쌀 것이 없고, {@code } 는 JSON 을 + * 읽지 않는다. + */ +@RestController +public class PublicMediaController { + + /** 내용이 바뀌면 주소도 바뀐다 — Asset 은 업로드 시점에 고정되고 같은 id 로 다른 바이트가 오지 않는다. 그래서 길게 캐시하고 immutable 을 붙인다. */ + private static final CacheControl CACHE = + CacheControl.maxAge(java.time.Duration.ofDays(365)).cachePublic().immutable(); + + private final GetPublicMediaUseCase media; + + public PublicMediaController(GetPublicMediaUseCase media) { + this.media = media; + } + + @GetMapping("/v1/public/media/{assetId}") + public ResponseEntity getPublicMedia(@PathVariable("assetId") UUID assetId) { + PublicMediaView view = media.handle(assetId); + return ResponseEntity.ok() + .cacheControl(CACHE) + .contentType(MediaType.parseMediaType(view.mediaType())) + .contentLength(view.content().length) + .body(view.content()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java index c00b9e2..babf0b9 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcDocumentDeletionAdapter.java @@ -117,6 +117,85 @@ public class JdbcDocumentDeletionAdapter implements DocumentDeletionPort { .single()); } + private static DeletableDecision mapDecision(ResultSet rs, int rowNum) throws SQLException { + return new DeletableDecision( + rs.getObject("id", UUID.class), rs.getString("decision_status"), rs.getLong("version")); + } + + @Override + public Optional findDecision(UUID projectId, UUID decisionId) { + return jdbcClient + .sql( + "SELECT id, decision_status, version FROM project_decision" + + " WHERE id = :id AND project_id = :projectId") + .param("id", decisionId) + .param("projectId", projectId) + .query(JdbcDocumentDeletionAdapter::mapDecision) + .optional(); + } + + @Override + public int deleteDecision(UUID decisionId, long expectedVersion) { + return jdbcClient + .sql("DELETE FROM project_decision WHERE id = :id AND version = :expected") + .param("id", decisionId) + .param("expected", expectedVersion) + .update(); + } + + /** + * {@code superseded_by_id} 는 자기 테이블을 가리키고 {@code home_focus_config} 도 CASCADE 가 아니다. 둘 다 그대로 지우면 + * 외래키 위반이 된다. + */ + @Override + public boolean decisionReferenced(UUID decisionId) { + return Boolean.TRUE.equals( + jdbcClient + .sql( + "SELECT EXISTS (" + + " SELECT 1 FROM project_decision WHERE superseded_by_id = :id" + + " UNION ALL SELECT 1 FROM home_focus_config WHERE recent_decision_id = :id" + + ")") + .param("id", decisionId) + .query(Boolean.class) + .single()); + } + + @Override + public boolean hasPublicationHistory(String sourceKind, UUID id) { + return Boolean.TRUE.equals( + jdbcClient + .sql( + "SELECT EXISTS (" + + " SELECT 1 FROM publication" + + " WHERE source_kind = :kind AND source_id = :id" + + " UNION ALL SELECT 1 FROM publication_event" + + " WHERE source_kind = :kind AND source_id = :id" + + ")") + .param("kind", sourceKind) + .param("id", id) + .query(Boolean.class) + .single()); + } + + /** 미리보기가 검증을 참조하므로 미리보기를 먼저 지운다. */ + @Override + public int deleteWorkArtifacts(String sourceKind, UUID id) { + int previews = + jdbcClient + .sql("DELETE FROM studio_preview WHERE source_kind = :kind AND source_id = :id") + .param("kind", sourceKind) + .param("id", id) + .update(); + int validations = + jdbcClient + .sql("DELETE FROM studio_validation WHERE source_kind = :kind AND source_id = :id") + .param("kind", sourceKind) + .param("id", id) + .update(); + return previews + validations; + } + @Override public Optional findQuestion(UUID id) { return jdbcClient diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java index 5a0d327..09b5e03 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java @@ -241,7 +241,7 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { ? null : new AssetReferenceView( assetId, - "/media/" + assetId, + "/api/v1/public/media/" + assetId, rs.getString("cover_alt"), (Integer) rs.getObject("cover_width"), (Integer) rs.getObject("cover_height"), diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java index 1e20806..6648395 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java @@ -58,7 +58,7 @@ public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort { return new AssetReferenceView( assetId, // 본문과 마찬가지로 저장소 경로가 아니라 안정적인 전송 경로를 노출한다(설계 05장 §3.1). - "/media/" + assetId, + "/api/v1/public/media/" + assetId, rs.getString("alt_text"), (Integer) rs.getObject("width"), (Integer) rs.getObject("height"), 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 index 8e571c7..113060e 100644 --- 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 @@ -177,7 +177,7 @@ public class JdbcStudioDependencyResolverAdapter implements StudioDependencyReso rs.getString("content_type"), // 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다 // (설계 05장 §3.1). - "/media/" + rs.getString("id"), + "/api/v1/public/media/" + rs.getString("id"), (Integer) rs.getObject("width"), (Integer) rs.getObject("height"), rs.getBoolean("decorative"))); 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 index ea2b867..da44f9c 100644 --- 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 @@ -189,7 +189,7 @@ public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort { rs.getBoolean("decorative"), AssetManagementStatusView.valueOf(rs.getString("management_status")), // 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다. - "/media/" + rs.getString("id"), + "/api/v1/public/media/" + rs.getString("id"), rs.getInt("usage_count"), rs.getLong("version"), rs.getTimestamp("created_at").toInstant(), diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java index d946eee..471efac 100644 --- a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/ManagementPersistenceIntegrationTest.java @@ -116,6 +116,43 @@ class ManagementPersistenceIntegrationTest { .update(); } + /** + * {@code publication.latest_event_id} 와 {@code publication_event.publication_id} 는 서로를 가리키고, 앞의 + * 것만 {@code DEFERRABLE INITIALLY DEFERRED} 다. 그래서 두 삽입이 한 transaction 안에 있어야 한다 — 문장마다 커밋하면 지연 + * 검사가 그 자리에서 터진다. + */ + private static void insertPublication(UUID sourceId) { + UUID publicationId = UUID.randomUUID(); + UUID eventId = UUID.randomUUID(); + try (java.sql.Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + try (java.sql.PreparedStatement publication = + connection.prepareStatement( + "INSERT INTO publication (publication_id, source_kind, source_id, status," + + " published_version, latest_event_id, public_path)" + + " VALUES (?, 'CASE', ?, 'PUBLISHED', 0, ?, ?)")) { + publication.setObject(1, publicationId); + publication.setObject(2, sourceId); + publication.setObject(3, eventId); + publication.setString(4, "/cases/published-" + sourceId); + publication.executeUpdate(); + } + try (java.sql.PreparedStatement event = + connection.prepareStatement( + "INSERT INTO publication_event (publication_event_id, publication_id, source_kind," + + " source_id, event_type, published_version, created_by)" + + " VALUES (?, ?, 'CASE', ?, 'PUBLISHED', 0, 'test')")) { + event.setObject(1, eventId); + event.setObject(2, publicationId); + event.setObject(3, sourceId); + event.executeUpdate(); + } + connection.commit(); + } catch (java.sql.SQLException failure) { + throw new IllegalStateException("failed to seed publication history", failure); + } + } + private static int countIn(String table, String column, UUID id) { return jdbcClient .sql("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = :id") @@ -232,4 +269,111 @@ class ManagementPersistenceIntegrationTest { assertThat(countIn("question_point", "question_id", id)).isZero(); assertThat(deletion.findQuestion(id)).isEmpty(); } + + @Test + void seesPublicationHistoryThroughTheKindAndIdPair() { + // 게시 이력은 문서를 외래키 없이 (source_kind, source_id) 로 가리킨다. 이 쿼리가 없어서 + // 지운 문서를 가리키는 이력이 남았고, 대시보드와 게시 기록 화면이 null 을 읽고 죽었다. + UUID id = insertCase("게시된 적 있는 기록"); + assertThat(deletion.hasPublicationHistory("CASE", id)).isFalse(); + + insertPublication(id); + + assertThat(deletion.hasPublicationHistory("CASE", id)).isTrue(); + assertThat(deletion.hasPublicationHistory("QUESTION", id)).isFalse(); + } + + @Test + void clearsTheValidationAndPreviewArtifactsInDependencyOrder() { + UUID id = insertCase("검증과 미리보기를 남긴 기록"); + UUID validationId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO studio_validation (validation_id, source_kind, source_id," + + " validated_version, status, dependency_revision, valid_until, created_by)" + + " VALUES (:vid, 'CASE', :id, 0, 'VALID', 'rev'," + + " now() + interval '1 hour', 'test')") + .param("vid", validationId) + .param("id", id) + .update(); + jdbcClient + .sql( + "INSERT INTO studio_preview (preview_id, validation_id, source_kind, source_id," + + " source_version, dependency_revision, render_model, expires_at, created_by)" + + " VALUES (:pid, :vid, 'CASE', :id, 0, 'rev', '{}'::jsonb," + + " now() + interval '1 hour', 'test')") + .param("pid", UUID.randomUUID()) + .param("vid", validationId) + .param("id", id) + .update(); + + // 미리보기가 검증을 참조하므로 순서가 틀리면 외래키 위반이다. + assertThat(deletion.deleteWorkArtifacts("CASE", id)).isEqualTo(2); + assertThat(countIn("studio_validation", "source_id", id)).isZero(); + assertThat(countIn("studio_preview", "source_id", id)).isZero(); + } + + private static UUID insertDecision(String statement) { + UUID projectId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO project (id, name, created_by, updated_by) VALUES (:id, :name, 'test', 'test')") + .param("id", projectId) + .param("name", "결정이 속한 프로젝트 " + projectId) + .update(); + UUID id = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO project_decision (id, project_id, statement, created_by, updated_by)" + + " VALUES (:id, :projectId, :statement, 'test', 'test')") + .param("id", id) + .param("projectId", projectId) + .param("statement", statement) + .update(); + return id; + } + + private static UUID projectOf(UUID decisionId) { + return jdbcClient + .sql("SELECT project_id FROM project_decision WHERE id = :id") + .param("id", decisionId) + .query(UUID.class) + .single(); + } + + @Test + void findsADecisionOnlyUnderItsOwnProject() { + UUID id = insertDecision("이 프로젝트의 결정"); + assertThat(deletion.findDecision(projectOf(id), id)).isPresent(); + // 경로가 프로젝트를 들고 있으므로, 다른 프로젝트 주소로는 닿지 않아야 한다. + assertThat(deletion.findDecision(UUID.randomUUID(), id)).isEmpty(); + } + + @Test + void reportsDecisionReferencesFromItsOwnTables() { + UUID id = insertDecision("가리켜지는 결정"); + assertThat(deletion.decisionReferenced(id)).isFalse(); + + UUID successor = insertDecision("대체하는 결정"); + jdbcClient + .sql("UPDATE project_decision SET superseded_by_id = :successor WHERE id = :id") + .param("successor", successor) + .param("id", id) + .update(); + // superseded_by_id 는 자기 테이블을 CASCADE 없이 가리킨다 — 가리켜지는 쪽(successor)을 + // 그대로 지우면 외래키 위반이 되므로 그쪽이 "쓰이고 있음" 이어야 한다. + assertThat(deletion.decisionReferenced(successor)).isTrue(); + // 가리키는 쪽은 자유롭게 지울 수 있다. + assertThat(deletion.decisionReferenced(id)).isFalse(); + } + + @Test + void deletesADecisionAndRefusesAStaleVersion() { + UUID id = insertDecision("지워질 결정"); + // 지운 뒤에는 프로젝트를 되물을 수 없으므로 먼저 붙잡아 둔다. + UUID projectId = projectOf(id); + assertThat(deletion.deleteDecision(id, 3L)).isZero(); + assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1); + assertThat(deletion.findDecision(projectId, id)).isEmpty(); + } } diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java index 85b2bbb..2ec11fe 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java @@ -161,7 +161,7 @@ class PublicContractDriftTest { .isNotEmpty(); } - /** 반대 방향 — 계약의 18 operation 이 전부 published 표면에 있는가. */ + /** 반대 방향 — 계약의 19 operation 이 전부 published 표면에 있는가. */ @Test void everyContractOperationIsPublished() throws Exception { JsonNode contract = readContract(); @@ -187,7 +187,7 @@ class PublicContractDriftTest { } assertThat(missing).as("계약이 약속했는데 서버가 제공하지 않는 operation").isEmpty(); // 계약이 통째로 비거나 잘못 읽혀도 위 단언은 통과한다 — 순회할 게 없으면 missing 도 비니까. - assertThat(contractOperations).as("public-v1 계약의 operation 수").isEqualTo(18); + assertThat(contractOperations).as("public-v1 계약의 operation 수").isEqualTo(19); } private static String serverPrefix(JsonNode contract) { @@ -415,6 +415,33 @@ class PublicContractDriftTest { }; } + /** + * 미디어 표면은 Studio 의 Asset 저장소를 읽는다. 이 게이트는 계약 표면만 확인하므로 저장소 두 개를 비어 있는 것으로 세워 컨트롤러가 조립되게만 한다. + */ + @Bean + dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase + getPublicMediaUseCase(TransactionPort tx) { + return new dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase( + new StubAssetRepositoryPortForMedia(), + new dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort() { + @Override + public String store(String objectKey, byte[] content, String mediaType) { + return objectKey; + } + + @Override + public java.util.Optional read(String objectKey) { + return java.util.Optional.empty(); + } + + @Override + public void delete(String objectKey) { + // 이 게이트는 바이너리를 다루지 않는다. + } + }, + tx); + } + @Bean GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) { return new GetPublicSiteUseCase(port, tx); @@ -539,4 +566,59 @@ class PublicContractDriftTest { return action.get(); } } + + /** + * 미디어 컨트롤러를 조립하기 위한 최소 Asset 저장소. {@code StudioContractDriftTest} 의 것과 같은 모양이며, 이 게이트는 계약 표면만 보므로 + * 어떤 Asset 도 돌려주지 않는다. + */ + private static final class StubAssetRepositoryPortForMedia + 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) { + // 이 게이트는 삭제하지 않는다. + } + } } 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 577128d..6e38f39 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 @@ -609,6 +609,12 @@ class StudioContractDriftTest { return objectKey; } + @Override + public java.util.Optional read(String objectKey) { + // 이 게이트는 바이너리를 다루지 않는다. + return java.util.Optional.empty(); + } + @Override public void delete(String objectKey) { // 이 게이트는 바이너리를 다루지 않는다. 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 index 82ac561..b404a1e 100644 --- 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 @@ -33,6 +33,11 @@ final class ObjectStorageAssetBinaryAdapter implements AssetBinaryStoragePort { return require().put(objectKey, content, mediaType).key(); } + @Override + public java.util.Optional read(String objectKey) { + return require().get(objectKey); + } + @Override public void delete(String objectKey) { require().delete(objectKey); diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java index d333e69..6afe6a5 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogManagementConfig.java @@ -8,6 +8,7 @@ import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCa import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase; import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase; import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase; +import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase; import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase; import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase; import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase; @@ -121,4 +122,10 @@ public class TechLogManagementConfig { DeleteQuestionUseCase deleteQuestionUseCase(DocumentDeletionPort documents, TransactionPort tx) { return new DeleteQuestionUseCase(documents, tx); } + + @Bean + DeleteProjectDecisionUseCase deleteProjectDecisionUseCase( + DocumentDeletionPort documents, TransactionPort tx) { + return new DeleteProjectDecisionUseCase(documents, tx); + } } diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java index e1a4fa8..7dbb954 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java @@ -11,6 +11,7 @@ import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUse import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase; import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase; import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase; import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase; import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase; import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase; @@ -136,4 +137,13 @@ public class TechLogPublicConfig { PublicSearchQueryPort port, TransactionPort tx) { return new SearchPublicResourcesUseCase(port, tx); } + + /** 공개 미디어. Studio 의 Asset 저장소를 읽지만 표면은 공개 쪽이다 — 업로드는 인증이 필요하고 읽기는 누구나 해야 하기 때문이다. */ + @Bean + GetPublicMediaUseCase getPublicMediaUseCase( + dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets, + dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries, + dev.caskeleton.application.transaction.TransactionPort transactions) { + return new GetPublicMediaUseCase(assets, binaries, transactions); + } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java index 80ded76..cb39d93 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/error/ManagementError.java @@ -10,7 +10,7 @@ import dev.caskeleton.shared.error.Category; *

{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 낼 수 있게 되면 그 * 순간 두 계약 모두 거짓이 된다. * - *

계약의 {@code ApiError.code} 는 20종인데 여기는 19종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통 + *

계약의 {@code ApiError.code} 는 22종인데 여기는 21종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통 * 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이 각자 status 와 retryable * 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은 {@code retryable=true} 다. {@code PublicError} 가 같은 * 이유로 같은 선택을 했다. @@ -34,7 +34,9 @@ public enum ManagementError implements ApiErrorCode { DOCUMENT_PUBLISHED(Category.CONFLICT, 409, false), DOCUMENT_IN_USE(Category.CONFLICT, 409, false), QUESTION_NOT_FOUND(Category.NOT_FOUND, 404, false), - QUESTION_IN_USE(Category.CONFLICT, 409, false); + QUESTION_IN_USE(Category.CONFLICT, 409, false), + DECISION_NOT_FOUND(Category.NOT_FOUND, 404, false), + DECISION_IN_USE(Category.CONFLICT, 409, false); private final Category category; private final int httpStatus; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java index 69108a8..529cd52 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/DocumentDeletionPort.java @@ -44,4 +44,24 @@ public interface DocumentDeletionPort { int deleteQuestion(UUID id, long expectedVersion); boolean questionReferenced(UUID id); + + /** + * 게시 이력이 남아 있는지. {@code publication}/{@code publication_event} 는 문서를 외래키 없이 {@code (source_kind, + * source_id)} 로 가리키므로 DB 가 막아 주지 않는다 — 실제로 그래서 지운 문서를 가리키는 이력이 남아 대시보드와 게시 기록 화면이 null 을 읽고 죽었다. + */ + boolean hasPublicationHistory(String sourceKind, UUID id); + + /** Decision 은 프로젝트에 속한다 — 경로가 둘 다 들고 있으므로 둘로 찾는다. */ + record DeletableDecision(UUID id, String decisionStatus, long version) {} + + Optional findDecision(UUID projectId, UUID decisionId); + + int deleteDecision(UUID decisionId, long expectedVersion); + + boolean decisionReferenced(UUID decisionId); + + /** + * 검증·미리보기 산출물을 치운다. 이것들도 외래키가 없지만 이력과 달리 문서의 작업 부산물이라 문서와 함께 사라지는 것이 맞다 — 남겨 두면 같은 방식으로 화면을 깨뜨린다. + */ + int deleteWorkArtifacts(String sourceKind, UUID id); } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java index 35cb32b..17384fa 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteDocumentDraftUseCase.java @@ -55,6 +55,13 @@ public class DeleteDocumentDraftUseCase { ManagementError.DOCUMENT_PUBLISHED, "the record is published; unpublish it before deleting"); } + // 게시된 적이 있으면 이력이 남아 있다. 그 이력은 무슨 일이 있었는지에 대한 기록이고, + // 문서만 지우면 아무것도 가리키지 않는 이력이 되어 화면을 깨뜨린다. + if (documents.hasPublicationHistory(documentType, command.id())) { + throw ManagementException.of( + ManagementError.DOCUMENT_IN_USE, + "the record has publication history; it cannot be deleted"); + } if (documents.documentReferenced(command.id())) { throw ManagementException.of( ManagementError.DOCUMENT_IN_USE, @@ -63,6 +70,7 @@ public class DeleteDocumentDraftUseCase { // 내려간 공개 투영은 기록에서 파생된 것이므로 함께 치운다 — 외래키가 없어 DB 가 // 대신 해 주지 않고, 남겨 두면 없는 기록을 가리키는 행이 된다. documents.deleteProjection(documentType, command.id()); + documents.deleteWorkArtifacts(documentType, command.id()); if (documents.deleteDocument(command.id(), command.expectedVersion()) == 0) { throw ManagementException.withDetails( ManagementError.VERSION_CONFLICT, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java new file mode 100644 index 0000000..aa1a605 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteProjectDecisionUseCase.java @@ -0,0 +1,74 @@ +package dev.caskeleton.application.techlog.management.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.ManagementError; +import dev.caskeleton.application.techlog.error.ManagementException; +import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort; +import dev.caskeleton.application.techlog.studio.service.StudioPermissions; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import java.util.Objects; +import java.util.UUID; + +/** + * {@code deleteProjectDecision}. + * + *

Case·Reference·Question 에는 삭제가 있었는데 Decision 에만 없어서, 작성자가 연 초안을 접을 방법이 없었다. 수락·기각·대체는 무슨 일이 + * 있었는지 남기는 수명주기이고, 이것은 아직 아무 판단도 하지 않은 초안을 없애는 다른 일이다. + * + *

다른 종류와 같은 두 가지를 지킨다: 게시 이력이 있으면 거절하고 (공개된 기록이 조용히 사라지지 않도록), 다른 기록이 가리키고 있으면 거절한다 (외래키가 + * CASCADE 가 아니므로 그대로 지우면 500 이 된다). + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class DeleteProjectDecisionUseCase { + + private final DocumentDeletionPort documents; + private final TransactionPort transactions; + + public DeleteProjectDecisionUseCase( + DocumentDeletionPort documents, TransactionPort transactions) { + this.documents = Objects.requireNonNull(documents, "documents"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public void handle(UUID projectId, UUID decisionId, long expectedVersion) { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(decisionId, "decisionId"); + transactions.inWrite( + () -> { + DocumentDeletionPort.DeletableDecision current = + documents + .findDecision(projectId, decisionId) + .orElseThrow( + () -> + ManagementException.of( + ManagementError.DECISION_NOT_FOUND, "no such decision")); + if (documents.hasPublicationHistory("PROJECT_DECISION", decisionId)) { + throw ManagementException.of( + ManagementError.DECISION_IN_USE, + "the decision has publication history; it cannot be deleted"); + } + if (documents.decisionReferenced(decisionId)) { + throw ManagementException.of( + ManagementError.DECISION_IN_USE, + "another record still points at this decision; unlink it first"); + } + documents.deleteProjection("PROJECT_DECISION", decisionId); + documents.deleteWorkArtifacts("PROJECT_DECISION", decisionId); + if (documents.deleteDecision(decisionId, expectedVersion) == 0) { + throw ManagementException.withDetails( + ManagementError.VERSION_CONFLICT, + "the decision changed since it was loaded", + new SaveTopicUseCase.VersionConflict(current.version())); + } + return null; + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java index dbc1128..b0bd700 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/DeleteQuestionUseCase.java @@ -48,12 +48,18 @@ public class DeleteQuestionUseCase { ManagementError.DOCUMENT_PUBLISHED, "the question is published; unpublish it before deleting"); } + if (documents.hasPublicationHistory("QUESTION", command.id())) { + throw ManagementException.of( + ManagementError.QUESTION_IN_USE, + "the question has publication history; it cannot be deleted"); + } if (documents.questionReferenced(command.id())) { throw ManagementException.of( ManagementError.QUESTION_IN_USE, "another record still links to this question; unlink it first"); } documents.deleteProjection("QUESTION", command.id()); + documents.deleteWorkArtifacts("QUESTION", command.id()); if (documents.deleteQuestion(command.id(), command.expectedVersion()) == 0) { throw ManagementException.withDetails( ManagementError.VERSION_CONFLICT, diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublicMediaView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublicMediaView.java new file mode 100644 index 0000000..81c16bc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublicMediaView.java @@ -0,0 +1,29 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.Objects; + +/** + * 공개 미디어 응답. 바이트와 그것을 해석할 media type 만 나른다. + * + *

record 가 아닌 이유는 배열 필드 때문이다 — record 의 {@code equals}/{@code hashCode} 는 배열을 참조로 비교하므로 같은 이미지를 + * 두 번 읽으면 서로 다르다고 답한다. 여기서 동치성을 쓰는 곳은 없지만, 쓸 수 있게 열어 두면 틀린 답을 하는 API 가 된다. + */ +public final class PublicMediaView { + + private final byte[] content; + private final String mediaType; + + public PublicMediaView(byte[] content, String mediaType) { + this.content = Objects.requireNonNull(content, "content").clone(); + this.mediaType = Objects.requireNonNull(mediaType, "mediaType"); + } + + /** 호출자가 고쳐도 이쪽이 바뀌지 않도록 복사본을 준다. */ + public byte[] content() { + return content.clone(); + } + + public String mediaType() { + return mediaType; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicMediaUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicMediaUseCase.java new file mode 100644 index 0000000..13a141f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicMediaUseCase.java @@ -0,0 +1,77 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; +import dev.caskeleton.application.techlog.publicsite.model.PublicMediaView; +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 java.util.Objects; +import java.util.UUID; + +/** + * 업로드한 이미지를 실제로 가져갈 수 있게 하는 경로. + * + *

이것이 없어서 Asset 은 저장은 되지만 어디서도 보이지 않았다 — 문서와 공개 화면이 모두 {@code /media/{assetId}} 를 가리키는데 그 주소를 + * 서빙하는 코드가 없었다. 미리보기만의 문제가 아니라 발행된 공개 화면에서도 마찬가지였다. + * + *

접근 조건은 {@code READY} 하나다. 더 좁히는 선택지 — 발행된 기록이 참조하는 Asset 만 — 은 Studio 미리보기를 깨뜨린다: 미리보기는 아직 + * 발행되지 않은 기록을 보는 화면이고, 그때 이미지가 안 보이면 미리보기가 미리보기 구실을 못 한다. 대신 주소를 추측할 수 없는 UUID 로 두고, {@code + * ARCHIVED}/{@code REJECTED}/{@code QUARANTINED} 는 내보내지 않는다. + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public class GetPublicMediaUseCase { + + private final AssetRepositoryPort assets; + private final AssetBinaryStoragePort binaries; + private final TransactionPort transactions; + + public GetPublicMediaUseCase( + AssetRepositoryPort assets, AssetBinaryStoragePort binaries, TransactionPort transactions) { + this.assets = Objects.requireNonNull(assets, "assets"); + this.binaries = Objects.requireNonNull(binaries, "binaries"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public PublicMediaView handle(UUID assetId) { + Objects.requireNonNull(assetId, "assetId"); + return transactions.inRead( + () -> { + AssetView asset = + assets + .find(assetId) + .filter( + candidate -> candidate.managementStatus() == AssetManagementStatusView.READY) + .orElseThrow( + () -> + PublicException.of( + PublicError.PUBLIC_RESOURCE_NOT_FOUND, "no such media")); + String objectKey = + assets + .findObjectKey(assetId) + .orElseThrow( + () -> + PublicException.of( + PublicError.PUBLIC_RESOURCE_NOT_FOUND, "no such media")); + byte[] content = + binaries + .read(objectKey) + // 메타데이터는 있는데 바이트가 없다. 저장소와 테이블이 어긋난 것이므로 서버 + // 오류가 옳지만, 읽는 쪽에서 할 수 있는 일은 없으므로 없는 것으로 답한다. + .orElseThrow( + () -> + PublicException.of( + PublicError.PUBLIC_RESOURCE_NOT_FOUND, "no such media")); + return new PublicMediaView(content, asset.mediaType()); + }); + } +} 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 index c58b255..9b95a74 100644 --- 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 @@ -16,5 +16,13 @@ public interface AssetBinaryStoragePort { */ String store(String objectKey, byte[] content, String mediaType); + /** + * 저장된 바이너리를 읽는다. 업로드만 있고 읽기가 없어서 이 표면에는 이미지가 존재하지만 아무도 가져갈 수 없는 상태였다 — 문서가 {@code + * /media/{id}} 를 가리키는데 그 주소를 서빙하는 곳이 없었다. + * + * @return 해당 키에 객체가 없으면 {@link java.util.Optional#empty()} + */ + java.util.Optional read(String objectKey); + void delete(String objectKey); } diff --git a/src/config/openapi/public-v1.yaml b/src/config/openapi/public-v1.yaml index 9e19c10..42935c1 100644 --- a/src/config/openapi/public-v1.yaml +++ b/src/config/openapi/public-v1.yaml @@ -676,6 +676,50 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + /media/{assetId}: + get: + operationId: getPublicMedia + tags: + - Media + description: |- + 업로드한 Asset 의 바이트를 그대로 돌려준다. 이 경로가 없어서 Asset 은 저장은 되지만 + 어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 이 주소를 가리키는데 서빙하는 곳이 + 없었다. + + `READY` 인 Asset 만 나간다. "발행된 기록이 참조하는 것만" 으로 더 좁히지 않는 이유는 + 그러면 Studio 미리보기가 깨지기 때문이다 — 미리보기는 아직 발행되지 않은 기록을 보는 + 화면이다. 대신 주소가 추측 불가능한 UUID 다. + + 봉투를 쓰지 않는다. 바이트를 반환하므로 감쌀 것이 없고, `` 는 JSON 을 읽지 + 않는다. + parameters: + - name: assetId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + components: schemas: FieldError: diff --git a/src/config/openapi/studio-management-v1.yaml b/src/config/openapi/studio-management-v1.yaml index 36c1573..0103e4e 100644 --- a/src/config/openapi/studio-management-v1.yaml +++ b/src/config/openapi/studio-management-v1.yaml @@ -3390,6 +3390,83 @@ paths: $ref: '#/components/schemas/DecisionUpdateRequest' security: - sessionCookie: [] + delete: + operationId: deleteProjectDecision + tags: + - Decisions + description: |- + 작업본 목록에서 Decision 을 지운다. Case·Reference·Question 에는 이 경로가 있었는데 + Decision 에만 없어서, 작성자가 연 초안을 접을 방법이 없었다. + + 수락·기각·대체는 무슨 일이 있었는지 남기는 수명주기이고 이것은 그것과 다르다 — 아직 + 아무 판단도 하지 않은 초안을 없애는 일이다. 그래서 이미 게시된 Decision 은 거절한다. + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: decisionId + in: path + required: true + schema: + type: string + format: uuid + - $ref: '#/components/parameters/CsrfToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExpectedVersionRequest' + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + security: + - sessionCookie: [] /api/v1/studio/projects/{id}/decisions/{decisionId}/accept: post: operationId: acceptProjectDecision @@ -5276,6 +5353,8 @@ components: - DOCUMENT_IN_USE - QUESTION_NOT_FOUND - QUESTION_IN_USE + - DECISION_NOT_FOUND + - DECISION_IN_USE - INTERNAL_ERROR description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' category: