From 561d02ae3a086cef33a3190d5dcd44716cdf72ff Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 23 Aug 2026 18:19:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EC=99=80=20=ED=99=88=20focus,=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0=20=EA=B8=B0=EB=A1=9D=20=EC=82=AC=EC=9D=B4=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EC=9D=84=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약에 선언만 되어 있고 구현이 없던 네 오퍼레이션과, 의도된 스텁으로 남아 있던 catalog 두 종류가 공개 화면 다섯 곳을 조용히 비워 두고 있었다. 프로젝트 게시 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데, 공개 조회들(프로젝트 목록·상세·프로필의 "현재 프로젝트"·홈 focus)은 전부 `public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을 세우는 경로가 없었으므로 프로젝트는 만들 수는 있어도 공개될 수는 없었다. 릴리스가 자체 경로를 갖는 것과 같은 이유로, 문서 파이프라인에 끼워 넣지 않고 원본 상태·투영·canonical route 세 곳을 한 트랜잭션에서 함께 세운다. 홈 focus — `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 읽고 쓸 use case 가 없었다. 지목한 대상이 실제로 있는지는 여기서 확인한다. 테이블에 FK 가 없어(설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 도 저장되고, 그러면 공개 화면은 조용히 빈 focus 를 그린다 — 저장은 성공했는데 아무것도 안 나오는, 이유를 알 수 없는 실패가 된다. 대상이 공개인지는 확인하지 않는다: 미리 지목해 두고 게시와 동시에 뜨게 하는 것이 정상적인 순서다. catalog RELATION/EVIDENCE — 「슬라이스 2·5에서 채운다」는 주석과 함께 `List.of()` 로 남아 있었다. 그래서 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은 작성 중에 고르는 것이라 작업본까지 포함하고(두 문서를 같이 쓰면서 서로 잇는 것이 정상적인 순서다), EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. 계약은 이 네 오퍼레이션을 ProblemDetails 모양으로 두고 있었다. 백엔드가 모든 JSON 응답을 envelope 으로 감싸므로 구현하는 순간 어긋난다 — 나머지와 같은 모양으로 옮겼다. `/home-focus` 는 케밥 세그먼트라 D19(AIP-122)를 위반해 `/home/focus` 로 나눴다. 함께 고친 것들 (모두 이 작업 전부터 빨간 상태였다): - `error-codes.yaml` 의 DOCUMENT_NOT_FOUND 가 표면마다 하나씩 두 행이었다. 이 레지스트리의 식별자는 code 하나뿐이라 로딩 자체가 깨졌고, 그 여파로 거버넌스 테스트 네 개와 outbox 계약 테스트가 함께 넘어졌다. 같은 코드는 같은 말을 해야 한다. - 벤더된 계약 세 개의 MANIFEST.sha256 이 실제 파일과 어긋나 있었다. - ActuatorSecurityHttpTest 는 "DB·Redis 없는 슬라이스"라고 적어 두고 Redis 자동설정을 막지 않아, localhost:6379 연결 실패가 /actuator/health 를 503 으로 만들었다. 보안 태세와 무관한 이유로 빨개지던 테스트다. - 테스트 JVM 힙이 Gradle 기본 512m 이라 Spring context 캐시 + ArchUnit + Testcontainers 조합에서 OOM 이 났다. 증상이 테스트 실패가 아니라 "Executor 를 완료할 수 없음"이어서 원인을 가리켰다. - APP_SESSION_TIMEOUT 이 env-keys 레지스트리에 없었다. 새 SQL 은 실제 PostgreSQL 위에서 돌린다 — 컴파일도 단위 테스트도 컬럼 이름을 검증하지 못한다는 것이 이 파일이 존재하는 이유다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu --- docs/registries/env-keys.yaml | 16 ++ docs/registries/error-codes.yaml | 20 +- .../ManagementClientSafeMessages.java | 2 +- .../ManagementHomeFocusController.java | 53 +++++ .../ManagementProjectController.java | 36 +++- .../mapper/ManagementResponseMapper.java | 25 +++ .../StudioPublicationController.java | 1 - .../techlog/studio/render/BlockRenderer.java | 63 +++++- .../render/StudioContentRendererTest.java | 7 +- .../JdbcHomeFocusConfigAdapter.java | 104 ++++++++++ .../JdbcProjectRepositoryAdapter.java | 142 +++++++++++++ .../JdbcPublicDocumentQueryAdapter.java | 16 +- .../query/JdbcCatalogQueryAdapter.java | 90 ++++++++- .../ManagementPersistenceIntegrationTest.java | 190 ++++++++++++++++++ .../techlog/TechLogManagementConfig.java | 26 +++ .../src/main/resources/application.yml | 8 + .../contract/ActuatorSecurityHttpTest.java | 12 +- .../command/UpdateHomeFocusCommand.java | 16 ++ .../management/model/HomeFocusConfigView.java | 16 ++ .../port/out/HomeFocusConfigPort.java | 22 ++ .../port/out/ProjectRepositoryPort.java | 11 + .../service/GetHomeFocusUseCase.java | 35 ++++ .../service/PublishProjectUseCase.java | 76 +++++++ .../service/UnpublishProjectUseCase.java | 54 +++++ .../service/UpdateHomeFocusUseCase.java | 89 ++++++++ .../publicsite/model/BodyAssetView.java | 4 +- src/build.gradle | 6 + src/config/openapi/MANIFEST.sha256 | 12 +- src/config/openapi/studio-management-v1.yaml | 129 ++++++------ src/config/openapi/studio-v1.yaml | 34 +++- 30 files changed, 1210 insertions(+), 105 deletions(-) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementHomeFocusController.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcHomeFocusConfigAdapter.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateHomeFocusCommand.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/HomeFocusConfigView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/HomeFocusConfigPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetHomeFocusUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/PublishProjectUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UnpublishProjectUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateHomeFocusUseCase.java diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index 4d66011..caef606 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -112,6 +112,22 @@ env_keys: compatibility_impact: behavior-change required_test: env-contract:server-shutdown-timeout-aligned + - name: APP_SESSION_TIMEOUT + # source: Studio 작성 세션. Spring 기본 30분 유휴 만료는 한 기록을 여러 번 저장하며 + # 오래 머무는 작성 리듬보다 짧아, 저장하지 않은 편집을 잃는 원인이 되었다. + # 늘릴수록 훔친 session cookie 가 유효한 창도 같이 늘어나므로 Keycloak realm 의 + # SSO idle 과 함께 맞춘다. + type: duration + default: 8h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: feature-env-driven-runtime-configuration + validation: spring_duration_shorthand + compatibility_impact: behavior-change + required_test: env-contract:session-timeout + - name: APP_SERVER_FORWARD_HEADERS_STRATEGY # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification # (trust X-Forwarded-* when behind LB/proxy) diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index 821265d..75091fa 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -960,7 +960,12 @@ errors: compatibility_impact: additive required_test: StudioErrorTest - # source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND) + # source: studio-v1.yaml + studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND + # (StudioError.DOCUMENT_NOT_FOUND, ManagementError.DOCUMENT_NOT_FOUND) + # + # 두 표면이 같은 코드를 쓴다. 이 레지스트리의 식별자는 code 하나뿐이므로 행도 하나다 — + # 한때 표면마다 행을 두고 문구를 다르게 적었고("작업본을 찾을 수 없습니다"), 그 중복이 + # 레지스트리 로딩 자체를 깨뜨렸다. 같은 코드는 같은 말을 해야 한다. - code: DOCUMENT_NOT_FOUND category: NOT_FOUND http_status: 404 @@ -1403,19 +1408,6 @@ errors: runbook_link: null compatibility_impact: additive required_test: ManagementErrorRegistryTest - # source: studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (ManagementError.DOCUMENT_NOT_FOUND) - - code: DOCUMENT_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 — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED) - code: DOCUMENT_PUBLISHED category: CONFLICT 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 ea0ae49..79c848a 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 @@ -26,7 +26,7 @@ public final class ManagementClientSafeMessages { case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다"; case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다"; case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다"; - case DOCUMENT_NOT_FOUND -> "작업본을 찾을 수 없습니다"; + case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다"; case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요"; case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다"; case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다"; diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementHomeFocusController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementHomeFocusController.java new file mode 100644 index 0000000..4f26f22 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementHomeFocusController.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.inbound.web.techlog.management.controller; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusRequest; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse; +import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper; +import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand; +import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase; +import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * 홈 focus 설정. 계약 {@code getHomeFocus}/{@code updateHomeFocus}. + * + *

단일 행이라 경로에 id 가 없다. 낙관적 잠금은 본문의 {@code expectedVersion} 으로만 한다. + */ +@RestController +public class ManagementHomeFocusController { + + private final GetHomeFocusUseCase getHomeFocus; + private final UpdateHomeFocusUseCase updateHomeFocus; + + public ManagementHomeFocusController( + GetHomeFocusUseCase getHomeFocus, UpdateHomeFocusUseCase updateHomeFocus) { + this.getHomeFocus = getHomeFocus; + this.updateHomeFocus = updateHomeFocus; + } + + @GetMapping("/v1/studio/home/focus") + public HomeFocusResponse getHomeFocus() { + return ManagementResponseMapper.homeFocus(getHomeFocus.handle()); + } + + @PutMapping("/v1/studio/home/focus") + public HomeFocusResponse updateHomeFocus( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @RequestBody HomeFocusRequest body) { + return ManagementResponseMapper.homeFocus( + updateHomeFocus.handle( + new UpdateHomeFocusCommand( + body.getExpectedVersion(), + body.getDefaultType() == null ? null : body.getDefaultType().getValue(), + body.getCurrentProjectId(), + body.getOpenQuestionId(), + body.getRecentDecisionId(), + ManagementPrincipals.require(principal)))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java index df811a0..82a6137 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/controller/ManagementProjectController.java @@ -8,6 +8,8 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedV import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishRequest; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse; import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper; import dev.caskeleton.application.techlog.management.command.CreateProjectCommand; import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand; @@ -16,6 +18,8 @@ import dev.caskeleton.application.techlog.management.service.CreateProjectUseCas import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase; import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; +import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase; +import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase; import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase; import java.util.List; import java.util.UUID; @@ -41,17 +45,24 @@ public class ManagementProjectController { private final UpdateProjectUseCase updateProject; private final DeleteProjectUseCase deleteProject; + private final PublishProjectUseCase publishProject; + private final UnpublishProjectUseCase unpublishProject; + public ManagementProjectController( ListStudioProjectsUseCase listProjects, GetProjectForEditUseCase getProject, CreateProjectUseCase createProject, UpdateProjectUseCase updateProject, - DeleteProjectUseCase deleteProject) { + DeleteProjectUseCase deleteProject, + PublishProjectUseCase publishProject, + UnpublishProjectUseCase unpublishProject) { this.listProjects = listProjects; this.getProject = getProject; this.createProject = createProject; this.updateProject = updateProject; this.deleteProject = deleteProject; + this.publishProject = publishProject; + this.unpublishProject = unpublishProject; } @GetMapping("/v1/studio/projects") @@ -113,4 +124,27 @@ public class ManagementProjectController { new DeleteProjectCommand( id, body.getExpectedVersion(), ManagementPrincipals.require(principal))); } + + @PostMapping("/v1/studio/projects/{id}/publish") + public PublishResponse publishProject( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody PublishRequest body) { + return ManagementResponseMapper.projectPublication( + publishProject.handle( + id, + body.getExpectedVersion(), + body.getVisibility() == null ? null : body.getVisibility().getValue(), + ManagementPrincipals.require(principal))); + } + + @PostMapping("/v1/studio/projects/{id}/unpublish") + public ProjectEditResponse unpublishProject( + @AuthenticationPrincipal AuthenticatedPrincipal principal, + @PathVariable("id") UUID id, + @RequestBody ExpectedVersionRequest body) { + return ManagementResponseMapper.project( + unpublishProject.handle( + id, body.getExpectedVersion(), ManagementPrincipals.require(principal))); + } } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java index b2baa43..3fd8ca4 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/management/mapper/ManagementResponseMapper.java @@ -1,6 +1,7 @@ package dev.caskeleton.adapter.inbound.web.techlog.management.mapper; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse; +import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem; @@ -11,6 +12,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEd import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit; +import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView; import dev.caskeleton.application.techlog.management.model.ProjectEditView; import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView; import dev.caskeleton.application.techlog.management.model.ReleaseEditView; @@ -188,6 +190,29 @@ public final class ManagementResponseMapper { pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages())); } + /** 홈 focus 설정. 세 슬롯은 비어 있을 수 있고, 비어 있음이 곧 "고르지 않았다"는 뜻이다. */ + public static HomeFocusResponse homeFocus(HomeFocusConfigView view) { + HomeFocusResponse response = new HomeFocusResponse(view.id(), view.version()); + response.setDefaultType(view.defaultType()); + response.setCurrentProjectId(view.currentProjectId()); + response.setOpenQuestionId(view.openQuestionId()); + response.setRecentDecisionId(view.recentDecisionId()); + return response; + } + + /** 프로젝트 게시 결과. canonical path 는 공개 조회가 쓰는 {@code /projects/} 와 같은 규칙으로 만든다. */ + public static PublishResponse projectPublication(ProjectEditView view) { + return new PublishResponse( + view.id(), + PublishResponse.StatusEnum.PUBLISHED, + "UNLISTED".equals(view.targetVisibility()) + ? PublishResponse.VisibilityEnum.UNLISTED + : PublishResponse.VisibilityEnum.PUBLIC, + "/projects/" + view.slug(), + at(view.lastPublishedAt()), + view.version()); + } + private static String nullToEmpty(String value) { return value == null ? "" : value; } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java index 3b4b39e..d073fe0 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioPublicationController.java @@ -28,7 +28,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; import java.util.UUID; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java index e06d45e..17a8d77 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/BlockRenderer.java @@ -8,10 +8,12 @@ import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ImageBlock; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock; +import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ThematicBreakBlock; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock; import java.util.ArrayList; import java.util.List; @@ -24,6 +26,7 @@ import org.commonmark.node.BlockQuote; import org.commonmark.node.BulletList; import org.commonmark.node.FencedCodeBlock; import org.commonmark.node.Heading; +import org.commonmark.node.Image; import org.commonmark.node.IndentedCodeBlock; import org.commonmark.node.Node; import org.commonmark.node.OrderedList; @@ -38,10 +41,13 @@ import org.commonmark.node.ThematicBreak; */ final class BlockRenderer { - /** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */ - private static final int MIN_HEADING_LEVEL = 2; + /** + * 계약 {@code HeadingBlock.level} 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 였고 h1/h5/h6 를 이 범위로 접었는데, + * 그러면 작성자가 쓴 위계가 화면에서 달라진다. 지금은 그대로 담는다. + */ + private static final int MIN_HEADING_LEVEL = 1; - private static final int MAX_HEADING_LEVEL = 4; + private static final int MAX_HEADING_LEVEL = 6; private final HeadingIds headingIds; private final List warnings; @@ -67,18 +73,18 @@ final class BlockRenderer { private CaseRenderBlock renderBlock(Node node) { return switch (node) { case Heading value -> heading(value); - case Paragraph value -> paragraph(InlineRenderer.render(value)); + case Paragraph value -> paragraphOrImage(value); case BlockQuote value -> blockquote(value); case BulletList value -> bulletList(value); case OrderedList value -> orderedList(value); case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo()); case IndentedCodeBlock value -> code(value.getLiteral(), null); case TableBlock value -> table(value); + // 계약에 수평선 타입이 생겼다(studio-v1 3.1.0). 예전에는 담을 곳이 없어 버리고 경고했다. case ThematicBreak ignored -> { - // 계약의 CaseRenderBlock 에 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던 - // 구조가 생기므로 버리고 경고한다. - warnings.add("THEMATIC_BREAK_NOT_RENDERABLE"); - yield null; + ThematicBreakBlock block = new ThematicBreakBlock(); + block.setType(ThematicBreakBlock.TypeEnum.THEMATIC_BREAK); + yield block; } default -> { List content = InlineRenderer.render(node); @@ -87,6 +93,47 @@ final class BlockRenderer { }; } + /** + * 문단 하나에 그림만 있으면 그림 블록으로 읽는다. + * + *

Markdown 에서 {@code ![alt](/media/...)} 는 문단 안의 inline 인데 계약의 Inline union 에는 그림이 없다. 예전에는 + * 그래서 대체 텍스트만 남기고 그림을 버렸다. 계약에 블록이 생겼으므로 (studio-v1 3.1.0) 문단이 그림 하나로만 이루어진 경우를 블록으로 올린다. + * + *

주소는 상대 경로이거나 http(s) 여야 한다 — {@code javascript:} 같은 스킴이 화면에 그대로 실리면 안 된다. + */ + private CaseRenderBlock paragraphOrImage(Paragraph value) { + Node only = value.getFirstChild(); + if (only instanceof Image image && only == value.getLastChild()) { + String source = image.getDestination(); + if (isRenderableImageSource(source)) { + ImageBlock block = new ImageBlock(); + block.setType(ImageBlock.TypeEnum.IMAGE); + block.setSrc(source); + block.setAlt(InlineRenderer.plainText(image)); + block.setTitle(image.getTitle()); + return block; + } + warnings.add("IMAGE_SOURCE_NOT_RENDERABLE"); + } + return paragraph(InlineRenderer.render(value)); + } + + private static boolean isRenderableImageSource(String source) { + if (source == null || source.isBlank()) { + return false; + } + // 프로토콜 상대 주소(`//host/…`)는 앱 경로가 아니다. 아래의 단일 슬래시 검사보다 먼저 걸러야 + // 한다 — 순서를 바꾸면 외부 호스트를 내부 경로로 오인한다. + if (source.startsWith("//")) { + return false; + } + if (source.startsWith("/")) { + return true; + } + String lower = source.toLowerCase(java.util.Locale.ROOT); + return lower.startsWith("http://") || lower.startsWith("https://"); + } + private CaseRenderBlock heading(Heading value) { HeadingBlock block = new HeadingBlock(); block.setType(HeadingBlock.TypeEnum.HEADING); diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java index f9a58ee..cac8471 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/render/StudioContentRendererTest.java @@ -57,9 +57,10 @@ class StudioContentRendererTest { List blocks = rendered.blocks(); assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance); - // 계약의 HeadingBlock.level 은 2..4 다. h1 과 h6 를 그대로 내보내면 계약 위반이다. - assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2); - assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4); + // 계약의 HeadingBlock.level 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 라 h1/h6 를 접었고, + // 그래서 작성자가 쓴 위계가 화면에서 달라졌다. 지금은 그대로 내보낸다. + assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(1); + assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(6); assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow"); assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론"); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcHomeFocusConfigAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcHomeFocusConfigAdapter.java new file mode 100644 index 0000000..3468d8e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcHomeFocusConfigAdapter.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.management; + +import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand; +import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView; +import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 홈 focus 설정 저장소. + * + *

{@code home_focus_config} 는 PK 가 고정 UUID 로 CHECK 되어 있는 단일 행 테이블이다. 그래서 조회에 WHERE 가 없고, 갱신은 그 + * 고정 id 를 그대로 쓴다 — 행이 여러 개일 수 없으므로 "어느 행" 을 고를 일이 없다. + * + *

V9 마이그레이션이 세 슬롯이 모두 비어 있는 행을 미리 넣는다. 따라서 {@link #load()} 는 항상 값을 돌려주며, 빈 결과는 스키마가 깨진 경우뿐이라 그때는 + * 예외가 맞다. + */ +@Repository +public class JdbcHomeFocusConfigAdapter implements HomeFocusConfigPort { + + private static final UUID ROW_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + + private static final String COLUMNS = + "id, version, default_focus_type, current_project_id, open_question_id, recent_decision_id"; + + private final JdbcClient jdbcClient; + + public JdbcHomeFocusConfigAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + private static HomeFocusConfigView map(ResultSet rs, int rowNum) throws SQLException { + return new HomeFocusConfigView( + rs.getObject("id", UUID.class), + rs.getLong("version"), + rs.getString("default_focus_type"), + rs.getObject("current_project_id", UUID.class), + rs.getObject("open_question_id", UUID.class), + rs.getObject("recent_decision_id", UUID.class)); + } + + @Override + public HomeFocusConfigView load() { + return jdbcClient + .sql("SELECT " + COLUMNS + " FROM home_focus_config") + .query(JdbcHomeFocusConfigAdapter::map) + .optional() + .orElseThrow( + () -> + new IllegalStateException("home_focus_config has no row; migration V9 seeds one")); + } + + @Override + public Optional update(UpdateHomeFocusCommand command) { + int updated = + jdbcClient + .sql( + "UPDATE home_focus_config SET default_focus_type = :defaultType," + + " current_project_id = :projectId, open_question_id = :questionId," + + " recent_decision_id = :decisionId, version = version + 1," + + " updated_at = now(), updated_by = :actor" + + " WHERE id = :id AND version = :expected") + .param("id", ROW_ID) + .param("expected", command.expectedVersion()) + .param("defaultType", command.defaultType()) + .param("projectId", command.currentProjectId()) + .param("questionId", command.openQuestionId()) + .param("decisionId", command.recentDecisionId()) + .param("actor", command.actor()) + .update(); + return updated == 0 ? Optional.empty() : Optional.of(load()); + } + + @Override + public boolean projectExists(UUID id) { + return exists("project", id); + } + + @Override + public boolean questionExists(UUID id) { + return exists("open_question", id); + } + + @Override + public boolean decisionExists(UUID id) { + return exists("project_decision", id); + } + + /* + * 테이블 이름은 이 클래스 안의 상수 셋에서만 온다 — 호출자가 문자열을 넘길 수 없으므로 연결해도 주입 경로가 없다. + */ + private boolean exists(String table, UUID id) { + return jdbcClient + .sql("SELECT 1 FROM " + table + " WHERE id = :id") + .param("id", id) + .query(Integer.class) + .optional() + .isPresent(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java index c40837d..681fb29 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/management/JdbcProjectRepositoryAdapter.java @@ -5,11 +5,15 @@ import dev.caskeleton.application.techlog.management.command.UpdateProjectComman import dev.caskeleton.application.techlog.management.model.ProjectEditView; import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView; import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; import java.util.ArrayList; +import java.util.HexFormat; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -230,4 +234,142 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort { .query(Boolean.class) .single()); } + + /** + * 게시는 세 곳을 한 트랜잭션 안에서 함께 세운다 — 원본의 상태, 공개 투영, canonical route. 어느 하나가 빠지면 증상이 제각각이다: 투영이 없으면 화면이 + * 조용히 비고, route 가 없으면 주소만 404 가 되며, 원본 상태가 안 바뀌면 Studio 가 계속 "초안" 이라고 말한다. + * + *

낙관적 잠금이 먼저다. 버전이 어긋나면 아래 두 문장은 아예 실행하지 않는다. + */ + @Override + public Optional publish( + UUID id, long expectedVersion, String visibility, String actor) { + int updated = + jdbcClient + .sql( + "UPDATE project SET workflow_status = 'PUBLISHED', target_visibility = :visibility," + + " first_published_at = COALESCE(first_published_at, now())," + + " last_published_at = now(), version = version + 1," + + " updated_at = now(), updated_by = :actor" + + " WHERE id = :id AND version = :expected") + .param("id", id) + .param("expected", expectedVersion) + .param("visibility", visibility) + .param("actor", actor) + .update(); + if (updated == 0) { + return Optional.empty(); + } + + ProjectEditView project = + find(id) + .orElseThrow(() -> new IllegalStateException("project vanished mid-publish: " + id)); + upsertProjection(project, visibility); + replaceRoute(project); + return Optional.of(project); + } + + @Override + public Optional unpublish(UUID id, long expectedVersion, String actor) { + int updated = + jdbcClient + .sql( + "UPDATE project SET workflow_status = 'DRAFT', target_visibility = 'PRIVATE'," + + " version = version + 1, updated_at = now(), updated_by = :actor" + + " WHERE id = :id AND version = :expected") + .param("id", id) + .param("expected", expectedVersion) + .param("actor", actor) + .update(); + if (updated == 0) { + return Optional.empty(); + } + jdbcClient + .sql( + "UPDATE public_resource_projection SET publication_state = 'WITHDRAWN'," + + " updated_at = now() WHERE resource_type = 'PROJECT' AND resource_id = :id") + .param("id", id) + .update(); + return find(id); + } + + /** + * 공개 조회들은 실제 내용을 {@code project} 테이블에서 직접 읽고 이 행은 가시성 관문·주소·정렬 시각으로만 쓴다. 그래서 {@code payload} 는 빈 + * 객체로 둔다 — 여기에 사본을 두면 원본이 바뀔 때마다 두 곳이 어긋난다. + * + *

{@code search_text} 만은 예외다. 검색은 이 테이블 하나만 훑으므로 여기에 없는 낱말은 영영 찾을 수 없다. + */ + private void upsertProjection(ProjectEditView project, String visibility) { + String searchText = + String.join( + " ", + nullToEmpty(project.name()), + nullToEmpty(project.oneLinePurpose()), + nullToEmpty(project.purposeMarkdown()), + nullToEmpty(project.currentObjective()), + nullToEmpty(project.nextStep())); + jdbcClient + .sql( + "INSERT INTO public_resource_projection (resource_type, resource_id, source_version," + + " publication_state, visibility, title, summary, state_code," + + " payload_schema_version, payload, body_plain_text, search_text, content_hash," + + " published_at, updated_at, navigation_path)" + + " VALUES ('PROJECT', :id, :version, 'ACTIVE', :visibility, :title, :summary," + + " :stateCode, 1, '{}'::jsonb, :bodyPlainText, :searchText, :contentHash," + + " now(), now(), :navigationPath)" + + " ON CONFLICT (resource_type, resource_id) DO UPDATE SET" + + " source_version = EXCLUDED.source_version," + + " publication_state = 'ACTIVE'," + + " visibility = EXCLUDED.visibility," + + " title = EXCLUDED.title," + + " summary = EXCLUDED.summary," + + " state_code = EXCLUDED.state_code," + + " body_plain_text = EXCLUDED.body_plain_text," + + " search_text = EXCLUDED.search_text," + + " content_hash = EXCLUDED.content_hash," + + " updated_at = now()," + + " navigation_path = EXCLUDED.navigation_path") + .param("id", project.id()) + .param("version", project.version()) + .param("visibility", visibility) + .param("title", project.name()) + .param("summary", project.oneLinePurpose()) + .param("stateCode", project.phase()) + .param("bodyPlainText", nullToEmpty(project.purposeMarkdown())) + .param("searchText", searchText) + .param("contentHash", sha256(searchText)) + .param("navigationPath", "/projects/" + project.slug()) + .update(); + } + + /** 이전 slug 의 route 는 alias 로 남긴다 — 지우면 이미 공개된 링크가 끊긴다. */ + private void replaceRoute(ProjectEditView project) { + jdbcClient + .sql( + "UPDATE public_route SET route_role = 'ALIAS'" + + " WHERE resource_type = 'PROJECT' AND resource_id = :id AND slug <> :slug") + .param("id", project.id()) + .param("slug", project.slug()) + .update(); + jdbcClient + .sql( + "INSERT INTO public_route (resource_type, slug, resource_id, route_role)" + + " VALUES ('PROJECT', :slug, :id, 'CANONICAL')" + + " ON CONFLICT (resource_type, slug) DO UPDATE SET" + + " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'") + .param("id", project.id()) + .param("slug", project.slug()) + .update(); + } + + private static String sha256(String value) { + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 must be available on every supported JVM", e); + } + } } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java index c1644bf..e6ba20a 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 @@ -87,7 +87,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { "SELECT d.id, d.title, d.body_markdown, d.content_format," + " d.content_format_version, d.cover_asset_id," // `environment_items` 는 V7 이 만든 jsonb 배열이고, Studio 가 쓰는 것은 V8 이 - // 따로 만든 `environment`/`reproduction` 문자열이다(V8__techlog_studio_working_copy.sql:23-26). + // 따로 만든 `environment`/`reproduction` + // 문자열이다(V8__techlog_studio_working_copy.sql:23-26). // 공개 조회가 배열 쪽을 읽는 동안 그 컬럼을 채우는 코드는 어디에도 없었고 — // 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 두 칸은 늘 비어 있었다. + " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction," @@ -229,8 +230,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { /** * 본문이 {@code :::evidence key="..."} 로 가리키는 Asset. * - *

{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 - * 하기 때문이다. {@code WORKING} scope 는 Studio 의 것이다. + *

{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 하기 + * 때문이다. {@code WORKING} scope 는 Studio 의 것이다. * *

{@code READY} 가 아닌 Asset 은 뺀다. {@code /media/{assetId}} 가 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를 * 가리키게 된다. @@ -275,15 +276,16 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { /** * 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다. * - *

계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열 - * (`environment`, `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로 - * 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다. + *

계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열 (`environment`, + * `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다. */ static List environment(ResultSet rs) throws SQLException { List items = new ArrayList<>(2); for (String column : List.of("environment", "reproduction")) { String value = rs.getString(column); - if (value != null && !value.isBlank()) items.add(value); + if (value != null && !value.isBlank()) { + items.add(value); + } } return List.copyOf(items); } diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java index 38e32bc..d7eb6c2 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/query/JdbcCatalogQueryAdapter.java @@ -13,11 +13,21 @@ import org.springframework.stereotype.Repository; /** * catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4). * - *

RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다. + *

RELATION 과 EVIDENCE 는 같은 기록을 서로 다른 시점에서 본다. RELATION 은 작성 중에 고르는 것이라 아직 게시되지 않은 작업본까지 + * 포함한다 — 두 문서를 같이 쓰면서 서로 잇는 것이 정상적인 순서이고, 게시된 것만 보이면 그 순서를 쓸 수 없다. EVIDENCE 는 공개된 기록을 근거로 인용하는 것이므로 + * 공개 투영에 살아 있는 행만 포함한다. 읽는 사람이 따라갈 수 없는 근거는 근거가 아니다. */ @Repository public class JdbcCatalogQueryAdapter implements CatalogQueryPort { + /** 공개 노출 조건. {@code PublicSql.ACTIVE} 와 같은 정의다 — 그쪽은 package-private 이라 여기에 다시 적는다. */ + private static final String PUBLICLY_VISIBLE = + " p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' "; + + /** 연결·근거 대상이 될 수 있는 기록 유형. PROJECT 는 관계가 아니라 소속이라 여기에 없다. */ + private static final String LINKABLE_TYPES = + " ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION') "; + private final JdbcClient jdbcClient; public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) { @@ -32,7 +42,8 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort { switch (type) { case TOPIC -> searchTopics(pattern, limit); case PROJECT -> searchProjects(pattern, limit); - case RELATION, EVIDENCE -> List.of(); + case RELATION -> searchRelations(pattern, limit); + case EVIDENCE -> searchEvidence(pattern, limit); }; return new CatalogPageView(items, null); } @@ -75,4 +86,79 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort { "project:" + rs.getTimestamp("updated_at").toInstant())) .list(); } + + /** + * 세 원천 테이블을 하나의 목록으로 합친다. {@code document} 는 {@code document_type} 이 그대로 계약의 {@code kind} 이고, 나머지 + * 둘은 테이블 자체가 유형을 정한다. + * + *

공개 경로는 게시된 것에만 있으므로 LEFT JOIN 이다. 작업본은 {@code publicPath} 가 null 이고, 이는 "아직 공개 주소가 없다"는 뜻이지 + * "고를 수 없다"는 뜻이 아니다. + */ + private List searchRelations(String pattern, int limit) { + return jdbcClient + .sql( + "SELECT id, kind, label, public_path, updated_at FROM (" + + " SELECT d.id AS id, d.document_type AS kind, d.title AS label," + + " p.navigation_path AS public_path, d.updated_at AS updated_at" + + " FROM document d" + + " LEFT JOIN public_resource_projection p" + + " ON p.resource_type = d.document_type AND p.resource_id = d.id" + + " AND " + + PUBLICLY_VISIBLE + + " WHERE lower(d.title) LIKE :pattern" + + " UNION ALL" + + " SELECT q.id, 'QUESTION', q.question," + + " p.navigation_path, q.updated_at" + + " FROM open_question q" + + " LEFT JOIN public_resource_projection p" + + " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id" + + " AND " + + PUBLICLY_VISIBLE + + " WHERE lower(q.question) LIKE :pattern" + + " UNION ALL" + + " SELECT pd.id, 'PROJECT_DECISION', pd.title," + + " p.navigation_path, pd.updated_at" + + " FROM project_decision pd" + + " LEFT JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = pd.id" + + " AND " + + PUBLICLY_VISIBLE + + " WHERE lower(pd.title) LIKE :pattern" + + ") linkable ORDER BY label LIMIT :limit") + .param("pattern", pattern) + .param("limit", limit) + .query((rs, rowNum) -> entry(rs, CatalogEntryType.RELATION, "relation")) + .list(); + } + + /** 공개 투영이 곧 "읽는 사람이 따라갈 수 있는 기록"의 정의다. 여기서는 그 테이블 하나면 충분하다. */ + private List searchEvidence(String pattern, int limit) { + return jdbcClient + .sql( + "SELECT p.resource_id AS id, p.resource_type AS kind, p.title AS label," + + " p.navigation_path AS public_path, p.updated_at AS updated_at" + + " FROM public_resource_projection p" + + " WHERE " + + PUBLICLY_VISIBLE + + " AND p.resource_type IN " + + LINKABLE_TYPES + + " AND lower(p.title) LIKE :pattern" + + " ORDER BY label LIMIT :limit") + .param("pattern", pattern) + .param("limit", limit) + .query((rs, rowNum) -> entry(rs, CatalogEntryType.EVIDENCE, "evidence")) + .list(); + } + + private static CatalogEntryView entry( + java.sql.ResultSet rs, CatalogEntryType type, String revisionPrefix) + throws java.sql.SQLException { + return new CatalogEntryView( + UUID.fromString(rs.getString("id")), + type, + rs.getString("label"), + rs.getString("kind"), + rs.getString("public_path"), + revisionPrefix + ":" + rs.getTimestamp("updated_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 3d24c57..c36a197 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 @@ -4,6 +4,13 @@ import static org.assertj.core.api.Assertions.assertThat; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapter; +import dev.caskeleton.application.techlog.management.command.CreateProjectCommand; +import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand; +import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView; +import dev.caskeleton.application.techlog.management.model.ProjectEditView; +import dev.caskeleton.application.techlog.studio.query.CatalogEntryType; +import dev.caskeleton.application.techlog.studio.query.CatalogPageView; import java.util.UUID; import org.flywaydb.core.Flyway; import org.junit.jupiter.api.AfterAll; @@ -12,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.simple.JdbcClient; import org.testcontainers.DockerClientFactory; import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.ObjectMapper; /** * 작업본 삭제 SQL 을 실제 PostgreSQL 위에서 돌린다. @@ -32,6 +40,9 @@ class ManagementPersistenceIntegrationTest { private static HikariDataSource dataSource; private static JdbcClient jdbcClient; private static JdbcDocumentDeletionAdapter deletion; + private static JdbcProjectRepositoryAdapter projects; + private static JdbcHomeFocusConfigAdapter homeFocus; + private static JdbcCatalogQueryAdapter catalog; @BeforeAll static void migrate() { @@ -61,6 +72,9 @@ class ManagementPersistenceIntegrationTest { jdbcClient = JdbcClient.create(dataSource); deletion = new JdbcDocumentDeletionAdapter(jdbcClient); + projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper()); + homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient); + catalog = new JdbcCatalogQueryAdapter(jdbcClient); } @AfterAll @@ -396,4 +410,180 @@ class ManagementPersistenceIntegrationTest { assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1); assertThat(deletion.findDecision(projectId, id)).isEmpty(); } + + // ---------------------------------------------------------------- 프로젝트 게시 + + /** + * 프로젝트 게시는 원본 상태·공개 투영·canonical route 세 곳을 함께 세운다. 어느 하나가 빠지면 증상이 제각각이라 (투영이 없으면 화면이 조용히 비고, + * route 가 없으면 주소만 404) 세 곳을 한꺼번에 확인한다. + */ + @Test + void publishingAProjectRaisesTheProjection() { + ProjectEditView draft = projects.create(new CreateProjectCommand("Publish Me", "test")); + jdbcClient + .sql("UPDATE project SET slug = 'publish-me' WHERE id = :id") + .param("id", draft.id()) + .update(); + ProjectEditView loaded = projects.find(draft.id()).orElseThrow(); + + ProjectEditView published = + projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow(); + + assertThat(published.targetVisibility()).isEqualTo("PUBLIC"); + assertThat(published.firstPublishedAt()).isNotNull(); + assertThat( + jdbcClient + .sql( + "SELECT navigation_path FROM public_resource_projection" + + " WHERE resource_type = 'PROJECT' AND resource_id = :id" + + " AND publication_state = 'ACTIVE' AND visibility = 'PUBLIC'") + .param("id", loaded.id()) + .query(String.class) + .optional()) + .contains("/projects/publish-me"); + assertThat( + jdbcClient + .sql( + "SELECT route_role FROM public_route" + + " WHERE resource_type = 'PROJECT' AND resource_id = :id") + .param("id", loaded.id()) + .query(String.class) + .list()) + .containsExactly("CANONICAL"); + } + + /** 게시 취소는 투영 행을 지우지 않고 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */ + @Test + void unpublishingWithdrawsTheProjectionInsteadOfDeletingIt() { + ProjectEditView draft = projects.create(new CreateProjectCommand("Withdraw Me", "test")); + jdbcClient + .sql("UPDATE project SET slug = 'withdraw-me' WHERE id = :id") + .param("id", draft.id()) + .update(); + ProjectEditView loaded = projects.find(draft.id()).orElseThrow(); + ProjectEditView published = + projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow(); + + assertThat(projects.unpublish(published.id(), published.version(), "test")).isPresent(); + + assertThat( + jdbcClient + .sql( + "SELECT publication_state FROM public_resource_projection" + + " WHERE resource_type = 'PROJECT' AND resource_id = :id") + .param("id", published.id()) + .query(String.class) + .optional()) + .contains("WITHDRAWN"); + } + + /** 낙관적 잠금. 어긋난 버전으로는 아무것도 세우지 않는다. */ + @Test + void publishingWithAStaleVersionChangesNothing() { + ProjectEditView draft = projects.create(new CreateProjectCommand("Stale", "test")); + jdbcClient + .sql("UPDATE project SET slug = 'stale' WHERE id = :id") + .param("id", draft.id()) + .update(); + ProjectEditView loaded = projects.find(draft.id()).orElseThrow(); + + assertThat(projects.publish(loaded.id(), loaded.version() + 99, "PUBLIC", "test")).isEmpty(); + assertThat( + jdbcClient + .sql( + "SELECT count(*) FROM public_resource_projection" + + " WHERE resource_type = 'PROJECT' AND resource_id = :id") + .param("id", loaded.id()) + .query(Integer.class) + .single()) + .isZero(); + } + + // ---------------------------------------------------------------- 홈 focus + + /** V9 가 빈 행 하나를 넣어 둔다. 조회가 실패하면 그 전제가 깨진 것이다. */ + @Test + void homeFocusStartsAsASingleEmptyRow() { + HomeFocusConfigView current = homeFocus.load(); + assertThat(current.currentProjectId()).isNull(); + assertThat(current.openQuestionId()).isNull(); + assertThat(current.recentDecisionId()).isNull(); + } + + @Test + void homeFocusRoundTripsAndRefusesAStaleVersion() { + ProjectEditView project = projects.create(new CreateProjectCommand("Focus Target", "test")); + HomeFocusConfigView before = homeFocus.load(); + + HomeFocusConfigView saved = + homeFocus + .update( + new UpdateHomeFocusCommand( + before.version(), "CURRENT_WORK", project.id(), null, null, "test")) + .orElseThrow(); + + assertThat(saved.currentProjectId()).isEqualTo(project.id()); + assertThat(saved.defaultType()).isEqualTo("CURRENT_WORK"); + assertThat(saved.version()).isEqualTo(before.version() + 1); + assertThat(homeFocus.projectExists(project.id())).isTrue(); + assertThat(homeFocus.projectExists(UUID.randomUUID())).isFalse(); + + assertThat( + homeFocus.update( + new UpdateHomeFocusCommand(before.version(), null, null, null, null, "test"))) + .isEmpty(); + + // null 은 "이 슬롯을 비운다" 다 — 부분 수정이 아니라 전체 교체. + HomeFocusConfigView cleared = + homeFocus + .update(new UpdateHomeFocusCommand(saved.version(), null, null, null, null, "test")) + .orElseThrow(); + assertThat(cleared.currentProjectId()).isNull(); + } + + // ---------------------------------------------------------------- catalog + + /** RELATION 은 작성 중에 고르는 것이므로 게시 여부와 무관하게 작업본까지 포함한다. */ + @Test + void relationCatalogIncludesUnpublishedWorkingCopies() { + UUID caseId = insertCase("Relation Target Case"); + UUID questionId = insertQuestion("Relation Target Question"); + + CatalogPageView page = catalog.search(CatalogEntryType.RELATION, null, null, 50); + + assertThat(page.items()).extracting("id").contains(caseId, questionId); + assertThat(page.items()) + .filteredOn(entry -> entry.id().equals(caseId)) + .singleElement() + .satisfies( + entry -> { + assertThat(entry.kind()).isEqualTo("CASE"); + assertThat(entry.publicPath()).isNull(); + }); + } + + /** EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. */ + @Test + void evidenceCatalogIncludesOnlyPubliclyVisibleRecords() { + UUID publishedId = insertCase("Evidence Published"); + UUID draftId = insertCase("Evidence Draft"); + UUID withdrawnId = insertCase("Evidence Withdrawn"); + insertProjection("CASE", publishedId, "ACTIVE", "Evidence Published"); + insertProjection("CASE", withdrawnId, "WITHDRAWN", "Evidence Withdrawn"); + + CatalogPageView page = catalog.search(CatalogEntryType.EVIDENCE, "evidence", null, 50); + + assertThat(page.items()).extracting("id").contains(publishedId); + assertThat(page.items()).extracting("id").doesNotContain(draftId, withdrawnId); + } + + /** 검색어는 대소문자를 가리지 않는다. */ + @Test + void relationCatalogSearchIsCaseInsensitive() { + UUID caseId = insertCase("MixedCase Needle"); + + assertThat(catalog.search(CatalogEntryType.RELATION, "mixedcase", null, 50).items()) + .extracting("id") + .contains(caseId); + } } 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 6afe6a5..d8efe0a 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 @@ -1,6 +1,7 @@ package dev.caskeleton.bootstrap.techlog; import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort; +import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort; import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort; @@ -13,13 +14,17 @@ import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCas import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase; import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase; import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase; +import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase; import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase; import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase; +import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase; import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase; import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase; +import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase; +import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase; import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase; import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase; import dev.caskeleton.application.transaction.TransactionPort; @@ -128,4 +133,25 @@ public class TechLogManagementConfig { DocumentDeletionPort documents, TransactionPort tx) { return new DeleteProjectDecisionUseCase(documents, tx); } + + @Bean + PublishProjectUseCase publishProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) { + return new PublishProjectUseCase(projects, tx); + } + + @Bean + UnpublishProjectUseCase unpublishProjectUseCase( + ProjectRepositoryPort projects, TransactionPort tx) { + return new UnpublishProjectUseCase(projects, tx); + } + + @Bean + GetHomeFocusUseCase getHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) { + return new GetHomeFocusUseCase(config, tx); + } + + @Bean + UpdateHomeFocusUseCase updateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) { + return new UpdateHomeFocusUseCase(config, tx); + } } diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index 42a6d32..809b5a0 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -173,6 +173,14 @@ spring: # true | false (Java 21 virtual threads for Tomcat request handlers) enabled: ${SPRING_THREADS_VIRTUAL_ENABLED} servlet: + session: + # Studio 작성자는 한 기록을 여러 번 저장하며 오래 머문다. Spring 기본 30분 유휴 만료는 그 + # 리듬보다 짧아 작성 도중 로그인 화면으로 돌아가는 일이 잦았다 — 저장하지 않은 편집이 + # 있으면 그 시점에 잃는다. + # + # 값은 배포가 정한다. 늘릴수록 훔친 session cookie가 유효한 창도 같이 늘어나므로, + # Keycloak realm 의 SSO idle 과 따로 놀지 않게 함께 맞춘다. + timeout: ${APP_SESSION_TIMEOUT:8h} multipart: # feature-api-contract-baseline D8: bound request body size so an oversized # upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java index 7c27ba7..6f02452 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java @@ -11,6 +11,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; +import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration; +import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration; import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration; import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; @@ -117,13 +119,21 @@ class ActuatorSecurityHttpTest { .andExpect(status().isForbidden()); } + /* + * Redis 도 함께 제외한다. 빼지 않으면 reactive Redis health indicator 가 이 슬라이스에 그대로 + * 올라와 localhost:6379 로 붙으려 하고, 실패하면 /actuator/health 가 503 이 된다. 그러면 이 + * 테스트는 "probe 가 permitAll 인가" 가 아니라 "이 기계에 Redis 가 떠 있는가" 를 재게 된다 — + * 보안 태세와 아무 상관 없는 이유로 빨개진다. + */ @EnableAutoConfiguration( exclude = { DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class, HibernateJpaAutoConfiguration.class, DataJpaRepositoriesAutoConfiguration.class, - FlywayAutoConfiguration.class + FlywayAutoConfiguration.class, + DataRedisAutoConfiguration.class, + DataRedisReactiveAutoConfiguration.class }) @Import(ManagementSecurityConfig.class) static class MinimalActuatorApp {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateHomeFocusCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateHomeFocusCommand.java new file mode 100644 index 0000000..a3e720c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/command/UpdateHomeFocusCommand.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.management.command; + +import java.util.UUID; + +/** + * {@code updateHomeFocus}. 세 슬롯은 전부 nullable 이고, null 은 "이 슬롯을 비운다" 는 뜻이다 — 부분 수정이 아니라 전체 교체다. + * + *

부분 수정으로 두면 "비우기" 를 표현할 방법이 없어진다. 화면이 세 칸을 한 번에 보여 주고 한 번에 저장하므로 전체 교체가 화면과도 맞다. + */ +public record UpdateHomeFocusCommand( + long expectedVersion, + String defaultType, + UUID currentProjectId, + UUID openQuestionId, + UUID recentDecisionId, + String actor) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/HomeFocusConfigView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/HomeFocusConfigView.java new file mode 100644 index 0000000..5a9b88d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/model/HomeFocusConfigView.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.management.model; + +import java.util.UUID; + +/** + * 홈 화면이 무엇을 앞에 둘지 정하는 단일 행 설정. 계약 {@code HomeFocusResponse} 의 application 표현. + * + *

세 슬롯은 모두 nullable 이다. 비어 있다는 것은 "고르지 않았다" 는 뜻이고, 공개 화면은 그때 focus 영역을 아예 그리지 않는다. + */ +public record HomeFocusConfigView( + UUID id, + long version, + String defaultType, + UUID currentProjectId, + UUID openQuestionId, + UUID recentDecisionId) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/HomeFocusConfigPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/HomeFocusConfigPort.java new file mode 100644 index 0000000..7566825 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/HomeFocusConfigPort.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.techlog.management.port.out; + +import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand; +import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView; +import java.util.Optional; +import java.util.UUID; + +/** 홈 focus 설정의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */ +public interface HomeFocusConfigPort { + + /** 단일 행이므로 조회는 실패하지 않는다. 마이그레이션이 빈 행을 이미 넣어 두었다. */ + HomeFocusConfigView load(); + + /** 낙관적 잠금. 버전이 어긋나면 {@code Optional.empty()}. */ + Optional update(UpdateHomeFocusCommand command); + + boolean projectExists(UUID id); + + boolean questionExists(UUID id); + + boolean decisionExists(UUID id); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectRepositoryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectRepositoryPort.java index 6b04815..1249ff4 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectRepositoryPort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/port/out/ProjectRepositoryPort.java @@ -25,5 +25,16 @@ public interface ProjectRepositoryPort { boolean isReferenced(UUID id); + /** + * 공개 게시. 프로젝트는 Studio 문서가 아니라 {@code RecordKind} 에 없고, 따라서 문서 게시 파이프라인을 타지 않는다. 공개 화면들은 모두 {@code + * public_resource_projection} 의 {@code PROJECT} 행을 가시성 관문으로 쓰므로, 그 행을 세우는 것이 곧 게시다. + * + *

버전이 어긋나면 {@code Optional.empty()}. + */ + Optional publish(UUID id, long expectedVersion, String visibility, String actor); + + /** 게시 취소. 투영 행은 지우지 않고 {@code WITHDRAWN} 으로 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */ + Optional unpublish(UUID id, long expectedVersion, String actor); + boolean slugTaken(String slug, UUID exceptId); } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetHomeFocusUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetHomeFocusUseCase.java new file mode 100644 index 0000000..e37421f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/GetHomeFocusUseCase.java @@ -0,0 +1,35 @@ +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.management.model.HomeFocusConfigView; +import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort; +import dev.caskeleton.application.techlog.studio.service.StudioPermissions; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import java.util.Objects; + +/* + * final 이 아닌 이유는 다른 use case 들과 같다 — @RequiresPermission 이 CGLIB 프록시로 강제된다. + */ +@RequiresPermission(StudioPermissions.READ) +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public class GetHomeFocusUseCase { + + private final HomeFocusConfigPort config; + private final TransactionPort transactions; + + public GetHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort transactions) { + this.config = Objects.requireNonNull(config, "config"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public HomeFocusConfigView handle() { + return transactions.inRead(config::load); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/PublishProjectUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/PublishProjectUseCase.java new file mode 100644 index 0000000..64c1516 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/PublishProjectUseCase.java @@ -0,0 +1,76 @@ +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.model.ProjectEditView; +import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; +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.Set; +import java.util.UUID; + +/** + * {@code publishProject}. + * + *

프로젝트는 Studio 문서가 아니다 — {@code RecordKind} 에 없고 본문도 검증 대상도 없다. 그래서 문서 게시 파이프라인 대신 여기서 직접 공개 상태를 + * 세운다. 릴리스가 자체 경로를 갖는 것과 같은 이유다. + * + *

slug 가 없으면 거절한다. 공개 주소가 {@code /projects/} 이므로 slug 없이 게시하면 아무도 닿을 수 없는 페이지가 생긴다 — 저장은 + * 성공했는데 링크는 없는, 이유를 알 수 없는 상태다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class PublishProjectUseCase { + + private static final Set VISIBILITIES = Set.of("PUBLIC", "UNLISTED"); + + private final ProjectRepositoryPort projects; + private final TransactionPort transactions; + + public PublishProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) { + this.projects = Objects.requireNonNull(projects, "projects"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public ProjectEditView handle(UUID id, long expectedVersion, String visibility, String actor) { + Objects.requireNonNull(id, "id"); + String requested = visibility == null ? "PUBLIC" : visibility; + if (!VISIBILITIES.contains(requested)) { + throw ManagementException.of( + ManagementError.REQUEST_VALIDATION_FAILED, "visibility must be PUBLIC or UNLISTED"); + } + + return transactions.inWrite( + () -> { + ProjectEditView current = + projects + .find(id) + .orElseThrow( + () -> + ManagementException.of( + ManagementError.PROJECT_NOT_FOUND, "no such project")); + if (current.slug() == null || current.slug().isBlank()) { + throw ManagementException.of( + ManagementError.REQUEST_VALIDATION_FAILED, + "a slug is required before a project can be published"); + } + return projects + .publish(id, expectedVersion, requested, actor) + .orElseThrow( + () -> + ManagementException.withDetails( + ManagementError.VERSION_CONFLICT, + "the project changed since it was loaded", + new SaveTopicUseCase.VersionConflict(current.version()))); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UnpublishProjectUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UnpublishProjectUseCase.java new file mode 100644 index 0000000..ed1789a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UnpublishProjectUseCase.java @@ -0,0 +1,54 @@ +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.model.ProjectEditView; +import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; +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 unpublishProject}. 투영 행은 지우지 않고 {@code WITHDRAWN} 으로 내린다. */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class UnpublishProjectUseCase { + + private final ProjectRepositoryPort projects; + private final TransactionPort transactions; + + public UnpublishProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) { + this.projects = Objects.requireNonNull(projects, "projects"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public ProjectEditView handle(UUID id, long expectedVersion, String actor) { + Objects.requireNonNull(id, "id"); + return transactions.inWrite( + () -> { + ProjectEditView current = + projects + .find(id) + .orElseThrow( + () -> + ManagementException.of( + ManagementError.PROJECT_NOT_FOUND, "no such project")); + return projects + .unpublish(id, expectedVersion, actor) + .orElseThrow( + () -> + ManagementException.withDetails( + ManagementError.VERSION_CONFLICT, + "the project changed since it was loaded", + new SaveTopicUseCase.VersionConflict(current.version()))); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateHomeFocusUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateHomeFocusUseCase.java new file mode 100644 index 0000000..728f6ce --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/management/service/UpdateHomeFocusUseCase.java @@ -0,0 +1,89 @@ +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.command.UpdateHomeFocusCommand; +import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView; +import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort; +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.Set; +import java.util.UUID; + +/** + * {@code updateHomeFocus}. + * + *

지목한 대상이 실제로 있는지 여기서 먼저 확인한다. 테이블에는 FK 가 없어서 (설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 를 그대로 저장할 수 + * 있고, 그러면 공개 화면은 조용히 빈 focus 를 그린다 — 저장은 성공했는데 화면에는 아무것도 안 나오는, 이유를 알 수 없는 실패가 된다. + * + *

대상이 공개인지는 확인하지 않는다. 아직 게시하지 않은 프로젝트를 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인 순서다. 공개 여부는 + * 공개 조회 쪽이 매번 다시 판단한다. + */ +@RequiresPermission(StudioPermissions.WRITE) +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public class UpdateHomeFocusUseCase { + + private static final Set DEFAULT_TYPES = + Set.of("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION"); + + private final HomeFocusConfigPort config; + private final TransactionPort transactions; + + public UpdateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort transactions) { + this.config = Objects.requireNonNull(config, "config"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + public HomeFocusConfigView handle(UpdateHomeFocusCommand command) { + Objects.requireNonNull(command, "command"); + if (command.defaultType() != null && !DEFAULT_TYPES.contains(command.defaultType())) { + throw ManagementException.of( + ManagementError.REQUEST_VALIDATION_FAILED, "defaultType is not a known focus type"); + } + + return transactions.inWrite( + () -> { + requireExists( + command.currentProjectId(), + config::projectExists, + ManagementError.PROJECT_NOT_FOUND, + "no such project"); + requireExists( + command.openQuestionId(), + config::questionExists, + ManagementError.QUESTION_NOT_FOUND, + "no such question"); + requireExists( + command.recentDecisionId(), + config::decisionExists, + ManagementError.DECISION_NOT_FOUND, + "no such decision"); + + HomeFocusConfigView current = config.load(); + return config + .update(command) + .orElseThrow( + () -> + ManagementException.withDetails( + ManagementError.VERSION_CONFLICT, + "the home focus changed since it was loaded", + new SaveTopicUseCase.VersionConflict(current.version()))); + }); + } + + private static void requireExists( + UUID id, java.util.function.Predicate exists, ManagementError error, String message) { + if (id != null && !exists.test(id)) { + throw ManagementException.of(error, message); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/BodyAssetView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/BodyAssetView.java index 5f27983..4768975 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/BodyAssetView.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/BodyAssetView.java @@ -6,8 +6,8 @@ import java.util.UUID; * 계약 {@code BodyAsset}. * *

본문이 {@code :::evidence key="..."} 로 가리키는 Asset 이다. 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데 - * {@code /media/{assetId}} 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이 의도된 성질이다 — key 에서 주소를 만들 수 없다. 공개 화면이 key 를 - * 해석할 수 있도록 게시된 기록이 실제로 참조하는 Asset 을 함께 준다. + * {@code /media/{assetId}} 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이 의도된 성질이다 — key 에서 주소를 만들 수 없다. 공개 화면이 + * key 를 해석할 수 있도록 게시된 기록이 실제로 참조하는 Asset 을 함께 준다. * * @param url 검증된 전송 경로다. object storage URL 이 아니다(설계 05장 §3.1). * @param decorative 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개 화면도 같은 값을 보고 {@code alt} 를 정해야 diff --git a/src/build.gradle b/src/build.gradle index 25de083..168883c 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -455,6 +455,12 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { useJUnitPlatform { excludeTags 'quarantine' } + // Gradle 기본값은 512m 이다. 이 스위트는 한 JVM 안에서 여러 Spring context 를 캐시하고 + // (@SpringBootTest 슬라이스마다 하나) ArchUnit 이 전체 클래스 그래프를 들고 있으며 + // Testcontainers 까지 함께 뜬다. 512m 에서는 그 조합이 OOM 으로 무너졌고, 증상은 테스트 + // 실패가 아니라 "Gradle Test Executor 를 완료할 수 없음" — 어느 테스트 때문인지 알 수 없는 + // 형태로 나타난다. + maxHeapSize = '2g' } // feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY diff --git a/src/config/openapi/MANIFEST.sha256 b/src/config/openapi/MANIFEST.sha256 index 659be88..616d203 100644 --- a/src/config/openapi/MANIFEST.sha256 +++ b/src/config/openapi/MANIFEST.sha256 @@ -1,6 +1,6 @@ -# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006) -6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml -# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 55a9599 (feature/public-v1-response-envelope) -8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e public-v1.yaml -# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 6ef5c1c (master) -ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f studio-management-v1.yaml +# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ ed04872 (master) +6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f studio-v1.yaml +# source: tech-log-design-package contracts/openapi/public-v1.yaml @ ed04872 (master) +37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b public-v1.yaml +# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ ed04872 (master) +c143ad3303eb05a02320940e2a0f5300a38b0c05f40b5bd5cf30960ef8004573 studio-management-v1.yaml diff --git a/src/config/openapi/studio-management-v1.yaml b/src/config/openapi/studio-management-v1.yaml index 0103e4e..a4d366e 100644 --- a/src/config/openapi/studio-management-v1.yaml +++ b/src/config/openapi/studio-management-v1.yaml @@ -3015,49 +3015,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PublishResponse' + $ref: '#/components/schemas/PublishResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3085,49 +3085,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectEditResponse' + $ref: '#/components/schemas/ProjectEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -4973,7 +4973,7 @@ paths: $ref: '#/components/schemas/ExpectedVersionRequest' security: - sessionCookie: [] - /api/v1/studio/home-focus: + /api/v1/studio/home/focus: get: operationId: getHomeFocus tags: @@ -4985,37 +4985,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/HomeFocusResponse' + $ref: '#/components/schemas/HomeFocusResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' security: - sessionCookie: [] put: @@ -5030,49 +5030,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/HomeFocusResponse' + $ref: '#/components/schemas/HomeFocusResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -5510,6 +5510,21 @@ components: $ref: '#/components/schemas/ReleaseEditResponse' meta: $ref: '#/components/schemas/ResponseMeta' + HomeFocusResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/HomeFocusResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' PublishResponseEnvelope: type: object additionalProperties: false diff --git a/src/config/openapi/studio-v1.yaml b/src/config/openapi/studio-v1.yaml index 8a2edbd..ac68d97 100644 --- a/src/config/openapi/studio-v1.yaml +++ b/src/config/openapi/studio-v1.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Tech Log Studio API - version: 3.0.0 + version: 3.1.0 description: | Tech Log Studio orchestration 계약이다. @@ -1321,7 +1321,10 @@ components: properties: type: { type: string, enum: [HEADING] } id: { type: string, minLength: 1, maxLength: 200 } - level: { type: integer, minimum: 2, maximum: 4 } + # 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를 + # 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한 + # 평범한 Markdown 이 통째로 렌더링되지 않는다. + level: { type: integer, minimum: 1, maximum: 6 } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } ParagraphBlock: type: object @@ -1452,6 +1455,29 @@ components: width: { type: [integer, "null"], minimum: 1 } height: { type: [integer, "null"], minimum: 1 } decorative: { type: boolean } + ThematicBreakBlock: + type: object + additionalProperties: false + description: | + `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다. + required: [type] + properties: + type: { type: string, enum: [THEMATIC_BREAK] } + ImageBlock: + type: object + additionalProperties: false + description: | + `![alt](/media/...)` 로 쓴 그림이다. + + `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시 + 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은 + 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다. + required: [type, src, alt, title] + properties: + type: { type: string, enum: [IMAGE] } + src: { type: string, minLength: 1, maxLength: 500 } + alt: { type: string, maxLength: 300 } + title: { type: [string, "null"], maxLength: 300 } CaseRenderBlock: oneOf: - { $ref: "#/components/schemas/HeadingBlock" } @@ -1463,6 +1489,8 @@ components: - { $ref: "#/components/schemas/DataTableBlock" } - { $ref: "#/components/schemas/CalloutBlock" } - { $ref: "#/components/schemas/EvidenceFigureBlock" } + - { $ref: "#/components/schemas/ThematicBreakBlock" } + - { $ref: "#/components/schemas/ImageBlock" } discriminator: propertyName: type mapping: @@ -1475,6 +1503,8 @@ components: DATA_TABLE: "#/components/schemas/DataTableBlock" CALLOUT: "#/components/schemas/CalloutBlock" EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock" + THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock" + IMAGE: "#/components/schemas/ImageBlock" CasePublicRenderModel: unevaluatedProperties: false allOf: