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: