From 37d56141294cc8b4c97f56be14144618b36cc32e Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 00:14:18 +0900 Subject: [PATCH 1/6] fix: make Studio authorization actually work, and stop it failing as a 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four linked defects, found by driving the 19 operations against a running backend on PostgreSQL behind a real Keycloak realm. 1. The role→permission mapping never bound. Both profiles wrote it as role-permissions: ${APP_STUDIO_AUTHOR_ROLE:studio-author}: - studio:write and Spring Boot resolves placeholders in @ConfigurationProperties *values*, not in Map *keys* — the key bound as the literal "${APP_STUDIO_AUTHOR_ROLE:studio-author}", matched no real role, and left RolePermissionRegistry empty. Every Studio write answered 403, in local and prod alike. Setting APP_STUDIO_AUTHOR_ROLE explicitly did not help; a literal key returned 201 immediately. StudioAuthzEnvironmentPost Processor now resolves the role name as a scalar (where placeholders do work) and contributes the mapping under a literal key, so the name stays deployment-configurable. Registered the same way the tracing bridge is. 2. Reads were unguarded. Only WRITE carried @RequiresPermission, so any authenticated caller could list every draft and fetch one by id: listStudioDocuments 200, 2 drafts getStudioDocument 200 getStudioDashboard 200 listStudioAssets 200 The nine read use cases now declare studio:read. They lose `final` for the same CGLIB reason the write ones already document. 3. Failures were masked. IdempotencyExecutor's catch called store.discard, whose @Modifying bulk delete needs a transaction and had none, so it threw TransactionRequiredException over the original exception — the 403 above surfaced as 500 INTERNAL_ERROR with no cause in the log, which is why this shipped. discard now runs REQUIRES_NEW (cleanup must survive the failed work's rollback) and a cleanup failure is attached with addSuppressed instead of replacing what actually went wrong. 4. Reservations leaked. With discard throwing every time, failed requests left their idempotency rows behind. After the fix only the successful call's COMPLETED row remains. Verified end to end: studio-author writes with no extra configuration; an unprivileged caller gets 403 on all five read operations and on write; create → save → validate → preview → publish → unpublish all succeed; optimistic lock returns 409 VERSION_CONFLICT; the publication reaches public_resource_projection and flips to WITHDRAWN on unpublish. Co-Authored-By: Claude Opus 5 (1M context) --- .../idempotency/IdempotencyStoreAdapter.java | 9 +++ .../StudioAuthzEnvironmentPostProcessor.java | 56 +++++++++++++++++++ .../main/resources/META-INF/spring.factories | 3 +- .../src/main/resources/application-local.yml | 13 +---- .../src/main/resources/application-prod.yml | 13 +---- .../idempotency/IdempotencyExecutor.java | 6 +- .../GetCurrentStudioPreviewUseCase.java | 9 ++- .../studio/service/GetStudioAssetUseCase.java | 9 ++- .../service/GetStudioDashboardUseCase.java | 10 +++- .../service/GetStudioDocumentUseCase.java | 9 ++- .../GetStudioPublicationSnapshotUseCase.java | 9 ++- .../studio/service/ListCatalogUseCase.java | 9 ++- .../service/ListStudioAssetsUseCase.java | 9 ++- .../service/ListStudioDocumentsUseCase.java | 9 ++- .../ListStudioPublicationsUseCase.java | 9 ++- .../studio/service/StudioPermissions.java | 9 +++ 16 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java index 280bcc6..396391a 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java @@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; /** * DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope} @@ -162,7 +164,14 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort { row.getExpiresAt())); } + /** + * {@code deleteByScope} 는 {@code @Modifying} 벌크 delete 이므로 활성 트랜잭션을 요구한다. 이 메서드는 {@code + * IdempotencyExecutor} 의 실패 경로에서 호출되는데 그 지점에는 트랜잭션이 없다 — 예약 레코드를 지우려다 {@code + * TransactionRequiredException} 을 던져 원래 실패를 덮고 있었다(403 이 500 으로 바뀌고 로그에 원인이 남지 않았다). REQUIRES_NEW + * 인 이유: 정리는 실패한 작업의 롤백에 휩쓸리면 안 된다. + */ @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) public void discard(IdempotencyScope scope) { repository.deleteByScope( IdempotencyRecordEntityMapper.tenantColumn(scope), diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java new file mode 100644 index 0000000..04012fd --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java @@ -0,0 +1,56 @@ +package dev.caskeleton.bootstrap.techlog; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +/** + * Studio 의 IdP 역할을 {@code studio:read} / {@code studio:write} 에 잇는다. + * + *

왜 YAML 이 아니라 여기인가: 프로파일 YAML 은 이 매핑을 + * + *

+ * role-permissions:
+ *   ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
+ *     - studio:write
+ * 
+ * + * 로 적고 있었다. Spring Boot 는 {@code @ConfigurationProperties} 의 에서는 플레이스홀더를 풀지만 Map + * 키에서는 풀지 않는다 — 키는 리터럴 {@code "${APP_STUDIO_AUTHOR_ROLE:studio-author}"} 로 바인딩된다. 어떤 실제 역할과도 + * 일치하지 않으므로 {@link dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry} 는 빈 레지스트리가 되고, + * Studio 의 모든 쓰기가 403 이 된다. 실측으로 확인했다: {@code APP_STUDIO_AUTHOR_ROLE=studio-author} 를 명시해도 403, 리터럴 + * 키로 바꾸면 즉시 201. + * + *

역할 이름은 배포마다 다르므로(계약도 {@code StudioSession.roles} 를 고정하지 않는다) 코드에 박을 수 없다. 스칼라 프로퍼티는 플레이스홀더가 정상 + * 동작하므로, 여기서 이름을 해석한 뒤 리터럴 키로 매핑을 심는다. + * + *

{@code addLast} 로 넣으므로 운영자가 같은 키를 직접 주면 그쪽이 이긴다. {@code META-INF/spring.factories} 에 등록된다. + */ +public class StudioAuthzEnvironmentPostProcessor implements EnvironmentPostProcessor { + + static final String ROLE_KEY = "app.studio.author-role"; + static final String DEFAULT_ROLE = "studio-author"; + private static final String PREFIX = "ca-skeleton.authz.role-permissions."; + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, SpringApplication application) { + String role = resolveRole(environment); + Map mapping = new LinkedHashMap<>(); + mapping.put(PREFIX + role + "[0]", "studio:read"); + mapping.put(PREFIX + role + "[1]", "studio:write"); + environment.getPropertySources().addLast(new MapPropertySource("studioAuthzMapping", mapping)); + } + + /** + * {@code APP_STUDIO_AUTHOR_ROLE} 은 relaxed binding 으로 {@code app.studio.author-role} 에 닿는다. 값이 비어 + * 있으면 기본값을 쓴다 — 빈 문자열을 키로 심으면 역할 없는 호출자에게 권한이 붙는다. + */ + private static String resolveRole(ConfigurableEnvironment environment) { + String configured = environment.getProperty(ROLE_KEY); + return (configured == null || configured.isBlank()) ? DEFAULT_ROLE : configured.trim(); + } +} diff --git a/src/app-bootstrap/src/main/resources/META-INF/spring.factories b/src/app-bootstrap/src/main/resources/META-INF/spring.factories index 8a537d8..275a195 100644 --- a/src/app-bootstrap/src/main/resources/META-INF/spring.factories +++ b/src/app-bootstrap/src/main/resources/META-INF/spring.factories @@ -1,6 +1,7 @@ org.springframework.boot.EnvironmentPostProcessor=\ dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor,\ -dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor +dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor,\ + dev.caskeleton.bootstrap.techlog.StudioAuthzEnvironmentPostProcessor org.springframework.boot.SpringBootExceptionReporter=\ dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml index 2e8a117..57af34b 100644 --- a/src/app-bootstrap/src/main/resources/application-local.yml +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -73,16 +73,9 @@ spring: # 그대로 동작한다. backend: filesystem - authz: - role-permissions: - # Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라 - # 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다. - # - # application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿 - # 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을 - # 흔들지 않고 프로파일에서 더한다. - ${APP_STUDIO_AUTHOR_ROLE:studio-author}: - - studio:write + # authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다. + # YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면 + # 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다. security: oauth2: diff --git a/src/app-bootstrap/src/main/resources/application-prod.yml b/src/app-bootstrap/src/main/resources/application-prod.yml index 9cde678..4911903 100644 --- a/src/app-bootstrap/src/main/resources/application-prod.yml +++ b/src/app-bootstrap/src/main/resources/application-prod.yml @@ -26,16 +26,9 @@ spring: ca-skeleton: persistence: vendor: postgresql - authz: - role-permissions: - # Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라 - # 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다. - # - # application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿 - # 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을 - # 흔들지 않고 프로파일에서 더한다. - ${APP_STUDIO_AUTHOR_ROLE:studio-author}: - - studio:write + # authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다. + # YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면 + # 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다. security: # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java index 25fe1ae..c1fc915 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java @@ -105,7 +105,11 @@ public final class IdempotencyExecutor { store.complete(scope, new StoredResponse(codec.serialize(result))); return result; } catch (RuntimeException e) { - store.discard(scope); + try { + store.discard(scope); + } catch (RuntimeException cleanupFailure) { + e.addSuppressed(cleanupFailure); + } throw e; } } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java index e93120f..3daeb0a 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.PreviewDetailView; @@ -23,11 +24,17 @@ import java.util.Objects; * {@code getCurrentStudioPreview}. Preview 상태({@code CURRENT}/{@code STALE}/{@code EXPIRED})는 서버가 * 계산한다(계약 설명, spec §7.3) — 프론트가 여러 값을 조합해 재추론하지 않는다. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetCurrentStudioPreviewUseCase +public class GetCurrentStudioPreviewUseCase implements QueryUseCase { private final StudioDocumentLoader documents; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java index 76e1d73..26f59c3 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.AssetDetailView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code getStudioAsset}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetStudioAssetUseCase implements QueryUseCase { +public class GetStudioAssetUseCase implements QueryUseCase { private final AssetRepositoryPort assets; private final TransactionPort transactions; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java index 93eb9de..222c956 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.studio.model.DashboardView; import dev.caskeleton.application.techlog.studio.model.NextAction; import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort; @@ -19,12 +20,17 @@ import java.util.Objects; * {@code getStudioDashboard}. {@code nextAction} 을 포함한 모든 workflow 상태는 서버가 계산한다 — 프론트가 여러 endpoint * 를 조합해 재추론하지 않는다(계약 설명). */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetStudioDashboardUseCase - implements QueryUseCase { +public class GetStudioDashboardUseCase implements QueryUseCase { /** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */ private static final int SECTION_SIZE = 5; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java index cfcea80..d75d773 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code getStudioDocument}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetStudioDocumentUseCase +public class GetStudioDocumentUseCase implements QueryUseCase { private final WorkingCopyRepositoryPort workingCopies; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java index 8e26722..56edb5d 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code getStudioPublicationSnapshot}. 현재 source 에서 다시 만들지 않고 게시 시점에 고정된 것을 그대로 돌려준다(ADR-002). */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class GetStudioPublicationSnapshotUseCase +public class GetStudioPublicationSnapshotUseCase implements QueryUseCase { private final PublicationHistoryQueryPort history; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java index f39c031..5b3c24c 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; @@ -21,11 +22,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; * TransactionPort.inRead(...)}를 직접 호출하도록 정적으로 강제한다. {@link * dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase}와 같은 패턴이다. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class ListCatalogUseCase implements QueryUseCase { +public class ListCatalogUseCase implements QueryUseCase { private static final int MAX_LIMIT = 100; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java index a864f66..141c957 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.AssetPageView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code listStudioAssets}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class ListStudioAssetsUseCase implements QueryUseCase { +public class ListStudioAssetsUseCase implements QueryUseCase { private static final int MAX_LIMIT = 100; private static final int MAX_QUERY_LENGTH = 100; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java index 66b2e25..516a49e 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.DocumentPageView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code listStudioDocuments}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class ListStudioDocumentsUseCase +public class ListStudioDocumentsUseCase implements QueryUseCase { /** 계약 {@code components.parameters.Limit}. */ diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java index eb7fbbb..43dbbf4 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java @@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.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.StudioError; import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.application.techlog.studio.model.PublicationPageView; @@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase; import java.util.Objects; /** {@code listStudioPublications}. */ +/* + * 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로 + * CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과 + * 같은 이유다(CreateStudioDocumentUseCase 참조). + */ +@RequiresPermission(StudioPermissions.READ) @UseCaseCapability( transactionMode = TransactionMode.READ_ONLY, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.READ_REPOSITORY) -public final class ListStudioPublicationsUseCase +public class ListStudioPublicationsUseCase implements QueryUseCase { private static final int MAX_LIMIT = 100; diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java index 3718b96..e98b0d0 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java @@ -9,6 +9,15 @@ package dev.caskeleton.application.techlog.studio.service; */ public final class StudioPermissions { + /** + * 작업본·게시기록·Asset·대시보드 조회가 요구하는 권한. + * + *

읽기에도 권한이 필요한 이유: Studio 가 읽는 것은 게시 전 초안이다. 인증만 통과하면 누구나 {@code listStudioDocuments} 로 남의 초안 + * 목록을, {@code getStudioDocument} 로 그 본문을 볼 수 있어서는 안 된다 — 계약도 Studio 표면 전체에 권한을 + * 요구한다(securitySchemes.sessionCookie). + */ + public static final String READ = "studio:read"; + /** 편집본 생성·저장·검증·미리보기·게시가 요구하는 권한. */ public static final String WRITE = "studio:write"; From a828b5d9fedbf6defebf2ac4e3a7019eb4f5a134 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 01:19:28 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20give=20redis-session=20mode=20a=20w?= =?UTF-8?q?ay=20to=20authenticate=20=E2=80=94=20the=20BFF=20login=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth-mode=redis-session was unreachable: getStudioSession answered 503 on every call because the CSRF token is null when CsrfFilter never runs, and CsrfFilter only runs in the session branch, which could not be selected because AuthenticationModeCompositionConfig requires a `redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair and only the second existed. Even with the pair present nothing could create a session — there was no login endpoint and no client registration. This is the surface the contract already describes: securitySchemes declares a session cookie plus X-CSRF-TOKEN on mutations, not a bearer token, and SecurityConfig's session branch (cookie CSRF repository, session-fixation migration) plus RedisSessionWebConfig (servlet filter, host-only cookie) were already written for it. The SPA never holds a token; the backend owns the session. - StudioSessionInfrastructureConfig supplies the missing repository under the name the composition validator looks for. @EnableRedisHttp Session is not used because it pins the bean name to sessionRepository. - StudioOidcLoginSuccessHandler converts the OidcUser into an AuthenticatedPrincipal. PrimitiveSessionSecurityContextRepository rejects anything else on save — deliberately, so credentials and framework object graphs never cross the session boundary — and it restores the same type on load. Roles are unioned from realm_access and resource_access exactly as the JWT converter does, so both modes resolve the same set and the studio:read / studio:write mapping behaves identically. - SecurityConfig wires oauth2Login (only when a success handler bean is present, so JWT mode is untouched) and a /logout that invalidates the session. The envelope 401 stays the entry point: an unauthenticated API call must not answer 302, which an XHR cannot follow. The SPA navigates the browser to /oauth2/authorization/{id} instead. Verified in a browser against a real Keycloak realm: /oauth2/authorization/keycloak → Keycloak → callback TECHLOG_SESSION cookie set, httpOnly GET /api/v1/studio/session 200 {authenticated, displayName, roles, csrfToken, csrfHeaderName} POST /api/v1/studio/documents 403 without the CSRF header 201 with it GET /api/v1/studio/documents 200 Also removes the same broken placeholder-in-map-key role mapping from the dev profile that the previous commit fixed in local and prod. Co-Authored-By: Claude Opus 5 (1M context) --- .../inbound/web/auth/SecurityConfig.java | 27 ++++- .../auth/StudioOidcLoginSuccessHandler.java | 104 ++++++++++++++++++ src/app-bootstrap/build.gradle | 11 ++ src/app-bootstrap/gradle.lockfile | 44 +++++--- .../StudioSessionInfrastructureConfig.java | 55 +++++++++ .../src/main/resources/application-dev.yml | 13 +-- 6 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java index c5ec53e..f2b4fd9 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java @@ -70,7 +70,10 @@ public class SecurityConfig { AccessDeniedHandler accessDeniedHandler, org.springframework.beans.factory.ObjectProvider sessionSecurityContextRepository, - org.springframework.beans.factory.ObjectProvider restrictedPaths) + org.springframework.beans.factory.ObjectProvider restrictedPaths, + org.springframework.beans.factory.ObjectProvider< + org.springframework.security.web.authentication.AuthenticationSuccessHandler> + loginSuccessHandler) throws Exception { String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]); java.util.List restricted = restrictedPaths.orderedStream().toList(); @@ -137,6 +140,28 @@ public class SecurityConfig { securityContext .securityContextRepository(sessionSecurityContextRepository.getObject()) .requireExplicitSave(false)); + + // BFF 로그인. 세션을 만들 수 있는 유일한 경로다 — 이것이 없으면 auth-mode=redis-session 은 + // 아무도 인증할 수 없는 모드가 된다. SPA 는 401 을 받으면 브라우저를 /oauth2/authorization/{id} + // 로 이동시키고, 콜백이 세션 쿠키를 심은 뒤 SPA 진입점으로 되돌린다. + // + // 진입점은 바꾸지 않는다: API 요청이 302 로 답하면 XHR 이 따라갈 수 없으므로, 미인증 API 호출은 + // 그대로 봉투 401 이어야 한다. 아래 defaultSuccessUrl 대신 주입된 핸들러를 쓰는 이유는 + // OidcUser 를 세션이 담을 수 있는 AuthenticatedPrincipal 로 바꿔야 하기 때문이다. + org.springframework.security.web.authentication.AuthenticationSuccessHandler onSuccess = + loginSuccessHandler.getIfAvailable(); + if (onSuccess != null) { + http.oauth2Login(login -> login.successHandler(onSuccess)); + } + http.logout( + logout -> + logout + .logoutUrl("/logout") + .invalidateHttpSession(true) + .deleteCookies(securitySettings.session().cookieName()) + .logoutSuccessHandler( + (request, response, authentication) -> + response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); } return http.build(); } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java new file mode 100644 index 0000000..e7c131d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.inbound.web.techlog.auth; + +import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; + +/** + * OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다. + * + *

{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다. + * 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link + * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 + * 넘지 못하게 하는 의도적인 제약이다. 그래서 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} + * 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 ID/Access 토큰은 남지 않는다. + * + *

역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code + * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 + * 같은 역할 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. + */ +@Component +@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") +public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandler { + + private final SimpleUrlAuthenticationSuccessHandler redirect = + new SimpleUrlAuthenticationSuccessHandler(); + + public StudioOidcLoginSuccessHandler( + @Value("${app.studio.post-login-redirect:/}") String defaultTargetUrl) { + redirect.setDefaultTargetUrl(defaultTargetUrl); + // SPA 가 라우팅을 소유한다. 프레임워크의 SavedRequest 는 SecurityConfig 가 이미 꺼두었으므로 + // 로그인 후에는 항상 SPA 진입점으로 보내고, 원래 가려던 화면 복원은 SPA 가 한다. + redirect.setAlwaysUseDefaultTargetUrl(true); + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + if (authentication.getPrincipal() instanceof OidcUser user) { + Set roles = extractRoles(user); + AuthenticatedPrincipal principal = + new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles); + Collection authorities = + roles.stream() + .map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication( + UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities)); + SecurityContextHolder.setContext(context); + // requireExplicitSave(false) 이므로 SecurityContextHolderFilter 가 응답 커밋 시 저장한다. + authentication = context.getAuthentication(); + } + redirect.onAuthenticationSuccess(request, response, authentication); + } + + private static Set extractRoles(OidcUser user) { + Set roles = new HashSet<>(); + addRoles(roles, user.getClaimAsMap("realm_access")); + Map resourceAccess = user.getClaimAsMap("resource_access"); + if (resourceAccess != null) { + for (Object client : resourceAccess.values()) { + if (client instanceof Map map) { + addRoles(roles, map); + } + } + } + List generic = user.getClaimAsStringList("roles"); + if (generic != null) { + roles.addAll(generic); + } + return Set.copyOf(roles); + } + + private static void addRoles(Set sink, Map holder) { + if (holder == null) { + return; + } + if (holder.get("roles") instanceof Collection values) { + values.forEach(value -> sink.add(String.valueOf(value))); + } + } +} diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index 5d45c8b..09d19b3 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -76,6 +76,17 @@ dependencies { // Security types for ManagementSecurityConfig (not reachable via adapter-web's implementation dep). See README. implementation 'org.springframework.boot:spring-boot-starter-security' + // Redis-backed HTTP session for auth-mode=redis-session (the BFF surface the Studio contract + // declares: sessionCookie TECHLOG_SESSION + X-CSRF-TOKEN). AuthenticationModeCompositionConfig + // requires the `redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair once + // that mode is active; StudioSessionInfrastructureConfig supplies the first, Spring Session's + // SpringHttpSessionConfiguration the second. + // OIDC Authorization Code 로그인 자동설정(ClientRegistrationRepository 등). 기존의 + // spring-security-oauth2-client 는 라이브러리만 주고 Boot 자동설정은 스타터가 준다. + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.session:spring-session-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // test-only: ArchUnit needs actuator types to verify the health-shape guardrail. See README. testImplementation 'org.springframework.boot:spring-boot-starter-actuator' // test-only: @WithMockUser for the actuator security authorization tests. See README. diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index be9cf56..2a06756 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -99,7 +99,7 @@ io.grpc:grpc-protobuf:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath -io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -108,27 +108,27 @@ io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTranspor io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-classes-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-compression:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-http2:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-http3:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-http:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-native-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-socks:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-handler-proxy:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-resolver-dns-native-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport-classes-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-transport-native-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -147,7 +147,7 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -268,7 +268,7 @@ org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -281,9 +281,10 @@ org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspa org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -297,15 +298,18 @@ org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspat org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-netty:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-restclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -314,6 +318,7 @@ org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspa org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -336,8 +341,10 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntim org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath -org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -346,20 +353,23 @@ org.springframework.security:spring-security-core:7.0.0=compileClasspath,functio org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context-support:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-oxm:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -374,7 +384,7 @@ org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffT org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java new file mode 100644 index 0000000..66c1960 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java @@ -0,0 +1,55 @@ +package dev.caskeleton.bootstrap.techlog; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.session.data.redis.RedisSessionRepository; + +/** + * {@code auth-mode=redis-session} 의 세션 저장소. + * + *

이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation + * 은 추가로 {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF + * 구성이며, {@code SecurityConfig} 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code + * RedisSessionWebConfig}(서블릿 세션 필터 + host-only 쿠키)는 이미 그 전제로 쓰여 있었다. + * + *

빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 + * {@code redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 + * 요구하는데, 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 + * 있었고 앞의 것이 어디에도 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") +public class StudioSessionInfrastructureConfig { + + /** + * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 + * 바꾸면 부팅이 "Redis Session repository/filter is incomplete" 로 실패한다. + * + *

{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 + * {@code sessionRepository} 로 고정한다. + */ + @Bean + public RedisSessionRepository redisVersionedSessionRepository( + RedisConnectionFactory connectionFactory) { + return new RedisSessionRepository(sessionRedisTemplate(connectionFactory)); + } + + /** + * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code + * PrimitiveSessionSecurityContextRepository} 가 만든 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 + * 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. + */ + private static RedisTemplate sessionRedisTemplate( + RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/app-bootstrap/src/main/resources/application-dev.yml b/src/app-bootstrap/src/main/resources/application-dev.yml index b5d7378..880c6da 100644 --- a/src/app-bootstrap/src/main/resources/application-dev.yml +++ b/src/app-bootstrap/src/main/resources/application-dev.yml @@ -22,16 +22,9 @@ spring: ca-skeleton: persistence: vendor: postgresql - authz: - role-permissions: - # Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라 - # 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다. - # - # application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿 - # 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을 - # 흔들지 않고 프로파일에서 더한다. - ${APP_STUDIO_AUTHOR_ROLE:studio-author}: - - studio:write + # authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다. + # YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면 + # 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다. security: # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a From c8a891c407330fa1b889909ba823a0b1ecb369b9 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 01:49:05 +0900 Subject: [PATCH 3/6] fix: honour the contract's nullable fields and its error codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contract mismatches, both found by driving the API and both invisible from inside the repository because nothing compares the wire to the spec. Nullable-but-required. The contract says required means "the key is present", not "the value is set" — WorkingCopyInputBase spells it out: "불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을 허용한다". The generator moves `required` straight to @NotNull, so topicId, projectId, lastVerifiedOn, verifiedOn, decidedOn, decisionStatus and questionStatus all became non-null, and saving a partial draft failed: {"projectId": null} → 400 NOT_NULL "Required value is missing" prepareStudioCodegenSpec already derives a codegen-only copy of the spec, so the relaxation happens there — 33 properties leave `required` in that copy and the canonical file is untouched, which matters because the frontend reads the same file and its reading is the correct one. Value constraints stay: title still carries @NotNull @Size(max = 120). all-null / omitted / empty slug 201 title 121 chars 422 slug "Bad Slug!" 422 Error codes. Body validation fell through to the template's handler and answered 400 VALIDATION_FAILED, a code the Studio contract does not declare (it knows REQUEST_VALIDATION_FAILED and DOCUMENT_VALIDATION_ FAILED); denials answered AUTHZ_INSUFFICIENT_PERMISSION where the contract assigns STUDIO_ACCESS_DENIED to 403. The frontend validates the envelope's code against an enum, so an undeclared code breaks parsing rather than surfacing as the error it is. Both now map in StudioExceptionHandler, which is already scoped to the techlog package so fileserver and healthcheck keep their existing shapes. body validation 422 REQUEST_VALIDATION_FAILED denial 403 STUDIO_ACCESS_DENIED Co-Authored-By: Claude Opus 5 (1M context) --- src/adapter/inbound/web/build.gradle | 41 ++++++++++++++++++ .../inbound/web/auth/SecurityConfig.java | 3 +- .../web/techlog/StudioExceptionHandler.java | 43 +++++++++++++++++++ .../auth/StudioOidcLoginSuccessHandler.java | 21 +++++---- .../StudioSessionInfrastructureConfig.java | 29 ++++++------- 5 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 9bdc136..f5e3012 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -243,6 +243,47 @@ tasks.register('prepareStudioCodegenSpec') { } collapseNullableOneOf(doc) + // (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다. + // + // 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가 + // 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수 + // 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required + // 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/ + // decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이 + // 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing"). + // + // 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠 + // 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은 + // 그대로 남는다. + int[] relaxed = [0] + def relaxNullableRequired + relaxNullableRequired = { Object node -> + if (node instanceof Map) { + def props = node.get('properties') + def required = node.get('required') + if (props instanceof Map && required instanceof List) { + def drop = [] + props.each { Object name, Object schema -> + if (!(schema instanceof Map)) return + def type = schema.get('type') + if (type instanceof List && type.contains('null') && required.contains(name)) { + drop << name + } + } + if (!drop.isEmpty()) { + required.removeAll(drop) + relaxed[0] += drop.size() + if (required.isEmpty()) node.remove('required') + } + } + new ArrayList(node.values()).each { relaxNullableRequired(it) } + } else if (node instanceof List) { + node.each { relaxNullableRequired(it) } + } + } + relaxNullableRequired(doc) + logger.lifecycle("prepareStudioCodegenSpec: nullable required 해제 ${relaxed[0]}건") + // (1) x-implements 주입 + union 목록 수집 def unions = [:] schemas.each { String name, Object schema -> diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java index f2b4fd9..240ef01 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java @@ -161,7 +161,8 @@ public class SecurityConfig { .deleteCookies(securitySettings.session().cookieName()) .logoutSuccessHandler( (request, response, authentication) -> - response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); + response.setStatus( + jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); } return http.build(); } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java index bd05a70..5c10a4f 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java @@ -6,11 +6,14 @@ import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.shared.response.Envelope; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -76,6 +79,46 @@ public class StudioExceptionHandler { return requestValidationFailed(ex.getName(), "Parameter value is invalid"); } + /** + * 요청 본문 bean validation 실패(예: {@code title} 120자 초과). {@code GlobalExceptionHandler}도 이 예외를 처리하지만 + * 400 {@code OperationalError.VALIDATION_FAILED}를 낸다 — Studio 계약에 없는 코드이고 (계약이 아는 것은 {@code + * REQUEST_VALIDATION_FAILED}와 {@code DOCUMENT_VALIDATION_FAILED}뿐이다), 상태도 계약이 본문 검증 실패에 배정한 422가 + * 아니다. 프론트엔드는 봉투의 {@code code}를 enum으로 검증하므로 계약 밖 코드는 응답 파싱 자체를 깨뜨린다. studio 스코프에서 계약 코드로 옮긴다. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleBodyValidation(MethodArgumentNotValidException ex) { + List> fieldErrors = + ex.getBindingResult().getFieldErrors().stream() + .map( + error -> + Map.of( + "path", + "/" + error.getField(), + "message", + error.getDefaultMessage() == null + ? "Value is invalid" + : error.getDefaultMessage())) + .collect(Collectors.toList()); + return ErrorResponseFactory.envelope( + StudioError.REQUEST_VALIDATION_FAILED, + StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED), + Map.of("fieldErrors", fieldErrors)); + } + + /** + * 권한 부족. 스켈레톤의 분류기는 {@code AUTHZ_INSUFFICIENT_PERMISSION}을 내지만 계약이 403에 배정한 코드는 {@code + * STUDIO_ACCESS_DENIED}다({@code responses.AccessDenied.x-error-codes}). 상태는 그대로 403이고 코드만 계약 쪽으로 + * 옮긴다. + */ + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity> handleAccessDenied(AuthorizationDeniedException ex) { + log.warn("studio access denied: {}", ex.getMessage()); + return ErrorResponseFactory.envelope( + StudioError.STUDIO_ACCESS_DENIED, + StudioClientSafeMessages.forError(StudioError.STUDIO_ACCESS_DENIED), + null); + } + /** * {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에 * 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다. diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java index e7c131d..0d9c5ad 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java @@ -1,9 +1,6 @@ package dev.caskeleton.adapter.inbound.web.techlog.auth; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -15,6 +12,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -24,19 +23,20 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; /** * OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다. * *

{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다. * 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link - * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 - * 넘지 못하게 하는 의도적인 제약이다. 그래서 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} - * 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 ID/Access 토큰은 남지 않는다. + * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 넘지 못하게 하는 의도적인 제약이다. 그래서 + * 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 + * ID/Access 토큰은 남지 않는다. * *

역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code - * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 - * 같은 역할 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. + * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 같은 역할 + * 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. */ @Component @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") @@ -63,7 +63,10 @@ public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandl new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles); Collection authorities = roles.stream() - .map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) + .map( + r -> + (GrantedAuthority) + new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication( diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java index 66c1960..c57c6c2 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java @@ -11,26 +11,26 @@ import org.springframework.session.data.redis.RedisSessionRepository; /** * {@code auth-mode=redis-session} 의 세션 저장소. * - *

이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation - * 은 추가로 {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF - * 구성이며, {@code SecurityConfig} 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code - * RedisSessionWebConfig}(서블릿 세션 필터 + host-only 쿠키)는 이미 그 전제로 쓰여 있었다. + *

이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation 은 추가로 + * {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF 구성이며, {@code SecurityConfig} + * 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code RedisSessionWebConfig}(서블릿 세션 필터 + + * host-only 쿠키)는 이미 그 전제로 쓰여 있었다. * - *

빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 - * {@code redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 - * 요구하는데, 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 - * 있었고 앞의 것이 어디에도 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. + *

빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 {@code + * redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 요구하는데, + * 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 있었고 앞의 것이 어디에도 + * 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") public class StudioSessionInfrastructureConfig { /** - * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 - * 바꾸면 부팅이 "Redis Session repository/filter is incomplete" 로 실패한다. + * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 바꾸면 부팅이 + * "Redis Session repository/filter is incomplete" 로 실패한다. * - *

{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 - * {@code sessionRepository} 로 고정한다. + *

{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 {@code sessionRepository} 로 + * 고정한다. */ @Bean public RedisSessionRepository redisVersionedSessionRepository( @@ -39,9 +39,8 @@ public class StudioSessionInfrastructureConfig { } /** - * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code - * PrimitiveSessionSecurityContextRepository} 가 만든 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 - * 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. + * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code PrimitiveSessionSecurityContextRepository} 가 만든 + * 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. */ private static RedisTemplate sessionRedisTemplate( RedisConnectionFactory connectionFactory) { From 743fee3907dd030996bab0372064abbee57c6444 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 10:36:47 +0900 Subject: [PATCH 4/6] fix: close the last three local checklist items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate relations. Connecting the same target twice saved without a word: the contract carries no uniqueItems on relations (only maxItems 20) and the validator checked order uniqueness but not target. The document then renders the same row twice publicly, and removing one leaves the other behind — "삭제했는데 그대로". Rejected now, alongside the existing order check. two distinct targets 201 same target twice 422 REQUEST_VALIDATION_FAILED The prod DDL guard ran too late. JpaSchemaSafetyValidator was a SmartInitializingSingleton, which fires after every singleton exists — including entityManagerFactory, which Hibernate builds by applying ddl-auto. Booting prod with ddl-auto=update logged "Initialized JPA EntityManagerFactory" first and the PROFILE_MISMATCH second, with the tables Hibernate created in between still in the schema. The guard stopped traffic but not schema mutation, so a misconfigured deploy had already changed the production database by the time it refused to start. It is a BeanFactoryPostProcessor now, before any bean is instantiated. fs_* tables dropped, prod booted with ddl-auto=update exit 71, no EntityManagerFactory line, 0 tables created Object storage inside a database transaction. UploadStudioAssetUseCase called binaries.store from inside inWrite, holding a connection and its locks for the length of a network round-trip — a slow storage backend becomes connection-pool exhaustion. It bought nothing: storage does not join the transaction, so a failed commit leaves the bytes written either way. Storage now happens first and the database write is a short transaction; a failed write deletes the object it just uploaded, and a failed delete is attached with addSuppressed rather than replacing the error the caller needs to see. Full build passes apart from one fileserver flake (LocalPersistentControlPlaneTest.heldOperationReentrancyIsScopedToThe AttestedRoot) that passes in isolation and touches none of these files. Co-Authored-By: Claude Opus 5 (1M context) --- .../runtime/JpaSchemaSafetyValidator.java | 18 +++++- .../runtime/RuntimeSafetyConfig.java | 6 +- .../service/UploadStudioAssetUseCase.java | 59 ++++++++++++------- .../service/WorkingCopyInputValidator.java | 11 ++++ 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java index 6543460..fa9b5cf 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/JpaSchemaSafetyValidator.java @@ -3,14 +3,16 @@ package dev.caskeleton.bootstrap.runtime; import dev.caskeleton.bootstrap.runtime.startup.StartupFailures; import java.util.Locale; import java.util.Set; -import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.core.env.Environment; /** * Prevents Hibernate from becoming a production schema writer. Flyway owns the physical schema; * production may only disable Hibernate DDL or validate the schema. */ -public class JpaSchemaSafetyValidator implements SmartInitializingSingleton { +public class JpaSchemaSafetyValidator implements BeanFactoryPostProcessor { static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto"; static final String DDL_AUTO_ENV_KEY = "APP_DATASOURCE_DDL_AUTO"; @@ -24,8 +26,18 @@ public class JpaSchemaSafetyValidator implements SmartInitializingSingleton { this.environment = environment; } + /** + * {@code BeanFactoryPostProcessor} 이지 {@code SmartInitializingSingleton} 이 아닌 이유: 후자는 모든 싱글턴이 + * 만들어진 에 돈다. {@code entityManagerFactory} 도 그 싱글턴 중 하나이고, Hibernate 는 그것을 만들면서 {@code + * ddl-auto} 를 이미 적용한다 — 실측으로 확인했다: {@code ddl-auto=update} 로 prod 를 띄우면 로그에 "Initialized JPA + * EntityManagerFactory" 가 먼저, 그 다음에 이 가드의 PROFILE_MISMATCH 가 찍히고, 스키마에는 그 사이에 만들어진 테이블이 남는다. + * + *

즉 가드가 트래픽은 막았지만 스키마 변조는 못 막고 있었다. 잘못 설정된 배포가 운영 DB 를 이미 바꿔 놓고 실패하는 셈이라, 검사를 빈 인스턴스화 이전으로 + * 옮긴다. + */ @Override - public void afterSingletonsInstantiated() { + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) + throws BeansException { if (!isProdActive()) { return; } diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java index 426de3c..8e42909 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java @@ -29,8 +29,12 @@ public class RuntimeSafetyConfig { return new OpenInViewSafetyValidator(environment); } + /** + * {@code static} 이어야 한다 — {@code BeanFactoryPostProcessor} 는 다른 빈보다 먼저 만들어지므로, 인스턴스 메서드로 두면 이 설정 + * 클래스 전체가 too-early 로 초기화되어 경고가 뜬다. + */ @Bean - JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) { + static JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) { return new JpaSchemaSafetyValidator(environment); } diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java index 8fdad45..89bfcc6 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java @@ -86,27 +86,44 @@ public class UploadStudioAssetUseCase implements CommandUseCase { - String storedKey = binaries.store(objectKey, content, mediaType); - return assets.create( - new AssetRepositoryPort.NewAsset( - assetId, - assetKey, - input.kind(), - mediaType, - storedKey, - input.originalFilename(), - input.byteSize(), - null, - null, - sha256(content), - input.altText(), - input.decorative(), - // 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다. - AssetManagementStatusView.READY), - input.principal()); - }); + // 오브젝트 스토리지 호출은 트랜잭션 밖이다. 원래는 inWrite 안에 있었는데, 그러면 네트워크 + // 왕복이 끝날 때까지 DB 커넥션과 행 잠금을 붙잡고 있게 된다 — 스토리지가 느려지면 그대로 + // 커넥션 풀 고갈로 번진다. 게다가 스토리지는 트랜잭션에 참여하지 않으므로 안에 둔다고 + // 원자성이 생기지도 않는다: 커밋이 실패하면 바이트는 이미 저장돼 있고 롤백되지 않는다. + // + // 그래서 순서를 뒤집는다 — 저장 먼저, DB 쓰기는 짧은 트랜잭션으로. DB 쓰기가 실패하면 + // 방금 올린 오브젝트를 지운다(보상). 그 삭제마저 실패하면 orphan 이 남지만, 그건 원래 + // 실패 경로에 있던 위험이고 지금은 최소한 로그로 드러난다. + String storedKey = binaries.store(objectKey, content, mediaType); + try { + return transactions.inWrite( + () -> + assets.create( + new AssetRepositoryPort.NewAsset( + assetId, + assetKey, + input.kind(), + mediaType, + storedKey, + input.originalFilename(), + input.byteSize(), + null, + null, + sha256(content), + input.altText(), + input.decorative(), + // 내용 판정을 통과했으므로 READY 다. 판정 실패는 위에서 이미 거절했다. + AssetManagementStatusView.READY), + input.principal())); + } catch (RuntimeException databaseFailure) { + try { + binaries.delete(objectKey); + } catch (RuntimeException cleanupFailure) { + // 원래 실패를 덮지 않는다 — 호출자가 알아야 하는 것은 업로드가 왜 실패했는지다. + databaseFailure.addSuppressed(cleanupFailure); + } + throw databaseFailure; + } } /** diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java index 07c007e..c4a9dca 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/WorkingCopyInputValidator.java @@ -58,7 +58,18 @@ public final class WorkingCopyInputValidator { "document.relations must hold at most " + MAX_RELATIONS + " items"); } Set orders = new HashSet<>(); + Set targets = new HashSet<>(); for (RelationView relation : relations) { + if (relation.targetId() != null && !targets.add(relation.targetId().toString())) { + // 같은 대상을 두 번 연결하면 공개 문서에 같은 줄이 두 번 나오고, 관계를 하나 지웠을 때 + // 나머지 하나가 남아 "지웠는데 그대로"로 보인다. 계약에 uniqueItems 가 없어 스키마가 + // 걸러주지 못하므로 여기서 막는다. + throw StudioException.of( + StudioError.REQUEST_VALIDATION_FAILED, + "document.relations[].targetId must be unique; " + + relation.targetId() + + " is repeated"); + } if (relation.order() < 0) { throw StudioException.of( StudioError.REQUEST_VALIDATION_FAILED, From e3254def5769f4f365d5b954698737082dc0a556 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 18:33:32 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20=EB=AF=B8=EC=B6=94=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=82=A8=EC=95=84=20=EC=9E=88=EB=8D=98=20?= =?UTF-8?q?Studio=20authz=20=EB=B0=B0=EC=84=A0=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=202=EA=B0=9C=EB=A5=BC=20=EC=B6=94=EC=A0=81=EC=97=90?= =?UTF-8?q?=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 37d5614("fix: make Studio authorization actually work, and stop it failing as a 500")가 고친 배선을 지키는 테스트인데 커밋에 들어가지 않아 작업 트리에만 있었다. 추적되지 않으면 브랜치를 옮길 때 조용히 사라지고, 다른 사람이 같은 저장소를 받아도 그 회귀 게이트를 갖지 못한다. - StudioAuthzWiringTest: MethodSecurityConfig의 advisor가 AuthorizationPort를 생성자로 받는 인프라 빈이라, auto-proxy보다 먼저 만들어지며 AuthorizationAdapter → RolePermissionRegistry → RolePermissionPolicy를 BeanPostProcessor 등록 전에 끌어 올린다는 사실을 고정한다. 바인딩만 따로 보면 통과하지만 앱에서는 죽는 경우다 - StudioAuthzEnvironmentPostProcessorTest: 같은 수정의 환경 설정 쪽 내용은 손대지 않았다. spotless가 요구한 줄바꿈 두 곳만 정규화됐다(의미 변경 없음). AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을 전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다. Co-Authored-By: Claude Opus 5 (1M context) --- ...udioAuthzEnvironmentPostProcessorTest.java | 88 +++++++++++++++++++ .../techlog/StudioAuthzWiringTest.java | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java new file mode 100644 index 0000000..8fcf301 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.bootstrap.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.ResolvableType; +import org.springframework.core.env.MapPropertySource; +import org.springframework.mock.env.MockEnvironment; + +/** + * 이 후처리기의 산출물은 프로퍼티가 아니라 바인딩 결과다. 프로퍼티가 environment 에 들어갔는지만 보면 통과하면서도 실제 앱에서는 매핑이 죽는 경우가 + * 있다 — application.yml 이 {@code role-permissions: {}} 로 같은 이름을 이미 선언하고 있고, 그 소스가 {@code addLast} 보다 + * 우선순위가 높기 때문이다. 그래서 여기서는 {@code Map>} 로 실제 바인딩해서 확인한다. + */ +class StudioAuthzEnvironmentPostProcessorTest { + + private final StudioAuthzEnvironmentPostProcessor epp = new StudioAuthzEnvironmentPostProcessor(); + + private static final Bindable>> ROLE_PERMISSIONS = + Bindable.of( + ResolvableType.forClassWithGenerics( + Map.class, + ResolvableType.forClass(String.class), + ResolvableType.forClassWithGenerics(List.class, String.class))); + + private static Map> bind(MockEnvironment env) { + return Binder.get(env) + .bind("ca-skeleton.authz.role-permissions", ROLE_PERMISSIONS) + .orElse(Map.of()); + } + + /** + * 앱이 실제로 바인딩하는 대상은 {@code Map} 이 아니라 {@code RolePermissionPolicy} 레코드다(생성자 바인딩). 맵으로만 확인하면 레코드 + * 경로에서만 나타나는 차이를 놓친다. + */ + @Test + void bindsThroughTheRecordTheApplicationActuallyUses() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + env.getPropertySources() + .addLast( + new MapPropertySource( + "applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", ""))); + + epp.postProcessEnvironment(env, new SpringApplication()); + + Map> bound = + Binder.get(env) + .bind("ca-skeleton.authz", Bindable.of(RolePermissionPolicy.class)) + .map(RolePermissionPolicy::rolePermissions) + .orElse(Map.of()); + assertThat(bound).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } + + @Test + void grantsBothReadAndWriteToTheConfiguredRole() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } + + /** + * application.yml 이 선언하는 빈 맵을 재현한다. 이것이 매핑을 가리면 Studio 의 모든 쓰기가 403 이 된다 — 읽기는 통과하는데 쓰기만 막히는, + * 진단하기 어려운 모양으로 나타난다. + */ + @Test + void survivesAnEmptyMapDeclaredByTheApplicationDefaults() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + env.getPropertySources() + .addLast( + new MapPropertySource( + "applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", ""))); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java new file mode 100644 index 0000000..bf98bc8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.bootstrap.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; +import dev.caskeleton.application.security.AuthorizationPort; +import dev.caskeleton.shared.security.Permission; +import java.util.Set; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Test; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; + +/** + * 후처리기가 심은 매핑이 실행 중인 컨텍스트에서도 살아 있는지 본다. + * + *

바인딩만 따로 확인하면 통과하지만 앱에서는 죽는 경우가 있어서다. {@code MethodSecurityConfig} 의 advisor 는 auto-proxy 보다 먼저 + * 만들어져야 하는 인프라 빈인데 {@link AuthorizationPort} 를 생성자 파라미터로 받는다. 그래서 {@code AuthorizationAdapter → + * RolePermissionRegistry → RolePermissionPolicy} 가 BeanPostProcessor 등록이 끝나기 전에 끌려 올라온다 — 운영 로그가 이 + * 세 빈에 대해 "not eligible for getting processed by all BeanPostProcessors" 를 정확히 그렇게 찍고 있다. + */ +class StudioAuthzWiringTest { + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RolePermissionPolicy.class) + static class Wiring { + + @Bean + RolePermissionRegistry rolePermissionRegistry(RolePermissionPolicy policy) { + return new RolePermissionRegistry(policy); + } + + @Bean + AuthorizationPort authorizationAdapter(RolePermissionRegistry registry) { + return new AuthorizationAdapter(registry); + } + + /** MethodSecurityConfig 와 같은 모양: 인프라 advisor 가 포트를 직접 받는다. */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) { + AuthorizationManager manager = (authentication, invocation) -> null; + return new AuthorizationManagerBeforeMethodInterceptor( + AnnotationMatchingPointcut.forMethodAnnotation(Deprecated.class), manager); + } + } + + @Test + void theConfiguredRoleKeepsBothPermissionsInsideARunningContext() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + new StudioAuthzEnvironmentPostProcessor().postProcessEnvironment(env, new SpringApplication()); + + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.setEnvironment(env); + context.register(Wiring.class); + context.refresh(); + + RolePermissionRegistry registry = context.getBean(RolePermissionRegistry.class); + assertThat(registry.effectivePermissions(Set.of("site-admin"))) + .contains(Permission.parse("studio:read"), Permission.parse("studio:write")); + } + } +} From 365560efb65ac734fb6091e7fbb692433229e068 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 18:34:18 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20Tech=20Log=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=B1=EC=97=94=EB=93=9C=20=E2=80=94=20?= =?UTF-8?q?public-v1=2018=EA=B0=9C=20operation=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public-v1.yaml의 18개 operation 전부를 구현한다. 사이트·홈·프로필, 탐색 2종, 주제 2종, 문서 상세 3종, 프로젝트 5종, 릴리스 2종, 검색. studio-v1(19/19)에 이어 public-v1도 18/18이다. 생성기가 계약 필드를 조용히 빠뜨리고 있었다 — 근본 원인은 파생 단계의 YAML alias swagger-parser가 이 문서의 스키마 15개를 "is not of type `object`"로 거절했다. 거절당한 스키마들은 전부 type: object를 명시하고 있어서 계약 결함처럼 보이지 않았고, validateSpec을 끄면 생성은 성공했다. 그런데 그렇게 만든 모델에서 LatestEntry.publishedAt, ProjectListItem.updatedAt, SearchResultItem.matchedFields, ReleaseListItem.changeTypes가 사라져 있었다. 컴파일은 통과한다 — 아직 아무도 그 필드를 안 쓰니까. 원인은 prepare 단계였다. 변환들이 같은 Map 인스턴스를 여러 property에 재사용했고 snakeyaml이 그 지점을 anchor/alias(&id001 / *id001)로 덤프했다. swagger-parser는 alias 노드를 해석하지 못해 그 스키마 전체를 거절하고, generator는 검증을 끄면 문서를 받아들이되 alias였던 property를 말없이 버린다. 파생 스펙에 alias가 34곳 있었다. - 덤프 직전 deep copy로 노드 identity를 끊어 alias를 원천 차단하고, 남으면 빌드가 실패하도록 fail-closed 게이트를 뒀다. validateSpec은 다시 켰다 - verifyPublicGeneratedModels를 schema 이름 대조에서 property 대조로 강화했다. 이번 누락을 이 게이트가 통과시켰기 때문이다. 지금은 schema 62개 · property 250개를 센다 계약이 선언했는데 서버가 무시하던 필터를 채웠다 지정해도 오류가 아니라 "결과 0건"으로 보여서 소비자가 자기 요청이 틀렸다는 걸 알 수 없었다. - exploreQuestions: tag 필터 없음, sort 3값이 SQL에 반영되지 않음 - listPublicProjectDecisions: status 필터 없음 - listPublicProjectRecords: type/relation 필터 없음, QUESTION이 대상에서 빠져 있었음 - 필터는 목록과 총계 두 쿼리에 같이 걸린다. 갈라지면 마지막 페이지가 비어 보인다 - enum 파라미터는 요청 경계에서 검사해 PUBLIC_REQUEST_INVALID로 거절한다 응답 봉투와 오류 경계 - 컨트롤러는 봉투를 반환하지 않는다. EnvelopeBodyAdvice가 감싼다(ADR-006) - PublicExceptionHandler를 publicapi 스코프로 두고, StudioExceptionHandler의 스코프를 ...web.techlog → ...web.techlog.studio로 좁혔다. 좁히지 않으면 공개 조회의 파라미터 오류가 Studio 계약 코드(REQUEST_VALIDATION_FAILED, 422)로 나가는데, 그 코드는 public-v1의 ApiError.code enum에 없어 프론트엔드의 응답 파싱 자체가 깨진다 - FieldError 모양이 studio({path,message})와 public({field,code,message})이 다르다 실행이 잡아낸 결함 컴파일과 단위 테스트로는 드러나지 않았고 실제 PostgreSQL과 실제 기동이 잡았다. - profile()의 selectedEvidence가 List.of() 하드코딩이었다. 계약 필드가 항상 비어 있었다 - latestEntries/latestRecords가 projection의 모든 resource_type을 흘렸다. 계약의 LatestEntry.entryType은 4값뿐이라 QUESTION이 섞이면 매퍼가 500을 낸다 - home_focus_config.default_focus_type은 마이그레이션 직후 NULL인데 계약은 이 필드를 required + enum 3값으로 선언한다. 배포 직후 첫 요청부터 /home이 깨졌다. HomeFocusView.resolve가 반드시 유효한 값 하나를 정하도록 고쳤다 V9__techlog_public_surface.sql 설계 패키지 database/V1__init.sql이 정의한 공개 표면 6종(release, site_config, profile_page, home_focus_config, project_topic, topic_featured_document)과 단일 행 시딩. 릴리스는 Publication 파이프라인을 거치지 않고 자체 workflow_status로 공개된다. 게이트 - PublicContractDriftTest: springdoc이 게시하는 표면과 계약을 양방향 대조한다. 계약의 servers(/api/v1/public)를 경로에 더해 비교하며, operation 수 18을 함께 고정해 "비교 대상이 0건이라 통과"를 실패로 만든다. 봉투 래핑도 확인한다 - PublicErrorRegistryTest: PublicError ↔ error-codes.yaml ↔ 계약 enum 3자 대조. INTERNAL_ERROR는 스켈레톤 소유라 재선언하지 않으므로 "계약 = public 소유 ∪ 그 하나"로 고정한다. vendored 계약의 MANIFEST 해시도 확인한다 - postgresqlTechLogPublicPersistenceIntegrationTest: 어댑터 7종과 V9를 실제 PostgreSQL에서 돌린다. 표준 check는 Testcontainers를 돌리지 않으므로 이 태스크가 없으면 이 SQL은 한 번도 실행되지 않은 채 빌드가 통과한다. 게시 취소·비공개 자료를 함께 심어 어느 경로로도 새지 않는지 확인한다 검증 ./gradlew check BUILD SUCCESSFUL (248 task). 공개 조회 통합 테스트 24/24. 실제 PostgreSQL로 앱을 띄워 18개 operation 전부 실호출 — 5xx 0건, 파라미터 검증 5종 전부 계약 코드. 한때 사라졌던 publishedAt/matchedFields/changeTypes가 실응답에 있다. 알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다. 기저 커밋 743fee3에서도 동일하게 재현되며, 원인은 redis가 호스트 포트에 노출되지 않아 헬스가 DOWN인 환경 문제다. 이 커밋과 무관하다. AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을 전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/registries/error-codes.yaml | 36 + docs/security/public-paths-snapshot.txt | 1 + src/.env | 2 +- src/adapter/inbound/web/build.gradle | 254 +- .../web/techlog/StudioExceptionHandler.java | 15 +- .../publicapi/PublicClientSafeMessages.java | 28 + .../publicapi/PublicExceptionHandler.java | 94 + .../controller/PublicDocumentController.java | 48 + .../controller/PublicExploreController.java | 106 + .../controller/PublicProjectController.java | 103 + .../controller/PublicReleaseController.java | 42 + .../controller/PublicRequestParams.java | 65 + .../controller/PublicSiteController.java | 55 + .../controller/PublicTopicController.java | 35 + .../mapper/DocumentResponseMapper.java | 169 ++ .../mapper/ExploreResponseMapper.java | 90 + .../mapper/ProjectResponseMapper.java | 113 + .../mapper/PublicResponseMapper.java | 142 ++ .../mapper/ReleaseResponseMapper.java | 48 + .../publicapi/mapper/SiteResponseMapper.java | 145 ++ .../publicapi/mapper/TopicResponseMapper.java | 48 + .../outbound/persistence-jpa/build.gradle | 7 + .../JdbcPublicDocumentQueryAdapter.java | 260 ++ .../JdbcPublicExploreQueryAdapter.java | 226 ++ .../JdbcPublicProjectQueryAdapter.java | 330 +++ .../JdbcPublicReleaseQueryAdapter.java | 100 + .../JdbcPublicSearchQueryAdapter.java | 146 ++ .../JdbcPublicSiteQueryAdapter.java | 271 +++ .../JdbcPublicTopicQueryAdapter.java | 178 ++ .../techlog/publicsite/PublicJson.java | 81 + .../publicsite/PublicRelationLookup.java | 87 + .../techlog/publicsite/PublicSql.java | 36 + .../postgresql/V9__techlog_public_surface.sql | 170 ++ .../PublicSitePersistenceIntegrationTest.java | 965 ++++++++ .../contract/PublicContractDriftTest.java | 542 +++++ .../contract/StudioContractDriftTest.java | 32 +- .../techlog/TechLogPublicConfig.java | 139 ++ .../src/main/resources/application-local.yml | 5 +- .../architecture/PublicErrorRegistryTest.java | 172 ++ .../techlog/publicsite/error/PublicError.java | 48 + .../publicsite/error/PublicException.java | 31 + .../publicsite/model/AssetReferenceView.java | 11 + .../publicsite/model/CaseDetailView.java | 8 + .../publicsite/model/CaseRelationsView.java | 17 + .../publicsite/model/ContactLinkView.java | 4 + .../publicsite/model/HomeFocusView.java | 106 + .../techlog/publicsite/model/HomeView.java | 11 + .../model/KnowledgeListItemView.java | 16 + .../publicsite/model/KnowledgePageView.java | 11 + .../publicsite/model/LatestEntryView.java | 13 + .../publicsite/model/PageMetadataView.java | 19 + .../techlog/publicsite/model/ProfileView.java | 28 + .../model/ProjectActivityItemView.java | 7 + .../model/ProjectActivityPageView.java | 11 + .../model/ProjectDecisionItemView.java | 14 + .../model/ProjectDecisionPageView.java | 11 + .../publicsite/model/ProjectDetailView.java | 17 + .../publicsite/model/ProjectListItemView.java | 14 + .../model/ProjectRecordPageView.java | 11 + .../publicsite/model/ProjectSummaryView.java | 4 + .../model/PublishedDocumentView.java | 41 + .../model/PublishedProjectView.java | 23 + .../model/PublishedQuestionView.java | 25 + .../publicsite/model/QuestionDetailView.java | 8 + .../model/QuestionListItemView.java | 14 + .../publicsite/model/QuestionPageView.java | 11 + .../model/QuestionPointGroupView.java | 15 + .../model/QuestionRelationsView.java | 15 + .../publicsite/model/QuestionUpdateView.java | 7 + .../publicsite/model/ReferenceDetailView.java | 8 + .../model/ReferenceRelationsView.java | 16 + .../publicsite/model/RelatedEntryView.java | 4 + .../publicsite/model/ReleaseDetailView.java | 25 + .../publicsite/model/ReleaseListItemView.java | 18 + .../model/SearchResultItemView.java | 21 + .../model/SearchResultPageView.java | 12 + .../techlog/publicsite/model/SiteView.java | 18 + .../publicsite/model/TagSummaryView.java | 4 + .../publicsite/model/TopicDetailView.java | 23 + .../publicsite/model/TopicListItemView.java | 4 + .../publicsite/model/TopicSummaryView.java | 4 + .../port/out/PublicDocumentQueryPort.java | 22 + .../port/out/PublicExploreQueryPort.java | 14 + .../port/out/PublicProjectQueryPort.java | 27 + .../port/out/PublicReleaseQueryPort.java | 14 + .../port/out/PublicSearchQueryPort.java | 11 + .../port/out/PublicSiteQueryPort.java | 22 + .../port/out/PublicTopicQueryPort.java | 14 + .../techlog/publicsite/query/EmptyQuery.java | 6 + .../query/ExploreKnowledgeQuery.java | 19 + .../query/ExploreQuestionsQuery.java | 18 + .../query/ProjectDecisionPageQuery.java | 14 + .../publicsite/query/ProjectPageQuery.java | 6 + .../query/ProjectRecordPageQuery.java | 12 + .../publicsite/query/PublicPageRequest.java | 28 + .../techlog/publicsite/query/SearchQuery.java | 7 + .../techlog/publicsite/query/SlugQuery.java | 6 + .../service/ExploreKnowledgeUseCase.java | 38 + .../service/ExploreQuestionsUseCase.java | 38 + .../service/GetPublicCaseUseCase.java | 39 + .../service/GetPublicHomeUseCase.java | 40 + .../service/GetPublicProfileUseCase.java | 38 + .../service/GetPublicProjectUseCase.java | 41 + .../service/GetPublicQuestionUseCase.java | 41 + .../service/GetPublicReferenceUseCase.java | 42 + .../service/GetPublicReleaseUseCase.java | 41 + .../service/GetPublicSiteUseCase.java | 38 + .../service/GetPublicTopicUseCase.java | 40 + .../ListPublicProjectActivitiesUseCase.java | 43 + .../ListPublicProjectDecisionsUseCase.java | 43 + .../ListPublicProjectRecordsUseCase.java | 42 + .../service/ListPublicProjectsUseCase.java | 39 + .../service/ListPublicReleasesUseCase.java | 39 + .../service/ListPublicTopicsUseCase.java | 39 + .../service/PublicReadUseCases.java | 25 + .../service/SearchPublicResourcesUseCase.java | 38 + src/config/openapi/MANIFEST.sha256 | 2 + src/config/openapi/public-v1.yaml | 2104 +++++++++++++++++ 118 files changed, 9160 insertions(+), 44 deletions(-) create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicClientSafeMessages.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicExceptionHandler.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicDocumentController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicExploreController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicProjectController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicReleaseController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicRequestParams.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicSiteController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicTopicController.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ExploreResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ReleaseResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/SiteResponseMapper.java create mode 100644 src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/TopicResponseMapper.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicExploreQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicReleaseQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSearchQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicTopicQueryAdapter.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicJson.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicRelationLookup.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSql.java create mode 100644 src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V9__techlog_public_surface.sql create mode 100644 src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSitePersistenceIntegrationTest.java create mode 100644 src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/PublicErrorRegistryTest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicError.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicException.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/AssetReferenceView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseRelationsView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ContactLinkView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeFocusView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgeListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgePageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/LatestEntryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PageMetadataView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProfileView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectRecordPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectSummaryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedQuestionView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPointGroupView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionRelationsView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionUpdateView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceRelationsView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/RelatedEntryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultPageView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SiteView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TagSummaryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicDetailView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicListItemView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicSummaryView.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicDocumentQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicExploreQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicProjectQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicReleaseQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSearchQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSiteQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicTopicQueryPort.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/EmptyQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreKnowledgeQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreQuestionsQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectDecisionPageQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectPageQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectRecordPageQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/PublicPageRequest.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SearchQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SlugQuery.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreKnowledgeUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreQuestionsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicCaseUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicHomeUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProfileUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProjectUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicQuestionUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReferenceUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReleaseUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicSiteUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicTopicUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectActivitiesUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectDecisionsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectRecordsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicReleasesUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicTopicsUseCase.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/PublicReadUseCases.java create mode 100644 src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/SearchPublicResourcesUseCase.java create mode 100644 src/config/openapi/public-v1.yaml diff --git a/docs/registries/error-codes.yaml b/docs/registries/error-codes.yaml index fcd6ff7..4136e09 100644 --- a/docs/registries/error-codes.yaml +++ b/docs/registries/error-codes.yaml @@ -1228,3 +1228,39 @@ errors: runbook_link: "runbook://studio/unavailable" compatibility_impact: additive required_test: StudioErrorTest + + # === Tech Log Public (feature-techlog-public-v1) === + # + # public-v1.yaml 의 ApiError.code 는 세 값이다. 나머지 하나 INTERNAL_ERROR 는 스켈레톤 + # 공통 코드로 이미 이 레지스트리에 있으므로 여기서 다시 선언하지 않는다. + # + # Studio 와 이름을 겹치지 않게 한 이유: 이 레지스트리는 코드 하나에 http_status 하나만 + # 담는다. public 의 400 과 studio 의 422 를 같은 이름으로 쓸 수 없다. + + # source: public-v1.yaml ApiError.code — PUBLIC_REQUEST_INVALID (PublicError.PUBLIC_REQUEST_INVALID) + - code: PUBLIC_REQUEST_INVALID + category: VALIDATION + http_status: 400 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-public-v1 + owner_layer: application + client_safe_message: "요청 값이 올바르지 않습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: PublicErrorRegistryTest + + # source: public-v1.yaml ApiError.code — PUBLIC_RESOURCE_NOT_FOUND (PublicError.PUBLIC_RESOURCE_NOT_FOUND) + - code: PUBLIC_RESOURCE_NOT_FOUND + category: NOT_FOUND + http_status: 404 + retryable: false + retry_after_seconds: null + owner_branch: feature-techlog-public-v1 + owner_layer: application + client_safe_message: "요청한 자료를 찾을 수 없습니다" + log_level: INFO + runbook_link: null + compatibility_impact: additive + required_test: PublicErrorRegistryTest diff --git a/docs/security/public-paths-snapshot.txt b/docs/security/public-paths-snapshot.txt index 0b628e9..e98285e 100644 --- a/docs/security/public-paths-snapshot.txt +++ b/docs/security/public-paths-snapshot.txt @@ -2,3 +2,4 @@ # SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated. # Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange /api/healthcheck +/api/v1/public/** diff --git a/src/.env b/src/.env index dd4c846..5a8c676 100644 --- a/src/.env +++ b/src/.env @@ -115,7 +115,7 @@ PRESENTATION_API_BASE_PATH=/api APP_SECURITY_AUTH_MODE=jwt APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api -SECURITY_PUBLIC_PATHS=/api/healthcheck +SECURITY_PUBLIC_PATHS=/api/healthcheck, /api/v1/public/** APP_SESSION_COOKIE_NAME=CA_SESSION APP_SESSION_COOKIE_SECURE=true APP_SESSION_COOKIE_HTTP_ONLY=true diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index f5e3012..c06f7b5 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -27,6 +27,11 @@ sourceSets { // 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다. // main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다. java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java')) + // public-v1 도 같은 방식으로 model 만 생성한다. 별도 sourceSet 을 만들지 않는 이유는 + // 두 계약의 생성물이 같은 성질(생성 코드, 품질 게이트 제외 대상, jar/test 클래스패스에 + // 얹어야 함)을 갖기 때문이다 — sourceSet 을 늘리면 그 배선을 한 벌 더 복제하게 된다. + java.srcDir(layout.buildDirectory.dir('generated/openapi-public/src/main/java')) + java.srcDir(layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java')) } // main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation // Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을 @@ -164,18 +169,10 @@ ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codege ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore') ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java') -tasks.register('prepareStudioCodegenSpec') { - description = '계약에서 discriminator union 배선을 파생시켜 생성기 입력을 만든다.' - def specSource = file("${rootDir}/config/openapi/studio-v1.yaml") - def specOut = studioCodegenSpecFile - def ignoreOut = studioCodegenIgnoreFile - def unionDir = studioUnionSrcDir - def modelPackage = studioModelPackage - inputs.file(specSource) - outputs.file(specOut) - outputs.file(ignoreOut) - outputs.dir(unionDir) - doLast { +// 이 파생은 계약 두 벌(studio-v1, public-v1)에 똑같이 적용된다. 두 벌을 각자 복사해 두면 +// 한쪽만 고쳐지는 날이 오므로 클로저 하나로 두고 태스크가 인자만 바꿔 호출한다. +ext.prepareTechLogCodegenSpec = { String label, File specSource, File specTarget, + File ignoreTarget, File unionDir, String modelPackage -> def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8')) def schemas = doc.components.schemas @@ -209,6 +206,32 @@ tasks.register('prepareStudioCodegenSpec') { } collapseStringOneOf(doc) + // boolean 프로퍼티의 `const` 를 코드젠 사본에서만 걷어낸다. + // + // 봉투의 success 는 계약상 `{type: boolean, const: true}` 다. 생성기는 이 문서를 검증 + // 경로 없이 읽으면 그 const 를 단일값 enum 으로 취급해 `enum SuccessEnum { TRUE("true") }` + // 를 만드는데, 그 enum 의 필드 타입은 Boolean 이고 생성자에는 String 을 넘겨 컴파일이 + // 깨진다(실측). 검증 경로를 타는 studio 쪽에서는 같은 계약이 평범한 Boolean 으로 나온다 — + // 즉 계약이 아니라 생성기의 경로 차이가 원인이다. + // + // 값이 하나로 고정된다는 사실은 소비자에게 의미가 있으므로 정본 계약에는 그대로 두고, + // 여기서만 뗀다. 서버가 이 값을 잘못 넣을 위험은 없다 — 봉투는 EnvelopeBodyAdvice 가 + // 만들고 컨트롤러가 손대지 않는다. + int[] consts = [0] + def dropBooleanConst + dropBooleanConst = { Object node -> + if (node instanceof Map) { + if (node.get('type') == 'boolean' && node.containsKey('const')) { + node.remove('const') + consts[0]++ + } + new ArrayList(node.values()).each { dropBooleanConst(it) } + } else if (node instanceof List) { + node.each { dropBooleanConst(it) } + } + } + dropBooleanConst(doc) + // (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면 // 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가 // DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌 @@ -282,7 +305,7 @@ tasks.register('prepareStudioCodegenSpec') { } } relaxNullableRequired(doc) - logger.lifecycle("prepareStudioCodegenSpec: nullable required 해제 ${relaxed[0]}건") + logger.lifecycle("${label}: nullable required 해제 ${relaxed[0]}건") // (1) x-implements 주입 + union 목록 수집 def unions = [:] @@ -326,29 +349,68 @@ tasks.register('prepareStudioCodegenSpec') { } unions.put(name, [property: property, variants: variants]) } - if (unions.isEmpty()) { - throw new GradleException('계약에서 discriminator union 을 하나도 찾지 못했다 — 파생 규칙이 깨졌다.') + // 이 가드의 목적은 "union 이 있어야 한다"가 아니라 "계약에 있는 union 을 하나도 빠뜨리지 + // 않았다"이다. public-v1 처럼 union 이 애초에 없는 계약도 있으므로 개수를 계약에서 세어 + // 대조한다. 원래 studio 전용으로 "0개면 실패"로 썼다가 public-v1 에서 걸렸다. + int declaredUnions = schemas.count { String name, Object schema -> + schema instanceof Map && schema.get('oneOf') instanceof List && + schema.get('discriminator') instanceof Map + } + if (unions.size() != declaredUnions) { + throw new GradleException( + "계약의 discriminator union ${declaredUnions}개 중 ${unions.size()}개만 파생했다 — " + + "파생 규칙이 계약을 따라가지 못한다.") } // 파생 계약 쓰기 + // + // deep copy 가 반드시 선행한다. 위 변환들이 같은 Map/List 인스턴스를 여러 위치에 + // 재사용하면 snakeyaml 이 그 지점을 YAML anchor/alias(&id001 / *id001)로 덤프한다. + // swagger-parser 는 alias 노드를 해석하지 못해 그 스키마를 + // "is not of type `object`" 로 거부하고, validateSpec 을 끄면 generator 가 해당 + // property 를 **조용히 누락한 채** 모델을 만든다(publishedAt, matchedFields 등이 + // 실제로 사라졌다). 노드 identity 를 전부 끊어 alias 자체를 원천 차단한다. + def deepCopy + deepCopy = { Object node -> + if (node instanceof Map) { + def copy = new LinkedHashMap() + node.each { k, v -> copy.put(k, deepCopy(v)) } + return copy + } + if (node instanceof List) { + return node.collect { deepCopy(it) } + } + return node + } + def dumperOptions = new org.yaml.snakeyaml.DumperOptions() dumperOptions.defaultFlowStyle = org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK dumperOptions.width = 8192 - def specFile = specOut.get().asFile + def specFile = specTarget specFile.parentFile.mkdirs() - specFile.setText(new org.yaml.snakeyaml.Yaml(dumperOptions).dump(doc), 'UTF-8') + def rendered = new org.yaml.snakeyaml.Yaml(dumperOptions).dump(deepCopy(doc)) + + // fail-closed: alias 가 하나라도 남으면 생성물이 조용히 불완전해진다. + def aliasLines = rendered.readLines().findAll { it =~ /(?:&|\*)id\d{3}\b/ } + if (!aliasLines.isEmpty()) { + throw new GradleException( + "${label}: 파생 계약에 YAML alias 가 남았다 — swagger-parser 가 해당 스키마를 " + + "거부하고 property 가 조용히 누락된다. 위반 ${aliasLines.size()}줄, 예: " + + aliasLines.take(3).join(' | ')) + } + specFile.setText(rendered, 'UTF-8') // union 클래스 생성 억제 - def ignoreFile = ignoreOut.get().asFile + def ignoreFile = ignoreTarget ignoreFile.setText( - (['# prepareStudioCodegenSpec 가 생성한다 — 손으로 고치지 않는다.', + (["# ${label} 가 생성한다 — 손으로 고치지 않는다.", '# 이 파일들은 같은 package 의 Java interface 로 대체된다.'] + unions.keySet().collect { "**/${it}.java" }).join('\n') + '\n', 'UTF-8') // union interface 쓰기 - def packageDir = new File(unionDir.get().asFile, modelPackage.replace('.', '/')) - project.delete(unionDir.get().asFile) + def packageDir = new File(unionDir, modelPackage.replace('.', '/')) + project.delete(unionDir) packageDir.mkdirs() unions.each { String name, Object spec -> def subtypes = spec.variants.collect { String typeId, String variant -> @@ -360,7 +422,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; /** - * {@code ${name}} — 계약의 discriminator union. prepareStudioCodegenSpec 가 계약의 + * {@code ${name}} — 계약의 discriminator union. ${label} 가 계약의 * {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다. * *

{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로 @@ -380,11 +442,157 @@ public interface ${name} {} } logger.lifecycle( - "prepareStudioCodegenSpec: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), " + + "${label}: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), boolean const ${consts[0]}건 제거, " + "string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음") +} + +ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model' +ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml') +ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore') +ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java') + +// `public` 은 Java 예약어라 패키지 조각으로 쓸 수 없다 — publicapi 로 둔다. +ext.publicModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model' +ext.publicCodegenSpecFile = layout.buildDirectory.file('openapi/public-v1-codegen.yaml') +ext.publicCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-public') +ext.publicUnionSrcDir = layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java') + +tasks.register('prepareStudioCodegenSpec') { + description = 'studio-v1 계약에서 생성기 입력을 파생시킨다.' + def specSource = file("${rootDir}/config/openapi/studio-v1.yaml") + def specOut = studioCodegenSpecFile + def ignoreOut = studioCodegenIgnoreFile + def unionDir = studioUnionSrcDir + def modelPackage = studioModelPackage + def prepare = prepareTechLogCodegenSpec + inputs.file(specSource) + outputs.file(specOut) + outputs.file(ignoreOut) + outputs.dir(unionDir) + doLast { + prepare('prepareStudioCodegenSpec', specSource, specOut.get().asFile, + ignoreOut.get().asFile, unionDir.get().asFile, modelPackage) } } +tasks.register('preparePublicCodegenSpec') { + description = 'public-v1 계약에서 생성기 입력을 파생시킨다.' + def specSource = file("${rootDir}/config/openapi/public-v1.yaml") + def specOut = publicCodegenSpecFile + def ignoreOut = publicCodegenIgnoreFile + def unionDir = publicUnionSrcDir + def modelPackage = publicModelPackage + def prepare = prepareTechLogCodegenSpec + inputs.file(specSource) + outputs.file(specOut) + outputs.file(ignoreOut) + outputs.dir(unionDir) + doLast { + prepare('preparePublicCodegenSpec', specSource, specOut.get().asFile, + ignoreOut.get().asFile, unionDir.get().asFile, modelPackage) + } +} + +// public-v1 생성. openApiGenerate 확장은 계약 하나만 다루므로 두 번째 계약은 GenerateTask 를 +// 직접 등록한다. 설정은 studio 쪽과 같은 근거를 따른다(model 만 생성, oneOf interface 미사용, +// openApiNullable=false) — 그 근거는 위 openApiGenerate 블록의 주석에 있다. +tasks.register('openApiGeneratePublic', + org.openapitools.generator.gradle.plugin.tasks.GenerateTask) { + dependsOn tasks.named('preparePublicCodegenSpec') + generatorName = 'spring' + inputSpec = publicCodegenSpecFile.get().asFile.path + ignoreFileOverride = publicCodegenIgnoreFile.get().asFile.path + outputDir = layout.buildDirectory.dir('generated/openapi-public').get().asFile.path + modelPackage = publicModelPackage + // 검증을 켠 채로 둔다. 한때 swagger-parser 가 이 문서의 스키마 15개를 + // "is not of type `object`" 로 거절했는데, 원인은 계약이 아니라 파생 단계였다. + // preparePublicCodegenSpec 의 변환이 같은 Map 인스턴스를 여러 property 에 재사용해 + // snakeyaml 이 YAML alias(*id001)로 덤프했고, swagger-parser 가 alias 노드를 + // 해석하지 못해 그 스키마 전체를 거절했다. validateSpec 을 끄면 generator 는 문서를 + // 받아들이되 alias 였던 property 를 **조용히 누락**한다 — publishedAt, updatedAt, + // matchedFields, changeTypes 가 실제로 모델에서 사라졌다. 파생 단계에서 deep copy 로 + // alias 를 원천 차단했으므로 검증을 다시 켠다. + validateSpec = true + globalProperties.set(['models': '']) + generateModelTests = false + generateModelDocumentation = false + configOptions = [ + useSpringBoot3: 'true', + useJakartaEe: 'true', + openApiNullable: 'false', + useOneOfInterfaces: 'false', + ] + // 생성기는 outputDir 를 비우지 않는다 — 계약에서 사라진 스키마의 .java 가 남아 드리프트를 + // 가린다(studio 쪽에서 실제로 겪었다). + doFirst { project.delete(layout.buildDirectory.dir('generated/openapi-public')) } +} + +// 생성기가 스키마나 property 를 조용히 빠뜨려도 컴파일은 그대로 통과한다(그 타입을 아직 +// 아무도 안 쓰니까) — 나중에 컨트롤러를 쓸 때서야 드러난다. 실제로 파생 계약의 YAML alias +// 때문에 publishedAt / updatedAt / matchedFields / changeTypes 가 모델에서 사라진 채로 +// 빌드가 성공한 적이 있고, 그때 이 게이트가 schema 이름만 봐서 놓쳤다. 그래서 property 까지 +// 대조한다. +tasks.register('verifyPublicGeneratedModels') { + group = 'verification' + description = 'public-v1 계약의 schema 와 property 가 전부 모델로 생성됐는지 대조한다.' + dependsOn tasks.named('openApiGeneratePublic') + def specFile = publicCodegenSpecFile + def modelDirProvider = layout.buildDirectory.dir('generated/openapi-public/src/main/java') + def modelPackage = publicModelPackage + doLast { + def doc = new org.yaml.snakeyaml.Yaml().load(specFile.get().asFile.getText('UTF-8')) + Set declared = new TreeSet<>(((Map) doc.components.schemas).keySet()) + File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/')) + Set generated = new TreeSet<>() + if (packageDir.isDirectory()) { + packageDir.eachFile { File f -> + if (f.name.endsWith('.java')) generated << f.name[0..-6] + } + } + // 생성기는 이름 없는 중첩 object 에 <부모><필드> 형태의 모델을 더 만든다. 그건 초과분이라 + // 문제가 아니고, 부족분만 문제다. + Set missing = new TreeSet<>(declared - generated) + if (!missing.isEmpty()) { + throw new GradleException( + "public-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}") + } + + // property 대조. 생성기는 @JsonProperty 에 계약의 원래 이름을 그대로 쓰므로 + // 그 문자열 리터럴이 파일에 있는지로 판정한다. + int checkedProps = 0 + List lost = [] + ((Map) doc.components.schemas).each { String name, Object schema -> + if (!(schema instanceof Map)) return + Object props = ((Map) schema).get('properties') + if (!(props instanceof Map)) return + File modelFile = new File(packageDir, "${name}.java") + if (!modelFile.isFile()) return + String body = modelFile.getText('UTF-8') + ((Map) props).keySet().each { Object prop -> + checkedProps++ + if (!body.contains("\"${prop}\"")) lost << "${name}.${prop}" + } + } + if (!lost.isEmpty()) { + throw new GradleException( + "public-v1 계약의 property ${lost.size()}개가 모델에서 빠졌다 " + + "(생성기가 조용히 누락한다): ${lost.take(20)}") + } + + logger.lifecycle( + "verifyPublicGeneratedModels: 계약 schema ${declared.size()}개 · " + + "property ${checkedProps}개 전부 생성 (생성 모델 ${generated.size()}개)") + } +} + +tasks.named('check') { + dependsOn tasks.named('verifyPublicGeneratedModels') +} + +tasks.named('compileGeneratedOpenapiJava') { + dependsOn tasks.named('openApiGeneratePublic') +} + // openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라 // dependsOn 을 받지 못한다. 태스크 쪽에 건다. tasks.named('openApiGenerate') { diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java index 5c10a4f..e001305 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java @@ -29,14 +29,17 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep * handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다. * *

{@code basePackages} 스코프 (final whole-branch review B4). 이 advice는 {@code - * dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code - * studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring - * MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그 - * 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이 - * 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다. + * dev.caskeleton.adapter.inbound.web.techlog.studio} 아래의 컨트롤러(studio 컨트롤러 전부가 여기 산다, {@code + * studio.controller})에만 적용된다. 원래는 한 단계 위인 {@code ...web.techlog}였는데, 공개 조회 컨트롤러가 {@code + * ...web.techlog.publicapi}에 들어오면서 그 스코프가 남의 기능까지 덮게 되었다 — 아래 바인딩 예외 처리기들이 공개 조회의 파라미터 오류를 Studio + * 계약 코드로 바꿔 내보냈을 것이고, 그 코드는 public-v1 계약의 enum 에 없어서 프론트엔드의 응답 파싱을 깨뜨린다. 그래서 studio 로 좁혔다. {@link + * #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 + * 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 + * 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이 없는 기능이다. {@code StudioException} + * 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다. */ @Order(Ordered.HIGHEST_PRECEDENCE) -@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog") +@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.studio") public class StudioExceptionHandler { private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class); diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicClientSafeMessages.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicClientSafeMessages.java new file mode 100644 index 0000000..fa49f1d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicClientSafeMessages.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi; + +import dev.caskeleton.application.techlog.publicsite.error.PublicError; + +/** + * 공개 조회 실패의 client-safe {@code error.message} 단일 출처. + * + *

{@code PublicException#getMessage()}는 use case 가 진단용으로 채우는 원문이라 {@code ApiErrorCarrier} + * javadoc 이 경고하는 대로 저장소 내부 사정을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고 이 클래스가 code 별 고정 문구만 내보낸다 — {@code + * StudioClientSafeMessages}가 {@code StudioError}에 대해 하는 것과 같은 역할이다. + * + *

문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 — + * {@code PublicErrorRegistryTest}가 그 일치를 고정한다. + * + *

{@link PublicError}를 exhaustive switch 로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 — + * 문구 누락이 생길 수 없다. + */ +public final class PublicClientSafeMessages { + + private PublicClientSafeMessages() {} + + public static String forError(PublicError error) { + return switch (error) { + case PUBLIC_REQUEST_INVALID -> "요청 값이 올바르지 않습니다"; + case PUBLIC_RESOURCE_NOT_FOUND -> "요청한 자료를 찾을 수 없습니다"; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicExceptionHandler.java new file mode 100644 index 0000000..e6aa9b5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/PublicExceptionHandler.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi; + +import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory; +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; +import dev.caskeleton.shared.response.Envelope; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +/** + * 공개 조회 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice 로 둔다 — 그 파일은 + * template sync 대상이다. + * + *

{@code basePackages} 스코프. 이 advice 는 {@code + * dev.caskeleton.adapter.inbound.web.techlog.publicapi} 아래의 컨트롤러에만 적용된다. 형제인 {@code + * StudioExceptionHandler}가 원래 {@code ...web.techlog} 전체를 잡고 있었는데, 그 스코프는 이 패키지까지 포함하므로 공개 조회의 파라미터 + * 오류가 Studio 계약의 {@code REQUEST_VALIDATION_FAILED}(422)로 나갔을 것이다 — public-v1 계약의 {@code + * ApiError.code} enum 에 없는 코드라 프론트엔드의 응답 파싱 자체가 깨진다. 그래서 이 advice 를 추가하면서 Studio 쪽 스코프를 {@code + * ...web.techlog.studio}로 좁혔다. 두 스코프는 이제 겹치지 않는다. + * + *

{@code error.message}에는 {@link PublicClientSafeMessages}가 주는 code 별 고정 문구만 싣는다 — {@link + * PublicException#getMessage()}(진단용 원문)는 그대로 내보내지 않는다({@code ApiErrorCarrier} javadoc). 원문은 버리지 않고 + * 서버 로그에만 남긴다. + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.publicapi") +public class PublicExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(PublicExceptionHandler.class); + + /** + * 공개 조회는 인증이 없고 열람자가 익명이다. 없는 slug 하나하나를 ERROR 로 남기면 크롤러가 만드는 404 가 로그를 덮어 실제 장애를 가린다 — {@code + * NOT_FOUND}는 WARN 이하로 남기고 나머지만 ERROR 로 올린다. + */ + @ExceptionHandler(PublicException.class) + public ResponseEntity> handlePublic(PublicException ex) { + PublicError error = ex.publicError(); + if (error == PublicError.PUBLIC_RESOURCE_NOT_FOUND) { + log.debug("public resource not found: {}", ex.getMessage()); + } else { + log.warn( + "public request rejected as {} (category={}): {}", + error.code(), + error.category(), + ex.getMessage()); + } + return ErrorResponseFactory.envelope(error, PublicClientSafeMessages.forError(error), null); + } + + /** + * 필수 쿼리 파라미터 누락 — 계약에서 {@code GET /v1/public/search}의 {@code q}가 유일하다. 이 예외를 그냥 두면 부모 {@code + * ResponseEntityExceptionHandler}가 bare {@code ProblemDetail}(content-type {@code + * application/problem+json})을 만들고, {@code EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 — + * ADR-006 이 쓰지 않기로 한 RFC 7807 이 그대로 나간다. + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParameter( + MissingServletRequestParameterException ex) { + return requestInvalid(ex.getParameterName(), "REQUIRED", "Required parameter is missing"); + } + + /** + * 쿼리 파라미터 타입 불일치(예: {@code page=abc}, {@code year=x}). {@code GlobalExceptionHandler}도 이 예외를 + * 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — public-v1 계약의 세 코드에 없다. + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + return requestInvalid(ex.getName(), "TYPE_MISMATCH", "Parameter value is invalid"); + } + + /** + * {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{field, code, + * message}]}) 모양에 맞춰 싣는다. 세 필드 전부 {@code required}이므로 하나라도 빠지면 계약 위반이다 — Studio 계약의 {@code {path, + * message}}와 모양이 다르니 그 코드를 복사해 오면 안 된다. + */ + private static ResponseEntity> requestInvalid( + String field, String code, String message) { + Map fieldError = Map.of("field", field, "code", code, "message", message); + Map details = Map.of("fieldErrors", List.of(fieldError)); + return ErrorResponseFactory.envelope( + PublicError.PUBLIC_REQUEST_INVALID, + PublicClientSafeMessages.forError(PublicError.PUBLIC_REQUEST_INVALID), + details); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicDocumentController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicDocumentController.java new file mode 100644 index 0000000..e57d81f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicDocumentController.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.DocumentResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * 문서 상세 세 종류. 계약 {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion}. + */ +@RestController +public class PublicDocumentController { + + private final GetPublicCaseUseCase getCase; + private final GetPublicReferenceUseCase getReference; + private final GetPublicQuestionUseCase getQuestion; + + public PublicDocumentController( + GetPublicCaseUseCase getCase, + GetPublicReferenceUseCase getReference, + GetPublicQuestionUseCase getQuestion) { + this.getCase = getCase; + this.getReference = getReference; + this.getQuestion = getQuestion; + } + + @GetMapping("/v1/public/cases/{slug}") + public CaseDetailResponse getPublicCase(@PathVariable("slug") String slug) { + return DocumentResponseMapper.caseDetail(getCase.handle(new SlugQuery(slug))); + } + + @GetMapping("/v1/public/references/{slug}") + public ReferenceDetailResponse getPublicReference(@PathVariable("slug") String slug) { + return DocumentResponseMapper.referenceDetail(getReference.handle(new SlugQuery(slug))); + } + + @GetMapping("/v1/public/questions/{slug}") + public QuestionDetailResponse getPublicQuestion(@PathVariable("slug") String slug) { + return DocumentResponseMapper.questionDetail(getQuestion.handle(new SlugQuery(slug))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicExploreController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicExploreController.java new file mode 100644 index 0000000..19b6eea --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicExploreController.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgePage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ExploreResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase; +import java.util.Set; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 탐색과 검색. 계약 {@code exploreKnowledge} / {@code exploreQuestions} / {@code searchPublicResources}. + * + *

계약이 enum 을 선언한 파라미터는 {@link PublicRequestParams} 로 검사한다 — 이유는 그 클래스 javadoc. + */ +@RestController +public class PublicExploreController { + + private static final Set KNOWLEDGE_TYPES = Set.of("CASE", "REFERENCE"); + private static final Set KNOWLEDGE_SORTS = + Set.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC"); + private static final Set QUESTION_STATUSES = + Set.of("OPEN", "INVESTIGATING", "PAUSED", "RESOLVED"); + private static final Set QUESTION_SORTS = + Set.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC"); + private static final Set SEARCH_TYPES = + Set.of("CASE", "REFERENCE", "QUESTION", "PROJECT", "RELEASE"); + + private final ExploreKnowledgeUseCase exploreKnowledge; + private final ExploreQuestionsUseCase exploreQuestions; + private final SearchPublicResourcesUseCase search; + + public PublicExploreController( + ExploreKnowledgeUseCase exploreKnowledge, + ExploreQuestionsUseCase exploreQuestions, + SearchPublicResourcesUseCase search) { + this.exploreKnowledge = exploreKnowledge; + this.exploreQuestions = exploreQuestions; + this.search = search; + } + + @GetMapping("/v1/public/explore/knowledge") + public KnowledgePage exploreKnowledge( + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "topic", required = false) String topic, + @RequestParam(value = "project", required = false) String project, + @RequestParam(value = "tag", required = false) String tag, + @RequestParam(value = "year", required = false) Integer year, + @RequestParam(value = "sort", required = false) String sort, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + ExploreKnowledgeQuery query = + new ExploreKnowledgeQuery( + PublicRequestParams.oneOf("type", type, KNOWLEDGE_TYPES), + topic, + project, + tag, + PublicRequestParams.year(year), + PublicRequestParams.sort("sort", sort, "PUBLISHED_DESC", KNOWLEDGE_SORTS), + PublicRequestParams.page(page, size)); + return ExploreResponseMapper.knowledge(exploreKnowledge.handle(query)); + } + + @GetMapping("/v1/public/explore/questions") + public QuestionPage exploreQuestions( + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "topic", required = false) String topic, + @RequestParam(value = "project", required = false) String project, + @RequestParam(value = "tag", required = false) String tag, + @RequestParam(value = "sort", required = false) String sort, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + ExploreQuestionsQuery query = + new ExploreQuestionsQuery( + PublicRequestParams.oneOf("status", status, QUESTION_STATUSES), + topic, + project, + tag, + PublicRequestParams.sort("sort", sort, "UPDATED_DESC", QUESTION_SORTS), + PublicRequestParams.page(page, size)); + return ExploreResponseMapper.questions(exploreQuestions.handle(query)); + } + + @GetMapping("/v1/public/search") + public SearchResultPage searchPublicResources( + @RequestParam("q") String q, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "topic", required = false) String topic, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + SearchQuery query = + new SearchQuery( + PublicRequestParams.searchTerm(q), + PublicRequestParams.oneOf("type", type, SEARCH_TYPES), + topic, + PublicRequestParams.page(page, size)); + return ExploreResponseMapper.search(search.handle(query)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicProjectController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicProjectController.java new file mode 100644 index 0000000..34f4a88 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicProjectController.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ProjectResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase; +import java.util.Set; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 프로젝트 허브. 계약 {@code listPublicProjects} / {@code getPublicProject} 와 하위 목록 셋({@code + * listPublicProjectDecisions} / {@code listPublicProjectRecords} / {@code + * listPublicProjectActivities}). + * + *

하위 목록은 프로젝트 자체가 공개가 아니면 빈 페이지가 아니라 404 다 — 비공개 프로젝트의 존재가 "결정이 0건인 프로젝트"로 새어 나가면 안 된다. 그 구분은 + * port 가 {@code Optional} 로 표현하고 use case 가 404 로 옮긴다. + */ +@RestController +public class PublicProjectController { + + private static final Set RECORD_TYPES = Set.of("CASE", "REFERENCE", "QUESTION"); + private static final Set RECORD_RELATIONS = Set.of("PRIMARY", "RELATED"); + + private final ListPublicProjectsUseCase listProjects; + private final GetPublicProjectUseCase getProject; + private final ListPublicProjectDecisionsUseCase listDecisions; + private final ListPublicProjectRecordsUseCase listRecords; + private final ListPublicProjectActivitiesUseCase listActivities; + + public PublicProjectController( + ListPublicProjectsUseCase listProjects, + GetPublicProjectUseCase getProject, + ListPublicProjectDecisionsUseCase listDecisions, + ListPublicProjectRecordsUseCase listRecords, + ListPublicProjectActivitiesUseCase listActivities) { + this.listProjects = listProjects; + this.getProject = getProject; + this.listDecisions = listDecisions; + this.listRecords = listRecords; + this.listActivities = listActivities; + } + + @GetMapping("/v1/public/projects") + public ProjectListResponse listPublicProjects() { + return ProjectResponseMapper.list(listProjects.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/projects/{slug}") + public ProjectDetailResponse getPublicProject(@PathVariable("slug") String slug) { + return ProjectResponseMapper.detail(getProject.handle(new SlugQuery(slug))); + } + + @GetMapping("/v1/public/projects/{slug}/decisions") + public ProjectDecisionPage listPublicProjectDecisions( + @PathVariable("slug") String slug, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + return ProjectResponseMapper.decisions( + listDecisions.handle( + new ProjectDecisionPageQuery(slug, status, PublicRequestParams.page(page, size)))); + } + + @GetMapping("/v1/public/projects/{slug}/records") + public ProjectRecordPage listPublicProjectRecords( + @PathVariable("slug") String slug, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "relation", required = false) String relation, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + ProjectRecordPageQuery query = + new ProjectRecordPageQuery( + slug, + PublicRequestParams.oneOf("type", type, RECORD_TYPES), + PublicRequestParams.oneOf("relation", relation, RECORD_RELATIONS), + PublicRequestParams.page(page, size)); + return ProjectResponseMapper.records(listRecords.handle(query)); + } + + @GetMapping("/v1/public/projects/{slug}/activities") + public ProjectActivityPage listPublicProjectActivities( + @PathVariable("slug") String slug, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + return ProjectResponseMapper.activities( + listActivities.handle(new ProjectPageQuery(slug, PublicRequestParams.page(page, size)))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicReleaseController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicReleaseController.java new file mode 100644 index 0000000..0e60baf --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicReleaseController.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ReleaseResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tech Log 자체 변경 기록. 계약 {@code listPublicReleases} / {@code getPublicRelease}. + * + *

{@code getPublicRelease} 의 path 변수는 slug 가 아니라 {@code version} 이다 — {@code SlugQuery} 를 그대로 쓰되 + * 어댑터가 {@code release.version} 으로 조회한다({@code PublicReleaseQueryPort#findByVersion}). 값의 의미가 다르므로 + * 이름을 그대로 옮겨 적는다. + */ +@RestController +public class PublicReleaseController { + + private final ListPublicReleasesUseCase listReleases; + private final GetPublicReleaseUseCase getRelease; + + public PublicReleaseController( + ListPublicReleasesUseCase listReleases, GetPublicReleaseUseCase getRelease) { + this.listReleases = listReleases; + this.getRelease = getRelease; + } + + @GetMapping("/v1/public/releases") + public ReleaseListResponse listPublicReleases() { + return ReleaseResponseMapper.list(listReleases.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/releases/{version}") + public ReleaseDetailResponse getPublicRelease(@PathVariable("version") String version) { + return ReleaseResponseMapper.detail(getRelease.handle(new SlugQuery(version))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicRequestParams.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicRequestParams.java new file mode 100644 index 0000000..70c0c87 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicRequestParams.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; +import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest; +import java.util.List; +import java.util.Set; + +/** + * 계약이 쿼리 파라미터에 건 제약을 요청 경계에서 강제한다. + * + *

enum 값을 검사하지 않고 그대로 SQL 필터로 넘기면 오타(`type=CASES`)가 오류가 아니라 "결과 0건"으로 보인다 — 소비자는 자기 요청이 틀렸다는 사실을 + * 영영 알 수 없다. 계약이 enum 을 선언한 자리는 계약 밖 값을 {@code PUBLIC_REQUEST_INVALID} 로 거절한다. + * + *

파라미터를 생성 DTO 의 enum 타입으로 바인딩하지 않는 이유는, 그 경우 Spring 이 던지는 {@code + * MethodArgumentTypeMismatchException} 이 "어떤 값이 허용되는지"를 응답에 남기지 못하고 스택 상위에서 잡히기 때문이다. 여기서 검사하면 거절 + * 이유를 계약의 {@code fieldErrors} 모양으로 정확히 실을 수 있다. + */ +final class PublicRequestParams { + + private PublicRequestParams() {} + + static PublicPageRequest page(int page, int size) { + return new PublicPageRequest(page, size); + } + + /** null(=필터 없음)은 통과시키고, 값이 있으면 계약의 허용 집합에 있어야 한다. */ + static String oneOf(String field, String value, Set allowed) { + if (value == null) { + return null; + } + if (!allowed.contains(value)) { + throw PublicException.of( + PublicError.PUBLIC_REQUEST_INVALID, + field + " must be one of " + List.copyOf(allowed) + " but was '" + value + "'"); + } + return value; + } + + /** 값이 없으면 계약의 default 를 쓴다 — 정렬은 optional 이지만 항상 하나로 정해져야 한다. */ + static String sort(String field, String value, String fallback, Set allowed) { + return value == null ? fallback : oneOf(field, value, allowed); + } + + /** 계약 {@code searchPublicResources.q}: minLength 1 / maxLength 100. */ + static String searchTerm(String q) { + String trimmed = q == null ? "" : q.strip(); + if (trimmed.isEmpty()) { + throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "q must not be blank"); + } + if (trimmed.length() > 100) { + throw PublicException.of( + PublicError.PUBLIC_REQUEST_INVALID, "q must be at most 100 characters"); + } + return trimmed; + } + + /** 계약 {@code exploreKnowledge.year}: minimum 2000. */ + static Integer year(Integer year) { + if (year != null && year < 2000) { + throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "year must be 2000 or later"); + } + return year; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicSiteController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicSiteController.java new file mode 100644 index 0000000..5b752b7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicSiteController.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.SiteResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 사이트 껍데기 · 홈 · 운영자 프로필. 계약 {@code getPublicSite} / {@code getPublicHome} / {@code + * getPublicProfile}. + * + *

반환값을 Envelope 로 감싸지 않는다 — {@code EnvelopeBodyAdvice} 가 감싼다. 계약의 {@code Envelope} 스키마로 + * 생성된 DTO 는 쓰지 않는다(그걸 반환하면 봉투가 두 번 씌워진다). + * + *

경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code + * ca-skeleton.presentation.api-base-path}("/api")를 모든 컨트롤러 매핑에 붙인다. 계약의 {@code servers} 가 {@code + * /api/v1/public} 이므로 여기 매핑은 {@code /v1/public/...} 이어야 최종 주소가 계약과 같아진다. + */ +@RestController +public class PublicSiteController { + + private final GetPublicSiteUseCase getSite; + private final GetPublicHomeUseCase getHome; + private final GetPublicProfileUseCase getProfile; + + public PublicSiteController( + GetPublicSiteUseCase getSite, + GetPublicHomeUseCase getHome, + GetPublicProfileUseCase getProfile) { + this.getSite = getSite; + this.getHome = getHome; + this.getProfile = getProfile; + } + + @GetMapping("/v1/public/site") + public SiteResponse getPublicSite() { + return SiteResponseMapper.site(getSite.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/home") + public HomeResponse getPublicHome() { + return SiteResponseMapper.home(getHome.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/profile") + public ProfileResponse getPublicProfile() { + return SiteResponseMapper.profile(getProfile.handle(new EmptyQuery())); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicTopicController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicTopicController.java new file mode 100644 index 0000000..73ecb23 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/controller/PublicTopicController.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.TopicResponseMapper; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** 주제 목록과 상세. 계약 {@code listPublicTopics} / {@code getPublicTopic}. */ +@RestController +public class PublicTopicController { + + private final ListPublicTopicsUseCase listTopics; + private final GetPublicTopicUseCase getTopic; + + public PublicTopicController(ListPublicTopicsUseCase listTopics, GetPublicTopicUseCase getTopic) { + this.listTopics = listTopics; + this.getTopic = getTopic; + } + + @GetMapping("/v1/public/topics") + public TopicListResponse listPublicTopics() { + return TopicResponseMapper.list(listTopics.handle(new EmptyQuery())); + } + + @GetMapping("/v1/public/topics/{topicSlug}") + public TopicDetailResponse getPublicTopic(@PathVariable("topicSlug") String topicSlug) { + return TopicResponseMapper.detail(getTopic.handle(new SlugQuery(topicSlug))); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java new file mode 100644 index 0000000..c592c9f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/DocumentResponseMapper.java @@ -0,0 +1,169 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseCase; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseRelations; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestion; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestionResolution; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseRelations; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPointGroup; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; + +/** + * {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion} 의 응답 조립. + * + *

Case 와 Reference 는 같은 {@link PublishedDocumentView} 를 읽지만 계약이 요약 필드를 서로 다르게 이름 붙였다 — Case 는 + * {@code problemSummary}/{@code conclusionSummary}, Reference 는 {@code scopeSummary} 다. view 는 + * {@code primarySummary}/{@code secondarySummary} 라는 중립 이름을 쓰고 그 매핑을 여기서 한 번만 한다. ADR-003 이 말하는 + * "API 용어와 Domain 용어 분리"가 이 자리다. + */ +public final class DocumentResponseMapper { + + private DocumentResponseMapper() {} + + public static CaseDetailResponse caseDetail(CaseDetailView view) { + PublishedDocumentView doc = view.document(); + CaseDetailResponseCase body = new CaseDetailResponseCase(); + body.setTitle(doc.title()); + body.setProblemSummary(doc.primarySummary()); + body.setConclusionSummary(doc.secondarySummary()); + body.setEnvironmentSummary(doc.environmentSummary()); + body.setContent(doc.content()); + body.setContentFormat(CaseDetailResponseCase.ContentFormatEnum.fromValue(doc.contentFormat())); + body.setContentFormatVersion(doc.contentFormatVersion()); + body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic())); + body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag)); + body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject())); + body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset())); + body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt())); + body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt())); + body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt())); + + CaseDetailResponseRelations relations = new CaseDetailResponseRelations(); + relations.setOriginQuestion(PublicResponseMapper.related(view.relations().originQuestion())); + relations.setProjectDecisions( + PublicResponseMapper.relatedList(view.relations().projectDecisions())); + relations.setDerivedReferences( + PublicResponseMapper.relatedList(view.relations().derivedReferences())); + relations.setRelatedCases(PublicResponseMapper.relatedList(view.relations().relatedCases())); + + CaseDetailResponse dto = new CaseDetailResponse(); + dto.setCanonicalPath(view.canonicalPath()); + dto.setIndexable(view.indexable()); + dto.setCase(body); + dto.setRelations(relations); + return dto; + } + + public static ReferenceDetailResponse referenceDetail(ReferenceDetailView view) { + PublishedDocumentView doc = view.document(); + ReferenceDetailResponseReference body = new ReferenceDetailResponseReference(); + body.setTitle(doc.title()); + body.setScopeSummary(doc.primarySummary()); + body.setAppliesTo(doc.appliesTo()); + body.setExcludedScope(doc.excludedScope()); + body.setFreshnessStatus( + ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus())); + body.setContent(doc.content()); + body.setContentFormat( + ReferenceDetailResponseReference.ContentFormatEnum.fromValue(doc.contentFormat())); + body.setContentFormatVersion(doc.contentFormatVersion()); + body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic())); + body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag)); + body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject())); + body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset())); + body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt())); + body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt())); + body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt())); + + ReferenceDetailResponseRelations relations = new ReferenceDetailResponseRelations(); + relations.setSupportingCases( + PublicResponseMapper.relatedList(view.relations().supportingCases())); + relations.setRelatedDecisions( + PublicResponseMapper.relatedList(view.relations().relatedDecisions())); + relations.setRelatedReferences( + PublicResponseMapper.relatedList(view.relations().relatedReferences())); + + ReferenceDetailResponse dto = new ReferenceDetailResponse(); + dto.setCanonicalPath(view.canonicalPath()); + dto.setIndexable(view.indexable()); + dto.setReference(body); + dto.setRelations(relations); + return dto; + } + + public static QuestionDetailResponse questionDetail(QuestionDetailView view) { + PublishedQuestionView q = view.question(); + QuestionDetailResponseQuestion body = new QuestionDetailResponseQuestion(); + body.setQuestion(q.question()); + body.setSummary(q.summary()); + body.setContext(q.context()); + body.setImportance(q.importance()); + body.setStatus(QuestionDetailResponseQuestion.StatusEnum.fromValue(q.status())); + body.setNextVerification(q.nextVerification()); + body.setPoints(points(q.points())); + body.setUpdates(PublicResponseMapper.map(q.updates(), DocumentResponseMapper::update)); + body.setResolution(resolution(q)); + body.setOpenedAt(PublicResponseMapper.at(q.openedAt())); + body.setUpdatedAt(PublicResponseMapper.at(q.updatedAt())); + + QuestionDetailResponseRelations relations = new QuestionDetailResponseRelations(); + relations.setPrimaryProject(PublicResponseMapper.related(view.relations().primaryProject())); + relations.setResultCase(PublicResponseMapper.related(view.relations().resultCase())); + relations.setProducedDecision( + PublicResponseMapper.related(view.relations().producedDecision())); + relations.setDerivedReferences( + PublicResponseMapper.relatedList(view.relations().derivedReferences())); + + QuestionDetailResponse dto = new QuestionDetailResponse(); + dto.setCanonicalPath(view.canonicalPath()); + dto.setIndexable(view.indexable()); + dto.setQuestion(body); + dto.setRelations(relations); + return dto; + } + + private static QuestionPointGroup points(QuestionPointGroupView view) { + QuestionPointGroup dto = new QuestionPointGroup(); + dto.setFacts(view.facts()); + dto.setAssumptions(view.assumptions()); + dto.setUnknowns(view.unknowns()); + dto.setConstraints(view.constraints()); + return dto; + } + + private static QuestionUpdatePublic update(QuestionUpdateView view) { + QuestionUpdatePublic dto = new QuestionUpdatePublic(); + dto.setType(view.type()); + dto.setTitle(view.title()); + dto.setBodyMarkdown(view.bodyMarkdown()); + dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt())); + return dto; + } + + /** + * 계약은 해결 정보를 별도 nullable object 로 묶었고 view 는 평평하게 들고 있다. 세 값이 전부 비어 있으면 빈 껍데기 object 대신 아예 내보내지 + * 않는다 — 미해결 질문에 {@code resolution: {}} 이 붙으면 소비자가 "해결됐지만 내용이 없다"로 읽는다. + */ + private static QuestionDetailResponseQuestionResolution resolution(PublishedQuestionView q) { + if (q.resolutionType() == null && q.resolutionSummary() == null && q.resolvedAt() == null) { + return null; + } + QuestionDetailResponseQuestionResolution dto = new QuestionDetailResponseQuestionResolution(); + dto.setType(q.resolutionType()); + dto.setSummary(q.resolutionSummary()); + dto.setResolvedAt(PublicResponseMapper.at(q.resolvedAt())); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ExploreResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ExploreResponseMapper.java new file mode 100644 index 0000000..d053de2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ExploreResponseMapper.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgeListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgePage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultPage; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; + +/** + * {@code exploreKnowledge} / {@code exploreQuestions} / {@code searchPublicResources} 의 응답 조립. + * + *

{@code fromValue} 는 계약 밖 값을 만나면 예외를 던진다. 그대로 둔다 — 여기서 조용히 null 을 넣으면 required 필드가 빈 채로 나가 소비자 + * 쪽에서 더 늦게, 더 알기 어려운 모양으로 깨진다. 공개 projection 이 계약 밖 상태값을 담고 있다면 그건 데이터 결함이고 500 으로 드러나야 한다({@code + * INTERNAL_ERROR} 는 계약이 열거한 코드다). + */ +public final class ExploreResponseMapper { + + private ExploreResponseMapper() {} + + public static KnowledgePage knowledge(KnowledgePageView view) { + KnowledgePage dto = new KnowledgePage(); + dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::knowledgeItem)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static KnowledgeListItem knowledgeItem(KnowledgeListItemView view) { + KnowledgeListItem dto = new KnowledgeListItem(); + dto.setType(KnowledgeListItem.TypeEnum.fromValue(view.type())); + dto.setTitle(view.title()); + dto.setPath(view.path()); + dto.setPrimarySummary(view.primarySummary()); + dto.setSecondarySummary(view.secondarySummary()); + dto.setPrimaryTopic(PublicResponseMapper.topic(view.primaryTopic())); + dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject())); + dto.setPublishedAt(PublicResponseMapper.at(view.publishedAt())); + dto.setLastVerifiedAt(PublicResponseMapper.at(view.lastVerifiedAt())); + dto.setFreshnessStatus(view.freshnessStatus()); + return dto; + } + + public static QuestionPage questions(QuestionPageView view) { + QuestionPage dto = new QuestionPage(); + dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::questionItem)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static QuestionListItem questionItem(QuestionListItemView view) { + QuestionListItem dto = new QuestionListItem(); + dto.setQuestion(view.question()); + dto.setPath(view.path()); + dto.setStatus(QuestionListItem.StatusEnum.fromValue(view.status())); + dto.setSummary(view.summary()); + dto.setCurrentUnderstanding(view.currentUnderstanding()); + dto.setNextVerification(view.nextVerification()); + dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject())); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } + + public static SearchResultPage search(SearchResultPageView view) { + SearchResultPage dto = new SearchResultPage(); + dto.setQuery(view.query()); + dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::searchItem)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static SearchResultItem searchItem(SearchResultItemView view) { + SearchResultItem dto = new SearchResultItem(); + dto.setContentType(view.contentType()); + dto.setTitle(view.title()); + dto.setPath(view.path()); + dto.setSnippet(view.snippet()); + dto.setMatchedFields(view.matchedFields()); + dto.setPrimaryTopic(PublicResponseMapper.topic(view.primaryTopic())); + dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject())); + dto.setPublishedAt(PublicResponseMapper.at(view.publishedAt())); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java new file mode 100644 index 0000000..c5642ca --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ProjectResponseMapper.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponseProject; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView; +import java.util.List; + +/** {@code listPublicProjects} 와 프로젝트 상세·하위 목록 세 개의 응답 조립. */ +public final class ProjectResponseMapper { + + private ProjectResponseMapper() {} + + public static ProjectListResponse list(List views) { + ProjectListResponse dto = new ProjectListResponse(); + dto.setItems(PublicResponseMapper.map(views, ProjectResponseMapper::listItem)); + return dto; + } + + private static ProjectListItem listItem(ProjectListItemView view) { + ProjectListItem dto = new ProjectListItem(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + dto.setPath(view.path()); + dto.setOneLinePurpose(view.oneLinePurpose()); + dto.setPhase(view.phase()); + dto.setCurrentObjective(view.currentObjective()); + dto.setNextStep(view.nextStep()); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } + + public static ProjectDetailResponse detail(ProjectDetailView view) { + PublishedProjectView p = view.project(); + ProjectDetailResponseProject body = new ProjectDetailResponseProject(); + body.setName(p.name()); + body.setSlug(p.slug()); + body.setOneLinePurpose(p.oneLinePurpose()); + body.setPurpose(p.purpose()); + body.setBoundary(p.boundary()); + body.setPhase(p.phase()); + body.setCurrentObjective(p.currentObjective()); + body.setNextStep(p.nextStep()); + body.setSystemOverviewMarkdown(p.systemOverviewMarkdown()); + body.setTechnologies(p.technologies()); + body.setUpdatedAt(PublicResponseMapper.at(p.updatedAt())); + + ProjectDetailResponse dto = new ProjectDetailResponse(); + dto.setCanonicalPath(view.canonicalPath()); + dto.setIndexable(view.indexable()); + dto.setProject(body); + dto.setFeaturedDecision(PublicResponseMapper.related(view.featuredDecision())); + dto.setActiveQuestion(PublicResponseMapper.related(view.activeQuestion())); + dto.setSelectedRecords(PublicResponseMapper.relatedList(view.selectedRecords())); + return dto; + } + + public static ProjectDecisionPage decisions(ProjectDecisionPageView view) { + ProjectDecisionPage dto = new ProjectDecisionPage(); + dto.setItems(PublicResponseMapper.map(view.items(), ProjectResponseMapper::decision)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static ProjectDecisionItem decision(ProjectDecisionItemView view) { + ProjectDecisionItem dto = new ProjectDecisionItem(); + dto.setId(view.id()); + dto.setStatement(view.statement()); + dto.setStatus(view.status()); + dto.setRationaleSummary(view.rationaleSummary()); + dto.setDecidedAt(PublicResponseMapper.at(view.decidedAt())); + dto.setSourceQuestion(PublicResponseMapper.related(view.sourceQuestion())); + dto.setSourceCase(PublicResponseMapper.related(view.sourceCase())); + return dto; + } + + public static ProjectRecordPage records(ProjectRecordPageView view) { + ProjectRecordPage dto = new ProjectRecordPage(); + dto.setItems(PublicResponseMapper.relatedList(view.items())); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + public static ProjectActivityPage activities(ProjectActivityPageView view) { + ProjectActivityPage dto = new ProjectActivityPage(); + dto.setItems(PublicResponseMapper.map(view.items(), ProjectResponseMapper::activity)); + dto.setPage(PublicResponseMapper.page(view.page())); + return dto; + } + + private static ProjectActivityItem activity(ProjectActivityItemView view) { + ProjectActivityItem dto = new ProjectActivityItem(); + dto.setType(view.type()); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt())); + dto.setRelatedPath(view.relatedPath()); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java new file mode 100644 index 0000000..72251b9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/PublicResponseMapper.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.AssetReference; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ContactLink; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.LatestEntry; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.PageMetadata; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectSummary; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RelatedEntry; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TagSummary; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary; +import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView; +import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView; +import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView; +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import java.net.URI; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.function.Function; + +/** 여러 응답이 함께 쓰는 조각의 매핑. */ +public final class PublicResponseMapper { + + private PublicResponseMapper() {} + + public static OffsetDateTime at(Instant instant) { + return instant == null ? null : instant.atOffset(ZoneOffset.UTC); + } + + public static TopicSummary topic(TopicSummaryView view) { + if (view == null) { + return null; + } + TopicSummary dto = new TopicSummary(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + return dto; + } + + public static TagSummary tag(TagSummaryView view) { + TagSummary dto = new TagSummary(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + return dto; + } + + public static ProjectSummary project(ProjectSummaryView view) { + if (view == null) { + return null; + } + ProjectSummary dto = new ProjectSummary(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + dto.setPath(view.path()); + return dto; + } + + public static RelatedEntry related(RelatedEntryView view) { + if (view == null) { + return null; + } + RelatedEntry dto = new RelatedEntry(); + dto.setType(RelatedEntry.TypeEnum.fromValue(view.type())); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setPath(view.path()); + return dto; + } + + public static AssetReference asset(AssetReferenceView view) { + if (view == null) { + return null; + } + AssetReference dto = new AssetReference(); + dto.setAssetId(view.assetId()); + dto.setUrl(view.url()); + dto.setAltText(view.altText()); + dto.setWidth(view.width()); + dto.setHeight(view.height()); + dto.setContentType(view.contentType()); + return dto; + } + + public static ContactLink contact(ContactLinkView view) { + ContactLink dto = new ContactLink(); + dto.setType(view.type()); + dto.setLabel(view.label()); + dto.setUrl(uri(view.url())); + return dto; + } + + public static LatestEntry latest(LatestEntryView view) { + LatestEntry dto = new LatestEntry(); + dto.setEntryType(LatestEntry.EntryTypeEnum.fromValue(view.entryType())); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setPath(view.path()); + dto.setPrimaryTopic(topic(view.primaryTopic())); + dto.setPrimaryProject(project(view.primaryProject())); + dto.setPublishedAt(at(view.publishedAt())); + return dto; + } + + public static PageMetadata page(PageMetadataView view) { + PageMetadata dto = new PageMetadata(); + dto.setNumber(view.number()); + dto.setSize(view.size()); + dto.setTotalElements(view.totalElements()); + dto.setTotalPages(view.totalPages()); + dto.setHasPrevious(view.hasPrevious()); + dto.setHasNext(view.hasNext()); + return dto; + } + + /** + * 계약이 {@code format: uri} 로 선언한 자리. 저장된 값이 URI 로 파싱되지 않으면 그 링크를 내보내지 않는다 — 깨진 주소를 넣는 것보다 없는 편이 + * 낫고, 소비자는 이 필드가 optional 임을 안다. + */ + static URI uri(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return URI.create(value); + } catch (IllegalArgumentException e) { + return null; + } + } + + public static List map(List source, Function mapper) { + return source == null ? List.of() : source.stream().map(mapper).toList(); + } + + public static List relatedList(List views) { + return map(views, PublicResponseMapper::related); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ReleaseResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ReleaseResponseMapper.java new file mode 100644 index 0000000..759262f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/ReleaseResponseMapper.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListResponse; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import java.util.List; + +/** {@code listPublicReleases} / {@code getPublicRelease} 의 응답 조립. */ +public final class ReleaseResponseMapper { + + private ReleaseResponseMapper() {} + + public static ReleaseListResponse list(List views) { + ReleaseListResponse dto = new ReleaseListResponse(); + dto.setItems(PublicResponseMapper.map(views, ReleaseResponseMapper::item)); + return dto; + } + + private static ReleaseListItem item(ReleaseListItemView view) { + ReleaseListItem dto = new ReleaseListItem(); + dto.setVersion(view.version()); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setReleasedOn(view.releasedOn()); + dto.setChangeTypes(view.changeTypes()); + dto.setPath(view.path()); + return dto; + } + + public static ReleaseDetailResponse detail(ReleaseDetailView view) { + ReleaseDetailResponse dto = new ReleaseDetailResponse(); + dto.setVersion(view.version()); + dto.setTitle(view.title()); + dto.setSummary(view.summary()); + dto.setReleasedOn(view.releasedOn()); + dto.setChangeTypes(view.changeTypes()); + dto.setReasonMarkdown(view.reasonMarkdown()); + dto.setChangesMarkdown(view.changesMarkdown()); + dto.setUserImpactMarkdown(view.userImpactMarkdown()); + dto.setImplementationImpactMarkdown(view.implementationImpactMarkdown()); + dto.setVerificationMarkdown(view.verificationMarkdown()); + dto.setKnownLimitationsMarkdown(view.knownLimitationsMarkdown()); + dto.setRelatedRecords(PublicResponseMapper.relatedList(view.relatedRecords())); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/SiteResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/SiteResponseMapper.java new file mode 100644 index 0000000..abd2364 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/SiteResponseMapper.java @@ -0,0 +1,145 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CurrentWorkFocus; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponseFocus; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.OpenQuestionFocus; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponsePosition; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseTerritoriesInner; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseTrajectoryInner; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseWorkingModelInner; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RecentDecisionFocus; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponseBrand; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponseOperator; +import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; + +/** {@code getPublicSite} / {@code getPublicHome} / {@code getPublicProfile} 의 응답 조립. */ +public final class SiteResponseMapper { + + private SiteResponseMapper() {} + + public static SiteResponse site(SiteView view) { + SiteResponseBrand brand = new SiteResponseBrand(); + brand.setTitle(view.brandTitle()); + brand.setIdentityStatement(view.identityStatement()); + + SiteResponseOperator operator = new SiteResponseOperator(); + operator.setDisplayName(view.operatorDisplayName()); + operator.setShortIdentity(view.operatorShortIdentity()); + operator.setAvatar(PublicResponseMapper.asset(view.operatorAvatar())); + operator.setProfilePath(view.operatorProfilePath()); + + SiteResponse dto = new SiteResponse(); + dto.setBrand(brand); + dto.setOperator(operator); + dto.setContacts(PublicResponseMapper.map(view.contacts(), PublicResponseMapper::contact)); + return dto; + } + + public static HomeResponse home(HomeView view) { + HomeResponse dto = new HomeResponse(); + dto.setFocus(focus(view.focus())); + dto.setLatestEntries( + PublicResponseMapper.map(view.latestEntries(), PublicResponseMapper::latest)); + return dto; + } + + private static HomeResponseFocus focus(HomeFocusView view) { + HomeResponseFocus dto = new HomeResponseFocus(); + dto.setDefaultType(HomeResponseFocus.DefaultTypeEnum.fromValue(view.defaultType())); + dto.setCurrentWork(currentWork(view.currentWork())); + dto.setOpenQuestion(openQuestion(view.openQuestion())); + dto.setRecentDecision(recentDecision(view.recentDecision())); + return dto; + } + + private static CurrentWorkFocus currentWork(HomeFocusView.CurrentWork view) { + if (view == null) { + return null; + } + CurrentWorkFocus dto = new CurrentWorkFocus(); + dto.setProjectName(view.projectName()); + dto.setProjectPath(view.projectPath()); + dto.setPurpose(view.purpose()); + dto.setPhase(CurrentWorkFocus.PhaseEnum.fromValue(view.phase())); + dto.setCurrentObjective(view.currentObjective()); + dto.setNextStep(view.nextStep()); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } + + private static OpenQuestionFocus openQuestion(HomeFocusView.OpenQuestion view) { + if (view == null) { + return null; + } + OpenQuestionFocus dto = new OpenQuestionFocus(); + dto.setQuestion(view.question()); + dto.setQuestionPath(view.questionPath()); + dto.setSummary(view.summary()); + dto.setKnownFacts(view.knownFacts()); + dto.setUnresolvedPoints(view.unresolvedPoints()); + dto.setNextVerification(view.nextVerification()); + dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt())); + return dto; + } + + private static RecentDecisionFocus recentDecision(HomeFocusView.RecentDecision view) { + if (view == null) { + return null; + } + RecentDecisionFocus dto = new RecentDecisionFocus(); + dto.setStatement(view.statement()); + dto.setDecisionPath(view.decisionPath()); + dto.setRationale(view.rationale()); + dto.setConsequences(view.consequences()); + dto.setDecidedAt(PublicResponseMapper.at(view.decidedAt())); + return dto; + } + + public static ProfileResponse profile(ProfileView view) { + ProfileResponsePosition position = new ProfileResponsePosition(); + position.setHeadline(view.headline()); + position.setDescription(view.description()); + + ProfileResponse dto = new ProfileResponse(); + dto.setPosition(position); + dto.setWorkingModel( + PublicResponseMapper.map(view.workingModel(), SiteResponseMapper::workingModel)); + dto.setTerritories(PublicResponseMapper.map(view.territories(), SiteResponseMapper::territory)); + dto.setSelectedEvidence(PublicResponseMapper.relatedList(view.selectedEvidence())); + dto.setTrajectory(PublicResponseMapper.map(view.trajectory(), SiteResponseMapper::trajectory)); + dto.setContacts(PublicResponseMapper.map(view.contacts(), PublicResponseMapper::contact)); + return dto; + } + + private static ProfileResponseWorkingModelInner workingModel(ProfileView.NamedDescription view) { + ProfileResponseWorkingModelInner dto = new ProfileResponseWorkingModelInner(); + dto.setName(view.name()); + dto.setDescription(view.description()); + return dto; + } + + private static ProfileResponseTerritoriesInner territory(ProfileView.Territory view) { + ProfileResponseTerritoriesInner dto = new ProfileResponseTerritoriesInner(); + dto.setName(view.name()); + dto.setCurrentQuestion(view.currentQuestion()); + dto.setTopicPath(view.topicPath()); + return dto; + } + + /** + * {@code trajectory} 의 계약 필드는 {@code title} 인데 view 는 {@code workingModel} 과 같은 {@code + * NamedDescription} 을 재사용한다 — 두 목록이 도메인적으로 같은 모양이라 record 를 나누지 않았고, 이름 차이는 여기서 흡수한다. + */ + private static ProfileResponseTrajectoryInner trajectory(ProfileView.NamedDescription view) { + ProfileResponseTrajectoryInner dto = new ProfileResponseTrajectoryInner(); + dto.setTitle(view.name()); + dto.setDescription(view.description()); + return dto; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/TopicResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/TopicResponseMapper.java new file mode 100644 index 0000000..fbbae12 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/publicapi/mapper/TopicResponseMapper.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponse; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponseTopic; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListItem; +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListResponse; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import java.util.List; + +/** {@code listPublicTopics} / {@code getPublicTopic} 의 응답 조립. */ +public final class TopicResponseMapper { + + private TopicResponseMapper() {} + + public static TopicListResponse list(List views) { + TopicListResponse dto = new TopicListResponse(); + dto.setItems(PublicResponseMapper.map(views, TopicResponseMapper::item)); + return dto; + } + + private static TopicListItem item(TopicListItemView view) { + TopicListItem dto = new TopicListItem(); + dto.setName(view.name()); + dto.setSlug(view.slug()); + dto.setDescription(view.description()); + dto.setRecordCount(view.recordCount()); + return dto; + } + + public static TopicDetailResponse detail(TopicDetailView view) { + TopicDetailResponseTopic topic = new TopicDetailResponseTopic(); + topic.setName(view.name()); + topic.setSlug(view.slug()); + topic.setDescription(view.description()); + topic.setScope(view.scope()); + + TopicDetailResponse dto = new TopicDetailResponse(); + dto.setTopic(topic); + dto.setFeaturedReference(PublicResponseMapper.related(view.featuredReference())); + dto.setFeaturedCases(PublicResponseMapper.relatedList(view.featuredCases())); + dto.setActiveQuestions(PublicResponseMapper.relatedList(view.activeQuestions())); + dto.setRelatedProjects(PublicResponseMapper.relatedList(view.relatedProjects())); + dto.setLatestRecords( + PublicResponseMapper.map(view.latestRecords(), PublicResponseMapper::latest)); + return dto; + } +} diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index a718442..7b0a8a8 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -131,6 +131,13 @@ def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadin 'postgresqlTechLogStudioPersistenceIntegrationTest', 'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest') +// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종, +// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를 +// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다. +def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest( + 'postgresqlTechLogPublicPersistenceIntegrationTest', + 'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest') + def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') { group = 'verification' description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.' 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 new file mode 100644 index 0000000..5a0d327 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicDocumentQueryAdapter.java @@ -0,0 +1,260 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionRelationsView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceRelationsView; +import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** + * 공개된 Case / Reference / Question 상세. + * + *

본문은 {@code public_resource_projection.payload}(Studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약은 블록 배열이 + * 아니라 Markdown 원문과 {@code contentFormat} 을 준다. projection 은 "공개됐는가"와 게시 시각을 정하는 데만 쓴다. + */ +@Repository +public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort { + + private final JdbcClient jdbcClient; + private final PublicJson json; + private final PublicRelationLookup relations; + + public JdbcPublicDocumentQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.json = new PublicJson(objectMapper); + this.relations = new PublicRelationLookup(jdbcClient); + } + + @Override + public Optional findCase(String slug) { + return document("CASE", slug) + .map( + row -> + new CaseDetailView( + row.view().canonicalPath(), + true, + row.view(), + new CaseRelationsView( + relations.firstTargetOfType("CASE", row.id(), "QUESTION"), + relations.targetsOfType("CASE", row.id(), "PROJECT_DECISION"), + // 이 Case 에서 파생된 Reference 는 역방향이다 — Reference 쪽이 Case 를 가리킨다. + relations.sourcesOfType(row.id(), "REFERENCE"), + relations.targetsOfType("CASE", row.id(), "CASE")))); + } + + @Override + public Optional findReference(String slug) { + return document("REFERENCE", slug) + .map( + row -> + new ReferenceDetailView( + row.view().canonicalPath(), + true, + row.view(), + new ReferenceRelationsView( + relations.targetsOfType("REFERENCE", row.id(), "CASE"), + relations.targetsOfType("REFERENCE", row.id(), "PROJECT_DECISION"), + relations.targetsOfType("REFERENCE", row.id(), "REFERENCE")))); + } + + /** 관계 조회에 문서 id 가 필요한데 계약의 응답에는 id 가 없다. 뷰 밖으로 id 를 새로 노출하지 않고 이 안에서만 함께 나른다. */ + private record DocumentRow(UUID id, PublishedDocumentView view) {} + + private Optional document(String type, String slug) { + return jdbcClient + .sql( + "SELECT d.id, d.title, d.body_markdown, d.content_format," + + " d.content_format_version, d.cover_asset_id," + + " c.problem_summary, c.conclusion_summary, c.environment_items," + + " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status," + + " p.navigation_path, p.published_at, p.updated_at, p.last_verified_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug," + + " a.content_type AS cover_content_type, a.alt_text AS cover_alt," + + " a.width AS cover_width, a.height AS cover_height" + + " FROM document d" + + " JOIN public_resource_projection p" + + " ON p.resource_type = d.document_type AND p.resource_id = d.id" + + " LEFT JOIN case_detail c ON c.document_id = d.id" + + " LEFT JOIN reference_detail r ON r.document_id = d.id" + + " LEFT JOIN topic t ON t.id = d.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id" + + " LEFT JOIN asset a ON a.id = d.cover_asset_id" + + " WHERE d.document_type = :type AND d.slug = :slug AND " + + PublicSql.ACTIVE) + .param("type", type) + .param("slug", slug) + .query( + (rs, rowNum) -> { + UUID id = rs.getObject("id", UUID.class); + boolean isCase = "CASE".equals(type); + return new DocumentRow( + id, + new PublishedDocumentView( + type, + rs.getString("navigation_path"), + rs.getString("title"), + // Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다. + isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"), + isCase ? rs.getString("conclusion_summary") : null, + isCase ? json.strings(rs.getString("environment_items")) : List.of(), + isCase ? List.of() : json.strings(rs.getString("applies_to")), + isCase ? List.of() : json.strings(rs.getString("excluded_scope")), + isCase ? null : rs.getString("freshness_status"), + rs.getString("body_markdown"), + rs.getString("content_format"), + rs.getInt("content_format_version"), + topic(rs), + tags(id), + project(rs), + cover(rs), + instant(rs, "published_at"), + instant(rs, "updated_at"), + instant(rs, "last_verified_at"))); + }) + .optional(); + } + + @Override + public Optional findQuestion(String slug) { + return jdbcClient + .sql( + "SELECT q.id, q.question, q.slug, q.summary, q.context_markdown," + + " q.importance_markdown, q.question_status, q.next_verification," + + " q.resolution_type, q.resolution_summary, q.resolved_at, q.opened_at," + + " p.navigation_path, p.updated_at" + + " FROM open_question q" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id" + + " WHERE q.slug = :slug AND " + + PublicSql.ACTIVE) + .param("slug", slug) + .query( + (rs, rowNum) -> { + UUID id = rs.getObject("id", UUID.class); + PublishedQuestionView question = + new PublishedQuestionView( + rs.getString("question"), + rs.getString("summary"), + rs.getString("context_markdown"), + rs.getString("importance_markdown"), + rs.getString("question_status"), + rs.getString("next_verification"), + new QuestionPointGroupView( + points(id, "FACT"), + points(id, "ASSUMPTION"), + points(id, "UNKNOWN"), + points(id, "CONSTRAINT")), + updates(id), + rs.getString("resolution_type"), + rs.getString("resolution_summary"), + instant(rs, "resolved_at"), + instant(rs, "opened_at"), + instant(rs, "updated_at")); + return new QuestionDetailView( + rs.getString("navigation_path"), + true, + question, + new QuestionRelationsView( + relations.primaryProject("project_question_link", "question_id", id), + relations.firstTargetOfType("QUESTION", id, "CASE"), + relations.firstTargetOfType("QUESTION", id, "PROJECT_DECISION"), + relations.targetsOfType("QUESTION", id, "REFERENCE"))); + }) + .optional(); + } + + private List points(UUID questionId, String pointKind) { + return jdbcClient + .sql( + "SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind" + + " ORDER BY display_order") + .param("id", questionId) + .param("kind", pointKind) + .query(String.class) + .list(); + } + + /** 공개된 조사 기록만 보여준다 — {@code PRIVATE} 기록은 Studio 안에만 있다. */ + private List updates(UUID questionId) { + return jdbcClient + .sql( + "SELECT update_type, title, body_markdown, occurred_at FROM question_update" + + " WHERE question_id = :id AND update_visibility = 'PUBLIC'" + + " ORDER BY sequence_no") + .param("id", questionId) + .query( + (rs, rowNum) -> + new QuestionUpdateView( + rs.getString("update_type"), + rs.getString("title"), + rs.getString("body_markdown"), + instant(rs, "occurred_at"))) + .list(); + } + + static Instant instant(ResultSet rs, String column) throws SQLException { + var value = rs.getTimestamp(column); + return value == null ? null : value.toInstant(); + } + + static TopicSummaryView topic(ResultSet rs) throws SQLException { + return rs.getString("topic_slug") == null + ? null + : new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug")); + } + + static ProjectSummaryView project(ResultSet rs) throws SQLException { + return rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")); + } + + static AssetReferenceView cover(ResultSet rs) throws SQLException { + UUID assetId = rs.getObject("cover_asset_id", UUID.class); + return assetId == null + ? null + : new AssetReferenceView( + assetId, + "/media/" + assetId, + rs.getString("cover_alt"), + (Integer) rs.getObject("cover_width"), + (Integer) rs.getObject("cover_height"), + rs.getString("cover_content_type")); + } + + List tags(UUID documentId) { + return jdbcClient + .sql( + "SELECT g.name, g.slug FROM document_tag dt JOIN tag g ON g.id = dt.tag_id" + + " WHERE dt.document_id = :id ORDER BY dt.display_order") + .param("id", documentId) + .query((rs, rowNum) -> new TagSummaryView(rs.getString("name"), rs.getString("slug"))) + .list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicExploreQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicExploreQueryAdapter.java new file mode 100644 index 0000000..a6de4d3 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicExploreQueryAdapter.java @@ -0,0 +1,226 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 탐색 목록. + * + *

필터와 정렬을 SQL 로 처리하고 페이지 총계를 같은 조건으로 센다 — 목록과 총계가 다른 조건을 쓰면 마지막 페이지가 비어 보이거나 있지도 않은 페이지 번호가 생긴다. + */ +@Repository +public class JdbcPublicExploreQueryAdapter implements PublicExploreQueryPort { + + private final JdbcClient jdbcClient; + + public JdbcPublicExploreQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public KnowledgePageView knowledge(ExploreKnowledgeQuery query) { + StringBuilder where = + new StringBuilder( + " WHERE " + PublicSql.ACTIVE + " AND p.resource_type IN ('CASE', 'REFERENCE')"); + Map params = new HashMap<>(); + if (query.type() != null) { + where.append(" AND p.resource_type = :type"); + params.put("type", query.type()); + } + if (query.topicSlug() != null) { + where.append(" AND t.slug = :topicSlug"); + params.put("topicSlug", query.topicSlug()); + } + if (query.projectSlug() != null) { + where.append(" AND pr.slug = :projectSlug"); + params.put("projectSlug", query.projectSlug()); + } + if (query.tagSlug() != null) { + where.append( + " AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id" + + " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id" + + " AND g.slug = :tagSlug)"); + params.put("tagSlug", query.tagSlug()); + } + if (query.year() != null) { + where.append(" AND date_part('year', p.published_at) = :year"); + params.put("year", query.year()); + } + + String joins = + " FROM public_resource_projection p" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id"; + + long total = count(joins + where, params); + List items = + page( + "SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.state_code," + + " p.published_at, p.last_verified_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug" + + joins + + where + + knowledgeOrder(query.sort()), + params, + query.page().size(), + query.page().offset(), + JdbcPublicExploreQueryAdapter::readKnowledge); + + return new KnowledgePageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + } + + /** 계약의 정렬 세 값. 같은 시각이 여럿일 때 페이지 경계가 흔들리지 않도록 id 를 tie-breaker 로 둔다. */ + private static String knowledgeOrder(String sort) { + String key = + switch (sort == null ? "PUBLISHED_DESC" : sort) { + case "UPDATED_DESC" -> "p.updated_at DESC"; + case "VERIFIED_DESC" -> "p.last_verified_at DESC NULLS LAST"; + default -> "p.published_at DESC"; + }; + return " ORDER BY " + key + ", p.resource_id DESC"; + } + + /** + * 계약 {@code exploreQuestions.sort} 의 세 값. {@code RESOLVED_DESC} 는 미해결 질문에 값이 없으므로 NULLS LAST 로 밀어 + * 낸다 — 그러지 않으면 PostgreSQL 의 DESC 기본값 NULLS FIRST 때문에 미해결 질문이 "가장 최근에 해결된 것" 자리에 올라온다. + */ + private static String questionOrder(String sort) { + String key = + switch (sort == null ? "UPDATED_DESC" : sort) { + case "OPENED_DESC" -> "q.opened_at DESC NULLS LAST"; + case "RESOLVED_DESC" -> "q.resolved_at DESC NULLS LAST"; + default -> "p.updated_at DESC"; + }; + return " ORDER BY " + key + ", p.resource_id DESC"; + } + + private static KnowledgeListItemView readKnowledge(ResultSet rs, int rowNum) throws SQLException { + return new KnowledgeListItemView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("navigation_path"), + rs.getString("summary"), + null, + rs.getString("topic_slug") == null + ? null + : new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug")), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("published_at").toInstant(), + rs.getTimestamp("last_verified_at") == null + ? null + : rs.getTimestamp("last_verified_at").toInstant(), + rs.getString("state_code")); + } + + @Override + public QuestionPageView questions(ExploreQuestionsQuery query) { + StringBuilder where = + new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND p.resource_type = 'QUESTION'"); + Map params = new HashMap<>(); + if (query.status() != null) { + where.append(" AND p.state_code = :status"); + params.put("status", query.status()); + } + if (query.topicSlug() != null) { + where.append(" AND t.slug = :topicSlug"); + params.put("topicSlug", query.topicSlug()); + } + if (query.projectSlug() != null) { + where.append(" AND pr.slug = :projectSlug"); + params.put("projectSlug", query.projectSlug()); + } + if (query.tagSlug() != null) { + where.append( + " AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id" + + " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id" + + " AND g.slug = :tagSlug)"); + params.put("tagSlug", query.tagSlug()); + } + + String joins = + " FROM public_resource_projection p" + + " JOIN open_question q ON q.id = p.resource_id" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id"; + + long total = count(joins + where, params); + List items = + page( + "SELECT q.question, p.navigation_path, q.question_status, p.summary," + + " q.next_verification, p.updated_at," + + " pr.name AS project_name, pr.slug AS project_slug" + + joins + + where + + questionOrder(query.sort()), + params, + query.page().size(), + query.page().offset(), + (rs, rowNum) -> + new QuestionListItemView( + rs.getString("question"), + rs.getString("navigation_path"), + rs.getString("question_status"), + rs.getString("summary"), + null, + rs.getString("next_verification"), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("updated_at").toInstant())); + + return new QuestionPageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + } + + private long count(String fromAndWhere, Map params) { + var spec = jdbcClient.sql("SELECT count(*)" + fromAndWhere); + for (Map.Entry e : params.entrySet()) { + spec = spec.param(e.getKey(), e.getValue()); + } + return spec.query(Long.class).single(); + } + + private List page( + String sql, + Map params, + int size, + int offset, + org.springframework.jdbc.core.RowMapper mapper) { + var spec = jdbcClient.sql(sql + " LIMIT :size OFFSET :offset"); + for (Map.Entry e : params.entrySet()) { + spec = spec.param(e.getKey(), e.getValue()); + } + return spec.param("size", size).param("offset", offset).query(mapper).list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java new file mode 100644 index 0000000..385259f --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicProjectQueryAdapter.java @@ -0,0 +1,330 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView; +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** 프로젝트 목록·상세와 그 하위 목록. */ +@Repository +public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort { + + private static final int SECTION_LIMIT = 10; + + private final JdbcClient jdbcClient; + private final PublicJson json; + + public JdbcPublicProjectQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.json = new PublicJson(objectMapper); + } + + @Override + public List list() { + return jdbcClient + .sql( + "SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective," + + " pr.next_step, p.updated_at FROM project pr" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id" + + " WHERE " + + PublicSql.ACTIVE + + " ORDER BY pr.featured_order NULLS LAST, p.updated_at DESC") + .query( + (rs, rowNum) -> + new ProjectListItemView( + rs.getString("name"), + rs.getString("slug"), + "/projects/" + rs.getString("slug"), + rs.getString("one_line_purpose"), + rs.getString("phase"), + rs.getString("current_objective"), + rs.getString("next_step"), + JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"))) + .list(); + } + + @Override + public Optional findBySlug(String slug) { + return jdbcClient + .sql( + "SELECT pr.id, pr.name, pr.slug, pr.one_line_purpose, pr.purpose_markdown," + + " pr.boundary_markdown, pr.phase, pr.current_objective, pr.next_step," + + " pr.system_overview_markdown, pr.technology_labels," + + " p.navigation_path, p.updated_at FROM project pr" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id" + + " WHERE pr.slug = :slug AND " + + PublicSql.ACTIVE) + .param("slug", slug) + .query( + (rs, rowNum) -> { + UUID projectId = rs.getObject("id", UUID.class); + PublishedProjectView project = + new PublishedProjectView( + rs.getString("name"), + rs.getString("slug"), + rs.getString("one_line_purpose"), + rs.getString("purpose_markdown"), + rs.getString("boundary_markdown"), + rs.getString("phase"), + rs.getString("current_objective"), + rs.getString("next_step"), + rs.getString("system_overview_markdown"), + json.strings(rs.getString("technology_labels")), + JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at")); + return new ProjectDetailView( + rs.getString("navigation_path"), + true, + project, + featuredDecision(projectId), + activeQuestion(projectId), + selectedRecords(projectId)); + }) + .optional(); + } + + private RelatedEntryView featuredDecision(UUID projectId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM project_decision d" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id" + + " WHERE d.project_id = :projectId AND " + + PublicSql.ACTIVE + + " ORDER BY d.is_featured DESC, d.decided_at DESC NULLS LAST LIMIT 1") + .param("projectId", projectId) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .optional() + .orElse(null); + } + + private RelatedEntryView activeQuestion(UUID projectId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM project_question_link l" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'QUESTION' AND p.resource_id = l.question_id" + + " WHERE l.project_id = :projectId AND p.state_code <> 'RESOLVED'" + + " AND " + + PublicSql.ACTIVE + + " ORDER BY p.updated_at DESC LIMIT 1") + .param("projectId", projectId) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .optional() + .orElse(null); + } + + private List selectedRecords(UUID projectId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_project_link l" + + " JOIN public_resource_projection p" + + " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id" + + " WHERE l.project_id = :projectId AND " + + PublicSql.ACTIVE + + " ORDER BY l.featured_order NULLS LAST, p.published_at DESC LIMIT :limit") + .param("projectId", projectId) + .param("limit", SECTION_LIMIT) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + @Override + public Optional decisions(ProjectDecisionPageQuery query) { + return projectId(query.projectSlug()) + .map( + projectId -> { + // 계약의 status 필터. 총계와 목록이 반드시 같은 조건을 써야 마지막 페이지가 비어 보이지 않는다. + String from = + " FROM project_decision d" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id" + + " WHERE d.project_id = :projectId AND " + + PublicSql.ACTIVE + + (query.status() == null ? "" : " AND d.decision_status = :status"); + long total = + bind(jdbcClient.sql("SELECT count(*)" + from), projectId, query.status()) + .query(Long.class) + .single(); + List items = + bind( + jdbcClient.sql( + "SELECT d.id, d.statement, d.decision_status, d.rationale_markdown," + + " d.decided_at, d.source_question_id, d.source_case_id" + + from + + " ORDER BY d.decided_at DESC NULLS LAST, d.id DESC" + + " LIMIT :size OFFSET :offset"), + projectId, + query.status()) + .param("size", query.page().size()) + .param("offset", query.page().offset()) + .query( + (rs, rowNum) -> + new ProjectDecisionItemView( + rs.getObject("id", UUID.class), + rs.getString("statement"), + rs.getString("decision_status"), + rs.getString("rationale_markdown"), + JdbcPublicDocumentQueryAdapter.instant(rs, "decided_at"), + publishedEntry(rs.getObject("source_question_id", UUID.class)), + publishedEntry(rs.getObject("source_case_id", UUID.class)))) + .list(); + return new ProjectDecisionPageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + }); + } + + /** 지목된 원천이 비공개면 링크를 만들지 않는다 — 404 로 이어지는 링크를 내보내지 않는다. */ + private RelatedEntryView publishedEntry(UUID resourceId) { + if (resourceId == null) { + return null; + } + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_projection p" + + " WHERE p.resource_id = :id AND " + + PublicSql.ACTIVE) + .param("id", resourceId) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .optional() + .orElse(null); + } + + @Override + public Optional records(ProjectRecordPageQuery query) { + return projectId(query.projectSlug()) + .map( + projectId -> { + // 계약이 세는 record 는 CASE/REFERENCE/QUESTION 세 종류다. type 이 없으면 셋 다 센다. + String from = + " FROM public_resource_project_link l" + + " JOIN public_resource_projection p" + + " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id" + + " WHERE l.project_id = :projectId" + + " AND p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION')" + + " AND " + + PublicSql.ACTIVE + + (query.type() == null ? "" : " AND p.resource_type = :type") + + (query.relation() == null ? "" : " AND l.relation_type = :relation"); + long total = + bindRecord(jdbcClient.sql("SELECT count(*)" + from), projectId, query) + .query(Long.class) + .single(); + List items = + bindRecord( + jdbcClient.sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + from + + " ORDER BY p.published_at DESC, p.resource_id DESC" + + " LIMIT :size OFFSET :offset"), + projectId, + query) + .param("size", query.page().size()) + .param("offset", query.page().offset()) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + return new ProjectRecordPageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + }); + } + + @Override + public Optional activities(ProjectPageQuery query) { + return projectId(query.projectSlug()) + .map( + projectId -> { + String from = + " FROM project_activity a" + + " WHERE a.project_id = :projectId AND a.visibility = 'PUBLIC'"; + long total = + jdbcClient + .sql("SELECT count(*)" + from) + .param("projectId", projectId) + .query(Long.class) + .single(); + List items = + jdbcClient + .sql( + "SELECT a.activity_type, a.title, a.summary, a.occurred_at," + + " a.related_resource_id" + + from + + " ORDER BY a.occurred_at DESC, a.id DESC" + + " LIMIT :size OFFSET :offset") + .param("projectId", projectId) + .param("size", query.page().size()) + .param("offset", query.page().offset()) + .query( + (rs, rowNum) -> { + RelatedEntryView related = + publishedEntry(rs.getObject("related_resource_id", UUID.class)); + return new ProjectActivityItemView( + rs.getString("activity_type"), + rs.getString("title"), + rs.getString("summary"), + JdbcPublicDocumentQueryAdapter.instant(rs, "occurred_at"), + related == null ? null : related.path()); + }) + .list(); + return new ProjectActivityPageView( + items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + }); + } + + /** + * optional 필터는 SQL 조각과 파라미터 바인딩을 함께 켜고 꺼야 한다. 조각만 빼고 바인딩을 남기면 JdbcClient 가 "쓰이지 않은 파라미터"로 실패하고, + * 반대면 파라미터 미해결로 실패한다 — 총계와 목록 두 쿼리에서 같은 실수를 두 번 하지 않도록 한 곳에 모은다. + */ + private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bind( + org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec, + UUID projectId, + String status) { + spec = spec.param("projectId", projectId); + return status == null ? spec : spec.param("status", status); + } + + private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bindRecord( + org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec, + UUID projectId, + ProjectRecordPageQuery query) { + spec = spec.param("projectId", projectId); + if (query.type() != null) { + spec = spec.param("type", query.type()); + } + return query.relation() == null ? spec : spec.param("relation", query.relation()); + } + + /** 공개된 프로젝트만 하위 목록을 연다 — 비공개 프로젝트의 결정 목록이 새어 나가면 안 된다. */ + private Optional projectId(String slug) { + return jdbcClient + .sql( + "SELECT pr.id FROM project pr" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id" + + " WHERE pr.slug = :slug AND " + + PublicSql.ACTIVE) + .param("slug", slug) + .query(UUID.class) + .optional(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicReleaseQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicReleaseQueryAdapter.java new file mode 100644 index 0000000..5ccfb70 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicReleaseQueryAdapter.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import java.util.List; +import java.util.Optional; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** + * 릴리스 목록·상세. + * + *

릴리스는 {@code public_resource_projection} 을 거치지 않는다 — 설계상 Publication 파이프라인의 대상이 아니라 자체 {@code + * workflow_status} 로 공개 여부를 정하는 기록이다. + */ +@Repository +public class JdbcPublicReleaseQueryAdapter implements PublicReleaseQueryPort { + + private final JdbcClient jdbcClient; + private final PublicJson json; + + public JdbcPublicReleaseQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.json = new PublicJson(objectMapper); + } + + @Override + public List list() { + return jdbcClient + .sql( + "SELECT version_label, title, summary, released_on, change_types FROM release" + + " WHERE workflow_status = 'PUBLISHED'" + + " ORDER BY released_on DESC NULLS LAST, version_label DESC") + .query( + (rs, rowNum) -> + new ReleaseListItemView( + rs.getString("version_label"), + rs.getString("title"), + rs.getString("summary"), + rs.getDate("released_on") == null + ? null + : rs.getDate("released_on").toLocalDate(), + json.strings(rs.getString("change_types")), + "/releases/" + rs.getString("version_label"))) + .list(); + } + + @Override + public Optional findByVersion(String version) { + return jdbcClient + .sql( + "SELECT version_label, title, summary, released_on, change_types, reason_markdown," + + " changes_markdown, user_impact_markdown, implementation_impact_markdown," + + " verification_markdown, known_limitations_markdown, related_resources" + + " FROM release WHERE version_label = :version AND workflow_status = 'PUBLISHED'") + .param("version", version) + .query( + (rs, rowNum) -> + new ReleaseDetailView( + rs.getString("version_label"), + rs.getString("title"), + rs.getString("summary"), + rs.getDate("released_on") == null + ? null + : rs.getDate("released_on").toLocalDate(), + json.strings(rs.getString("change_types")), + rs.getString("reason_markdown"), + rs.getString("changes_markdown"), + rs.getString("user_impact_markdown"), + rs.getString("implementation_impact_markdown"), + rs.getString("verification_markdown"), + rs.getString("known_limitations_markdown"), + relatedRecords(rs.getString("related_resources")))) + .optional(); + } + + /** + * {@code related_resources} 는 resource id 배열이다. 그중 공개된 것만 되살린다 — 릴리스가 지목한 기록이 비공개로 바뀌었을 수 + * 있고, 그 링크를 그대로 내보내면 404 로 이어진다. + */ + private List relatedRecords( + String relatedResourcesJson) { + List ids = json.strings(relatedResourcesJson); + if (ids.isEmpty()) { + return List.of(); + } + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_projection p" + + " WHERE p.resource_id::text IN (:ids) AND " + + PublicSql.ACTIVE + + " ORDER BY p.published_at DESC") + .param("ids", ids) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSearchQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSearchQueryAdapter.java new file mode 100644 index 0000000..fc921cc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSearchQueryAdapter.java @@ -0,0 +1,146 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * 공개 검색. + * + *

게시 시 만들어 둔 {@code search_text}(제목 + 요약 + 본문 평문)를 본다. 검색 때 본문을 다시 훑지 않는 이유는 그 평문이 게시 시점에 확정된 + * 값이기 때문이다 — 나중에 초안이 바뀌어도 공개 검색 결과는 공개된 내용을 따라야 한다. + */ +@Repository +public class JdbcPublicSearchQueryAdapter implements PublicSearchQueryPort { + + /** 스니펫 길이. 너무 길면 목록이 읽히지 않고, 너무 짧으면 왜 걸렸는지 알 수 없다. */ + private static final int SNIPPET_LENGTH = 200; + + private final JdbcClient jdbcClient; + + public JdbcPublicSearchQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public SearchResultPageView search(SearchQuery query) { + String pattern = "%" + query.query().toLowerCase(Locale.ROOT) + "%"; + StringBuilder where = + new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND lower(p.search_text) LIKE :pattern"); + Map params = new HashMap<>(); + params.put("pattern", pattern); + if (query.type() != null) { + where.append(" AND p.resource_type = :type"); + params.put("type", query.type()); + } + if (query.topicSlug() != null) { + where.append(" AND t.slug = :topicSlug"); + params.put("topicSlug", query.topicSlug()); + } + + String joins = + " FROM public_resource_projection p" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id"; + + var countSpec = jdbcClient.sql("SELECT count(*)" + joins + where); + for (Map.Entry e : params.entrySet()) { + countSpec = countSpec.param(e.getKey(), e.getValue()); + } + long total = countSpec.query(Long.class).single(); + + var spec = + jdbcClient.sql( + "SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.body_plain_text," + + " p.published_at, p.updated_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug" + + joins + + where + + " ORDER BY p.published_at DESC, p.resource_id DESC" + + " LIMIT :size OFFSET :offset"); + for (Map.Entry e : params.entrySet()) { + spec = spec.param(e.getKey(), e.getValue()); + } + List items = + spec.param("size", query.page().size()) + .param("offset", query.page().offset()) + .query( + (rs, rowNum) -> + new SearchResultItemView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("navigation_path"), + snippet( + rs.getString("body_plain_text"), + rs.getString("summary"), + query.query()), + matchedFields( + query.query(), + rs.getString("title"), + rs.getString("summary"), + rs.getString("body_plain_text")), + rs.getString("topic_slug") == null + ? null + : new TopicSummaryView( + rs.getString("topic_name"), rs.getString("topic_slug")), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("published_at").toInstant(), + rs.getTimestamp("updated_at").toInstant())) + .list(); + + return new SearchResultPageView( + query.query(), items, PageMetadataView.of(query.page().page(), query.page().size(), total)); + } + + /** 검색어가 나온 자리를 중심으로 잘라 준다. 없으면 요약을 쓴다. */ + private static String snippet(String body, String summary, String term) { + String source = (body == null || body.isBlank()) ? summary : body; + if (source == null || source.isBlank()) { + return ""; + } + int at = source.toLowerCase(Locale.ROOT).indexOf(term.toLowerCase(Locale.ROOT)); + if (at < 0) { + return source.length() <= SNIPPET_LENGTH ? source : source.substring(0, SNIPPET_LENGTH); + } + int from = Math.max(0, at - SNIPPET_LENGTH / 2); + int to = Math.min(source.length(), from + SNIPPET_LENGTH); + return source.substring(from, to); + } + + /** 어느 필드에서 걸렸는지. 사용자가 왜 이 결과가 나왔는지 알 수 있어야 한다. */ + private static List matchedFields( + String term, String title, String summary, String body) { + String needle = term.toLowerCase(Locale.ROOT); + List fields = new ArrayList<>(); + if (title != null && title.toLowerCase(Locale.ROOT).contains(needle)) { + fields.add("title"); + } + if (summary != null && summary.toLowerCase(Locale.ROOT).contains(needle)) { + fields.add("summary"); + } + if (body != null && body.toLowerCase(Locale.ROOT).contains(needle)) { + fields.add("content"); + } + return fields; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java new file mode 100644 index 0000000..1e20806 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicSiteQueryAdapter.java @@ -0,0 +1,271 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView; +import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; +import tools.jackson.databind.ObjectMapper; + +/** 사이트 · 홈 · 프로필. 셋 다 단일 행 테이블이 원천이다. */ +@Repository +public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort { + + private final JdbcClient jdbcClient; + private final PublicJson json; + + public JdbcPublicSiteQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.json = new PublicJson(objectMapper); + } + + @Override + public Optional site() { + return jdbcClient + .sql( + "SELECT s.brand_title, s.identity_statement, s.operator_display_name," + + " s.short_identity, s.contacts, s.avatar_asset_id," + + " a.content_type, a.alt_text, a.width, a.height" + + " FROM site_config s LEFT JOIN asset a ON a.id = s.avatar_asset_id") + .query( + (rs, rowNum) -> + new SiteView( + rs.getString("brand_title"), + rs.getString("identity_statement"), + rs.getString("operator_display_name"), + rs.getString("short_identity"), + avatar(rs), + "/profile", + json.contacts(rs.getString("contacts")))) + .optional(); + } + + private static AssetReferenceView avatar(java.sql.ResultSet rs) throws java.sql.SQLException { + UUID assetId = rs.getObject("avatar_asset_id", UUID.class); + if (assetId == null) { + return null; + } + return new AssetReferenceView( + assetId, + // 본문과 마찬가지로 저장소 경로가 아니라 안정적인 전송 경로를 노출한다(설계 05장 §3.1). + "/media/" + assetId, + rs.getString("alt_text"), + (Integer) rs.getObject("width"), + (Integer) rs.getObject("height"), + rs.getString("content_type")); + } + + @Override + public HomeView home(int latestEntryLimit) { + HomeFocusView focus = + jdbcClient + .sql( + "SELECT default_focus_type, current_project_id, open_question_id," + + " recent_decision_id FROM home_focus_config") + .query( + (rs, rowNum) -> + HomeFocusView.resolve( + rs.getString("default_focus_type"), + currentWork(rs.getObject("current_project_id", UUID.class)), + openQuestion(rs.getObject("open_question_id", UUID.class)), + recentDecision(rs.getObject("recent_decision_id", UUID.class)))) + .optional() + .orElseGet(() -> HomeFocusView.resolve(null, null, null, null)); + return new HomeView(focus, latestEntries(latestEntryLimit)); + } + + /** 지목한 프로젝트가 지워졌거나 비공개면 focus 는 비운다 — 없는 것을 억지로 채우지 않는다. */ + private HomeFocusView.CurrentWork currentWork(UUID projectId) { + if (projectId == null) { + return null; + } + return jdbcClient + .sql( + "SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective," + + " pr.next_step, pr.updated_at FROM project pr" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id" + + " WHERE pr.id = :id AND " + + PublicSql.ACTIVE) + .param("id", projectId) + .query( + (rs, rowNum) -> + new HomeFocusView.CurrentWork( + rs.getString("name"), + "/projects/" + rs.getString("slug"), + rs.getString("one_line_purpose"), + rs.getString("phase"), + rs.getString("current_objective"), + rs.getString("next_step"), + rs.getTimestamp("updated_at").toInstant())) + .optional() + .orElse(null); + } + + private HomeFocusView.OpenQuestion openQuestion(UUID questionId) { + if (questionId == null) { + return null; + } + return jdbcClient + .sql( + "SELECT q.id, q.question, q.slug, q.summary, q.next_verification, q.updated_at" + + " FROM open_question q" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id" + + " WHERE q.id = :id AND " + + PublicSql.ACTIVE) + .param("id", questionId) + .query( + (rs, rowNum) -> + new HomeFocusView.OpenQuestion( + rs.getString("question"), + "/questions/" + rs.getString("slug"), + rs.getString("summary"), + points(questionId, "FACT"), + points(questionId, "UNKNOWN"), + rs.getString("next_verification"), + rs.getTimestamp("updated_at").toInstant())) + .optional() + .orElse(null); + } + + private List points(UUID questionId, String pointKind) { + return jdbcClient + .sql( + "SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind" + + " ORDER BY display_order") + .param("id", questionId) + .param("kind", pointKind) + .query(String.class) + .list(); + } + + private HomeFocusView.RecentDecision recentDecision(UUID decisionId) { + if (decisionId == null) { + return null; + } + return jdbcClient + .sql( + "SELECT d.statement, d.slug, d.rationale_markdown, d.consequences, d.decided_at," + + " pr.slug AS project_slug FROM project_decision d" + + " LEFT JOIN project pr ON pr.id = d.project_id" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id" + + " WHERE d.id = :id AND " + + PublicSql.ACTIVE) + .param("id", decisionId) + .query( + (rs, rowNum) -> + new HomeFocusView.RecentDecision( + rs.getString("statement"), + PublicSql.pathOf( + "PROJECT_DECISION", rs.getString("slug"), rs.getString("project_slug")), + rs.getString("rationale_markdown"), + json.strings(rs.getString("consequences")), + rs.getTimestamp("decided_at") == null + ? null + : rs.getTimestamp("decided_at").toInstant())) + .optional() + .orElse(null); + } + + /** + * 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만 + * 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도 + * 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다. + * + *

{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code + * workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석). + * 계약은 그 값을 허용할 뿐 매번 포함하라고 요구하지 않는다. + */ + private List latestEntries(int limit) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug" + + " FROM public_resource_projection p" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id" + + " WHERE " + + PublicSql.ACTIVE + + " AND " + + PublicSql.LATEST_ENTRY_TYPES + + " ORDER BY p.published_at DESC LIMIT :limit") + .param("limit", limit) + .query( + (rs, rowNum) -> + new LatestEntryView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("summary"), + rs.getString("navigation_path"), + rs.getString("topic_slug") == null + ? null + : new TopicSummaryView( + rs.getString("topic_name"), rs.getString("topic_slug")), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("published_at").toInstant())) + .list(); + } + + @Override + public Optional profile() { + return jdbcClient + .sql( + "SELECT headline, introduction_markdown, working_model, territories," + + " selected_evidence, trajectory, contacts FROM profile_page" + + " WHERE target_visibility = 'PUBLIC'") + .query( + (rs, rowNum) -> + new ProfileView( + rs.getString("headline"), + rs.getString("introduction_markdown"), + json.namedDescriptions(rs.getString("working_model")), + json.territories(rs.getString("territories")), + selectedEvidence(rs.getString("selected_evidence")), + json.namedDescriptions(rs.getString("trajectory")), + json.contacts(rs.getString("contacts")))) + .optional(); + } + + /** + * {@code selected_evidence} 는 resource id 배열이다. 그중 공개된 것만 되살린다 — 프로필이 지목한 기록이 비공개로 바뀌었을 수 + * 있고, 그 링크를 그대로 내보내면 404 로 이어진다({@code JdbcPublicReleaseQueryAdapter} 의 {@code related_resources} + * 와 같은 규칙). + */ + private List selectedEvidence(String selectedEvidenceJson) { + List ids = json.strings(selectedEvidenceJson); + if (ids.isEmpty()) { + return List.of(); + } + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_projection p" + + " WHERE p.resource_id::text IN (:ids) AND " + + PublicSql.ACTIVE + + " ORDER BY p.published_at DESC") + .param("ids", ids) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicTopicQueryAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicTopicQueryAdapter.java new file mode 100644 index 0000000..0e1cbd8 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/JdbcPublicTopicQueryAdapter.java @@ -0,0 +1,178 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView; +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** 주제 목록·상세. 개수와 목록 모두 공개된 것만 센다. */ +@Repository +public class JdbcPublicTopicQueryAdapter implements PublicTopicQueryPort { + + /** 상세 화면이 한 화면에 담는 개수. */ + private static final int SECTION_LIMIT = 10; + + private final JdbcClient jdbcClient; + + public JdbcPublicTopicQueryAdapter(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public List list() { + return jdbcClient + .sql( + "SELECT t.name, t.slug, t.description," + + " (SELECT count(*) FROM public_resource_projection p" + + " WHERE p.primary_topic_id = t.id AND " + + PublicSql.ACTIVE + + ") AS record_count" + + " FROM topic t WHERE t.status = 'ACTIVE' ORDER BY t.name") + .query( + (rs, rowNum) -> + new TopicListItemView( + rs.getString("name"), + rs.getString("slug"), + rs.getString("description"), + rs.getInt("record_count"))) + .list(); + } + + @Override + public Optional findBySlug(String slug) { + return jdbcClient + .sql( + "SELECT id, name, slug, description, scope FROM topic WHERE slug = :slug AND status = 'ACTIVE'") + .param("slug", slug) + .query( + (rs, rowNum) -> { + UUID topicId = rs.getObject("id", UUID.class); + return new TopicDetailView( + rs.getString("name"), + rs.getString("slug"), + rs.getString("description"), + rs.getString("scope"), + featured(topicId, "START_HERE").stream().findFirst().orElse(null), + featured(topicId, "FEATURED_CASE"), + activeQuestions(topicId), + relatedProjects(topicId), + latestRecords(topicId)); + }) + .optional(); + } + + /** + * {@code topic_featured_document} 가 지목한 문서 중 공개된 것만 보여준다 — 지목은 Studio 의 편집 행위이고 공개 여부와 + * 별개다. + */ + private List featured(UUID topicId, String role) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM topic_featured_document f" + + " JOIN public_resource_projection p ON p.resource_id = f.document_id" + + " WHERE f.topic_id = :topicId AND f.feature_role = :role AND " + + PublicSql.ACTIVE + + " ORDER BY f.display_order") + .param("topicId", topicId) + .param("role", role) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + private List activeQuestions(UUID topicId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM public_resource_projection p" + + " WHERE p.resource_type = 'QUESTION' AND p.primary_topic_id = :topicId" + + " AND p.state_code <> 'RESOLVED' AND " + + PublicSql.ACTIVE + + " ORDER BY p.updated_at DESC LIMIT :limit") + .param("topicId", topicId) + .param("limit", SECTION_LIMIT) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + private List relatedProjects(UUID topicId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM project_topic pt" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = pt.project_id" + + " WHERE pt.topic_id = :topicId AND " + + PublicSql.ACTIVE + + " ORDER BY pt.display_order") + .param("topicId", topicId) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + /** + * 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만 + * 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도 + * 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다. + * + *

{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code + * workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석). + * 계약은 그 값을 허용할 뿐 매번 포함하라고 요구하지 않는다. + */ + private List latestRecords(UUID topicId) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at," + + " t.name AS topic_name, t.slug AS topic_slug," + + " pr.name AS project_name, pr.slug AS project_slug" + + " FROM public_resource_projection p" + + " LEFT JOIN topic t ON t.id = p.primary_topic_id" + + " LEFT JOIN public_resource_project_link l" + + " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id" + + " AND l.relation_type = 'PRIMARY'" + + " LEFT JOIN project pr ON pr.id = l.project_id" + + " WHERE p.primary_topic_id = :topicId AND " + + PublicSql.ACTIVE + + " AND " + + PublicSql.LATEST_ENTRY_TYPES + + " ORDER BY p.published_at DESC LIMIT :limit") + .param("topicId", topicId) + .param("limit", SECTION_LIMIT) + .query( + (rs, rowNum) -> + new LatestEntryView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("summary"), + rs.getString("navigation_path"), + rs.getString("topic_slug") == null + ? null + : new TopicSummaryView( + rs.getString("topic_name"), rs.getString("topic_slug")), + rs.getString("project_slug") == null + ? null + : new ProjectSummaryView( + rs.getString("project_name"), + rs.getString("project_slug"), + "/projects/" + rs.getString("project_slug")), + rs.getTimestamp("published_at").toInstant())) + .list(); + } + + static RelatedEntryView relatedEntry(java.sql.ResultSet rs, int rowNum) + throws java.sql.SQLException { + return new RelatedEntryView( + rs.getString("resource_type"), + rs.getString("title"), + rs.getString("summary"), + rs.getString("navigation_path")); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicJson.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicJson.java new file mode 100644 index 0000000..9c4bbc4 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicJson.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.shared.error.MappingException; +import java.util.ArrayList; +import java.util.List; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * 공개 조회가 읽는 jsonb 컬럼을 푼다. + * + *

Jackson 의 POJO 바인딩을 쓰지 않고 key 를 명시적으로 읽는다 — 이 값들은 DB 에 영속된 모양이라 application record 의 필드 이름이 + * 바뀌면 이미 저장된 행을 못 읽게 된다. + */ +final class PublicJson { + + private final ObjectMapper mapper; + + PublicJson(ObjectMapper mapper) { + this.mapper = mapper; + } + + List strings(String json) { + List out = new ArrayList<>(); + for (JsonNode node : array(json)) { + // 설계의 배열 컬럼은 문자열이거나 {text: ...} 모양일 수 있다. 둘 다 받는다. + out.add(node.isString() ? node.asString("") : node.path("text").asString(node.toString())); + } + return out; + } + + List contacts(String json) { + List out = new ArrayList<>(); + for (JsonNode node : array(json)) { + out.add( + new ContactLinkView( + node.path("type").asString(""), + node.path("label").asString(""), + node.path("url").asString(""))); + } + return out; + } + + List namedDescriptions(String json) { + List out = new ArrayList<>(); + for (JsonNode node : array(json)) { + out.add( + new ProfileView.NamedDescription( + node.path("name").asString(node.path("title").asString("")), + node.path("description").asString(""))); + } + return out; + } + + List territories(String json) { + List out = new ArrayList<>(); + for (JsonNode node : array(json)) { + out.add( + new ProfileView.Territory( + node.path("name").asString(""), + node.path("currentQuestion").asString(null), + node.path("topicPath").asString(null))); + } + return out; + } + + private Iterable array(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + JsonNode node = mapper.readTree(json); + return node.isArray() ? node : List.of(); + } catch (JacksonException e) { + throw new MappingException("failed to read a public jsonb column", e); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicRelationLookup.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicRelationLookup.java new file mode 100644 index 0000000..3b75e51 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicRelationLookup.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView; +import java.util.List; +import java.util.UUID; +import org.springframework.jdbc.core.simple.JdbcClient; + +/** + * 공개 상세가 보여주는 관계. + * + *

어디서 읽는지가 중요하다. 설계 스키마에는 유형별 링크 테이블({@code document_relation}, {@code + * question_document_link})이 있지만 그 테이블들에 쓰는 경로가 없다 — Studio 편집기가 만드는 관계는 전부 {@code + * studio_relation} 에 들어간다(계약의 relations[] 가 네 유형 공통이라 그렇게 설계했다). 그래서 공개도 같은 곳에서 읽는다. 링크 테이블을 읽으면 + * 관계가 항상 비어 보인다. + * + *

관계의 종류는 저장돼 있지 않으므로 대상의 유형으로 나눈다 — 계약이 관계를 유형별 묶음 (relatedCases / derivedReferences / + * projectDecisions / originQuestion)으로 요구하기 때문이다. 공개되지 않은 대상은 제외한다. + */ +final class PublicRelationLookup { + + private final JdbcClient jdbcClient; + + PublicRelationLookup(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + /** {@code sourceKind} 문서가 가리키는 관계 중 대상이 {@code targetType} 이고 공개된 것들. */ + List targetsOfType(String sourceKind, UUID sourceId, String targetType) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM studio_relation r" + + " JOIN public_resource_projection p ON p.resource_id = r.target_id" + + " WHERE r.source_kind = :sourceKind AND r.source_id = :sourceId" + + " AND p.resource_type = :targetType AND " + + PublicSql.ACTIVE + + " ORDER BY r.display_order") + .param("sourceKind", sourceKind) + .param("sourceId", sourceId) + .param("targetType", targetType) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + /** 같은 조회의 단수형. 계약이 하나만 받는 자리(originQuestion 등)에 쓴다. */ + RelatedEntryView firstTargetOfType(String sourceKind, UUID sourceId, String targetType) { + return targetsOfType(sourceKind, sourceId, targetType).stream().findFirst().orElse(null); + } + + /** 이 기록을 가리키는 역방향 관계. "이 Reference 를 적용한 Case" 같은 자리에 쓴다. */ + List sourcesOfType(UUID targetId, String sourceType) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM studio_relation r" + + " JOIN public_resource_projection p ON p.resource_id = r.source_id" + + " WHERE r.target_id = :targetId AND p.resource_type = :sourceType" + + " AND " + + PublicSql.ACTIVE + + " ORDER BY p.published_at DESC") + .param("targetId", targetId) + .param("sourceType", sourceType) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .list(); + } + + /** 이 기록이 속한 프로젝트. {@code project_*_link} 의 PRIMARY 를 따른다. */ + RelatedEntryView primaryProject(String linkTable, String idColumn, UUID id) { + return jdbcClient + .sql( + "SELECT p.resource_type, p.title, p.summary, p.navigation_path" + + " FROM " + + linkTable + + " l" + + " JOIN public_resource_projection p" + + " ON p.resource_type = 'PROJECT' AND p.resource_id = l.project_id" + + " WHERE l." + + idColumn + + " = :id AND l.relation_type = 'PRIMARY'" + + " AND " + + PublicSql.ACTIVE) + .param("id", id) + .query(JdbcPublicTopicQueryAdapter::relatedEntry) + .optional() + .orElse(null); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSql.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSql.java new file mode 100644 index 0000000..4d9487c --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSql.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +/** + * 공개 조회가 공유하는 SQL 조각. + * + *

"무엇이 공개인가"의 정의를 한 곳에 둔다. 각 쿼리가 조건을 따로 쓰면 어느 하나가 {@code publication_state} 를 빠뜨려도 드러나지 않고, 그 + * 결과는 게시 취소한 문서가 계속 보이는 사고다. + */ +final class PublicSql { + + /** 공개 노출 조건. 게시 취소({@code WITHDRAWN})와 비공개({@code UNLISTED})를 함께 배제한다. */ + static final String ACTIVE = " p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' "; + + /** + * 계약 {@code LatestEntry.entryType} 이 허용하는 값 중 이 projection 에 실제로 담기는 것들. 홈과 주제 상세가 같은 목록 의미를 쓰므로 + * 조건도 한 곳에서 정의한다. + */ + static final String LATEST_ENTRY_TYPES = + " p.resource_type IN ('CASE', 'REFERENCE', 'PROJECT_ACTIVITY') "; + + private PublicSql() {} + + /** 유형별 공개 경로. 게시 시 {@code navigation_path} 에 저장된 값을 그대로 쓴다. */ + static String pathOf(String resourceType, String slug, String projectSlug) { + return switch (resourceType) { + case "CASE" -> "/cases/" + slug; + case "REFERENCE" -> "/references/" + slug; + case "QUESTION" -> "/questions/" + slug; + case "PROJECT" -> "/projects/" + slug; + case "PROJECT_DECISION" -> + projectSlug == null ? null : "/projects/" + projectSlug + "/decisions/" + slug; + case "RELEASE" -> "/releases/" + slug; + default -> null; + }; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V9__techlog_public_surface.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V9__techlog_public_surface.sql new file mode 100644 index 0000000..feb9835 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V9__techlog_public_surface.sql @@ -0,0 +1,170 @@ +-- public-v1 계약이 요구하는 나머지 테이블. +-- +-- 원본: tech-log-design-package/database/V1__init.sql (설계 패키지 커밋 55a9599 기준) +-- +-- V7 이 이 여섯을 제외하며 남긴 이유는 "이번 범위 밖(spec §2.2)" 이었다. 그 §2.2 가 +-- 미룬 것이 바로 public-v1 이고, 여섯 테이블은 전부 public-v1 전용이다. +-- +-- release -> listPublicReleases / getPublicRelease +-- site_config -> getPublicSite +-- profile_page -> getPublicProfile +-- home_focus_config -> getPublicHome (focus) +-- project_topic -> getPublicTopic (relatedProjects) +-- topic_featured_document -> getPublicTopic (featuredReference / featuredCases) +-- +-- 원본 DDL 을 그대로 옮긴다. V7 이 tech_log 전용 스키마를 쓰지 않고 public 스키마에 +-- 만들기로 한 결정만 이어받는다(원본의 CREATE SCHEMA / SET search_path 는 V7 이 이미 제외했다). +-- +-- 시딩 INSERT 3건도 원본 그대로 가져온다. site_config / profile_page / +-- home_focus_config 는 단일 행 테이블이고(PK 가 고정 UUID 로 CHECK 되어 있다) 그 행이 +-- 없으면 getPublicSite / getPublicProfile / getPublicHome 이 줄 것이 없다. 이 세 값을 +-- 편집하는 API 는 studio-management-v1 이 소유하며 아직 구현 범위 밖이라, 지금은 이 +-- 시딩이 유일한 공급원이다. + + +CREATE TABLE release ( + id uuid PRIMARY KEY, + version_label varchar(32) NOT NULL, + title varchar(180) NOT NULL, + summary varchar(600) NOT NULL DEFAULT '', + released_on date, + workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT' + CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')), + change_types jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(change_types) = 'array'), + reason_markdown text NOT NULL DEFAULT '', + changes_markdown text NOT NULL DEFAULT '', + user_impact_markdown text NOT NULL DEFAULT '', + implementation_impact_markdown text NOT NULL DEFAULT '', + verification_markdown text NOT NULL DEFAULT '', + known_limitations_markdown text NOT NULL DEFAULT '', + related_resources jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(related_resources) = 'array'), + first_published_at timestamptz, + last_published_at timestamptz, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL, + CONSTRAINT uq_release_version_label UNIQUE (version_label) +); + +CREATE TABLE site_config ( + id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000001'::uuid), + brand_title varchar(80) NOT NULL DEFAULT 'Tech Log', + identity_statement varchar(600) NOT NULL DEFAULT '', + operator_display_name varchar(80) NOT NULL DEFAULT '', + short_identity varchar(120), + avatar_asset_id uuid REFERENCES asset(id), + contacts jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(contacts) = 'array'), + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL +); + +CREATE TABLE profile_page ( + id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000002'::uuid), + headline varchar(300) NOT NULL DEFAULT '', + introduction_markdown text NOT NULL DEFAULT '', + working_model jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(working_model) = 'array'), + territories jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(territories) = 'array'), + selected_evidence jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(selected_evidence) = 'array'), + trajectory jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(trajectory) = 'array'), + contacts jsonb NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(contacts) = 'array'), + target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE' + CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')), + first_published_at timestamptz, + last_published_at timestamptz, + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL +); + +CREATE TABLE home_focus_config ( + id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000003'::uuid), + default_focus_type varchar(30) + CHECK (default_focus_type IS NULL OR default_focus_type IN ( + 'CURRENT_WORK', 'OPEN_QUESTION', 'RECENT_DECISION' + )), + current_project_id uuid REFERENCES project(id), + open_question_id uuid REFERENCES open_question(id), + recent_decision_id uuid REFERENCES project_decision(id), + version bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(255) NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by varchar(255) NOT NULL +); + +CREATE TABLE project_topic ( + project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE, + topic_id uuid NOT NULL REFERENCES topic(id), + display_order integer NOT NULL CHECK (display_order >= 0), + PRIMARY KEY (project_id, topic_id), + CONSTRAINT uq_project_topic_order UNIQUE (project_id, display_order) +); + +CREATE TABLE topic_featured_document ( + topic_id uuid NOT NULL REFERENCES topic(id) ON DELETE CASCADE, + document_id uuid NOT NULL REFERENCES document(id), + feature_role varchar(30) NOT NULL + CHECK (feature_role IN ('START_HERE', 'FEATURED_CASE')), + display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0), + PRIMARY KEY (topic_id, document_id, feature_role) +); + +-- 한 Topic 의 START_HERE 는 하나뿐이다. +CREATE UNIQUE INDEX uq_topic_start_here + ON topic_featured_document(topic_id) + WHERE feature_role = 'START_HERE'; + +-- 단일 행 시딩. 이미 있으면 건드리지 않는다. + +INSERT INTO site_config ( + id, + brand_title, + identity_statement, + operator_display_name, + short_identity, + created_by, + updated_by +) VALUES ( + '00000000-0000-0000-0000-000000000001'::uuid, + 'Tech Log', + '문제를 재현하고 검증하여 운영 가능한 시스템 설계로 연결합니다.', + '동현', + 'Backend · Platform', + 'system:migration', + 'system:migration' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO profile_page ( + id, + created_by, + updated_by +) VALUES ( + '00000000-0000-0000-0000-000000000002'::uuid, + 'system:migration', + 'system:migration' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO home_focus_config ( + id, + created_by, + updated_by +) VALUES ( + '00000000-0000-0000-0000-000000000003'::uuid, + 'system:migration', + 'system:migration' +) ON CONFLICT (id) DO NOTHING; diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSitePersistenceIntegrationTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSitePersistenceIntegrationTest.java new file mode 100644 index 0000000..e674b84 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/techlog/publicsite/PublicSitePersistenceIntegrationTest.java @@ -0,0 +1,965 @@ +package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +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; + +/** + * 공개 조회 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다. + * + *

{@code StudioPersistenceIntegrationTest} 와 같은 이유로 존재한다 — 이 저장소의 표준 {@code check} 는 + * Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이 테스트 없이는 한 번도 실행되지 않은 채 통과한다. 컴파일도 단위 테스트도 + * 컬럼 이름 오타, jsonb 캐스팅, {@code EXISTS} 서브쿼리의 상관 조건을 검증하지 못한다. + * + *

특히 두 가지를 겨냥한다. + * + *

    + *
  1. 공개 조건({@code PublicSql#ACTIVE}) 이 모든 경로에 걸려 있는가 — 게시 취소({@code WITHDRAWN})나 + * 비공개({@code UNLISTED}) 자료가 어느 한 쿼리에서라도 새면 사고다. 그래서 모든 목록/상세 테스트에 "새면 안 되는 행"을 함께 심는다. + *
  2. 총계와 목록이 같은 조건을 쓰는가 — 페이지네이션이 있는 여섯 operation 은 count 쿼리와 목록 쿼리를 따로 만든다. 조건이 갈라지면 마지막 + * 페이지가 비어 보이거나 없는 페이지 번호가 생긴다. + *
+ */ +class PublicSitePersistenceIntegrationTest { + + private static final String IMAGE = + System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine"); + + private static final UUID SITE_CONFIG_ID = + UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID PROFILE_PAGE_ID = + UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID HOME_FOCUS_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + + private static PostgreSQLContainer postgres; + private static HikariDataSource dataSource; + private static JdbcClient jdbcClient; + + private static JdbcPublicSiteQueryAdapter site; + private static JdbcPublicExploreQueryAdapter explore; + private static JdbcPublicTopicQueryAdapter topics; + private static JdbcPublicDocumentQueryAdapter documents; + private static JdbcPublicProjectQueryAdapter projects; + private static JdbcPublicReleaseQueryAdapter releases; + private static JdbcPublicSearchQueryAdapter search; + + private static UUID topicId; + private static UUID projectId; + private static UUID tagId; + private static UUID caseId; + private static UUID referenceId; + private static UUID questionId; + private static UUID decisionId; + private static UUID hiddenCaseId; + + private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS); + + @BeforeAll + static void migrateAndSeed() { + if (!DockerClientFactory.instance().isDockerAvailable()) { + throw new IllegalStateException( + "Docker is required for the public-site persistence integration test;" + + " skipping is forbidden"); + } + postgres = new PostgreSQLContainer(IMAGE).withReuse(false); + postgres.start(); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(postgres.getJdbcUrl()); + config.setUsername(postgres.getUsername()); + config.setPassword(postgres.getPassword()); + config.setMaximumPoolSize(5); + config.setMinimumIdle(1); + dataSource = new HikariDataSource(config); + + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration/postgresql") + .baselineOnMigrate(false) + .outOfOrder(false) + .load() + .migrate(); + + jdbcClient = JdbcClient.create(dataSource); + ObjectMapper objectMapper = new ObjectMapper(); + + site = new JdbcPublicSiteQueryAdapter(jdbcClient, objectMapper); + explore = new JdbcPublicExploreQueryAdapter(jdbcClient); + topics = new JdbcPublicTopicQueryAdapter(jdbcClient); + documents = new JdbcPublicDocumentQueryAdapter(jdbcClient, objectMapper); + projects = new JdbcPublicProjectQueryAdapter(jdbcClient, objectMapper); + releases = new JdbcPublicReleaseQueryAdapter(jdbcClient, objectMapper); + search = new JdbcPublicSearchQueryAdapter(jdbcClient); + + seed(); + } + + @AfterAll + static void stopPostgreSql() { + if (dataSource != null) { + dataSource.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + // ---------------------------------------------------------------- V9 스키마 + + @Test + void v9CreatesEveryTableThePublicContractReads() { + assertThat(tableExists("release")).isTrue(); + assertThat(tableExists("site_config")).isTrue(); + assertThat(tableExists("profile_page")).isTrue(); + assertThat(tableExists("home_focus_config")).isTrue(); + assertThat(tableExists("project_topic")).isTrue(); + assertThat(tableExists("topic_featured_document")).isTrue(); + } + + /** 한 Topic 의 {@code START_HERE} 는 하나뿐이라는 부분 유니크 인덱스가 실제로 강제되는지. */ + @Test + void aTopicCanOnlyHaveOneStartHereDocument() { + UUID scratchTopic = insertTopic("start-here-probe", "Start Here Probe"); + jdbcClient + .sql( + "INSERT INTO topic_featured_document (topic_id, document_id, feature_role," + + " display_order) VALUES (:t, :d, 'START_HERE', 0)") + .param("t", scratchTopic) + .param("d", referenceId) + .update(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + jdbcClient + .sql( + "INSERT INTO topic_featured_document (topic_id, document_id, feature_role," + + " display_order) VALUES (:t, :d, 'START_HERE', 1)") + .param("t", scratchTopic) + .param("d", caseId) + .update()) + .as("uq_topic_start_here 가 한 주제의 두 번째 START_HERE 를 막아야 한다") + .isInstanceOf(org.springframework.dao.DuplicateKeyException.class); + + jdbcClient + .sql("DELETE FROM topic_featured_document WHERE topic_id = :t") + .param("t", scratchTopic) + .update(); + jdbcClient.sql("DELETE FROM topic WHERE id = :id").param("id", scratchTopic).update(); + } + + // ---------------------------------------------------------------- 사이트 · 홈 · 프로필 + + @Test + void siteReadsTheSingleRowConfigWithItsContacts() { + SiteView view = site.site().orElseThrow(); + + assertThat(view.brandTitle()).isEqualTo("Tech Log"); + assertThat(view.operatorDisplayName()).isEqualTo("동현"); + assertThat(view.operatorProfilePath()).isEqualTo("/profile"); + assertThat(view.contacts()).hasSize(1); + assertThat(view.contacts().getFirst().type()).isEqualTo("GITHUB"); + assertThat(view.contacts().getFirst().url()).isEqualTo("https://github.com/example"); + } + + @Test + void homeResolvesTheConfiguredFocusAndTheLatestEntries() { + HomeView view = site.home(10); + + assertThat(view.focus().defaultType()).isEqualTo("CURRENT_WORK"); + assertThat(view.focus().currentWork()).isNotNull(); + assertThat(view.focus().currentWork().projectPath()).isEqualTo("/projects/tech-log"); + assertThat(view.latestEntries()).isNotEmpty(); + assertThat(view.latestEntries()) + .as("게시 취소된 자료는 최신 목록에 없어야 한다") + .noneMatch(entry -> entry.title().contains("숨김")); + assertThat(view.latestEntries()) + .as( + "계약 LatestEntry.entryType 은 네 값만 허용한다 — projection 의 QUESTION/PROJECT 등이 섞이면" + + " 응답 매퍼가 계약 밖 값을 만나 500 이 된다") + .extracting("entryType") + .containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY") + .allSatisfy( + type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE")); + } + + /** + * 갓 마이그레이션한 상태에서 {@code default_focus_type} 은 NULL 이다. 계약은 이 필드를 required 로 선언하고 값 셋만 허용하므로, NULL + * 이 그대로 나가면 홈 화면 전체가 500 이 된다 — 실제 앱 기동 후 첫 요청에서 그렇게 깨졌다. 설정이 비어도 계약이 아는 값 하나로 정해져야 한다. + */ + @Test + void homeFocusFallsBackToAContractValueWhenNothingIsConfigured() { + jdbcClient + .sql( + "UPDATE home_focus_config SET default_focus_type = NULL," + + " current_project_id = NULL, open_question_id = NULL," + + " recent_decision_id = NULL WHERE id = :id") + .param("id", HOME_FOCUS_ID) + .update(); + try { + HomeView view = site.home(10); + assertThat(view.focus().defaultType()) + .isIn("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION"); + assertThat(view.focus().currentWork()).isNull(); + assertThat(view.focus().openQuestion()).isNull(); + assertThat(view.focus().recentDecision()).isNull(); + + // 설정은 비어 있지만 내용이 있는 갈래가 있으면 그쪽을 고른다. + jdbcClient + .sql("UPDATE home_focus_config SET open_question_id = :q WHERE id = :id") + .param("q", questionId) + .param("id", HOME_FOCUS_ID) + .update(); + assertThat(site.home(10).focus().defaultType()).isEqualTo("OPEN_QUESTION"); + } finally { + jdbcClient + .sql( + "UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK'," + + " current_project_id = :project, open_question_id = :question," + + " recent_decision_id = :decision WHERE id = :id") + .param("id", HOME_FOCUS_ID) + .param("project", projectId) + .param("question", questionId) + .param("decision", decisionId) + .update(); + } + } + + @Test + void profileReadsItsJsonbColumnsIntoTypedViews() { + ProfileView view = site.profile().orElseThrow(); + + assertThat(view.headline()).isEqualTo("문제를 재현해 검증한다"); + assertThat(view.workingModel()).extracting(ProfileView.NamedDescription::name).contains("재현"); + assertThat(view.territories()).extracting(ProfileView.Territory::name).contains("Kafka"); + assertThat(view.contacts()).hasSize(1); + assertThat(view.selectedEvidence()) + .as("selected_evidence 는 공개된 것만 되살린다") + .extracting("title") + .containsExactly("Kafka 재처리"); + } + + // ---------------------------------------------------------------- 탐색 + + @Test + void knowledgeListsOnlyPublishedCasesAndReferences() { + KnowledgePageView page = + explore.knowledge( + new ExploreKnowledgeQuery(null, null, null, null, null, null, page(1, 20))); + + assertThat(page.items()).extracting("title").contains("Kafka 재처리", "Kafka 운영 기준"); + assertThat(page.items()).extracting("title").doesNotContain("숨김 Case"); + assertThat(page.page().totalElements()) + .as("총계와 목록이 같은 조건을 써야 한다") + .isEqualTo(page.items().size()); + } + + @Test + void knowledgeAppliesEveryContractFilter() { + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery("CASE", null, null, null, null, null, page(1, 20))) + .items()) + .extracting("type") + .containsOnly("CASE"); + + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery(null, "kafka", null, null, null, null, page(1, 20))) + .items()) + .isNotEmpty(); + + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery( + null, "no-such-topic", null, null, null, null, page(1, 20))) + .items()) + .isEmpty(); + + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery( + null, null, null, "reprocessing", null, null, page(1, 20))) + .items()) + .as("tag 필터의 상관 EXISTS 서브쿼리") + .extracting("title") + .containsExactly("Kafka 재처리"); + + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery(null, null, null, null, 1999, null, page(1, 20))) + .items()) + .as("year 필터는 date_part 로 건다") + .isEmpty(); + } + + /** 계약의 정렬 세 값이 전부 유효한 SQL 이어야 한다 — 오타는 문법 오류로만 드러난다. */ + @Test + void knowledgeAcceptsEveryContractSort() { + for (String sort : List.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC")) { + assertThat( + explore + .knowledge( + new ExploreKnowledgeQuery(null, null, null, null, null, sort, page(1, 20))) + .items()) + .as("sort=%s", sort) + .isNotEmpty(); + } + } + + @Test + void questionsListAppliesStatusTagAndEverySort() { + QuestionPageView all = + explore.questions(new ExploreQuestionsQuery(null, null, null, null, null, page(1, 20))); + assertThat(all.items()).extracting("question").contains("재처리 지연을 어떻게 줄일까"); + + assertThat( + explore + .questions( + new ExploreQuestionsQuery("RESOLVED", null, null, null, null, page(1, 20))) + .items()) + .isEmpty(); + + assertThat( + explore + .questions( + new ExploreQuestionsQuery(null, null, null, "reprocessing", null, page(1, 20))) + .items()) + .as("질문에도 tag 필터가 걸려야 한다") + .isNotEmpty(); + + for (String sort : List.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC")) { + assertThat( + explore + .questions(new ExploreQuestionsQuery(null, null, null, null, sort, page(1, 20))) + .items()) + .as("sort=%s", sort) + .isNotEmpty(); + } + } + + // ---------------------------------------------------------------- 주제 + + @Test + void topicListCountsOnlyPublishedRecords() { + assertThat(topics.list()).extracting("slug").contains("kafka"); + + var kafka = + topics.list().stream().filter(t -> t.slug().equals("kafka")).findFirst().orElseThrow(); + // Case · Reference · Question 셋만 이 주제를 primary 로 가지며, 게시 취소된 Case 는 세지 않는다. + assertThat(kafka.recordCount()).as("게시 취소된 자료는 세지 않는다").isEqualTo(3); + } + + @Test + void topicDetailResolvesEverySection() { + TopicDetailView view = topics.findBySlug("kafka").orElseThrow(); + + assertThat(view.name()).isEqualTo("Kafka"); + assertThat(view.featuredReference()).isNotNull(); + assertThat(view.featuredReference().title()).isEqualTo("Kafka 운영 기준"); + assertThat(view.activeQuestions()).isNotEmpty(); + assertThat(view.relatedProjects()).extracting("title").contains("Tech Log"); + assertThat(view.latestRecords()).isNotEmpty(); + assertThat(view.latestRecords()) + .as("주제 상세의 최신 기록도 계약의 entryType 네 값을 벗어나면 안 된다") + .extracting("entryType") + .allSatisfy( + type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE")); + } + + @Test + void topicDetailIsAbsentForAnUnknownSlug() { + assertThat(topics.findBySlug("no-such-topic")).isEmpty(); + } + + // ---------------------------------------------------------------- 문서 상세 + + @Test + void caseDetailReadsTheOriginalTableNotTheProjectionPayload() { + CaseDetailView view = documents.findCase("kafka-reprocessing").orElseThrow(); + + assertThat(view.canonicalPath()).isEqualTo("/cases/kafka-reprocessing"); + assertThat(view.document().title()).isEqualTo("Kafka 재처리"); + assertThat(view.document().primarySummary()).isEqualTo("재처리가 지연된다"); + assertThat(view.document().secondarySummary()).isEqualTo("컨슈머 랙을 먼저 본다"); + assertThat(view.document().content()).contains("# 재처리"); + assertThat(view.document().contentFormat()).isEqualTo("MARKDOWN"); + assertThat(view.document().environmentSummary()).containsExactly("Kafka 3.7"); + assertThat(view.document().primaryTopic().slug()).isEqualTo("kafka"); + assertThat(view.document().primaryProject().slug()).isEqualTo("tech-log"); + assertThat(view.document().tags()).extracting("slug").containsExactly("reprocessing"); + } + + @Test + void referenceDetailReadsItsOwnScopeColumns() { + ReferenceDetailView view = documents.findReference("kafka-operations").orElseThrow(); + + assertThat(view.document().primarySummary()).isEqualTo("운영 기준을 정한다"); + assertThat(view.document().appliesTo()).containsExactly("Kafka 3.x"); + assertThat(view.document().excludedScope()).containsExactly("Kinesis"); + assertThat(view.document().freshnessStatus()).isEqualTo("CURRENT"); + } + + @Test + void questionDetailReadsItsTimelineAndResolutionColumns() { + QuestionDetailView view = documents.findQuestion("reprocessing-latency").orElseThrow(); + + assertThat(view.question().question()).isEqualTo("재처리 지연을 어떻게 줄일까"); + assertThat(view.question().status()).isEqualTo("OPEN"); + assertThat(view.question().resolvedAt()).isNull(); + assertThat(view.question().points()).isNotNull(); + } + + @Test + void aWithdrawnDocumentIsNotReadable() { + assertThat(documents.findCase("hidden-case")).as("게시 취소된 문서는 상세로도 열리면 안 된다").isEmpty(); + } + + // ---------------------------------------------------------------- 프로젝트 + + @Test + void projectListAndDetailReadEveryPublishedColumn() { + assertThat(projects.list()).extracting("slug").containsExactly("tech-log"); + + ProjectDetailView view = projects.findBySlug("tech-log").orElseThrow(); + assertThat(view.project().name()).isEqualTo("Tech Log"); + assertThat(view.project().technologies()).contains("Spring Boot"); + assertThat(view.canonicalPath()).isEqualTo("/projects/tech-log"); + assertThat(view.featuredDecision()).isNotNull(); + assertThat(view.selectedRecords()).isNotEmpty(); + } + + @Test + void projectSubListsReturnEmptyOptionalForAnUnknownProject() { + assertThat(projects.decisions(new ProjectDecisionPageQuery("nope", null, page(1, 20)))) + .isEmpty(); + assertThat(projects.records(new ProjectRecordPageQuery("nope", null, null, page(1, 20)))) + .isEmpty(); + assertThat(projects.activities(new ProjectPageQuery("nope", page(1, 20)))).isEmpty(); + } + + @Test + void projectDecisionsApplyTheStatusFilterToBothCountAndPage() { + var all = + projects + .decisions(new ProjectDecisionPageQuery("tech-log", null, page(1, 20))) + .orElseThrow(); + assertThat(all.items()).hasSize(1); + assertThat(all.page().totalElements()).isEqualTo(1); + + var accepted = + projects + .decisions(new ProjectDecisionPageQuery("tech-log", "ACCEPTED", page(1, 20))) + .orElseThrow(); + assertThat(accepted.items()).hasSize(1); + assertThat(accepted.page().totalElements()).isEqualTo(1); + + var proposed = + projects + .decisions(new ProjectDecisionPageQuery("tech-log", "PROPOSED", page(1, 20))) + .orElseThrow(); + assertThat(proposed.items()).isEmpty(); + assertThat(proposed.page().totalElements()).as("필터가 목록에만 걸리고 총계에 안 걸리면 여기서 드러난다").isZero(); + } + + @Test + void projectRecordsApplyTypeAndRelationFilters() { + var all = + projects + .records(new ProjectRecordPageQuery("tech-log", null, null, page(1, 20))) + .orElseThrow(); + assertThat(all.items()).isNotEmpty(); + assertThat(all.page().totalElements()).isEqualTo(all.items().size()); + + var cases = + projects + .records(new ProjectRecordPageQuery("tech-log", "CASE", null, page(1, 20))) + .orElseThrow(); + assertThat(cases.items()).extracting("type").containsOnly("CASE"); + + var related = + projects + .records(new ProjectRecordPageQuery("tech-log", null, "RELATED", page(1, 20))) + .orElseThrow(); + assertThat(related.page().totalElements()).isEqualTo(related.items().size()); + + var none = + projects + .records(new ProjectRecordPageQuery("tech-log", "QUESTION", "RELATED", page(1, 20))) + .orElseThrow(); + assertThat(none.page().totalElements()).isEqualTo(none.items().size()); + } + + @Test + void projectActivitiesListOnlyPublicOnes() { + var activities = + projects.activities(new ProjectPageQuery("tech-log", page(1, 20))).orElseThrow(); + + assertThat(activities.items()).extracting("title").containsExactly("첫 게시"); + assertThat(activities.page().totalElements()).isEqualTo(1); + } + + // ---------------------------------------------------------------- 릴리스 + + @Test + void releasesListOnlyPublishedOnesAndResolveRelatedRecords() { + assertThat(releases.list()).extracting("version").containsExactly("1.0.0"); + + ReleaseDetailView detail = releases.findByVersion("1.0.0").orElseThrow(); + assertThat(detail.title()).isEqualTo("첫 공개"); + assertThat(detail.changeTypes()).containsExactly("ADDED"); + assertThat(detail.relatedRecords()) + .as("related_resources 는 공개된 것만 되살린다") + .extracting("title") + .containsExactly("Kafka 재처리"); + + assertThat(releases.findByVersion("0.9.0")).as("DRAFT 릴리스는 열리면 안 된다").isEmpty(); + } + + // ---------------------------------------------------------------- 검색 + + @Test + void searchMatchesOnSearchTextAndAppliesFilters() { + SearchResultPageView hits = search.search(new SearchQuery("재처리", null, null, page(1, 20))); + + assertThat(hits.query()).isEqualTo("재처리"); + assertThat(hits.items()).isNotEmpty(); + assertThat(hits.page().totalElements()).isEqualTo(hits.items().size()); + assertThat(hits.items()).extracting("title").doesNotContain("숨김 Case"); + + assertThat(search.search(new SearchQuery("기준", "REFERENCE", null, page(1, 20))).items()) + .extracting("contentType") + .containsOnly("REFERENCE"); + assertThat(search.search(new SearchQuery("기준", "CASE", null, page(1, 20))).items()) + .as("type 필터가 실제로 걸려야 한다") + .isEmpty(); + + assertThat(search.search(new SearchQuery("존재하지않는단어", null, null, page(1, 20))).items()) + .isEmpty(); + } + + // ---------------------------------------------------------------- 시딩 + + /** + * 삽입 순서가 곧 제약이다. {@code public_resource_project_link}/{@code public_resource_tag} 는 {@code + * public_resource_projection} 을 복합 FK 로 참조하므로 원본 테이블 → projection → 링크/태그 순서를 지킨다. + */ + private static void seed() { + topicId = insertTopic("kafka", "Kafka"); + + projectId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO project (id, slug, name, one_line_purpose, purpose_markdown," + + " boundary_markdown, system_overview_markdown, phase, current_objective," + + " next_step, technology_labels, workflow_status, target_visibility," + + " created_by, updated_by)" + + " VALUES (:id, 'tech-log', 'Tech Log', '기록을 남긴다', '목적', '경계', '개요'," + + " 'IMPLEMENTATION', '공개 API 완성', '통합 테스트'," + + " '[\"Spring Boot\", \"PostgreSQL\"]'::jsonb, 'PUBLISHED', 'PUBLIC'," + + " 'test', 'test')") + .param("id", projectId) + .update(); + + tagId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO tag (id, name, normalized_name, slug, created_by, updated_by)" + + " VALUES (:id, 'reprocessing', 'reprocessing', 'reprocessing', 'test', 'test')") + .param("id", tagId) + .update(); + + // --- Case (공개) --- + caseId = UUID.randomUUID(); + insertDocument(caseId, "CASE", "kafka-reprocessing", "Kafka 재처리", topicId); + jdbcClient + .sql( + "INSERT INTO case_detail (document_id, problem_summary, conclusion_summary," + + " environment_items) VALUES (:id, '재처리가 지연된다', '컨슈머 랙을 먼저 본다'," + + " '[\"Kafka 3.7\"]'::jsonb)") + .param("id", caseId) + .update(); + publish( + "CASE", + caseId, + "Kafka 재처리", + "재처리가 지연된다", + "/cases/kafka-reprocessing", + "ACTIVE", + "PUBLIC", + topicId); + link(caseId, "CASE", "PRIMARY", 0); + tag(caseId, "CASE"); + // 목록의 tag 필터는 projection(public_resource_tag)을, 상세는 원본(document_tag)을 읽는다. + jdbcClient + .sql( + "INSERT INTO document_tag (document_id, tag_id, display_order)" + " VALUES (:d, :t, 0)") + .param("d", caseId) + .param("t", tagId) + .update(); + + // --- Reference (공개) --- + referenceId = UUID.randomUUID(); + insertDocument(referenceId, "REFERENCE", "kafka-operations", "Kafka 운영 기준", topicId); + jdbcClient + .sql( + "INSERT INTO reference_detail (document_id, scope_summary, applies_to," + + " excluded_scope, freshness_status) VALUES (:id, '운영 기준을 정한다'," + + " '[\"Kafka 3.x\"]'::jsonb, '[\"Kinesis\"]'::jsonb, 'CURRENT')") + .param("id", referenceId) + .update(); + publish( + "REFERENCE", + referenceId, + "Kafka 운영 기준", + "운영 기준을 정한다", + "/references/kafka-operations", + "ACTIVE", + "PUBLIC", + topicId); + link(referenceId, "REFERENCE", "RELATED", null); + + // --- Case (게시 취소) — 어느 경로로도 새면 안 된다 --- + hiddenCaseId = UUID.randomUUID(); + insertDocument(hiddenCaseId, "CASE", "hidden-case", "숨김 Case", topicId); + jdbcClient + .sql("INSERT INTO case_detail (document_id, problem_summary) VALUES (:id, '재처리 비밀')") + .param("id", hiddenCaseId) + .update(); + publish( + "CASE", + hiddenCaseId, + "숨김 Case", + "재처리 비밀", + "/cases/hidden-case", + "WITHDRAWN", + "PUBLIC", + topicId); + + // --- OpenQuestion (공개) --- + questionId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO open_question (id, slug, question, summary, context_markdown," + + " importance_markdown, next_verification, question_status, target_visibility," + + " primary_topic_id, opened_at, created_by, updated_by)" + + " VALUES (:id, 'reprocessing-latency', '재처리 지연을 어떻게 줄일까'," + + " '지연 원인을 좁힌다', '맥락', '중요도', '컨슈머 랙 측정', 'OPEN', 'PUBLIC', :topic," + + " :openedAt, 'test', 'test')") + .param("id", questionId) + .param("topic", topicId) + .param("openedAt", java.sql.Timestamp.from(NOW.minus(10, ChronoUnit.DAYS))) + .update(); + publish( + "QUESTION", + questionId, + "재처리 지연을 어떻게 줄일까", + "지연 원인을 좁힌다", + "/questions/reprocessing-latency", + "ACTIVE", + "PUBLIC", + topicId); + // 질문 목록의 status 필터는 projection 의 state_code 를 본다. + jdbcClient + .sql( + "UPDATE public_resource_projection SET state_code = 'OPEN'" + + " WHERE resource_type = 'QUESTION' AND resource_id = :id") + .param("id", questionId) + .update(); + link(questionId, "QUESTION", "PRIMARY", 1); + tag(questionId, "QUESTION"); + + // --- ProjectDecision (공개) --- + decisionId = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO project_decision (id, project_id, statement, rationale_markdown," + + " consequences, decision_status, target_visibility, source_question_id," + + " source_case_id, is_featured, decided_at, created_by, updated_by)" + + " VALUES (:id, :project, '재처리는 별도 토픽으로 분리한다', '격리해야 관측이 쉬워진다'," + + " '[\"운영 토픽 증가\"]'::jsonb, 'ACCEPTED', 'PUBLIC', :question, :sourceCase," + + " true, :decidedAt, 'test', 'test')") + .param("id", decisionId) + .param("project", projectId) + .param("question", questionId) + .param("sourceCase", caseId) + .param("decidedAt", java.sql.Timestamp.from(NOW.minus(2, ChronoUnit.DAYS))) + .update(); + publish( + "PROJECT_DECISION", + decisionId, + "재처리는 별도 토픽으로 분리한다", + "격리해야 관측이 쉬워진다", + "/projects/tech-log/decisions/" + decisionId, + "ACTIVE", + "PUBLIC", + null); + + // --- Project (공개) --- + publish( + "PROJECT", + projectId, + "Tech Log", + "기록을 남긴다", + "/projects/tech-log", + "ACTIVE", + "PUBLIC", + null); + + // --- 활동: 공개 하나 · 비공개 하나 --- + jdbcClient + .sql( + "INSERT INTO project_activity (id, project_id, activity_type, title, summary," + + " visibility, origin, related_resource_type, related_resource_id, occurred_at," + + " created_by, updated_by)" + + " VALUES (gen_random_uuid(), :project, 'CASE_PUBLISHED', '첫 게시'," + + " '첫 문서를 공개했다', 'PUBLIC', 'AUTO', 'CASE', :relatedCase, :at," + + " 'test', 'test')") + .param("project", projectId) + .param("relatedCase", caseId) + .param("at", java.sql.Timestamp.from(NOW.minus(1, ChronoUnit.DAYS))) + .update(); + jdbcClient + .sql( + "INSERT INTO project_activity (id, project_id, activity_type, title, visibility," + + " origin, occurred_at, created_by, updated_by)" + + " VALUES (gen_random_uuid(), :project, 'MILESTONE_REACHED', '비공개 메모', 'PRIVATE'," + + " 'MANUAL', :at, 'test', 'test')") + .param("project", projectId) + .param("at", java.sql.Timestamp.from(NOW)) + .update(); + + // --- V9 연결 테이블 --- + jdbcClient + .sql("INSERT INTO project_topic (project_id, topic_id, display_order) VALUES (:p, :t, 0)") + .param("p", projectId) + .param("t", topicId) + .update(); + jdbcClient + .sql( + "INSERT INTO topic_featured_document (topic_id, document_id, feature_role," + + " display_order) VALUES (:t, :d, 'START_HERE', 0)") + .param("t", topicId) + .param("d", referenceId) + .update(); + jdbcClient + .sql( + "INSERT INTO topic_featured_document (topic_id, document_id, feature_role," + + " display_order) VALUES (:t, :d, 'FEATURED_CASE', 0)") + .param("t", topicId) + .param("d", caseId) + .update(); + + // --- 단일 행 설정 --- + jdbcClient + .sql("UPDATE site_config SET contacts = :contacts::jsonb WHERE id = :id") + .param("id", SITE_CONFIG_ID) + .param( + "contacts", + "[{\"type\":\"GITHUB\",\"label\":\"GitHub\"," + + "\"url\":\"https://github.com/example\"}]") + .update(); + + // V9 가 단일 행을 이미 시딩했으므로(INSERT ... ON CONFLICT DO NOTHING) 값 채우기는 UPDATE 다. + jdbcClient + .sql( + "UPDATE profile_page SET headline = '문제를 재현해 검증한다'," + + " introduction_markdown = '소개', working_model = :workingModel::jsonb," + + " territories = :territories::jsonb, selected_evidence = :evidence::jsonb," + + " trajectory = :trajectory::jsonb, contacts = :contacts::jsonb," + + " target_visibility = 'PUBLIC' WHERE id = :id") + .param("id", PROFILE_PAGE_ID) + .param("workingModel", "[{\"name\":\"재현\",\"description\":\"먼저 재현한다\"}]") + .param( + "territories", + "[{\"name\":\"Kafka\",\"currentQuestion\":\"재처리 지연\"," + + "\"topicPath\":\"/topics/kafka\"}]") + // 게시 취소된 자료 id 를 함께 넣는다 — 공개된 것만 되살아나야 한다. + .param("evidence", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]") + .param("trajectory", "[{\"title\":\"2026\",\"description\":\"Tech Log 시작\"}]") + .param( + "contacts", + "[{\"type\":\"EMAIL\",\"label\":\"Email\"," + "\"url\":\"mailto:a@example.com\"}]") + .update(); + + jdbcClient + .sql( + "UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK'," + + " current_project_id = :project, open_question_id = :question," + + " recent_decision_id = :decision WHERE id = :id") + .param("id", HOME_FOCUS_ID) + .param("project", projectId) + .param("question", questionId) + .param("decision", decisionId) + .update(); + + // --- 릴리스: 공개 하나 · 초안 하나 --- + jdbcClient + .sql( + "INSERT INTO release (id, version_label, title, summary, released_on," + + " workflow_status, change_types, changes_markdown, verification_markdown," + + " related_resources, created_by, updated_by)" + + " VALUES (gen_random_uuid(), '1.0.0', '첫 공개', '공개 API 를 열었다'," + + " DATE '2026-08-01', 'PUBLISHED', '[\"ADDED\"]'::jsonb, '변경', '검증'," + + " :related::jsonb, 'test', 'test')") + // 게시 취소된 자료 id 를 일부러 함께 넣는다 — 공개된 것만 되살아나야 한다. + .param("related", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]") + .update(); + jdbcClient + .sql( + "INSERT INTO release (id, version_label, title, summary, released_on," + + " workflow_status, created_by, updated_by)" + + " VALUES (gen_random_uuid(), '0.9.0', '초안', '아직 공개 전', DATE '2026-07-01'," + + " 'DRAFT', 'test', 'test')") + .update(); + } + + private static UUID insertTopic(String slug, String name) { + UUID id = UUID.randomUUID(); + jdbcClient + .sql( + "INSERT INTO topic (id, name, normalized_name, slug, description, scope," + + " status, created_by, updated_by)" + + " VALUES (:id, :name, lower(:name), :slug, :name || ' 설명', '범위'," + + " 'ACTIVE', 'test', 'test')") + .param("id", id) + .param("name", name) + .param("slug", slug) + .update(); + return id; + } + + private static void insertDocument(UUID id, String type, String slug, String title, UUID topic) { + jdbcClient + .sql( + "INSERT INTO document (id, document_type, slug, title, body_markdown," + + " content_format, content_format_version, workflow_status, target_visibility," + + " primary_topic_id, last_verified_at, created_by, updated_by)" + + " VALUES (:id, :type, :slug, :title, '# 재처리\n본문', 'MARKDOWN', 1," + + " 'PUBLISHED', 'PUBLIC', :topic, :verifiedAt, 'test', 'test')") + .param("id", id) + .param("type", type) + .param("slug", slug) + .param("title", title) + .param("topic", topic) + .param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS))) + .update(); + } + + private static void link(UUID resourceId, String type, String relation, Integer order) { + jdbcClient + .sql( + "INSERT INTO public_resource_project_link (resource_type, resource_id, project_id," + + " relation_type, featured_order)" + + " VALUES (:type, :id, :project, :relation, :order)") + .param("type", type) + .param("id", resourceId) + .param("project", projectId) + .param("relation", relation) + .param("order", order) + .update(); + } + + private static void tag(UUID resourceId, String type) { + jdbcClient + .sql( + "INSERT INTO public_resource_tag (resource_type, resource_id, tag_id, display_order)" + + " VALUES (:type, :id, :tag, 0)") + .param("type", type) + .param("id", resourceId) + .param("tag", tagId) + .update(); + } + + /** + * projection 행을 만든다. {@code public_resource_project_link} 와 {@code public_resource_tag} 가 이 행을 + * (resource_type, resource_id) 복합 FK 로 참조하므로 반드시 링크·태그보다 먼저 삽입해야 한다. + * + *

{@code topic} 을 인자로 받는 이유는 주제별 record 수를 세는 쿼리가 {@code primary_topic_id} 를 보기 때문이다. 모든 + * projection 에 같은 주제를 박아 두면 Project 나 Decision 까지 그 주제의 기록으로 세어져, 실제 값과 다른 숫자에 테스트를 맞추게 된다. + */ + private static void publish( + String type, + UUID id, + String title, + String summary, + String path, + String state, + String visibility, + UUID topic) { + jdbcClient + .sql( + "INSERT INTO public_resource_projection (resource_type, resource_id, source_version," + + " publication_state, visibility, title, summary, primary_topic_id," + + " payload_schema_version, payload, body_plain_text, search_text, content_hash," + + " published_at, updated_at, last_verified_at, navigation_path)" + + " VALUES (:type, :id, 1, :state, :visibility, :title, :summary, :topic, 1," + + " '{}'::jsonb, :body, :search, repeat('a', 64), :publishedAt, :updatedAt," + + " :verifiedAt, :path)") + .param("type", type) + .param("id", id) + .param("state", state) + .param("visibility", visibility) + .param("title", title) + .param("summary", summary) + .param("topic", topic) + .param("body", title + " " + summary) + .param("search", title + " " + summary) + .param("publishedAt", java.sql.Timestamp.from(NOW.minus(5, ChronoUnit.DAYS))) + .param("updatedAt", java.sql.Timestamp.from(NOW.minus(4, ChronoUnit.DAYS))) + .param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS))) + .param("path", path) + .update(); + } + + private static PublicPageRequest page(int page, int size) { + return new PublicPageRequest(page, size); + } + + private static boolean tableExists(String table) { + return Boolean.TRUE.equals( + jdbcClient + .sql( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables" + + " WHERE table_schema = 'public' AND table_name = :t)") + .param("t", table) + .query(Boolean.class) + .single()); + } +} diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java new file mode 100644 index 0000000..85b2bbb --- /dev/null +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/PublicContractDriftTest.java @@ -0,0 +1,542 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig; +import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.yaml.snakeyaml.Yaml; + +/** + * {@code PublicContractDriftTest} 는 {@code StudioContractDriftTest} 가 studio-v1 에 대해 하는 일을 + * public-v1 에 대해 한다: springdoc 이 실제로 게시하는 표면과 vendored {@code src/config/openapi/public-v1.yaml} 을 + * 양방향으로 대조한다. + * + *

스캔 범위는 {@code dev.caskeleton.adapter.inbound.web.techlog.publicapi} 다. 이 아래에 새 컨트롤러를 두면 이 파일을 + * 고치지 않아도 자동으로 감시 대상이 되고, 밖에 두면 게이트가 그것을 보지 못한다 — 그 성질과 함정은 형제 테스트의 클래스 javadoc 에 자세히 적혀 있다. + * + *

계약의 {@code servers} 를 경로에 더해야 한다

+ * + *

studio-v1 은 {@code servers: "/"} 라 계약의 path 가 그대로 최종 주소지만, public-v1 은 {@code servers: + * "/api/v1/public"} 이고 path 는 {@code /site} 처럼 짧다. 그래서 대조 전에 server prefix 를 붙인다 — 이걸 빠뜨리면 + * published 와 계약이 한 건도 겹치지 않는데도 "published ⊆ 계약" 방향은 비교 대상이 0건이라 통과해 버린다. 아래 {@code compared} 비어있지 + * 않음 단언이 그 상태를 실패로 만든다. + */ +class PublicContractDriftTest { + + @Nested + @SpringBootTest(classes = ContractSurface.ContractSurfaceApp.class) + @AutoConfigureMockMvc(addFilters = false) + class ContractSurface { + + @Autowired private MockMvc mvc; + + @Test + void publishedPublicOperationsMatchTheContract() throws Exception { + JsonNode contract = readContract(); + String prefix = serverPrefix(contract); + JsonNode published = readPublishedApiDocs(); + + List problems = new ArrayList<>(); + List compared = new ArrayList<>(); + JsonNode publishedPaths = published.path("paths"); + for (Map.Entry path : publishedPaths.properties()) { + if (!path.getKey().startsWith(prefix + "/")) { + continue; + } + compared.add(path.getKey()); + String contractKey = path.getKey().substring(prefix.length()); + JsonNode contractPath = contract.path("paths").path(contractKey); + if (contractPath.isMissingNode()) { + problems.add("계약에 없는 path: " + path.getKey()); + continue; + } + for (Map.Entry method : path.getValue().properties()) { + JsonNode contractOp = contractPath.path(method.getKey()); + if (contractOp.isMissingNode()) { + problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey()); + continue; + } + String publishedId = method.getValue().path("operationId").asText(""); + String contractId = contractOp.path("operationId").asText(""); + if (!publishedId.equals(contractId)) { + problems.add( + "operationId 불일치 " + + method.getKey() + + " " + + path.getKey() + + ": published=" + + publishedId + + " contract=" + + contractId); + } + } + } + assertThat(problems).isEmpty(); + assertThat(compared) + .as( + "published 표면에서 " + + prefix + + " 경로를 하나도 대조하지 못했다 —" + + " PresentationWebConfig 의 api-base-path 배선이나 컨트롤러 매핑을 확인하라." + + " published paths=" + + publishedPaths.properties().stream().map(Map.Entry::getKey).toList()) + .isNotEmpty(); + } + + /** 반대 방향 — 계약의 18 operation 이 전부 published 표면에 있는가. */ + @Test + void everyContractOperationIsPublished() throws Exception { + JsonNode contract = readContract(); + String prefix = serverPrefix(contract); + JsonNode published = readPublishedApiDocs(); + + List missing = new ArrayList<>(); + int contractOperations = 0; + for (Map.Entry path : contract.path("paths").properties()) { + for (Map.Entry method : path.getValue().properties()) { + JsonNode operationId = method.getValue().path("operationId"); + if (operationId.isMissingNode()) { + continue; + } + contractOperations++; + JsonNode publishedOperation = + published.path("paths").path(prefix + path.getKey()).path(method.getKey()); + if (publishedOperation.isMissingNode() + || !operationId.asText().equals(publishedOperation.path("operationId").asText(""))) { + missing.add(operationId.asText() + " (" + method.getKey() + " " + path.getKey() + ")"); + } + } + } + assertThat(missing).as("계약이 약속했는데 서버가 제공하지 않는 operation").isEmpty(); + // 계약이 통째로 비거나 잘못 읽혀도 위 단언은 통과한다 — 순회할 게 없으면 missing 도 비니까. + assertThat(contractOperations).as("public-v1 계약의 operation 수").isEqualTo(18); + } + + private static String serverPrefix(JsonNode contract) { + String url = contract.path("servers").path(0).path("url").asText(""); + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private static JsonNode readContract() throws Exception { + Path contractFile = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("src/config/openapi/public-v1.yaml"); + Map contractYaml; + try (InputStream in = Files.newInputStream(contractFile)) { + contractYaml = new Yaml().load(in); + } + return new ObjectMapper().valueToTree(contractYaml); + } + + private JsonNode readPublishedApiDocs() throws Exception { + String body = + mvc.perform(get("/api/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + return new ObjectMapper().readTree(body); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.publicapi") + @Import({PresentationWebConfig.class, PublicContractDriftTest.PublicPortStubs.class}) + static class ContractSurfaceApp { + + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + } + } + + @Nested + @SpringBootTest(classes = EnvelopeWrapping.EnvelopeApp.class) + @AutoConfigureMockMvc(addFilters = false) + class EnvelopeWrapping { + + @Autowired private MockMvc mvc; + + /** + * ADR-006: 공개 조회 응답도 봉투로 나간다. {@code /topics} 를 고른 이유는 반환 타입이 평범한 POJO 라 {@code + * EnvelopeBodyAdvice} 가 감싸기 전후로 같은 JSON 컨버터가 처리하기 때문이다(형제 테스트가 {@code byte[]} 반환 컨트롤러에서 겪은 + * {@code ClassCastException} 을 피한다). + */ + @Test + void everyPublicResponseIsWrappedInTheEnvelope() throws Exception { + String body = + mvc.perform(get("/api/v1/public/topics")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\""); + assertThat(body).doesNotContain("\"data\":{\"success\""); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.publicapi") + @Import({ + EnvelopeBodyAdvice.class, + PresentationWebConfig.class, + PublicContractDriftTest.PublicPortStubs.class + }) + static class EnvelopeApp { + + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + } + } + + /** + * 7개 outbound port 의 빈 stub 과 그 위에 올린 18개 use case. 첫 번째 테스트는 springdoc 리플렉션이라 컨트롤러 메서드를 아예 호출하지 + * 않고, 두 번째 테스트는 {@code /topics} 하나만 두드리며 감싸는 모양만 본다 — 실제 영속성 어댑터를 끌어오면 이 게이트가 말하려는 것(계약 표면과 봉투)과 + * 무관한 DB 인프라가 딸려 온다. + * + *

production 의 {@code TechLogPublicConfig} 를 그대로 {@code @Import} 하지 않는 이유는 그 클래스가 + * app-bootstrap 의 {@code main} 소스셋에 있고, 이 functionalTest 소스셋은 {@code main} output 을 클래스패스에 두지 않기 + * 때문이다. 억지로 넣으면 app-bootstrap 의 {@code AutoConfiguration.imports}(fileserver / httpclient)까지 함께 + * 활성화되어, 계약 표면만 보려는 최소 컨텍스트가 무관한 인프라를 요구하게 된다. 대신 같은 조립을 여기서 반복한다 — production 배선 자체는 앱 기동으로 + * 확인한다. + */ + @Configuration(proxyBeanMethods = false) + static class PublicPortStubs { + + @Bean + TransactionPort transactionPort() { + return new PassThroughTransactionPort(); + } + + @Bean + PublicSiteQueryPort publicSiteQueryPort() { + return new PublicSiteQueryPort() { + @Override + public Optional site() { + return Optional.empty(); + } + + @Override + public HomeView home(int latestEntryLimit) { + return new HomeView(null, List.of()); + } + + @Override + public Optional profile() { + return Optional.empty(); + } + }; + } + + @Bean + PublicExploreQueryPort publicExploreQueryPort() { + return new PublicExploreQueryPort() { + @Override + public KnowledgePageView knowledge(ExploreKnowledgeQuery query) { + return new KnowledgePageView(List.of(), PageMetadataView.of(1, 20, 0)); + } + + @Override + public QuestionPageView questions(ExploreQuestionsQuery query) { + return new QuestionPageView(List.of(), PageMetadataView.of(1, 20, 0)); + } + }; + } + + @Bean + PublicTopicQueryPort publicTopicQueryPort() { + return new PublicTopicQueryPort() { + @Override + public List list() { + return List.of(); + } + + @Override + public Optional findBySlug(String slug) { + return Optional.empty(); + } + }; + } + + @Bean + PublicDocumentQueryPort publicDocumentQueryPort() { + return new PublicDocumentQueryPort() { + @Override + public Optional findCase(String slug) { + return Optional.empty(); + } + + @Override + public Optional findReference(String slug) { + return Optional.empty(); + } + + @Override + public Optional findQuestion(String slug) { + return Optional.empty(); + } + }; + } + + @Bean + PublicProjectQueryPort publicProjectQueryPort() { + return new PublicProjectQueryPort() { + @Override + public List list() { + return List.of(); + } + + @Override + public Optional findBySlug(String slug) { + return Optional.empty(); + } + + @Override + public Optional decisions(ProjectDecisionPageQuery query) { + return Optional.empty(); + } + + @Override + public Optional records(ProjectRecordPageQuery query) { + return Optional.empty(); + } + + @Override + public Optional activities(ProjectPageQuery query) { + return Optional.empty(); + } + }; + } + + @Bean + PublicReleaseQueryPort publicReleaseQueryPort() { + return new PublicReleaseQueryPort() { + @Override + public List list() { + return List.of(); + } + + @Override + public Optional findByVersion(String version) { + return Optional.empty(); + } + }; + } + + @Bean + PublicSearchQueryPort publicSearchQueryPort() { + return new PublicSearchQueryPort() { + @Override + public SearchResultPageView search(SearchQuery query) { + return new SearchResultPageView(query.query(), List.of(), PageMetadataView.of(1, 20, 0)); + } + }; + } + + @Bean + GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicSiteUseCase(port, tx); + } + + @Bean + GetPublicHomeUseCase getPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicHomeUseCase(port, tx); + } + + @Bean + GetPublicProfileUseCase getPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicProfileUseCase(port, tx); + } + + @Bean + ExploreKnowledgeUseCase exploreKnowledgeUseCase( + PublicExploreQueryPort port, TransactionPort tx) { + return new ExploreKnowledgeUseCase(port, tx); + } + + @Bean + ExploreQuestionsUseCase exploreQuestionsUseCase( + PublicExploreQueryPort port, TransactionPort tx) { + return new ExploreQuestionsUseCase(port, tx); + } + + @Bean + ListPublicTopicsUseCase listPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort tx) { + return new ListPublicTopicsUseCase(port, tx); + } + + @Bean + GetPublicTopicUseCase getPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort tx) { + return new GetPublicTopicUseCase(port, tx); + } + + @Bean + GetPublicCaseUseCase getPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicCaseUseCase(port, tx); + } + + @Bean + GetPublicReferenceUseCase getPublicReferenceUseCase( + PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicReferenceUseCase(port, tx); + } + + @Bean + GetPublicQuestionUseCase getPublicQuestionUseCase( + PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicQuestionUseCase(port, tx); + } + + @Bean + ListPublicProjectsUseCase listPublicProjectsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectsUseCase(port, tx); + } + + @Bean + GetPublicProjectUseCase getPublicProjectUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new GetPublicProjectUseCase(port, tx); + } + + @Bean + ListPublicProjectDecisionsUseCase listPublicProjectDecisionsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectDecisionsUseCase(port, tx); + } + + @Bean + ListPublicProjectRecordsUseCase listPublicProjectRecordsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectRecordsUseCase(port, tx); + } + + @Bean + ListPublicProjectActivitiesUseCase listPublicProjectActivitiesUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectActivitiesUseCase(port, tx); + } + + @Bean + ListPublicReleasesUseCase listPublicReleasesUseCase( + PublicReleaseQueryPort port, TransactionPort tx) { + return new ListPublicReleasesUseCase(port, tx); + } + + @Bean + GetPublicReleaseUseCase getPublicReleaseUseCase( + PublicReleaseQueryPort port, TransactionPort tx) { + return new GetPublicReleaseUseCase(port, tx); + } + + @Bean + SearchPublicResourcesUseCase searchPublicResourcesUseCase( + PublicSearchQueryPort port, TransactionPort tx) { + return new SearchPublicResourcesUseCase(port, tx); + } + } + + private static final class PassThroughTransactionPort implements TransactionPort { + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java index 32e857f..577128d 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java @@ -46,18 +46,24 @@ import org.yaml.snakeyaml.Yaml; * implemented operations are checked (direction is "published ⊆ contract", never the * reverse), so this stays green as slices 2-5 add the other 17 operations — on one * condition: the new controllers must live somewhere under {@code - * dev.caskeleton.adapter.inbound.web.techlog}, the package {@link + * dev.caskeleton.adapter.inbound.web.techlog.studio}, the package {@link * ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp} - * {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to - * this file. A controller placed outside that package tree is invisible to both minimal - * contexts — springdoc never sees it, so this gate stays green even if its path/method/operationId - * contradicts the contract — and the {@code @ComponentScan} base package below must be widened (or - * the new controller moved) before this gate can be trusted again. (An earlier draft of this class - * named the two controllers directly via {@code @Import} instead of scanning; that hardcoded list - * had exactly this blind spot — confirmed by temporarily reintroducing it and observing a - * controller with an out-of-contract mapping pass silently, see task-10-report.md.) This test also - * fails the moment an in-scan controller's method name drifts from its {@code operationId} or ships - * an endpoint outside the contract. + * {@code @ComponentScan}. (The scan sat one level higher — {@code ...web.techlog} — until the + * public-v1 controllers arrived under {@code ...web.techlog.publicapi}: scanning those pulled a + * second contract's controllers into a Studio-only context, which then needs their use-case beans + * and has nothing to say about their contract. {@code PublicContractDriftTest} is this same gate + * for that tree, scanning {@code ...web.techlog.publicapi} against {@code public-v1.yaml}, so each + * contract keeps the automatic-pickup property inside its own package.) A controller placed there + * is picked up automatically, with no edit to this file. A controller placed outside that + * package tree is invisible to both minimal contexts — springdoc never sees it, so this gate stays + * green even if its path/method/operationId contradicts the contract — and the + * {@code @ComponentScan} base package below must be widened (or the new controller moved) before + * this gate can be trusted again. (An earlier draft of this class named the two controllers + * directly via {@code @Import} instead of scanning; that hardcoded list had exactly this blind spot + * — confirmed by temporarily reintroducing it and observing a controller with an out-of-contract + * mapping pass silently, see task-10-report.md.) This test also fails the moment an in-scan + * controller's method name drifts from its {@code operationId} or ships an endpoint outside the + * contract. * *

Why a hand-built minimal context rather than {@code CaSkeletonApplication}

* @@ -277,7 +283,7 @@ class StudioContractDriftTest { */ @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) - @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.studio") @Import({PresentationWebConfig.class, StudioContractDriftTest.StudioDocumentTestBeans.class}) static class ContractSurfaceApp { @@ -333,7 +339,7 @@ class StudioContractDriftTest { */ @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) - @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") + @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.studio") @Import({ EnvelopeBodyAdvice.class, PresentationWebConfig.class, diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java new file mode 100644 index 0000000..e1a4fa8 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/TechLogPublicConfig.java @@ -0,0 +1,139 @@ +package dev.caskeleton.bootstrap.techlog; + +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase; +import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase; +import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase; +import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase; +import dev.caskeleton.application.transaction.TransactionPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Tech Log 공개 조회(public-v1) 조립. application-core 는 Spring 을 보지 않으므로 여기서 배선한다 — {@link + * TechLogStudioConfig} 와 같은 이유·같은 모양이다. + * + *

계약의 18 operation 이 18 use case 와 1:1 이다. 배선을 한 파일에 모아 두면 operation 이 늘거나 줄 때 어디를 고쳐야 하는지가 한 + * 곳으로 정해진다. + */ +@Configuration +public class TechLogPublicConfig { + + @Bean + GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicSiteUseCase(port, tx); + } + + @Bean + GetPublicHomeUseCase getPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicHomeUseCase(port, tx); + } + + @Bean + GetPublicProfileUseCase getPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort tx) { + return new GetPublicProfileUseCase(port, tx); + } + + @Bean + ExploreKnowledgeUseCase exploreKnowledgeUseCase(PublicExploreQueryPort port, TransactionPort tx) { + return new ExploreKnowledgeUseCase(port, tx); + } + + @Bean + ExploreQuestionsUseCase exploreQuestionsUseCase(PublicExploreQueryPort port, TransactionPort tx) { + return new ExploreQuestionsUseCase(port, tx); + } + + @Bean + ListPublicTopicsUseCase listPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort tx) { + return new ListPublicTopicsUseCase(port, tx); + } + + @Bean + GetPublicTopicUseCase getPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort tx) { + return new GetPublicTopicUseCase(port, tx); + } + + @Bean + GetPublicCaseUseCase getPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicCaseUseCase(port, tx); + } + + @Bean + GetPublicReferenceUseCase getPublicReferenceUseCase( + PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicReferenceUseCase(port, tx); + } + + @Bean + GetPublicQuestionUseCase getPublicQuestionUseCase( + PublicDocumentQueryPort port, TransactionPort tx) { + return new GetPublicQuestionUseCase(port, tx); + } + + @Bean + ListPublicProjectsUseCase listPublicProjectsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectsUseCase(port, tx); + } + + @Bean + GetPublicProjectUseCase getPublicProjectUseCase(PublicProjectQueryPort port, TransactionPort tx) { + return new GetPublicProjectUseCase(port, tx); + } + + @Bean + ListPublicProjectDecisionsUseCase listPublicProjectDecisionsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectDecisionsUseCase(port, tx); + } + + @Bean + ListPublicProjectRecordsUseCase listPublicProjectRecordsUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectRecordsUseCase(port, tx); + } + + @Bean + ListPublicProjectActivitiesUseCase listPublicProjectActivitiesUseCase( + PublicProjectQueryPort port, TransactionPort tx) { + return new ListPublicProjectActivitiesUseCase(port, tx); + } + + @Bean + ListPublicReleasesUseCase listPublicReleasesUseCase( + PublicReleaseQueryPort port, TransactionPort tx) { + return new ListPublicReleasesUseCase(port, tx); + } + + @Bean + GetPublicReleaseUseCase getPublicReleaseUseCase(PublicReleaseQueryPort port, TransactionPort tx) { + return new GetPublicReleaseUseCase(port, tx); + } + + @Bean + SearchPublicResourcesUseCase searchPublicResourcesUseCase( + PublicSearchQueryPort port, TransactionPort tx) { + return new SearchPublicResourcesUseCase(port, tx); + } +} diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml index 57af34b..03a9927 100644 --- a/src/app-bootstrap/src/main/resources/application-local.yml +++ b/src/app-bootstrap/src/main/resources/application-local.yml @@ -154,7 +154,10 @@ ca-skeleton: security: issuer-uri: http://localhost:8081/realms/ca-skeleton audience: ca-skeleton-api - public-paths: /api/healthcheck + # public-v1(공개 조회 계약)은 인증이 없다 — 계약의 security 가 비어 있고 서문이 + # "인증이 필요하지 않다"고 명시한다. deny-by-default 기준선을 넓히는 변경이라 + # docs/security/public-paths-snapshot.txt 가 함께 갱신되어야 통과한다. + public-paths: /api/healthcheck, /api/v1/public/** # Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a # `const`. The template default is X-XSRF-TOKEN (application.yml:498, restated verbatim by # src/.env:125, the profile src/.env:8 activates) — StudioSessionController's constructor diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/PublicErrorRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/PublicErrorRegistryTest.java new file mode 100644 index 0000000..ad0729c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/PublicErrorRegistryTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.bootstrap.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.techlog.publicapi.PublicClientSafeMessages; +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources; +import dev.caskeleton.shared.error.OperationalError; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * {@code StudioErrorRegistryTest} 가 {@link dev.caskeleton.application.techlog.error.StudioError} 에 + * 대해 하는 일을 {@link PublicError} 에 대해 한다 — 같은 세 축(row 존재 / 값 드리프트 / client-safe 문구)에 계약 code 집합 대조와 + * vendored 계약 해시까지. + * + *

계약의 {@code ApiError.code} enum 은 세 값인데 {@link PublicError} 는 둘뿐이다. 나머지 하나 {@code + * INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통 처리기가 내는 코드({@link OperationalError#INTERNAL_ERROR}) 이며, 같은 + * code 를 두 enum 이 각자 status 와 함께 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없어 일부러 재선언하지 않았다. 그래서 code 집합 대조는 + * "정확히 일치"가 아니라 "계약 = public 소유 ∪ {@code INTERNAL_ERROR}" 를 고정한다 — 어느 쪽에 새 code 가 생기든 이 테스트가 먼저 + * 빨간불이 된다. + */ +class PublicErrorRegistryTest { + + /** 계약이 열거하지만 이 기능이 소유하지 않는 code. 근거는 클래스 javadoc. */ + private static final String SKELETON_OWNED_CODE = OperationalError.INTERNAL_ERROR.code(); + + private static Map> registryRowsByCode; + + @BeforeAll + @SuppressWarnings("unchecked") + static void loadRegistry() throws Exception { + Path registry = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("docs/registries/error-codes.yaml"); + registryRowsByCode = new LinkedHashMap<>(); + try (InputStream in = Files.newInputStream(registry)) { + Map root = new Yaml().load(in); + List> errors = (List>) root.get("errors"); + for (Map row : errors) { + registryRowsByCode.put((String) row.get("code"), row); + } + } + } + + @Test + void everyPublicErrorHasARegistryRow() { + Set declared = + Arrays.stream(PublicError.values()).map(PublicError::code).collect(Collectors.toSet()); + + assertThat(registryRowsByCode.keySet()).containsAll(declared); + } + + @Test + void everyPublicErrorRowMatchesCategoryHttpStatusAndRetryable() { + for (PublicError error : PublicError.values()) { + Map row = registryRowsByCode.get(error.code()); + assertThat(row).as("registry row for %s", error.code()).isNotNull(); + + assertThat(row.get("category")) + .as("category for %s", error.code()) + .isEqualTo(error.category().name()); + assertThat(((Number) row.get("http_status")).intValue()) + .as("http_status for %s", error.code()) + .isEqualTo(error.httpStatus()); + assertThat(row.get("retryable")) + .as("retryable for %s", error.code()) + .isEqualTo(error.retryable()); + } + } + + @Test + void everyPublicErrorClientSafeMessageMatchesRegistry() { + for (PublicError error : PublicError.values()) { + Map row = registryRowsByCode.get(error.code()); + assertThat(row).as("registry row for %s", error.code()).isNotNull(); + + assertThat(PublicClientSafeMessages.forError(error)) + .as("client_safe_message for %s", error.code()) + .isEqualTo(row.get("client_safe_message")); + } + } + + @Test + void enumPlusTheSkeletonOwnedCodeMatchesTheContractCodeSet() throws Exception { + Path contract = + RepositoryContractResources.fromSystemProperty() + .requireTrackedFile("src/config/openapi/public-v1.yaml"); + Set contractCodes = contractApiErrorCodes(contract); + + Set enumCodes = + Arrays.stream(PublicError.values()).map(PublicError::code).collect(Collectors.toSet()); + + assertThat(contractCodes) + .as("public-v1.yaml ApiError.code enum vs PublicError + %s", SKELETON_OWNED_CODE) + .containsExactlyInAnyOrderElementsOf( + java.util.stream.Stream.concat( + enumCodes.stream(), java.util.stream.Stream.of(SKELETON_OWNED_CODE)) + .collect(Collectors.toSet())); + assertThat(enumCodes) + .as("PublicError 는 스켈레톤 소유 code 를 재선언하지 않는다") + .doesNotContain(SKELETON_OWNED_CODE); + } + + @SuppressWarnings("unchecked") + private static Set contractApiErrorCodes(Path contract) throws IOException { + try (InputStream in = Files.newInputStream(contract)) { + Map root = new Yaml().load(in); + Map components = (Map) root.get("components"); + Map schemas = (Map) components.get("schemas"); + Map apiError = (Map) schemas.get("ApiError"); + Map properties = (Map) apiError.get("properties"); + Map code = (Map) properties.get("code"); + List enumValues = (List) code.get("enum"); + return Set.copyOf(enumValues); + } + } + + /** + * {@code src/config/openapi/public-v1.yaml} 은 설계 패키지 계약의 vendored 사본이다({@code MANIFEST.sha256} 의 + * {@code # source:} 줄이 출처를 기록한다). 이 단언이 없으면 vendor 사본을 손으로 고쳐도 아무도 알아채지 못한다 — studio 쪽과 같은 이유의 같은 + * 게이트다. + */ + @Test + void vendoredContractMatchesTheRecordedManifestHash() throws Exception { + RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty(); + Path contract = resources.requireTrackedFile("src/config/openapi/public-v1.yaml"); + Path manifest = resources.requireTrackedFile("src/config/openapi/MANIFEST.sha256"); + + assertThat(sha256Hex(contract)) + .as( + "src/config/openapi/public-v1.yaml sha256 must match the value MANIFEST.sha256 recorded" + + " for it (local edit or vendoring drift)") + .isEqualTo(recordedSha256(manifest, "public-v1.yaml")); + } + + private static String recordedSha256(Path manifest, String filename) throws IOException { + return Files.readAllLines(manifest).stream() + .map(String::strip) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .filter(line -> line.endsWith(filename)) + .map(line -> line.substring(0, line.indexOf(' ')).strip()) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "MANIFEST.sha256 has no hash row for " + filename + ": " + manifest)); + } + + private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(Files.readAllBytes(file)); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicError.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicError.java new file mode 100644 index 0000000..bed8d17 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicError.java @@ -0,0 +1,48 @@ +package dev.caskeleton.application.techlog.publicsite.error; + +import dev.caskeleton.shared.error.ApiErrorCode; +import dev.caskeleton.shared.error.Category; + +/** + * 공개 조회 계약(`public-v1.yaml`)의 `ApiError.code`. + * + *

계약의 enum 은 세 값인데 여기엔 둘뿐이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤의 공통 예외 처리기가 내는 코드이므로 + * 여기서 다시 선언하지 않는다 — 같은 코드를 두 enum 이 각자 status 와 함께 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다. + * + *

Studio 의 코드와 이름을 겹치지 않게 한 것도 같은 이유다. 레지스트리는 코드 하나에 status 하나만 담을 수 있어서 public 의 400 과 studio 의 + * 422 를 같은 이름으로 쓸 수 없다. + */ +public enum PublicError implements ApiErrorCode { + PUBLIC_REQUEST_INVALID(Category.VALIDATION, 400, false), + PUBLIC_RESOURCE_NOT_FOUND(Category.NOT_FOUND, 404, false); + + private final Category category; + private final int httpStatus; + private final boolean retryable; + + PublicError(Category category, int httpStatus, boolean retryable) { + this.category = category; + this.httpStatus = httpStatus; + this.retryable = retryable; + } + + @Override + public String code() { + return name(); + } + + @Override + public Category category() { + return category; + } + + @Override + public int httpStatus() { + return httpStatus; + } + + @Override + public boolean retryable() { + return retryable; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicException.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicException.java new file mode 100644 index 0000000..f479341 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/error/PublicException.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.techlog.publicsite.error; + +import dev.caskeleton.shared.error.ApiErrorCarrier; +import dev.caskeleton.shared.error.ApiErrorCode; + +/** + * 공개 조회 실패. {@link #getMessage()} 는 진단용이며 클라이언트에게 그대로 나가지 않는다 — 응답 문구는 레지스트리의 client-safe message 를 + * 쓴다({@code ApiErrorCarrier} javadoc). + */ +public final class PublicException extends RuntimeException implements ApiErrorCarrier { + + private final transient PublicError error; + + private PublicException(PublicError error, String message) { + super(message); + this.error = error; + } + + public static PublicException of(PublicError error, String message) { + return new PublicException(error, message); + } + + @Override + public ApiErrorCode errorCode() { + return error; + } + + public PublicError publicError() { + return error; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/AssetReferenceView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/AssetReferenceView.java new file mode 100644 index 0000000..7ae1fad --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/AssetReferenceView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.UUID; + +/** + * 계약 {@code AssetReference}. + * + * @param url 검증된 전송 경로다. object storage URL 이 아니다(설계 05장 §3.1). + */ +public record AssetReferenceView( + UUID assetId, String url, String altText, Integer width, Integer height, String contentType) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseDetailView.java new file mode 100644 index 0000000..aa1bc1e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseDetailView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code CaseDetailResponse}. */ +public record CaseDetailView( + String canonicalPath, + boolean indexable, + PublishedDocumentView document, + CaseRelationsView relations) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseRelationsView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseRelationsView.java new file mode 100644 index 0000000..c33192e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/CaseRelationsView.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code CaseDetailResponse.relations}. */ +public record CaseRelationsView( + RelatedEntryView originQuestion, + List projectDecisions, + List derivedReferences, + List relatedCases) { + + public CaseRelationsView { + projectDecisions = projectDecisions == null ? List.of() : List.copyOf(projectDecisions); + derivedReferences = derivedReferences == null ? List.of() : List.copyOf(derivedReferences); + relatedCases = relatedCases == null ? List.of() : List.copyOf(relatedCases); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ContactLinkView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ContactLinkView.java new file mode 100644 index 0000000..88496ec --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ContactLinkView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code ContactLink}. */ +public record ContactLinkView(String type, String label, String url) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeFocusView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeFocusView.java new file mode 100644 index 0000000..e751cc0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeFocusView.java @@ -0,0 +1,106 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** + * 계약 {@code HomeResponse.focus}. {@code defaultType} 이 어느 갈래를 보여줄지 정하고 세 갈래는 전부 optional 이다 — 계약이 + * 그렇게 정했다. 설정된 갈래가 비어 있을 수 있으므로(예: 지목한 질문이 비공개가 되었을 때) 타입으로 "반드시 하나는 있다"를 강제하지 않는다. + */ +public record HomeFocusView( + String defaultType, + CurrentWork currentWork, + OpenQuestion openQuestion, + RecentDecision recentDecision) { + + /** + * 설정된 값이 없거나 그 갈래가 비었을 때 쓸 순서. 계약이 {@code defaultType} 을 required 로 선언했으므로 "정해진 게 없다"를 null 로 표현할 + * 수 없다. + */ + private static final String[] FALLBACK_ORDER = { + "CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION" + }; + + /** + * 계약이 {@code focus.defaultType} 을 required 로 선언하고 값 셋만 허용한다. 그런데 설정 테이블은 갓 마이그레이션한 상태에서 {@code + * default_focus_type} 이 NULL 이고, 지목한 갈래가 비공개로 바뀌어 비는 경우도 있다. 그대로 내보내면 응답 매퍼가 계약 밖 값을 만나 홈 화면 전체가 + * 실패한다 — 실제로 배포 직후 첫 요청이 그렇게 깨졌다. + * + *

그래서 이 자리에서 반드시 유효한 값 하나를 정한다. + * + *

    + *
  1. 설정된 값이 유효하고 그 갈래에 내용이 있으면 그대로 쓴다. + *
  2. 아니면 내용이 있는 갈래를 {@link #FALLBACK_ORDER} 순으로 고른다. + *
  3. 셋 다 비었으면 첫 값을 쓴다 — 세 갈래는 전부 optional 이므로 비어 있어도 계약을 만족한다. + *
+ */ + public static HomeFocusView resolve( + String configuredType, + CurrentWork currentWork, + OpenQuestion openQuestion, + RecentDecision recentDecision) { + if (configuredType != null + && hasContent(configuredType, currentWork, openQuestion, recentDecision)) { + return new HomeFocusView(configuredType, currentWork, openQuestion, recentDecision); + } + for (String candidate : FALLBACK_ORDER) { + if (hasContent(candidate, currentWork, openQuestion, recentDecision)) { + return new HomeFocusView(candidate, currentWork, openQuestion, recentDecision); + } + } + return new HomeFocusView(FALLBACK_ORDER[0], currentWork, openQuestion, recentDecision); + } + + private static boolean hasContent( + String type, + CurrentWork currentWork, + OpenQuestion openQuestion, + RecentDecision recentDecision) { + return switch (type) { + case "CURRENT_WORK" -> currentWork != null; + case "OPEN_QUESTION" -> openQuestion != null; + case "RECENT_DECISION" -> recentDecision != null; + // 계약 밖 값이 설정에 들어 있는 경우다. 그대로 쓰면 응답이 깨지므로 없는 것으로 친다. + default -> false; + }; + } + + /** 계약 {@code CurrentWorkFocus}. */ + public record CurrentWork( + String projectName, + String projectPath, + String purpose, + String phase, + String currentObjective, + String nextStep, + Instant updatedAt) {} + + /** 계약 {@code OpenQuestionFocus}. */ + public record OpenQuestion( + String question, + String questionPath, + String summary, + List knownFacts, + List unresolvedPoints, + String nextVerification, + Instant updatedAt) { + + public OpenQuestion { + knownFacts = knownFacts == null ? List.of() : List.copyOf(knownFacts); + unresolvedPoints = unresolvedPoints == null ? List.of() : List.copyOf(unresolvedPoints); + } + } + + /** 계약 {@code RecentDecisionFocus}. */ + public record RecentDecision( + String statement, + String decisionPath, + String rationale, + List consequences, + Instant decidedAt) { + + public RecentDecision { + consequences = consequences == null ? List.of() : List.copyOf(consequences); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeView.java new file mode 100644 index 0000000..acfae3c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/HomeView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code HomeResponse}. */ +public record HomeView(HomeFocusView focus, List latestEntries) { + + public HomeView { + latestEntries = latestEntries == null ? List.of() : List.copyOf(latestEntries); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgeListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgeListItemView.java new file mode 100644 index 0000000..230ce4a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgeListItemView.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code KnowledgeListItem}. */ +public record KnowledgeListItemView( + String type, + String title, + String path, + String primarySummary, + String secondarySummary, + TopicSummaryView primaryTopic, + ProjectSummaryView primaryProject, + Instant publishedAt, + Instant lastVerifiedAt, + String freshnessStatus) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgePageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgePageView.java new file mode 100644 index 0000000..04462fc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/KnowledgePageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code KnowledgePage}. */ +public record KnowledgePageView(List items, PageMetadataView page) { + + public KnowledgePageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/LatestEntryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/LatestEntryView.java new file mode 100644 index 0000000..9ac52cc --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/LatestEntryView.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code LatestEntry}. */ +public record LatestEntryView( + String entryType, + String title, + String summary, + String path, + TopicSummaryView primaryTopic, + ProjectSummaryView primaryProject, + Instant publishedAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PageMetadataView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PageMetadataView.java new file mode 100644 index 0000000..2565ee8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PageMetadataView.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** + * 계약 {@code PageMetadata}. 공개 조회는 studio 와 달리 offset 페이지네이션이다 — 계약이 그렇게 정했고, 공개 목록은 "3페이지로 바로 가기"가 + * 필요한 화면이라 cursor 로 대체할 수 없다. + */ +public record PageMetadataView( + int number, + int size, + long totalElements, + int totalPages, + boolean hasPrevious, + boolean hasNext) { + + public static PageMetadataView of(int page, int size, long total) { + int totalPages = size <= 0 ? 0 : (int) Math.ceil((double) total / size); + return new PageMetadataView(page, size, total, totalPages, page > 1, page < totalPages); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProfileView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProfileView.java new file mode 100644 index 0000000..226294d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProfileView.java @@ -0,0 +1,28 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProfileResponse}. */ +public record ProfileView( + String headline, + String description, + List workingModel, + List territories, + List selectedEvidence, + List trajectory, + List contacts) { + + public ProfileView { + workingModel = workingModel == null ? List.of() : List.copyOf(workingModel); + territories = territories == null ? List.of() : List.copyOf(territories); + selectedEvidence = selectedEvidence == null ? List.of() : List.copyOf(selectedEvidence); + trajectory = trajectory == null ? List.of() : List.copyOf(trajectory); + contacts = contacts == null ? List.of() : List.copyOf(contacts); + } + + /** {@code workingModel[]} 과 {@code trajectory[]} 가 같은 모양이라 하나로 쓴다. */ + public record NamedDescription(String name, String description) {} + + /** {@code territories[]}. */ + public record Territory(String name, String currentQuestion, String topicPath) {} +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityItemView.java new file mode 100644 index 0000000..e405d78 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityItemView.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code ProjectActivityItem}. */ +public record ProjectActivityItemView( + String type, String title, String summary, Instant occurredAt, String relatedPath) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityPageView.java new file mode 100644 index 0000000..0139197 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectActivityPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProjectActivityPage}. */ +public record ProjectActivityPageView(List items, PageMetadataView page) { + + public ProjectActivityPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionItemView.java new file mode 100644 index 0000000..e55c431 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionItemView.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.UUID; + +/** 계약 {@code ProjectDecisionItem}. */ +public record ProjectDecisionItemView( + UUID id, + String statement, + String status, + String rationaleSummary, + Instant decidedAt, + RelatedEntryView sourceQuestion, + RelatedEntryView sourceCase) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionPageView.java new file mode 100644 index 0000000..cbd2443 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDecisionPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProjectDecisionPage}. */ +public record ProjectDecisionPageView(List items, PageMetadataView page) { + + public ProjectDecisionPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDetailView.java new file mode 100644 index 0000000..2c4685c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectDetailView.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProjectDetailResponse}. */ +public record ProjectDetailView( + String canonicalPath, + boolean indexable, + PublishedProjectView project, + RelatedEntryView featuredDecision, + RelatedEntryView activeQuestion, + List selectedRecords) { + + public ProjectDetailView { + selectedRecords = selectedRecords == null ? List.of() : List.copyOf(selectedRecords); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectListItemView.java new file mode 100644 index 0000000..ef4ee64 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectListItemView.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code ProjectListItem}. */ +public record ProjectListItemView( + String name, + String slug, + String path, + String oneLinePurpose, + String phase, + String currentObjective, + String nextStep, + Instant updatedAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectRecordPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectRecordPageView.java new file mode 100644 index 0000000..5145083 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectRecordPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ProjectRecordPage}. */ +public record ProjectRecordPageView(List items, PageMetadataView page) { + + public ProjectRecordPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectSummaryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectSummaryView.java new file mode 100644 index 0000000..79c491a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ProjectSummaryView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code ProjectSummary}. */ +public record ProjectSummaryView(String name, String slug, String path) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java new file mode 100644 index 0000000..27bdf31 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedDocumentView.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** + * 공개된 Case / Reference 의 본문과 메타데이터. + * + *

계약의 {@code CaseDetailResponse.case} 와 {@code ReferenceDetailResponse.reference} 는 담는 필드가 + * 다르지만(문제/결론 vs 범위/적용), 원천이 같은 {@code document} + 유형별 detail 이라 하나의 레코드로 읽고 웹 계층에서 유형별 모양으로 나눈다. + * + * @param content Markdown 원문이다. studio 의 렌더 블록이 아니다 — 공개 계약은 {@code contentFormat} 과 함께 원문을 준다. + */ +public record PublishedDocumentView( + String type, + String canonicalPath, + String title, + String primarySummary, + String secondarySummary, + List environmentSummary, + List appliesTo, + List excludedScope, + String freshnessStatus, + String content, + String contentFormat, + int contentFormatVersion, + TopicSummaryView primaryTopic, + List tags, + ProjectSummaryView primaryProject, + AssetReferenceView coverAsset, + Instant publishedAt, + Instant updatedAt, + Instant lastVerifiedAt) { + + public PublishedDocumentView { + environmentSummary = environmentSummary == null ? List.of() : List.copyOf(environmentSummary); + appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo); + excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope); + tags = tags == null ? List.of() : List.copyOf(tags); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java new file mode 100644 index 0000000..b297bff --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedProjectView.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** 계약 {@code ProjectDetailResponse.project}. */ +public record PublishedProjectView( + String name, + String slug, + String oneLinePurpose, + String purpose, + String boundary, + String phase, + String currentObjective, + String nextStep, + String systemOverviewMarkdown, + List technologies, + Instant updatedAt) { + + public PublishedProjectView { + technologies = technologies == null ? List.of() : List.copyOf(technologies); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedQuestionView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedQuestionView.java new file mode 100644 index 0000000..741eaf3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/PublishedQuestionView.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** 계약 {@code QuestionDetailResponse.question}. */ +public record PublishedQuestionView( + String question, + String summary, + String context, + String importance, + String status, + String nextVerification, + QuestionPointGroupView points, + List updates, + String resolutionType, + String resolutionSummary, + Instant resolvedAt, + Instant openedAt, + Instant updatedAt) { + + public PublishedQuestionView { + updates = updates == null ? List.of() : List.copyOf(updates); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionDetailView.java new file mode 100644 index 0000000..d09ff02 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionDetailView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code QuestionDetailResponse}. */ +public record QuestionDetailView( + String canonicalPath, + boolean indexable, + PublishedQuestionView question, + QuestionRelationsView relations) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionListItemView.java new file mode 100644 index 0000000..d206157 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionListItemView.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code QuestionListItem}. */ +public record QuestionListItemView( + String question, + String path, + String status, + String summary, + String currentUnderstanding, + String nextVerification, + ProjectSummaryView primaryProject, + Instant updatedAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPageView.java new file mode 100644 index 0000000..26a7753 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPageView.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code QuestionPage}. */ +public record QuestionPageView(List items, PageMetadataView page) { + + public QuestionPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPointGroupView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPointGroupView.java new file mode 100644 index 0000000..ecf87c5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionPointGroupView.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code QuestionPointGroup}. */ +public record QuestionPointGroupView( + List facts, List assumptions, List unknowns, List constraints) { + + public QuestionPointGroupView { + facts = facts == null ? List.of() : List.copyOf(facts); + assumptions = assumptions == null ? List.of() : List.copyOf(assumptions); + unknowns = unknowns == null ? List.of() : List.copyOf(unknowns); + constraints = constraints == null ? List.of() : List.copyOf(constraints); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionRelationsView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionRelationsView.java new file mode 100644 index 0000000..84d56e9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionRelationsView.java @@ -0,0 +1,15 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code QuestionDetailResponse.relations}. */ +public record QuestionRelationsView( + RelatedEntryView primaryProject, + RelatedEntryView resultCase, + RelatedEntryView producedDecision, + List derivedReferences) { + + public QuestionRelationsView { + derivedReferences = derivedReferences == null ? List.of() : List.copyOf(derivedReferences); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionUpdateView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionUpdateView.java new file mode 100644 index 0000000..b633954 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/QuestionUpdateView.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; + +/** 계약 {@code QuestionUpdatePublic}. 공개된 조사 기록 한 건이다. */ +public record QuestionUpdateView( + String type, String title, String bodyMarkdown, Instant occurredAt) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceDetailView.java new file mode 100644 index 0000000..667da15 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceDetailView.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code ReferenceDetailResponse}. */ +public record ReferenceDetailView( + String canonicalPath, + boolean indexable, + PublishedDocumentView document, + ReferenceRelationsView relations) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceRelationsView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceRelationsView.java new file mode 100644 index 0000000..4784dcd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReferenceRelationsView.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code ReferenceDetailResponse.relations}. Case 의 관계와 이름·구성이 다르다. */ +public record ReferenceRelationsView( + List supportingCases, + List relatedDecisions, + List relatedReferences) { + + public ReferenceRelationsView { + supportingCases = supportingCases == null ? List.of() : List.copyOf(supportingCases); + relatedDecisions = relatedDecisions == null ? List.of() : List.copyOf(relatedDecisions); + relatedReferences = relatedReferences == null ? List.of() : List.copyOf(relatedReferences); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/RelatedEntryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/RelatedEntryView.java new file mode 100644 index 0000000..f335bb2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/RelatedEntryView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code RelatedEntry}. */ +public record RelatedEntryView(String type, String title, String summary, String path) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseDetailView.java new file mode 100644 index 0000000..efa0b2b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseDetailView.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.LocalDate; +import java.util.List; + +/** 계약 {@code ReleaseDetailResponse}. */ +public record ReleaseDetailView( + String version, + String title, + String summary, + LocalDate releasedOn, + List changeTypes, + String reasonMarkdown, + String changesMarkdown, + String userImpactMarkdown, + String implementationImpactMarkdown, + String verificationMarkdown, + String knownLimitationsMarkdown, + List relatedRecords) { + + public ReleaseDetailView { + changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes); + relatedRecords = relatedRecords == null ? List.of() : List.copyOf(relatedRecords); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseListItemView.java new file mode 100644 index 0000000..a3c2286 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/ReleaseListItemView.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.LocalDate; +import java.util.List; + +/** 계약 {@code ReleaseListItem}. */ +public record ReleaseListItemView( + String version, + String title, + String summary, + LocalDate releasedOn, + List changeTypes, + String path) { + + public ReleaseListItemView { + changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultItemView.java new file mode 100644 index 0000000..3138a81 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultItemView.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.time.Instant; +import java.util.List; + +/** 계약 {@code SearchResultItem}. */ +public record SearchResultItemView( + String contentType, + String title, + String path, + String snippet, + List matchedFields, + TopicSummaryView primaryTopic, + ProjectSummaryView primaryProject, + Instant publishedAt, + Instant updatedAt) { + + public SearchResultItemView { + matchedFields = matchedFields == null ? List.of() : List.copyOf(matchedFields); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultPageView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultPageView.java new file mode 100644 index 0000000..c8d20df --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SearchResultPageView.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code SearchResultPage}. */ +public record SearchResultPageView( + String query, List items, PageMetadataView page) { + + public SearchResultPageView { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SiteView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SiteView.java new file mode 100644 index 0000000..abb1256 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/SiteView.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code SiteResponse}. */ +public record SiteView( + String brandTitle, + String identityStatement, + String operatorDisplayName, + String operatorShortIdentity, + AssetReferenceView operatorAvatar, + String operatorProfilePath, + List contacts) { + + public SiteView { + contacts = contacts == null ? List.of() : List.copyOf(contacts); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TagSummaryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TagSummaryView.java new file mode 100644 index 0000000..866c452 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TagSummaryView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code TagSummary}. */ +public record TagSummaryView(String name, String slug) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicDetailView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicDetailView.java new file mode 100644 index 0000000..07d38f0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicDetailView.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +import java.util.List; + +/** 계약 {@code TopicDetailResponse}. */ +public record TopicDetailView( + String name, + String slug, + String description, + String scope, + RelatedEntryView featuredReference, + List featuredCases, + List activeQuestions, + List relatedProjects, + List latestRecords) { + + public TopicDetailView { + featuredCases = featuredCases == null ? List.of() : List.copyOf(featuredCases); + activeQuestions = activeQuestions == null ? List.of() : List.copyOf(activeQuestions); + relatedProjects = relatedProjects == null ? List.of() : List.copyOf(relatedProjects); + latestRecords = latestRecords == null ? List.of() : List.copyOf(latestRecords); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicListItemView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicListItemView.java new file mode 100644 index 0000000..4e21ce6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicListItemView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code TopicListItem}. */ +public record TopicListItemView(String name, String slug, String description, int recordCount) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicSummaryView.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicSummaryView.java new file mode 100644 index 0000000..fb362fe --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/model/TopicSummaryView.java @@ -0,0 +1,4 @@ +package dev.caskeleton.application.techlog.publicsite.model; + +/** 계약 {@code TopicSummary}. */ +public record TopicSummaryView(String name, String slug) {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicDocumentQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicDocumentQueryPort.java new file mode 100644 index 0000000..ef0c331 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicDocumentQueryPort.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import java.util.Optional; + +/** + * 공개된 기록 상세. + * + *

본문은 {@code public_resource_projection.payload}(studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약의 상세 모양은 + * 렌더 모델과 다르다(본문이 블록 배열이 아니라 Markdown 원문이고, environmentSummary 가 배열이며, tags/coverAsset 과 유형별 관계가 따로 + * 있다). projection 은 "무엇이 공개됐는가"와 게시 시각을 정하는 데 쓴다. + */ +public interface PublicDocumentQueryPort { + + Optional findCase(String slug); + + Optional findReference(String slug); + + Optional findQuestion(String slug); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicExploreQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicExploreQueryPort.java new file mode 100644 index 0000000..a58d083 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicExploreQueryPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; + +/** 탐색 목록. 공개된 것만 본다 — {@code public_resource_projection.publication_state = 'ACTIVE'}. */ +public interface PublicExploreQueryPort { + + KnowledgePageView knowledge(ExploreKnowledgeQuery query); + + QuestionPageView questions(ExploreQuestionsQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicProjectQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicProjectQueryPort.java new file mode 100644 index 0000000..76c5263 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicProjectQueryPort.java @@ -0,0 +1,27 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import java.util.List; +import java.util.Optional; + +/** 프로젝트 목록·상세와 그 하위 목록. */ +public interface PublicProjectQueryPort { + + List list(); + + Optional findBySlug(String slug); + + /** 프로젝트가 없으면 {@link Optional#empty()} — 빈 페이지와 404 를 호출자가 구분해야 한다. */ + Optional decisions(ProjectDecisionPageQuery query); + + Optional records(ProjectRecordPageQuery query); + + Optional activities(ProjectPageQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicReleaseQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicReleaseQueryPort.java new file mode 100644 index 0000000..e6ba7bb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicReleaseQueryPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import java.util.List; +import java.util.Optional; + +/** 릴리스 목록·상세. 공개된 것({@code workflow_status = 'PUBLISHED'})만 본다. */ +public interface PublicReleaseQueryPort { + + List list(); + + Optional findByVersion(String version); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSearchQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSearchQueryPort.java new file mode 100644 index 0000000..7a4ef5c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSearchQueryPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; + +/** 공개 검색. {@code public_resource_projection.search_text} 를 본다. */ +@FunctionalInterface +public interface PublicSearchQueryPort { + + SearchResultPageView search(SearchQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSiteQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSiteQueryPort.java new file mode 100644 index 0000000..a470e5d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicSiteQueryPort.java @@ -0,0 +1,22 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import java.util.Optional; + +/** + * 사이트 정체성 · 홈 · 프로필. + * + *

이 셋의 원천은 단일 행 테이블({@code site_config} / {@code home_focus_config} / {@code profile_page})이고 편집 + * API 는 {@code studio-management-v1}(범위 밖)이 소유한다. 지금은 V9 의 시딩이 유일한 공급원이며, 프로필은 공개로 전환되기 전까지 비어 있을 수 + * 있어 {@link Optional} 이다. + */ +public interface PublicSiteQueryPort { + + Optional site(); + + HomeView home(int latestEntryLimit); + + Optional profile(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicTopicQueryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicTopicQueryPort.java new file mode 100644 index 0000000..2db73f2 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/port/out/PublicTopicQueryPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.port.out; + +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import java.util.List; +import java.util.Optional; + +/** 주제 목록·상세. */ +public interface PublicTopicQueryPort { + + List list(); + + Optional findBySlug(String slug); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/EmptyQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/EmptyQuery.java new file mode 100644 index 0000000..c7b5c1a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/EmptyQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** 파라미터가 없는 조회({@code getPublicSite} / {@code getPublicHome} / 목록 전체 등). */ +public record EmptyQuery() implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreKnowledgeQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreKnowledgeQuery.java new file mode 100644 index 0000000..6c373b7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreKnowledgeQuery.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** + * {@code exploreKnowledge} 의 입력. + * + * @param type CASE / REFERENCE. null 이면 둘 다 + * @param sort PUBLISHED_DESC / UPDATED_DESC / VERIFIED_DESC + */ +public record ExploreKnowledgeQuery( + String type, + String topicSlug, + String projectSlug, + String tagSlug, + Integer year, + String sort, + PublicPageRequest page) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreQuestionsQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreQuestionsQuery.java new file mode 100644 index 0000000..ea9fe5a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ExploreQuestionsQuery.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** + * {@code exploreQuestions} 의 입력. + * + * @param status OPEN / INVESTIGATING / PAUSED / RESOLVED. null 이면 전부 + * @param sort UPDATED_DESC / OPENED_DESC / RESOLVED_DESC + */ +public record ExploreQuestionsQuery( + String status, + String topicSlug, + String projectSlug, + String tagSlug, + String sort, + PublicPageRequest page) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectDecisionPageQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectDecisionPageQuery.java new file mode 100644 index 0000000..46ecc6e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectDecisionPageQuery.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** + * {@code listPublicProjectDecisions} 의 입력. + * + *

계약이 이 operation 에만 주는 {@code status} 필터 때문에 {@link ProjectPageQuery} 와 나눈다 — 하나의 record 에 세 + * operation 의 필터를 다 담으면 어느 필드가 어느 operation 에서 무시되는지 타입으로 알 수 없다. + * + * @param status 계약이 enum 을 두지 않은 자유 문자열이다 — 도메인의 decision lifecycle 값이 그대로 들어온다 + */ +public record ProjectDecisionPageQuery(String projectSlug, String status, PublicPageRequest page) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectPageQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectPageQuery.java new file mode 100644 index 0000000..0bebb81 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectPageQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** {@code listPublicProjectActivities} 의 입력. 계약이 이 operation 에는 필터를 두지 않았다. */ +public record ProjectPageQuery(String projectSlug, PublicPageRequest page) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectRecordPageQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectRecordPageQuery.java new file mode 100644 index 0000000..b7913ef --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/ProjectRecordPageQuery.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** + * {@code listPublicProjectRecords} 의 입력. + * + * @param type CASE / REFERENCE / QUESTION. null 이면 셋 다 + * @param relation PRIMARY / RELATED. null 이면 둘 다 + */ +public record ProjectRecordPageQuery( + String projectSlug, String type, String relation, PublicPageRequest page) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/PublicPageRequest.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/PublicPageRequest.java new file mode 100644 index 0000000..56d644a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/PublicPageRequest.java @@ -0,0 +1,28 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; + +/** + * 계약이 모든 목록에 쓰는 {@code page}/{@code size}. 1-based 다. + * + *

검증을 한 곳에 모은 이유는 여섯 개 목록 operation 이 같은 규칙을 쓰기 때문이다 — 각자 검사하면 어느 하나가 한계를 빠뜨려도 드러나지 않는다. + */ +public record PublicPageRequest(int page, int size) { + + private static final int MAX_SIZE = 100; + + public PublicPageRequest { + if (page < 1) { + throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "page must be at least 1"); + } + if (size < 1 || size > MAX_SIZE) { + throw PublicException.of( + PublicError.PUBLIC_REQUEST_INVALID, "size must be between 1 and " + MAX_SIZE); + } + } + + public int offset() { + return (page - 1) * size; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SearchQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SearchQuery.java new file mode 100644 index 0000000..c34fec4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SearchQuery.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** {@code searchPublicResources} 의 입력. */ +public record SearchQuery(String query, String type, String topicSlug, PublicPageRequest page) + implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SlugQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SlugQuery.java new file mode 100644 index 0000000..2a14b33 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/query/SlugQuery.java @@ -0,0 +1,6 @@ +package dev.caskeleton.application.techlog.publicsite.query; + +import dev.caskeleton.application.query.Query; + +/** slug 하나로 조회하는 operation 들의 공통 입력. */ +public record SlugQuery(String slug) implements Query {} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreKnowledgeUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreKnowledgeUseCase.java new file mode 100644 index 0000000..4ab4487 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreKnowledgeUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code exploreKnowledge}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ExploreKnowledgeUseCase + implements QueryUseCase { + + private final PublicExploreQueryPort port; + private final TransactionPort transactions; + + public ExploreKnowledgeUseCase(PublicExploreQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public KnowledgePageView handle(ExploreKnowledgeQuery input) { + return transactions.inRead(() -> port.knowledge(input)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreQuestionsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreQuestionsUseCase.java new file mode 100644 index 0000000..0418ea8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ExploreQuestionsUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code exploreQuestions}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ExploreQuestionsUseCase + implements QueryUseCase { + + private final PublicExploreQueryPort port; + private final TransactionPort transactions; + + public ExploreQuestionsUseCase(PublicExploreQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public QuestionPageView handle(ExploreQuestionsQuery input) { + return transactions.inRead(() -> port.questions(input)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicCaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicCaseUseCase.java new file mode 100644 index 0000000..5deecd1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicCaseUseCase.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicCase}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicCaseUseCase implements QueryUseCase { + + private final PublicDocumentQueryPort port; + private final TransactionPort transactions; + + public GetPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public CaseDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> port.findCase(slug).orElseThrow(() -> PublicReadUseCases.notFound("case " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicHomeUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicHomeUseCase.java new file mode 100644 index 0000000..79e8fd0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicHomeUseCase.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.HomeView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicHome}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicHomeUseCase implements QueryUseCase { + + /** 계약 {@code HomeResponse.latestEntries} 는 최신 목록이며 화면이 한 화면에 담는 개수다. */ + private static final int LATEST_ENTRY_LIMIT = 10; + + private final PublicSiteQueryPort port; + private final TransactionPort transactions; + + public GetPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public HomeView handle(EmptyQuery input) { + return transactions.inRead(() -> port.home(LATEST_ENTRY_LIMIT)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProfileUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProfileUseCase.java new file mode 100644 index 0000000..14f994a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProfileUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProfileView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicProfile}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicProfileUseCase implements QueryUseCase { + + private final PublicSiteQueryPort port; + private final TransactionPort transactions; + + public GetPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProfileView handle(EmptyQuery input) { + return transactions.inRead( + () -> port.profile().orElseThrow(() -> PublicReadUseCases.notFound("the profile page"))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProjectUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProjectUseCase.java new file mode 100644 index 0000000..4e28f0e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicProjectUseCase.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicProject}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicProjectUseCase implements QueryUseCase { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public GetPublicProjectUseCase(PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProjectDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findBySlug(slug) + .orElseThrow(() -> PublicReadUseCases.notFound("project " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicQuestionUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicQuestionUseCase.java new file mode 100644 index 0000000..4798885 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicQuestionUseCase.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicQuestion}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicQuestionUseCase implements QueryUseCase { + + private final PublicDocumentQueryPort port; + private final TransactionPort transactions; + + public GetPublicQuestionUseCase(PublicDocumentQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public QuestionDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findQuestion(slug) + .orElseThrow(() -> PublicReadUseCases.notFound("question " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReferenceUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReferenceUseCase.java new file mode 100644 index 0000000..622bdee --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReferenceUseCase.java @@ -0,0 +1,42 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicReference}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicReferenceUseCase + implements QueryUseCase { + + private final PublicDocumentQueryPort port; + private final TransactionPort transactions; + + public GetPublicReferenceUseCase(PublicDocumentQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ReferenceDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findReference(slug) + .orElseThrow(() -> PublicReadUseCases.notFound("reference " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReleaseUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReleaseUseCase.java new file mode 100644 index 0000000..e68b30f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicReleaseUseCase.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicRelease}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicReleaseUseCase implements QueryUseCase { + + private final PublicReleaseQueryPort port; + private final TransactionPort transactions; + + public GetPublicReleaseUseCase(PublicReleaseQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ReleaseDetailView handle(SlugQuery input) { + String version = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findByVersion(version) + .orElseThrow(() -> PublicReadUseCases.notFound("release " + version))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicSiteUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicSiteUseCase.java new file mode 100644 index 0000000..01a76dd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicSiteUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.SiteView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicSite}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicSiteUseCase implements QueryUseCase { + + private final PublicSiteQueryPort port; + private final TransactionPort transactions; + + public GetPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public SiteView handle(EmptyQuery input) { + return transactions.inRead( + () -> port.site().orElseThrow(() -> PublicReadUseCases.notFound("the site profile"))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicTopicUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicTopicUseCase.java new file mode 100644 index 0000000..800470e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/GetPublicTopicUseCase.java @@ -0,0 +1,40 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SlugQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code getPublicTopic}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class GetPublicTopicUseCase implements QueryUseCase { + + private final PublicTopicQueryPort port; + private final TransactionPort transactions; + + public GetPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public TopicDetailView handle(SlugQuery input) { + String slug = PublicReadUseCases.requireSlug(input.slug()); + return transactions.inRead( + () -> + port.findBySlug(slug).orElseThrow(() -> PublicReadUseCases.notFound("topic " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectActivitiesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectActivitiesUseCase.java new file mode 100644 index 0000000..0ad3fdf --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectActivitiesUseCase.java @@ -0,0 +1,43 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code listPublicProjectActivities}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicProjectActivitiesUseCase + implements QueryUseCase { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public ListPublicProjectActivitiesUseCase( + PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProjectActivityPageView handle(ProjectPageQuery input) { + String slug = PublicReadUseCases.requireSlug(input.projectSlug()); + return transactions.inRead( + () -> + port.activities(input) + .orElseThrow(() -> PublicReadUseCases.notFound("project " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectDecisionsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectDecisionsUseCase.java new file mode 100644 index 0000000..7ae20cd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectDecisionsUseCase.java @@ -0,0 +1,43 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code listPublicProjectDecisions}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicProjectDecisionsUseCase + implements QueryUseCase { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public ListPublicProjectDecisionsUseCase( + PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProjectDecisionPageView handle(ProjectDecisionPageQuery input) { + String slug = PublicReadUseCases.requireSlug(input.projectSlug()); + return transactions.inRead( + () -> + port.decisions(input) + .orElseThrow(() -> PublicReadUseCases.notFound("project " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectRecordsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectRecordsUseCase.java new file mode 100644 index 0000000..a4fc929 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectRecordsUseCase.java @@ -0,0 +1,42 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code listPublicProjectRecords}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicProjectRecordsUseCase + implements QueryUseCase { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public ListPublicProjectRecordsUseCase( + PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public ProjectRecordPageView handle(ProjectRecordPageQuery input) { + String slug = PublicReadUseCases.requireSlug(input.projectSlug()); + return transactions.inRead( + () -> + port.records(input).orElseThrow(() -> PublicReadUseCases.notFound("project " + slug))); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectsUseCase.java new file mode 100644 index 0000000..93a1bc7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicProjectsUseCase.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.List; +import java.util.Objects; + +/** + * 계약 {@code listPublicProjects}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicProjectsUseCase + implements QueryUseCase> { + + private final PublicProjectQueryPort port; + private final TransactionPort transactions; + + public ListPublicProjectsUseCase(PublicProjectQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public List handle(EmptyQuery input) { + return transactions.inRead(port::list); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicReleasesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicReleasesUseCase.java new file mode 100644 index 0000000..cb1540b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicReleasesUseCase.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.List; +import java.util.Objects; + +/** + * 계약 {@code listPublicReleases}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicReleasesUseCase + implements QueryUseCase> { + + private final PublicReleaseQueryPort port; + private final TransactionPort transactions; + + public ListPublicReleasesUseCase(PublicReleaseQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public List handle(EmptyQuery input) { + return transactions.inRead(port::list); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicTopicsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicTopicsUseCase.java new file mode 100644 index 0000000..86ecb97 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/ListPublicTopicsUseCase.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.List; +import java.util.Objects; + +/** + * 계약 {@code listPublicTopics}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class ListPublicTopicsUseCase + implements QueryUseCase> { + + private final PublicTopicQueryPort port; + private final TransactionPort transactions; + + public ListPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public List handle(EmptyQuery input) { + return transactions.inRead(port::list); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/PublicReadUseCases.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/PublicReadUseCases.java new file mode 100644 index 0000000..c46c96e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/PublicReadUseCases.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.techlog.publicsite.error.PublicError; +import dev.caskeleton.application.techlog.publicsite.error.PublicException; + +/** + * 공개 조회 use case 들이 공유하는 조각. + * + *

18개 operation 이 전부 읽기 전용이고 도메인 규칙이 없다. 각자 "없으면 404" 를 따로 쓰면 문구와 코드가 갈라지므로 한 곳에 둔다. + */ +final class PublicReadUseCases { + + private PublicReadUseCases() {} + + static PublicException notFound(String what) { + return PublicException.of(PublicError.PUBLIC_RESOURCE_NOT_FOUND, what + " is not published"); + } + + static String requireSlug(String slug) { + if (slug == null || slug.isBlank()) { + throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "a slug is required"); + } + return slug; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/SearchPublicResourcesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/SearchPublicResourcesUseCase.java new file mode 100644 index 0000000..cd5ffd3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/publicsite/service/SearchPublicResourcesUseCase.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.techlog.publicsite.service; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView; +import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort; +import dev.caskeleton.application.techlog.publicsite.query.SearchQuery; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** + * 계약 {@code searchPublicResources}. 공개 조회이며 인증이 없다. + * + *

공개된 것만 보여준다 — 어떤 경로로도 Working Copy 나 검증/미리보기 artifact 에 닿지 않는다 (계약 서문 "이 계약이 반환하지 않는 것"). + */ +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY) +public final class SearchPublicResourcesUseCase + implements QueryUseCase { + + private final PublicSearchQueryPort port; + private final TransactionPort transactions; + + public SearchPublicResourcesUseCase(PublicSearchQueryPort port, TransactionPort transactions) { + this.port = Objects.requireNonNull(port, "port"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + } + + @Override + public SearchResultPageView handle(SearchQuery input) { + return transactions.inRead(() -> port.search(input)); + } +} diff --git a/src/config/openapi/MANIFEST.sha256 b/src/config/openapi/MANIFEST.sha256 index 9315d6e..f7c25bb 100644 --- a/src/config/openapi/MANIFEST.sha256 +++ b/src/config/openapi/MANIFEST.sha256 @@ -1,2 +1,4 @@ # 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 diff --git a/src/config/openapi/public-v1.yaml b/src/config/openapi/public-v1.yaml new file mode 100644 index 0000000..9e19c10 --- /dev/null +++ b/src/config/openapi/public-v1.yaml @@ -0,0 +1,2104 @@ +openapi: 3.1.0 +info: + title: Tech Log Public API + version: 2.0.0 + description: | + Tech Log 공개 조회 계약이다. 인증이 필요하지 않다. + + ## 응답 봉투 (ADR-006) + + 모든 응답은 `{success, data|error, meta}` 봉투다. v1.0.0 은 bare payload + + RFC 7807 ProblemDetails 였고, ADR-006 이 그 둘을 함께 쓰지 않기로 정했으므로 + 구현 착수와 함께 studio-v1.yaml 과 같은 방식으로 변환했다. + + 오류 본문은 `application/problem+json` 이 아니라 `application/json` + + `ErrorEnvelope` 다. `error.code` 가 클라이언트가 분기하는 값이며 HTTP status 는 + 그 코드의 부수 정보다. + + ## UI route와 API route는 같을 필요가 없다 + + Frontend의 `/explore/:kind`는 화면 route다. 이 계약의 endpoint와 URL 구조를 + 억지로 일치시키지 않는다. + + | Frontend 화면 route | 호출하는 operation | + |---|---| + | `/explore` | `exploreKnowledge` + `exploreQuestions` | + | `/explore/cases` | `exploreKnowledge` (`type=CASE`) | + | `/explore/references` | `exploreKnowledge` (`type=REFERENCE`) | + | `/explore/questions` | `exploreQuestions` | + + ## 이 계약이 반환하지 않는 것 + + - Working Copy: Studio 계약(`studio-v1.yaml`)만 반환한다. + - Validation / Preview artifact: 인증된 Studio 계약에만 존재한다. + - Publication Event / Snapshot 이력: Studio 계약이 소유한다. + + Public이 노출하는 것은 현재 ACTIVE Public Projection뿐이다. 과거 게시 시점 + 화면은 Studio의 immutable Publication Snapshot이 책임진다. + + ## Asset + + 본문의 Evidence Figure는 managed `assetKey`로 해석된다. object storage URL이 + 콘텐츠 원문에 들어가지 않으며, 응답은 검증된 delivery path만 제공한다. + SVG도 URL 기반 ``로 렌더링하고 원문을 inline하지 않는다. +servers: +- url: /api/v1/public +tags: +- name: Site +- name: Home +- name: Explore +- name: Topic +- name: Case +- name: Reference +- name: Question +- name: Project +- name: Release +- name: Profile +- name: Search +paths: + /site: + get: + operationId: getPublicSite + tags: + - Site + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SiteResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /home: + get: + operationId: getPublicHome + tags: + - Home + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HomeResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /explore/knowledge: + get: + operationId: exploreKnowledge + description: | + Frontend의 `/explore`, `/explore/cases`, `/explore/references` 화면이 + 호출한다. 화면 route별로 별도 endpoint를 만들지 않고 `type` 필터로 구분한다. + + ```text + /explore/cases → type=CASE + /explore/references → type=REFERENCE + /explore → type 생략 + ``` + tags: + - Explore + parameters: + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - name: topic + in: query + schema: + type: string + - name: project + in: query + schema: + type: string + - name: tag + in: query + schema: + type: string + - name: year + in: query + schema: + type: integer + minimum: 2000 + - name: sort + in: query + schema: + type: string + enum: + - PUBLISHED_DESC + - UPDATED_DESC + - VERIFIED_DESC + default: PUBLISHED_DESC + - &id001 + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - &id002 + name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/KnowledgePageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /explore/questions: + get: + operationId: exploreQuestions + description: | + Frontend의 `/explore/questions` 화면이 호출한다. + + 여기의 공개 상태는 `OPEN`/`RESOLVED` 축약 표현이다. Backend Inquiry의 + `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현되며 `ARCHIVED`는 공개되지 않는다. + Domain lifecycle 자체는 축소되지 않는다. + tags: + - Explore + parameters: + - name: status + in: query + schema: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + - name: topic + in: query + schema: + type: string + - name: project + in: query + schema: + type: string + - name: tag + in: query + schema: + type: string + - name: sort + in: query + schema: + type: string + enum: + - UPDATED_DESC + - OPENED_DESC + - RESOLVED_DESC + default: UPDATED_DESC + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /topics: + get: + operationId: listPublicTopics + tags: + - Topic + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /topics/{topicSlug}: + get: + operationId: getPublicTopic + tags: + - Topic + parameters: + - name: topicSlug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TopicDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /cases/{slug}: + get: + operationId: getPublicCase + tags: + - Case + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CaseDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /references/{slug}: + get: + operationId: getPublicReference + tags: + - Reference + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReferenceDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /questions/{slug}: + get: + operationId: getPublicQuestion + tags: + - Question + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects: + get: + operationId: listPublicProjects + tags: + - Project + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}: + get: + operationId: getPublicProject + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/decisions: + get: + operationId: listPublicProjectDecisions + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: status + in: query + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDecisionPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/records: + get: + operationId: listPublicProjectRecords + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - name: relation + in: query + schema: + type: string + enum: + - PRIMARY + - RELATED + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectRecordPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /projects/{slug}/activities: + get: + operationId: listPublicProjectActivities + tags: + - Project + parameters: + - name: slug + in: path + required: true + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectActivityPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /releases: + get: + operationId: listPublicReleases + tags: + - Release + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseListResponseEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /releases/{version}: + get: + operationId: getPublicRelease + tags: + - Release + parameters: + - name: version + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseDetailResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /profile: + get: + operationId: getPublicProfile + tags: + - Profile + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileResponseEnvelope' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + /search: + get: + operationId: searchPublicResources + tags: + - Search + parameters: + - name: q + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 100 + - name: type + in: query + schema: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - PROJECT + - RELEASE + - name: topic + in: query + schema: + type: string + - *id001 + - *id002 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SearchResultPageEnvelope' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' +components: + schemas: + FieldError: + type: object + required: + - field + - code + - message + properties: + field: + type: string + code: + type: string + message: + type: string + PageMetadata: + type: object + required: + - number + - size + - totalElements + - totalPages + - hasPrevious + - hasNext + properties: + number: + type: integer + minimum: 1 + size: + type: integer + minimum: 1 + maximum: 100 + totalElements: + type: integer + format: int64 + minimum: 0 + totalPages: + type: integer + minimum: 0 + hasPrevious: + type: boolean + hasNext: + type: boolean + TopicSummary: + type: object + required: + - name + - slug + properties: + name: + type: string + slug: + type: string + TagSummary: + type: object + required: + - name + - slug + properties: + name: + type: string + slug: + type: string + ProjectSummary: + type: object + required: + - name + - slug + - path + properties: + name: + type: string + slug: + type: string + path: + type: string + AssetReference: + type: object + required: + - assetId + - url + properties: + assetId: &id004 + type: string + format: uuid + url: + type: string + altText: + type: string + width: + type: integer + height: + type: integer + contentType: + type: string + RelatedEntry: + type: object + required: + - type + - title + - path + properties: + type: + type: string + enum: + - CASE + - REFERENCE + - QUESTION + - PROJECT + - PROJECT_DECISION + - RELEASE + title: + type: string + summary: + type: string + path: + type: string + ContactLink: + type: object + required: + - type + - label + - url + properties: + type: + type: string + label: + type: string + url: + type: string + format: uri + SiteResponse: + type: object + required: + - brand + - operator + - contacts + properties: + brand: + type: object + required: + - title + - identityStatement + properties: + title: + type: string + identityStatement: + type: string + operator: + type: object + required: + - displayName + - profilePath + properties: + displayName: + type: string + shortIdentity: + type: string + avatar: + $ref: '#/components/schemas/AssetReference' + profilePath: + type: string + contacts: + type: array + items: + $ref: '#/components/schemas/ContactLink' + CurrentWorkFocus: + type: object + required: + - projectName + - projectPath + - purpose + - phase + - currentObjective + - updatedAt + properties: + projectName: + type: string + projectPath: + type: string + purpose: + type: string + phase: + type: string + enum: + - RESEARCH + - DESIGN + - IMPLEMENTATION + - VERIFICATION + - MAINTENANCE + - PAUSED + - COMPLETED + currentObjective: + type: string + nextStep: + type: string + updatedAt: &id003 + type: string + format: date-time + OpenQuestionFocus: + type: object + required: + - question + - questionPath + - summary + - knownFacts + - unresolvedPoints + - nextVerification + - updatedAt + properties: + question: + type: string + questionPath: + type: string + summary: + type: string + knownFacts: + type: array + items: + type: string + unresolvedPoints: + type: array + items: + type: string + nextVerification: + type: string + updatedAt: *id003 + RecentDecisionFocus: + type: object + required: + - statement + - decisionPath + - rationale + - consequences + - decidedAt + properties: + statement: + type: string + decisionPath: + type: string + rationale: + type: string + consequences: + type: array + items: + type: string + decidedAt: *id003 + LatestEntry: + type: object + required: + - entryType + - title + - summary + - path + - publishedAt + properties: + entryType: + type: string + enum: + - CASE + - REFERENCE + - PROJECT_ACTIVITY + - RELEASE + title: + type: string + summary: + type: string + path: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + HomeResponse: + type: object + required: + - focus + - latestEntries + properties: + focus: + type: object + required: + - defaultType + properties: + defaultType: + type: string + enum: + - CURRENT_WORK + - OPEN_QUESTION + - RECENT_DECISION + currentWork: + $ref: '#/components/schemas/CurrentWorkFocus' + openQuestion: + $ref: '#/components/schemas/OpenQuestionFocus' + recentDecision: + $ref: '#/components/schemas/RecentDecisionFocus' + latestEntries: + type: array + maxItems: 6 + items: + $ref: '#/components/schemas/LatestEntry' + KnowledgeListItem: + type: object + required: + - type + - title + - path + - primarySummary + - publishedAt + properties: + type: + type: string + enum: + - CASE + - REFERENCE + title: + type: string + path: + type: string + primarySummary: + type: string + secondarySummary: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + lastVerifiedAt: *id003 + freshnessStatus: + type: string + KnowledgePage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/KnowledgeListItem' + page: + $ref: '#/components/schemas/PageMetadata' + QuestionListItem: + type: object + required: + - question + - path + - status + - summary + - updatedAt + properties: + question: + type: string + path: + type: string + status: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + summary: + type: string + currentUnderstanding: + type: string + nextVerification: + type: string + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + updatedAt: *id003 + QuestionPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/QuestionListItem' + page: + $ref: '#/components/schemas/PageMetadata' + TopicListItem: + type: object + required: + - name + - slug + - description + properties: + name: + type: string + slug: + type: string + description: + type: string + recordCount: + type: integer + TopicListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/TopicListItem' + TopicDetailResponse: + type: object + required: + - topic + - featuredCases + - activeQuestions + - relatedProjects + - latestRecords + properties: + topic: + type: object + required: + - name + - slug + - description + properties: + name: + type: string + slug: + type: string + description: + type: string + scope: + type: string + featuredReference: + $ref: '#/components/schemas/RelatedEntry' + featuredCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + activeQuestions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedProjects: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + latestRecords: + type: array + items: + $ref: '#/components/schemas/LatestEntry' + CaseDetailResponse: + type: object + required: + - canonicalPath + - case + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + case: + type: object + required: + - title + - problemSummary + - conclusionSummary + - content + - contentFormat + - contentFormatVersion + - tags + - publishedAt + - updatedAt + properties: + title: + type: string + problemSummary: + type: string + conclusionSummary: + type: string + environmentSummary: + type: array + items: + type: string + content: + type: string + contentFormat: + type: string + enum: + - MARKDOWN + contentFormatVersion: + type: integer + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + coverAsset: + $ref: '#/components/schemas/AssetReference' + publishedAt: *id003 + updatedAt: *id003 + lastVerifiedAt: *id003 + relations: + type: object + required: + - projectDecisions + - derivedReferences + - relatedCases + properties: + originQuestion: + $ref: '#/components/schemas/RelatedEntry' + projectDecisions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + derivedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ReferenceDetailResponse: + type: object + required: + - canonicalPath + - reference + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + reference: + type: object + required: + - title + - scopeSummary + - appliesTo + - excludedScope + - freshnessStatus + - content + - contentFormat + - contentFormatVersion + - tags + - publishedAt + - updatedAt + properties: + title: + type: string + scopeSummary: + type: string + appliesTo: + type: array + items: + type: string + excludedScope: + type: array + items: + type: string + freshnessStatus: + type: string + enum: + - CURRENT + - REVIEW_DUE + - HISTORICAL + content: + type: string + contentFormat: + type: string + enum: + - MARKDOWN + contentFormatVersion: + type: integer + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + tags: + type: array + items: + $ref: '#/components/schemas/TagSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + coverAsset: + $ref: '#/components/schemas/AssetReference' + publishedAt: *id003 + updatedAt: *id003 + lastVerifiedAt: *id003 + relations: + type: object + required: + - supportingCases + - relatedDecisions + - relatedReferences + properties: + supportingCases: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedDecisions: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + relatedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + QuestionPointGroup: + type: object + required: + - facts + - assumptions + - unknowns + - constraints + properties: + facts: + type: array + items: + type: string + assumptions: + type: array + items: + type: string + unknowns: + type: array + items: + type: string + constraints: + type: array + items: + type: string + QuestionUpdatePublic: + type: object + required: + - type + - title + - bodyMarkdown + - occurredAt + properties: + type: + type: string + title: + type: string + bodyMarkdown: + type: string + occurredAt: *id003 + QuestionDetailResponse: + type: object + required: + - canonicalPath + - indexable + - question + - relations + properties: + canonicalPath: + type: string + indexable: + type: boolean + question: + type: object + required: + - question + - summary + - context + - importance + - status + - points + - updates + - openedAt + - updatedAt + properties: + question: + type: string + summary: + type: string + context: + type: string + importance: + type: string + status: + type: string + enum: + - OPEN + - INVESTIGATING + - PAUSED + - RESOLVED + nextVerification: + type: string + points: + $ref: '#/components/schemas/QuestionPointGroup' + updates: + type: array + items: + $ref: '#/components/schemas/QuestionUpdatePublic' + resolution: + type: object + properties: + type: + type: string + summary: + type: string + resolvedAt: *id003 + openedAt: *id003 + updatedAt: *id003 + relations: + type: object + required: + - derivedReferences + properties: + primaryProject: + $ref: '#/components/schemas/RelatedEntry' + resultCase: + $ref: '#/components/schemas/RelatedEntry' + producedDecision: + $ref: '#/components/schemas/RelatedEntry' + derivedReferences: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProjectListItem: + type: object + required: + - name + - slug + - path + - oneLinePurpose + - phase + - updatedAt + properties: + name: + type: string + slug: + type: string + path: + type: string + oneLinePurpose: + type: string + phase: + type: string + currentObjective: + type: string + nextStep: + type: string + updatedAt: *id003 + ProjectListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectListItem' + ProjectDetailResponse: + type: object + required: + - canonicalPath + - project + - selectedRecords + properties: + canonicalPath: + type: string + indexable: + type: boolean + default: true + project: + type: object + required: + - name + - slug + - purpose + - boundary + - phase + - oneLinePurpose + - updatedAt + properties: + name: + type: string + slug: + type: string + oneLinePurpose: + type: string + purpose: + type: string + boundary: + type: string + phase: + type: string + currentObjective: + type: string + nextStep: + type: string + systemOverviewMarkdown: + type: string + technologies: + type: array + items: + type: string + updatedAt: *id003 + featuredDecision: + $ref: '#/components/schemas/RelatedEntry' + activeQuestion: + $ref: '#/components/schemas/RelatedEntry' + selectedRecords: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProjectDecisionItem: + type: object + required: + - id + - statement + - status + - decidedAt + properties: + id: *id004 + statement: + type: string + status: + type: string + rationaleSummary: + type: string + decidedAt: *id003 + sourceQuestion: + $ref: '#/components/schemas/RelatedEntry' + sourceCase: + $ref: '#/components/schemas/RelatedEntry' + ProjectDecisionPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectDecisionItem' + page: + $ref: '#/components/schemas/PageMetadata' + ProjectRecordPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + page: + $ref: '#/components/schemas/PageMetadata' + ProjectActivityItem: + type: object + required: + - type + - title + - occurredAt + properties: + type: + type: string + title: + type: string + summary: + type: string + occurredAt: *id003 + relatedPath: + type: string + ProjectActivityPage: + type: object + required: + - items + - page + properties: + items: + type: array + items: + $ref: '#/components/schemas/ProjectActivityItem' + page: + $ref: '#/components/schemas/PageMetadata' + ReleaseListItem: + type: object + required: + - version + - title + - summary + - releasedOn + - changeTypes + - path + properties: + version: + type: string + title: + type: string + summary: + type: string + releasedOn: &id005 + type: string + format: date + changeTypes: + type: array + items: + type: string + path: + type: string + ReleaseListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/ReleaseListItem' + ReleaseDetailResponse: + type: object + required: + - version + - title + - summary + - releasedOn + - changeTypes + - changesMarkdown + - verificationMarkdown + - relatedRecords + properties: + version: + type: string + title: + type: string + summary: + type: string + releasedOn: *id005 + changeTypes: + type: array + items: + type: string + reasonMarkdown: + type: string + changesMarkdown: + type: string + userImpactMarkdown: + type: string + implementationImpactMarkdown: + type: string + verificationMarkdown: + type: string + knownLimitationsMarkdown: + type: string + relatedRecords: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + ProfileResponse: + type: object + required: + - position + - workingModel + - territories + - selectedEvidence + - trajectory + - contacts + properties: + position: + type: object + required: + - headline + - description + properties: + headline: + type: string + description: + type: string + workingModel: + type: array + items: + type: object + required: + - name + - description + properties: + name: + type: string + description: + type: string + territories: + type: array + items: + type: object + required: + - name + properties: + name: + type: string + currentQuestion: + type: string + topicPath: + type: string + selectedEvidence: + type: array + items: + $ref: '#/components/schemas/RelatedEntry' + trajectory: + type: array + items: + type: object + required: + - title + - description + properties: + title: + type: string + description: + type: string + contacts: + type: array + items: + $ref: '#/components/schemas/ContactLink' + SearchResultItem: + type: object + required: + - contentType + - title + - path + - snippet + - matchedFields + properties: + contentType: + type: string + title: + type: string + path: + type: string + snippet: + type: string + matchedFields: + type: array + items: + type: string + primaryTopic: + $ref: '#/components/schemas/TopicSummary' + primaryProject: + $ref: '#/components/schemas/ProjectSummary' + publishedAt: *id003 + updatedAt: *id003 + SearchResultPage: + type: object + required: + - query + - items + - page + properties: + query: + type: string + items: + type: array + items: + $ref: '#/components/schemas/SearchResultItem' + page: + $ref: '#/components/schemas/PageMetadata' + ResponseMeta: + type: object + additionalProperties: false + required: + - requestId + - traceId + properties: + requestId: + type: string + minLength: 1 + maxLength: 200 + traceId: + type: string + minLength: 1 + maxLength: 200 + correlationId: + type: + - string + - 'null' + maxLength: 200 + page: + type: + - object + - 'null' + additionalProperties: true + description: offset 페이지네이션 정보는 각 페이지 payload 의 `page` 필드가 소유한다. 이 필드는 백엔드 템플릿의 ResponseMeta record 가 직렬화하는 자리이며 공개 조회에서는 항상 null 이다. + ValidationErrorDetails: + type: object + additionalProperties: false + required: + - fieldErrors + properties: + fieldErrors: + type: array + maxItems: 200 + items: + $ref: '#/components/schemas/FieldError' + ApiError: + type: object + additionalProperties: false + required: + - code + - category + - message + - retryable + properties: + code: + type: string + enum: + - PUBLIC_REQUEST_INVALID + - PUBLIC_RESOURCE_NOT_FOUND + - INTERNAL_ERROR + description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' + category: + type: string + enum: + - VALIDATION + - AUTH + - AUTHZ + - NOT_FOUND + - CONFLICT + - RATE_LIMIT + - TRANSIENT_DEPENDENCY + - PERMANENT_DEPENDENCY + - DATA_INTEGRITY + - INTERNAL + message: + type: string + minLength: 1 + maxLength: 5000 + retryable: + type: boolean + details: + oneOf: + - $ref: '#/components/schemas/ValidationErrorDetails' + - type: 'null' + ErrorEnvelope: + type: object + additionalProperties: false + required: + - success + - error + - meta + properties: + success: + type: boolean + const: false + error: + $ref: '#/components/schemas/ApiError' + meta: + $ref: '#/components/schemas/ResponseMeta' + SiteResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/SiteResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + HomeResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/HomeResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + KnowledgePageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/KnowledgePage' + meta: + $ref: '#/components/schemas/ResponseMeta' + QuestionPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/QuestionPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + TopicListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/TopicListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + TopicDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/TopicDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + CaseDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/CaseDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReferenceDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReferenceDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + QuestionDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/QuestionDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectDecisionPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectDecisionPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectRecordPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectRecordPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProjectActivityPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProjectActivityPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseListResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseListResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseDetailResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseDetailResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + ProfileResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ProfileResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + SearchResultPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/SearchResultPage' + meta: + $ref: '#/components/schemas/ResponseMeta'