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/ Markdown 에서 {@code } 는 문단 안의 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 {@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 낙관적 잠금이 먼저다. 버전이 어긋나면 아래 두 문장은 아예 실행하지 않는다.
+ */
+ @Override
+ public Optional {@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 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 부분 수정으로 두면 "비우기" 를 표현할 방법이 없어진다. 화면이 세 칸을 한 번에 보여 주고 한 번에 저장하므로 전체 교체가 화면과도 맞다.
+ */
+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 버전이 어긋나면 {@code Optional.empty()}.
+ */
+ Optional 프로젝트는 Studio 문서가 아니다 — {@code RecordKind} 에 없고 본문도 검증 대상도 없다. 그래서 문서 게시 파이프라인 대신 여기서 직접 공개 상태를
+ * 세운다. 릴리스가 자체 경로를 갖는 것과 같은 이유다.
+ *
+ * slug 가 없으면 거절한다. 공개 주소가 {@code /projects/ 지목한 대상이 실제로 있는지 여기서 먼저 확인한다. 테이블에는 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 본문이 {@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: |
+ `` 로 쓴 그림이다.
+
+ `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: