feat: serve uploaded media, delete a decision, and stop orphaning publications
Three gaps that all showed up as something broken on screen. An uploaded image could never be fetched. Every asset address the backend builds pointed at a path nothing served, so a figure was missing in the Studio preview and in the published page alike. There is now a public media endpoint under the prefix that is already anonymous and already routed, so no edge configuration has to change for an image to appear. Only READY assets are served; narrowing it to assets a published record uses would break previewing, which is the one thing an author does before publishing. Deleting a working copy left wreckage. Publications, validations and previews address a document by (kind, id) with no foreign key, so deleting one left rows pointing at nothing — and the dashboard and publication history read a title off that nothing and crashed. Both screens went down in production this way. A record with publication history is now refused outright, because that history says what was once public; validations and previews go with the record, because they are its scaffolding. A Decision could not be deleted at all while every other kind could. It can now, under the same two rules, and refuses when another decision supersedes it or the home page features it — neither cascades, so deleting would have been a foreign-key violation reaching the author as a 500. The persistence tests cover every one of these queries against real PostgreSQL. That suite exists because a column name I assumed rather than checked reached production once already; three more assumptions were caught here before it could happen again.
This commit is contained in:
@@ -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
|
||||
+2
@@ -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 -> "이 결정을 참조하는 곳이 있어 삭제할 수 없습니다";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -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(
|
||||
|
||||
+46
@@ -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;
|
||||
|
||||
/**
|
||||
* 업로드한 이미지를 실제로 내보내는 곳.
|
||||
*
|
||||
* <p>이 경로가 없어서 Asset 은 저장은 되지만 어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 asset 주소를 가리키는데 그 주소를 서빙하는 코드가 없었다.
|
||||
*
|
||||
* <p>{@code /api/v1/public/} 아래에 두는 이유는 그 접두사가 이미 익명 허용({@code security.public-paths})이고 엣지가 이미
|
||||
* 백엔드로 보내기 때문이다. 별도 접두사를 쓰면 nginx 를 함께 고쳐야 하고, 그 사이에 이미지는 계속 깨진다.
|
||||
*
|
||||
* <p>이 표면은 봉투를 쓰지 않는다. 바이트를 반환하므로 {@code EnvelopeBodyAdvice} 가 감쌀 것이 없고, {@code <img src>} 는 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<byte[]> 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());
|
||||
}
|
||||
}
|
||||
+79
@@ -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<DeletableDecision> 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<DeletableQuestion> findQuestion(UUID id) {
|
||||
return jdbcClient
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
|
||||
+1
-1
@@ -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"),
|
||||
|
||||
+1
-1
@@ -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")));
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+144
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+84
-2
@@ -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<byte[]> 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<dev.caskeleton.application.techlog.studio.model.AssetView> find(
|
||||
java.util.UUID assetId) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetDetailView>
|
||||
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<dev.caskeleton.application.techlog.studio.model.AssetView> 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<String> findObjectKey(java.util.UUID assetId) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(java.util.UUID assetId) {
|
||||
// 이 게이트는 삭제하지 않는다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -609,6 +609,12 @@ class StudioContractDriftTest {
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<byte[]> read(String objectKey) {
|
||||
// 이 게이트는 바이너리를 다루지 않는다.
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String objectKey) {
|
||||
// 이 게이트는 바이너리를 다루지 않는다.
|
||||
|
||||
+5
@@ -33,6 +33,11 @@ final class ObjectStorageAssetBinaryAdapter implements AssetBinaryStoragePort {
|
||||
return require().put(objectKey, content, mediaType).key();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<byte[]> read(String objectKey) {
|
||||
return require().get(objectKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String objectKey) {
|
||||
require().delete(objectKey);
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -10,7 +10,7 @@ import dev.caskeleton.shared.error.Category;
|
||||
* <p>{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 낼 수 있게 되면 그
|
||||
* 순간 두 계약 모두 거짓이 된다.
|
||||
*
|
||||
* <p>계약의 {@code ApiError.code} 는 20종인데 여기는 19종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통
|
||||
* <p>계약의 {@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;
|
||||
|
||||
+20
@@ -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<DeletableDecision> findDecision(UUID projectId, UUID decisionId);
|
||||
|
||||
int deleteDecision(UUID decisionId, long expectedVersion);
|
||||
|
||||
boolean decisionReferenced(UUID decisionId);
|
||||
|
||||
/**
|
||||
* 검증·미리보기 산출물을 치운다. 이것들도 외래키가 없지만 이력과 달리 문서의 작업 부산물이라 문서와 함께 사라지는 것이 맞다 — 남겨 두면 같은 방식으로 화면을 깨뜨린다.
|
||||
*/
|
||||
int deleteWorkArtifacts(String sourceKind, UUID id);
|
||||
}
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+74
@@ -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}.
|
||||
*
|
||||
* <p>Case·Reference·Question 에는 삭제가 있었는데 Decision 에만 없어서, 작성자가 연 초안을 접을 방법이 없었다. 수락·기각·대체는 무슨 일이
|
||||
* 있었는지 남기는 수명주기이고, 이것은 아직 아무 판단도 하지 않은 초안을 없애는 다른 일이다.
|
||||
*
|
||||
* <p>다른 종류와 같은 두 가지를 지킨다: 게시 이력이 있으면 거절하고 (공개된 기록이 조용히 사라지지 않도록), 다른 기록이 가리키고 있으면 거절한다 (외래키가
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -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,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.application.techlog.publicsite.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 공개 미디어 응답. 바이트와 그것을 해석할 media type 만 나른다.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+77
@@ -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;
|
||||
|
||||
/**
|
||||
* 업로드한 이미지를 실제로 가져갈 수 있게 하는 경로.
|
||||
*
|
||||
* <p>이것이 없어서 Asset 은 저장은 되지만 어디서도 보이지 않았다 — 문서와 공개 화면이 모두 {@code /media/{assetId}} 를 가리키는데 그 주소를
|
||||
* 서빙하는 코드가 없었다. 미리보기만의 문제가 아니라 발행된 공개 화면에서도 마찬가지였다.
|
||||
*
|
||||
* <p>접근 조건은 {@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());
|
||||
});
|
||||
}
|
||||
}
|
||||
+8
@@ -16,5 +16,13 @@ public interface AssetBinaryStoragePort {
|
||||
*/
|
||||
String store(String objectKey, byte[] content, String mediaType);
|
||||
|
||||
/**
|
||||
* 저장된 바이너리를 읽는다. 업로드만 있고 읽기가 없어서 이 표면에는 이미지가 <b>존재하지만 아무도 가져갈 수 없는</b> 상태였다 — 문서가 {@code
|
||||
* /media/{id}} 를 가리키는데 그 주소를 서빙하는 곳이 없었다.
|
||||
*
|
||||
* @return 해당 키에 객체가 없으면 {@link java.util.Optional#empty()}
|
||||
*/
|
||||
java.util.Optional<byte[]> read(String objectKey);
|
||||
|
||||
void delete(String objectKey);
|
||||
}
|
||||
|
||||
@@ -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 다.
|
||||
|
||||
봉투를 쓰지 않는다. 바이트를 반환하므로 감쌀 것이 없고, `<img src>` 는 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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user