From 365560efb65ac734fb6091e7fbb692433229e068 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 18:34:18 +0900 Subject: [PATCH] =?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'