Compare commits
34
Commits
ab0447a0f9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb0890dc8 | ||
|
|
c6d9d2d675 | ||
|
|
a5f93b9b75 | ||
|
|
f1fd56fcb5 | ||
|
|
bd66fb3610 | ||
|
|
a7e2b7d7fe | ||
|
|
6aa140077d | ||
|
|
ca63d7d3ec | ||
|
|
4c14f1eb8f | ||
|
|
561d02ae3a | ||
|
|
23d82bd250 | ||
|
|
857e6a9c08 | ||
|
|
8d228255e9 | ||
|
|
e65b9e2c33 | ||
|
|
96521a94d4 | ||
|
|
af91f7d768 | ||
|
|
37f474ade2 | ||
|
|
1befdc37a4 | ||
|
|
386f360122 | ||
|
|
48517b9e65 | ||
|
|
0da7c7e2db | ||
|
|
bb6d2330bb | ||
|
|
bde5826cfd | ||
|
|
55a71fb33d | ||
|
|
0854d42c67 | ||
|
|
365560efb6 | ||
|
|
e3254def57 | ||
|
|
743fee3907 | ||
|
|
c8a891c407 | ||
|
|
a828b5d9fe | ||
|
|
37d5614129 | ||
|
|
d5889d644a | ||
|
|
48ff648112 | ||
|
|
e615c24152 |
@@ -112,6 +112,22 @@ env_keys:
|
|||||||
compatibility_impact: behavior-change
|
compatibility_impact: behavior-change
|
||||||
required_test: env-contract:server-shutdown-timeout-aligned
|
required_test: env-contract:server-shutdown-timeout-aligned
|
||||||
|
|
||||||
|
- name: APP_SESSION_TIMEOUT
|
||||||
|
# source: Studio 작성 세션. Spring 기본 30분 유휴 만료는 한 기록을 여러 번 저장하며
|
||||||
|
# 오래 머무는 작성 리듬보다 짧아, 저장하지 않은 편집을 잃는 원인이 되었다.
|
||||||
|
# 늘릴수록 훔친 session cookie 가 유효한 창도 같이 늘어나므로 Keycloak realm 의
|
||||||
|
# SSO idle 과 함께 맞춘다.
|
||||||
|
type: duration
|
||||||
|
default: 8h
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: feature-env-driven-runtime-configuration
|
||||||
|
validation: spring_duration_shorthand
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: env-contract:session-timeout
|
||||||
|
|
||||||
- name: APP_SERVER_FORWARD_HEADERS_STRATEGY
|
- name: APP_SERVER_FORWARD_HEADERS_STRATEGY
|
||||||
# source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification
|
# source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification
|
||||||
# (trust X-Forwarded-* when behind LB/proxy)
|
# (trust X-Forwarded-* when behind LB/proxy)
|
||||||
@@ -4249,3 +4265,66 @@ env_keys:
|
|||||||
validation: positive_int_bounded
|
validation: positive_int_bounded
|
||||||
compatibility_impact: behavior-change
|
compatibility_impact: behavior-change
|
||||||
required_test: async-contract:executor-queue-bounded
|
required_test: async-contract:executor-queue-bounded
|
||||||
|
|
||||||
|
# === Tech Log Studio (feature-techlog-studio-backend) ===
|
||||||
|
|
||||||
|
- name: APP_STUDIO_CURSOR_SIGNING_KEY
|
||||||
|
# source: studio-v1.yaml components.parameters.Cursor — "Opaque cursor bound to
|
||||||
|
# normalized filters and sort". 서명 키가 인스턴스마다 다르면 한 인스턴스가 발급한
|
||||||
|
# 커서를 다른 인스턴스가 거부한다. 비어 있으면 StudioSettings가 경고하고 개발용 값으로
|
||||||
|
# 대체한다(커서에 권한이 실리지 않으므로 부팅은 막지 않는다).
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
allowed_values: null
|
||||||
|
classification: secret
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: feature-techlog-studio-backend
|
||||||
|
validation: min_length_16_bytes
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: techlog-studio-contract:cursor-round-trip
|
||||||
|
|
||||||
|
- name: APP_STUDIO_VALIDATION_TTL
|
||||||
|
# source: 백엔드 설계 §7.3 — "now() < validUntil" 이 Validation 유효 조건의 하나다.
|
||||||
|
# studio_validation.valid_until 을 채우는 값.
|
||||||
|
type: duration
|
||||||
|
default: 1h
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: feature-techlog-studio-backend
|
||||||
|
validation: spring_duration_shorthand
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: techlog-studio-contract:validation-window
|
||||||
|
|
||||||
|
- name: APP_STUDIO_PREVIEW_TTL
|
||||||
|
# source: 백엔드 설계 §7.3 — Preview 가 EXPIRED 로 넘어가는 기준.
|
||||||
|
# studio_preview.expires_at 을 채우는 값.
|
||||||
|
type: duration
|
||||||
|
default: 24h
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: feature-techlog-studio-backend
|
||||||
|
validation: spring_duration_shorthand
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: techlog-studio-contract:preview-expiry
|
||||||
|
|
||||||
|
- name: APP_STUDIO_AUTHOR_ROLE
|
||||||
|
# source: 백엔드 설계 §9 — 권한은 기존 RolePermissionPolicy 를 재사용한다.
|
||||||
|
# ca-skeleton.authz.role-permissions 의 키로 쓰이는 IdP RAW role 이름. 배포마다 다르다.
|
||||||
|
# application.yml 이 아니라 프로파일(application-{local,dev,prod}.yml)에 있다 —
|
||||||
|
# SampleRemovalSmokeContractTest 가 application.yml 의 `role-permissions: {}` 기준선을
|
||||||
|
# 그대로 유지하도록 요구하기 때문이다.
|
||||||
|
type: string
|
||||||
|
default: studio-author
|
||||||
|
allowed_values: null
|
||||||
|
classification: public-config
|
||||||
|
required: false
|
||||||
|
reload_policy: restart-only
|
||||||
|
owner_branch: feature-techlog-studio-backend
|
||||||
|
validation: non_blank
|
||||||
|
compatibility_impact: behavior-change
|
||||||
|
required_test: techlog-studio-contract:author-permission
|
||||||
|
|||||||
@@ -960,7 +960,12 @@ errors:
|
|||||||
compatibility_impact: additive
|
compatibility_impact: additive
|
||||||
required_test: StudioErrorTest
|
required_test: StudioErrorTest
|
||||||
|
|
||||||
# source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND)
|
# source: studio-v1.yaml + studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND
|
||||||
|
# (StudioError.DOCUMENT_NOT_FOUND, ManagementError.DOCUMENT_NOT_FOUND)
|
||||||
|
#
|
||||||
|
# 두 표면이 같은 코드를 쓴다. 이 레지스트리의 식별자는 code 하나뿐이므로 행도 하나다 —
|
||||||
|
# 한때 표면마다 행을 두고 문구를 다르게 적었고("작업본을 찾을 수 없습니다"), 그 중복이
|
||||||
|
# 레지스트리 로딩 자체를 깨뜨렸다. 같은 코드는 같은 말을 해야 한다.
|
||||||
- code: DOCUMENT_NOT_FOUND
|
- code: DOCUMENT_NOT_FOUND
|
||||||
category: NOT_FOUND
|
category: NOT_FOUND
|
||||||
http_status: 404
|
http_status: 404
|
||||||
@@ -1228,3 +1233,256 @@ errors:
|
|||||||
runbook_link: "runbook://studio/unavailable"
|
runbook_link: "runbook://studio/unavailable"
|
||||||
compatibility_impact: additive
|
compatibility_impact: additive
|
||||||
required_test: StudioErrorTest
|
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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TECH LOG STUDIO MANAGEMENT (studio-management-v1.yaml ApiError.code)
|
||||||
|
#
|
||||||
|
# ManagementError(dev.caskeleton.application.techlog.error.ManagementError)와 1:1.
|
||||||
|
# AUTHENTICATION_REQUIRED / STUDIO_ACCESS_DENIED / REQUEST_VALIDATION_FAILED /
|
||||||
|
# VERSION_CONFLICT 는 StudioError 에도 있어 행이 이미 존재한다 — 이 파일의 identity
|
||||||
|
# column 은 `code` 이므로 중복 행을 만들지 않는다.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — TOPIC_NOT_FOUND (ManagementError.TOPIC_NOT_FOUND)
|
||||||
|
- code: TOPIC_NOT_FOUND
|
||||||
|
category: NOT_FOUND
|
||||||
|
http_status: 404
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "주제를 찾을 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — TOPIC_NAME_TAKEN (ManagementError.TOPIC_NAME_TAKEN)
|
||||||
|
- code: TOPIC_NAME_TAKEN
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "같은 이름의 주제가 이미 있습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — TOPIC_SLUG_TAKEN (ManagementError.TOPIC_SLUG_TAKEN)
|
||||||
|
- code: TOPIC_SLUG_TAKEN
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "같은 slug 의 주제가 이미 있습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — TOPIC_IN_USE (ManagementError.TOPIC_IN_USE)
|
||||||
|
- code: TOPIC_IN_USE
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "이 주제를 쓰는 기록이 있어 삭제할 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — PROJECT_NOT_FOUND (ManagementError.PROJECT_NOT_FOUND)
|
||||||
|
- code: PROJECT_NOT_FOUND
|
||||||
|
category: NOT_FOUND
|
||||||
|
http_status: 404
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "프로젝트를 찾을 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — PROJECT_SLUG_TAKEN (ManagementError.PROJECT_SLUG_TAKEN)
|
||||||
|
- code: PROJECT_SLUG_TAKEN
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "같은 slug 의 프로젝트가 이미 있습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — PROJECT_IN_USE (ManagementError.PROJECT_IN_USE)
|
||||||
|
- code: PROJECT_IN_USE
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — RELEASE_NOT_FOUND (ManagementError.RELEASE_NOT_FOUND)
|
||||||
|
- code: RELEASE_NOT_FOUND
|
||||||
|
category: NOT_FOUND
|
||||||
|
http_status: 404
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "요청한 릴리즈를 찾을 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — RELEASE_VERSION_TAKEN (ManagementError.RELEASE_VERSION_TAKEN)
|
||||||
|
- code: RELEASE_VERSION_TAKEN
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "같은 버전의 릴리즈가 이미 있습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — RELEASE_NOT_PUBLISHABLE (ManagementError.RELEASE_NOT_PUBLISHABLE)
|
||||||
|
- code: RELEASE_NOT_PUBLISHABLE
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "지금 상태에서는 발행할 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED)
|
||||||
|
- code: DOCUMENT_PUBLISHED
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — QUESTION_NOT_FOUND (ManagementError.QUESTION_NOT_FOUND)
|
||||||
|
- code: QUESTION_NOT_FOUND
|
||||||
|
category: NOT_FOUND
|
||||||
|
http_status: 404
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "질문을 찾을 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_IN_USE (ManagementError.DOCUMENT_IN_USE)
|
||||||
|
- code: DOCUMENT_IN_USE
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — QUESTION_IN_USE (ManagementError.QUESTION_IN_USE)
|
||||||
|
- code: QUESTION_IN_USE
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "이 질문을 참조하는 곳이 있어 삭제할 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — DECISION_NOT_FOUND (ManagementError.DECISION_NOT_FOUND)
|
||||||
|
- code: DECISION_NOT_FOUND
|
||||||
|
category: NOT_FOUND
|
||||||
|
http_status: 404
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "결정을 찾을 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
|
# source: studio-management-v1.yaml ApiError.code — DECISION_IN_USE (ManagementError.DECISION_IN_USE)
|
||||||
|
- code: DECISION_IN_USE
|
||||||
|
category: CONFLICT
|
||||||
|
http_status: 409
|
||||||
|
retryable: false
|
||||||
|
retry_after_seconds: null
|
||||||
|
owner_branch: feature-techlog-management-v1
|
||||||
|
owner_layer: application
|
||||||
|
client_safe_message: "이 결정을 참조하는 곳이 있어 삭제할 수 없습니다"
|
||||||
|
log_level: INFO
|
||||||
|
runbook_link: null
|
||||||
|
compatibility_impact: additive
|
||||||
|
required_test: ManagementErrorRegistryTest
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
|
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
|
||||||
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
|
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
|
||||||
/api/healthcheck
|
/api/healthcheck
|
||||||
|
/api/v1/public/**
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ PRESENTATION_API_BASE_PATH=/api
|
|||||||
APP_SECURITY_AUTH_MODE=jwt
|
APP_SECURITY_AUTH_MODE=jwt
|
||||||
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
|
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
|
||||||
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
|
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_NAME=CA_SESSION
|
||||||
APP_SESSION_COOKIE_SECURE=true
|
APP_SESSION_COOKIE_SECURE=true
|
||||||
APP_SESSION_COOKIE_HTTP_ONLY=true
|
APP_SESSION_COOKIE_HTTP_ONLY=true
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ plugins { id 'org.openapi.generator' }
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
generatedOpenapi {
|
generatedOpenapi {
|
||||||
java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java'))
|
java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java'))
|
||||||
|
// 계약의 discriminator union을 Java interface로 파생한 소스(prepareStudioCodegenSpec).
|
||||||
|
// 생성 DTO와 같은 sourceSet이어야 한다 — 생성된 하위 타입이 `implements <Union>` 하므로
|
||||||
|
// 이 인터페이스가 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'))
|
||||||
|
// studio-management-v1 도 같은 방식이다. 세 번째 계약이라 이 목록이 길어지는데,
|
||||||
|
// 계약마다 model 패키지를 분리하는 편이 이름 충돌보다 낫다 — 세 계약 모두
|
||||||
|
// TopicSummary 처럼 같은 이름의 서로 다른 스키마를 갖는다.
|
||||||
|
java.srcDir(layout.buildDirectory.dir('generated/openapi-management/src/main/java'))
|
||||||
|
java.srcDir(layout.buildDirectory.dir('generated/openapi-management-unions/src/main/java'))
|
||||||
}
|
}
|
||||||
// main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation
|
// main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation
|
||||||
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
|
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
|
||||||
@@ -64,6 +79,15 @@ dependencies {
|
|||||||
// never a hand-maintained stale schema). The release-blocking drift gate is
|
// never a hand-maintained stale schema). The release-blocking drift gate is
|
||||||
// owned by feature-contract-verification-test-suite (planned).
|
// owned by feature-contract-verification-test-suite (planned).
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
||||||
|
// Studio Preview / Public Snapshot 의 CaseRenderBlock 을 만드는 Markdown 파서.
|
||||||
|
// 설계 05장 §16 이 "특정 라이브러리를 도메인 계약으로 만들지 않는다"고 정하므로 이 의존은
|
||||||
|
// techlog/studio/render 패키지 안에서만 쓰고 바깥에는 계약 DTO 만 내보낸다.
|
||||||
|
// 프론트가 remark 계열로 같은 문법을 다루므로(ADR-005 동등성) CommonMark + GFM 확장이라는
|
||||||
|
// 같은 기준을 쓴다 — 자체 파서를 쓰면 두 화면의 해석이 갈라진다.
|
||||||
|
implementation 'org.commonmark:commonmark:0.21.0'
|
||||||
|
implementation 'org.commonmark:commonmark-ext-gfm-tables:0.21.0'
|
||||||
|
implementation 'org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0'
|
||||||
|
implementation 'org.commonmark:commonmark-ext-autolink:0.21.0'
|
||||||
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
|
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
|
||||||
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
|
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
|
||||||
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
|
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
|
||||||
@@ -121,6 +145,559 @@ tasks.named('check') {
|
|||||||
dependsOn tasks.named('webSecurityBoundaryTest')
|
dependsOn tasks.named('webSecurityBoundaryTest')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 계약 union -> 생성 코드 배선 (슬라이스 2 선행 / spec §5.2 보강).
|
||||||
|
//
|
||||||
|
// 문제: useOneOfInterfaces=false 는 컴파일은 통과시키지만 discriminator union이 Jackson
|
||||||
|
// 양방향 모두 계약을 위반한다(역직렬화 InvalidTypeIdException, 직렬화는 kind 대신 클래스
|
||||||
|
// simple name). useOneOfInterfaces=true 는 SpringCodegen이 union interface의 discriminator
|
||||||
|
// getter를 String으로 고정해 컴파일이 깨진다 — 계약 쪽으로 우회 불가(Plan 01 task-4-report).
|
||||||
|
//
|
||||||
|
// 해법: 계약 원본은 그대로 두고, 생성 직전에 사본을 파생시킨다.
|
||||||
|
// (1) oneOf + discriminator 를 가진 스키마 S 의 각 하위 타입에 `x-implements: [S]` 를 주입한다
|
||||||
|
// -> 생성된 하위 타입이 `implements S` 로 나온다(7.18.0에서 실측 확인).
|
||||||
|
// (2) S 자체의 생성은 ignore 시키고, 같은 package 에 Jackson 다형성 애너테이션을 단
|
||||||
|
// Java interface 로 이 태스크가 직접 써 넣는다.
|
||||||
|
// (3) 모든 분기가 `type: string` 인 이름 없는 oneOf 는 `type: string` 으로 접는다.
|
||||||
|
// 접지 않으면 생성기가 필드가 하나도 없는 껍데기 클래스를 만든다
|
||||||
|
// (WorkingCopyInputBase.slug -> WorkingCopyInputBaseSlug: 실측 확인). 그 결과
|
||||||
|
// slug 가 문자열이 아니라 `{}` 로 직렬화되어 계약을 위반한다.
|
||||||
|
//
|
||||||
|
// 파생 규칙은 계약의 `oneOf`/`discriminator.mapping` 에서 전부 읽어낸다 — union 목록을
|
||||||
|
// 손으로 관리하지 않으므로 계약에 union이 추가돼도 따라온다.
|
||||||
|
//
|
||||||
|
// snakeyaml 은 org.openapi.generator 플러그인이 buildscript classpath 로 이미 가져온다
|
||||||
|
// (swagger-parser 경유, 2.4 — 실측 확인).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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')
|
||||||
|
|
||||||
|
// 이 파생은 계약 두 벌(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
|
||||||
|
|
||||||
|
// (3) 전부 string 인 이름 없는 oneOf 접기
|
||||||
|
int[] collapsed = [0]
|
||||||
|
def collapseStringOneOf
|
||||||
|
collapseStringOneOf = { Object node ->
|
||||||
|
if (node instanceof Map) {
|
||||||
|
for (Object key : new ArrayList(node.keySet())) {
|
||||||
|
def value = node.get(key)
|
||||||
|
if (value instanceof Map && value.get('oneOf') instanceof List
|
||||||
|
&& !value.containsKey('discriminator')) {
|
||||||
|
def branches = value.get('oneOf')
|
||||||
|
if (!branches.isEmpty()
|
||||||
|
&& branches.every { it instanceof Map && it.get('type') == 'string' }) {
|
||||||
|
def folded = new LinkedHashMap()
|
||||||
|
folded.put('type', 'string')
|
||||||
|
if (value.containsKey('description')) {
|
||||||
|
folded.put('description', value.get('description'))
|
||||||
|
}
|
||||||
|
node.put(key, folded)
|
||||||
|
collapsed[0]++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collapseStringOneOf(value)
|
||||||
|
}
|
||||||
|
} else if (node instanceof List) {
|
||||||
|
node.each { collapseStringOneOf(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 가 된다), 같은 모양의 타입이 여러 벌
|
||||||
|
// 생겨 매핑 코드가 그 사이를 오가게 된다. Java 참조는 어차피 nullable 이라 손실이 없으므로
|
||||||
|
// null 분기를 지우고 남은 하나로 접는다.
|
||||||
|
int[] nullable = [0]
|
||||||
|
def collapseNullableOneOf
|
||||||
|
collapseNullableOneOf = { Object node ->
|
||||||
|
if (node instanceof Map) {
|
||||||
|
for (Object key : new ArrayList(node.keySet())) {
|
||||||
|
def value = node.get(key)
|
||||||
|
if (value instanceof Map && value.get('oneOf') instanceof List
|
||||||
|
&& !value.containsKey('discriminator')) {
|
||||||
|
def branches = value.get('oneOf')
|
||||||
|
def nulls = branches.findAll { it instanceof Map && it.get('type') == 'null' }
|
||||||
|
def rest = branches - nulls
|
||||||
|
if (!nulls.isEmpty() && rest.size() == 1 && rest[0] instanceof Map) {
|
||||||
|
def folded = new LinkedHashMap(rest[0])
|
||||||
|
if (value.containsKey('description') && !folded.containsKey('description')) {
|
||||||
|
folded.put('description', value.get('description'))
|
||||||
|
}
|
||||||
|
node.put(key, folded)
|
||||||
|
nullable[0]++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collapseNullableOneOf(value)
|
||||||
|
}
|
||||||
|
} else if (node instanceof List) {
|
||||||
|
node.each { collapseNullableOneOf(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collapseNullableOneOf(doc)
|
||||||
|
|
||||||
|
// (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다.
|
||||||
|
//
|
||||||
|
// 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가
|
||||||
|
// 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수
|
||||||
|
// 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required
|
||||||
|
// 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/
|
||||||
|
// decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이
|
||||||
|
// 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing").
|
||||||
|
//
|
||||||
|
// 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠
|
||||||
|
// 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은
|
||||||
|
// 그대로 남는다.
|
||||||
|
int[] relaxed = [0]
|
||||||
|
def relaxNullableRequired
|
||||||
|
relaxNullableRequired = { Object node ->
|
||||||
|
if (node instanceof Map) {
|
||||||
|
def props = node.get('properties')
|
||||||
|
def required = node.get('required')
|
||||||
|
if (props instanceof Map && required instanceof List) {
|
||||||
|
def drop = []
|
||||||
|
props.each { Object name, Object schema ->
|
||||||
|
if (!(schema instanceof Map)) return
|
||||||
|
def type = schema.get('type')
|
||||||
|
if (type instanceof List && type.contains('null') && required.contains(name)) {
|
||||||
|
drop << name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!drop.isEmpty()) {
|
||||||
|
required.removeAll(drop)
|
||||||
|
relaxed[0] += drop.size()
|
||||||
|
if (required.isEmpty()) node.remove('required')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new ArrayList(node.values()).each { relaxNullableRequired(it) }
|
||||||
|
} else if (node instanceof List) {
|
||||||
|
node.each { relaxNullableRequired(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relaxNullableRequired(doc)
|
||||||
|
logger.lifecycle("${label}: nullable required 해제 ${relaxed[0]}건")
|
||||||
|
|
||||||
|
// (1) x-implements 주입 + union 목록 수집
|
||||||
|
def unions = [:]
|
||||||
|
schemas.each { String name, Object schema ->
|
||||||
|
if (!(schema instanceof Map)) return
|
||||||
|
def disc = schema.get('discriminator')
|
||||||
|
if (!(schema.get('oneOf') instanceof List) || !(disc instanceof Map)) return
|
||||||
|
def property = disc.get('propertyName')
|
||||||
|
def mapping = disc.get('mapping')
|
||||||
|
if (!property || !(mapping instanceof Map) || mapping.isEmpty()) {
|
||||||
|
throw new GradleException(
|
||||||
|
"union ${name} 에 discriminator.propertyName 과 mapping 이 모두 있어야 한다 " +
|
||||||
|
"— 없으면 Jackson @JsonSubTypes 의 type id 를 계약에서 유도할 수 없다.")
|
||||||
|
}
|
||||||
|
def variants = new LinkedHashMap()
|
||||||
|
mapping.each { String typeId, String ref ->
|
||||||
|
def variant = ref.tokenize('/').last()
|
||||||
|
if (!schemas.containsKey(variant)) {
|
||||||
|
throw new GradleException("union ${name} 의 mapping 이 없는 스키마 ${variant} 를 가리킨다.")
|
||||||
|
}
|
||||||
|
variants.put(typeId, variant)
|
||||||
|
}
|
||||||
|
schema.get('oneOf').each { branch ->
|
||||||
|
if (branch instanceof Map && branch.get('$ref')) {
|
||||||
|
def variant = branch.get('$ref').tokenize('/').last()
|
||||||
|
if (!variants.containsValue(variant)) {
|
||||||
|
throw new GradleException(
|
||||||
|
"union ${name} 의 oneOf 분기 ${variant} 가 discriminator.mapping 에 없다 " +
|
||||||
|
"— type id 를 알 수 없어 Jackson 배선을 파생시킬 수 없다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
variants.values().toSet().each { String variant ->
|
||||||
|
def target = schemas.get(variant)
|
||||||
|
def impls = target.get('x-implements')
|
||||||
|
if (impls == null) {
|
||||||
|
target.put('x-implements', [name])
|
||||||
|
} else if (!impls.contains(name)) {
|
||||||
|
target.put('x-implements', impls + [name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unions.put(name, [property: property, variants: variants])
|
||||||
|
}
|
||||||
|
// 이 가드의 목적은 "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<String, Object>()
|
||||||
|
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 = specTarget
|
||||||
|
specFile.parentFile.mkdirs()
|
||||||
|
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 = ignoreTarget
|
||||||
|
ignoreFile.setText(
|
||||||
|
(["# ${label} 가 생성한다 — 손으로 고치지 않는다.",
|
||||||
|
'# 이 파일들은 같은 package 의 Java interface 로 대체된다.']
|
||||||
|
+ unions.keySet().collect { "**/${it}.java" }).join('\n') + '\n',
|
||||||
|
'UTF-8')
|
||||||
|
|
||||||
|
// union interface 쓰기
|
||||||
|
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 ->
|
||||||
|
" @JsonSubTypes.Type(value = ${variant}.class, name = \"${typeId}\")"
|
||||||
|
}.join(',\n')
|
||||||
|
def source = """package ${modelPackage};
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code ${name}} — 계약의 discriminator union. ${label} 가 계약의
|
||||||
|
* {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다.
|
||||||
|
*
|
||||||
|
* <p>{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로
|
||||||
|
* 이미 직렬화하므로 Jackson 이 판별 필드를 한 번 더 쓰면 키가 중복된다.
|
||||||
|
*/
|
||||||
|
@JsonTypeInfo(
|
||||||
|
use = JsonTypeInfo.Id.NAME,
|
||||||
|
include = JsonTypeInfo.As.EXISTING_PROPERTY,
|
||||||
|
property = "${spec.property}",
|
||||||
|
visible = true)
|
||||||
|
@JsonSubTypes({
|
||||||
|
${subtypes}
|
||||||
|
})
|
||||||
|
public interface ${name} {}
|
||||||
|
"""
|
||||||
|
new File(packageDir, "${name}.java").setText(source, 'UTF-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.lifecycle(
|
||||||
|
"${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')
|
||||||
|
|
||||||
|
ext.managementModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.management.api.model'
|
||||||
|
ext.managementCodegenSpecFile = layout.buildDirectory.file('openapi/studio-management-v1-codegen.yaml')
|
||||||
|
ext.managementCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-management')
|
||||||
|
ext.managementUnionSrcDir = layout.buildDirectory.dir('generated/openapi-management-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<String> declared = new TreeSet<>(((Map) doc.components.schemas).keySet())
|
||||||
|
File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/'))
|
||||||
|
Set<String> generated = new TreeSet<>()
|
||||||
|
if (packageDir.isDirectory()) {
|
||||||
|
packageDir.eachFile { File f ->
|
||||||
|
if (f.name.endsWith('.java')) generated << f.name[0..-6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 생성기는 이름 없는 중첩 object 에 <부모><필드> 형태의 모델을 더 만든다. 그건 초과분이라
|
||||||
|
// 문제가 아니고, 부족분만 문제다.
|
||||||
|
Set<String> missing = new TreeSet<>(declared - generated)
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
throw new GradleException(
|
||||||
|
"public-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// property 대조. 생성기는 @JsonProperty 에 계약의 원래 이름을 그대로 쓰므로
|
||||||
|
// 그 문자열 리터럴이 파일에 있는지로 판정한다.
|
||||||
|
int checkedProps = 0
|
||||||
|
List<String> 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.register('prepareManagementCodegenSpec') {
|
||||||
|
description = 'studio-management-v1 계약에서 생성기 입력을 파생시킨다.'
|
||||||
|
def specSource = file("${rootDir}/config/openapi/studio-management-v1.yaml")
|
||||||
|
def specOut = managementCodegenSpecFile
|
||||||
|
def ignoreOut = managementCodegenIgnoreFile
|
||||||
|
def unionDir = managementUnionSrcDir
|
||||||
|
def modelPackage = managementModelPackage
|
||||||
|
def prepare = prepareTechLogCodegenSpec
|
||||||
|
inputs.file(specSource)
|
||||||
|
outputs.file(specOut)
|
||||||
|
outputs.file(ignoreOut)
|
||||||
|
outputs.dir(unionDir)
|
||||||
|
doLast {
|
||||||
|
prepare('prepareManagementCodegenSpec', specSource, specOut.get().asFile,
|
||||||
|
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('openApiGenerateManagement',
|
||||||
|
org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
|
||||||
|
dependsOn tasks.named('prepareManagementCodegenSpec')
|
||||||
|
generatorName = 'spring'
|
||||||
|
inputSpec = managementCodegenSpecFile.get().asFile.path
|
||||||
|
ignoreFileOverride = managementCodegenIgnoreFile.get().asFile.path
|
||||||
|
outputDir = layout.buildDirectory.dir('generated/openapi-management').get().asFile.path
|
||||||
|
modelPackage = managementModelPackage
|
||||||
|
validateSpec = true
|
||||||
|
globalProperties.set(['models': ''])
|
||||||
|
generateModelTests = false
|
||||||
|
generateModelDocumentation = false
|
||||||
|
configOptions = [
|
||||||
|
useSpringBoot3: 'true',
|
||||||
|
useJakartaEe: 'true',
|
||||||
|
openApiNullable: 'false',
|
||||||
|
useOneOfInterfaces: 'false',
|
||||||
|
]
|
||||||
|
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi-management')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('verifyManagementGeneratedModels') {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'studio-management-v1 계약의 schema 와 property 가 전부 모델로 생성됐는지 대조한다.'
|
||||||
|
dependsOn tasks.named('openApiGenerateManagement')
|
||||||
|
def specFile = managementCodegenSpecFile
|
||||||
|
def modelDirProvider = layout.buildDirectory.dir('generated/openapi-management/src/main/java')
|
||||||
|
def modelPackage = managementModelPackage
|
||||||
|
doLast {
|
||||||
|
def doc = new org.yaml.snakeyaml.Yaml().load(specFile.get().asFile.getText('UTF-8'))
|
||||||
|
Set<String> declared = new TreeSet<>(((Map) doc.components.schemas).keySet())
|
||||||
|
File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/'))
|
||||||
|
Set<String> generated = new TreeSet<>()
|
||||||
|
if (packageDir.isDirectory()) {
|
||||||
|
packageDir.eachFile { File f -> if (f.name.endsWith('.java')) generated << f.name[0..-6] }
|
||||||
|
}
|
||||||
|
Set<String> missing = new TreeSet<>(declared - generated)
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
throw new GradleException(
|
||||||
|
"studio-management-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}")
|
||||||
|
}
|
||||||
|
int checkedProps = 0
|
||||||
|
List<String> 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(
|
||||||
|
"studio-management-v1 계약의 property ${lost.size()}개가 모델에서 빠졌다: ${lost.take(20)}")
|
||||||
|
}
|
||||||
|
logger.lifecycle(
|
||||||
|
"verifyManagementGeneratedModels: 계약 schema ${declared.size()}개 · " +
|
||||||
|
"property ${checkedProps}개 전부 생성 (생성 모델 ${generated.size()}개)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('check') {
|
||||||
|
dependsOn tasks.named('verifyPublicGeneratedModels')
|
||||||
|
dependsOn tasks.named('verifyManagementGeneratedModels')
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('compileGeneratedOpenapiJava') {
|
||||||
|
dependsOn tasks.named('openApiGeneratePublic')
|
||||||
|
dependsOn tasks.named('openApiGenerateManagement')
|
||||||
|
}
|
||||||
|
|
||||||
|
// openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라
|
||||||
|
// dependsOn 을 받지 못한다. 태스크 쪽에 건다.
|
||||||
|
tasks.named('openApiGenerate') {
|
||||||
|
dependsOn tasks.named('prepareStudioCodegenSpec')
|
||||||
|
// openapi-generator 는 outputDir 를 비우지 않는다 — 계약에서 스키마가 사라져도 직전
|
||||||
|
// 실행의 .java 가 그대로 남아 컴파일에 성공하고, 그래서 드리프트가 아니라 정상으로 보인다
|
||||||
|
// (이 배선을 넣는 과정에서 실제로 겪음: 삭제됐어야 할 union 6개가 남아 있었다).
|
||||||
|
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi')) }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Studio 계약 DTO 생성 (ADR-004 / ADR-006).
|
// Studio 계약 DTO 생성 (ADR-004 / ADR-006).
|
||||||
//
|
//
|
||||||
@@ -155,16 +732,24 @@ tasks.named('check') {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
openApiGenerate {
|
openApiGenerate {
|
||||||
generatorName = 'spring'
|
generatorName = 'spring'
|
||||||
inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString()
|
// 원본이 아니라 prepareStudioCodegenSpec 가 파생한 사본을 먹인다 — 이유는 그 태스크의 주석 참고.
|
||||||
|
inputSpec = studioCodegenSpecFile.get().asFile.path
|
||||||
|
ignoreFileOverride = studioCodegenIgnoreFile.get().asFile.path
|
||||||
outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path
|
outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path
|
||||||
modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
|
modelPackage = studioModelPackage
|
||||||
globalProperties.set(['models': ''])
|
globalProperties.set(['models': ''])
|
||||||
generateModelTests = false
|
generateModelTests = false
|
||||||
generateModelDocumentation = false
|
generateModelDocumentation = false
|
||||||
configOptions = [
|
configOptions = [
|
||||||
useSpringBoot3: 'true',
|
useSpringBoot3: 'true',
|
||||||
useJakartaEe: 'true',
|
useJakartaEe: 'true',
|
||||||
openApiNullable: 'true',
|
// false 다. openApiNullable=true 는 nullable 필드를 JsonNullable<T> 로 만드는데,
|
||||||
|
// 그 타입을 읽는 모듈(org.openapitools:jackson-databind-nullable)은 Jackson 2 용이고
|
||||||
|
// 이 앱의 HTTP 변환기는 Jackson 3(tools.jackson, Spring Boot 4 기본)다 — 모듈이
|
||||||
|
// 등록될 수 없어 JsonNullable 이 그냥 POJO 로 직렬화되고 역직렬화는 깨진다.
|
||||||
|
// 계약이 이 필드들을 required 로 두므로(예: questionStatus, decisionStatus)
|
||||||
|
// "없음"과 "null" 을 구분할 필요도 없다. 평범한 nullable 필드로 생성한다.
|
||||||
|
openApiNullable: 'false',
|
||||||
useOneOfInterfaces: 'false',
|
useOneOfInterfaces: 'false',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
|||||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||||
|
org.commonmark:commonmark-ext-autolink:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
org.commonmark:commonmark-ext-gfm-tables:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
|
org.commonmark:commonmark:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.dom4j:dom4j:2.2.0=spotbugs
|
org.dom4j:dom4j:2.2.0=spotbugs
|
||||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||||
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
@@ -108,6 +112,7 @@ org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
|||||||
org.junit:junit-bom:6.1.0=spotbugs
|
org.junit:junit-bom:6.1.0=spotbugs
|
||||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||||
|
org.nibor.autolink:autolink:0.10.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||||
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||||
|
|||||||
+27
-1
@@ -70,7 +70,10 @@ public class SecurityConfig {
|
|||||||
AccessDeniedHandler accessDeniedHandler,
|
AccessDeniedHandler accessDeniedHandler,
|
||||||
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
|
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
|
||||||
sessionSecurityContextRepository,
|
sessionSecurityContextRepository,
|
||||||
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths)
|
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths,
|
||||||
|
org.springframework.beans.factory.ObjectProvider<
|
||||||
|
org.springframework.security.web.authentication.AuthenticationSuccessHandler>
|
||||||
|
loginSuccessHandler)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
|
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
|
||||||
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
|
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
|
||||||
@@ -137,6 +140,29 @@ public class SecurityConfig {
|
|||||||
securityContext
|
securityContext
|
||||||
.securityContextRepository(sessionSecurityContextRepository.getObject())
|
.securityContextRepository(sessionSecurityContextRepository.getObject())
|
||||||
.requireExplicitSave(false));
|
.requireExplicitSave(false));
|
||||||
|
|
||||||
|
// BFF 로그인. 세션을 만들 수 있는 유일한 경로다 — 이것이 없으면 auth-mode=redis-session 은
|
||||||
|
// 아무도 인증할 수 없는 모드가 된다. SPA 는 401 을 받으면 브라우저를 /oauth2/authorization/{id}
|
||||||
|
// 로 이동시키고, 콜백이 세션 쿠키를 심은 뒤 SPA 진입점으로 되돌린다.
|
||||||
|
//
|
||||||
|
// 진입점은 바꾸지 않는다: API 요청이 302 로 답하면 XHR 이 따라갈 수 없으므로, 미인증 API 호출은
|
||||||
|
// 그대로 봉투 401 이어야 한다. 아래 defaultSuccessUrl 대신 주입된 핸들러를 쓰는 이유는
|
||||||
|
// OidcUser 를 세션이 담을 수 있는 AuthenticatedPrincipal 로 바꿔야 하기 때문이다.
|
||||||
|
org.springframework.security.web.authentication.AuthenticationSuccessHandler onSuccess =
|
||||||
|
loginSuccessHandler.getIfAvailable();
|
||||||
|
if (onSuccess != null) {
|
||||||
|
http.oauth2Login(login -> login.successHandler(onSuccess));
|
||||||
|
}
|
||||||
|
http.logout(
|
||||||
|
logout ->
|
||||||
|
logout
|
||||||
|
.logoutUrl("/logout")
|
||||||
|
.invalidateHttpSession(true)
|
||||||
|
.deleteCookies(securitySettings.session().cookieName())
|
||||||
|
.logoutSuccessHandler(
|
||||||
|
(request, response, authentication) ->
|
||||||
|
response.setStatus(
|
||||||
|
jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT)));
|
||||||
}
|
}
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-6
@@ -6,11 +6,14 @@ import dev.caskeleton.application.techlog.error.StudioException;
|
|||||||
import dev.caskeleton.shared.response.Envelope;
|
import dev.caskeleton.shared.response.Envelope;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.core.Ordered;
|
import org.springframework.core.Ordered;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.authorization.AuthorizationDeniedException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@@ -26,14 +29,17 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
|
|||||||
* handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다.
|
* handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다.
|
||||||
*
|
*
|
||||||
* <p><b>{@code basePackages} 스코프 (final whole-branch review B4).</b> 이 advice는 {@code
|
* <p><b>{@code basePackages} 스코프 (final whole-branch review B4).</b> 이 advice는 {@code
|
||||||
* dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code
|
* dev.caskeleton.adapter.inbound.web.techlog.studio} 아래의 컨트롤러(studio 컨트롤러 전부가 여기 산다, {@code
|
||||||
* studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring
|
* studio.controller})에만 적용된다. 원래는 한 단계 위인 {@code ...web.techlog}였는데, 공개 조회 컨트롤러가 {@code
|
||||||
* MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그
|
* ...web.techlog.publicapi}에 들어오면서 그 스코프가 남의 기능까지 덮게 되었다 — 아래 바인딩 예외 처리기들이 공개 조회의 파라미터 오류를 Studio
|
||||||
* 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이
|
* 계약 코드로 바꿔 내보냈을 것이고, 그 코드는 public-v1 계약의 enum 에 없어서 프론트엔드의 응답 파싱을 깨뜨린다. 그래서 studio 로 좁혔다. {@link
|
||||||
* 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
|
* #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이
|
||||||
|
* 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을
|
||||||
|
* 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이 없는 기능이다. {@code StudioException}
|
||||||
|
* 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
|
||||||
*/
|
*/
|
||||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")
|
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.studio")
|
||||||
public class StudioExceptionHandler {
|
public class StudioExceptionHandler {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class);
|
private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class);
|
||||||
@@ -76,6 +82,46 @@ public class StudioExceptionHandler {
|
|||||||
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
|
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 본문 bean validation 실패(예: {@code title} 120자 초과). {@code GlobalExceptionHandler}도 이 예외를 처리하지만
|
||||||
|
* 400 {@code OperationalError.VALIDATION_FAILED}를 낸다 — Studio 계약에 없는 코드이고 (계약이 아는 것은 {@code
|
||||||
|
* REQUEST_VALIDATION_FAILED}와 {@code DOCUMENT_VALIDATION_FAILED}뿐이다), 상태도 계약이 본문 검증 실패에 배정한 422가
|
||||||
|
* 아니다. 프론트엔드는 봉투의 {@code code}를 enum으로 검증하므로 계약 밖 코드는 응답 파싱 자체를 깨뜨린다. studio 스코프에서 계약 코드로 옮긴다.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Envelope<Void>> handleBodyValidation(MethodArgumentNotValidException ex) {
|
||||||
|
List<Map<String, Object>> fieldErrors =
|
||||||
|
ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(
|
||||||
|
error ->
|
||||||
|
Map.<String, Object>of(
|
||||||
|
"path",
|
||||||
|
"/" + error.getField(),
|
||||||
|
"message",
|
||||||
|
error.getDefaultMessage() == null
|
||||||
|
? "Value is invalid"
|
||||||
|
: error.getDefaultMessage()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ErrorResponseFactory.envelope(
|
||||||
|
StudioError.REQUEST_VALIDATION_FAILED,
|
||||||
|
StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED),
|
||||||
|
Map.of("fieldErrors", fieldErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 부족. 스켈레톤의 분류기는 {@code AUTHZ_INSUFFICIENT_PERMISSION}을 내지만 계약이 403에 배정한 코드는 {@code
|
||||||
|
* STUDIO_ACCESS_DENIED}다({@code responses.AccessDenied.x-error-codes}). 상태는 그대로 403이고 코드만 계약 쪽으로
|
||||||
|
* 옮긴다.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AuthorizationDeniedException.class)
|
||||||
|
public ResponseEntity<Envelope<Void>> handleAccessDenied(AuthorizationDeniedException ex) {
|
||||||
|
log.warn("studio access denied: {}", ex.getMessage());
|
||||||
|
return ErrorResponseFactory.envelope(
|
||||||
|
StudioError.STUDIO_ACCESS_DENIED,
|
||||||
|
StudioClientSafeMessages.forError(StudioError.STUDIO_ACCESS_DENIED),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에
|
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에
|
||||||
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
|
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.auth;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||||
|
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||||
|
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다.
|
||||||
|
*
|
||||||
|
* <p>{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다.
|
||||||
|
* 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link
|
||||||
|
* AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 넘지 못하게 하는 의도적인 제약이다. 그래서
|
||||||
|
* 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고
|
||||||
|
* ID/Access 토큰은 남지 않는다.
|
||||||
|
*
|
||||||
|
* <p>역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code
|
||||||
|
* realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 같은 역할
|
||||||
|
* 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")
|
||||||
|
public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandler {
|
||||||
|
|
||||||
|
private final SimpleUrlAuthenticationSuccessHandler redirect =
|
||||||
|
new SimpleUrlAuthenticationSuccessHandler();
|
||||||
|
|
||||||
|
public StudioOidcLoginSuccessHandler(
|
||||||
|
@Value("${app.studio.post-login-redirect:/}") String defaultTargetUrl) {
|
||||||
|
redirect.setDefaultTargetUrl(defaultTargetUrl);
|
||||||
|
// SPA 가 라우팅을 소유한다. 프레임워크의 SavedRequest 는 SecurityConfig 가 이미 꺼두었으므로
|
||||||
|
// 로그인 후에는 항상 SPA 진입점으로 보내고, 원래 가려던 화면 복원은 SPA 가 한다.
|
||||||
|
redirect.setAlwaysUseDefaultTargetUrl(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAuthenticationSuccess(
|
||||||
|
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||||
|
throws IOException, ServletException {
|
||||||
|
if (authentication.getPrincipal() instanceof OidcUser user) {
|
||||||
|
Set<String> roles = extractRoles(user);
|
||||||
|
AuthenticatedPrincipal principal =
|
||||||
|
new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles);
|
||||||
|
Collection<GrantedAuthority> authorities =
|
||||||
|
roles.stream()
|
||||||
|
.map(
|
||||||
|
r ->
|
||||||
|
(GrantedAuthority)
|
||||||
|
new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT)))
|
||||||
|
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
|
||||||
|
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||||
|
context.setAuthentication(
|
||||||
|
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
|
||||||
|
SecurityContextHolder.setContext(context);
|
||||||
|
// requireExplicitSave(false) 이므로 SecurityContextHolderFilter 가 응답 커밋 시 저장한다.
|
||||||
|
authentication = context.getAuthentication();
|
||||||
|
}
|
||||||
|
redirect.onAuthenticationSuccess(request, response, authentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> extractRoles(OidcUser user) {
|
||||||
|
Set<String> roles = new HashSet<>();
|
||||||
|
addRoles(roles, user.getClaimAsMap("realm_access"));
|
||||||
|
Map<String, Object> resourceAccess = user.getClaimAsMap("resource_access");
|
||||||
|
if (resourceAccess != null) {
|
||||||
|
for (Object client : resourceAccess.values()) {
|
||||||
|
if (client instanceof Map<?, ?> map) {
|
||||||
|
addRoles(roles, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> generic = user.getClaimAsStringList("roles");
|
||||||
|
if (generic != null) {
|
||||||
|
roles.addAll(generic);
|
||||||
|
}
|
||||||
|
return Set.copyOf(roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addRoles(Set<String> sink, Map<?, ?> holder) {
|
||||||
|
if (holder == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (holder.get("roles") instanceof Collection<?> values) {
|
||||||
|
values.forEach(value -> sink.add(String.valueOf(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나 SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도
|
||||||
|
* 아니다.
|
||||||
|
*/
|
||||||
|
public final class ManagementClientSafeMessages {
|
||||||
|
|
||||||
|
private ManagementClientSafeMessages() {}
|
||||||
|
|
||||||
|
public static String forError(ManagementError error) {
|
||||||
|
return switch (error) {
|
||||||
|
case AUTHENTICATION_REQUIRED -> "로그인이 필요합니다";
|
||||||
|
case STUDIO_ACCESS_DENIED -> "권한이 없습니다";
|
||||||
|
case REQUEST_VALIDATION_FAILED -> "요청 값이 올바르지 않습니다";
|
||||||
|
case VERSION_CONFLICT -> "다른 곳에서 먼저 수정되었습니다. 새로 불러온 뒤 다시 시도해 주세요";
|
||||||
|
case TOPIC_NOT_FOUND -> "주제를 찾을 수 없습니다";
|
||||||
|
case TOPIC_NAME_TAKEN -> "같은 이름의 주제가 이미 있습니다";
|
||||||
|
case TOPIC_SLUG_TAKEN -> "같은 slug 의 주제가 이미 있습니다";
|
||||||
|
case TOPIC_IN_USE -> "이 주제를 쓰는 기록이 있어 삭제할 수 없습니다";
|
||||||
|
case PROJECT_NOT_FOUND -> "프로젝트를 찾을 수 없습니다";
|
||||||
|
case PROJECT_SLUG_TAKEN -> "같은 slug 의 프로젝트가 이미 있습니다";
|
||||||
|
case PROJECT_IN_USE -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다";
|
||||||
|
case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다";
|
||||||
|
case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다";
|
||||||
|
case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다";
|
||||||
|
case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
|
||||||
|
case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요";
|
||||||
|
case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다";
|
||||||
|
case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다";
|
||||||
|
case QUESTION_IN_USE -> "이 질문을 참조하는 곳이 있어 삭제할 수 없습니다";
|
||||||
|
case DECISION_NOT_FOUND -> "결정을 찾을 수 없습니다";
|
||||||
|
case DECISION_IN_USE -> "이 결정을 참조하는 곳이 있어 삭제할 수 없습니다";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
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.http.converter.HttpMessageNotReadableException;
|
||||||
|
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 ...web.techlog.management} 로 좁히는 이유는 형제 표면들과 같다 — 각 계약이 자기 {@code
|
||||||
|
* ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그 계약이 거짓이 된다.
|
||||||
|
*/
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.management")
|
||||||
|
public class ManagementExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ManagementExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(ManagementException.class)
|
||||||
|
public ResponseEntity<Envelope<Void>> handle(ManagementException ex) {
|
||||||
|
ManagementError error = ex.managementError();
|
||||||
|
if (error.httpStatus() >= 500) {
|
||||||
|
log.error("management request failed as {}: {}", error.code(), ex.getMessage(), ex);
|
||||||
|
} else {
|
||||||
|
log.warn("management request rejected as {}: {}", error.code(), ex.getMessage());
|
||||||
|
}
|
||||||
|
return ErrorResponseFactory.envelope(
|
||||||
|
error, ManagementClientSafeMessages.forError(error), ex.details());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고 {@code EnvelopeBodyAdvice}
|
||||||
|
* 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한 모양이 그대로 나간다.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||||
|
public ResponseEntity<Envelope<Void>> handleUnreadableBody(HttpMessageNotReadableException ex) {
|
||||||
|
log.warn("management request body was unreadable: {}", ex.getMessage());
|
||||||
|
return invalid("body", "MALFORMED", "Request body is malformed or unparsable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||||
|
public ResponseEntity<Envelope<Void>> handleMissingParameter(
|
||||||
|
MissingServletRequestParameterException ex) {
|
||||||
|
return invalid(ex.getParameterName(), "REQUIRED", "Required parameter is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||||
|
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||||
|
return invalid(ex.getName(), "TYPE_MISMATCH", "Parameter value is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */
|
||||||
|
private static ResponseEntity<Envelope<Void>> invalid(String field, String code, String message) {
|
||||||
|
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
|
||||||
|
return ErrorResponseFactory.envelope(
|
||||||
|
ManagementError.REQUEST_VALIDATION_FAILED,
|
||||||
|
ManagementClientSafeMessages.forError(ManagementError.REQUEST_VALIDATION_FAILED),
|
||||||
|
Map.of("fieldErrors", List.of(fieldError)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||||
|
import dev.caskeleton.application.techlog.error.ManagementException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 — 계약이 각자 code 집합을 열거하므로
|
||||||
|
* 예외까지 공유하면 한쪽 계약이 거짓이 된다.
|
||||||
|
*/
|
||||||
|
public final class ManagementPrincipals {
|
||||||
|
|
||||||
|
private ManagementPrincipals() {}
|
||||||
|
|
||||||
|
public static String require(AuthenticatedPrincipal principal) {
|
||||||
|
if (principal == null || principal.idpUserId() == null || principal.idpUserId().isBlank()) {
|
||||||
|
throw ManagementException.of(
|
||||||
|
ManagementError.AUTHENTICATION_REQUIRED,
|
||||||
|
"the request has no usable authenticated principal");
|
||||||
|
}
|
||||||
|
return principal.idpUserId();
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.DeleteDocumentCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업본 삭제. 계약의 delete 3개.
|
||||||
|
*
|
||||||
|
* <p>경로가 종류별로 갈리는 것은 계약이 그렇게 선언했기 때문이고, 그럴 이유도 있다 — Case 와 Reference 는 한 테이블을 나눠 쓰지만 Question 은 다른
|
||||||
|
* 테이블이고, 종류를 경로에 두면 Case 주소로 Reference 를 지우는 요청이 애초에 성립하지 않는다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ManagementDocumentController {
|
||||||
|
|
||||||
|
private final DeleteDocumentDraftUseCase deleteDocument;
|
||||||
|
private final DeleteQuestionUseCase deleteQuestion;
|
||||||
|
private final DeleteProjectDecisionUseCase deleteDecision;
|
||||||
|
|
||||||
|
public ManagementDocumentController(
|
||||||
|
DeleteDocumentDraftUseCase deleteDocument,
|
||||||
|
DeleteQuestionUseCase deleteQuestion,
|
||||||
|
DeleteProjectDecisionUseCase deleteDecision) {
|
||||||
|
this.deleteDocument = deleteDocument;
|
||||||
|
this.deleteQuestion = deleteQuestion;
|
||||||
|
this.deleteDecision = deleteDecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/cases/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteCaseDraft(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteDocument.handle(command(id, body, principal), "CASE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/references/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteReferenceDraft(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteDocument.handle(command(id, body, principal), "REFERENCE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/questions/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteQuestion(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteQuestion.handle(command(id, body, principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/projects/{id}/decisions/{decisionId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteProjectDecision(
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@PathVariable("decisionId") UUID decisionId,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteDecision.handle(id, decisionId, body.getExpectedVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeleteDocumentCommand command(
|
||||||
|
UUID id, ExpectedVersionRequest body, AuthenticatedPrincipal principal) {
|
||||||
|
return new DeleteDocumentCommand(
|
||||||
|
id, body.getExpectedVersion(), ManagementPrincipals.require(principal));
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈 focus 설정. 계약 {@code getHomeFocus}/{@code updateHomeFocus}.
|
||||||
|
*
|
||||||
|
* <p>단일 행이라 경로에 id 가 없다. 낙관적 잠금은 본문의 {@code expectedVersion} 으로만 한다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ManagementHomeFocusController {
|
||||||
|
|
||||||
|
private final GetHomeFocusUseCase getHomeFocus;
|
||||||
|
private final UpdateHomeFocusUseCase updateHomeFocus;
|
||||||
|
|
||||||
|
public ManagementHomeFocusController(
|
||||||
|
GetHomeFocusUseCase getHomeFocus, UpdateHomeFocusUseCase updateHomeFocus) {
|
||||||
|
this.getHomeFocus = getHomeFocus;
|
||||||
|
this.updateHomeFocus = updateHomeFocus;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/home/focus")
|
||||||
|
public HomeFocusResponse getHomeFocus() {
|
||||||
|
return ManagementResponseMapper.homeFocus(getHomeFocus.handle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/home/focus")
|
||||||
|
public HomeFocusResponse updateHomeFocus(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@RequestBody HomeFocusRequest body) {
|
||||||
|
return ManagementResponseMapper.homeFocus(
|
||||||
|
updateHomeFocus.handle(
|
||||||
|
new UpdateHomeFocusCommand(
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getDefaultType() == null ? null : body.getDefaultType().getValue(),
|
||||||
|
body.getCurrentProjectId(),
|
||||||
|
body.getOpenQuestionId(),
|
||||||
|
body.getRecentDecisionId(),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.UpdateProjectActivityRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectActivityUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateProjectActivityUseCase;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동. 계약 {@code listStudioProjectActivities}/{@code createProjectActivity}/{@code
|
||||||
|
* updateProjectActivity}/{@code deleteProjectActivity}.
|
||||||
|
*
|
||||||
|
* <p>공개 프로젝트 화면의 "활동" 은 {@code project_activity} 를 직접 읽는다 — 투영을 거치지 않으므로 여기서 만든 줄이 곧 그 화면이다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ManagementProjectActivityController {
|
||||||
|
|
||||||
|
private final ListStudioProjectActivitiesUseCase listActivities;
|
||||||
|
private final CreateProjectActivityUseCase createActivity;
|
||||||
|
private final UpdateProjectActivityUseCase updateActivity;
|
||||||
|
private final DeleteProjectActivityUseCase deleteActivity;
|
||||||
|
|
||||||
|
public ManagementProjectActivityController(
|
||||||
|
ListStudioProjectActivitiesUseCase listActivities,
|
||||||
|
CreateProjectActivityUseCase createActivity,
|
||||||
|
UpdateProjectActivityUseCase updateActivity,
|
||||||
|
DeleteProjectActivityUseCase deleteActivity) {
|
||||||
|
this.listActivities = listActivities;
|
||||||
|
this.createActivity = createActivity;
|
||||||
|
this.updateActivity = updateActivity;
|
||||||
|
this.deleteActivity = deleteActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/projects/{id}/activities")
|
||||||
|
public List<ProjectActivityResponse> listStudioProjectActivities(@PathVariable("id") UUID id) {
|
||||||
|
return ManagementResponseMapper.projectActivities(listActivities.handle(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/projects/{id}/activities")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public ProjectActivityResponse createProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ProjectActivityRequest body) {
|
||||||
|
return ManagementResponseMapper.projectActivity(
|
||||||
|
createActivity.handle(
|
||||||
|
new CreateProjectActivityCommand(
|
||||||
|
id,
|
||||||
|
body.getExpectedProjectVersion(),
|
||||||
|
body.getActivityType(),
|
||||||
|
body.getTitle(),
|
||||||
|
body.getSummary(),
|
||||||
|
body.getVisibility() == null ? null : body.getVisibility().getValue(),
|
||||||
|
body.getRelatedResourceType(),
|
||||||
|
body.getRelatedResourceId(),
|
||||||
|
instant(body.getOccurredAt()),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/projects/{id}/activities/{activityId}")
|
||||||
|
public ProjectActivityResponse updateProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@PathVariable("activityId") UUID activityId,
|
||||||
|
@RequestBody UpdateProjectActivityRequest body) {
|
||||||
|
return ManagementResponseMapper.projectActivity(
|
||||||
|
updateActivity.handle(
|
||||||
|
new UpdateProjectActivityCommand(
|
||||||
|
id,
|
||||||
|
activityId,
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getTitle(),
|
||||||
|
body.getSummary(),
|
||||||
|
body.getVisibility() == null ? null : body.getVisibility().getValue(),
|
||||||
|
instant(body.getOccurredAt()),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/projects/{id}/activities/{activityId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteProjectActivity(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@PathVariable("activityId") UUID activityId,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteActivity.handle(
|
||||||
|
id, activityId, body.getExpectedVersion(), ManagementPrincipals.require(principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.time.Instant instant(OffsetDateTime value) {
|
||||||
|
return value == null ? null : value.toInstant();
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 프로젝트 관리. 계약의 project CRUD 5개. */
|
||||||
|
@RestController
|
||||||
|
public class ManagementProjectController {
|
||||||
|
|
||||||
|
private final ListStudioProjectsUseCase listProjects;
|
||||||
|
private final GetProjectForEditUseCase getProject;
|
||||||
|
private final CreateProjectUseCase createProject;
|
||||||
|
private final UpdateProjectUseCase updateProject;
|
||||||
|
private final DeleteProjectUseCase deleteProject;
|
||||||
|
|
||||||
|
private final PublishProjectUseCase publishProject;
|
||||||
|
private final UnpublishProjectUseCase unpublishProject;
|
||||||
|
|
||||||
|
public ManagementProjectController(
|
||||||
|
ListStudioProjectsUseCase listProjects,
|
||||||
|
GetProjectForEditUseCase getProject,
|
||||||
|
CreateProjectUseCase createProject,
|
||||||
|
UpdateProjectUseCase updateProject,
|
||||||
|
DeleteProjectUseCase deleteProject,
|
||||||
|
PublishProjectUseCase publishProject,
|
||||||
|
UnpublishProjectUseCase unpublishProject) {
|
||||||
|
this.listProjects = listProjects;
|
||||||
|
this.getProject = getProject;
|
||||||
|
this.createProject = createProject;
|
||||||
|
this.updateProject = updateProject;
|
||||||
|
this.deleteProject = deleteProject;
|
||||||
|
this.publishProject = publishProject;
|
||||||
|
this.unpublishProject = unpublishProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/projects")
|
||||||
|
public ProjectIndexPage listStudioProjects(
|
||||||
|
@RequestParam(name = "page", defaultValue = "0") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
return ManagementResponseMapper.projects(listProjects.handle(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/projects/{id}")
|
||||||
|
public ProjectEditResponse getProjectForEdit(@PathVariable("id") UUID id) {
|
||||||
|
return ManagementResponseMapper.project(getProject.handle(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/projects")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public CreateDraftResponse createProject(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@RequestBody CreateDraftRequest body) {
|
||||||
|
return ManagementResponseMapper.draft(
|
||||||
|
createProject.handle(
|
||||||
|
new CreateProjectCommand(body.getTitle(), ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/projects/{id}")
|
||||||
|
public ProjectEditResponse updateProject(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ProjectUpdateRequest body) {
|
||||||
|
List<String> labels =
|
||||||
|
body.getTechnologyLabels() == null ? List.of() : List.copyOf(body.getTechnologyLabels());
|
||||||
|
return ManagementResponseMapper.project(
|
||||||
|
updateProject.handle(
|
||||||
|
new UpdateProjectCommand(
|
||||||
|
id,
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getName(),
|
||||||
|
body.getSlug(),
|
||||||
|
body.getOneLinePurpose(),
|
||||||
|
body.getPurposeMarkdown(),
|
||||||
|
body.getBoundaryMarkdown(),
|
||||||
|
body.getSystemOverviewMarkdown(),
|
||||||
|
body.getPhase(),
|
||||||
|
body.getCurrentObjective(),
|
||||||
|
body.getNextStep(),
|
||||||
|
labels,
|
||||||
|
body.getTopicIds() == null ? List.of() : List.copyOf(body.getTopicIds()),
|
||||||
|
body.getTargetVisibility().getValue(),
|
||||||
|
body.getFeaturedOrder(),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/projects/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteProject(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteProject.handle(
|
||||||
|
new DeleteProjectCommand(
|
||||||
|
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/projects/{id}/publish")
|
||||||
|
public PublishResponse publishProject(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody PublishRequest body) {
|
||||||
|
return ManagementResponseMapper.projectPublication(
|
||||||
|
publishProject.handle(
|
||||||
|
id,
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getVisibility() == null ? null : body.getVisibility().getValue(),
|
||||||
|
ManagementPrincipals.require(principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/projects/{id}/unpublish")
|
||||||
|
public ProjectEditResponse unpublishProject(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
return ManagementResponseMapper.project(
|
||||||
|
unpublishProject.handle(
|
||||||
|
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEditResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseUpdateRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.ReleaseLifecycleCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 릴리즈 관리. 계약의 release 7개. */
|
||||||
|
@RestController
|
||||||
|
public class ManagementReleaseController {
|
||||||
|
|
||||||
|
private final ListStudioReleasesUseCase listReleases;
|
||||||
|
private final GetReleaseForEditUseCase getRelease;
|
||||||
|
private final CreateReleaseUseCase createRelease;
|
||||||
|
private final UpdateReleaseUseCase updateRelease;
|
||||||
|
private final DeleteReleaseUseCase deleteRelease;
|
||||||
|
private final PublishReleaseUseCase publishRelease;
|
||||||
|
private final ArchiveReleaseUseCase archiveRelease;
|
||||||
|
|
||||||
|
public ManagementReleaseController(
|
||||||
|
ListStudioReleasesUseCase listReleases,
|
||||||
|
GetReleaseForEditUseCase getRelease,
|
||||||
|
CreateReleaseUseCase createRelease,
|
||||||
|
UpdateReleaseUseCase updateRelease,
|
||||||
|
DeleteReleaseUseCase deleteRelease,
|
||||||
|
PublishReleaseUseCase publishRelease,
|
||||||
|
ArchiveReleaseUseCase archiveRelease) {
|
||||||
|
this.listReleases = listReleases;
|
||||||
|
this.getRelease = getRelease;
|
||||||
|
this.createRelease = createRelease;
|
||||||
|
this.updateRelease = updateRelease;
|
||||||
|
this.deleteRelease = deleteRelease;
|
||||||
|
this.publishRelease = publishRelease;
|
||||||
|
this.archiveRelease = archiveRelease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/releases")
|
||||||
|
public ReleaseIndexPage listStudioReleases(
|
||||||
|
@RequestParam(name = "page", defaultValue = "0") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
return ManagementResponseMapper.releases(listReleases.handle(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/releases/{id}")
|
||||||
|
public ReleaseEditResponse getReleaseForEdit(@PathVariable("id") UUID id) {
|
||||||
|
return ManagementResponseMapper.release(getRelease.handle(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/releases")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public CreateDraftResponse createRelease(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@RequestBody CreateDraftRequest body) {
|
||||||
|
return ManagementResponseMapper.releaseDraft(
|
||||||
|
createRelease.handle(
|
||||||
|
new CreateReleaseCommand(body.getTitle(), ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/releases/{id}")
|
||||||
|
public ReleaseEditResponse updateRelease(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ReleaseUpdateRequest body) {
|
||||||
|
List<String> changeTypes =
|
||||||
|
body.getChangeTypes() == null ? List.of() : List.copyOf(body.getChangeTypes());
|
||||||
|
return ManagementResponseMapper.release(
|
||||||
|
updateRelease.handle(
|
||||||
|
new UpdateReleaseCommand(
|
||||||
|
id,
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
body.getVersionLabel(),
|
||||||
|
body.getTitle(),
|
||||||
|
body.getSummary(),
|
||||||
|
body.getReleasedOn(),
|
||||||
|
changeTypes,
|
||||||
|
body.getReasonMarkdown(),
|
||||||
|
body.getChangesMarkdown(),
|
||||||
|
body.getUserImpactMarkdown(),
|
||||||
|
body.getImplementationImpactMarkdown(),
|
||||||
|
body.getVerificationMarkdown(),
|
||||||
|
body.getKnownLimitationsMarkdown(),
|
||||||
|
ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/releases/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteRelease(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteRelease.handle(lifecycle(id, body, principal));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/releases/{id}/publish")
|
||||||
|
public PublishResponse publishRelease(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
return ManagementResponseMapper.releasePublication(
|
||||||
|
publishRelease.handle(lifecycle(id, body, principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/releases/{id}/archive")
|
||||||
|
public ReleaseEditResponse archiveRelease(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
return ManagementResponseMapper.release(archiveRelease.handle(lifecycle(id, body, principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReleaseLifecycleCommand lifecycle(
|
||||||
|
UUID id, ExpectedVersionRequest body, AuthenticatedPrincipal principal) {
|
||||||
|
return new ReleaseLifecycleCommand(
|
||||||
|
id, body.getExpectedVersion(), ManagementPrincipals.require(principal));
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.DeleteTopicCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.SaveTopicCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code deleteTopic}.
|
||||||
|
*
|
||||||
|
* <p>쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와 저장소가 정한 값을 쓴다 — 그러지 않으면
|
||||||
|
* 클라이언트가 남의 행을 덮어쓸 수 있다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ManagementTopicController {
|
||||||
|
|
||||||
|
private final ListStudioTopicsUseCase listTopics;
|
||||||
|
private final SaveTopicUseCase saveTopic;
|
||||||
|
private final DeleteTopicUseCase deleteTopic;
|
||||||
|
|
||||||
|
public ManagementTopicController(
|
||||||
|
ListStudioTopicsUseCase listTopics,
|
||||||
|
SaveTopicUseCase saveTopic,
|
||||||
|
DeleteTopicUseCase deleteTopic) {
|
||||||
|
this.listTopics = listTopics;
|
||||||
|
this.saveTopic = saveTopic;
|
||||||
|
this.deleteTopic = deleteTopic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/topics")
|
||||||
|
public List<TopicEdit> listStudioTopics() {
|
||||||
|
return ManagementResponseMapper.topics(listTopics.handle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/topics")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public TopicEdit createTopic(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal, @RequestBody TopicEdit body) {
|
||||||
|
return ManagementResponseMapper.topic(
|
||||||
|
saveTopic.handle(command(null, body, ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/topics/{id}")
|
||||||
|
public TopicEdit updateTopic(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody TopicEdit body) {
|
||||||
|
return ManagementResponseMapper.topic(
|
||||||
|
saveTopic.handle(command(id, body, ManagementPrincipals.require(principal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/topics/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteTopic(
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
@PathVariable("id") UUID id,
|
||||||
|
@RequestBody ExpectedVersionRequest body) {
|
||||||
|
deleteTopic.handle(
|
||||||
|
new DeleteTopicCommand(
|
||||||
|
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SaveTopicCommand command(UUID id, TopicEdit body, String actor) {
|
||||||
|
return new SaveTopicCommand(
|
||||||
|
id,
|
||||||
|
body.getName(),
|
||||||
|
body.getSlug(),
|
||||||
|
body.getDescription(),
|
||||||
|
body.getScope(),
|
||||||
|
body.getStatus() == null ? null : body.getStatus().getValue(),
|
||||||
|
body.getExpectedVersion(),
|
||||||
|
actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.management.mapper;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublicationStatus;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEditResponse;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.TopicEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 애플리케이션 뷰 → 계약 모델. 반대 방향(요청 → command)은 컨트롤러가 직접 한다. */
|
||||||
|
public final class ManagementResponseMapper {
|
||||||
|
|
||||||
|
private ManagementResponseMapper() {}
|
||||||
|
|
||||||
|
private static OffsetDateTime at(Instant value) {
|
||||||
|
return value == null ? null : value.atOffset(ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TopicEdit topic(TopicEditView view) {
|
||||||
|
TopicEdit model = new TopicEdit(view.name(), view.slug());
|
||||||
|
model.setId(view.id());
|
||||||
|
model.setVersion(view.version());
|
||||||
|
model.setDescription(view.description());
|
||||||
|
model.setScope(view.scope());
|
||||||
|
if (view.status() != null) {
|
||||||
|
model.setStatus(TopicEdit.StatusEnum.fromValue(view.status()));
|
||||||
|
}
|
||||||
|
model.setFeaturedReferenceId(view.featuredReferenceId());
|
||||||
|
model.setFeaturedCaseIds(List.copyOf(view.featuredCaseIds()));
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<TopicEdit> topics(List<TopicEditView> views) {
|
||||||
|
return views.stream().map(ManagementResponseMapper::topic).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 발행 상태는 두 타임스탬프에서 파생한다 — 테이블에 상태 컬럼이 따로 없고, 그 둘이 사실의 출처이기 때문이다. */
|
||||||
|
private static PublicationStatus publication(Instant first, Instant last) {
|
||||||
|
// 상태 컬럼이 따로 없으므로 두 타임스탬프에서 파생한다. WITHDRAWN 은 발행 이력이 있는데
|
||||||
|
// 지금은 내려간 상태인데, 그 구분은 unpublish 를 구현할 때 생긴다 — 지금은 그 경로가
|
||||||
|
// 없으므로 발행된 적이 있으면 ACTIVE 다.
|
||||||
|
PublicationStatus status =
|
||||||
|
new PublicationStatus(
|
||||||
|
first == null
|
||||||
|
? PublicationStatus.StateEnum.NEVER_PUBLISHED
|
||||||
|
: PublicationStatus.StateEnum.ACTIVE,
|
||||||
|
false);
|
||||||
|
status.setPublishedAt(at(last));
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProjectEditResponse project(ProjectEditView view) {
|
||||||
|
ProjectEditResponse model =
|
||||||
|
new ProjectEditResponse(
|
||||||
|
view.id(),
|
||||||
|
view.version(),
|
||||||
|
view.name(),
|
||||||
|
view.phase(),
|
||||||
|
view.workflowStatus(),
|
||||||
|
view.targetVisibility(),
|
||||||
|
publication(view.firstPublishedAt(), view.lastPublishedAt()),
|
||||||
|
at(view.updatedAt()));
|
||||||
|
model.setSlug(view.slug());
|
||||||
|
model.setOneLinePurpose(view.oneLinePurpose());
|
||||||
|
model.setPurposeMarkdown(view.purposeMarkdown());
|
||||||
|
model.setBoundaryMarkdown(view.boundaryMarkdown());
|
||||||
|
model.setSystemOverviewMarkdown(view.systemOverviewMarkdown());
|
||||||
|
model.setCurrentObjective(view.currentObjective());
|
||||||
|
model.setNextStep(view.nextStep());
|
||||||
|
model.setTechnologyLabels(List.copyOf(view.technologyLabels()));
|
||||||
|
model.setFeaturedOrder(view.featuredOrder());
|
||||||
|
model.setTopicIds(List.copyOf(view.topicIds()));
|
||||||
|
model.setDocumentLinks(List.of());
|
||||||
|
model.setQuestionLinks(List.of());
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CreateDraftResponse draft(ProjectEditView view) {
|
||||||
|
return new CreateDraftResponse(
|
||||||
|
view.id(), CreateDraftResponse.StatusEnum.DRAFT, view.version(), at(view.updatedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProjectIndexItem indexItem(ProjectIndexItemView view) {
|
||||||
|
ProjectIndexItem item =
|
||||||
|
new ProjectIndexItem(
|
||||||
|
view.id(),
|
||||||
|
view.name(),
|
||||||
|
view.phase(),
|
||||||
|
view.workflowStatus(),
|
||||||
|
view.targetVisibility(),
|
||||||
|
at(view.updatedAt()),
|
||||||
|
view.version(),
|
||||||
|
publication(view.firstPublishedAt(), view.lastPublishedAt()));
|
||||||
|
item.setCurrentObjective(view.currentObjective());
|
||||||
|
item.setNextStep(view.nextStep());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PageMetadata pageMetadata(
|
||||||
|
int number, int size, int totalElements, int totalPages) {
|
||||||
|
return new PageMetadata(
|
||||||
|
number, size, (long) totalElements, totalPages, number > 0, number + 1 < totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProjectIndexPage projects(ListStudioProjectsUseCase.Page page) {
|
||||||
|
return new ProjectIndexPage(
|
||||||
|
page.items().stream().map(ManagementResponseMapper::indexItem).toList(),
|
||||||
|
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 릴리즈 응답은 {@code ReleaseUpdateRequest} 를 allOf 로 물고 있어 {@code expectedVersion} 을 함께 싣는다. 현재
|
||||||
|
* version 을 그대로 넣는다 — 그래야 편집 화면이 받은 응답을 그대로 다음 저장 요청으로 되돌려보낼 수 있고, 두 값이 갈라질 이유가 없다.
|
||||||
|
*/
|
||||||
|
public static ReleaseEditResponse release(ReleaseEditView view) {
|
||||||
|
ReleaseEditResponse model =
|
||||||
|
new ReleaseEditResponse(
|
||||||
|
view.version(),
|
||||||
|
view.versionLabel(),
|
||||||
|
view.title(),
|
||||||
|
nullToEmpty(view.summary()),
|
||||||
|
List.copyOf(view.changeTypes()),
|
||||||
|
nullToEmpty(view.changesMarkdown()),
|
||||||
|
nullToEmpty(view.verificationMarkdown()),
|
||||||
|
view.id(),
|
||||||
|
view.workflowStatus(),
|
||||||
|
view.version(),
|
||||||
|
publication(view.firstPublishedAt(), view.lastPublishedAt()));
|
||||||
|
model.setReleasedOn(view.releasedOn());
|
||||||
|
model.setReasonMarkdown(view.reasonMarkdown());
|
||||||
|
model.setUserImpactMarkdown(view.userImpactMarkdown());
|
||||||
|
model.setImplementationImpactMarkdown(view.implementationImpactMarkdown());
|
||||||
|
model.setKnownLimitationsMarkdown(view.knownLimitationsMarkdown());
|
||||||
|
// 관련 자료 링크는 별도 테이블이 소유하고 그 편집 화면이 아직 없다.
|
||||||
|
model.setRelatedResources(List.of());
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CreateDraftResponse releaseDraft(ReleaseEditView view) {
|
||||||
|
return new CreateDraftResponse(
|
||||||
|
view.id(), CreateDraftResponse.StatusEnum.DRAFT, view.version(), at(view.updatedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 릴리즈에는 자체 공개 경로가 있다 — 공개 화면이 {@code version_label} 로 조회하므로 canonical path 도 그것으로 만든다. */
|
||||||
|
public static PublishResponse releasePublication(ReleaseEditView view) {
|
||||||
|
return new PublishResponse(
|
||||||
|
view.id(),
|
||||||
|
PublishResponse.StatusEnum.PUBLISHED,
|
||||||
|
PublishResponse.VisibilityEnum.PUBLIC,
|
||||||
|
"/releases/" + view.versionLabel(),
|
||||||
|
at(view.lastPublishedAt()),
|
||||||
|
view.version());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReleaseIndexItem releaseIndexItem(ReleaseIndexItemView view) {
|
||||||
|
ReleaseIndexItem item =
|
||||||
|
new ReleaseIndexItem(
|
||||||
|
view.id(),
|
||||||
|
view.versionLabel(),
|
||||||
|
view.title(),
|
||||||
|
view.workflowStatus(),
|
||||||
|
at(view.updatedAt()),
|
||||||
|
view.version(),
|
||||||
|
publication(view.firstPublishedAt(), view.lastPublishedAt()));
|
||||||
|
item.setReleasedOn(view.releasedOn());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReleaseIndexPage releases(ListStudioReleasesUseCase.Page page) {
|
||||||
|
return new ReleaseIndexPage(
|
||||||
|
page.items().stream().map(ManagementResponseMapper::releaseIndexItem).toList(),
|
||||||
|
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 홈 focus 설정. 세 슬롯은 비어 있을 수 있고, 비어 있음이 곧 "고르지 않았다"는 뜻이다. */
|
||||||
|
public static HomeFocusResponse homeFocus(HomeFocusConfigView view) {
|
||||||
|
HomeFocusResponse response = new HomeFocusResponse(view.id(), view.version());
|
||||||
|
response.setDefaultType(view.defaultType());
|
||||||
|
response.setCurrentProjectId(view.currentProjectId());
|
||||||
|
response.setOpenQuestionId(view.openQuestionId());
|
||||||
|
response.setRecentDecisionId(view.recentDecisionId());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로젝트 게시 결과. canonical path 는 공개 조회가 쓰는 {@code /projects/<slug>} 와 같은 규칙으로 만든다. */
|
||||||
|
public static PublishResponse projectPublication(ProjectEditView view) {
|
||||||
|
return new PublishResponse(
|
||||||
|
view.id(),
|
||||||
|
PublishResponse.StatusEnum.PUBLISHED,
|
||||||
|
"UNLISTED".equals(view.targetVisibility())
|
||||||
|
? PublishResponse.VisibilityEnum.UNLISTED
|
||||||
|
: PublishResponse.VisibilityEnum.PUBLIC,
|
||||||
|
"/projects/" + view.slug(),
|
||||||
|
at(view.lastPublishedAt()),
|
||||||
|
view.version());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로젝트 활동 한 줄. {@code origin} 을 그대로 실어 보낸다 — 화면이 지울 수 있는 줄과 아닌 줄을 그것으로 가른다. */
|
||||||
|
public static ProjectActivityResponse projectActivity(ProjectActivityView view) {
|
||||||
|
ProjectActivityResponse response =
|
||||||
|
new ProjectActivityResponse(
|
||||||
|
view.id(),
|
||||||
|
view.projectId(),
|
||||||
|
view.activityType(),
|
||||||
|
view.title(),
|
||||||
|
view.visibility(),
|
||||||
|
view.origin(),
|
||||||
|
at(view.occurredAt()),
|
||||||
|
view.version());
|
||||||
|
response.setSummary(view.summary());
|
||||||
|
response.setRelatedResourceType(view.relatedResourceType());
|
||||||
|
response.setRelatedResourceId(view.relatedResourceId());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static java.util.List<ProjectActivityResponse> projectActivities(
|
||||||
|
java.util.List<ProjectActivityView> views) {
|
||||||
|
return views.stream().map(ManagementResponseMapper::projectActivity).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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} 단일 출처.
|
||||||
|
*
|
||||||
|
* <p>{@code PublicException#getMessage()}는 use case 가 진단용으로 채우는 원문이라 {@code ApiErrorCarrier}
|
||||||
|
* javadoc 이 경고하는 대로 저장소 내부 사정을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고 이 클래스가 code 별 고정 문구만 내보낸다 — {@code
|
||||||
|
* StudioClientSafeMessages}가 {@code StudioError}에 대해 하는 것과 같은 역할이다.
|
||||||
|
*
|
||||||
|
* <p>문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 —
|
||||||
|
* {@code PublicErrorRegistryTest}가 그 일치를 고정한다.
|
||||||
|
*
|
||||||
|
* <p>{@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 -> "요청한 자료를 찾을 수 없습니다";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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 대상이다.
|
||||||
|
*
|
||||||
|
* <p><b>{@code basePackages} 스코프.</b> 이 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}로 좁혔다. 두 스코프는 이제 겹치지 않는다.
|
||||||
|
*
|
||||||
|
* <p>{@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<Envelope<Void>> 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<Envelope<Void>> 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<Envelope<Void>> 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<Envelope<Void>> requestInvalid(
|
||||||
|
String field, String code, String message) {
|
||||||
|
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
|
||||||
|
Map<String, Object> details = Map.of("fieldErrors", List.of(fieldError));
|
||||||
|
return ErrorResponseFactory.envelope(
|
||||||
|
PublicError.PUBLIC_REQUEST_INVALID,
|
||||||
|
PublicClientSafeMessages.forError(PublicError.PUBLIC_REQUEST_INVALID),
|
||||||
|
details);
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>계약이 enum 을 선언한 파라미터는 {@link PublicRequestParams} 로 검사한다 — 이유는 그 클래스 javadoc.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class PublicExploreController {
|
||||||
|
|
||||||
|
private static final Set<String> KNOWLEDGE_TYPES = Set.of("CASE", "REFERENCE");
|
||||||
|
private static final Set<String> KNOWLEDGE_SORTS =
|
||||||
|
Set.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC");
|
||||||
|
private static final Set<String> QUESTION_STATUSES =
|
||||||
|
Set.of("OPEN", "INVESTIGATING", "PAUSED", "RESOLVED");
|
||||||
|
private static final Set<String> QUESTION_SORTS =
|
||||||
|
Set.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC");
|
||||||
|
private static final Set<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.publicsite.model.PublicMediaView;
|
||||||
|
import dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드한 이미지를 실제로 내보내는 곳.
|
||||||
|
*
|
||||||
|
* <p>이 경로가 없어서 Asset 은 저장은 되지만 어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 asset 주소를 가리키는데 그 주소를 서빙하는 코드가 없었다.
|
||||||
|
*
|
||||||
|
* <p>{@code /api/v1/public/} 아래에 두는 이유는 그 접두사가 이미 익명 허용({@code security.public-paths})이고 엣지가 이미
|
||||||
|
* 백엔드로 보내기 때문이다. 별도 접두사를 쓰면 nginx 를 함께 고쳐야 하고, 그 사이에 이미지는 계속 깨진다.
|
||||||
|
*
|
||||||
|
* <p>이 표면은 봉투를 쓰지 않는다. 바이트를 반환하므로 {@code EnvelopeBodyAdvice} 가 감쌀 것이 없고, {@code <img src>} 는 JSON 을
|
||||||
|
* 읽지 않는다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class PublicMediaController {
|
||||||
|
|
||||||
|
/** 내용이 바뀌면 주소도 바뀐다 — Asset 은 업로드 시점에 고정되고 같은 id 로 다른 바이트가 오지 않는다. 그래서 길게 캐시하고 immutable 을 붙인다. */
|
||||||
|
private static final CacheControl CACHE =
|
||||||
|
CacheControl.maxAge(java.time.Duration.ofDays(365)).cachePublic().immutable();
|
||||||
|
|
||||||
|
private final GetPublicMediaUseCase media;
|
||||||
|
|
||||||
|
public PublicMediaController(GetPublicMediaUseCase media) {
|
||||||
|
this.media = media;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/public/media/{assetId}")
|
||||||
|
public ResponseEntity<byte[]> getPublicMedia(@PathVariable("assetId") UUID assetId) {
|
||||||
|
PublicMediaView view = media.handle(assetId);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.cacheControl(CACHE)
|
||||||
|
.contentType(MediaType.parseMediaType(view.mediaType()))
|
||||||
|
.contentLength(view.content().length)
|
||||||
|
.body(view.content());
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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}).
|
||||||
|
*
|
||||||
|
* <p>하위 목록은 프로젝트 자체가 공개가 아니면 빈 페이지가 아니라 404 다 — 비공개 프로젝트의 존재가 "결정이 0건인 프로젝트"로 새어 나가면 안 된다. 그 구분은
|
||||||
|
* port 가 {@code Optional} 로 표현하고 use case 가 404 로 옮긴다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class PublicProjectController {
|
||||||
|
|
||||||
|
private static final Set<String> RECORD_TYPES = Set.of("CASE", "REFERENCE", "QUESTION");
|
||||||
|
private static final Set<String> 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))));
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>{@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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약이 쿼리 파라미터에 건 제약을 요청 경계에서 강제한다.
|
||||||
|
*
|
||||||
|
* <p>enum 값을 검사하지 않고 그대로 SQL 필터로 넘기면 오타(`type=CASES`)가 오류가 아니라 "결과 0건"으로 보인다 — 소비자는 자기 요청이 틀렸다는 사실을
|
||||||
|
* 영영 알 수 없다. 계약이 enum 을 선언한 자리는 계약 밖 값을 {@code PUBLIC_REQUEST_INVALID} 로 거절한다.
|
||||||
|
*
|
||||||
|
* <p>파라미터를 생성 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<String> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>반환값을 Envelope 로 감싸지 않는다 — {@code EnvelopeBodyAdvice} 가 감싼다. 계약의 {@code <Payload>Envelope} 스키마로
|
||||||
|
* 생성된 DTO 는 쓰지 않는다(그걸 반환하면 봉투가 두 번 씌워진다).
|
||||||
|
*
|
||||||
|
* <p>경로에 {@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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
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.ReferenceDetailResponseReferenceRulesInner;
|
||||||
|
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} 의 응답 조립.
|
||||||
|
*
|
||||||
|
* <p>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.setSummary(doc.summary());
|
||||||
|
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.setBodyAssets(PublicResponseMapper.map(doc.bodyAssets(), PublicResponseMapper::bodyAsset));
|
||||||
|
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.setSummary(doc.summary());
|
||||||
|
body.setScopeSummary(doc.primarySummary());
|
||||||
|
body.setAppliesTo(doc.appliesTo());
|
||||||
|
body.setExcludedScope(doc.excludedScope());
|
||||||
|
// Reference 의 본문은 `content` 가 아니라 여기 있다 — 이것을 빼면 공개 화면에 판단 기준과
|
||||||
|
// 예시가 통째로 빠진다.
|
||||||
|
body.setRules(
|
||||||
|
doc.rules().stream()
|
||||||
|
.map(rule -> new ReferenceDetailResponseReferenceRulesInner(rule.title(), rule.body()))
|
||||||
|
.toList());
|
||||||
|
body.setExamples(doc.examples());
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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} 의 응답 조립.
|
||||||
|
*
|
||||||
|
* <p>{@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
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.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
|
||||||
|
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<ProjectListItemView> 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.setTopics(
|
||||||
|
p.topics().stream().map(topic -> new TopicSummary(topic.name(), topic.slug())).toList());
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
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.BodyAsset;
|
||||||
|
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.BodyAssetView;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본문이 {@code :::evidence key="..."} 로 가리키는 Asset. key 에서 주소를 만들 수 없어 함께 내려보낸다. */
|
||||||
|
public static BodyAsset bodyAsset(BodyAssetView view) {
|
||||||
|
BodyAsset dto = new BodyAsset();
|
||||||
|
dto.setAssetKey(view.assetKey());
|
||||||
|
dto.setAssetId(view.assetId());
|
||||||
|
dto.setUrl(view.url());
|
||||||
|
dto.setContentType(view.contentType());
|
||||||
|
dto.setAltText(view.altText());
|
||||||
|
dto.setWidth(view.width());
|
||||||
|
dto.setHeight(view.height());
|
||||||
|
dto.setDecorative(view.decorative());
|
||||||
|
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 <S, T> List<T> map(List<S> source, Function<S, T> mapper) {
|
||||||
|
return source == null ? List.of() : source.stream().map(mapper).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<RelatedEntry> relatedList(List<RelatedEntryView> views) {
|
||||||
|
return map(views, PublicResponseMapper::related);
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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<ReleaseListItemView> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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<TopicListItemView> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UpdateAssetCommand;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioError;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
|
import dev.caskeleton.application.techlog.studio.command.DeleteAssetCommand;
|
||||||
|
import dev.caskeleton.application.techlog.studio.command.UploadAssetCommand;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.GetAssetQuery;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code studio-v1.yaml}의 Assets 다섯 operation.
|
||||||
|
*
|
||||||
|
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class StudioAssetController {
|
||||||
|
|
||||||
|
private final ListStudioAssetsUseCase listAssets;
|
||||||
|
private final UploadStudioAssetUseCase uploadAsset;
|
||||||
|
private final GetStudioAssetUseCase getAsset;
|
||||||
|
private final UpdateStudioAssetUseCase updateAsset;
|
||||||
|
private final DeleteStudioAssetUseCase deleteAsset;
|
||||||
|
private final StudioDetailMapper detailMapper;
|
||||||
|
private final StudioCursors cursors;
|
||||||
|
private final StudioIdempotency idempotency;
|
||||||
|
|
||||||
|
public StudioAssetController(
|
||||||
|
ListStudioAssetsUseCase listAssets,
|
||||||
|
UploadStudioAssetUseCase uploadAsset,
|
||||||
|
GetStudioAssetUseCase getAsset,
|
||||||
|
UpdateStudioAssetUseCase updateAsset,
|
||||||
|
DeleteStudioAssetUseCase deleteAsset,
|
||||||
|
StudioDetailMapper detailMapper,
|
||||||
|
StudioCursors cursors,
|
||||||
|
StudioIdempotency idempotency) {
|
||||||
|
this.listAssets = listAssets;
|
||||||
|
this.uploadAsset = uploadAsset;
|
||||||
|
this.getAsset = getAsset;
|
||||||
|
this.updateAsset = updateAsset;
|
||||||
|
this.deleteAsset = deleteAsset;
|
||||||
|
this.detailMapper = detailMapper;
|
||||||
|
this.cursors = cursors;
|
||||||
|
this.idempotency = idempotency;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/assets")
|
||||||
|
public AssetPage listStudioAssets(
|
||||||
|
@RequestParam(value = "q", required = false) String query,
|
||||||
|
@RequestParam(value = "kind", required = false) AssetKind kind,
|
||||||
|
@RequestParam(value = "managementStatus", required = false) AssetManagementStatus status,
|
||||||
|
@RequestParam(value = "cursor", required = false) String cursor,
|
||||||
|
@RequestParam(value = "limit", defaultValue = "20") int limit) {
|
||||||
|
|
||||||
|
String fingerprint =
|
||||||
|
StudioCursors.fingerprint(
|
||||||
|
query,
|
||||||
|
kind == null ? null : kind.getValue(),
|
||||||
|
status == null ? null : status.getValue());
|
||||||
|
DocumentCursorPosition position =
|
||||||
|
cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false);
|
||||||
|
|
||||||
|
var page =
|
||||||
|
listAssets.handle(
|
||||||
|
new ListAssetsQuery(
|
||||||
|
query,
|
||||||
|
kind == null ? null : AssetKindView.valueOf(kind.getValue()),
|
||||||
|
status == null ? null : AssetManagementStatusView.valueOf(status.getValue()),
|
||||||
|
position == null ? null : position.updatedAt(),
|
||||||
|
position == null ? null : position.id(),
|
||||||
|
limit));
|
||||||
|
|
||||||
|
AssetPage body = detailMapper.toApi(page);
|
||||||
|
body.setNextCursor(
|
||||||
|
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/v1/studio/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ResponseEntity<Asset> uploadStudioAsset(
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam("kind") AssetKind kind,
|
||||||
|
@RequestParam(value = "altText", required = false) String altText,
|
||||||
|
@RequestParam(value = "decorative", defaultValue = "false") boolean decorative,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
byte[] content;
|
||||||
|
try {
|
||||||
|
content = file.getBytes();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.REQUEST_VALIDATION_FAILED, "the uploaded file could not be read");
|
||||||
|
}
|
||||||
|
|
||||||
|
StudioIdempotency.Outcome<Asset> outcome =
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"uploadStudioAsset",
|
||||||
|
// 바이트 전체가 아니라 파일명·크기·종류로 지문을 만든다 — 20MB 를 다시 직렬화해 해시하면
|
||||||
|
// 업로드마다 그만큼을 한 번 더 읽고 쓰는 셈이 된다.
|
||||||
|
java.util.List.of(
|
||||||
|
String.valueOf(file.getOriginalFilename()),
|
||||||
|
String.valueOf(file.getSize()),
|
||||||
|
kind.getValue()),
|
||||||
|
Asset.class,
|
||||||
|
() ->
|
||||||
|
detailMapper.toApi(
|
||||||
|
uploadAsset.handle(
|
||||||
|
new UploadAssetCommand(
|
||||||
|
file.getOriginalFilename(),
|
||||||
|
file.getContentType(),
|
||||||
|
content,
|
||||||
|
AssetKindView.valueOf(kind.getValue()),
|
||||||
|
altText,
|
||||||
|
decorative,
|
||||||
|
StudioPrincipals.require(principal)))));
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
|
||||||
|
.body(outcome.result());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/assets/{assetId}")
|
||||||
|
public AssetDetail getStudioAsset(@PathVariable("assetId") UUID assetId) {
|
||||||
|
return detailMapper.toApi(getAsset.handle(new GetAssetQuery(assetId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/assets/{assetId}")
|
||||||
|
public ResponseEntity<Asset> updateStudioAsset(
|
||||||
|
@PathVariable("assetId") UUID assetId,
|
||||||
|
@Valid @RequestBody UpdateAssetCommand command,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
StudioIdempotency.Outcome<Asset> outcome =
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"updateStudioAsset",
|
||||||
|
command,
|
||||||
|
Asset.class,
|
||||||
|
() ->
|
||||||
|
detailMapper.toApi(
|
||||||
|
updateAsset.handle(
|
||||||
|
new dev.caskeleton.application.techlog.studio.command.UpdateAssetCommand(
|
||||||
|
assetId,
|
||||||
|
command.getExpectedVersion() == null
|
||||||
|
? 0L
|
||||||
|
: command.getExpectedVersion(),
|
||||||
|
command.getKind() == null
|
||||||
|
? null
|
||||||
|
: AssetKindView.valueOf(command.getKind().getValue()),
|
||||||
|
command.getAltText(),
|
||||||
|
command.getAltText() != null,
|
||||||
|
command.getDecorative(),
|
||||||
|
command.getManagementStatus() == null
|
||||||
|
? null
|
||||||
|
: AssetManagementStatusView.valueOf(
|
||||||
|
command.getManagementStatus().getValue()),
|
||||||
|
StudioPrincipals.require(principal)))));
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
|
||||||
|
.body(outcome.result());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/v1/studio/assets/{assetId}")
|
||||||
|
public ResponseEntity<Void> deleteStudioAsset(
|
||||||
|
@PathVariable("assetId") UUID assetId,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"deleteStudioAsset",
|
||||||
|
assetId.toString(),
|
||||||
|
Void.class,
|
||||||
|
() -> {
|
||||||
|
deleteAsset.handle(new DeleteAssetCommand(assetId, StudioPrincipals.require(principal)));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
// 계약은 204 다. 본문이 없으므로 EnvelopeBodyAdvice 도 감쌀 것이 없다.
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -29,7 +29,13 @@ public class StudioCatalogController {
|
|||||||
this.listCatalog = listCatalog;
|
this.listCatalog = listCatalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/v1/studio/catalog")
|
/**
|
||||||
|
* 경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code
|
||||||
|
* ca-skeleton.presentation.api-base-path}("/api") 를 모든 컨트롤러 매핑에 붙인다. 이 저장소의 다른 컨트롤러들(healthcheck,
|
||||||
|
* /v1/uploads, /v1/files)과 같은 규칙이며, 여기에 {@code /api} 를 다시 쓰면 실제 경로가 {@code /api/api/v1/...} 로 밀려
|
||||||
|
* 계약(studio-v1.yaml, {@code servers: "/"})이 선언한 주소에서 사라진다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/v1/studio/catalog")
|
||||||
public CatalogPage listStudioCatalog(
|
public CatalogPage listStudioCatalog(
|
||||||
@RequestParam("type") CatalogEntryType type,
|
@RequestParam("type") CatalogEntryType type,
|
||||||
@RequestParam(value = "q", required = false) String q,
|
@RequestParam(value = "q", required = false) String q,
|
||||||
|
|||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.SaveDocumentCommand;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioRequestMapper;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioError;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
|
import dev.caskeleton.application.techlog.studio.command.CreateDocumentCommand;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.GetDocumentQuery;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.SaveOutcome;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code studio-v1.yaml}의 Documents 네 operation.
|
||||||
|
*
|
||||||
|
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 springdoc이 게시한 이름과 계약을 대조한다.
|
||||||
|
* 이름을 바꾸면 그 게이트가 빨간불이 된다.
|
||||||
|
*
|
||||||
|
* <p>반환값을 봉투로 감싸지 않는다 — {@code EnvelopeBodyAdvice}가 감싼다(ADR-006).
|
||||||
|
*
|
||||||
|
* <p>경로에 {@code /api}를 쓰지 않는다 — {@code PresentationWebConfig}가 {@code
|
||||||
|
* ca-skeleton.presentation.api-base-path}를 모든 매핑에 붙인다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class StudioDocumentController {
|
||||||
|
|
||||||
|
private final ListStudioDocumentsUseCase listDocuments;
|
||||||
|
private final CreateStudioDocumentUseCase createDocument;
|
||||||
|
private final GetStudioDocumentUseCase getDocument;
|
||||||
|
private final SaveStudioDocumentUseCase saveDocument;
|
||||||
|
private final StudioDetailMapper detailMapper;
|
||||||
|
private final StudioCursors cursors;
|
||||||
|
private final StudioIdempotency idempotency;
|
||||||
|
|
||||||
|
public StudioDocumentController(
|
||||||
|
ListStudioDocumentsUseCase listDocuments,
|
||||||
|
CreateStudioDocumentUseCase createDocument,
|
||||||
|
GetStudioDocumentUseCase getDocument,
|
||||||
|
SaveStudioDocumentUseCase saveDocument,
|
||||||
|
StudioDetailMapper detailMapper,
|
||||||
|
StudioCursors cursors,
|
||||||
|
StudioIdempotency idempotency) {
|
||||||
|
this.listDocuments = listDocuments;
|
||||||
|
this.createDocument = createDocument;
|
||||||
|
this.getDocument = getDocument;
|
||||||
|
this.saveDocument = saveDocument;
|
||||||
|
this.detailMapper = detailMapper;
|
||||||
|
this.cursors = cursors;
|
||||||
|
this.idempotency = idempotency;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/documents")
|
||||||
|
public DocumentPage listStudioDocuments(
|
||||||
|
@RequestParam(value = "q", required = false) String query,
|
||||||
|
@RequestParam(value = "kind", required = false) RecordKind kind,
|
||||||
|
@RequestParam(value = "publicationStatus", required = false)
|
||||||
|
PublicationStatus publicationStatus,
|
||||||
|
@RequestParam(value = "nextAction", required = false) NextAction nextAction,
|
||||||
|
@RequestParam(value = "projectId", required = false) UUID projectId,
|
||||||
|
@RequestParam(value = "sort", defaultValue = "UPDATED_DESC") String sort,
|
||||||
|
@RequestParam(value = "cursor", required = false) String cursor,
|
||||||
|
@RequestParam(value = "limit", defaultValue = "20") int limit) {
|
||||||
|
|
||||||
|
DocumentSort documentSort = documentSort(sort);
|
||||||
|
// 지문에 정렬까지 넣는다 — 정렬만 바꾸고 커서를 재사용하면 커서가 가리키는 키의 의미가 달라진다.
|
||||||
|
String fingerprint =
|
||||||
|
StudioCursors.fingerprint(
|
||||||
|
query,
|
||||||
|
kind == null ? null : kind.getValue(),
|
||||||
|
publicationStatus == null ? null : publicationStatus.getValue(),
|
||||||
|
nextAction == null ? null : nextAction.getValue(),
|
||||||
|
projectId == null ? null : projectId.toString(),
|
||||||
|
documentSort.name());
|
||||||
|
DocumentCursorPosition position =
|
||||||
|
cursor == null || cursor.isBlank()
|
||||||
|
? null
|
||||||
|
: cursors.decode(cursor, fingerprint, documentSort == DocumentSort.TITLE_ASC);
|
||||||
|
|
||||||
|
DocumentPageView page =
|
||||||
|
listDocuments.handle(
|
||||||
|
new ListDocumentsQuery(
|
||||||
|
query,
|
||||||
|
kind == null
|
||||||
|
? null
|
||||||
|
: dev.caskeleton.application.techlog.studio.model.RecordKind.valueOf(
|
||||||
|
kind.getValue()),
|
||||||
|
publicationStatus == null
|
||||||
|
? null
|
||||||
|
: PublicationStatusView.valueOf(publicationStatus.getValue()),
|
||||||
|
nextAction == null
|
||||||
|
? null
|
||||||
|
: dev.caskeleton.application.techlog.studio.model.NextAction.valueOf(
|
||||||
|
nextAction.getValue()),
|
||||||
|
projectId,
|
||||||
|
documentSort,
|
||||||
|
position,
|
||||||
|
limit));
|
||||||
|
|
||||||
|
DocumentPage body = StudioResponseMapper.toApi(page);
|
||||||
|
// 어댑터가 준 것은 다음 쪽의 시작 위치일 뿐이다. 클라이언트에 나가는 것은 서명된 opaque 값이어야 한다.
|
||||||
|
body.setNextCursor(
|
||||||
|
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/documents")
|
||||||
|
public ResponseEntity<WorkingCopy> createStudioDocument(
|
||||||
|
@Valid @RequestBody WorkingCopyInput document,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
StudioIdempotency.Outcome<WorkingCopy> outcome =
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"createStudioDocument",
|
||||||
|
document,
|
||||||
|
WorkingCopy.class,
|
||||||
|
() -> {
|
||||||
|
WorkingCopyView created =
|
||||||
|
createDocument.handle(
|
||||||
|
new CreateDocumentCommand(
|
||||||
|
StudioRequestMapper.toApplication(document),
|
||||||
|
StudioPrincipals.require(principal)));
|
||||||
|
return StudioResponseMapper.toApi(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
|
||||||
|
.body(outcome.result());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/documents/{documentId}")
|
||||||
|
public WorkingCopyDetail getStudioDocument(@PathVariable("documentId") UUID documentId) {
|
||||||
|
return detailMapper.toApi(getDocument.handle(new GetDocumentQuery(documentId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/v1/studio/documents/{documentId}")
|
||||||
|
public ResponseEntity<WorkingCopyDetail> saveStudioDocument(
|
||||||
|
@PathVariable("documentId") UUID documentId,
|
||||||
|
@Valid @RequestBody SaveDocumentCommand command,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
StudioIdempotency.Outcome<WorkingCopyDetail> outcome =
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"saveStudioDocument",
|
||||||
|
command,
|
||||||
|
WorkingCopyDetail.class,
|
||||||
|
() -> {
|
||||||
|
SaveOutcome saved =
|
||||||
|
saveDocument.handle(
|
||||||
|
new dev.caskeleton.application.techlog.studio.command.SaveDocumentCommand(
|
||||||
|
documentId,
|
||||||
|
command.getExpectedVersion() == null ? 0 : command.getExpectedVersion(),
|
||||||
|
StudioRequestMapper.toApplication(command.getDocument()),
|
||||||
|
StudioPrincipals.require(principal)));
|
||||||
|
return switch (saved) {
|
||||||
|
case SaveOutcome.Saved value -> detailMapper.toApi(value.detail());
|
||||||
|
case SaveOutcome.VersionConflict value -> throw versionConflict(value.latest());
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
|
||||||
|
.body(outcome.result());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약의 {@code VersionConflictDetails}는 {@code latestDocument}로 현재 상태 전체를 함께 준다 — 클라이언트가 다시 조회하지
|
||||||
|
* 않고도 충돌 화면을 그릴 수 있어야 한다.
|
||||||
|
*/
|
||||||
|
private StudioException versionConflict(WorkingCopyDetailView latest) {
|
||||||
|
Map<String, Object> details = new LinkedHashMap<>();
|
||||||
|
details.put("latestDocument", detailMapper.toApi(latest));
|
||||||
|
return StudioException.withDetails(
|
||||||
|
StudioError.VERSION_CONFLICT, "expectedVersion does not match the stored version", details);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentSort documentSort(String sort) {
|
||||||
|
try {
|
||||||
|
return DocumentSort.valueOf(sort);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.REQUEST_VALIDATION_FAILED,
|
||||||
|
"sort must be one of UPDATED_DESC, UPDATED_ASC, TITLE_ASC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CreatePreviewCommand;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidateDocumentCommand;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.GetPreviewQuery;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code studio-v1.yaml}의 Validation / Preview 세 operation.
|
||||||
|
*
|
||||||
|
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class StudioPreviewController {
|
||||||
|
|
||||||
|
private final ValidateStudioDocumentUseCase validateDocument;
|
||||||
|
private final CreateStudioPreviewUseCase createPreview;
|
||||||
|
private final GetCurrentStudioPreviewUseCase getPreview;
|
||||||
|
private final StudioDetailMapper detailMapper;
|
||||||
|
private final StudioIdempotency idempotency;
|
||||||
|
|
||||||
|
public StudioPreviewController(
|
||||||
|
ValidateStudioDocumentUseCase validateDocument,
|
||||||
|
CreateStudioPreviewUseCase createPreview,
|
||||||
|
GetCurrentStudioPreviewUseCase getPreview,
|
||||||
|
StudioDetailMapper detailMapper,
|
||||||
|
StudioIdempotency idempotency) {
|
||||||
|
this.validateDocument = validateDocument;
|
||||||
|
this.createPreview = createPreview;
|
||||||
|
this.getPreview = getPreview;
|
||||||
|
this.detailMapper = detailMapper;
|
||||||
|
this.idempotency = idempotency;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/documents/{documentId}/validate")
|
||||||
|
public ResponseEntity<ValidationReport> validateStudioDocument(
|
||||||
|
@PathVariable("documentId") UUID documentId,
|
||||||
|
@Valid @RequestBody ValidateDocumentCommand command,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
StudioIdempotency.Outcome<ValidationReport> outcome =
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"validateStudioDocument",
|
||||||
|
command,
|
||||||
|
ValidationReport.class,
|
||||||
|
() ->
|
||||||
|
detailMapper.toApi(
|
||||||
|
validateDocument.handle(
|
||||||
|
new dev.caskeleton.application.techlog.studio.command
|
||||||
|
.ValidateDocumentCommand(
|
||||||
|
documentId,
|
||||||
|
expectedVersion(command.getExpectedVersion()),
|
||||||
|
StudioPrincipals.require(principal)))));
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
|
||||||
|
.body(outcome.result());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/documents/{documentId}/preview")
|
||||||
|
public PreviewDetail getCurrentStudioPreview(@PathVariable("documentId") UUID documentId) {
|
||||||
|
return detailMapper.toApi(getPreview.handle(new GetPreviewQuery(documentId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/documents/{documentId}/preview")
|
||||||
|
public ResponseEntity<PublicPreview> createStudioPreview(
|
||||||
|
@PathVariable("documentId") UUID documentId,
|
||||||
|
@Valid @RequestBody CreatePreviewCommand command,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
StudioIdempotency.Outcome<PublicPreview> outcome =
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"createStudioPreview",
|
||||||
|
command,
|
||||||
|
PublicPreview.class,
|
||||||
|
() ->
|
||||||
|
detailMapper.toApi(
|
||||||
|
createPreview.handle(
|
||||||
|
new dev.caskeleton.application.techlog.studio.command.CreatePreviewCommand(
|
||||||
|
documentId,
|
||||||
|
expectedVersion(command.getExpectedVersion()),
|
||||||
|
command.getValidationId(),
|
||||||
|
StudioPrincipals.require(principal)))));
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
|
||||||
|
.body(outcome.result());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다 — 0 은 어떤 저장된 버전과도 일치하지 않아
|
||||||
|
* VERSION_CONFLICT 로 나간다.
|
||||||
|
*/
|
||||||
|
private static long expectedVersion(Integer value) {
|
||||||
|
return value == null ? 0L : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishDocumentCommand;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnpublishCommand;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
|
||||||
|
import dev.caskeleton.application.techlog.studio.command.UnpublishPublicationCommand;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.GetDashboardQuery;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.GetSnapshotQuery;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase;
|
||||||
|
import dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code studio-v1.yaml}의 Publication / Dashboard 다섯 operation.
|
||||||
|
*
|
||||||
|
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class StudioPublicationController {
|
||||||
|
|
||||||
|
private final PublishStudioDocumentUseCase publishDocument;
|
||||||
|
private final UnpublishStudioPublicationUseCase unpublishPublication;
|
||||||
|
private final ListStudioPublicationsUseCase listPublications;
|
||||||
|
private final GetStudioPublicationSnapshotUseCase getSnapshot;
|
||||||
|
private final GetStudioDashboardUseCase getDashboard;
|
||||||
|
private final StudioDetailMapper detailMapper;
|
||||||
|
private final StudioCursors cursors;
|
||||||
|
private final StudioIdempotency idempotency;
|
||||||
|
|
||||||
|
public StudioPublicationController(
|
||||||
|
PublishStudioDocumentUseCase publishDocument,
|
||||||
|
UnpublishStudioPublicationUseCase unpublishPublication,
|
||||||
|
ListStudioPublicationsUseCase listPublications,
|
||||||
|
GetStudioPublicationSnapshotUseCase getSnapshot,
|
||||||
|
GetStudioDashboardUseCase getDashboard,
|
||||||
|
StudioDetailMapper detailMapper,
|
||||||
|
StudioCursors cursors,
|
||||||
|
StudioIdempotency idempotency) {
|
||||||
|
this.publishDocument = publishDocument;
|
||||||
|
this.unpublishPublication = unpublishPublication;
|
||||||
|
this.listPublications = listPublications;
|
||||||
|
this.getSnapshot = getSnapshot;
|
||||||
|
this.getDashboard = getDashboard;
|
||||||
|
this.detailMapper = detailMapper;
|
||||||
|
this.cursors = cursors;
|
||||||
|
this.idempotency = idempotency;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/dashboard")
|
||||||
|
public StudioDashboard getStudioDashboard() {
|
||||||
|
return detailMapper.toApi(getDashboard.handle(new GetDashboardQuery()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/documents/{documentId}/publish")
|
||||||
|
public ResponseEntity<PublishResult> publishStudioDocument(
|
||||||
|
@PathVariable("documentId") UUID documentId,
|
||||||
|
@Valid @RequestBody PublishDocumentCommand command,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
// 게시 Event 에 최초 요청의 key 를 남긴다 — 재시도가 중복 Event 를 만들지 않았음을 이력에서
|
||||||
|
// 되짚을 수 있어야 한다(V7 publication_event.idempotency_key 주석).
|
||||||
|
String idempotencyKey = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY);
|
||||||
|
|
||||||
|
StudioIdempotency.Outcome<PublishResult> outcome =
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"publishStudioDocument",
|
||||||
|
command,
|
||||||
|
PublishResult.class,
|
||||||
|
() ->
|
||||||
|
detailMapper.toApi(
|
||||||
|
publishDocument.handle(
|
||||||
|
new dev.caskeleton.application.techlog.studio.command
|
||||||
|
.PublishDocumentCommand(
|
||||||
|
documentId,
|
||||||
|
expectedVersion(command.getExpectedVersion()),
|
||||||
|
command.getValidationId(),
|
||||||
|
command.getPreviewId(),
|
||||||
|
List.copyOf(command.getAcknowledgedWarningCodes()),
|
||||||
|
idempotencyKey,
|
||||||
|
StudioPrincipals.require(principal)))));
|
||||||
|
|
||||||
|
// 계약이 200 으로 선언한 응답이다 (studio-v1.yaml `publishStudioDocument` responses).
|
||||||
|
// 여기서 201 을 돌려주는 동안 클라이언트는 게시가 끝난 요청을 계약 위반으로 거절했다 —
|
||||||
|
// 서버는 Publication 을 만들고 공개 경로까지 내줬는데 화면에는 실패로 보였다.
|
||||||
|
//
|
||||||
|
// 계약 드리프트 테스트가 이것을 잡지 못한 이유는 `ResponseEntity.status(...)` 가 런타임
|
||||||
|
// 값이라 springdoc 이 읽는 published 문서에는 나타나지 않기 때문이다. 상태를 계약과 함께
|
||||||
|
// 두려면 애노테이션이나 반환 타입으로 드러나야 한다.
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
|
||||||
|
.body(outcome.result());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/publications")
|
||||||
|
public PublicationPage listStudioPublications(
|
||||||
|
@RequestParam(value = "type", required = false) PublicationEventType type,
|
||||||
|
@RequestParam(value = "cursor", required = false) String cursor,
|
||||||
|
@RequestParam(value = "limit", defaultValue = "20") int limit) {
|
||||||
|
|
||||||
|
String fingerprint = StudioCursors.fingerprint(type == null ? null : type.getValue());
|
||||||
|
DocumentCursorPosition position =
|
||||||
|
cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false);
|
||||||
|
|
||||||
|
var page =
|
||||||
|
listPublications.handle(
|
||||||
|
new ListPublicationsQuery(
|
||||||
|
type == null ? null : PublicationEventTypeView.valueOf(type.getValue()),
|
||||||
|
position == null ? null : position.updatedAt(),
|
||||||
|
position == null ? null : position.id(),
|
||||||
|
limit));
|
||||||
|
|
||||||
|
PublicationPage body = detailMapper.toApi(page);
|
||||||
|
body.setNextCursor(
|
||||||
|
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/v1/studio/publications/{publicationId}/unpublish")
|
||||||
|
public ResponseEntity<PublishResult> unpublishStudioPublication(
|
||||||
|
@PathVariable("publicationId") UUID publicationId,
|
||||||
|
@Valid @RequestBody UnpublishCommand command,
|
||||||
|
@AuthenticationPrincipal AuthenticatedPrincipal principal,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
StudioIdempotency.Outcome<PublishResult> outcome =
|
||||||
|
idempotency.run(
|
||||||
|
request,
|
||||||
|
"unpublishStudioPublication",
|
||||||
|
command,
|
||||||
|
PublishResult.class,
|
||||||
|
() ->
|
||||||
|
detailMapper.toApi(
|
||||||
|
unpublishPublication.handle(
|
||||||
|
new UnpublishPublicationCommand(
|
||||||
|
publicationId,
|
||||||
|
expectedVersion(command.getExpectedPublicationRevision()),
|
||||||
|
StudioPrincipals.require(principal)))));
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
|
||||||
|
.body(outcome.result());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/v1/studio/publications/{publicationEventId}/preview")
|
||||||
|
public PublicationSnapshot getStudioPublicationSnapshot(
|
||||||
|
@PathVariable("publicationEventId") UUID publicationEventId) {
|
||||||
|
return detailMapper.toApi(getSnapshot.handle(new GetSnapshotQuery(publicationEventId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다. */
|
||||||
|
private static long expectedVersion(Integer value) {
|
||||||
|
return value == null ? 0L : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -55,7 +55,13 @@ public class StudioSessionController {
|
|||||||
this.csrfHeaderName = configured;
|
this.csrfHeaderName = configured;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/v1/studio/session")
|
/**
|
||||||
|
* 경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code
|
||||||
|
* ca-skeleton.presentation.api-base-path}("/api") 를 모든 컨트롤러 매핑에 붙인다. 이 저장소의 다른 컨트롤러들(healthcheck,
|
||||||
|
* /v1/uploads, /v1/files)과 같은 규칙이며, 여기에 {@code /api} 를 다시 쓰면 실제 경로가 {@code /api/api/v1/...} 로 밀려
|
||||||
|
* 계약(studio-v1.yaml, {@code servers: "/"})이 선언한 주소에서 사라진다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/v1/studio/session")
|
||||||
public StudioSession getStudioSession(
|
public StudioSession getStudioSession(
|
||||||
@AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) {
|
@AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) {
|
||||||
if (csrfToken == null) {
|
if (csrfToken == null) {
|
||||||
|
|||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetUsage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DashboardTotals;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAction;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAggregate;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEvent;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationListItem;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationIssue;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DashboardView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PreviewDetailView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
|
||||||
|
import dev.caskeleton.shared.error.MappingException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tools.jackson.core.JacksonException;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code WorkingCopyDetail} 조립. {@code getStudioDocument}, {@code saveStudioDocument}, 그리고 낙관적 잠금
|
||||||
|
* 충돌의 {@code VersionConflictDetails.latestDocument}가 모두 이 결과를 쓴다.
|
||||||
|
*
|
||||||
|
* <p>Spring 컴포넌트인 이유는 하나뿐이다 — 미리보기의 {@code renderModel}이 DB에 문자열로 저장되어 있어 계약 DTO로 되살리려면 매퍼가 필요하다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class StudioDetailMapper {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public StudioDetailMapper(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkingCopyDetail toApi(WorkingCopyDetailView view) {
|
||||||
|
WorkingCopyDetail detail = new WorkingCopyDetail();
|
||||||
|
detail.setDocument(StudioResponseMapper.toApi(view.document()));
|
||||||
|
detail.setCurrentValidation(toApi(view.currentValidation()));
|
||||||
|
detail.setLatestPreview(toApi(view.latestPreview()));
|
||||||
|
detail.setCurrentPublication(toApi(view.currentPublication()));
|
||||||
|
detail.setDependencyRevision(view.dependencyRevision());
|
||||||
|
detail.setNextAction(NextAction.fromValue(view.nextAction().name()));
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValidationReport toApi(ValidationReportView view) {
|
||||||
|
if (view == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ValidationReport report = new ValidationReport();
|
||||||
|
report.setValidationId(view.validationId());
|
||||||
|
report.setDocumentId(view.documentId());
|
||||||
|
report.setValidatedVersion(Math.toIntExact(view.validatedVersion()));
|
||||||
|
report.setStatus(ValidationReport.StatusEnum.fromValue(view.status().name()));
|
||||||
|
report.setIssues(
|
||||||
|
view.issues().stream()
|
||||||
|
.map(
|
||||||
|
issue -> {
|
||||||
|
ValidationIssue dto = new ValidationIssue();
|
||||||
|
dto.setCode(issue.code());
|
||||||
|
dto.setSeverity(ValidationIssue.SeverityEnum.fromValue(issue.severity().name()));
|
||||||
|
dto.setPath(issue.path());
|
||||||
|
dto.setMessage(issue.message());
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
|
.toList());
|
||||||
|
report.setValidatedAt(StudioResponseMapper.offsetDateTime(view.validatedAt()));
|
||||||
|
report.setValidUntil(StudioResponseMapper.offsetDateTime(view.validUntil()));
|
||||||
|
report.setDependencyRevision(view.dependencyRevision());
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicPreview toApi(PublicPreviewView view) {
|
||||||
|
if (view == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
PublicPreview preview = new PublicPreview();
|
||||||
|
preview.setPreviewId(view.previewId());
|
||||||
|
preview.setDocumentId(view.documentId());
|
||||||
|
preview.setPreviewVersion(Math.toIntExact(view.previewVersion()));
|
||||||
|
preview.setValidationId(view.validationId());
|
||||||
|
preview.setDependencyRevision(view.dependencyRevision());
|
||||||
|
preview.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt()));
|
||||||
|
preview.setExpiresAt(StudioResponseMapper.offsetDateTime(view.expiresAt()));
|
||||||
|
preview.setRenderModel(renderModel(view.renderModelJson()));
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicationAggregate toApi(PublicationAggregateView view) {
|
||||||
|
if (view == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
PublicationAggregate aggregate = new PublicationAggregate();
|
||||||
|
aggregate.setPublicationId(view.publicationId());
|
||||||
|
aggregate.setDocumentId(view.documentId());
|
||||||
|
aggregate.setStatus(PublicationAggregate.StatusEnum.fromValue(view.status().name()));
|
||||||
|
aggregate.setPublishedVersion(Math.toIntExact(view.publishedVersion()));
|
||||||
|
aggregate.setPublicationRevision(Math.toIntExact(view.publicationRevision()));
|
||||||
|
aggregate.setLatestEventId(view.latestEventId());
|
||||||
|
aggregate.setPublicPath(view.publicPath());
|
||||||
|
aggregate.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt()));
|
||||||
|
return aggregate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PreviewDetail toApi(PreviewDetailView view) {
|
||||||
|
PreviewDetail detail = new PreviewDetail();
|
||||||
|
detail.setPreview(toApi(view.preview()));
|
||||||
|
detail.setState(PreviewDetail.StateEnum.fromValue(view.state().name()));
|
||||||
|
detail.setCurrentDocumentVersion(Math.toIntExact(view.currentDocumentVersion()));
|
||||||
|
detail.setCurrentValidationId(view.currentValidationId());
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicationEvent toApi(PublicationEventView view) {
|
||||||
|
PublicationEvent event = new PublicationEvent();
|
||||||
|
event.setPublicationEventId(view.publicationEventId());
|
||||||
|
event.setPublicationId(view.publicationId());
|
||||||
|
event.setDocumentId(view.documentId());
|
||||||
|
event.setType(PublicationEventType.fromValue(view.type().name()));
|
||||||
|
event.setOccurredAt(StudioResponseMapper.offsetDateTime(view.occurredAt()));
|
||||||
|
event.setPublishedVersion(Math.toIntExact(view.publishedVersion()));
|
||||||
|
event.setSourcePublishedEventId(view.sourcePublishedEventId());
|
||||||
|
event.setSnapshotAvailable(view.snapshotAvailable());
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublishResult toApi(PublishResultView view) {
|
||||||
|
PublishResult result = new PublishResult();
|
||||||
|
result.setPublication(toApi(view.publication()));
|
||||||
|
result.setEvent(toApi(view.event()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicationListItem toApi(PublicationListItemView view) {
|
||||||
|
PublicationListItem item = new PublicationListItem();
|
||||||
|
item.setEvent(toApi(view.event()));
|
||||||
|
item.setPublication(toApi(view.publication()));
|
||||||
|
item.setDocument(view.document() == null ? null : StudioResponseMapper.toApi(view.document()));
|
||||||
|
item.setAvailableActions(
|
||||||
|
view.availableActions().stream()
|
||||||
|
.map(action -> PublicationAction.fromValue(action.name()))
|
||||||
|
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicationPage toApi(PublicationPageView view) {
|
||||||
|
PublicationPage page = new PublicationPage();
|
||||||
|
page.setItems(view.items().stream().map(this::toApi).toList());
|
||||||
|
page.setNextCursor(view.nextCursor());
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicationSnapshot toApi(PublicationSnapshotView view) {
|
||||||
|
PublicationSnapshot snapshot = new PublicationSnapshot();
|
||||||
|
snapshot.setEvent(toApi(view.event()));
|
||||||
|
snapshot.setRenderModel(renderModel(view.renderModelJson()));
|
||||||
|
snapshot.setContentFormatVersion(view.contentFormatVersion());
|
||||||
|
snapshot.setRendererContractVersion(view.rendererContractVersion());
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StudioDashboard toApi(DashboardView view) {
|
||||||
|
StudioDashboard dashboard = new StudioDashboard();
|
||||||
|
dashboard.setContinueWriting(
|
||||||
|
view.continueWriting().stream().map(StudioResponseMapper::toApi).toList());
|
||||||
|
dashboard.setReadyToPublish(
|
||||||
|
view.readyToPublish().stream().map(StudioResponseMapper::toApi).toList());
|
||||||
|
dashboard.setRecentPublications(view.recentPublications().stream().map(this::toApi).toList());
|
||||||
|
DashboardTotals totals = new DashboardTotals();
|
||||||
|
totals.setDocuments(view.totals().documents());
|
||||||
|
totals.setNeedsValidation(view.totals().needsValidation());
|
||||||
|
totals.setReadyToPublish(view.totals().readyToPublish());
|
||||||
|
totals.setPublications(view.totals().publications());
|
||||||
|
dashboard.setTotals(totals);
|
||||||
|
return dashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Asset toApi(AssetView view) {
|
||||||
|
Asset asset = new Asset();
|
||||||
|
asset.setId(view.id());
|
||||||
|
asset.setAssetKey(view.assetKey());
|
||||||
|
asset.setKind(AssetKind.fromValue(view.kind().name()));
|
||||||
|
asset.setMediaType(view.mediaType());
|
||||||
|
asset.setOriginalFilename(view.originalFilename());
|
||||||
|
asset.setByteSize(Math.toIntExact(view.byteSize()));
|
||||||
|
asset.setWidth(view.width());
|
||||||
|
asset.setHeight(view.height());
|
||||||
|
asset.setAltText(view.altText());
|
||||||
|
asset.setDecorative(view.decorative());
|
||||||
|
asset.setManagementStatus(AssetManagementStatus.fromValue(view.managementStatus().name()));
|
||||||
|
asset.setPublicPath(view.publicPath());
|
||||||
|
asset.setUsageCount(view.usageCount());
|
||||||
|
asset.setVersion(Math.toIntExact(view.version()));
|
||||||
|
asset.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt()));
|
||||||
|
asset.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt()));
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetPage toApi(AssetPageView view) {
|
||||||
|
AssetPage page = new AssetPage();
|
||||||
|
page.setItems(view.items().stream().map(this::toApi).toList());
|
||||||
|
page.setNextCursor(view.nextCursor());
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetDetail toApi(AssetDetailView view) {
|
||||||
|
AssetDetail detail = new AssetDetail();
|
||||||
|
detail.setAsset(toApi(view.asset()));
|
||||||
|
detail.setUsages(
|
||||||
|
view.usages().stream()
|
||||||
|
.map(
|
||||||
|
usage -> {
|
||||||
|
AssetUsage dto = new AssetUsage();
|
||||||
|
dto.setDocumentId(usage.documentId());
|
||||||
|
dto.setDocumentKind(RecordKind.fromValue(usage.documentKind().name()));
|
||||||
|
dto.setTitle(usage.title());
|
||||||
|
dto.setPublished(usage.published());
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
|
.toList());
|
||||||
|
detail.setHasPublicationHistory(view.hasPublicationHistory());
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장된 렌더 모델 JSON을 계약 union 타입으로 되살린다. */
|
||||||
|
private PublicRenderModel renderModel(String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, PublicRenderModel.class);
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new MappingException("a stored preview render model no longer matches the contract", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseInput;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionInput;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceInput;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RelationInput;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioError;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RelationView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 DTO({@code WorkingCopyInput} union) → application 입력 모델.
|
||||||
|
*
|
||||||
|
* <p>계약 union 의 네 분기를 {@code switch} 로 남김없이 다룬다 — 계약에 다섯 번째 유형이 생기면 생성 DTO 가 늘어나고 여기서 {@code
|
||||||
|
* default} 가 없는 채로 컴파일이 깨져 알려준다.
|
||||||
|
*/
|
||||||
|
public final class StudioRequestMapper {
|
||||||
|
|
||||||
|
private StudioRequestMapper() {}
|
||||||
|
|
||||||
|
public static WorkingCopyInputView toApplication(WorkingCopyInput input) {
|
||||||
|
if (input == null) {
|
||||||
|
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document is required");
|
||||||
|
}
|
||||||
|
return switch (input) {
|
||||||
|
case CaseInput value ->
|
||||||
|
new WorkingCopyInputView.CaseInputView(
|
||||||
|
base(
|
||||||
|
RecordKind.CASE,
|
||||||
|
value.getTitle(),
|
||||||
|
value.getSlug(),
|
||||||
|
value.getSummary(),
|
||||||
|
value.getTopicId(),
|
||||||
|
value.getProjectId(),
|
||||||
|
value.getRelations()),
|
||||||
|
value.getProblem(),
|
||||||
|
value.getConclusion(),
|
||||||
|
value.getEnvironment(),
|
||||||
|
value.getReproduction(),
|
||||||
|
value.getLastVerifiedOn(),
|
||||||
|
value.getBodyMarkdown());
|
||||||
|
case ReferenceInput value ->
|
||||||
|
new WorkingCopyInputView.ReferenceInputView(
|
||||||
|
base(
|
||||||
|
RecordKind.REFERENCE,
|
||||||
|
value.getTitle(),
|
||||||
|
value.getSlug(),
|
||||||
|
value.getSummary(),
|
||||||
|
value.getTopicId(),
|
||||||
|
value.getProjectId(),
|
||||||
|
value.getRelations()),
|
||||||
|
value.getPurpose(),
|
||||||
|
rules(value.getRules()),
|
||||||
|
orderedText(value.getApplyWhen()),
|
||||||
|
orderedText(value.getExceptions()),
|
||||||
|
orderedText(value.getExamples()),
|
||||||
|
value.getVerifiedOn());
|
||||||
|
case QuestionInput value ->
|
||||||
|
new WorkingCopyInputView.QuestionInputView(
|
||||||
|
base(
|
||||||
|
RecordKind.QUESTION,
|
||||||
|
value.getTitle(),
|
||||||
|
value.getSlug(),
|
||||||
|
value.getSummary(),
|
||||||
|
value.getTopicId(),
|
||||||
|
value.getProjectId(),
|
||||||
|
value.getRelations()),
|
||||||
|
questionStatus(value.getQuestionStatus()),
|
||||||
|
orderedText(value.getFacts()),
|
||||||
|
orderedText(value.getAssumptions()),
|
||||||
|
orderedText(value.getUnknowns()),
|
||||||
|
orderedText(value.getConstraints()),
|
||||||
|
options(value.getOptions()),
|
||||||
|
value.getNextValidation(),
|
||||||
|
resolution(value.getResolution()));
|
||||||
|
case ProjectDecisionInput value ->
|
||||||
|
new WorkingCopyInputView.ProjectDecisionInputView(
|
||||||
|
base(
|
||||||
|
RecordKind.PROJECT_DECISION,
|
||||||
|
value.getTitle(),
|
||||||
|
value.getSlug(),
|
||||||
|
value.getSummary(),
|
||||||
|
value.getTopicId(),
|
||||||
|
value.getProjectId(),
|
||||||
|
value.getRelations()),
|
||||||
|
decisionStatus(value.getDecisionStatus()),
|
||||||
|
value.getDecidedOn(),
|
||||||
|
value.getStatement(),
|
||||||
|
value.getRationale(),
|
||||||
|
orderedText(value.getConsequences()));
|
||||||
|
default ->
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.REQUEST_VALIDATION_FAILED,
|
||||||
|
"unsupported document kind: " + input.getClass().getSimpleName());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkingCopyBaseInput base(
|
||||||
|
RecordKind kind,
|
||||||
|
String title,
|
||||||
|
String slug,
|
||||||
|
String summary,
|
||||||
|
UUID topicId,
|
||||||
|
UUID projectId,
|
||||||
|
List<RelationInput> relations) {
|
||||||
|
return new WorkingCopyBaseInput(
|
||||||
|
kind, title, slug, summary, topicId, projectId, relations(relations));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RelationView> relations(List<RelationInput> relations) {
|
||||||
|
return relations == null
|
||||||
|
? List.of()
|
||||||
|
: relations.stream()
|
||||||
|
.map(
|
||||||
|
relation ->
|
||||||
|
new RelationView(
|
||||||
|
relation.getId(),
|
||||||
|
relation.getTargetId(),
|
||||||
|
relation.getReason(),
|
||||||
|
order(relation.getOrder())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<OrderedTextView> orderedText(List<OrderedText> items) {
|
||||||
|
return items == null
|
||||||
|
? List.of()
|
||||||
|
: items.stream()
|
||||||
|
.map(item -> new OrderedTextView(item.getId(), item.getText(), order(item.getOrder())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ReferenceRuleView> rules(List<ReferenceRule> items) {
|
||||||
|
return items == null
|
||||||
|
? List.of()
|
||||||
|
: items.stream()
|
||||||
|
.map(
|
||||||
|
item ->
|
||||||
|
new ReferenceRuleView(
|
||||||
|
item.getId(), item.getTitle(), item.getBody(), order(item.getOrder())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<QuestionOptionView> options(List<QuestionOption> items) {
|
||||||
|
return items == null
|
||||||
|
? List.of()
|
||||||
|
: items.stream()
|
||||||
|
.map(
|
||||||
|
item ->
|
||||||
|
new QuestionOptionView(
|
||||||
|
item.getId(),
|
||||||
|
item.getTitle(),
|
||||||
|
item.getDescription(),
|
||||||
|
order(item.getOrder())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestionResolutionView resolution(QuestionResolution resolution) {
|
||||||
|
return resolution == null
|
||||||
|
? null
|
||||||
|
: new QuestionResolutionView(
|
||||||
|
resolution.getSummary(), resolution.getEvidenceTargetId(), resolution.getLinkLabel());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestionStatusView questionStatus(QuestionInput.QuestionStatusEnum status) {
|
||||||
|
if (status == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return status == QuestionInput.QuestionStatusEnum.RESOLVED
|
||||||
|
? QuestionStatusView.RESOLVED
|
||||||
|
: QuestionStatusView.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DecisionStatusView decisionStatus(ProjectDecisionInput.DecisionStatusEnum status) {
|
||||||
|
if (status == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return status == ProjectDecisionInput.DecisionStatusEnum.ADOPTED
|
||||||
|
? DecisionStatusView.ADOPTED
|
||||||
|
: DecisionStatusView.PROPOSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 계약상 {@code order}는 required지만 null 을 실어 보내는 클라이언트를 500으로 떨어뜨리지 않는다. */
|
||||||
|
private static int order(Integer order) {
|
||||||
|
return order == null ? 0 : order;
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentSummary;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionWorkingCopy;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionWorkingCopy;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceWorkingCopy;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Relation;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RelationView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/** application 모델 → 계약 DTO. */
|
||||||
|
public final class StudioResponseMapper {
|
||||||
|
|
||||||
|
private StudioResponseMapper() {}
|
||||||
|
|
||||||
|
public static WorkingCopy toApi(WorkingCopyView view) {
|
||||||
|
return switch (view) {
|
||||||
|
case WorkingCopyView.CaseWorkingCopyView value -> {
|
||||||
|
CaseWorkingCopy dto = new CaseWorkingCopy();
|
||||||
|
dto.setKind(CaseWorkingCopy.KindEnum.CASE);
|
||||||
|
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
|
||||||
|
dto.setTopicId(value.base().topicId());
|
||||||
|
dto.setProjectId(value.base().projectId());
|
||||||
|
dto.setRelations(relations(value.base().relations()));
|
||||||
|
dto.setId(value.id());
|
||||||
|
dto.setVersion(version(value.version()));
|
||||||
|
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
|
||||||
|
dto.setProblem(value.problem());
|
||||||
|
dto.setConclusion(value.conclusion());
|
||||||
|
dto.setEnvironment(value.environment());
|
||||||
|
dto.setReproduction(value.reproduction());
|
||||||
|
dto.setLastVerifiedOn(value.lastVerifiedOn());
|
||||||
|
dto.setBodyMarkdown(value.bodyMarkdown());
|
||||||
|
yield dto;
|
||||||
|
}
|
||||||
|
case WorkingCopyView.ReferenceWorkingCopyView value -> {
|
||||||
|
ReferenceWorkingCopy dto = new ReferenceWorkingCopy();
|
||||||
|
dto.setKind(ReferenceWorkingCopy.KindEnum.REFERENCE);
|
||||||
|
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
|
||||||
|
dto.setTopicId(value.base().topicId());
|
||||||
|
dto.setProjectId(value.base().projectId());
|
||||||
|
dto.setRelations(relations(value.base().relations()));
|
||||||
|
dto.setId(value.id());
|
||||||
|
dto.setVersion(version(value.version()));
|
||||||
|
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
|
||||||
|
dto.setPurpose(value.purpose());
|
||||||
|
dto.setRules(rulesToApi(value.rules()));
|
||||||
|
dto.setApplyWhen(orderedTextToApi(value.applyWhen()));
|
||||||
|
dto.setExceptions(orderedTextToApi(value.exceptions()));
|
||||||
|
dto.setExamples(orderedTextToApi(value.examples()));
|
||||||
|
dto.setVerifiedOn(value.verifiedOn());
|
||||||
|
yield dto;
|
||||||
|
}
|
||||||
|
case WorkingCopyView.QuestionWorkingCopyView value -> {
|
||||||
|
QuestionWorkingCopy dto = new QuestionWorkingCopy();
|
||||||
|
dto.setKind(QuestionWorkingCopy.KindEnum.QUESTION);
|
||||||
|
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
|
||||||
|
dto.setTopicId(value.base().topicId());
|
||||||
|
dto.setProjectId(value.base().projectId());
|
||||||
|
dto.setRelations(relations(value.base().relations()));
|
||||||
|
dto.setId(value.id());
|
||||||
|
dto.setVersion(version(value.version()));
|
||||||
|
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
|
||||||
|
dto.setQuestionStatus(questionStatus(value.questionStatus()));
|
||||||
|
dto.setFacts(orderedTextToApi(value.facts()));
|
||||||
|
dto.setAssumptions(orderedTextToApi(value.assumptions()));
|
||||||
|
dto.setUnknowns(orderedTextToApi(value.unknowns()));
|
||||||
|
dto.setConstraints(orderedTextToApi(value.constraints()));
|
||||||
|
dto.setOptions(optionsToApi(value.options()));
|
||||||
|
dto.setNextValidation(value.nextValidation());
|
||||||
|
dto.setResolution(resolution(value.resolution()));
|
||||||
|
yield dto;
|
||||||
|
}
|
||||||
|
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
|
||||||
|
ProjectDecisionWorkingCopy dto = new ProjectDecisionWorkingCopy();
|
||||||
|
dto.setKind(ProjectDecisionWorkingCopy.KindEnum.PROJECT_DECISION);
|
||||||
|
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
|
||||||
|
dto.setTopicId(value.base().topicId());
|
||||||
|
dto.setProjectId(value.base().projectId());
|
||||||
|
dto.setRelations(relations(value.base().relations()));
|
||||||
|
dto.setId(value.id());
|
||||||
|
dto.setVersion(version(value.version()));
|
||||||
|
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
|
||||||
|
dto.setDecisionStatus(decisionStatus(value.decisionStatus()));
|
||||||
|
dto.setDecidedOn(value.decidedOn());
|
||||||
|
dto.setStatement(value.statement());
|
||||||
|
dto.setRationale(value.rationale());
|
||||||
|
dto.setConsequences(orderedTextToApi(value.consequences()));
|
||||||
|
yield dto;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DocumentPage toApi(DocumentPageView view) {
|
||||||
|
DocumentPage page = new DocumentPage();
|
||||||
|
page.setItems(view.items().stream().map(StudioResponseMapper::toApi).toList());
|
||||||
|
page.setNextCursor(view.nextCursor());
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DocumentSummary toApi(DocumentSummaryView view) {
|
||||||
|
DocumentSummary summary = new DocumentSummary();
|
||||||
|
summary.setId(view.id());
|
||||||
|
summary.setTitle(view.title());
|
||||||
|
summary.setKind(RecordKind.fromValue(view.kind().name()));
|
||||||
|
summary.setProject(displayTarget(view.project()));
|
||||||
|
summary.setUpdatedAt(offsetDateTime(view.updatedAt()));
|
||||||
|
summary.setPublicationStatus(PublicationStatus.fromValue(view.publicationStatus().name()));
|
||||||
|
summary.setPublishedVersion(
|
||||||
|
view.publishedVersion() == null ? null : Math.toIntExact(view.publishedVersion()));
|
||||||
|
summary.setHasUnpublishedChanges(view.hasUnpublishedChanges());
|
||||||
|
summary.setNextAction(NextAction.fromValue(view.nextAction().name()));
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DisplayTarget displayTarget(DisplayTargetView view) {
|
||||||
|
if (view == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
DisplayTarget target = new DisplayTarget();
|
||||||
|
target.setId(view.id());
|
||||||
|
target.setLabel(view.label());
|
||||||
|
target.setPublicPath(view.publicPath());
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OffsetDateTime offsetDateTime(Instant instant) {
|
||||||
|
return instant == null ? null : instant.atOffset(ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약의 {@code version}은 {@code integer}이고 컬럼은 {@code bigint}다. 넘치는 값을 조용히 잘라내면 클라이언트가 보내는 {@code
|
||||||
|
* expectedVersion}이 영영 맞지 않게 되므로 예외로 드러낸다.
|
||||||
|
*/
|
||||||
|
private static Integer version(long version) {
|
||||||
|
return Math.toIntExact(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyBase(
|
||||||
|
WorkingCopyBaseInput base,
|
||||||
|
java.util.function.Consumer<String> title,
|
||||||
|
java.util.function.Consumer<String> slug,
|
||||||
|
java.util.function.Consumer<String> summary) {
|
||||||
|
title.accept(base.title());
|
||||||
|
slug.accept(base.slug());
|
||||||
|
summary.accept(base.summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Relation> relations(List<RelationView> views) {
|
||||||
|
return map(
|
||||||
|
views,
|
||||||
|
view -> {
|
||||||
|
Relation relation = new Relation();
|
||||||
|
relation.setId(view.id());
|
||||||
|
relation.setTargetId(view.targetId());
|
||||||
|
relation.setReason(view.reason());
|
||||||
|
relation.setOrder(view.order());
|
||||||
|
return relation;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<OrderedText> orderedTextToApi(List<OrderedTextView> views) {
|
||||||
|
return map(
|
||||||
|
views,
|
||||||
|
view -> {
|
||||||
|
OrderedText text = new OrderedText();
|
||||||
|
text.setId(view.id());
|
||||||
|
text.setText(view.text());
|
||||||
|
text.setOrder(view.order());
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ReferenceRule> rulesToApi(List<ReferenceRuleView> views) {
|
||||||
|
return map(
|
||||||
|
views,
|
||||||
|
view -> {
|
||||||
|
ReferenceRule rule = new ReferenceRule();
|
||||||
|
rule.setId(view.id());
|
||||||
|
rule.setTitle(view.title());
|
||||||
|
rule.setBody(view.body());
|
||||||
|
rule.setOrder(view.order());
|
||||||
|
return rule;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<QuestionOption> optionsToApi(List<QuestionOptionView> views) {
|
||||||
|
return map(
|
||||||
|
views,
|
||||||
|
view -> {
|
||||||
|
QuestionOption option = new QuestionOption();
|
||||||
|
option.setId(view.id());
|
||||||
|
option.setTitle(view.title());
|
||||||
|
option.setDescription(view.description());
|
||||||
|
option.setOrder(view.order());
|
||||||
|
return option;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestionResolution resolution(QuestionResolutionView view) {
|
||||||
|
if (view == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
QuestionResolution resolution = new QuestionResolution();
|
||||||
|
resolution.setSummary(view.summary());
|
||||||
|
resolution.setEvidenceTargetId(view.evidenceTargetId());
|
||||||
|
resolution.setLinkLabel(view.linkLabel());
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestionWorkingCopy.QuestionStatusEnum questionStatus(QuestionStatusView status) {
|
||||||
|
if (status == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return status == QuestionStatusView.RESOLVED
|
||||||
|
? QuestionWorkingCopy.QuestionStatusEnum.RESOLVED
|
||||||
|
: QuestionWorkingCopy.QuestionStatusEnum.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProjectDecisionWorkingCopy.DecisionStatusEnum decisionStatus(
|
||||||
|
dev.caskeleton.application.techlog.studio.model.DecisionStatusView status) {
|
||||||
|
if (status == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return status == dev.caskeleton.application.techlog.studio.model.DecisionStatusView.ADOPTED
|
||||||
|
? ProjectDecisionWorkingCopy.DecisionStatusEnum.ADOPTED
|
||||||
|
: ProjectDecisionWorkingCopy.DecisionStatusEnum.PROPOSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <S, T> List<T> map(List<S> source, Function<S, T> mapper) {
|
||||||
|
return source == null ? List.of() : source.stream().map(mapper).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ImageBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ThematicBreakBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.commonmark.ext.gfm.tables.TableBlock;
|
||||||
|
import org.commonmark.ext.gfm.tables.TableBody;
|
||||||
|
import org.commonmark.ext.gfm.tables.TableCell;
|
||||||
|
import org.commonmark.ext.gfm.tables.TableHead;
|
||||||
|
import org.commonmark.ext.gfm.tables.TableRow;
|
||||||
|
import org.commonmark.node.BlockQuote;
|
||||||
|
import org.commonmark.node.BulletList;
|
||||||
|
import org.commonmark.node.FencedCodeBlock;
|
||||||
|
import org.commonmark.node.Heading;
|
||||||
|
import org.commonmark.node.Image;
|
||||||
|
import org.commonmark.node.IndentedCodeBlock;
|
||||||
|
import org.commonmark.node.Node;
|
||||||
|
import org.commonmark.node.OrderedList;
|
||||||
|
import org.commonmark.node.Paragraph;
|
||||||
|
import org.commonmark.node.ThematicBreak;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* commonmark 블록 노드를 계약의 {@code CaseRenderBlock} union 으로 옮긴다.
|
||||||
|
*
|
||||||
|
* <p>계약이 표현할 수 없는 것은 조용히 다른 것으로 바꾸지 않고 경고로 남긴다(설계 05장 §12) — 렌더러가 지원하지 않는 문법을 그럴듯하게 잘못 해석하면 작성자는 게시
|
||||||
|
* 결과를 신뢰할 수 없다.
|
||||||
|
*/
|
||||||
|
final class BlockRenderer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code HeadingBlock.level} 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 였고 h1/h5/h6 를 이 범위로 접었는데,
|
||||||
|
* 그러면 작성자가 쓴 위계가 화면에서 달라진다. 지금은 그대로 담는다.
|
||||||
|
*/
|
||||||
|
private static final int MIN_HEADING_LEVEL = 1;
|
||||||
|
|
||||||
|
private static final int MAX_HEADING_LEVEL = 6;
|
||||||
|
|
||||||
|
private final HeadingIds headingIds;
|
||||||
|
private final List<String> warnings;
|
||||||
|
private int tableSequence;
|
||||||
|
private int listItemSequence;
|
||||||
|
|
||||||
|
BlockRenderer(HeadingIds headingIds, List<String> warnings) {
|
||||||
|
this.headingIds = headingIds;
|
||||||
|
this.warnings = warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CaseRenderBlock> render(Node document) {
|
||||||
|
List<CaseRenderBlock> blocks = new ArrayList<>();
|
||||||
|
for (Node node = document.getFirstChild(); node != null; node = node.getNext()) {
|
||||||
|
CaseRenderBlock block = renderBlock(node);
|
||||||
|
if (block != null) {
|
||||||
|
blocks.add(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaseRenderBlock renderBlock(Node node) {
|
||||||
|
return switch (node) {
|
||||||
|
case Heading value -> heading(value);
|
||||||
|
case Paragraph value -> paragraphOrImage(value);
|
||||||
|
case BlockQuote value -> blockquote(value);
|
||||||
|
case BulletList value -> bulletList(value);
|
||||||
|
case OrderedList value -> orderedList(value);
|
||||||
|
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
|
||||||
|
case IndentedCodeBlock value -> code(value.getLiteral(), null);
|
||||||
|
case TableBlock value -> table(value);
|
||||||
|
// 계약에 수평선 타입이 생겼다(studio-v1 3.1.0). 예전에는 담을 곳이 없어 버리고 경고했다.
|
||||||
|
case ThematicBreak ignored -> {
|
||||||
|
ThematicBreakBlock block = new ThematicBreakBlock();
|
||||||
|
block.setType(ThematicBreakBlock.TypeEnum.THEMATIC_BREAK);
|
||||||
|
yield block;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
List<Inline> content = InlineRenderer.render(node);
|
||||||
|
yield content.isEmpty() ? null : paragraph(content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
|
||||||
|
*
|
||||||
|
* <p>Markdown 에서 {@code } 는 문단 안의 inline 인데 계약의 Inline union 에는 그림이 없다. 예전에는
|
||||||
|
* 그래서 대체 텍스트만 남기고 그림을 버렸다. 계약에 블록이 생겼으므로 (studio-v1 3.1.0) 문단이 그림 하나로만 이루어진 경우를 블록으로 올린다.
|
||||||
|
*
|
||||||
|
* <p>주소는 상대 경로이거나 http(s) 여야 한다 — {@code javascript:} 같은 스킴이 화면에 그대로 실리면 안 된다.
|
||||||
|
*/
|
||||||
|
private CaseRenderBlock paragraphOrImage(Paragraph value) {
|
||||||
|
Node only = value.getFirstChild();
|
||||||
|
if (only instanceof Image image && only == value.getLastChild()) {
|
||||||
|
String source = image.getDestination();
|
||||||
|
if (isRenderableImageSource(source)) {
|
||||||
|
ImageBlock block = new ImageBlock();
|
||||||
|
block.setType(ImageBlock.TypeEnum.IMAGE);
|
||||||
|
block.setSrc(source);
|
||||||
|
block.setAlt(InlineRenderer.plainText(image));
|
||||||
|
block.setTitle(image.getTitle());
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
warnings.add("IMAGE_SOURCE_NOT_RENDERABLE");
|
||||||
|
}
|
||||||
|
return paragraph(InlineRenderer.render(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isRenderableImageSource(String source) {
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 프로토콜 상대 주소(`//host/…`)는 앱 경로가 아니다. 아래의 단일 슬래시 검사보다 먼저 걸러야
|
||||||
|
// 한다 — 순서를 바꾸면 외부 호스트를 내부 경로로 오인한다.
|
||||||
|
if (source.startsWith("//")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (source.startsWith("/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String lower = source.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaseRenderBlock heading(Heading value) {
|
||||||
|
HeadingBlock block = new HeadingBlock();
|
||||||
|
block.setType(HeadingBlock.TypeEnum.HEADING);
|
||||||
|
block.setId(headingIds.nextFor(InlineRenderer.plainText(value)));
|
||||||
|
block.setLevel(Math.clamp(value.getLevel(), MIN_HEADING_LEVEL, MAX_HEADING_LEVEL));
|
||||||
|
block.setContent(InlineRenderer.render(value));
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CaseRenderBlock paragraph(List<Inline> content) {
|
||||||
|
ParagraphBlock block = new ParagraphBlock();
|
||||||
|
block.setType(ParagraphBlock.TypeEnum.PARAGRAPH);
|
||||||
|
block.setContent(content);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 계약의 {@code BlockquoteBlock.content} 는 블록이 아니라 inline 배열이라 안쪽 문단을 이어 붙인다. */
|
||||||
|
private CaseRenderBlock blockquote(BlockQuote value) {
|
||||||
|
BlockquoteBlock block = new BlockquoteBlock();
|
||||||
|
block.setType(BlockquoteBlock.TypeEnum.BLOCKQUOTE);
|
||||||
|
List<Inline> content = new ArrayList<>();
|
||||||
|
for (Node child = value.getFirstChild(); child != null; child = child.getNext()) {
|
||||||
|
List<Inline> rendered = InlineRenderer.render(child);
|
||||||
|
if (rendered.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!content.isEmpty()) {
|
||||||
|
// 인용 안의 문단 경계. 이어 붙이기만 하면 앞 문단의 마지막 낱말과 다음 문단의 첫 낱말이
|
||||||
|
// 한 낱말로 붙어 읽힌다.
|
||||||
|
content.add(InlineRenderer.spacer());
|
||||||
|
}
|
||||||
|
content.addAll(rendered);
|
||||||
|
}
|
||||||
|
block.setContent(content);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaseRenderBlock bulletList(BulletList value) {
|
||||||
|
UnorderedListBlock block = new UnorderedListBlock();
|
||||||
|
block.setType(UnorderedListBlock.TypeEnum.UNORDERED_LIST);
|
||||||
|
block.setItems(listItems(value));
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaseRenderBlock orderedList(OrderedList value) {
|
||||||
|
OrderedListBlock block = new OrderedListBlock();
|
||||||
|
block.setType(OrderedListBlock.TypeEnum.ORDERED_LIST);
|
||||||
|
block.setItems(listItems(value));
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ListItem> listItems(Node list) {
|
||||||
|
List<ListItem> items = new ArrayList<>();
|
||||||
|
for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
|
||||||
|
ListItem item = new ListItem();
|
||||||
|
listItemSequence++;
|
||||||
|
item.setId("li-" + listItemSequence);
|
||||||
|
List<Inline> content = new ArrayList<>();
|
||||||
|
for (Node paragraph = child.getFirstChild();
|
||||||
|
paragraph != null;
|
||||||
|
paragraph = paragraph.getNext()) {
|
||||||
|
content.addAll(InlineRenderer.render(paragraph));
|
||||||
|
}
|
||||||
|
item.setContent(content);
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CaseRenderBlock code(String literal, String info) {
|
||||||
|
CodeBlock block = new CodeBlock();
|
||||||
|
block.setType(CodeBlock.TypeEnum.CODE_BLOCK);
|
||||||
|
block.setCode(literal == null ? "" : literal);
|
||||||
|
block.setLanguage(info == null || info.isBlank() ? null : info.strip());
|
||||||
|
block.setLabel(null);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaseRenderBlock table(TableBlock value) {
|
||||||
|
DataTableBlock block = new DataTableBlock();
|
||||||
|
block.setType(DataTableBlock.TypeEnum.DATA_TABLE);
|
||||||
|
tableSequence++;
|
||||||
|
block.setId("table-" + tableSequence);
|
||||||
|
block.setCaption("");
|
||||||
|
// 계약은 rowHeaderColumn 을 1-based 로 정의한다. GFM 표에는 행 머리글 개념이 없으므로 비운다.
|
||||||
|
block.setRowHeaderColumn(null);
|
||||||
|
|
||||||
|
List<DataTableColumn> columns = new ArrayList<>();
|
||||||
|
List<DataTableRow> rows = new ArrayList<>();
|
||||||
|
int rowSequence = 0;
|
||||||
|
|
||||||
|
for (Node section = value.getFirstChild(); section != null; section = section.getNext()) {
|
||||||
|
for (Node row = section.getFirstChild(); row != null; row = row.getNext()) {
|
||||||
|
if (!(row instanceof TableRow tableRow)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (section instanceof TableHead) {
|
||||||
|
int index = 0;
|
||||||
|
for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) {
|
||||||
|
DataTableColumn column = new DataTableColumn();
|
||||||
|
index++;
|
||||||
|
column.setId("col-" + index);
|
||||||
|
String label = InlineRenderer.plainText(cell);
|
||||||
|
// 계약의 label 은 minLength 1 이다. 빈 머리글 칸은 열 번호로 대신한다.
|
||||||
|
column.setLabel(label.isBlank() ? "col-" + index : label);
|
||||||
|
column.setAlignment(alignment(cell));
|
||||||
|
columns.add(column);
|
||||||
|
}
|
||||||
|
} else if (section instanceof TableBody) {
|
||||||
|
DataTableRow dataRow = new DataTableRow();
|
||||||
|
rowSequence++;
|
||||||
|
dataRow.setId("row-" + rowSequence);
|
||||||
|
List<DataTableCell> cells = new ArrayList<>();
|
||||||
|
int index = 0;
|
||||||
|
for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) {
|
||||||
|
DataTableCell dataCell = new DataTableCell();
|
||||||
|
index++;
|
||||||
|
dataCell.setColumnId("col-" + index);
|
||||||
|
dataCell.setContent(InlineRenderer.render(cell));
|
||||||
|
cells.add(dataCell);
|
||||||
|
}
|
||||||
|
dataRow.setCells(cells);
|
||||||
|
rows.add(dataRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (columns.isEmpty()) {
|
||||||
|
// 계약의 columns 는 minItems 1 이다. 머리글 없는 표는 계약상 표현할 수 없다.
|
||||||
|
warnings.add("DATA_TABLE_WITHOUT_HEADER_NOT_RENDERABLE");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
block.setColumns(columns);
|
||||||
|
block.setRows(rows);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataTableColumn.AlignmentEnum alignment(Node cell) {
|
||||||
|
if (!(cell instanceof TableCell tableCell) || tableCell.getAlignment() == null) {
|
||||||
|
return DataTableColumn.AlignmentEnum.LEFT;
|
||||||
|
}
|
||||||
|
return switch (tableCell.getAlignment()) {
|
||||||
|
case CENTER -> DataTableColumn.AlignmentEnum.CENTER;
|
||||||
|
case RIGHT -> DataTableColumn.AlignmentEnum.RIGHT;
|
||||||
|
case LEFT -> DataTableColumn.AlignmentEnum.LEFT;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설계 05장 §6의 heading ID 알고리즘. TOC와 anchor가 Public과 Studio에서 같아야 하므로 구현은 한 곳에만 둔다.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* "Authorization Code Flow" -> authorization-code-flow
|
||||||
|
* "JPA N+1 문제" -> jpa-n-1-문제
|
||||||
|
* "결론" -> 결론
|
||||||
|
* "결론" (두 번째) -> 결론-2
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
final class HeadingIds {
|
||||||
|
|
||||||
|
private static final String FALLBACK = "section";
|
||||||
|
|
||||||
|
private final Map<String, Integer> used = new HashMap<>();
|
||||||
|
|
||||||
|
/** 같은 문서 안에서 중복되면 {@code -2}, {@code -3} 순으로 suffix 를 붙인다. */
|
||||||
|
String nextFor(String headingPlainText) {
|
||||||
|
String base = slugify(headingPlainText);
|
||||||
|
int seen = used.merge(base, 1, Integer::sum);
|
||||||
|
return seen == 1 ? base : base + "-" + seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String slugify(String text) {
|
||||||
|
String normalized =
|
||||||
|
Normalizer.normalize(text == null ? "" : text, Normalizer.Form.NFKC)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
StringBuilder out = new StringBuilder(normalized.length());
|
||||||
|
boolean pendingSeparator = false;
|
||||||
|
for (int i = 0; i < normalized.length(); i++) {
|
||||||
|
char ch = normalized.charAt(i);
|
||||||
|
if (isKept(ch)) {
|
||||||
|
// 구분자는 실제로 유지 문자가 뒤따를 때만 쓴다 — 그래야 끝에 하이픈이 남지 않는다.
|
||||||
|
if (pendingSeparator && !out.isEmpty()) {
|
||||||
|
out.append('-');
|
||||||
|
}
|
||||||
|
pendingSeparator = false;
|
||||||
|
out.append(ch);
|
||||||
|
} else {
|
||||||
|
pendingSeparator = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.isEmpty() ? FALLBACK : out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 한글·영문·숫자·하이픈만 남긴다(설계 05장 §6 6단계). */
|
||||||
|
private static boolean isKept(char ch) {
|
||||||
|
if (ch == '-') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isHangul(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isHangul(char ch) {
|
||||||
|
return (ch >= 0xAC00 && ch <= 0xD7A3) // 완성형 음절
|
||||||
|
|| (ch >= 0x1100 && ch <= 0x11FF) // 초·중·종성 자모
|
||||||
|
|| (ch >= 0x3130 && ch <= 0x318F); // 호환용 자모
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineEmphasis;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineLink;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.commonmark.node.Code;
|
||||||
|
import org.commonmark.node.Emphasis;
|
||||||
|
import org.commonmark.node.HardLineBreak;
|
||||||
|
import org.commonmark.node.Image;
|
||||||
|
import org.commonmark.node.Link;
|
||||||
|
import org.commonmark.node.Node;
|
||||||
|
import org.commonmark.node.SoftLineBreak;
|
||||||
|
import org.commonmark.node.StrongEmphasis;
|
||||||
|
import org.commonmark.node.Text;
|
||||||
|
|
||||||
|
/** commonmark inline 노드를 계약의 {@code Inline} union 으로 옮긴다. */
|
||||||
|
final class InlineRenderer {
|
||||||
|
|
||||||
|
private InlineRenderer() {}
|
||||||
|
|
||||||
|
static List<Inline> render(Node parent) {
|
||||||
|
List<Inline> out = new ArrayList<>();
|
||||||
|
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
|
||||||
|
Inline rendered = renderNode(child);
|
||||||
|
if (rendered != null) {
|
||||||
|
out.add(rendered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** heading id 계산과 alt 추출에 쓰는 평문. */
|
||||||
|
static String plainText(Node parent) {
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
appendPlainText(parent, text);
|
||||||
|
return text.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendPlainText(Node parent, StringBuilder text) {
|
||||||
|
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
|
||||||
|
switch (child) {
|
||||||
|
case Text value -> text.append(value.getLiteral());
|
||||||
|
case Code value -> text.append(value.getLiteral());
|
||||||
|
case SoftLineBreak ignored -> text.append(' ');
|
||||||
|
case HardLineBreak ignored -> text.append(' ');
|
||||||
|
default -> appendPlainText(child, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Inline renderNode(Node node) {
|
||||||
|
return switch (node) {
|
||||||
|
case Text value -> text(value.getLiteral());
|
||||||
|
case Code value -> {
|
||||||
|
// 계약의 InlineCode.code 는 minLength 1 이다. 빈 백틱은 보낼 값이 없으므로 버린다.
|
||||||
|
if (value.getLiteral().isEmpty()) {
|
||||||
|
yield null;
|
||||||
|
}
|
||||||
|
InlineCode code = new InlineCode();
|
||||||
|
code.setType(InlineCode.TypeEnum.INLINE_CODE);
|
||||||
|
code.setCode(value.getLiteral());
|
||||||
|
yield code;
|
||||||
|
}
|
||||||
|
case Emphasis value -> {
|
||||||
|
InlineEmphasis emphasis = new InlineEmphasis();
|
||||||
|
emphasis.setType(InlineEmphasis.TypeEnum.EMPHASIS);
|
||||||
|
emphasis.setChildren(render(value));
|
||||||
|
yield emphasis;
|
||||||
|
}
|
||||||
|
case StrongEmphasis value -> {
|
||||||
|
InlineStrong strong = new InlineStrong();
|
||||||
|
strong.setType(InlineStrong.TypeEnum.STRONG);
|
||||||
|
strong.setChildren(render(value));
|
||||||
|
yield strong;
|
||||||
|
}
|
||||||
|
case Link value -> {
|
||||||
|
InlineLink link = new InlineLink();
|
||||||
|
link.setType(InlineLink.TypeEnum.LINK);
|
||||||
|
String label = plainText(value);
|
||||||
|
// 계약의 label 은 minLength 1 이다. 라벨 없는 링크는 주소 자체를 라벨로 쓴다 —
|
||||||
|
// 버리면 사용자가 쓴 링크가 통째로 사라진다.
|
||||||
|
link.setLabel(label.isBlank() ? value.getDestination() : label);
|
||||||
|
link.setHref(java.net.URI.create(value.getDestination()));
|
||||||
|
yield link;
|
||||||
|
}
|
||||||
|
// 이미지는 EvidenceFigure 로만 다룬다(설계 05장 §3). 인라인 이미지는 계약의 Inline union 에
|
||||||
|
// 대응 타입이 없으므로 alt 를 글자로 남긴다 — 조용히 사라지게 두지 않는다.
|
||||||
|
case Image value -> text(plainText(value));
|
||||||
|
case SoftLineBreak ignored -> text(" ");
|
||||||
|
case HardLineBreak ignored -> text(" ");
|
||||||
|
default -> {
|
||||||
|
String plain = plainText(node);
|
||||||
|
yield plain.isEmpty() ? null : text(plain);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 블록 경계를 한 칸 띄우는 조각. 계약의 Inline union 에 줄바꿈 타입이 없어 공백으로 표현한다. */
|
||||||
|
static Inline spacer() {
|
||||||
|
return text(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Inline text(String literal) {
|
||||||
|
if (literal == null || literal.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
InlineText inlineText = new InlineText();
|
||||||
|
inlineText.setType(InlineText.TypeEnum.TEXT);
|
||||||
|
inlineText.setText(literal);
|
||||||
|
return inlineText;
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문을 "평범한 Markdown" 조각과 "directive" 조각으로 순서대로 자른다.
|
||||||
|
*
|
||||||
|
* <p>fenced code block 안의 {@code :::} 는 directive 가 아니다 — 코드 예시로 directive 문법 자체를 적는 문서가 그 자리에서 잘리면
|
||||||
|
* 안 된다. 그래서 코드 펜스 안에 있는 동안은 directive 를 찾지 않는다.
|
||||||
|
*/
|
||||||
|
final class MarkdownSegments {
|
||||||
|
|
||||||
|
private MarkdownSegments() {}
|
||||||
|
|
||||||
|
/** 조각 하나. {@code directive} 가 null 이면 평범한 Markdown 이다. */
|
||||||
|
record Segment(String markdown, StudioDirective directive, String directiveBody) {}
|
||||||
|
|
||||||
|
static List<Segment> split(String source) {
|
||||||
|
List<Segment> segments = new ArrayList<>();
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
String[] lines = source.split("\n", -1);
|
||||||
|
StringBuilder markdown = new StringBuilder();
|
||||||
|
String codeFence = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < lines.length; i++) {
|
||||||
|
String line = lines[i];
|
||||||
|
String trimmed = line.strip();
|
||||||
|
|
||||||
|
if (codeFence != null) {
|
||||||
|
markdown.append(line).append('\n');
|
||||||
|
if (trimmed.startsWith(codeFence)) {
|
||||||
|
codeFence = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
|
||||||
|
codeFence = trimmed.startsWith("```") ? "```" : "~~~";
|
||||||
|
markdown.append(line).append('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
StudioDirective directive = StudioDirective.parse(line);
|
||||||
|
if (directive == null || directive.name().isEmpty()) {
|
||||||
|
markdown.append(line).append('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush(segments, markdown);
|
||||||
|
|
||||||
|
// 컨테이너형(:::name ... 내용 ... :::)인지 leaf형(:::evidence ...)인지는 <b>닫는 줄이
|
||||||
|
// 실제로 있는지</b>로 정한다. 이름으로 정하면 새 directive 를 더할 때마다 목록을 고쳐야 하고,
|
||||||
|
// "닫는 줄이 없으면 문서 끝까지 본문"으로 정하면 닫기를 빠뜨린 directive 하나가 뒤 내용을
|
||||||
|
// 통째로 삼킨다. 닫는 줄은 다음 directive 가 열리기 전까지만 찾는다.
|
||||||
|
int closing = -1;
|
||||||
|
for (int j = i + 1; j < lines.length; j++) {
|
||||||
|
String candidate = lines[j].strip();
|
||||||
|
if (":::".equals(candidate)) {
|
||||||
|
closing = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (StudioDirective.parse(lines[j]) != null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closing < 0) {
|
||||||
|
segments.add(new Segment(null, directive, ""));
|
||||||
|
} else {
|
||||||
|
StringBuilder body = new StringBuilder();
|
||||||
|
for (int j = i + 1; j < closing; j++) {
|
||||||
|
body.append(lines[j]).append('\n');
|
||||||
|
}
|
||||||
|
segments.add(new Segment(null, directive, body.toString()));
|
||||||
|
i = closing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(segments, markdown);
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void flush(List<Segment> segments, StringBuilder markdown) {
|
||||||
|
if (!markdown.isEmpty()) {
|
||||||
|
String text = markdown.toString();
|
||||||
|
if (!text.isBlank()) {
|
||||||
|
segments.add(new Segment(text, null, null));
|
||||||
|
}
|
||||||
|
markdown.setLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionPublicRenderModel;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionPublicRenderModel;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferencePublicRenderModel;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RenderContext;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedQuestionResolution;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedRelation;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RenderInput;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 편집본 + 해석된 의존 상태 → 계약 {@code PublicRenderModel}.
|
||||||
|
*
|
||||||
|
* <p>본문 Markdown 을 블록으로 바꾸는 것은 {@code CASE} 뿐이다 — 나머지 세 유형의 공개 모델은 구조화된 필드로만 이루어져 있다(계약 {@code
|
||||||
|
* *PublicRenderModel}).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PublicRenderModelFactory {
|
||||||
|
|
||||||
|
private final StudioContentRenderer contentRenderer;
|
||||||
|
|
||||||
|
public PublicRenderModelFactory(StudioContentRenderer contentRenderer) {
|
||||||
|
this.contentRenderer = contentRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더 결과와 그 과정에서 생긴 경고.
|
||||||
|
*
|
||||||
|
* @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들(설계 05장 §12)
|
||||||
|
*/
|
||||||
|
public record Rendered(PublicRenderModel model, List<String> warnings) {}
|
||||||
|
|
||||||
|
public Rendered create(RenderInput input) {
|
||||||
|
RenderContext context = new RenderContext();
|
||||||
|
context.setGeneratedAt(StudioResponseMapper.offsetDateTime(input.generatedAt()));
|
||||||
|
context.setDependencyRevision(input.dependencyRevision());
|
||||||
|
|
||||||
|
List<ResolvedRelation> relations =
|
||||||
|
input.relations().stream().map(PublicRenderModelFactory::relation).toList();
|
||||||
|
DisplayTarget topic = StudioResponseMapper.displayTarget(input.topic());
|
||||||
|
DisplayTarget project = StudioResponseMapper.displayTarget(input.project());
|
||||||
|
|
||||||
|
return switch (input.document()) {
|
||||||
|
case WorkingCopyView.CaseWorkingCopyView value -> {
|
||||||
|
StudioContentRenderer.RenderedContent body =
|
||||||
|
contentRenderer.render(value.bodyMarkdown(), input.assetsByKey());
|
||||||
|
CasePublicRenderModel model = new CasePublicRenderModel();
|
||||||
|
model.setKind(CasePublicRenderModel.KindEnum.CASE);
|
||||||
|
model.setSlug(value.base().slug());
|
||||||
|
model.setTitle(value.base().title());
|
||||||
|
model.setSummary(value.base().summary());
|
||||||
|
model.setPublicPath(input.publicPath());
|
||||||
|
model.setTopic(topic);
|
||||||
|
model.setProject(project);
|
||||||
|
model.setRelations(relations);
|
||||||
|
model.setRenderContext(context);
|
||||||
|
model.setProblem(value.problem());
|
||||||
|
model.setConclusion(value.conclusion());
|
||||||
|
model.setEnvironment(value.environment());
|
||||||
|
model.setReproduction(value.reproduction());
|
||||||
|
model.setLastVerifiedOn(value.lastVerifiedOn());
|
||||||
|
model.setBodyBlocks(body.blocks());
|
||||||
|
yield new Rendered(model, body.warnings());
|
||||||
|
}
|
||||||
|
case WorkingCopyView.ReferenceWorkingCopyView value -> {
|
||||||
|
ReferencePublicRenderModel model = new ReferencePublicRenderModel();
|
||||||
|
model.setKind(ReferencePublicRenderModel.KindEnum.REFERENCE);
|
||||||
|
model.setSlug(value.base().slug());
|
||||||
|
model.setTitle(value.base().title());
|
||||||
|
model.setSummary(value.base().summary());
|
||||||
|
model.setPublicPath(input.publicPath());
|
||||||
|
model.setTopic(topic);
|
||||||
|
model.setProject(project);
|
||||||
|
model.setRelations(relations);
|
||||||
|
model.setRenderContext(context);
|
||||||
|
model.setPurpose(value.purpose());
|
||||||
|
model.setRules(StudioResponseMapper.rulesToApi(value.rules()));
|
||||||
|
model.setApplyWhen(StudioResponseMapper.orderedTextToApi(value.applyWhen()));
|
||||||
|
model.setExceptions(StudioResponseMapper.orderedTextToApi(value.exceptions()));
|
||||||
|
model.setExamples(StudioResponseMapper.orderedTextToApi(value.examples()));
|
||||||
|
model.setVerifiedOn(value.verifiedOn());
|
||||||
|
yield new Rendered(model, List.of());
|
||||||
|
}
|
||||||
|
case WorkingCopyView.QuestionWorkingCopyView value -> {
|
||||||
|
QuestionPublicRenderModel model = new QuestionPublicRenderModel();
|
||||||
|
model.setKind(QuestionPublicRenderModel.KindEnum.QUESTION);
|
||||||
|
model.setSlug(value.base().slug());
|
||||||
|
model.setTitle(value.base().title());
|
||||||
|
model.setSummary(value.base().summary());
|
||||||
|
model.setPublicPath(input.publicPath());
|
||||||
|
model.setTopic(topic);
|
||||||
|
model.setProject(project);
|
||||||
|
model.setRelations(relations);
|
||||||
|
model.setRenderContext(context);
|
||||||
|
model.setStatus(
|
||||||
|
value.questionStatus() == QuestionStatusView.RESOLVED
|
||||||
|
? QuestionPublicRenderModel.StatusEnum.RESOLVED
|
||||||
|
: QuestionPublicRenderModel.StatusEnum.OPEN);
|
||||||
|
model.setFacts(StudioResponseMapper.orderedTextToApi(value.facts()));
|
||||||
|
model.setAssumptions(StudioResponseMapper.orderedTextToApi(value.assumptions()));
|
||||||
|
model.setUnknowns(StudioResponseMapper.orderedTextToApi(value.unknowns()));
|
||||||
|
model.setConstraints(StudioResponseMapper.orderedTextToApi(value.constraints()));
|
||||||
|
model.setOptions(StudioResponseMapper.optionsToApi(value.options()));
|
||||||
|
model.setNextValidation(value.nextValidation());
|
||||||
|
model.setResolution(resolution(value, input));
|
||||||
|
yield new Rendered(model, List.of());
|
||||||
|
}
|
||||||
|
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
|
||||||
|
ProjectDecisionPublicRenderModel model = new ProjectDecisionPublicRenderModel();
|
||||||
|
model.setKind(ProjectDecisionPublicRenderModel.KindEnum.PROJECT_DECISION);
|
||||||
|
model.setSlug(value.base().slug());
|
||||||
|
model.setTitle(value.base().title());
|
||||||
|
model.setSummary(value.base().summary());
|
||||||
|
model.setPublicPath(input.publicPath());
|
||||||
|
model.setTopic(topic);
|
||||||
|
model.setProject(project);
|
||||||
|
model.setRelations(relations);
|
||||||
|
model.setRenderContext(context);
|
||||||
|
model.setStatus(
|
||||||
|
value.decisionStatus() == DecisionStatusView.ADOPTED
|
||||||
|
? ProjectDecisionPublicRenderModel.StatusEnum.ADOPTED
|
||||||
|
: ProjectDecisionPublicRenderModel.StatusEnum.PROPOSED);
|
||||||
|
model.setDecidedOn(value.decidedOn());
|
||||||
|
model.setStatement(value.statement());
|
||||||
|
model.setRationale(value.rationale());
|
||||||
|
model.setConsequences(StudioResponseMapper.orderedTextToApi(value.consequences()));
|
||||||
|
yield new Rendered(model, List.of());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResolvedRelation relation(ResolvedRelationView view) {
|
||||||
|
ResolvedRelation relation = new ResolvedRelation();
|
||||||
|
relation.setId(view.id());
|
||||||
|
relation.setTargetId(view.targetId());
|
||||||
|
relation.setTargetKind(ResolvedRelation.TargetKindEnum.fromValue(view.targetKind()));
|
||||||
|
relation.setTitle(view.title());
|
||||||
|
relation.setPublicPath(view.publicPath());
|
||||||
|
relation.setReason(view.reason());
|
||||||
|
relation.setOrder(view.order());
|
||||||
|
return relation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResolvedQuestionResolution resolution(
|
||||||
|
WorkingCopyView.QuestionWorkingCopyView value, RenderInput input) {
|
||||||
|
if (value.resolution() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ResolvedQuestionResolution resolution = new ResolvedQuestionResolution();
|
||||||
|
resolution.setSummary(value.resolution().summary());
|
||||||
|
resolution.setEvidenceTarget(
|
||||||
|
StudioResponseMapper.displayTarget(input.resolutionEvidenceTarget()));
|
||||||
|
resolution.setLinkLabel(value.resolution().linkLabel());
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RenderInput;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort;
|
||||||
|
import dev.caskeleton.shared.error.MappingException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tools.jackson.core.JacksonException;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더 결과를 {@code studio_preview.render_model} 에 그대로 들어갈 JSON 으로 만든다.
|
||||||
|
*
|
||||||
|
* <p>렌더 경고는 여기서 버리지 않고 로그로 남긴다 — 계약의 {@code PublicPreview} 에 경고를 실을 자리가 없지만, 경고가 생겼다는 사실 자체가 "작성자가
|
||||||
|
* 쓴 문법 일부가 계약으로 표현되지 못했다"는 신호라 흔적 없이 사라지면 안 된다. 게시를 막아야 하는 종류(미해결 Asset 등)는 검증이 별도로 잡는다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RenderModelJsonAdapter implements RenderModelPort {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RenderModelJsonAdapter.class);
|
||||||
|
|
||||||
|
private final PublicRenderModelFactory factory;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public RenderModelJsonAdapter(PublicRenderModelFactory factory, ObjectMapper objectMapper) {
|
||||||
|
this.factory = factory;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String renderToJson(RenderInput input) {
|
||||||
|
PublicRenderModelFactory.Rendered rendered = factory.create(input);
|
||||||
|
if (!rendered.warnings().isEmpty()) {
|
||||||
|
log.warn(
|
||||||
|
"studio render produced {} warning(s) for document {}: {}",
|
||||||
|
rendered.warnings().size(),
|
||||||
|
input.document().id(),
|
||||||
|
rendered.warnings());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(rendered.model());
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new MappingException("failed to serialise a Studio render model", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedAsset;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.commonmark.ext.autolink.AutolinkExtension;
|
||||||
|
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
|
||||||
|
import org.commonmark.ext.gfm.tables.TablesExtension;
|
||||||
|
import org.commonmark.parser.Parser;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문 Markdown → 계약의 {@code CaseRenderBlock} 목록. Preview·공개·Snapshot 세 화면이 이 한 구현을 공유한다(ADR-005) —
|
||||||
|
* 화면마다 다른 경로를 두면 작성자가 확인한 것과 공개된 것이 달라진다.
|
||||||
|
*
|
||||||
|
* <p>Asset 해석은 렌더러가 직접 조회하지 않고 {@code assetsByKey} 로 주입받는다. Snapshot 만 게시 시점에 고정된 manifest 를 넣고
|
||||||
|
* 나머지는 현재 상태를 넣으며, 그것이 ADR-002 가 요구하는 유일하게 허용된 차이다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class StudioContentRenderer implements ContentAnalyzerPort {
|
||||||
|
|
||||||
|
/** 설계 05장 §4가 허용한 callout 종류. 그 밖의 이름은 경고를 만들고 일반 인용으로 처리한다. */
|
||||||
|
private static final Set<String> INFO_CALLOUTS = Set.of("note", "tip");
|
||||||
|
|
||||||
|
private static final Set<String> WARNING_CALLOUTS = Set.of("warning", "danger");
|
||||||
|
|
||||||
|
private static final String EVIDENCE = "evidence";
|
||||||
|
|
||||||
|
private final Parser parser =
|
||||||
|
Parser.builder()
|
||||||
|
.extensions(
|
||||||
|
List.of(
|
||||||
|
TablesExtension.create(),
|
||||||
|
StrikethroughExtension.create(),
|
||||||
|
AutolinkExtension.create()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더 결과.
|
||||||
|
*
|
||||||
|
* @param blocks 계약 모양의 본문 블록
|
||||||
|
* @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들의 코드
|
||||||
|
*/
|
||||||
|
public record RenderedContent(List<CaseRenderBlock> blocks, List<String> warnings) {}
|
||||||
|
|
||||||
|
public RenderedContent render(String bodyMarkdown, Map<String, ResolvedAssetView> assetsByKey) {
|
||||||
|
List<CaseRenderBlock> blocks = new ArrayList<>();
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
HeadingIds headingIds = new HeadingIds();
|
||||||
|
|
||||||
|
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
|
||||||
|
if (segment.directive() == null) {
|
||||||
|
blocks.addAll(
|
||||||
|
new BlockRenderer(headingIds, warnings).render(parser.parse(segment.markdown())));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
CaseRenderBlock block = renderDirective(segment, assetsByKey, headingIds, warnings);
|
||||||
|
if (block != null) {
|
||||||
|
blocks.add(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RenderedContent(blocks, warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ContentAnalysis analyze(String bodyMarkdown) {
|
||||||
|
List<AssetUsage> usages = new ArrayList<>();
|
||||||
|
Set<String> unsupported = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
|
||||||
|
StudioDirective directive = segment.directive();
|
||||||
|
if (directive == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (EVIDENCE.equals(directive.name())) {
|
||||||
|
usages.add(new AssetUsage(directive.attribute("key", ""), directive.attribute("alt", "")));
|
||||||
|
} else if (!INFO_CALLOUTS.contains(directive.name())
|
||||||
|
&& !WARNING_CALLOUTS.contains(directive.name())) {
|
||||||
|
unsupported.add(directive.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ContentAnalysis(usages, List.copyOf(unsupported), plainText(bodyMarkdown));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검색 색인용 평문. 렌더러가 이미 파싱한 것을 다시 쓴다 — 정규식으로 마크업을 지우는 별도 구현을 두면 두 해석이 갈라져 색인이 본문과 어긋난다. */
|
||||||
|
private String plainText(String bodyMarkdown) {
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
|
||||||
|
String source = segment.directive() == null ? segment.markdown() : segment.directiveBody();
|
||||||
|
if (source == null || source.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String plain = InlineRenderer.plainText(parser.parse(source));
|
||||||
|
if (!plain.isBlank()) {
|
||||||
|
if (!text.isEmpty()) {
|
||||||
|
text.append(' ');
|
||||||
|
}
|
||||||
|
text.append(plain.strip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CaseRenderBlock renderDirective(
|
||||||
|
MarkdownSegments.Segment segment,
|
||||||
|
Map<String, ResolvedAssetView> assetsByKey,
|
||||||
|
HeadingIds headingIds,
|
||||||
|
List<String> warnings) {
|
||||||
|
|
||||||
|
StudioDirective directive = segment.directive();
|
||||||
|
if (EVIDENCE.equals(directive.name())) {
|
||||||
|
return evidence(directive, assetsByKey, warnings);
|
||||||
|
}
|
||||||
|
if (INFO_CALLOUTS.contains(directive.name()) || WARNING_CALLOUTS.contains(directive.name())) {
|
||||||
|
CalloutBlock callout = new CalloutBlock();
|
||||||
|
callout.setType(CalloutBlock.TypeEnum.CALLOUT);
|
||||||
|
callout.setTone(
|
||||||
|
WARNING_CALLOUTS.contains(directive.name())
|
||||||
|
? CalloutBlock.ToneEnum.WARNING
|
||||||
|
: CalloutBlock.ToneEnum.INFO);
|
||||||
|
callout.setLabel(directive.argument());
|
||||||
|
callout.setContent(
|
||||||
|
InlineRenderer.render(
|
||||||
|
parser.parse(segment.directiveBody()).getFirstChild() == null
|
||||||
|
? parser.parse("")
|
||||||
|
: parser.parse(segment.directiveBody()).getFirstChild()));
|
||||||
|
return callout;
|
||||||
|
}
|
||||||
|
// 설계 05장 §4: 알 수 없는 종류는 경고를 만들고 일반 blockquote 로 안전하게 처리한다.
|
||||||
|
warnings.add("UNSUPPORTED_DIRECTIVE:" + directive.name());
|
||||||
|
List<CaseRenderBlock> fallback =
|
||||||
|
new BlockRenderer(headingIds, warnings)
|
||||||
|
.render(parser.parse("> " + segment.directiveBody().replace("\n", "\n> ")));
|
||||||
|
return fallback.isEmpty() ? null : fallback.getFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CaseRenderBlock evidence(
|
||||||
|
StudioDirective directive,
|
||||||
|
Map<String, ResolvedAssetView> assetsByKey,
|
||||||
|
List<String> warnings) {
|
||||||
|
|
||||||
|
String key = directive.attribute("key", "");
|
||||||
|
if (key.isBlank()) {
|
||||||
|
warnings.add("EVIDENCE_WITHOUT_KEY");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ResolvedAssetView resolved = assetsByKey.get(key);
|
||||||
|
if (resolved == null) {
|
||||||
|
// 미해결 key 를 임의 경로로 채워 넣지 않는다 — 렌더 결과가 존재하지 않는 파일을 가리키게 된다.
|
||||||
|
// 게시는 검증이 막고, 미리보기에서는 이 경고가 사용자에게 무엇이 빠졌는지 알려준다.
|
||||||
|
warnings.add("UNRESOLVED_ASSET_KEY:" + key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
EvidenceFigureBlock block = new EvidenceFigureBlock();
|
||||||
|
block.setType(EvidenceFigureBlock.TypeEnum.EVIDENCE_FIGURE);
|
||||||
|
block.setKey(key);
|
||||||
|
block.setAlt(directive.attribute("alt", ""));
|
||||||
|
block.setCaption(directive.attribute("caption", ""));
|
||||||
|
block.setZoom(directive.booleanAttribute("zoom"));
|
||||||
|
|
||||||
|
ResolvedAsset asset = new ResolvedAsset();
|
||||||
|
asset.setAssetId(resolved.assetId());
|
||||||
|
asset.setAssetKey(resolved.assetKey());
|
||||||
|
asset.setMediaType(resolved.mediaType());
|
||||||
|
asset.setPublicPath(resolved.publicPath());
|
||||||
|
asset.setWidth(resolved.width());
|
||||||
|
asset.setHeight(resolved.height());
|
||||||
|
asset.setDecorative(resolved.decorative());
|
||||||
|
block.setAsset(asset);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code :::name key="value" ...} 한 줄을 이름과 속성으로 나눈다.
|
||||||
|
*
|
||||||
|
* <p>directive 를 commonmark 확장이 아니라 줄 단위 스캔으로 다루는 이유: v1 문법에서 directive 는 중첩이 없고 줄 맨 앞에서만 열린다(설계
|
||||||
|
* 05장 §3.2, §4). 줄 스캔이면 동작이 눈으로 확인되고, 지원하지 않는 directive 를 "조용히 다른 것으로 해석"하는 일이 구조적으로 생기지 않는다.
|
||||||
|
*/
|
||||||
|
record StudioDirective(String name, String argument, Map<String, String> attributes) {
|
||||||
|
|
||||||
|
private static final Pattern OPENING = Pattern.compile("^:::([A-Za-z][A-Za-z0-9_-]*)\\s*(.*)$");
|
||||||
|
private static final Pattern ATTRIBUTE = Pattern.compile("([A-Za-z][A-Za-z0-9_-]*)=\"([^\"]*)\"");
|
||||||
|
|
||||||
|
static StudioDirective parse(String line) {
|
||||||
|
Matcher opening = OPENING.matcher(line.strip());
|
||||||
|
if (!opening.matches()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String rest = opening.group(2).strip();
|
||||||
|
|
||||||
|
Map<String, String> attributes = new LinkedHashMap<>();
|
||||||
|
Matcher attribute = ATTRIBUTE.matcher(rest);
|
||||||
|
int firstAttributeStart = rest.length();
|
||||||
|
while (attribute.find()) {
|
||||||
|
if (attribute.start() < firstAttributeStart) {
|
||||||
|
firstAttributeStart = attribute.start();
|
||||||
|
}
|
||||||
|
attributes.put(attribute.group(1), attribute.group(2));
|
||||||
|
}
|
||||||
|
String argument = rest.substring(0, firstAttributeStart).strip();
|
||||||
|
return new StudioDirective(opening.group(1), argument, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
String attribute(String name, String fallback) {
|
||||||
|
String value = attributes.get(name);
|
||||||
|
return value == null ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean booleanAttribute(String name) {
|
||||||
|
return "true".equalsIgnoreCase(attributes.get(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.cursor.CursorCodec;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioError;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약이 "opaque 하고 정규화된 필터·정렬에 결합된 커서"라고 정한 것을 실제로 그렇게 만든다.
|
||||||
|
*
|
||||||
|
* <p>필터 지문을 커서 안에 함께 서명한다 — 그래야 필터를 바꾼 뒤 옛 커서를 재사용하는 요청을 거절할 수 있다. 거절하지 않으면 정렬 키의 의미가 달라진 채로 페이지가
|
||||||
|
* 이어져 사용자에게는 항목이 조용히 사라지거나 중복돼 보인다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class StudioCursors {
|
||||||
|
|
||||||
|
private static final String SEPARATOR = "~";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지문 입력의 필드 구분자(ASCII unit separator). 사용자 입력에 나타나지 않는 제어문자라 인접한 필드가 서로 섞여 같은 지문을 만드는 일이 없다 — 예를
|
||||||
|
* 들어 구분자가 없으면 (kind="A", q="B")와 (kind="AB", q="")가 같은 값이 된다.
|
||||||
|
*/
|
||||||
|
private static final char FIELD_SEPARATOR = (char) 0x1f;
|
||||||
|
|
||||||
|
private final CursorCodec codec;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public StudioCursors(StudioSettings settings, Clock clock) {
|
||||||
|
this.codec =
|
||||||
|
new CursorCodec(
|
||||||
|
settings.cursorSigningKey().getBytes(StandardCharsets.UTF_8), CursorCodec.DEFAULT_TTL);
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이 페이지 요청의 필터·정렬을 대표하는 값. 커서에 함께 실린다. */
|
||||||
|
public static String fingerprint(String... normalizedFilterParts) {
|
||||||
|
StringBuilder joined = new StringBuilder();
|
||||||
|
for (String part : normalizedFilterParts) {
|
||||||
|
joined.append(part == null ? "" : part).append(FIELD_SEPARATOR);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] digest =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(joined.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(digest, 0, 8);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String encode(String payload, String fingerprint) {
|
||||||
|
String body =
|
||||||
|
fingerprint
|
||||||
|
+ SEPARATOR
|
||||||
|
+ Base64.getUrlEncoder()
|
||||||
|
.withoutPadding()
|
||||||
|
.encodeToString(payload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return codec.encode(body, clock.instant());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커서를 풀어 페이지 위치로 돌려준다.
|
||||||
|
*
|
||||||
|
* @throws StudioException 서명·만료·필터 지문 중 하나라도 맞지 않으면 {@code REQUEST_VALIDATION_FAILED}
|
||||||
|
*/
|
||||||
|
public DocumentCursorPosition decode(String cursor, String fingerprint, boolean titleSort) {
|
||||||
|
String body;
|
||||||
|
try {
|
||||||
|
body = codec.decode(cursor, clock.instant());
|
||||||
|
} catch (CursorException e) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.REQUEST_VALIDATION_FAILED, "cursor is not usable: " + e.getMessage());
|
||||||
|
}
|
||||||
|
int separator = body.indexOf(SEPARATOR);
|
||||||
|
if (separator <= 0) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
if (!fingerprint.equals(body.substring(0, separator))) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.REQUEST_VALIDATION_FAILED,
|
||||||
|
"cursor was issued for a different filter or sort; start from the first page");
|
||||||
|
}
|
||||||
|
String payload =
|
||||||
|
new String(
|
||||||
|
Base64.getUrlDecoder().decode(body.substring(separator + SEPARATOR.length())),
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
int pipe = payload.lastIndexOf('|');
|
||||||
|
if (pipe <= 0) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
String head = payload.substring(0, pipe);
|
||||||
|
UUID id;
|
||||||
|
try {
|
||||||
|
id = UUID.fromString(payload.substring(pipe + 1));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
if (titleSort) {
|
||||||
|
return new DocumentCursorPosition(null, head, id);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new DocumentCursorPosition(Instant.parse(head), null, id);
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
throw malformed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StudioException malformed() {
|
||||||
|
return StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "cursor is malformed");
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport;
|
||||||
|
import dev.caskeleton.application.idempotency.IdempotencyContext;
|
||||||
|
import dev.caskeleton.application.idempotency.IdempotencyExecutor;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioError;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약이 모든 mutation 에 요구하는 {@code Idempotency-Key} 처리(spec §8.2).
|
||||||
|
*
|
||||||
|
* <p>재생 여부를 스스로 판단하지 않고 <b>동작이 실제로 실행됐는지</b>로 안다 — 저장소를 미리 들여다보고 판정하면 그 사이에 다른 요청이 끼어들 수 있어 헤더가
|
||||||
|
* 거짓말을 하게 된다. 실행되지 않았다면 결과는 재생된 것이다.
|
||||||
|
*
|
||||||
|
* <p>{@code IdempotencyExecutor} 는 provider 가 {@code disabled} 인 배포에는 빈이 없다. 그때는 키의 존재만 계약대로 강제하고
|
||||||
|
* 실행은 그대로 통과시킨다 — 여기서 빈을 필수로 요구하면 그런 배포는 Studio 컨트롤러 때문에 부팅 자체가 실패한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class StudioIdempotency {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code components.headers.IdempotencyReplayed}. {@code ApiHeaders}에 두지 않는 이유는 그 파일이 템플릿
|
||||||
|
* SSOT({@code wiki/projects/ca-tmpl/registries/headers.yaml}) 소유라 이 기능이 손대면 다음 동기화에서 충돌하기 때문이다.
|
||||||
|
*/
|
||||||
|
public static final String IDEMPOTENCY_REPLAYED = "Idempotency-Replayed";
|
||||||
|
|
||||||
|
/** 계약 {@code components.parameters.IdempotencyKey.schema.maxLength}. */
|
||||||
|
private static final int MAX_KEY_LENGTH = 200;
|
||||||
|
|
||||||
|
private final ObjectProvider<IdempotencyExecutor> executors;
|
||||||
|
private final IdempotencyKeySupport keys;
|
||||||
|
|
||||||
|
public StudioIdempotency(
|
||||||
|
ObjectProvider<IdempotencyExecutor> executors, IdempotencyKeySupport keys) {
|
||||||
|
this.executors = executors;
|
||||||
|
this.keys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결과와 그 결과가 재생된 것인지 여부.
|
||||||
|
*
|
||||||
|
* @param <R> 동작의 결과 타입
|
||||||
|
*/
|
||||||
|
public record Outcome<R>(R result, boolean replayed) {}
|
||||||
|
|
||||||
|
public <R> Outcome<R> run(
|
||||||
|
HttpServletRequest request,
|
||||||
|
String operationId,
|
||||||
|
Object requestPayload,
|
||||||
|
Class<R> responseType,
|
||||||
|
Supplier<R> action) {
|
||||||
|
|
||||||
|
String key = requireKey(request);
|
||||||
|
IdempotencyExecutor executor = executors.getIfAvailable();
|
||||||
|
if (executor == null) {
|
||||||
|
return new Outcome<>(action.get(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
AtomicBoolean executed = new AtomicBoolean(false);
|
||||||
|
R result =
|
||||||
|
executor.execute(
|
||||||
|
IdempotencyContext.of(keys.scope(key, operationId), keys.fingerprint(requestPayload)),
|
||||||
|
() -> {
|
||||||
|
executed.set(true);
|
||||||
|
return action.get();
|
||||||
|
},
|
||||||
|
keys.codec(responseType));
|
||||||
|
return new Outcome<>(result, !executed.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireKey(HttpServletRequest request) {
|
||||||
|
Optional<String> key = keys.idempotencyKey(request);
|
||||||
|
if (key.isEmpty()) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.REQUEST_VALIDATION_FAILED, "the Idempotency-Key header is required");
|
||||||
|
}
|
||||||
|
if (key.get().length() > MAX_KEY_LENGTH) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.REQUEST_VALIDATION_FAILED,
|
||||||
|
"the Idempotency-Key header must be at most " + MAX_KEY_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
return key.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioError;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 감사 컬럼({@code created_by}/{@code updated_by})에 남길 주체를 뽑는다.
|
||||||
|
*
|
||||||
|
* <p>{@code idpUserId} 를 쓴다 — 이메일이나 표시 이름은 사용자가 바꿀 수 있어 과거 기록의 주체를 되짚을 수 없게 된다.
|
||||||
|
*/
|
||||||
|
public final class StudioPrincipals {
|
||||||
|
|
||||||
|
private StudioPrincipals() {}
|
||||||
|
|
||||||
|
public static String require(AuthenticatedPrincipal principal) {
|
||||||
|
if (principal == null || principal.idpUserId() == null || principal.idpUserId().isBlank()) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.AUTHENTICATION_REQUIRED, "the request has no usable authenticated principal");
|
||||||
|
}
|
||||||
|
return principal.idpUserId();
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code ca-skeleton.techlog.studio.*}. Studio 고유 설정을 템플릿 소유 파일({@code PresentationSettings} 등)에 섞지
|
||||||
|
* 않고 여기 모은다 — 그 파일들은 template sync 대상이라 이 기능이 손대면 다음 동기화에서 충돌한다.
|
||||||
|
*
|
||||||
|
* @param cursorSigningKey 목록 커서 서명 키. 비어 있으면 개발용 값으로 대체하고 경고한다 — 커서에는 권한이 실리지 않으므로 부팅을 막을 사유는 아니지만,
|
||||||
|
* 인스턴스마다 값이 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다.
|
||||||
|
* @param validationTtl 검증 결과가 유효한 기간({@code studio_validation.valid_until})
|
||||||
|
* @param previewTtl 미리보기가 유효한 기간({@code studio_preview.expires_at})
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "ca-skeleton.techlog.studio")
|
||||||
|
public record StudioSettings(String cursorSigningKey, Duration validationTtl, Duration previewTtl) {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(StudioSettings.class);
|
||||||
|
private static final String DEV_CURSOR_KEY = "__LOCAL_DEV_techlog_studio_cursor_signing_key";
|
||||||
|
private static final int MIN_KEY_BYTES = 16;
|
||||||
|
private static final Duration DEFAULT_VALIDATION_TTL = Duration.ofHours(1);
|
||||||
|
private static final Duration DEFAULT_PREVIEW_TTL = Duration.ofHours(24);
|
||||||
|
|
||||||
|
public StudioSettings {
|
||||||
|
if (cursorSigningKey == null
|
||||||
|
|| cursorSigningKey.getBytes(StandardCharsets.UTF_8).length < MIN_KEY_BYTES) {
|
||||||
|
log.warn(
|
||||||
|
"APP_STUDIO_CURSOR_SIGNING_KEY is missing or shorter than {} bytes; using a development"
|
||||||
|
+ " key. Cursors issued by one instance verify on another only while every instance"
|
||||||
|
+ " falls back to the same value.",
|
||||||
|
MIN_KEY_BYTES);
|
||||||
|
cursorSigningKey = DEV_CURSOR_KEY;
|
||||||
|
}
|
||||||
|
if (validationTtl == null || validationTtl.isZero() || validationTtl.isNegative()) {
|
||||||
|
validationTtl = DEFAULT_VALIDATION_TTL;
|
||||||
|
}
|
||||||
|
if (previewTtl == null || previewTtl.isZero() || previewTtl.isNegative()) {
|
||||||
|
previewTtl = DEFAULT_PREVIEW_TTL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.contract;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약의 discriminator union이 Jackson 양방향으로 계약대로 동작하는지 고정한다.
|
||||||
|
*
|
||||||
|
* <p>이 게이트가 필요한 이유는 <b>컴파일이 이걸 못 잡기 때문</b>이다. Plan 01에서 {@code useOneOfInterfaces=false}로 생성한
|
||||||
|
* union은 컴파일 오류 0개였지만 런타임에는 양방향 모두 계약을 위반했다 — 역직렬화는 {@code InvalidTypeIdException}("CaseInput not
|
||||||
|
* subtype of WorkingCopyInput"), 직렬화는 판별 필드에 {@code kind} 값 대신 클래스 simple name. 지금은 {@code
|
||||||
|
* prepareStudioCodegenSpec}이 계약에서 {@code x-implements}와 union interface를 파생시켜 고쳤고, 이 테스트가 그 파생 배선이
|
||||||
|
* 살아 있는지를 지킨다. 파생이 깨지면 여기서 빨간불이 난다.
|
||||||
|
*
|
||||||
|
* <p>{@code new ObjectMapper()}는 이 모듈의 다른 테스트와 같은 관용구다 — Jackson 3({@code tools.jackson})이며 앱의 HTTP
|
||||||
|
* 변환기와 같은 계열이다. Jackson 2 ({@code com.fasterxml.jackson.databind})로 검증하면 프로덕션에서 실제로 쓰이지 않는 경로를 재는
|
||||||
|
* 셈이라 의미가 없다.
|
||||||
|
*/
|
||||||
|
class StudioContractUnionJacksonTest {
|
||||||
|
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
private static CaseWorkingCopy caseWorkingCopy() {
|
||||||
|
CaseWorkingCopy document = new CaseWorkingCopy();
|
||||||
|
document.setKind(CaseWorkingCopy.KindEnum.CASE);
|
||||||
|
document.setId(UUID.fromString("00000000-0000-4000-8000-000000000001"));
|
||||||
|
document.setVersion(1);
|
||||||
|
document.setTitle("제목");
|
||||||
|
document.setSlug("some-slug");
|
||||||
|
document.setSummary("요약");
|
||||||
|
document.setProblem("문제");
|
||||||
|
document.setConclusion("결론");
|
||||||
|
document.setEnvironment("환경");
|
||||||
|
document.setReproduction("재현");
|
||||||
|
document.setBodyMarkdown("본문");
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void workingCopyUnionSerializesTheContractDiscriminatorAndRoundTrips() {
|
||||||
|
WorkingCopyDetail detail = new WorkingCopyDetail();
|
||||||
|
detail.setDocument(caseWorkingCopy());
|
||||||
|
detail.setDependencyRevision("rev-1");
|
||||||
|
detail.setNextAction(NextAction.VALIDATE);
|
||||||
|
|
||||||
|
String json = mapper.writeValueAsString(detail);
|
||||||
|
|
||||||
|
// 판별 필드는 계약이 정한 값이어야 한다. 클래스 이름("CaseWorkingCopy")이 나가면 프론트가 깨진다.
|
||||||
|
assertThat(json).contains("\"kind\":\"CASE\"");
|
||||||
|
assertThat(json).doesNotContain("CaseWorkingCopy");
|
||||||
|
// 판별 필드가 두 번 나가면 안 된다 — union interface가 As.EXISTING_PROPERTY인 이유다.
|
||||||
|
assertThat(json.split("\"kind\":", -1)).hasSize(2);
|
||||||
|
// slug는 문자열이다. 계약의 문자열 oneOf를 접지 않으면 여기서 {} 가 나간다.
|
||||||
|
assertThat(json).contains("\"slug\":\"some-slug\"");
|
||||||
|
|
||||||
|
WorkingCopyDetail back = mapper.readValue(json, WorkingCopyDetail.class);
|
||||||
|
|
||||||
|
assertThat(back.getDocument()).isInstanceOf(CaseWorkingCopy.class);
|
||||||
|
assertThat(((CaseWorkingCopy) back.getDocument()).getProblem()).isEqualTo("문제");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void workingCopyInputUnionRoundTripsThroughTheDeclaredUnionType() {
|
||||||
|
QuestionInput input = new QuestionInput();
|
||||||
|
input.setKind(QuestionInput.KindEnum.QUESTION);
|
||||||
|
input.setTitle("질문");
|
||||||
|
input.setSlug("");
|
||||||
|
input.setSummary("요약");
|
||||||
|
input.setNextValidation("다음 검증");
|
||||||
|
|
||||||
|
WorkingCopyInput declared = input;
|
||||||
|
String json = mapper.writeValueAsString(declared);
|
||||||
|
assertThat(json).contains("\"kind\":\"QUESTION\"");
|
||||||
|
|
||||||
|
WorkingCopyInput back = mapper.readValue(json, WorkingCopyInput.class);
|
||||||
|
assertThat(back).isInstanceOf(QuestionInput.class);
|
||||||
|
assertThat(((QuestionInput) back).getNextValidation()).isEqualTo("다음 검증");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renderBlockAndInlineUnionsRoundTripInsideCollections() {
|
||||||
|
InlineText text = new InlineText();
|
||||||
|
text.setType(InlineText.TypeEnum.TEXT);
|
||||||
|
text.setText("본문 조각");
|
||||||
|
|
||||||
|
HeadingBlock heading = new HeadingBlock();
|
||||||
|
heading.setType(HeadingBlock.TypeEnum.HEADING);
|
||||||
|
heading.setId("h-1");
|
||||||
|
heading.setLevel(2);
|
||||||
|
heading.setContent(List.of(text));
|
||||||
|
|
||||||
|
CasePublicRenderModel model = new CasePublicRenderModel();
|
||||||
|
model.setKind(CasePublicRenderModel.KindEnum.CASE);
|
||||||
|
model.setSlug("some-slug");
|
||||||
|
model.setTitle("제목");
|
||||||
|
model.setSummary("요약");
|
||||||
|
model.setPublicPath("/case/some-slug");
|
||||||
|
model.setBodyBlocks(List.of(heading));
|
||||||
|
|
||||||
|
PublicRenderModel declared = model;
|
||||||
|
String json = mapper.writeValueAsString(declared);
|
||||||
|
assertThat(json).contains("\"kind\":\"CASE\"");
|
||||||
|
assertThat(json).contains("\"type\":\"HEADING\"");
|
||||||
|
assertThat(json).contains("\"type\":\"TEXT\"");
|
||||||
|
|
||||||
|
PublicRenderModel back = mapper.readValue(json, PublicRenderModel.class);
|
||||||
|
assertThat(back).isInstanceOf(CasePublicRenderModel.class);
|
||||||
|
|
||||||
|
List<CaseRenderBlock> blocks = ((CasePublicRenderModel) back).getBodyBlocks();
|
||||||
|
assertThat(blocks).hasSize(1).first().isInstanceOf(HeadingBlock.class);
|
||||||
|
|
||||||
|
List<Inline> content = ((HeadingBlock) blocks.get(0)).getContent();
|
||||||
|
assertThat(content).hasSize(1).first().isInstanceOf(InlineText.class);
|
||||||
|
assertThat(((InlineText) content.get(0)).getText()).isEqualTo("본문 조각");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unionDeserializationRejectsAnUnknownDiscriminatorInsteadOfSilentlyDroppingIt() {
|
||||||
|
String json = "{\"kind\":\"NOT_A_KIND\",\"title\":\"제목\"}";
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
org.assertj.core.api.Assertions.catchThrowable(
|
||||||
|
() -> mapper.readValue(json, WorkingCopy.class)))
|
||||||
|
.isNotNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -5,8 +5,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
|
||||||
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
|
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
|
||||||
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
|
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
|
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
|
||||||
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
|
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
|
||||||
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
|
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
|
||||||
@@ -58,6 +60,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
excludeAutoConfiguration = SecurityAutoConfiguration.class)
|
excludeAutoConfiguration = SecurityAutoConfiguration.class)
|
||||||
@AutoConfigureMockMvc(addFilters = false)
|
@AutoConfigureMockMvc(addFilters = false)
|
||||||
@Import({
|
@Import({
|
||||||
|
PresentationWebConfig.class,
|
||||||
StudioCatalogController.class,
|
StudioCatalogController.class,
|
||||||
StudioExceptionHandler.class,
|
StudioExceptionHandler.class,
|
||||||
GlobalExceptionHandler.class,
|
GlobalExceptionHandler.class,
|
||||||
@@ -98,6 +101,16 @@ class StudioCatalogBindingErrorEnvelopeTest {
|
|||||||
|
|
||||||
static class TestBeans {
|
static class TestBeans {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
|
||||||
|
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
|
||||||
|
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
PresentationSettings presentationSettings() {
|
||||||
|
return new PresentationSettings("/api");
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
ListCatalogUseCase listCatalogUseCase() {
|
ListCatalogUseCase listCatalogUseCase() {
|
||||||
CatalogQueryPort neverInvoked =
|
CatalogQueryPort neverInvoked =
|
||||||
|
|||||||
+13
@@ -6,9 +6,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
|
||||||
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
|
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
|
||||||
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
|
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
|
||||||
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
|
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
|
||||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||||
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
|
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -46,6 +48,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
*/
|
*/
|
||||||
@WebMvcTest(controllers = StudioSessionController.class)
|
@WebMvcTest(controllers = StudioSessionController.class)
|
||||||
@Import({
|
@Import({
|
||||||
|
PresentationWebConfig.class,
|
||||||
StudioSessionController.class,
|
StudioSessionController.class,
|
||||||
EnvelopeBodyAdvice.class,
|
EnvelopeBodyAdvice.class,
|
||||||
StudioExceptionHandler.class,
|
StudioExceptionHandler.class,
|
||||||
@@ -89,6 +92,16 @@ class StudioSessionCsrfDisabledTest {
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
static class SecurityTestConfig {
|
static class SecurityTestConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
|
||||||
|
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
|
||||||
|
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
PresentationSettings presentationSettings() {
|
||||||
|
return new PresentationSettings("/api");
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception {
|
SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception {
|
||||||
http.csrf(csrf -> csrf.disable())
|
http.csrf(csrf -> csrf.disable())
|
||||||
|
|||||||
+13
@@ -7,8 +7,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
|
||||||
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
|
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
|
||||||
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
|
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
|
||||||
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -68,6 +70,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
*/
|
*/
|
||||||
@WebMvcTest(controllers = StudioSessionController.class)
|
@WebMvcTest(controllers = StudioSessionController.class)
|
||||||
@Import({
|
@Import({
|
||||||
|
PresentationWebConfig.class,
|
||||||
StudioSessionController.class,
|
StudioSessionController.class,
|
||||||
EnvelopeBodyAdvice.class,
|
EnvelopeBodyAdvice.class,
|
||||||
StudioSessionEnvelopeTest.SecurityTestConfig.class
|
StudioSessionEnvelopeTest.SecurityTestConfig.class
|
||||||
@@ -111,6 +114,16 @@ class StudioSessionEnvelopeTest {
|
|||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
static class SecurityTestConfig {
|
static class SecurityTestConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
|
||||||
|
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
|
||||||
|
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
PresentationSettings presentationSettings() {
|
||||||
|
return new PresentationSettings("/api");
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
|
SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
|
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
|
||||||
|
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더러가 설계 05장이 정한 문법을 계약 블록으로 옮기는지 고정한다.
|
||||||
|
*
|
||||||
|
* <p>여기서 지키는 것은 "그럴듯하게 렌더링된다"가 아니라 <b>계약 제약을 어기지 않는다</b>이다 — heading level 범위, 표의 최소 열 수, 미해결 Asset
|
||||||
|
* 을 지어내지 않는 것. 이것들이 깨지면 미리보기는 화면에 나오지만 게시된 문서가 계약을 위반한다.
|
||||||
|
*/
|
||||||
|
class StudioContentRendererTest {
|
||||||
|
|
||||||
|
private final StudioContentRenderer renderer = new StudioContentRenderer();
|
||||||
|
|
||||||
|
private static ResolvedAssetView asset(String key, boolean decorative) {
|
||||||
|
return new ResolvedAssetView(
|
||||||
|
UUID.fromString("00000000-0000-4000-8000-000000000009"),
|
||||||
|
key,
|
||||||
|
"image/png",
|
||||||
|
"/media/00000000-0000-4000-8000-000000000009",
|
||||||
|
800,
|
||||||
|
600,
|
||||||
|
decorative);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headingsGetContractLevelsAndStableIds() {
|
||||||
|
var rendered =
|
||||||
|
renderer.render(
|
||||||
|
"""
|
||||||
|
# Authorization Code Flow
|
||||||
|
###### 아주 깊은 제목
|
||||||
|
## 결론
|
||||||
|
## 결론
|
||||||
|
""",
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
List<CaseRenderBlock> blocks = rendered.blocks();
|
||||||
|
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
|
||||||
|
|
||||||
|
// 계약의 HeadingBlock.level 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 라 h1/h6 를 접었고,
|
||||||
|
// 그래서 작성자가 쓴 위계가 화면에서 달라졌다. 지금은 그대로 내보낸다.
|
||||||
|
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(1);
|
||||||
|
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(6);
|
||||||
|
|
||||||
|
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
|
||||||
|
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
|
||||||
|
// 같은 제목이 두 번이면 두 번째부터 suffix 가 붙는다(설계 05장 §6 8단계).
|
||||||
|
assertThat(((HeadingBlock) blocks.get(3)).getId()).isEqualTo("결론-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headingIdFollowsTheDesignedNormalisation() {
|
||||||
|
var rendered = renderer.render("## JPA N+1 문제\n", Map.of());
|
||||||
|
assertThat(((HeadingBlock) rendered.blocks().getFirst()).getId()).isEqualTo("jpa-n-1-문제");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inlineMarkupBecomesTheContractInlineUnion() {
|
||||||
|
var rendered = renderer.render("본문 **강조** 와 `code` 조각\n", Map.of());
|
||||||
|
|
||||||
|
ParagraphBlock paragraph = (ParagraphBlock) rendered.blocks().getFirst();
|
||||||
|
assertThat(paragraph.getContent()).hasSize(5);
|
||||||
|
assertThat(paragraph.getContent().get(0)).isInstanceOf(InlineText.class);
|
||||||
|
assertThat(paragraph.getContent().get(1)).isInstanceOf(InlineStrong.class);
|
||||||
|
assertThat(paragraph.getContent().get(3)).isInstanceOf(InlineCode.class);
|
||||||
|
assertThat(((InlineCode) paragraph.getContent().get(3)).getCode()).isEqualTo("code");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsCodeAndTablesBecomeTheirContractBlocks() {
|
||||||
|
var rendered =
|
||||||
|
renderer.render(
|
||||||
|
"""
|
||||||
|
- 첫째
|
||||||
|
- 둘째
|
||||||
|
|
||||||
|
```java
|
||||||
|
int x = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
| 이름 | 값 |
|
||||||
|
| --- | ---: |
|
||||||
|
| a | 1 |
|
||||||
|
""",
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
List<CaseRenderBlock> blocks = rendered.blocks();
|
||||||
|
assertThat(blocks.get(0)).isInstanceOf(UnorderedListBlock.class);
|
||||||
|
assertThat(((UnorderedListBlock) blocks.get(0)).getItems()).hasSize(2);
|
||||||
|
|
||||||
|
CodeBlock code = (CodeBlock) blocks.get(1);
|
||||||
|
assertThat(code.getLanguage()).isEqualTo("java");
|
||||||
|
assertThat(code.getCode()).contains("int x = 1;");
|
||||||
|
|
||||||
|
DataTableBlock table = (DataTableBlock) blocks.get(2);
|
||||||
|
assertThat(table.getColumns()).hasSize(2);
|
||||||
|
assertThat(table.getColumns().get(1).getAlignment())
|
||||||
|
.isEqualTo(
|
||||||
|
dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn
|
||||||
|
.AlignmentEnum.RIGHT);
|
||||||
|
assertThat(table.getRows()).hasSize(1);
|
||||||
|
assertThat(table.getRows().getFirst().getCells().getFirst().getColumnId()).isEqualTo("col-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calloutDirectivesBecomeCalloutBlocksWithTheContractTone() {
|
||||||
|
var rendered =
|
||||||
|
renderer.render(
|
||||||
|
"""
|
||||||
|
:::warning 주의
|
||||||
|
Presigned URL 을 본문에 저장하지 않습니다.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::note
|
||||||
|
확인했습니다.
|
||||||
|
:::
|
||||||
|
""",
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
CalloutBlock warning = (CalloutBlock) rendered.blocks().get(0);
|
||||||
|
assertThat(warning.getTone()).isEqualTo(CalloutBlock.ToneEnum.WARNING);
|
||||||
|
assertThat(warning.getLabel()).isEqualTo("주의");
|
||||||
|
|
||||||
|
CalloutBlock note = (CalloutBlock) rendered.blocks().get(1);
|
||||||
|
// 설계는 note/tip/warning/danger 네 가지를 허용하지만 계약의 tone 은 두 가지다.
|
||||||
|
assertThat(note.getTone()).isEqualTo(CalloutBlock.ToneEnum.INFO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anUnknownDirectiveDegradesToAQuoteAndWarnsInsteadOfBeingSilentlyReinterpreted() {
|
||||||
|
var rendered =
|
||||||
|
renderer.render(
|
||||||
|
"""
|
||||||
|
:::mermaid
|
||||||
|
graph TD;
|
||||||
|
:::
|
||||||
|
""",
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
assertThat(rendered.blocks().getFirst()).isInstanceOf(BlockquoteBlock.class);
|
||||||
|
assertThat(rendered.warnings()).contains("UNSUPPORTED_DIRECTIVE:mermaid");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evidenceDirectiveResolvesThroughTheInjectedAssetManifest() {
|
||||||
|
var rendered =
|
||||||
|
renderer.render(
|
||||||
|
":::evidence key=\"flow\" alt=\"요청 흐름\" caption=\"흐름도\" zoom=\"true\"\n",
|
||||||
|
Map.of("flow", asset("flow", false)));
|
||||||
|
|
||||||
|
EvidenceFigureBlock figure = (EvidenceFigureBlock) rendered.blocks().getFirst();
|
||||||
|
assertThat(figure.getKey()).isEqualTo("flow");
|
||||||
|
assertThat(figure.getAlt()).isEqualTo("요청 흐름");
|
||||||
|
assertThat(figure.getCaption()).isEqualTo("흐름도");
|
||||||
|
assertThat(figure.getZoom()).isTrue();
|
||||||
|
assertThat(figure.getAsset().getPublicPath())
|
||||||
|
.isEqualTo("/media/00000000-0000-4000-8000-000000000009");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anUnresolvedAssetKeyIsDroppedWithAWarningRatherThanPointedAtNothing() {
|
||||||
|
var rendered = renderer.render(":::evidence key=\"missing\" alt=\"x\"\n", Map.of());
|
||||||
|
|
||||||
|
// 임의 경로를 지어내면 렌더 결과가 존재하지 않는 파일을 가리킨다.
|
||||||
|
assertThat(rendered.blocks()).isEmpty();
|
||||||
|
assertThat(rendered.warnings()).contains("UNRESOLVED_ASSET_KEY:missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void directiveSyntaxInsideACodeFenceIsNotADirective() {
|
||||||
|
var rendered =
|
||||||
|
renderer.render(
|
||||||
|
"""
|
||||||
|
```markdown
|
||||||
|
:::evidence key="example"
|
||||||
|
```
|
||||||
|
""",
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
assertThat(rendered.blocks().getFirst()).isInstanceOf(CodeBlock.class);
|
||||||
|
assertThat(rendered.warnings()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void analysisReportsEveryUsageSiteSeparatelyBecauseAltIsPerUsage() {
|
||||||
|
ContentAnalyzerPort.ContentAnalysis analysis =
|
||||||
|
renderer.analyze(
|
||||||
|
"""
|
||||||
|
:::evidence key="flow" alt="첫 번째"
|
||||||
|
|
||||||
|
:::evidence key="flow" alt=""
|
||||||
|
|
||||||
|
:::mermaid
|
||||||
|
:::
|
||||||
|
""");
|
||||||
|
|
||||||
|
assertThat(analysis.assetUsages())
|
||||||
|
.extracting(ContentAnalyzerPort.AssetUsage::assetKey)
|
||||||
|
.containsExactly("flow", "flow");
|
||||||
|
assertThat(analysis.assetUsages().get(0).alt()).isEqualTo("첫 번째");
|
||||||
|
assertThat(analysis.assetUsages().get(1).alt()).isEmpty();
|
||||||
|
assertThat(analysis.unsupportedDirectives()).containsExactly("mermaid");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ dependencies {
|
|||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
|
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
// Studio 편집본의 jsonb 컬럼(reference_detail.rules/examples, open_question.options,
|
||||||
|
// project_decision.consequences, applies_to/excluded_scope)을 읽고 쓰려면 이 모듈에 JSON 매퍼가
|
||||||
|
// 필요하다. Jackson 2 databind 가 data-jpa 경유로 이미 classpath 에 딸려오지만 그건 선언하지 않은
|
||||||
|
// 우연한 가용성이고, 이 저장소는 dependency locking 을 쓴다 — 앱의 다른 계층과 같은
|
||||||
|
// Jackson 3(tools.jackson)을 명시적으로 선언한다.
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-jackson'
|
||||||
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
|
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
|
||||||
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
|
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
|
||||||
implementation 'org.springframework.integration:spring-integration-jdbc'
|
implementation 'org.springframework.integration:spring-integration-jdbc'
|
||||||
@@ -117,6 +123,29 @@ def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTe
|
|||||||
'postgresqlTechLogCatalogQueryIntegrationTest',
|
'postgresqlTechLogCatalogQueryIntegrationTest',
|
||||||
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
|
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
|
||||||
|
|
||||||
|
// 슬라이스 2~5: Studio 영속 경로 전체(편집본 4종 왕복, 낙관적 잠금, union 목록, 의존 해석,
|
||||||
|
// validation/preview artifact, 게시 20단계, 게시 취소, Asset)를 실제 PostgreSQL 위에서 돌린다.
|
||||||
|
// 표준 check 는 Testcontainers 를 돌리지 않으므로, 이 태스크가 없으면 그 SQL 은 한 번도 실행되지
|
||||||
|
// 않은 채로 빌드가 통과한다.
|
||||||
|
def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
|
||||||
|
'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')
|
||||||
|
|
||||||
|
// studio-management-v1: 작업본 삭제 SQL 을 실제 PostgreSQL 위에서 돌린다. 이 태스크는 사고
|
||||||
|
// 하나에서 나왔다 — 참조 검사가 없는 열(public_resource_projection.document_id)을 읽었고,
|
||||||
|
// 컴파일과 단위 테스트를 모두 통과한 뒤 작성자가 삭제를 누른 순간 500 이 됐다. 위 둘과 같은
|
||||||
|
// 이유이며, 삭제 경로만 그 밖에 있었다.
|
||||||
|
def postgresqlTechLogManagementPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
|
||||||
|
'postgresqlTechLogManagementPersistenceIntegrationTest',
|
||||||
|
'dev.caskeleton.adapter.outbound.persistence.techlog.management.ManagementPersistenceIntegrationTest')
|
||||||
|
|
||||||
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
|
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
|
||||||
group = 'verification'
|
group = 'verification'
|
||||||
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
|
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlInt
|
|||||||
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
@@ -163,7 +163,7 @@ org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegr
|
|||||||
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
@@ -205,7 +205,7 @@ org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspat
|
|||||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||||
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-core:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
tools.jackson:jackson-bom:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
empty=
|
empty=
|
||||||
|
|||||||
+2
-3
@@ -12,9 +12,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
*
|
*
|
||||||
* <p>Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the
|
* <p>Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the
|
||||||
* two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first
|
* two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first
|
||||||
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming
|
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming {@code
|
||||||
* {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that
|
* OutboxClaimRepository} — a symptom several layers away from the misspelled value that caused it.
|
||||||
* caused it.
|
|
||||||
*/
|
*/
|
||||||
@ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX)
|
@ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX)
|
||||||
public record PersistenceVendorSettings(Vendor vendor) {
|
public record PersistenceVendorSettings(Vendor vendor) {
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable;
|
|||||||
* H2 atomic scope claim.
|
* H2 atomic scope claim.
|
||||||
*
|
*
|
||||||
* <p>H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL
|
* <p>H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL
|
||||||
* statement does not port. The standard {@code MERGE ... USING} does, and carries the same
|
* statement does not port. The standard {@code MERGE ... USING} does, and carries the same meaning
|
||||||
* meaning in one statement:
|
* in one statement:
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row);
|
* <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row);
|
||||||
|
|||||||
+6
-6
@@ -15,12 +15,12 @@ import org.springframework.jdbc.core.JdbcOperations;
|
|||||||
* <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)}
|
* <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)}
|
||||||
* — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives
|
* — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives
|
||||||
* the transaction on a pooled connection. It is not left stale in practice because the
|
* the transaction on a pooled connection. It is not left stale in practice because the
|
||||||
* transaction port applies these before every transaction, so each one overwrites the last;
|
* transaction port applies these before every transaction, so each one overwrites the last; a
|
||||||
* a connection borrowed outside that path keeps the previous transaction's guard.
|
* connection borrowed outside that path keeps the previous transaction's guard.
|
||||||
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to
|
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to {@code
|
||||||
* {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the
|
* idle_in_transaction_session_timeout}, so that budget cannot be pushed into the database
|
||||||
* database here. It is left to the caller-side deadline the transaction port already
|
* here. It is left to the caller-side deadline the transaction port already enforces, rather
|
||||||
* enforces, rather than silently reported as applied.
|
* than silently reported as applied.
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They
|
* <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They
|
||||||
|
|||||||
+8
-8
@@ -15,13 +15,13 @@ import org.springframework.jdbc.core.JdbcOperations;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers,
|
* H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers,
|
||||||
* implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the
|
* implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the {@code
|
||||||
* {@code local} profile sets.
|
* local} profile sets.
|
||||||
*
|
*
|
||||||
* <p><b>No Flyway location customizer, deliberately.</b> The PostgreSQL vendor points Flyway at
|
* <p><b>No Flyway location customizer, deliberately.</b> The PostgreSQL vendor points Flyway at
|
||||||
* {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local
|
* {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local
|
||||||
* profile turns Flyway off and lets Hibernate derive the schema from the entities. Two
|
* profile turns Flyway off and lets Hibernate derive the schema from the entities. Two consequences
|
||||||
* consequences worth stating out loud:
|
* worth stating out loud:
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery
|
* <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery
|
||||||
@@ -30,12 +30,12 @@ import org.springframework.jdbc.core.JdbcOperations;
|
|||||||
* there will fail on a missing table rather than silently misbehave.
|
* there will fail on a missing table rather than silently misbehave.
|
||||||
* <li>A fork that enables Flyway while this vendor is selected gets no location override, so
|
* <li>A fork that enables Flyway while this vendor is selected gets no location override, so
|
||||||
* Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including
|
* Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including
|
||||||
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own
|
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own {@code
|
||||||
* {@code FlywayConfigurationCustomizer} naming an H2 location.
|
* FlywayConfigurationCustomizer} naming an H2 location.
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency
|
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and
|
||||||
* fidelity stay with the real-PostgreSQL integration suites.
|
* vendor-concurrency fidelity stay with the real-PostgreSQL integration suites.
|
||||||
*/
|
*/
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
@ConditionalOnProperty(
|
@ConditionalOnProperty(
|
||||||
|
|||||||
+9
@@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope}
|
* DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope}
|
||||||
@@ -162,7 +164,14 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort {
|
|||||||
row.getExpiresAt()));
|
row.getExpiresAt()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code deleteByScope} 는 {@code @Modifying} 벌크 delete 이므로 활성 트랜잭션을 요구한다. 이 메서드는 {@code
|
||||||
|
* IdempotencyExecutor} 의 실패 경로에서 호출되는데 그 지점에는 트랜잭션이 없다 — 예약 레코드를 지우려다 {@code
|
||||||
|
* TransactionRequiredException} 을 던져 원래 실패를 덮고 있었다(403 이 500 으로 바뀌고 로그에 원인이 남지 않았다). REQUIRES_NEW
|
||||||
|
* 인 이유: 정리는 실패한 작업의 롤백에 휩쓸리면 안 된다.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public void discard(IdempotencyScope scope) {
|
public void discard(IdempotencyScope scope) {
|
||||||
repository.deleteByScope(
|
repository.deleteByScope(
|
||||||
IdempotencyRecordEntityMapper.tenantColumn(scope),
|
IdempotencyRecordEntityMapper.tenantColumn(scope),
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code studio_preview} 접근.
|
||||||
|
*
|
||||||
|
* <p>{@code render_model}은 렌더러가 만든 계약 모양 그대로를 문자열로 저장하고 그대로 돌려준다 — 중간에서 파싱했다 다시 직렬화하면 사용자가 확인한 화면과
|
||||||
|
* 저장된 화면이 미묘하게 달라질 수 있고, 게시 시점에 그대로 snapshot 으로 옮겨야 하는 값이라 그 차이가 공개 결과까지 간다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcPreviewArtifactAdapter implements PreviewArtifactPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcPreviewArtifactAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<PublicPreviewView> latestFor(RecordKind kind, UUID documentId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
selectColumns()
|
||||||
|
+ " WHERE source_kind = :kind AND source_id = :id"
|
||||||
|
+ " ORDER BY created_at DESC LIMIT 1")
|
||||||
|
.param("kind", kind.name())
|
||||||
|
.param("id", documentId)
|
||||||
|
.query(JdbcPreviewArtifactAdapter::mapRow)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<PublicPreviewView> findById(UUID previewId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(selectColumns() + " WHERE preview_id = :id")
|
||||||
|
.param("id", previewId)
|
||||||
|
.query(JdbcPreviewArtifactAdapter::mapRow)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO studio_preview (preview_id, source_kind, source_id, source_version,"
|
||||||
|
+ " validation_id, dependency_revision, render_model, created_at, expires_at,"
|
||||||
|
+ " created_by)"
|
||||||
|
+ " VALUES (:previewId, :kind, :sourceId, :sourceVersion, :validationId,"
|
||||||
|
+ " :dependencyRevision, CAST(:renderModel AS jsonb), :createdAt, :expiresAt,"
|
||||||
|
+ " :principal)")
|
||||||
|
.param("previewId", preview.previewId())
|
||||||
|
.param("kind", kind.name())
|
||||||
|
.param("sourceId", preview.documentId())
|
||||||
|
.param("sourceVersion", preview.previewVersion())
|
||||||
|
.param("validationId", preview.validationId())
|
||||||
|
.param("dependencyRevision", preview.dependencyRevision())
|
||||||
|
.param("renderModel", preview.renderModelJson())
|
||||||
|
.param("createdAt", Timestamp.from(preview.createdAt()))
|
||||||
|
.param("expiresAt", Timestamp.from(preview.expiresAt()))
|
||||||
|
.param("principal", principal)
|
||||||
|
.update();
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String selectColumns() {
|
||||||
|
return "SELECT preview_id, source_id, source_version, validation_id, dependency_revision,"
|
||||||
|
+ " render_model, created_at, expires_at FROM studio_preview";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PublicPreviewView mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new PublicPreviewView(
|
||||||
|
rs.getObject("preview_id", UUID.class),
|
||||||
|
rs.getObject("source_id", UUID.class),
|
||||||
|
rs.getLong("source_version"),
|
||||||
|
rs.getObject("validation_id", UUID.class),
|
||||||
|
rs.getString("dependency_revision"),
|
||||||
|
rs.getTimestamp("created_at").toInstant(),
|
||||||
|
rs.getTimestamp("expires_at").toInstant(),
|
||||||
|
rs.getString("render_model"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ValidationIssueView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ValidationSeverity;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
|
||||||
|
import dev.caskeleton.shared.error.MappingException;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
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.core.JacksonException;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
import tools.jackson.databind.node.ArrayNode;
|
||||||
|
import tools.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code studio_validation} 접근. 검증 결과는 일급 artifact이며 실행 후 버리지 않는다(spec §7.3).
|
||||||
|
*
|
||||||
|
* <p>{@code studio_validation}에는 UPDATE가 없다. 재검증은 새 행이며, 이전 결과는 "그때 이 버전은 이런 상태였다"는 사실로 남는다 — 덮어쓰면
|
||||||
|
* 게시 시점에 어떤 근거로 통과했는지 되짚을 수 없다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcValidationArtifactAdapter implements ValidationArtifactPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public JdbcValidationArtifactAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ValidationReportView> latestFor(RecordKind kind, UUID documentId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
selectColumns()
|
||||||
|
+ " WHERE source_kind = :kind AND source_id = :id"
|
||||||
|
+ " ORDER BY validated_at DESC LIMIT 1")
|
||||||
|
.param("kind", kind.name())
|
||||||
|
.param("id", documentId)
|
||||||
|
.query(this::mapRow)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ValidationReportView> findById(UUID validationId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(selectColumns() + " WHERE validation_id = :id")
|
||||||
|
.param("id", validationId)
|
||||||
|
.query(this::mapRow)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ValidationReportView save(RecordKind kind, ValidationReportView report, String principal) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO studio_validation (validation_id, source_kind, source_id,"
|
||||||
|
+ " validated_version, status, issues, dependency_revision, validated_at,"
|
||||||
|
+ " valid_until, created_by)"
|
||||||
|
+ " VALUES (:validationId, :kind, :sourceId, :validatedVersion, :status,"
|
||||||
|
+ " CAST(:issues AS jsonb), :dependencyRevision, :validatedAt, :validUntil,"
|
||||||
|
+ " :principal)")
|
||||||
|
.param("validationId", report.validationId())
|
||||||
|
.param("kind", kind.name())
|
||||||
|
.param("sourceId", report.documentId())
|
||||||
|
.param("validatedVersion", report.validatedVersion())
|
||||||
|
.param("status", report.status().name())
|
||||||
|
.param("issues", issuesToJson(report.issues()))
|
||||||
|
.param("dependencyRevision", report.dependencyRevision())
|
||||||
|
.param("validatedAt", Timestamp.from(report.validatedAt()))
|
||||||
|
.param("validUntil", Timestamp.from(report.validUntil()))
|
||||||
|
.param("principal", principal)
|
||||||
|
.update();
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String selectColumns() {
|
||||||
|
return "SELECT validation_id, source_id, validated_version, status, issues,"
|
||||||
|
+ " dependency_revision, validated_at, valid_until FROM studio_validation";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ValidationReportView mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new ValidationReportView(
|
||||||
|
rs.getObject("validation_id", UUID.class),
|
||||||
|
rs.getObject("source_id", UUID.class),
|
||||||
|
rs.getLong("validated_version"),
|
||||||
|
ValidationStatus.valueOf(rs.getString("status")),
|
||||||
|
issuesFromJson(rs.getString("issues")),
|
||||||
|
rs.getTimestamp("validated_at").toInstant(),
|
||||||
|
rs.getTimestamp("valid_until").toInstant(),
|
||||||
|
rs.getString("dependency_revision"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String issuesToJson(List<ValidationIssueView> issues) {
|
||||||
|
ArrayNode array = objectMapper.createArrayNode();
|
||||||
|
for (ValidationIssueView issue : issues) {
|
||||||
|
ObjectNode node = array.addObject();
|
||||||
|
node.put("code", issue.code());
|
||||||
|
node.put("severity", issue.severity().name());
|
||||||
|
node.put("path", issue.path());
|
||||||
|
node.put("message", issue.message());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(array);
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new MappingException("failed to serialise validation issues", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ValidationIssueView> issuesFromJson(String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode array = objectMapper.readTree(json);
|
||||||
|
if (!array.isArray()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return array
|
||||||
|
.valueStream()
|
||||||
|
.map(
|
||||||
|
node ->
|
||||||
|
new ValidationIssueView(
|
||||||
|
node.path("code").asString(""),
|
||||||
|
ValidationSeverity.valueOf(node.path("severity").asString("ERROR")),
|
||||||
|
node.path("path").asString(""),
|
||||||
|
node.path("message").asString("")))
|
||||||
|
.toList();
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new MappingException("failed to read validation issues", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업본 삭제.
|
||||||
|
*
|
||||||
|
* <p>작업본 자신의 것(상세, 태그, 나가는 관계)은 {@code ON DELETE CASCADE} 로 따라 지워진다. 하지만 <em>다른</em> 기록이 이쪽을 가리키는
|
||||||
|
* 참조는 그렇지 않다 — 다른 문서의 관계 대상, 질문의 링크, 프로젝트 소속, 주제의 추천 목록, 결정의 근거 Case 다섯 곳이 CASCADE 없이 걸려 있다. 그대로
|
||||||
|
* 지우면 외래키 위반이고, 작성자에게는 500 으로 도착한다. 그래서 먼저 확인하고 거절한다 — 남의 기록을 조용히 고쳐 주는 것보다 낫다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcDocumentDeletionAdapter implements DocumentDeletionPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcDocumentDeletionAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeletableDocument mapDocument(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new DeletableDocument(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("document_type"),
|
||||||
|
rs.getString("workflow_status"),
|
||||||
|
rs.getLong("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeletableQuestion mapQuestion(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new DeletableQuestion(rs.getObject("id", UUID.class), rs.getLong("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DeletableDocument> findDocument(UUID id, String documentType) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT id, document_type, workflow_status, version FROM document"
|
||||||
|
+ " WHERE id = :id AND document_type = :type")
|
||||||
|
.param("id", id)
|
||||||
|
.param("type", documentType)
|
||||||
|
.query(JdbcDocumentDeletionAdapter::mapDocument)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteDocument(UUID id, long expectedVersion) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("DELETE FROM document WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean documentReferenced(UUID id) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS ("
|
||||||
|
+ " SELECT 1 FROM document_relation WHERE target_document_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM question_document_link WHERE document_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM project_document_link WHERE document_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id"
|
||||||
|
+ ")")
|
||||||
|
.param("id", id)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 투영은 {@code document_id} 가 아니라 {@code (resource_type, resource_id)} 로 기록을 가리킨다 — 한 테이블이
|
||||||
|
* Case·Question·Project·Release 를 모두 담기 때문이다. 외래키도 없다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean publiclyProjected(String resourceType, UUID id) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM public_resource_projection"
|
||||||
|
+ " WHERE resource_type = :type AND resource_id = :id"
|
||||||
|
+ " AND publication_state = 'ACTIVE')")
|
||||||
|
.param("type", resourceType)
|
||||||
|
.param("id", id)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteProjection(String resourceType, UUID id) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"DELETE FROM public_resource_projection"
|
||||||
|
+ " WHERE resource_type = :type AND resource_id = :id")
|
||||||
|
.param("type", resourceType)
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean questionReferenced(UUID id) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS ("
|
||||||
|
+ " SELECT 1 FROM project_question_link WHERE question_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM home_focus_config WHERE open_question_id = :id"
|
||||||
|
+ ")")
|
||||||
|
.param("id", id)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeletableDecision mapDecision(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new DeletableDecision(
|
||||||
|
rs.getObject("id", UUID.class), rs.getString("decision_status"), rs.getLong("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DeletableDecision> findDecision(UUID projectId, UUID decisionId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT id, decision_status, version FROM project_decision"
|
||||||
|
+ " WHERE id = :id AND project_id = :projectId")
|
||||||
|
.param("id", decisionId)
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.query(JdbcDocumentDeletionAdapter::mapDecision)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteDecision(UUID decisionId, long expectedVersion) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("DELETE FROM project_decision WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", decisionId)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code superseded_by_id} 는 자기 테이블을 가리키고 {@code home_focus_config} 도 CASCADE 가 아니다. 둘 다 그대로 지우면
|
||||||
|
* 외래키 위반이 된다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean decisionReferenced(UUID decisionId) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS ("
|
||||||
|
+ " SELECT 1 FROM project_decision WHERE superseded_by_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM home_focus_config WHERE recent_decision_id = :id"
|
||||||
|
+ ")")
|
||||||
|
.param("id", decisionId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCurrentlyPublished(String sourceKind, UUID id) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM publication"
|
||||||
|
+ " WHERE source_kind = :kind AND source_id = :id AND status = 'PUBLISHED')")
|
||||||
|
.param("kind", sourceKind)
|
||||||
|
.param("id", id)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트와 게시를 한 문장으로 지운다.
|
||||||
|
*
|
||||||
|
* <p>둘은 서로를 가리키고, 한쪽 방향만 지연 검사다 — 어떤 순서로 나눠 지워도 중간 상태에서 한쪽 제약이 깨진다. 한 문장 안에서는 외래키 검사가 문장 끝에 한 번
|
||||||
|
* 도는 덕에 그 중간 상태가 존재하지 않는다. 순서를 맞추는 대신 순서가 필요 없게 만든다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int deletePublicationHistory(String sourceKind, UUID id) {
|
||||||
|
int snapshots =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"DELETE FROM publication_snapshot WHERE publication_event_id IN"
|
||||||
|
+ " (SELECT publication_event_id FROM publication_event"
|
||||||
|
+ " WHERE source_kind = :kind AND source_id = :id)")
|
||||||
|
.param("kind", sourceKind)
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
int removed =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"WITH gone_events AS ("
|
||||||
|
+ " DELETE FROM publication_event"
|
||||||
|
+ " WHERE source_kind = :kind AND source_id = :id"
|
||||||
|
+ " RETURNING publication_event_id"
|
||||||
|
+ ")"
|
||||||
|
+ " DELETE FROM publication WHERE source_kind = :kind AND source_id = :id")
|
||||||
|
.param("kind", sourceKind)
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
return snapshots + removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미리보기가 검증을 참조하므로 미리보기를 먼저 지운다. */
|
||||||
|
@Override
|
||||||
|
public int deleteWorkArtifacts(String sourceKind, UUID id) {
|
||||||
|
int previews =
|
||||||
|
jdbcClient
|
||||||
|
.sql("DELETE FROM studio_preview WHERE source_kind = :kind AND source_id = :id")
|
||||||
|
.param("kind", sourceKind)
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
int validations =
|
||||||
|
jdbcClient
|
||||||
|
.sql("DELETE FROM studio_validation WHERE source_kind = :kind AND source_id = :id")
|
||||||
|
.param("kind", sourceKind)
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
return previews + validations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<DeletableQuestion> findQuestion(UUID id) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT id, version FROM open_question WHERE id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.query(JdbcDocumentDeletionAdapter::mapQuestion)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteQuestion(UUID id, long expectedVersion) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("DELETE FROM open_question WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈 focus 설정 저장소.
|
||||||
|
*
|
||||||
|
* <p>{@code home_focus_config} 는 PK 가 고정 UUID 로 CHECK 되어 있는 단일 행 테이블이다. 그래서 조회에 WHERE 가 없고, 갱신은 그
|
||||||
|
* 고정 id 를 그대로 쓴다 — 행이 여러 개일 수 없으므로 "어느 행" 을 고를 일이 없다.
|
||||||
|
*
|
||||||
|
* <p>V9 마이그레이션이 세 슬롯이 모두 비어 있는 행을 미리 넣는다. 따라서 {@link #load()} 는 항상 값을 돌려주며, 빈 결과는 스키마가 깨진 경우뿐이라 그때는
|
||||||
|
* 예외가 맞다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcHomeFocusConfigAdapter implements HomeFocusConfigPort {
|
||||||
|
|
||||||
|
private static final UUID ROW_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
|
private static final String COLUMNS =
|
||||||
|
"id, version, default_focus_type, current_project_id, open_question_id, recent_decision_id";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcHomeFocusConfigAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HomeFocusConfigView map(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new HomeFocusConfigView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getLong("version"),
|
||||||
|
rs.getString("default_focus_type"),
|
||||||
|
rs.getObject("current_project_id", UUID.class),
|
||||||
|
rs.getObject("open_question_id", UUID.class),
|
||||||
|
rs.getObject("recent_decision_id", UUID.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HomeFocusConfigView load() {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT " + COLUMNS + " FROM home_focus_config")
|
||||||
|
.query(JdbcHomeFocusConfigAdapter::map)
|
||||||
|
.optional()
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new IllegalStateException("home_focus_config has no row; migration V9 seeds one"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<HomeFocusConfigView> update(UpdateHomeFocusCommand command) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE home_focus_config SET default_focus_type = :defaultType,"
|
||||||
|
+ " current_project_id = :projectId, open_question_id = :questionId,"
|
||||||
|
+ " recent_decision_id = :decisionId, version = version + 1,"
|
||||||
|
+ " updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", ROW_ID)
|
||||||
|
.param("expected", command.expectedVersion())
|
||||||
|
.param("defaultType", command.defaultType())
|
||||||
|
.param("projectId", command.currentProjectId())
|
||||||
|
.param("questionId", command.openQuestionId())
|
||||||
|
.param("decisionId", command.recentDecisionId())
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return updated == 0 ? Optional.empty() : Optional.of(load());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean projectExists(UUID id) {
|
||||||
|
return exists("project", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean questionExists(UUID id) {
|
||||||
|
return exists("open_question", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean decisionExists(UUID id) {
|
||||||
|
return exists("project_decision", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 테이블 이름은 이 클래스 안의 상수 셋에서만 온다 — 호출자가 문자열을 넘길 수 없으므로 연결해도 주입 경로가 없다.
|
||||||
|
*/
|
||||||
|
private boolean exists(String table, UUID id) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT 1 FROM " + table + " WHERE id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.query(Integer.class)
|
||||||
|
.optional()
|
||||||
|
.isPresent();
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동 편집 저장소.
|
||||||
|
*
|
||||||
|
* <p>{@code origin} 은 이 경로에서 언제나 {@code MANUAL} 이다. 이 어댑터로 들어오는 것은 작성자가 손으로 적은 줄뿐이고, {@code AUTO} 는
|
||||||
|
* 게시 파이프라인이 남기는 몫이다. 클라이언트가 값을 정하게 두면 손으로 적은 줄에 {@code AUTO} 를 붙여 삭제 금지를 우회할 수 있다.
|
||||||
|
*
|
||||||
|
* <p>모든 쓰기에 {@code project_id} 조건이 붙는다. 경로가 프로젝트와 활동을 함께 요구하므로, 활동 id 만으로 수정하면 남의 프로젝트에 달린 줄을 고칠 수
|
||||||
|
* 있다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcProjectActivityRepositoryAdapter implements ProjectActivityRepositoryPort {
|
||||||
|
|
||||||
|
private static final String COLUMNS =
|
||||||
|
"id, project_id, activity_type, title, summary, visibility, origin,"
|
||||||
|
+ " related_resource_type, related_resource_id, occurred_at, version";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 생성자는 하나뿐이어야 한다. 한때 테스트용 id 생성기를 받는 두 번째 생성자가 있었고, 그러면
|
||||||
|
* 컴포넌트 스캔이 어느 것을 쓸지 정하지 못해 기본 생성자를 찾다가 실패한다 — 컴파일도 테스트도
|
||||||
|
* 통과하고 운영에서 기동만 못 한다. id 는 여기서 만들면 되고, 통합 테스트는 그 값을 볼 필요가 없다.
|
||||||
|
*/
|
||||||
|
public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProjectActivityView map(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new ProjectActivityView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getObject("project_id", UUID.class),
|
||||||
|
rs.getString("activity_type"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("summary"),
|
||||||
|
rs.getString("visibility"),
|
||||||
|
rs.getString("origin"),
|
||||||
|
rs.getString("related_resource_type"),
|
||||||
|
rs.getObject("related_resource_id", UUID.class),
|
||||||
|
rs.getTimestamp("occurred_at").toInstant(),
|
||||||
|
rs.getLong("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProjectActivityView> listByProject(UUID projectId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT "
|
||||||
|
+ COLUMNS
|
||||||
|
+ " FROM project_activity WHERE project_id = :projectId"
|
||||||
|
+ " ORDER BY occurred_at DESC, id DESC")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.query(JdbcProjectActivityRepositoryAdapter::map)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectActivityView> find(UUID projectId, UUID activityId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT "
|
||||||
|
+ COLUMNS
|
||||||
|
+ " FROM project_activity"
|
||||||
|
+ " WHERE project_id = :projectId AND id = :id")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.param("id", activityId)
|
||||||
|
.query(JdbcProjectActivityRepositoryAdapter::map)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ProjectActivityView create(CreateProjectActivityCommand command) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
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 (:id, :projectId, :type, :title, :summary, :visibility, 'MANUAL',"
|
||||||
|
+ " :relatedType, :relatedId, :occurredAt, :actor, :actor)")
|
||||||
|
.param("id", id)
|
||||||
|
.param("projectId", command.projectId())
|
||||||
|
.param("type", command.activityType())
|
||||||
|
.param("title", command.title().trim())
|
||||||
|
.param("summary", command.summary())
|
||||||
|
.param("visibility", command.visibility())
|
||||||
|
.param("relatedType", command.relatedResourceType())
|
||||||
|
.param("relatedId", command.relatedResourceId())
|
||||||
|
.param("occurredAt", java.sql.Timestamp.from(command.occurredAt()))
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return find(command.projectId(), id)
|
||||||
|
.orElseThrow(
|
||||||
|
() -> new IllegalStateException("activity vanished right after insert: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectActivityView> update(UpdateProjectActivityCommand command) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE project_activity SET title = :title, summary = :summary,"
|
||||||
|
+ " visibility = :visibility, occurred_at = :occurredAt,"
|
||||||
|
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND project_id = :projectId AND version = :expected")
|
||||||
|
.param("id", command.activityId())
|
||||||
|
.param("projectId", command.projectId())
|
||||||
|
.param("expected", command.expectedVersion())
|
||||||
|
.param("title", command.title().trim())
|
||||||
|
.param("summary", command.summary())
|
||||||
|
.param("visibility", command.visibility())
|
||||||
|
.param("occurredAt", java.sql.Timestamp.from(command.occurredAt()))
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return updated == 0 ? Optional.empty() : find(command.projectId(), command.activityId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(UUID projectId, UUID activityId, long expectedVersion) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"DELETE FROM project_activity"
|
||||||
|
+ " WHERE id = :id AND project_id = :projectId AND version = :expected")
|
||||||
|
.param("id", activityId)
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project 편집 저장소.
|
||||||
|
*
|
||||||
|
* <p>{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON
|
||||||
|
* 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
|
||||||
|
|
||||||
|
private static final String EDIT_COLUMNS =
|
||||||
|
"id, version, name, slug, one_line_purpose, purpose_markdown, boundary_markdown,"
|
||||||
|
+ " system_overview_markdown, phase, current_objective, next_step, technology_labels,"
|
||||||
|
+ " workflow_status, target_visibility, featured_order, first_published_at,"
|
||||||
|
+ " last_published_at, updated_at";
|
||||||
|
|
||||||
|
private static final String INDEX_COLUMNS =
|
||||||
|
"id, name, phase, workflow_status, target_visibility, current_objective, next_step,"
|
||||||
|
+ " updated_at, version, first_published_at, last_published_at";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public JdbcProjectRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant instant(ResultSet rs, String column) throws SQLException {
|
||||||
|
Timestamp t = rs.getTimestamp(column);
|
||||||
|
return t == null ? null : t.toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> labels(String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(json);
|
||||||
|
if (!node.isArray()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (JsonNode item : node) {
|
||||||
|
out.add(item.asString(""));
|
||||||
|
}
|
||||||
|
return List.copyOf(out);
|
||||||
|
} catch (RuntimeException malformed) {
|
||||||
|
// 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
|
||||||
|
// 편집 화면 전체를 막는 대신 빈 목록으로 두고 나머지 필드를 보여준다.
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String labelsJson(List<String> values) {
|
||||||
|
return objectMapper.writeValueAsString(values == null ? List.of() : values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProjectEditView mapEdit(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new ProjectEditView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getLong("version"),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("slug"),
|
||||||
|
rs.getString("one_line_purpose"),
|
||||||
|
rs.getString("purpose_markdown"),
|
||||||
|
rs.getString("boundary_markdown"),
|
||||||
|
rs.getString("system_overview_markdown"),
|
||||||
|
rs.getString("phase"),
|
||||||
|
rs.getString("current_objective"),
|
||||||
|
rs.getString("next_step"),
|
||||||
|
labels(rs.getString("technology_labels")),
|
||||||
|
topicIdsOf(rs.getObject("id", UUID.class)),
|
||||||
|
rs.getString("workflow_status"),
|
||||||
|
rs.getString("target_visibility"),
|
||||||
|
(Integer) rs.getObject("featured_order"),
|
||||||
|
instant(rs, "first_published_at"),
|
||||||
|
instant(rs, "last_published_at"),
|
||||||
|
instant(rs, "updated_at"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProjectIndexItemView mapIndex(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new ProjectIndexItemView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("phase"),
|
||||||
|
rs.getString("workflow_status"),
|
||||||
|
rs.getString("target_visibility"),
|
||||||
|
rs.getString("current_objective"),
|
||||||
|
rs.getString("next_step"),
|
||||||
|
instant(rs, "updated_at"),
|
||||||
|
rs.getLong("version"),
|
||||||
|
instant(rs, "first_published_at"),
|
||||||
|
instant(rs, "last_published_at"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProjectIndexItemView> listAll(int limit, int offset) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT "
|
||||||
|
+ INDEX_COLUMNS
|
||||||
|
+ " FROM project ORDER BY updated_at DESC, id"
|
||||||
|
+ " LIMIT :limit OFFSET :offset")
|
||||||
|
.param("limit", limit)
|
||||||
|
.param("offset", offset)
|
||||||
|
.query(JdbcProjectRepositoryAdapter::mapIndex)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int countAll() {
|
||||||
|
return Optional.ofNullable(
|
||||||
|
jdbcClient.sql("SELECT COUNT(*) FROM project").query(Integer.class).single())
|
||||||
|
.orElse(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectEditView> find(UUID id) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT " + EDIT_COLUMNS + " FROM project WHERE id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.query(this::mapEdit)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ProjectEditView create(CreateProjectCommand command) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO project (id, name, created_by, updated_by) "
|
||||||
|
+ "VALUES (:id, :name, :actor, :actor)")
|
||||||
|
.param("id", id)
|
||||||
|
.param("name", command.title().trim())
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return find(id).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectEditView> update(UpdateProjectCommand command) {
|
||||||
|
String slug = command.slug() == null || command.slug().isBlank() ? null : command.slug().trim();
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE project SET name = :name, slug = :slug, one_line_purpose = :purpose,"
|
||||||
|
+ " purpose_markdown = :purposeMd, boundary_markdown = :boundaryMd,"
|
||||||
|
+ " system_overview_markdown = :overviewMd, phase = :phase,"
|
||||||
|
+ " current_objective = :objective, next_step = :nextStep,"
|
||||||
|
+ " technology_labels = CAST(:labels AS jsonb),"
|
||||||
|
+ " target_visibility = :visibility, featured_order = :featured,"
|
||||||
|
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", command.id())
|
||||||
|
.param("expected", command.expectedVersion())
|
||||||
|
.param("name", command.name().trim())
|
||||||
|
.param("slug", slug)
|
||||||
|
.param("purpose", nullToEmpty(command.oneLinePurpose()))
|
||||||
|
.param("purposeMd", nullToEmpty(command.purposeMarkdown()))
|
||||||
|
.param("boundaryMd", nullToEmpty(command.boundaryMarkdown()))
|
||||||
|
.param("overviewMd", nullToEmpty(command.systemOverviewMarkdown()))
|
||||||
|
.param("phase", command.phase())
|
||||||
|
.param("objective", command.currentObjective())
|
||||||
|
.param("nextStep", command.nextStep())
|
||||||
|
.param("labels", labelsJson(command.technologyLabels()))
|
||||||
|
.param("visibility", command.targetVisibility())
|
||||||
|
.param("featured", command.featuredOrder())
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
if (updated == 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
replaceTopics(command.id(), command.topicIds());
|
||||||
|
return find(command.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 편집기가 고른 주제. 순서가 곧 화면 순서이므로 목록의 자리를 {@code display_order} 에 그대로 적는다. */
|
||||||
|
private List<UUID> topicIdsOf(UUID projectId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT topic_id FROM project_topic WHERE project_id = :projectId"
|
||||||
|
+ " ORDER BY display_order")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.query(UUID.class)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 통째로 교체한다. 부분 수정으로 두면 "주제를 전부 뗀다" 를 표현할 방법이 없고, 화면도 목록 하나를
|
||||||
|
* 한 번에 저장하므로 그쪽과도 맞는다.
|
||||||
|
*/
|
||||||
|
private void replaceTopics(UUID projectId, List<UUID> topicIds) {
|
||||||
|
jdbcClient
|
||||||
|
.sql("DELETE FROM project_topic WHERE project_id = :projectId")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.update();
|
||||||
|
int order = 0;
|
||||||
|
for (UUID topicId : topicIds) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO project_topic (project_id, topic_id, display_order)"
|
||||||
|
+ " VALUES (:projectId, :topicId, :order)")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.param("topicId", topicId)
|
||||||
|
.param("order", order++)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(UUID id, long expectedVersion) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("DELETE FROM project WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReferenced(UUID id) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS ("
|
||||||
|
+ " SELECT 1 FROM project_document_link WHERE project_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM project_question_link WHERE project_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM project_decision WHERE project_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM public_resource_project_link WHERE project_id = :id"
|
||||||
|
+ ")")
|
||||||
|
.param("id", id)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean slugTaken(String slug, UUID exceptId) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM project WHERE slug = :slug"
|
||||||
|
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
|
||||||
|
.param("slug", slug)
|
||||||
|
.param("except", exceptId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시는 세 곳을 한 트랜잭션 안에서 함께 세운다 — 원본의 상태, 공개 투영, canonical route. 어느 하나가 빠지면 증상이 제각각이다: 투영이 없으면 화면이
|
||||||
|
* 조용히 비고, route 가 없으면 주소만 404 가 되며, 원본 상태가 안 바뀌면 Studio 가 계속 "초안" 이라고 말한다.
|
||||||
|
*
|
||||||
|
* <p>낙관적 잠금이 먼저다. 버전이 어긋나면 아래 두 문장은 아예 실행하지 않는다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectEditView> publish(
|
||||||
|
UUID id, long expectedVersion, String visibility, String actor) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE project SET workflow_status = 'PUBLISHED', target_visibility = :visibility,"
|
||||||
|
+ " first_published_at = COALESCE(first_published_at, now()),"
|
||||||
|
+ " last_published_at = now(), version = version + 1,"
|
||||||
|
+ " updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.param("visibility", visibility)
|
||||||
|
.param("actor", actor)
|
||||||
|
.update();
|
||||||
|
if (updated == 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectEditView project =
|
||||||
|
find(id)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("project vanished mid-publish: " + id));
|
||||||
|
upsertProjection(project, visibility);
|
||||||
|
replaceRoute(project);
|
||||||
|
return Optional.of(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ProjectEditView> unpublish(UUID id, long expectedVersion, String actor) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE project SET workflow_status = 'DRAFT', target_visibility = 'PRIVATE',"
|
||||||
|
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.param("actor", actor)
|
||||||
|
.update();
|
||||||
|
if (updated == 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
|
||||||
|
+ " updated_at = now() WHERE resource_type = 'PROJECT' AND resource_id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
return find(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 조회들은 실제 내용을 {@code project} 테이블에서 직접 읽고 이 행은 가시성 관문·주소·정렬 시각으로만 쓴다. 그래서 {@code payload} 는 빈
|
||||||
|
* 객체로 둔다 — 여기에 사본을 두면 원본이 바뀔 때마다 두 곳이 어긋난다.
|
||||||
|
*
|
||||||
|
* <p>{@code search_text} 만은 예외다. 검색은 이 테이블 하나만 훑으므로 여기에 없는 낱말은 영영 찾을 수 없다.
|
||||||
|
*/
|
||||||
|
private void upsertProjection(ProjectEditView project, String visibility) {
|
||||||
|
String searchText =
|
||||||
|
String.join(
|
||||||
|
" ",
|
||||||
|
nullToEmpty(project.name()),
|
||||||
|
nullToEmpty(project.oneLinePurpose()),
|
||||||
|
nullToEmpty(project.purposeMarkdown()),
|
||||||
|
nullToEmpty(project.currentObjective()),
|
||||||
|
nullToEmpty(project.nextStep()));
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
|
||||||
|
+ " publication_state, visibility, title, summary, state_code,"
|
||||||
|
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
|
||||||
|
+ " published_at, updated_at, navigation_path)"
|
||||||
|
+ " VALUES ('PROJECT', :id, :version, 'ACTIVE', :visibility, :title, :summary,"
|
||||||
|
+ " :stateCode, 1, '{}'::jsonb, :bodyPlainText, :searchText, :contentHash,"
|
||||||
|
+ " now(), now(), :navigationPath)"
|
||||||
|
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
|
||||||
|
+ " source_version = EXCLUDED.source_version,"
|
||||||
|
+ " publication_state = 'ACTIVE',"
|
||||||
|
+ " visibility = EXCLUDED.visibility,"
|
||||||
|
+ " title = EXCLUDED.title,"
|
||||||
|
+ " summary = EXCLUDED.summary,"
|
||||||
|
+ " state_code = EXCLUDED.state_code,"
|
||||||
|
+ " body_plain_text = EXCLUDED.body_plain_text,"
|
||||||
|
+ " search_text = EXCLUDED.search_text,"
|
||||||
|
+ " content_hash = EXCLUDED.content_hash,"
|
||||||
|
+ " updated_at = now(),"
|
||||||
|
+ " navigation_path = EXCLUDED.navigation_path")
|
||||||
|
.param("id", project.id())
|
||||||
|
.param("version", project.version())
|
||||||
|
.param("visibility", visibility)
|
||||||
|
.param("title", project.name())
|
||||||
|
.param("summary", project.oneLinePurpose())
|
||||||
|
.param("stateCode", project.phase())
|
||||||
|
.param("bodyPlainText", nullToEmpty(project.purposeMarkdown()))
|
||||||
|
.param("searchText", searchText)
|
||||||
|
.param("contentHash", sha256(searchText))
|
||||||
|
.param("navigationPath", "/projects/" + project.slug())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이전 slug 의 route 는 alias 로 남긴다 — 지우면 이미 공개된 링크가 끊긴다. */
|
||||||
|
private void replaceRoute(ProjectEditView project) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE public_route SET route_role = 'ALIAS'"
|
||||||
|
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id AND slug <> :slug")
|
||||||
|
.param("id", project.id())
|
||||||
|
.param("slug", project.slug())
|
||||||
|
.update();
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
|
||||||
|
+ " VALUES ('PROJECT', :slug, :id, 'CANONICAL')"
|
||||||
|
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
|
||||||
|
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
|
||||||
|
.param("id", project.id())
|
||||||
|
.param("slug", project.slug())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha256(String value) {
|
||||||
|
try {
|
||||||
|
return HexFormat.of()
|
||||||
|
.formatHex(
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.management.service.ReleaseDrafts;
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
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.JsonNode;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release 편집 저장소.
|
||||||
|
*
|
||||||
|
* <p>{@code change_types} 는 jsonb 다 — 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON 으로 직렬화해
|
||||||
|
* {@code ::jsonb} 로 캐스팅한다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcReleaseRepositoryAdapter implements ReleaseRepositoryPort {
|
||||||
|
|
||||||
|
private static final String EDIT_COLUMNS =
|
||||||
|
"id, version, version_label, title, summary, released_on, change_types, reason_markdown,"
|
||||||
|
+ " changes_markdown, user_impact_markdown, implementation_impact_markdown,"
|
||||||
|
+ " verification_markdown, known_limitations_markdown, workflow_status,"
|
||||||
|
+ " first_published_at, last_published_at, updated_at";
|
||||||
|
|
||||||
|
private static final String INDEX_COLUMNS =
|
||||||
|
"id, version_label, title, released_on, workflow_status, updated_at, version,"
|
||||||
|
+ " first_published_at, last_published_at";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public JdbcReleaseRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant instant(ResultSet rs, String column) throws SQLException {
|
||||||
|
Timestamp t = rs.getTimestamp(column);
|
||||||
|
return t == null ? null : t.toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate date(ResultSet rs, String column) throws SQLException {
|
||||||
|
Date d = rs.getDate(column);
|
||||||
|
return d == null ? null : d.toLocalDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> changeTypes(String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(json);
|
||||||
|
if (!node.isArray()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (JsonNode item : node) {
|
||||||
|
out.add(item.asString(""));
|
||||||
|
}
|
||||||
|
return List.copyOf(out);
|
||||||
|
} catch (RuntimeException malformed) {
|
||||||
|
// 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
|
||||||
|
// 편집 화면 전체를 막는 대신 빈 목록으로 두고 나머지 필드를 보여준다.
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String changeTypesJson(List<String> values) {
|
||||||
|
return objectMapper.writeValueAsString(values == null ? List.of() : values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReleaseEditView mapEdit(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new ReleaseEditView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getLong("version"),
|
||||||
|
rs.getString("version_label"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("summary"),
|
||||||
|
date(rs, "released_on"),
|
||||||
|
changeTypes(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"),
|
||||||
|
rs.getString("workflow_status"),
|
||||||
|
instant(rs, "first_published_at"),
|
||||||
|
instant(rs, "last_published_at"),
|
||||||
|
instant(rs, "updated_at"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReleaseIndexItemView mapIndex(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new ReleaseIndexItemView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("version_label"),
|
||||||
|
rs.getString("title"),
|
||||||
|
date(rs, "released_on"),
|
||||||
|
rs.getString("workflow_status"),
|
||||||
|
instant(rs, "updated_at"),
|
||||||
|
rs.getLong("version"),
|
||||||
|
instant(rs, "first_published_at"),
|
||||||
|
instant(rs, "last_published_at"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ReleaseIndexItemView> listAll(int limit, int offset) {
|
||||||
|
// 발행된 것이 위로, 그다음 최근 수정 순. 변경 기록은 버전 순서가 의미를 갖지만
|
||||||
|
// version_label 은 자유 문자열이라 정렬 키로 쓸 수 없다.
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT "
|
||||||
|
+ INDEX_COLUMNS
|
||||||
|
+ " FROM release"
|
||||||
|
+ " ORDER BY released_on DESC NULLS LAST, updated_at DESC, id"
|
||||||
|
+ " LIMIT :limit OFFSET :offset")
|
||||||
|
.param("limit", limit)
|
||||||
|
.param("offset", offset)
|
||||||
|
.query(JdbcReleaseRepositoryAdapter::mapIndex)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int countAll() {
|
||||||
|
return Optional.ofNullable(
|
||||||
|
jdbcClient.sql("SELECT COUNT(*) FROM release").query(Integer.class).single())
|
||||||
|
.orElse(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ReleaseEditView> find(UUID id) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT " + EDIT_COLUMNS + " FROM release WHERE id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.query(this::mapEdit)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ReleaseEditView create(CreateReleaseCommand command) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
// version_label 은 NOT NULL UNIQUE 이고 초안에는 아직 버전이 없다. id 에서 파생한 자리표시자를
|
||||||
|
// 넣어 유일성을 만족시키고, 발행이 그것이 실제 버전으로 바뀌었는지 확인한다.
|
||||||
|
String placeholder = ReleaseDrafts.PLACEHOLDER_PREFIX + id.toString().substring(0, 8);
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO release (id, version_label, title, created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :label, :title, :actor, :actor)")
|
||||||
|
.param("id", id)
|
||||||
|
.param("label", placeholder)
|
||||||
|
.param("title", command.title().trim())
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return find(id).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ReleaseEditView> update(UpdateReleaseCommand command) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE release SET version_label = :label, title = :title, summary = :summary,"
|
||||||
|
+ " released_on = :releasedOn,"
|
||||||
|
+ " change_types = CAST(:changeTypes AS jsonb),"
|
||||||
|
+ " reason_markdown = :reasonMd, changes_markdown = :changesMd,"
|
||||||
|
+ " user_impact_markdown = :userImpactMd,"
|
||||||
|
+ " implementation_impact_markdown = :implImpactMd,"
|
||||||
|
+ " verification_markdown = :verificationMd,"
|
||||||
|
+ " known_limitations_markdown = :limitationsMd,"
|
||||||
|
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", command.id())
|
||||||
|
.param("expected", command.expectedVersion())
|
||||||
|
.param("label", command.versionLabel().trim())
|
||||||
|
.param("title", command.title().trim())
|
||||||
|
.param("summary", nullToEmpty(command.summary()))
|
||||||
|
.param(
|
||||||
|
"releasedOn",
|
||||||
|
command.releasedOn() == null ? null : Date.valueOf(command.releasedOn()))
|
||||||
|
.param("changeTypes", changeTypesJson(command.changeTypes()))
|
||||||
|
.param("reasonMd", nullToEmpty(command.reasonMarkdown()))
|
||||||
|
.param("changesMd", nullToEmpty(command.changesMarkdown()))
|
||||||
|
.param("userImpactMd", nullToEmpty(command.userImpactMarkdown()))
|
||||||
|
.param("implImpactMd", nullToEmpty(command.implementationImpactMarkdown()))
|
||||||
|
.param("verificationMd", nullToEmpty(command.verificationMarkdown()))
|
||||||
|
.param("limitationsMd", nullToEmpty(command.knownLimitationsMarkdown()))
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return updated == 0 ? Optional.empty() : find(command.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(UUID id, long expectedVersion) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("DELETE FROM release WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean versionLabelTaken(String versionLabel, UUID exceptId) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM release WHERE version_label = :label"
|
||||||
|
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
|
||||||
|
.param("label", versionLabel)
|
||||||
|
.param("except", exceptId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ReleaseEditView> transition(
|
||||||
|
UUID id,
|
||||||
|
long expectedVersion,
|
||||||
|
String workflowStatus,
|
||||||
|
boolean stampPublication,
|
||||||
|
String actor) {
|
||||||
|
// COALESCE 가 최초 발행 시각을 지킨다 — 재발행은 last_published_at 만 옮긴다.
|
||||||
|
String publicationClause =
|
||||||
|
stampPublication
|
||||||
|
? " first_published_at = COALESCE(first_published_at, now()), last_published_at = now(),"
|
||||||
|
: "";
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE release SET workflow_status = :status,"
|
||||||
|
+ publicationClause
|
||||||
|
+ " version = version + 1, updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.param("status", workflowStatus)
|
||||||
|
.param("actor", actor)
|
||||||
|
.update();
|
||||||
|
return updated == 0 ? Optional.empty() : find(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.management.command.SaveTopicCommand;
|
||||||
|
import dev.caskeleton.application.techlog.management.model.TopicEditView;
|
||||||
|
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topic 편집 저장소.
|
||||||
|
*
|
||||||
|
* <p>정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한
|
||||||
|
* 곳에만 있어야 사전 확인과 제약이 같은 값을 본다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
|
||||||
|
|
||||||
|
private static final String COLUMNS = "id, name, slug, description, scope, status, version";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcTopicRepositoryAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String name) {
|
||||||
|
return name == null ? "" : name.trim().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TopicEditView map(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new TopicEditView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("slug"),
|
||||||
|
rs.getString("description"),
|
||||||
|
rs.getString("scope"),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getLong("version"),
|
||||||
|
null,
|
||||||
|
List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TopicEditView> listAll() {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT " + COLUMNS + " FROM topic ORDER BY name")
|
||||||
|
.query(JdbcTopicRepositoryAdapter::map)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<TopicEditView> find(UUID id) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT " + COLUMNS + " FROM topic WHERE id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.query(JdbcTopicRepositoryAdapter::map)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TopicEditView create(SaveTopicCommand command) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO topic (id, name, normalized_name, slug, description, scope, status,"
|
||||||
|
+ " version, created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :name, :normalized, :slug, :description, :scope,"
|
||||||
|
+ " COALESCE(:status, 'ACTIVE'), 0, :actor, :actor)")
|
||||||
|
.param("id", id)
|
||||||
|
.param("name", command.name().trim())
|
||||||
|
.param("normalized", normalize(command.name()))
|
||||||
|
.param("slug", command.slug().trim())
|
||||||
|
.param("description", command.description())
|
||||||
|
.param("scope", command.scope())
|
||||||
|
.param("status", command.status())
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return find(id).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<TopicEditView> update(SaveTopicCommand command) {
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE topic SET name = :name, normalized_name = :normalized, slug = :slug,"
|
||||||
|
+ " description = :description, scope = :scope,"
|
||||||
|
+ " status = COALESCE(:status, status), version = version + 1,"
|
||||||
|
+ " updated_at = now(), updated_by = :actor"
|
||||||
|
+ " WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", command.id())
|
||||||
|
.param("expected", command.expectedVersion())
|
||||||
|
.param("name", command.name().trim())
|
||||||
|
.param("normalized", normalize(command.name()))
|
||||||
|
.param("slug", command.slug().trim())
|
||||||
|
.param("description", command.description())
|
||||||
|
.param("scope", command.scope())
|
||||||
|
.param("status", command.status())
|
||||||
|
.param("actor", command.actor())
|
||||||
|
.update();
|
||||||
|
return updated == 0 ? Optional.empty() : find(command.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(UUID id, long expectedVersion) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("DELETE FROM topic WHERE id = :id AND version = :expected")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expected", expectedVersion)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고 500 이 나간다. */
|
||||||
|
@Override
|
||||||
|
public boolean isReferenced(UUID id) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS ("
|
||||||
|
+ " SELECT 1 FROM document WHERE primary_topic_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM open_question WHERE primary_topic_id = :id"
|
||||||
|
+ " UNION ALL SELECT 1 FROM public_resource_projection WHERE primary_topic_id = :id"
|
||||||
|
+ ")")
|
||||||
|
.param("id", id)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean nameTaken(String normalizedName, UUID exceptId) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM topic WHERE normalized_name = :name"
|
||||||
|
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
|
||||||
|
.param("name", normalizedName)
|
||||||
|
.param("except", exceptId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean slugTaken(String slug, UUID exceptId) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM topic WHERE slug = :slug"
|
||||||
|
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
|
||||||
|
.param("slug", slug)
|
||||||
|
.param("except", exceptId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single());
|
||||||
|
}
|
||||||
|
}
|
||||||
+336
@@ -0,0 +1,336 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
|
||||||
|
import dev.caskeleton.application.techlog.publicsite.model.BodyAssetView;
|
||||||
|
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.ArrayList;
|
||||||
|
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 상세.
|
||||||
|
*
|
||||||
|
* <p>본문은 {@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<CaseDetailView> 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<ReferenceDetailView> 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<DocumentRow> 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,"
|
||||||
|
// `environment_items` 는 V7 이 만든 jsonb 배열이고, Studio 가 쓰는 것은 V8 이
|
||||||
|
// 따로 만든 `environment`/`reproduction`
|
||||||
|
// 문자열이다(V8__techlog_studio_working_copy.sql:23-26).
|
||||||
|
// 공개 조회가 배열 쪽을 읽는 동안 그 컬럼을 채우는 코드는 어디에도 없었고 —
|
||||||
|
// 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 두 칸은 늘 비어 있었다.
|
||||||
|
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
|
||||||
|
+ " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status,"
|
||||||
|
// 검증일은 원본 `document.last_verified_at` 에서 읽는다. 투영 테이블의 같은 이름
|
||||||
|
// 컬럼(`public_resource_projection.last_verified_at`)은 `upsertProjection` 이
|
||||||
|
// 채우지 않아 언제나 null 이었다.
|
||||||
|
+ " p.navigation_path, p.published_at, p.updated_at, d.last_verified_at,"
|
||||||
|
+ " d.summary,"
|
||||||
|
+ " r.rules, r.examples,"
|
||||||
|
+ " 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"),
|
||||||
|
rs.getString("summary"),
|
||||||
|
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
|
||||||
|
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
|
||||||
|
isCase ? rs.getString("conclusion_summary") : null,
|
||||||
|
isCase ? environment(rs) : List.of(),
|
||||||
|
isCase ? List.of() : json.strings(rs.getString("applies_to")),
|
||||||
|
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
|
||||||
|
// Reference 의 본문은 body_markdown 이 아니라 규칙과 예시에 있다.
|
||||||
|
isCase ? List.of() : json.referenceRules(rs.getString("rules")),
|
||||||
|
isCase ? List.of() : json.strings(rs.getString("examples")),
|
||||||
|
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),
|
||||||
|
// 본문에 evidence 를 담는 것은 CASE 뿐이다.
|
||||||
|
isCase ? bodyAssets(id) : List.of(),
|
||||||
|
instant(rs, "published_at"),
|
||||||
|
instant(rs, "updated_at"),
|
||||||
|
instant(rs, "last_verified_at")));
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<QuestionDetailView> 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<String> 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<QuestionUpdateView> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본문이 {@code :::evidence key="..."} 로 가리키는 Asset.
|
||||||
|
*
|
||||||
|
* <p>{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 하기
|
||||||
|
* 때문이다. {@code WORKING} scope 는 Studio 의 것이다.
|
||||||
|
*
|
||||||
|
* <p>{@code READY} 가 아닌 Asset 은 뺀다. {@code /media/{assetId}} 가 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를
|
||||||
|
* 가리키게 된다.
|
||||||
|
*/
|
||||||
|
private List<BodyAssetView> bodyAssets(UUID documentId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT a.id, a.asset_key, a.content_type, a.alt_text, a.width, a.height, a.decorative"
|
||||||
|
+ " FROM asset_reference r"
|
||||||
|
+ " JOIN asset a ON a.id = r.asset_id"
|
||||||
|
+ " WHERE r.owner_type = 'DOCUMENT' AND r.owner_id = :id"
|
||||||
|
+ " AND r.reference_scope = 'PUBLISHED' AND r.reference_role = 'BODY'"
|
||||||
|
+ " AND a.management_status = 'READY'"
|
||||||
|
+ " ORDER BY a.asset_key")
|
||||||
|
.param("id", documentId)
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) -> {
|
||||||
|
UUID assetId = rs.getObject("id", UUID.class);
|
||||||
|
return new BodyAssetView(
|
||||||
|
rs.getString("asset_key"),
|
||||||
|
assetId,
|
||||||
|
"/api/v1/public/media/" + assetId,
|
||||||
|
rs.getString("content_type"),
|
||||||
|
rs.getString("alt_text"),
|
||||||
|
integer(rs, "width"),
|
||||||
|
integer(rs, "height"),
|
||||||
|
rs.getBoolean("decorative"));
|
||||||
|
})
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Integer integer(ResultSet rs, String column) throws SQLException {
|
||||||
|
int value = rs.getInt(column);
|
||||||
|
return rs.wasNull() ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Instant instant(ResultSet rs, String column) throws SQLException {
|
||||||
|
var value = rs.getTimestamp(column);
|
||||||
|
return value == null ? null : value.toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다.
|
||||||
|
*
|
||||||
|
* <p>계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열 (`environment`,
|
||||||
|
* `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
|
||||||
|
*/
|
||||||
|
static List<String> environment(ResultSet rs) throws SQLException {
|
||||||
|
List<String> items = new ArrayList<>(2);
|
||||||
|
for (String column : List.of("environment", "reproduction")) {
|
||||||
|
String value = rs.getString(column);
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
items.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
"/api/v1/public/media/" + assetId,
|
||||||
|
rs.getString("cover_alt"),
|
||||||
|
(Integer) rs.getObject("cover_width"),
|
||||||
|
(Integer) rs.getObject("cover_height"),
|
||||||
|
rs.getString("cover_content_type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TagSummaryView> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탐색 목록.
|
||||||
|
*
|
||||||
|
* <p>필터와 정렬을 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<String, Object> 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<KnowledgeListItemView> 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<String, Object> 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<QuestionListItemView> 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<String, Object> params) {
|
||||||
|
var spec = jdbcClient.sql("SELECT count(*)" + fromAndWhere);
|
||||||
|
for (Map.Entry<String, Object> e : params.entrySet()) {
|
||||||
|
spec = spec.param(e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
return spec.query(Long.class).single();
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> List<T> page(
|
||||||
|
String sql,
|
||||||
|
Map<String, Object> params,
|
||||||
|
int size,
|
||||||
|
int offset,
|
||||||
|
org.springframework.jdbc.core.RowMapper<T> mapper) {
|
||||||
|
var spec = jdbcClient.sql(sql + " LIMIT :size OFFSET :offset");
|
||||||
|
for (Map.Entry<String, Object> e : params.entrySet()) {
|
||||||
|
spec = spec.param(e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
return spec.param("size", size).param("offset", offset).query(mapper).list();
|
||||||
|
}
|
||||||
|
}
|
||||||
+350
@@ -0,0 +1,350 @@
|
|||||||
|
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.model.TopicSummaryView;
|
||||||
|
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<ProjectListItemView> 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<ProjectDetailView> 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")),
|
||||||
|
topicsOf(projectId),
|
||||||
|
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<RelatedEntryView> 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<ProjectDecisionPageView> 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<ProjectDecisionItemView> 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<ProjectRecordPageView> 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<RelatedEntryView> 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<ProjectActivityPageView> 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<ProjectActivityItemView> 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<UUID> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트가 다루는 주제. 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||||
|
*
|
||||||
|
* <p>{@code ACTIVE} 인 주제만 내보낸다 — 보관된 주제를 링크로 내보내면 따라간 곳이 비어 있다. 순서는 편집기가 정한 {@code display_order}
|
||||||
|
* 를 그대로 따른다.
|
||||||
|
*/
|
||||||
|
private List<TopicSummaryView> topicsOf(UUID projectId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT t.name, t.slug FROM project_topic pt"
|
||||||
|
+ " JOIN topic t ON t.id = pt.topic_id"
|
||||||
|
+ " WHERE pt.project_id = :projectId AND t.status = 'ACTIVE'"
|
||||||
|
+ " ORDER BY pt.display_order, t.name")
|
||||||
|
.param("projectId", projectId)
|
||||||
|
.query((rs, rowNum) -> new TopicSummaryView(rs.getString("name"), rs.getString("slug")))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 릴리스 목록·상세.
|
||||||
|
*
|
||||||
|
* <p>릴리스는 {@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<ReleaseListItemView> 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<ReleaseDetailView> 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 배열이다. 그중 <b>공개된 것만</b> 되살린다 — 릴리스가 지목한 기록이 비공개로 바뀌었을 수
|
||||||
|
* 있고, 그 링크를 그대로 내보내면 404 로 이어진다.
|
||||||
|
*/
|
||||||
|
private List<dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView> relatedRecords(
|
||||||
|
String relatedResourcesJson) {
|
||||||
|
List<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 검색.
|
||||||
|
*
|
||||||
|
* <p>게시 시 만들어 둔 {@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<String, Object> 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<String, Object> 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<String, Object> e : params.entrySet()) {
|
||||||
|
spec = spec.param(e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
List<SearchResultItemView> 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<String> matchedFields(
|
||||||
|
String term, String title, String summary, String body) {
|
||||||
|
String needle = term.toLowerCase(Locale.ROOT);
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+271
@@ -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<SiteView> 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).
|
||||||
|
"/api/v1/public/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<String> 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 / QUESTION / PROJECT_ACTIVITY /
|
||||||
|
* RELEASE} 다섯 값을 허용한다. projection 에는 {@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
|
||||||
|
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
|
||||||
|
*
|
||||||
|
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
|
||||||
|
* workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
|
||||||
|
* 계약은 그 값을 <b>허용</b>할 뿐 매번 포함하라고 요구하지 않는다.
|
||||||
|
*/
|
||||||
|
private List<LatestEntryView> 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<ProfileView> 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 배열이다. 그중 <b>공개된 것만</b> 되살린다 — 프로필이 지목한 기록이 비공개로 바뀌었을 수
|
||||||
|
* 있고, 그 링크를 그대로 내보내면 404 로 이어진다({@code JdbcPublicReleaseQueryAdapter} 의 {@code related_resources}
|
||||||
|
* 와 같은 규칙).
|
||||||
|
*/
|
||||||
|
private List<RelatedEntryView> selectedEvidence(String selectedEvidenceJson) {
|
||||||
|
List<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -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<TopicListItemView> 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<TopicDetailView> 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} 가 지목한 문서 중 <b>공개된 것만</b> 보여준다 — 지목은 Studio 의 편집 행위이고 공개 여부와
|
||||||
|
* 별개다.
|
||||||
|
*/
|
||||||
|
private List<RelatedEntryView> 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<RelatedEntryView> 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<RelatedEntryView> 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 은 홈 화면 전체를 못 쓰게 만든다.
|
||||||
|
*
|
||||||
|
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
|
||||||
|
* workflow_status} 로 공개되므로 이 projection 에 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
|
||||||
|
* 계약은 그 값을 <b>허용</b>할 뿐 매번 포함하라고 요구하지 않는다.
|
||||||
|
*/
|
||||||
|
private List<LatestEntryView> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
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.application.techlog.publicsite.model.ReferenceRuleView;
|
||||||
|
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 컬럼을 푼다.
|
||||||
|
*
|
||||||
|
* <p>Jackson 의 POJO 바인딩을 쓰지 않고 key 를 명시적으로 읽는다 — 이 값들은 DB 에 영속된 모양이라 application record 의 필드 이름이
|
||||||
|
* 바뀌면 이미 저장된 행을 못 읽게 된다.
|
||||||
|
*/
|
||||||
|
final class PublicJson {
|
||||||
|
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
PublicJson(ObjectMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> strings(String json) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (JsonNode node : array(json)) {
|
||||||
|
// 설계의 배열 컬럼은 문자열이거나 {text: ...} 모양일 수 있다. 둘 다 받는다.
|
||||||
|
out.add(node.isString() ? node.asString("") : node.path("text").asString(node.toString()));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reference 의 판단 기준. 설계의 컬럼은 {@code {title, body, order}} 배열이다. */
|
||||||
|
List<ReferenceRuleView> referenceRules(String json) {
|
||||||
|
List<ReferenceRuleView> out = new ArrayList<>();
|
||||||
|
for (JsonNode node : array(json)) {
|
||||||
|
out.add(
|
||||||
|
new ReferenceRuleView(node.path("title").asString(""), node.path("body").asString("")));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ContactLinkView> contacts(String json) {
|
||||||
|
List<ContactLinkView> 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<ProfileView.NamedDescription> namedDescriptions(String json) {
|
||||||
|
List<ProfileView.NamedDescription> 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<ProfileView.Territory> territories(String json) {
|
||||||
|
List<ProfileView.Territory> 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<JsonNode> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 상세가 보여주는 관계.
|
||||||
|
*
|
||||||
|
* <p><b>어디서 읽는지가 중요하다.</b> 설계 스키마에는 유형별 링크 테이블({@code document_relation}, {@code
|
||||||
|
* question_document_link})이 있지만 <b>그 테이블들에 쓰는 경로가 없다</b> — Studio 편집기가 만드는 관계는 전부 {@code
|
||||||
|
* studio_relation} 에 들어간다(계약의 relations[] 가 네 유형 공통이라 그렇게 설계했다). 그래서 공개도 같은 곳에서 읽는다. 링크 테이블을 읽으면
|
||||||
|
* 관계가 항상 비어 보인다.
|
||||||
|
*
|
||||||
|
* <p>관계의 종류는 저장돼 있지 않으므로 <b>대상의 유형</b>으로 나눈다 — 계약이 관계를 유형별 묶음 (relatedCases / derivedReferences /
|
||||||
|
* projectDecisions / originQuestion)으로 요구하기 때문이다. 공개되지 않은 대상은 제외한다.
|
||||||
|
*/
|
||||||
|
final class PublicRelationLookup {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
PublicRelationLookup(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code sourceKind} 문서가 가리키는 관계 중 대상이 {@code targetType} 이고 공개된 것들. */
|
||||||
|
List<RelatedEntryView> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이 기록을 가리키는 <b>역방향</b> 관계. "이 Reference 를 적용한 Case" 같은 자리에 쓴다. */
|
||||||
|
List<RelatedEntryView> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 조회가 공유하는 SQL 조각.
|
||||||
|
*
|
||||||
|
* <p>"무엇이 공개인가"의 정의를 한 곳에 둔다. 각 쿼리가 조건을 따로 쓰면 어느 하나가 {@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', 'QUESTION', '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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
-5
@@ -13,11 +13,21 @@ import org.springframework.stereotype.Repository;
|
|||||||
/**
|
/**
|
||||||
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
|
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
|
||||||
*
|
*
|
||||||
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다.
|
* <p>RELATION 과 EVIDENCE 는 같은 기록을 서로 다른 시점에서 본다. RELATION 은 <em>작성 중</em>에 고르는 것이라 아직 게시되지 않은 작업본까지
|
||||||
|
* 포함한다 — 두 문서를 같이 쓰면서 서로 잇는 것이 정상적인 순서이고, 게시된 것만 보이면 그 순서를 쓸 수 없다. EVIDENCE 는 공개된 기록을 근거로 인용하는 것이므로
|
||||||
|
* 공개 투영에 살아 있는 행만 포함한다. 읽는 사람이 따라갈 수 없는 근거는 근거가 아니다.
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
||||||
|
|
||||||
|
/** 공개 노출 조건. {@code PublicSql.ACTIVE} 와 같은 정의다 — 그쪽은 package-private 이라 여기에 다시 적는다. */
|
||||||
|
private static final String PUBLICLY_VISIBLE =
|
||||||
|
" p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
|
||||||
|
|
||||||
|
/** 연결·근거 대상이 될 수 있는 기록 유형. PROJECT 는 관계가 아니라 소속이라 여기에 없다. */
|
||||||
|
private static final String LINKABLE_TYPES =
|
||||||
|
" ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION') ";
|
||||||
|
|
||||||
private final JdbcClient jdbcClient;
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
|
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
|
||||||
@@ -32,7 +42,8 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case TOPIC -> searchTopics(pattern, limit);
|
case TOPIC -> searchTopics(pattern, limit);
|
||||||
case PROJECT -> searchProjects(pattern, limit);
|
case PROJECT -> searchProjects(pattern, limit);
|
||||||
case RELATION, EVIDENCE -> List.of();
|
case RELATION -> searchRelations(pattern, limit);
|
||||||
|
case EVIDENCE -> searchEvidence(pattern, limit);
|
||||||
};
|
};
|
||||||
return new CatalogPageView(items, null);
|
return new CatalogPageView(items, null);
|
||||||
}
|
}
|
||||||
@@ -57,11 +68,25 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
|||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트의 공개 경로를 함께 싣는다.
|
||||||
|
*
|
||||||
|
* <p>여기서 {@code publicPath} 를 늘 null 로 두었더니 Decision 의 즉시 미리보기가 어떤 문서에서도 열리지 않았다. Decision 의 공개
|
||||||
|
* 주소는 자기 slug 가 아니라 {@code <프로젝트 경로>/decisions#<slug>} 라, 렌더 모델이 프로젝트 경로를 요구한다 — 그것이 비어 있으면
|
||||||
|
* "PROJECT public path is required" 로 미리보기 전체가 멈춘다. 화면에는 무엇이 모자란지 나오지 않는다.
|
||||||
|
*
|
||||||
|
* <p>게시되지 않은 프로젝트는 여전히 null 이다. 그때는 공개 주소가 실제로 없고, 없는 주소를 지어내면 미리보기가 보여 주는 링크가 게시 뒤에 달라진다.
|
||||||
|
*/
|
||||||
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
|
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
|
||||||
return jdbcClient
|
return jdbcClient
|
||||||
.sql(
|
.sql(
|
||||||
"SELECT id, name, updated_at FROM project "
|
"SELECT pr.id, pr.name, pr.updated_at, p.navigation_path"
|
||||||
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
|
+ " FROM project pr"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(pr.name) LIKE :pattern ORDER BY pr.name LIMIT :limit")
|
||||||
.param("pattern", pattern)
|
.param("pattern", pattern)
|
||||||
.param("limit", limit)
|
.param("limit", limit)
|
||||||
.query(
|
.query(
|
||||||
@@ -71,8 +96,83 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
|||||||
CatalogEntryType.PROJECT,
|
CatalogEntryType.PROJECT,
|
||||||
rs.getString("name"),
|
rs.getString("name"),
|
||||||
"PROJECT",
|
"PROJECT",
|
||||||
null,
|
rs.getString("navigation_path"),
|
||||||
"project:" + rs.getTimestamp("updated_at").toInstant()))
|
"project:" + rs.getTimestamp("updated_at").toInstant()))
|
||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세 원천 테이블을 하나의 목록으로 합친다. {@code document} 는 {@code document_type} 이 그대로 계약의 {@code kind} 이고, 나머지
|
||||||
|
* 둘은 테이블 자체가 유형을 정한다.
|
||||||
|
*
|
||||||
|
* <p>공개 경로는 게시된 것에만 있으므로 LEFT JOIN 이다. 작업본은 {@code publicPath} 가 null 이고, 이는 "아직 공개 주소가 없다"는 뜻이지
|
||||||
|
* "고를 수 없다"는 뜻이 아니다.
|
||||||
|
*/
|
||||||
|
private List<CatalogEntryView> searchRelations(String pattern, int limit) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT id, kind, label, public_path, updated_at FROM ("
|
||||||
|
+ " SELECT d.id AS id, d.document_type AS kind, d.title AS label,"
|
||||||
|
+ " p.navigation_path AS public_path, d.updated_at AS updated_at"
|
||||||
|
+ " FROM document d"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(d.title) LIKE :pattern"
|
||||||
|
+ " UNION ALL"
|
||||||
|
+ " SELECT q.id, 'QUESTION', q.question,"
|
||||||
|
+ " p.navigation_path, q.updated_at"
|
||||||
|
+ " FROM open_question q"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(q.question) LIKE :pattern"
|
||||||
|
+ " UNION ALL"
|
||||||
|
+ " SELECT pd.id, 'PROJECT_DECISION', pd.title,"
|
||||||
|
+ " p.navigation_path, pd.updated_at"
|
||||||
|
+ " FROM project_decision pd"
|
||||||
|
+ " LEFT JOIN public_resource_projection p"
|
||||||
|
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = pd.id"
|
||||||
|
+ " AND "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " WHERE lower(pd.title) LIKE :pattern"
|
||||||
|
+ ") linkable ORDER BY label LIMIT :limit")
|
||||||
|
.param("pattern", pattern)
|
||||||
|
.param("limit", limit)
|
||||||
|
.query((rs, rowNum) -> entry(rs, CatalogEntryType.RELATION, "relation"))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공개 투영이 곧 "읽는 사람이 따라갈 수 있는 기록"의 정의다. 여기서는 그 테이블 하나면 충분하다. */
|
||||||
|
private List<CatalogEntryView> searchEvidence(String pattern, int limit) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT p.resource_id AS id, p.resource_type AS kind, p.title AS label,"
|
||||||
|
+ " p.navigation_path AS public_path, p.updated_at AS updated_at"
|
||||||
|
+ " FROM public_resource_projection p"
|
||||||
|
+ " WHERE "
|
||||||
|
+ PUBLICLY_VISIBLE
|
||||||
|
+ " AND p.resource_type IN "
|
||||||
|
+ LINKABLE_TYPES
|
||||||
|
+ " AND lower(p.title) LIKE :pattern"
|
||||||
|
+ " ORDER BY label LIMIT :limit")
|
||||||
|
.param("pattern", pattern)
|
||||||
|
.param("limit", limit)
|
||||||
|
.query((rs, rowNum) -> entry(rs, CatalogEntryType.EVIDENCE, "evidence"))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CatalogEntryView entry(
|
||||||
|
java.sql.ResultSet rs, CatalogEntryType type, String revisionPrefix)
|
||||||
|
throws java.sql.SQLException {
|
||||||
|
return new CatalogEntryView(
|
||||||
|
UUID.fromString(rs.getString("id")),
|
||||||
|
type,
|
||||||
|
rs.getString("label"),
|
||||||
|
rs.getString("kind"),
|
||||||
|
rs.getString("public_path"),
|
||||||
|
revisionPrefix + ":" + rs.getTimestamp("updated_at").toInstant());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioError;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 의존 상태 해시를 {@link StudioDocumentSql}의 정의로 계산한다.
|
||||||
|
*
|
||||||
|
* <p>목록이 쓰는 SQL과 <b>같은 식</b>을 쓴다. 여기서 다른 식을 쓰면 상세 화면이 계산한 값과 목록이 계산한 값이 달라져, 검증을 막 통과한 문서가 목록에서는
|
||||||
|
* "다시 검증하라"로 보인다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcDependencyRevisionAdapter implements DependencyRevisionPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcDependencyRevisionAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String revisionFor(RecordKind kind, UUID documentId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
StudioDocumentSql.documentProjectionCte()
|
||||||
|
+ " SELECT dependency_revision FROM studio_document WHERE id = :id")
|
||||||
|
.param("id", documentId)
|
||||||
|
.query(String.class)
|
||||||
|
.optional()
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
StudioException.of(
|
||||||
|
StudioError.DOCUMENT_NOT_FOUND,
|
||||||
|
"cannot compute a dependency revision for unknown document " + documentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/** 현재 게시 상태 조회. {@code publication}은 (source_kind, source_id)당 한 행이다. */
|
||||||
|
@Repository
|
||||||
|
public class JdbcPublicationQueryAdapter implements PublicationQueryPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcPublicationQueryAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<PublicationAggregateView> currentFor(UUID documentId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT publication_id, source_id, status, published_version, publication_revision,"
|
||||||
|
+ " latest_event_id, public_path, updated_at"
|
||||||
|
+ " FROM publication WHERE source_id = :id")
|
||||||
|
.param("id", documentId)
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) ->
|
||||||
|
new PublicationAggregateView(
|
||||||
|
rs.getObject("publication_id", UUID.class),
|
||||||
|
rs.getObject("source_id", UUID.class),
|
||||||
|
PublicationAggregateStatus.valueOf(rs.getString("status")),
|
||||||
|
rs.getLong("published_version"),
|
||||||
|
rs.getLong("publication_revision"),
|
||||||
|
rs.getObject("latest_event_id", UUID.class),
|
||||||
|
rs.getString("public_path"),
|
||||||
|
rs.getTimestamp("updated_at").toInstant()))
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DashboardTotalsView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.NextAction;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 집계. 목록과 <b>같은</b> {@code studio_document} 정의를 쓴다 — 대시보드가 "게시 준비됨"이라고 센 문서와 목록에서 그 필터로 나오는
|
||||||
|
* 문서가 달라지면 숫자를 믿을 수 없다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcStudioDashboardQueryAdapter implements StudioDashboardQueryPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcStudioDashboardQueryAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<DocumentSummaryView> topByNextAction(List<NextAction> actions, int limit) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
StudioDocumentSql.documentProjectionCte()
|
||||||
|
+ " SELECT * FROM studio_document WHERE next_action IN (:actions)"
|
||||||
|
+ " ORDER BY updated_at DESC, id DESC LIMIT :limit")
|
||||||
|
.param("actions", actions.stream().map(Enum::name).toList())
|
||||||
|
.param("limit", limit)
|
||||||
|
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DashboardTotalsView totals() {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
StudioDocumentSql.documentProjectionCte()
|
||||||
|
+ " SELECT count(*) AS documents,"
|
||||||
|
+ " count(*) FILTER (WHERE next_action IN ('VALIDATE', 'FIX_VALIDATION'))"
|
||||||
|
+ " AS needs_validation,"
|
||||||
|
+ " count(*) FILTER (WHERE next_action = 'PUBLISH') AS ready_to_publish,"
|
||||||
|
+ " (SELECT count(*) FROM publication WHERE status = 'PUBLISHED') AS publications"
|
||||||
|
+ " FROM studio_document")
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) ->
|
||||||
|
new DashboardTotalsView(
|
||||||
|
rs.getInt("documents"),
|
||||||
|
rs.getInt("needs_validation"),
|
||||||
|
rs.getInt("ready_to_publish"),
|
||||||
|
rs.getInt("publications")))
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicPaths;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RelationView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/** 편집본이 의존하는 바깥 상태를 한 번에 읽는다. 검증과 렌더링이 같은 결과를 공유하도록 조회는 이 한 곳에서만 한다. */
|
||||||
|
@Repository
|
||||||
|
public class JdbcStudioDependencyResolverAdapter implements StudioDependencyResolverPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcStudioDependencyResolverAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Resolved resolve(WorkingCopyView document, Set<String> referencedAssetKeys) {
|
||||||
|
UUID topicId = document.base().topicId();
|
||||||
|
UUID projectId = document.base().projectId();
|
||||||
|
|
||||||
|
DisplayTargetView topic = topicId == null ? null : findTopic(topicId);
|
||||||
|
DisplayTargetView project = projectId == null ? null : findProject(projectId);
|
||||||
|
|
||||||
|
List<ResolvedRelationView> relations = new ArrayList<>();
|
||||||
|
List<UUID> missingTargets = new ArrayList<>();
|
||||||
|
resolveRelations(document, relations, missingTargets);
|
||||||
|
|
||||||
|
Map<String, ResolvedAssetView> assetsByKey = new LinkedHashMap<>();
|
||||||
|
Map<String, String> assetStatusByKey = new LinkedHashMap<>();
|
||||||
|
resolveAssets(referencedAssetKeys, assetsByKey, assetStatusByKey);
|
||||||
|
|
||||||
|
String projectSlug = projectId == null ? null : findProjectSlug(projectId);
|
||||||
|
String publicPath = PublicPaths.forKind(document.kind(), document.base().slug(), projectSlug);
|
||||||
|
|
||||||
|
return new Resolved(
|
||||||
|
topic,
|
||||||
|
topicId != null && topic == null,
|
||||||
|
project,
|
||||||
|
projectId != null && project == null,
|
||||||
|
relations,
|
||||||
|
missingTargets,
|
||||||
|
assetsByKey,
|
||||||
|
assetStatusByKey,
|
||||||
|
resolveEvidenceTarget(document),
|
||||||
|
publicPath,
|
||||||
|
findSlugOwner(document));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayTargetView findTopic(UUID topicId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT id, name, slug FROM topic WHERE id = :id")
|
||||||
|
.param("id", topicId)
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) ->
|
||||||
|
new DisplayTargetView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
"/topics/" + rs.getString("slug")))
|
||||||
|
.optional()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayTargetView findProject(UUID projectId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT id, name, slug FROM project WHERE id = :id")
|
||||||
|
.param("id", projectId)
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) ->
|
||||||
|
new DisplayTargetView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("slug") == null ? null : "/projects/" + rs.getString("slug")))
|
||||||
|
.optional()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findProjectSlug(UUID projectId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT slug FROM project WHERE id = :id")
|
||||||
|
.param("id", projectId)
|
||||||
|
.query(String.class)
|
||||||
|
.optional()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관계 대상은 네 유형 어디에도 있을 수 있고 프로젝트일 수도 있다(계약 {@code ResolvedRelation.targetKind} 가 {@code
|
||||||
|
* RecordKind} 보다 하나 넓다). UNION 으로 한 번에 찾는다.
|
||||||
|
*/
|
||||||
|
private void resolveRelations(
|
||||||
|
WorkingCopyView document, List<ResolvedRelationView> resolved, List<UUID> missing) {
|
||||||
|
|
||||||
|
for (RelationView relation : document.base().relations()) {
|
||||||
|
if (relation.targetId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TargetRow row = findTarget(relation.targetId());
|
||||||
|
if (row == null) {
|
||||||
|
missing.add(relation.targetId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved.add(
|
||||||
|
new ResolvedRelationView(
|
||||||
|
relation.id(),
|
||||||
|
relation.targetId(),
|
||||||
|
row.kind(),
|
||||||
|
row.title(),
|
||||||
|
row.publicPath(),
|
||||||
|
relation.reason() == null ? "" : relation.reason(),
|
||||||
|
relation.order()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TargetRow(String kind, String title, String publicPath) {}
|
||||||
|
|
||||||
|
private TargetRow findTarget(UUID targetId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT document_type AS kind, title, slug, NULL::text AS project_slug FROM document"
|
||||||
|
+ " WHERE id = :id"
|
||||||
|
+ " UNION ALL SELECT 'QUESTION', question, slug, NULL::text FROM open_question"
|
||||||
|
+ " WHERE id = :id"
|
||||||
|
+ " UNION ALL SELECT 'PROJECT', name, slug, NULL::text FROM project"
|
||||||
|
+ " WHERE id = :id"
|
||||||
|
+ " UNION ALL SELECT 'PROJECT_DECISION', pd.title, pd.slug, p.slug"
|
||||||
|
+ " FROM project_decision pd LEFT JOIN project p ON p.id = pd.project_id"
|
||||||
|
+ " WHERE pd.id = :id")
|
||||||
|
.param("id", targetId)
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) -> {
|
||||||
|
String kind = rs.getString("kind");
|
||||||
|
String slug = rs.getString("slug");
|
||||||
|
String publicPath =
|
||||||
|
"PROJECT".equals(kind)
|
||||||
|
? (slug == null ? null : "/projects/" + slug)
|
||||||
|
: PublicPaths.forKind(
|
||||||
|
RecordKind.valueOf(kind), slug, rs.getString("project_slug"));
|
||||||
|
return new TargetRow(kind, rs.getString("title"), publicPath);
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resolveAssets(
|
||||||
|
Set<String> keys, Map<String, ResolvedAssetView> assets, Map<String, String> statuses) {
|
||||||
|
if (keys.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT id, asset_key, content_type, object_key, width, height, decorative,"
|
||||||
|
+ " management_status FROM asset WHERE asset_key IN (:keys)")
|
||||||
|
.param("keys", keys)
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) -> {
|
||||||
|
String key = rs.getString("asset_key");
|
||||||
|
statuses.put(key, rs.getString("management_status"));
|
||||||
|
assets.put(
|
||||||
|
key,
|
||||||
|
new ResolvedAssetView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
key,
|
||||||
|
rs.getString("content_type"),
|
||||||
|
// 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다
|
||||||
|
// (설계 05장 §3.1).
|
||||||
|
"/api/v1/public/media/" + rs.getString("id"),
|
||||||
|
(Integer) rs.getObject("width"),
|
||||||
|
(Integer) rs.getObject("height"),
|
||||||
|
rs.getBoolean("decorative")));
|
||||||
|
return key;
|
||||||
|
})
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayTargetView resolveEvidenceTarget(WorkingCopyView document) {
|
||||||
|
if (!(document instanceof WorkingCopyView.QuestionWorkingCopyView value)
|
||||||
|
|| value.resolution() == null
|
||||||
|
|| value.resolution().evidenceTargetId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TargetRow row = findTarget(value.resolution().evidenceTargetId());
|
||||||
|
return row == null
|
||||||
|
? null
|
||||||
|
: new DisplayTargetView(
|
||||||
|
value.resolution().evidenceTargetId(), row.title(), row.publicPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 같은 공개 경로 이름공간(= 같은 유형)에서 이 slug 를 이미 쓰는 다른 기록. 유형이 다르면 경로 접두사가 달라 충돌하지 않는다({@code /cases/x} 와
|
||||||
|
* {@code /questions/x} 는 다른 주소다).
|
||||||
|
*/
|
||||||
|
private UUID findSlugOwner(WorkingCopyView document) {
|
||||||
|
String slug = document.base().slug();
|
||||||
|
if (slug == null || slug.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String sql =
|
||||||
|
switch (document.kind()) {
|
||||||
|
case CASE, REFERENCE ->
|
||||||
|
"SELECT id FROM document WHERE slug = :slug AND document_type = :kind AND id <> :id";
|
||||||
|
case QUESTION -> "SELECT id FROM open_question WHERE slug = :slug AND id <> :id";
|
||||||
|
case PROJECT_DECISION ->
|
||||||
|
"SELECT id FROM project_decision WHERE slug = :slug AND id <> :id";
|
||||||
|
};
|
||||||
|
var spec = jdbcClient.sql(sql).param("slug", slug).param("id", document.id());
|
||||||
|
if (document.kind() == RecordKind.CASE || document.kind() == RecordKind.REFERENCE) {
|
||||||
|
spec = spec.param("kind", document.kind().name());
|
||||||
|
}
|
||||||
|
return spec.query(UUID.class).optional().orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
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 listStudioDocuments}. 네 유형이 서로 다른 테이블에 살기 때문에 공통 repository 대신 union projection을 돌린다(계약
|
||||||
|
* 설명, spec §8.3).
|
||||||
|
*
|
||||||
|
* <p>정렬 키에 항상 {@code id}를 붙인다. {@code updated_at}만으로 자르면 같은 시각의 행들이 페이지 경계에서 중복되거나 누락된다 — 대량 저장 직후에
|
||||||
|
* 실제로 일어나는 일이다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcStudioDocumentQueryAdapter implements StudioDocumentQueryPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcStudioDocumentQueryAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DocumentPageView list(ListDocumentsQuery query) {
|
||||||
|
StringBuilder sql = new StringBuilder(StudioDocumentSql.documentProjectionCte());
|
||||||
|
sql.append(" SELECT * FROM studio_document WHERE 1 = 1");
|
||||||
|
Map<String, Object> params = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
if (query.kind() != null) {
|
||||||
|
sql.append(" AND kind = :kind");
|
||||||
|
params.put("kind", query.kind().name());
|
||||||
|
}
|
||||||
|
if (query.projectId() != null) {
|
||||||
|
sql.append(" AND project_id = :projectId");
|
||||||
|
params.put("projectId", query.projectId());
|
||||||
|
}
|
||||||
|
if (query.nextAction() != null) {
|
||||||
|
sql.append(" AND next_action = :nextAction");
|
||||||
|
params.put("nextAction", query.nextAction().name());
|
||||||
|
}
|
||||||
|
if (query.publicationStatus() != null) {
|
||||||
|
sql.append(publicationStatusPredicate());
|
||||||
|
params.put("publicationStatus", query.publicationStatus().name());
|
||||||
|
}
|
||||||
|
if (query.query() != null && !query.query().isBlank()) {
|
||||||
|
sql.append(" AND lower(title) LIKE :titlePattern");
|
||||||
|
params.put("titlePattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
|
||||||
|
}
|
||||||
|
appendCursorPredicate(sql, params, query);
|
||||||
|
sql.append(orderBy(query.sort()));
|
||||||
|
// limit + 1 을 읽어 "다음 쪽이 있는가"를 별도 count 없이 판정한다.
|
||||||
|
sql.append(" LIMIT :limitPlusOne");
|
||||||
|
params.put("limitPlusOne", query.limit() + 1);
|
||||||
|
|
||||||
|
var spec = jdbcClient.sql(sql.toString());
|
||||||
|
for (Map.Entry<String, Object> param : params.entrySet()) {
|
||||||
|
spec = spec.param(param.getKey(), param.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
|
||||||
|
boolean hasMore = rows.size() > query.limit();
|
||||||
|
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
|
||||||
|
|
||||||
|
List<DocumentSummaryView> items = new ArrayList<>(page.size());
|
||||||
|
for (Row row : page) {
|
||||||
|
items.add(row.summary());
|
||||||
|
}
|
||||||
|
return new DocumentPageView(
|
||||||
|
items, hasMore ? cursorPayload(page.getLast(), query.sort()) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 이력이 없는 문서는 {@code publication} 행 자체가 없다 — {@code NEVER_PUBLISHED}는 "행이 없음"이지 특정 status 값이
|
||||||
|
* 아니다.
|
||||||
|
*/
|
||||||
|
private static String publicationStatusPredicate() {
|
||||||
|
return " AND ((:publicationStatus = 'NEVER_PUBLISHED' AND publication_status IS NULL)"
|
||||||
|
+ " OR publication_status = :publicationStatus)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String orderBy(DocumentSort sort) {
|
||||||
|
return switch (sort) {
|
||||||
|
case UPDATED_DESC -> " ORDER BY updated_at DESC, id DESC";
|
||||||
|
case UPDATED_ASC -> " ORDER BY updated_at ASC, id ASC";
|
||||||
|
case TITLE_ASC -> " ORDER BY title ASC, id ASC";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendCursorPredicate(
|
||||||
|
StringBuilder sql, Map<String, Object> params, ListDocumentsQuery query) {
|
||||||
|
DocumentCursorPosition position = query.position();
|
||||||
|
if (position == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 정렬이 늘면 컴파일러가
|
||||||
|
// 여기서 막아 준다(문이면 커서 조건 없이 조용히 첫 페이지를 다시 준다).
|
||||||
|
String predicate =
|
||||||
|
switch (query.sort()) {
|
||||||
|
case UPDATED_DESC -> {
|
||||||
|
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
|
||||||
|
yield " AND (updated_at, id) < (:cursorUpdatedAt, :cursorId)";
|
||||||
|
}
|
||||||
|
case UPDATED_ASC -> {
|
||||||
|
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
|
||||||
|
yield " AND (updated_at, id) > (:cursorUpdatedAt, :cursorId)";
|
||||||
|
}
|
||||||
|
case TITLE_ASC -> {
|
||||||
|
params.put("cursorTitle", position.title());
|
||||||
|
yield " AND (title, id) > (:cursorTitle, :cursorId)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
params.put("cursorId", position.id());
|
||||||
|
sql.append(predicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다음 쪽의 시작 위치. web 계층이 이 값을 서명해 opaque cursor 로 만든다. */
|
||||||
|
private static String cursorPayload(Row last, DocumentSort sort) {
|
||||||
|
return switch (sort) {
|
||||||
|
case UPDATED_DESC, UPDATED_ASC -> last.updatedAt().toInstant() + "|" + last.summary().id();
|
||||||
|
case TITLE_ASC -> last.title() + "|" + last.summary().id();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 커서 계산에 필요한 정렬 키만 요약과 함께 들고 다닌다. */
|
||||||
|
private record Row(DocumentSummaryView summary, java.sql.Timestamp updatedAt, String title) {}
|
||||||
|
|
||||||
|
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||||
|
return new Row(
|
||||||
|
StudioDocumentRowMapper.read(rs), rs.getTimestamp("updated_at"), rs.getString("title"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.NextAction;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code studio_document} 한 행 → 계약 {@code DocumentSummary}.
|
||||||
|
*
|
||||||
|
* <p>목록·대시보드·게시 이력이 모두 이 매퍼를 쓴다. 화면마다 따로 만들면 같은 문서가 화면마다 다른 {@code nextAction} 이나 {@code
|
||||||
|
* hasUnpublishedChanges} 로 보인다.
|
||||||
|
*/
|
||||||
|
public final class StudioDocumentRowMapper {
|
||||||
|
|
||||||
|
private StudioDocumentRowMapper() {}
|
||||||
|
|
||||||
|
public static DocumentSummaryView read(ResultSet rs) throws SQLException {
|
||||||
|
UUID projectId = rs.getObject("project_id", UUID.class);
|
||||||
|
String projectSlug = rs.getString("project_slug");
|
||||||
|
Long publishedVersion = (Long) rs.getObject("published_version");
|
||||||
|
long version = rs.getLong("version");
|
||||||
|
String publicationStatus = rs.getString("publication_status");
|
||||||
|
|
||||||
|
return new DocumentSummaryView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("title"),
|
||||||
|
RecordKind.valueOf(rs.getString("kind")),
|
||||||
|
projectId == null
|
||||||
|
? null
|
||||||
|
: new DisplayTargetView(
|
||||||
|
projectId,
|
||||||
|
rs.getString("project_name"),
|
||||||
|
projectSlug == null ? null : "/projects/" + projectSlug),
|
||||||
|
rs.getTimestamp("updated_at").toInstant(),
|
||||||
|
publicationStatus == null
|
||||||
|
? PublicationStatusView.NEVER_PUBLISHED
|
||||||
|
: PublicationStatusView.valueOf(publicationStatus),
|
||||||
|
publishedVersion,
|
||||||
|
// 계약: "게시 취소 상태에서도 과거 publishedVersion 과 비교한다."
|
||||||
|
publishedVersion != null && publishedVersion != version,
|
||||||
|
NextAction.valueOf(rs.getString("next_action")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Studio 문서 union projection의 SQL 정의. 목록·대시보드·단건 조회가 <b>같은</b> 정의를 쓴다.
|
||||||
|
*
|
||||||
|
* <p>{@code dependencyRevision}과 {@code nextAction}은 저장하지 않고 계산하는 값이다(spec §6.3, §7.3). 계산식이 SQL 한
|
||||||
|
* 곳과 Java 한 곳에 따로 있으면 목록의 {@code nextAction}과 상세의 {@code nextAction}이 조용히 갈라진다 — 사용자에게는 "목록에서는
|
||||||
|
* 게시하라더니 열어 보니 검증하라"는 모순으로 보인다. 그래서 계산은 여기 SQL 한 벌만 둔다.
|
||||||
|
*/
|
||||||
|
public final class StudioDocumentSql {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 — 올리는 순간 기존 validation/preview가 전부 stale이 되어 다시 검증·미리보기를 거치게 된다.
|
||||||
|
*/
|
||||||
|
public static final String RENDERER_CONTRACT_VERSION = "1";
|
||||||
|
|
||||||
|
private StudioDocumentSql() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네 유형을 하나의 행 모양으로 모으고, 그 위에 의존 상태 해시와 최신 artifact를 붙인 CTE 묶음.
|
||||||
|
*
|
||||||
|
* <p>마지막 CTE {@code studio_document}가 최종 결과이며 컬럼은 다음과 같다.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* id kind title version updated_at topic_id project_id project_name project_slug
|
||||||
|
* dependency_revision
|
||||||
|
* validation_version validation_status validation_valid_until validation_revision
|
||||||
|
* preview_version preview_expires_at preview_revision
|
||||||
|
* publication_status published_version
|
||||||
|
* next_action
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public static String documentProjectionCte() {
|
||||||
|
return """
|
||||||
|
WITH studio_source AS (
|
||||||
|
SELECT d.id,
|
||||||
|
d.document_type AS kind,
|
||||||
|
d.title,
|
||||||
|
d.version,
|
||||||
|
d.updated_at,
|
||||||
|
d.primary_topic_id AS topic_id,
|
||||||
|
(SELECT l.project_id FROM project_document_link l
|
||||||
|
WHERE l.document_id = d.id AND l.relation_type = 'PRIMARY') AS project_id
|
||||||
|
FROM document d
|
||||||
|
UNION ALL
|
||||||
|
SELECT q.id, 'QUESTION', q.question, q.version, q.updated_at, q.primary_topic_id,
|
||||||
|
(SELECT l.project_id FROM project_question_link l
|
||||||
|
WHERE l.question_id = q.id AND l.relation_type = 'PRIMARY')
|
||||||
|
FROM open_question q
|
||||||
|
UNION ALL
|
||||||
|
SELECT pd.id, 'PROJECT_DECISION', pd.title, pd.version, pd.updated_at,
|
||||||
|
pd.primary_topic_id, pd.project_id
|
||||||
|
FROM project_decision pd
|
||||||
|
),
|
||||||
|
studio_dependency AS (
|
||||||
|
SELECT s.id,
|
||||||
|
md5(concat_ws('|',
|
||||||
|
coalesce(t.id::text || ':' || t.version::text, '-'),
|
||||||
|
coalesce(p.id::text || ':' || p.version::text, '-'),
|
||||||
|
coalesce((
|
||||||
|
SELECT md5(string_agg(rel.sig, ',' ORDER BY rel.sig))
|
||||||
|
FROM (
|
||||||
|
SELECT r.target_id::text || ':'
|
||||||
|
|| coalesce(rd.version, rq.version, rp.version, 0)::text AS sig
|
||||||
|
FROM studio_relation r
|
||||||
|
LEFT JOIN document rd ON rd.id = r.target_id
|
||||||
|
LEFT JOIN open_question rq ON rq.id = r.target_id
|
||||||
|
LEFT JOIN project_decision rp ON rp.id = r.target_id
|
||||||
|
WHERE r.source_kind = s.kind AND r.source_id = s.id
|
||||||
|
) rel
|
||||||
|
), '-'),
|
||||||
|
'renderer:%s'
|
||||||
|
)) AS dependency_revision
|
||||||
|
FROM studio_source s
|
||||||
|
LEFT JOIN topic t ON t.id = s.topic_id
|
||||||
|
LEFT JOIN project p ON p.id = s.project_id
|
||||||
|
),
|
||||||
|
studio_latest_validation AS (
|
||||||
|
SELECT DISTINCT ON (v.source_id)
|
||||||
|
v.source_id, v.validation_id, v.validated_version, v.status,
|
||||||
|
v.valid_until, v.dependency_revision
|
||||||
|
FROM studio_validation v
|
||||||
|
ORDER BY v.source_id, v.validated_at DESC
|
||||||
|
),
|
||||||
|
studio_latest_preview AS (
|
||||||
|
SELECT DISTINCT ON (pv.source_id)
|
||||||
|
pv.source_id, pv.preview_id, pv.source_version, pv.expires_at,
|
||||||
|
pv.dependency_revision
|
||||||
|
FROM studio_preview pv
|
||||||
|
ORDER BY pv.source_id, pv.created_at DESC
|
||||||
|
),
|
||||||
|
studio_document AS (
|
||||||
|
SELECT s.id, s.kind, s.title, s.version, s.updated_at, s.topic_id, s.project_id,
|
||||||
|
pr.name AS project_name, pr.slug AS project_slug,
|
||||||
|
dep.dependency_revision,
|
||||||
|
val.validated_version AS validation_version,
|
||||||
|
val.status AS validation_status,
|
||||||
|
val.valid_until AS validation_valid_until,
|
||||||
|
val.dependency_revision AS validation_revision,
|
||||||
|
prev.source_version AS preview_version,
|
||||||
|
prev.expires_at AS preview_expires_at,
|
||||||
|
prev.dependency_revision AS preview_revision,
|
||||||
|
pub.status AS publication_status,
|
||||||
|
pub.published_version,
|
||||||
|
CASE
|
||||||
|
WHEN s.title IS NULL OR btrim(s.title) = '' THEN 'CONTINUE_EDITING'
|
||||||
|
WHEN val.validated_version IS NULL
|
||||||
|
OR val.validated_version <> s.version
|
||||||
|
OR val.dependency_revision IS DISTINCT FROM dep.dependency_revision
|
||||||
|
OR val.valid_until <= now() THEN 'VALIDATE'
|
||||||
|
WHEN val.status = 'INVALID' THEN 'FIX_VALIDATION'
|
||||||
|
WHEN prev.source_version IS NULL
|
||||||
|
OR prev.source_version <> s.version
|
||||||
|
OR prev.dependency_revision IS DISTINCT FROM dep.dependency_revision
|
||||||
|
OR prev.expires_at <= now() THEN 'CREATE_PREVIEW'
|
||||||
|
WHEN pub.status IS DISTINCT FROM 'PUBLISHED'
|
||||||
|
OR pub.published_version <> s.version THEN 'PUBLISH'
|
||||||
|
ELSE 'NONE'
|
||||||
|
END AS next_action
|
||||||
|
FROM studio_source s
|
||||||
|
JOIN studio_dependency dep ON dep.id = s.id
|
||||||
|
LEFT JOIN project pr ON pr.id = s.project_id
|
||||||
|
LEFT JOIN studio_latest_validation val ON val.source_id = s.id
|
||||||
|
LEFT JOIN studio_latest_preview prev ON prev.source_id = s.id
|
||||||
|
LEFT JOIN publication pub ON pub.source_id = s.id AND pub.source_kind = s.kind
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
.formatted(RENDERER_CONTRACT_VERSION);
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetUsageView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code asset} 메타데이터 접근.
|
||||||
|
*
|
||||||
|
* <p>{@code usageCount} 는 {@code asset_reference} 에서 센다 — Asset 행에 캐시해 두면 참조가 바뀔 때마다 두 곳을 맞춰야 하고,
|
||||||
|
* 어긋나면 "쓰이고 있는데 삭제 가능"으로 보인다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcAssetRepositoryAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AssetPageView list(ListAssetsQuery query) {
|
||||||
|
StringBuilder sql = new StringBuilder(selectColumns() + " WHERE 1 = 1");
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
if (query.kind() != null) {
|
||||||
|
sql.append(" AND a.asset_kind = :kind");
|
||||||
|
params.put("kind", query.kind().name());
|
||||||
|
}
|
||||||
|
if (query.managementStatus() != null) {
|
||||||
|
sql.append(" AND a.management_status = :status");
|
||||||
|
params.put("status", query.managementStatus().name());
|
||||||
|
}
|
||||||
|
if (query.query() != null && !query.query().isBlank()) {
|
||||||
|
sql.append(" AND (lower(a.asset_key) LIKE :pattern OR lower(a.original_name) LIKE :pattern)");
|
||||||
|
params.put("pattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
|
||||||
|
}
|
||||||
|
if (query.beforeCreatedAt() != null && query.beforeId() != null) {
|
||||||
|
sql.append(" AND (a.created_at, a.id) < (:before, :beforeId)");
|
||||||
|
params.put("before", Timestamp.from(query.beforeCreatedAt()));
|
||||||
|
params.put("beforeId", query.beforeId());
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY a.created_at DESC, a.id DESC LIMIT :limitPlusOne");
|
||||||
|
params.put("limitPlusOne", query.limit() + 1);
|
||||||
|
|
||||||
|
var spec = jdbcClient.sql(sql.toString());
|
||||||
|
for (Map.Entry<String, Object> param : params.entrySet()) {
|
||||||
|
spec = spec.param(param.getKey(), param.getValue());
|
||||||
|
}
|
||||||
|
List<AssetView> rows = spec.query(JdbcAssetRepositoryAdapter::mapAsset).list();
|
||||||
|
|
||||||
|
boolean hasMore = rows.size() > query.limit();
|
||||||
|
List<AssetView> page = hasMore ? rows.subList(0, query.limit()) : rows;
|
||||||
|
String nextCursor = hasMore ? page.getLast().createdAt() + "|" + page.getLast().id() : null;
|
||||||
|
return new AssetPageView(page, nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AssetView> find(UUID assetId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(selectColumns() + " WHERE a.id = :id")
|
||||||
|
.param("id", assetId)
|
||||||
|
.query(JdbcAssetRepositoryAdapter::mapAsset)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AssetDetailView> findDetail(UUID assetId) {
|
||||||
|
return find(assetId)
|
||||||
|
.map(
|
||||||
|
asset -> new AssetDetailView(asset, usagesOf(assetId), hasPublicationHistory(assetId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AssetView create(NewAsset asset, String principal) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO asset (id, asset_key, asset_kind, management_status, object_key,"
|
||||||
|
+ " original_name, display_name, content_type, size_bytes, width, height,"
|
||||||
|
+ " checksum_sha256, alt_text, decorative, version, created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :assetKey, :kind, :status, :objectKey, :originalName,"
|
||||||
|
+ " :originalName, :contentType, :size, :width, :height, :checksum, :altText,"
|
||||||
|
// 계약의 Asset.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면 생성 직후
|
||||||
|
// 응답이 계약을 위반하고, 클라이언트가 보내는 expectedVersion 도 맞출 수 없다.
|
||||||
|
+ " :decorative, 1, :principal, :principal)")
|
||||||
|
.param("id", asset.id())
|
||||||
|
.param("assetKey", asset.assetKey())
|
||||||
|
.param("kind", asset.kind().name())
|
||||||
|
.param("status", asset.managementStatus().name())
|
||||||
|
.param("objectKey", asset.objectKey())
|
||||||
|
.param("originalName", asset.originalFilename())
|
||||||
|
.param("contentType", asset.mediaType())
|
||||||
|
.param("size", asset.byteSize())
|
||||||
|
.param("width", asset.width())
|
||||||
|
.param("height", asset.height())
|
||||||
|
.param("checksum", asset.checksumSha256())
|
||||||
|
.param("altText", asset.altText())
|
||||||
|
.param("decorative", asset.decorative())
|
||||||
|
.param("principal", principal)
|
||||||
|
.update();
|
||||||
|
return find(asset.id()).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AssetView> update(
|
||||||
|
UUID assetId,
|
||||||
|
long expectedVersion,
|
||||||
|
AssetKindView kind,
|
||||||
|
String altText,
|
||||||
|
boolean altTextProvided,
|
||||||
|
Boolean decorative,
|
||||||
|
AssetManagementStatusView managementStatus,
|
||||||
|
String principal) {
|
||||||
|
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE asset SET"
|
||||||
|
// 보내지 않은 필드는 그대로 둔다 — PUT 이지만 계약의 UpdateAssetCommand 는
|
||||||
|
// expectedVersion 외 전부 optional 이라 부분 갱신 의미다.
|
||||||
|
+ " asset_kind = COALESCE(:kind, asset_kind),"
|
||||||
|
+ " alt_text = CASE WHEN :altTextProvided THEN :altText ELSE alt_text END,"
|
||||||
|
+ " decorative = COALESCE(:decorative, decorative),"
|
||||||
|
+ " management_status = COALESCE(:status, management_status),"
|
||||||
|
+ " version = version + 1, updated_at = now(), updated_by = :principal"
|
||||||
|
+ " WHERE id = :id AND version = :expectedVersion")
|
||||||
|
.param("kind", kind == null ? null : kind.name())
|
||||||
|
.param("altTextProvided", altTextProvided)
|
||||||
|
.param("altText", altText)
|
||||||
|
.param("decorative", decorative)
|
||||||
|
.param("status", managementStatus == null ? null : managementStatus.name())
|
||||||
|
.param("principal", principal)
|
||||||
|
.param("id", assetId)
|
||||||
|
.param("expectedVersion", expectedVersion)
|
||||||
|
.update();
|
||||||
|
return updated == 0 ? Optional.empty() : find(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<String> findObjectKey(UUID assetId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql("SELECT object_key FROM asset WHERE id = :id")
|
||||||
|
.param("id", assetId)
|
||||||
|
.query(String.class)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(UUID assetId) {
|
||||||
|
jdbcClient.sql("DELETE FROM asset WHERE id = :id").param("id", assetId).update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String selectColumns() {
|
||||||
|
return "SELECT a.id, a.asset_key, a.asset_kind, a.management_status, a.original_name,"
|
||||||
|
+ " a.content_type, a.size_bytes, a.width, a.height, a.alt_text, a.decorative,"
|
||||||
|
+ " a.version, a.created_at, a.updated_at, a.first_published_at,"
|
||||||
|
+ " (SELECT count(*) FROM asset_reference r WHERE r.asset_id = a.id) AS usage_count"
|
||||||
|
+ " FROM asset a";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AssetView mapAsset(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new AssetView(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("asset_key"),
|
||||||
|
AssetKindView.valueOf(rs.getString("asset_kind")),
|
||||||
|
rs.getString("content_type"),
|
||||||
|
rs.getString("original_name"),
|
||||||
|
rs.getLong("size_bytes"),
|
||||||
|
(Integer) rs.getObject("width"),
|
||||||
|
(Integer) rs.getObject("height"),
|
||||||
|
rs.getString("alt_text"),
|
||||||
|
rs.getBoolean("decorative"),
|
||||||
|
AssetManagementStatusView.valueOf(rs.getString("management_status")),
|
||||||
|
// 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다.
|
||||||
|
"/api/v1/public/media/" + rs.getString("id"),
|
||||||
|
rs.getInt("usage_count"),
|
||||||
|
rs.getLong("version"),
|
||||||
|
rs.getTimestamp("created_at").toInstant(),
|
||||||
|
rs.getTimestamp("updated_at").toInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AssetUsageView> usagesOf(UUID assetId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT r.owner_id, r.owner_type, r.reference_scope,"
|
||||||
|
+ " COALESCE(d.title, q.question, pd.title) AS title,"
|
||||||
|
+ " COALESCE(d.document_type, 'QUESTION') AS document_kind"
|
||||||
|
+ " FROM asset_reference r"
|
||||||
|
+ " LEFT JOIN document d ON d.id = r.owner_id"
|
||||||
|
+ " LEFT JOIN open_question q ON q.id = r.owner_id"
|
||||||
|
+ " LEFT JOIN project_decision pd ON pd.id = r.owner_id"
|
||||||
|
+ " WHERE r.asset_id = :id")
|
||||||
|
.param("id", assetId)
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) ->
|
||||||
|
new AssetUsageView(
|
||||||
|
rs.getObject("owner_id", UUID.class),
|
||||||
|
kindOf(rs.getString("owner_type"), rs.getString("document_kind")),
|
||||||
|
rs.getString("title") == null ? "(제목 없음)" : rs.getString("title"),
|
||||||
|
"PUBLISHED".equals(rs.getString("reference_scope"))))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
|
||||||
|
private static RecordKind kindOf(String ownerType, String documentKind) {
|
||||||
|
return switch (ownerType) {
|
||||||
|
case "DOCUMENT" -> RecordKind.valueOf(documentKind);
|
||||||
|
case "DECISION" -> RecordKind.PROJECT_DECISION;
|
||||||
|
default -> RecordKind.QUESTION;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 한 번이라도 공개된 적이 있으면 hard delete 를 금지한다(계약 {@code AssetDetail} 설명). */
|
||||||
|
private boolean hasPublicationHistory(UUID assetId) {
|
||||||
|
return Boolean.TRUE.equals(
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT (first_published_at IS NOT NULL"
|
||||||
|
+ " OR EXISTS (SELECT 1 FROM asset_reference r"
|
||||||
|
+ " WHERE r.asset_id = :id AND r.reference_scope = 'PUBLISHED'))"
|
||||||
|
+ " FROM asset WHERE id = :id")
|
||||||
|
.param("id", assetId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.optional()
|
||||||
|
.orElse(Boolean.FALSE));
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
|
||||||
|
|
||||||
|
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentRowMapper;
|
||||||
|
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentSql;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationActionView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
|
||||||
|
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시 이력 조회. 이력은 항상 최신순이며 정렬 선택지가 없다 — 계약에도 정렬 파라미터가 없다.
|
||||||
|
*
|
||||||
|
* <p>목록의 {@code document} 요약은 {@link StudioDocumentSql} 의 같은 정의에서 가져온다. 여기서 따로 만들면 목록 화면과 이력 화면의
|
||||||
|
* {@code nextAction} 이 갈라진다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcPublicationHistoryQueryAdapter implements PublicationHistoryQueryPort {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public JdbcPublicationHistoryQueryAdapter(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicationPageView list(ListPublicationsQuery query) {
|
||||||
|
StringBuilder sql =
|
||||||
|
new StringBuilder(
|
||||||
|
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
|
||||||
|
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
|
||||||
|
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available,"
|
||||||
|
+ " p.status, p.publication_revision, p.latest_event_id, p.public_path,"
|
||||||
|
+ " p.updated_at AS publication_updated_at, p.published_version AS current_version"
|
||||||
|
+ " FROM publication_event e"
|
||||||
|
+ " JOIN publication p ON p.publication_id = e.publication_id"
|
||||||
|
+ " LEFT JOIN publication_snapshot s"
|
||||||
|
+ " ON s.publication_event_id = e.publication_event_id"
|
||||||
|
+ " WHERE 1 = 1");
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
if (query.type() != null) {
|
||||||
|
sql.append(" AND e.event_type = :type");
|
||||||
|
params.put("type", query.type().name());
|
||||||
|
}
|
||||||
|
if (query.beforeOccurredAt() != null && query.beforeEventId() != null) {
|
||||||
|
sql.append(" AND (e.occurred_at, e.publication_event_id) < (:before, :beforeId)");
|
||||||
|
params.put("before", Timestamp.from(query.beforeOccurredAt()));
|
||||||
|
params.put("beforeId", query.beforeEventId());
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY e.occurred_at DESC, e.publication_event_id DESC LIMIT :limitPlusOne");
|
||||||
|
params.put("limitPlusOne", query.limit() + 1);
|
||||||
|
|
||||||
|
var spec = jdbcClient.sql(sql.toString());
|
||||||
|
for (Map.Entry<String, Object> param : params.entrySet()) {
|
||||||
|
spec = spec.param(param.getKey(), param.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
|
||||||
|
boolean hasMore = rows.size() > query.limit();
|
||||||
|
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
|
||||||
|
|
||||||
|
Map<UUID, DocumentSummaryView> summaries = summariesFor(page);
|
||||||
|
List<PublicationListItemView> items = new ArrayList<>(page.size());
|
||||||
|
for (Row row : page) {
|
||||||
|
items.add(
|
||||||
|
new PublicationListItemView(
|
||||||
|
row.event(),
|
||||||
|
row.publication(),
|
||||||
|
summaries.get(row.event().documentId()),
|
||||||
|
actionsFor(row)));
|
||||||
|
}
|
||||||
|
String nextCursor =
|
||||||
|
hasMore
|
||||||
|
? page.getLast().event().occurredAt()
|
||||||
|
+ "|"
|
||||||
|
+ page.getLast().event().publicationEventId()
|
||||||
|
: null;
|
||||||
|
return new PublicationPageView(items, nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<PublicationSnapshotView> findSnapshot(UUID publicationEventId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
|
||||||
|
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
|
||||||
|
+ " true AS snapshot_available,"
|
||||||
|
+ " s.render_model, s.content_format_version, s.renderer_contract_version"
|
||||||
|
+ " FROM publication_snapshot s"
|
||||||
|
+ " JOIN publication_event e"
|
||||||
|
+ " ON e.publication_event_id = s.publication_event_id"
|
||||||
|
+ " WHERE s.publication_event_id = :id")
|
||||||
|
.param("id", publicationEventId)
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) ->
|
||||||
|
new PublicationSnapshotView(
|
||||||
|
PublicationRowMappers.mapEvent(rs, rowNum),
|
||||||
|
rs.getString("render_model"),
|
||||||
|
rs.getString("content_format_version"),
|
||||||
|
rs.getString("renderer_contract_version")))
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<PublicationAggregateView> findById(UUID publicationId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT publication_id, source_id, status, published_version, publication_revision,"
|
||||||
|
+ " latest_event_id, public_path, updated_at FROM publication"
|
||||||
|
+ " WHERE publication_id = :id")
|
||||||
|
.param("id", publicationId)
|
||||||
|
.query(JdbcPublicationWriterAdapter::mapAggregate)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<UUID, DocumentSummaryView> summariesFor(List<Row> rows) {
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
List<UUID> ids = rows.stream().map(row -> row.event().documentId()).distinct().toList();
|
||||||
|
Map<UUID, DocumentSummaryView> summaries = new HashMap<>();
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
StudioDocumentSql.documentProjectionCte()
|
||||||
|
+ " SELECT * FROM studio_document WHERE id IN (:ids)")
|
||||||
|
.param("ids", ids)
|
||||||
|
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
|
||||||
|
.list()
|
||||||
|
.forEach(summary -> summaries.put(summary.id(), summary));
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계약 {@code PublicationListItem.availableActions}. 지금 상태에서 실제로 할 수 있는 것만 담는다 — 화면이 눌러도 실패할 버튼을
|
||||||
|
* 그리지 않게 하려는 값이다.
|
||||||
|
*/
|
||||||
|
private static List<PublicationActionView> actionsFor(Row row) {
|
||||||
|
List<PublicationActionView> actions = new ArrayList<>();
|
||||||
|
if (row.event().snapshotAvailable()) {
|
||||||
|
actions.add(PublicationActionView.VIEW_SNAPSHOT);
|
||||||
|
}
|
||||||
|
if (row.event().sourcePublishedEventId() != null) {
|
||||||
|
actions.add(PublicationActionView.VIEW_SOURCE_SNAPSHOT);
|
||||||
|
}
|
||||||
|
if (row.publication().status() == PublicationAggregateStatus.PUBLISHED
|
||||||
|
&& row.publication().latestEventId().equals(row.event().publicationEventId())) {
|
||||||
|
actions.add(PublicationActionView.UNPUBLISH);
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Row(PublicationEventView event, PublicationAggregateView publication) {}
|
||||||
|
|
||||||
|
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||||
|
PublicationEventView event =
|
||||||
|
new PublicationEventView(
|
||||||
|
rs.getObject("publication_event_id", UUID.class),
|
||||||
|
rs.getObject("publication_id", UUID.class),
|
||||||
|
rs.getObject("source_id", UUID.class),
|
||||||
|
PublicationEventTypeView.valueOf(rs.getString("event_type")),
|
||||||
|
rs.getTimestamp("occurred_at").toInstant(),
|
||||||
|
rs.getLong("published_version"),
|
||||||
|
rs.getObject("source_published_event_id", UUID.class),
|
||||||
|
rs.getBoolean("snapshot_available"));
|
||||||
|
PublicationAggregateView publication =
|
||||||
|
new PublicationAggregateView(
|
||||||
|
rs.getObject("publication_id", UUID.class),
|
||||||
|
rs.getObject("source_id", UUID.class),
|
||||||
|
PublicationAggregateStatus.valueOf(rs.getString("status")),
|
||||||
|
rs.getLong("current_version"),
|
||||||
|
rs.getLong("publication_revision"),
|
||||||
|
rs.getObject("latest_event_id", UUID.class),
|
||||||
|
rs.getString("public_path"),
|
||||||
|
rs.getTimestamp("publication_updated_at").toInstant());
|
||||||
|
return new Row(event, publication);
|
||||||
|
}
|
||||||
|
}
|
||||||
+561
@@ -0,0 +1,561 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioError;
|
||||||
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
|
||||||
|
import dev.caskeleton.shared.error.MappingException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import tools.jackson.core.JacksonException;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
import tools.jackson.databind.node.ArrayNode;
|
||||||
|
import tools.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* spec §7.5 의 게시 트랜잭션 10~19단계.
|
||||||
|
*
|
||||||
|
* <p>{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 는 서로를 가리킨다. 첫
|
||||||
|
* 게시는 publication INSERT → event INSERT → publication UPDATE 순서로 한 트랜잭션 안에서 끝나며, 그 순환을 허용하는 것이 V7 의
|
||||||
|
* {@code DEFERRABLE INITIALLY DEFERRED} 다. 즉시 검사로 바꾸면 첫 게시가 구조적으로 불가능해진다.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
|
||||||
|
|
||||||
|
/** 공개 projection payload 의 스키마 버전. 모양이 바뀌면 올린다. */
|
||||||
|
private static final short PAYLOAD_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Supplier<UUID> idGenerator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
|
||||||
|
* 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
public JdbcPublicationWriterAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
|
||||||
|
this(jdbcClient, objectMapper, UUID::randomUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
JdbcPublicationWriterAdapter(
|
||||||
|
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.idGenerator = idGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<PublicationAggregateView> lockCurrentPublication(
|
||||||
|
RecordKind kind, UUID documentId) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT publication_id, source_id, status, published_version, publication_revision,"
|
||||||
|
+ " latest_event_id, public_path, updated_at FROM publication"
|
||||||
|
+ " WHERE source_kind = :kind AND source_id = :id FOR UPDATE")
|
||||||
|
.param("kind", kind.name())
|
||||||
|
.param("id", documentId)
|
||||||
|
.query(JdbcPublicationWriterAdapter::mapAggregate)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublishResultView publish(PublishRequest request) {
|
||||||
|
requireTransaction("publish");
|
||||||
|
Optional<PublicationAggregateView> existing =
|
||||||
|
lockCurrentPublication(request.kind(), request.documentId());
|
||||||
|
|
||||||
|
UUID publicationId =
|
||||||
|
existing.map(PublicationAggregateView::publicationId).orElseGet(idGenerator);
|
||||||
|
UUID eventId = idGenerator.get();
|
||||||
|
PublicationEventTypeView eventType =
|
||||||
|
existing.isEmpty()
|
||||||
|
? PublicationEventTypeView.PUBLISHED
|
||||||
|
: PublicationEventTypeView.REPUBLISHED;
|
||||||
|
|
||||||
|
if (existing.isEmpty()) {
|
||||||
|
// 10 이전: aggregate 를 먼저 만든다. latest_event_id 는 아직 없는 event 를 가리키지만
|
||||||
|
// 지연 검사라 커밋 시점에만 확인된다.
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO publication (publication_id, source_kind, source_id, status,"
|
||||||
|
+ " published_version, publication_revision, latest_event_id, public_path)"
|
||||||
|
+ " VALUES (:publicationId, :kind, :id, 'PUBLISHED', :version, 1, :eventId,"
|
||||||
|
+ " :publicPath)")
|
||||||
|
.param("publicationId", publicationId)
|
||||||
|
.param("kind", request.kind().name())
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.param("version", request.version())
|
||||||
|
.param("eventId", eventId)
|
||||||
|
.param("publicPath", request.publicPath())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Event
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
|
||||||
|
+ " source_id, event_type, published_version, occurred_at, idempotency_key,"
|
||||||
|
+ " created_by)"
|
||||||
|
+ " VALUES (:eventId, :publicationId, :kind, :id, :type, :version, now(),"
|
||||||
|
+ " :idempotencyKey, :principal)")
|
||||||
|
.param("eventId", eventId)
|
||||||
|
.param("publicationId", publicationId)
|
||||||
|
.param("kind", request.kind().name())
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.param("type", eventType.name())
|
||||||
|
.param("version", request.version())
|
||||||
|
.param("idempotencyKey", request.idempotencyKey())
|
||||||
|
.param("principal", request.principal())
|
||||||
|
.update();
|
||||||
|
|
||||||
|
// 11. Snapshot — 게시 시점의 렌더 모델을 그대로 고정한다. 다시 렌더링하지 않는다.
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO publication_snapshot (publication_event_id, render_model,"
|
||||||
|
+ " content_format_version, renderer_contract_version, asset_manifest)"
|
||||||
|
+ " VALUES (:eventId, CAST(:renderModel AS jsonb), :contentFormatVersion,"
|
||||||
|
+ " :rendererContractVersion, CAST(:assetManifest AS jsonb))")
|
||||||
|
.param("eventId", eventId)
|
||||||
|
.param("renderModel", request.renderModelJson())
|
||||||
|
.param("contentFormatVersion", request.contentFormatVersion())
|
||||||
|
.param("rendererContractVersion", request.rendererContractVersion())
|
||||||
|
.param("assetManifest", manifestJson(request.assetManifest()))
|
||||||
|
.update();
|
||||||
|
|
||||||
|
upsertProjection(request);
|
||||||
|
replaceRoute(request);
|
||||||
|
replaceProjectLink(request);
|
||||||
|
replacePublishedAssetReferences(request);
|
||||||
|
markAssetsFirstPublished(request);
|
||||||
|
|
||||||
|
// 17. aggregate 갱신
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE publication SET status = 'PUBLISHED', published_version = :version,"
|
||||||
|
+ " publication_revision = publication_revision + :bump,"
|
||||||
|
+ " latest_event_id = :eventId, public_path = :publicPath, updated_at = now()"
|
||||||
|
+ " WHERE publication_id = :publicationId")
|
||||||
|
.param("version", request.version())
|
||||||
|
// 첫 게시는 INSERT 가 이미 revision 1 을 넣었다. 여기서 또 올리면 클라이언트가 받은 값과
|
||||||
|
// 다음 unpublish 가 요구하는 값이 어긋난다.
|
||||||
|
.param("bump", existing.isEmpty() ? 0 : 1)
|
||||||
|
.param("eventId", eventId)
|
||||||
|
.param("publicPath", request.publicPath())
|
||||||
|
.param("publicationId", publicationId)
|
||||||
|
.update();
|
||||||
|
|
||||||
|
// 18. Document publish metadata
|
||||||
|
markSourcePublished(request);
|
||||||
|
|
||||||
|
// 19. 프로젝트 활동 로그
|
||||||
|
recordProjectActivity(request);
|
||||||
|
|
||||||
|
return result(publicationId, eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트 활동은 손으로 적는 것이 아니라 게시가 남기는 로그다.
|
||||||
|
*
|
||||||
|
* <p>한동안 이 줄을 Studio 에서 직접 써야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다.
|
||||||
|
* 게시가 곧 사건이므로 게시가 기록한다.
|
||||||
|
*
|
||||||
|
* <p>{@code operation_key} 로 문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로 타임라인에 다시 나타나지 않아야 한다
|
||||||
|
* — {@code uq_project_activity_operation_key} 가 그것을 보장하고, 여기서는 충돌을 무시한다.
|
||||||
|
*
|
||||||
|
* <p>프로젝트에 매달리지 않은 기록은 남길 자리가 없다. 그때는 아무것도 하지 않는다.
|
||||||
|
*/
|
||||||
|
private void recordProjectActivity(PublishRequest request) {
|
||||||
|
if (request.projectId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
|
||||||
|
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
|
||||||
|
+ " operation_key, created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :projectId, :type, :title, '', 'PUBLIC', 'AUTO',"
|
||||||
|
+ " :resourceType, :resourceId, now(), :operationKey, :actor, :actor)"
|
||||||
|
+ " ON CONFLICT (project_id, operation_key) DO NOTHING")
|
||||||
|
.param("id", idGenerator.get())
|
||||||
|
.param("projectId", request.projectId())
|
||||||
|
.param("type", activityTypeOf(request.kind()))
|
||||||
|
.param("title", request.title())
|
||||||
|
.param("resourceType", request.kind().name())
|
||||||
|
.param("resourceId", request.documentId())
|
||||||
|
.param("operationKey", "publication:" + request.documentId())
|
||||||
|
.param("actor", request.principal())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code project_activity_activity_type_check} 가 허용하는 값으로 옮긴다. */
|
||||||
|
private static String activityTypeOf(RecordKind kind) {
|
||||||
|
return switch (kind) {
|
||||||
|
case CASE -> "CASE_PUBLISHED";
|
||||||
|
case REFERENCE -> "REFERENCE_PUBLISHED";
|
||||||
|
case QUESTION -> "QUESTION_OPENED";
|
||||||
|
case PROJECT_DECISION -> "DECISION_ACCEPTED";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublishResultView unpublish(UnpublishRequest request) {
|
||||||
|
requireTransaction("unpublish");
|
||||||
|
UUID lastPublishedEventId =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT publication_event_id FROM publication_event"
|
||||||
|
+ " WHERE publication_id = :publicationId"
|
||||||
|
+ " AND event_type IN ('PUBLISHED', 'REPUBLISHED')"
|
||||||
|
+ " ORDER BY occurred_at DESC LIMIT 1")
|
||||||
|
.param("publicationId", request.publicationId())
|
||||||
|
.query(UUID.class)
|
||||||
|
.optional()
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
StudioException.of(
|
||||||
|
StudioError.PUBLICATION_CONFLICT,
|
||||||
|
"this publication has no published event to withdraw"));
|
||||||
|
|
||||||
|
UUID eventId = idGenerator.get();
|
||||||
|
// UNPUBLISHED Event 는 자체 snapshot 을 만들지 않고 마지막 공개 Snapshot 을 참조한다(V7 주석).
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
|
||||||
|
+ " source_id, event_type, published_version, source_published_event_id,"
|
||||||
|
+ " occurred_at, created_by)"
|
||||||
|
+ " SELECT :eventId, p.publication_id, p.source_kind, p.source_id, 'UNPUBLISHED',"
|
||||||
|
+ " p.published_version, :sourceEventId, now(), :principal"
|
||||||
|
+ " FROM publication p WHERE p.publication_id = :publicationId")
|
||||||
|
.param("eventId", eventId)
|
||||||
|
.param("sourceEventId", lastPublishedEventId)
|
||||||
|
.param("principal", request.principal())
|
||||||
|
.param("publicationId", request.publicationId())
|
||||||
|
.update();
|
||||||
|
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE publication SET status = 'UNPUBLISHED',"
|
||||||
|
+ " publication_revision = publication_revision + 1,"
|
||||||
|
+ " latest_event_id = :eventId, updated_at = now()"
|
||||||
|
+ " WHERE publication_id = :publicationId"
|
||||||
|
+ " AND publication_revision = :expectedRevision")
|
||||||
|
.param("eventId", eventId)
|
||||||
|
.param("publicationId", request.publicationId())
|
||||||
|
.param("expectedRevision", request.expectedRevision())
|
||||||
|
.update();
|
||||||
|
if (updated == 0) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.PUBLICATION_CONFLICT,
|
||||||
|
"the publication revision changed while withdrawing it");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projection ACTIVE -> WITHDRAWN. route 는 유지한다 — 주소가 사라지면 링크가 끊긴다.
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
|
||||||
|
+ " updated_at = now() WHERE resource_type = :type AND resource_id = :id")
|
||||||
|
.param("type", request.kind().name())
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.update();
|
||||||
|
|
||||||
|
// Working copy 는 다시 초안으로 돌아간다.
|
||||||
|
if (request.kind() == RecordKind.CASE || request.kind() == RecordKind.REFERENCE) {
|
||||||
|
jdbcClient
|
||||||
|
.sql("UPDATE document SET workflow_status = 'DRAFT' WHERE id = :id")
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
return result(request.publicationId(), eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 12. 공개 projection upsert. */
|
||||||
|
private void upsertProjection(PublishRequest request) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
|
||||||
|
+ " publication_state, visibility, title, summary, state_code, primary_topic_id,"
|
||||||
|
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
|
||||||
|
+ " published_at, updated_at, navigation_path)"
|
||||||
|
+ " VALUES (:type, :id, :version, 'ACTIVE', 'PUBLIC', :title, :summary,"
|
||||||
|
+ " :stateCode, :topicId, :schemaVersion, CAST(:payload AS jsonb), :bodyPlainText,"
|
||||||
|
+ " :searchText, :contentHash, now(), now(), :navigationPath)"
|
||||||
|
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
|
||||||
|
+ " source_version = EXCLUDED.source_version,"
|
||||||
|
+ " publication_state = 'ACTIVE',"
|
||||||
|
+ " visibility = EXCLUDED.visibility,"
|
||||||
|
+ " title = EXCLUDED.title,"
|
||||||
|
+ " summary = EXCLUDED.summary,"
|
||||||
|
+ " state_code = EXCLUDED.state_code,"
|
||||||
|
+ " primary_topic_id = EXCLUDED.primary_topic_id,"
|
||||||
|
+ " payload = EXCLUDED.payload,"
|
||||||
|
+ " body_plain_text = EXCLUDED.body_plain_text,"
|
||||||
|
+ " search_text = EXCLUDED.search_text,"
|
||||||
|
+ " content_hash = EXCLUDED.content_hash,"
|
||||||
|
+ " updated_at = now(),"
|
||||||
|
+ " navigation_path = EXCLUDED.navigation_path")
|
||||||
|
.param("type", request.kind().name())
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.param("version", request.version())
|
||||||
|
.param("title", request.title())
|
||||||
|
.param("summary", request.summary())
|
||||||
|
.param("stateCode", request.stateCode())
|
||||||
|
.param("topicId", request.topicId())
|
||||||
|
.param("schemaVersion", PAYLOAD_SCHEMA_VERSION)
|
||||||
|
.param("payload", request.renderModelJson())
|
||||||
|
.param("bodyPlainText", request.bodyPlainText())
|
||||||
|
.param(
|
||||||
|
"searchText",
|
||||||
|
String.join(
|
||||||
|
" ",
|
||||||
|
nullToEmpty(request.title()),
|
||||||
|
nullToEmpty(request.summary()),
|
||||||
|
nullToEmpty(request.bodyPlainText())))
|
||||||
|
.param("contentHash", sha256(request.renderModelJson()))
|
||||||
|
.param("navigationPath", request.publicPath())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 13. canonical route. 이전 slug 의 route 는 alias 로 남긴다 — 지우면 공개된 링크가 끊긴다. */
|
||||||
|
private void replaceRoute(PublishRequest request) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE public_route SET route_role = 'ALIAS'"
|
||||||
|
+ " WHERE resource_type = :type AND resource_id = :id AND slug <> :slug")
|
||||||
|
.param("type", request.kind().name())
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.param("slug", slugOf(request.publicPath()))
|
||||||
|
.update();
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
|
||||||
|
+ " VALUES (:type, :slug, :id, 'CANONICAL')"
|
||||||
|
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
|
||||||
|
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
|
||||||
|
.param("type", request.kind().name())
|
||||||
|
.param("slug", slugOf(request.publicPath()))
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 14. 공개 projection 의 프로젝트 링크. Studio 는 PRIMARY 하나만 소유한다. */
|
||||||
|
private void replaceProjectLink(PublishRequest request) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"DELETE FROM public_resource_project_link"
|
||||||
|
+ " WHERE resource_type = :type AND resource_id = :id AND relation_type = 'PRIMARY'")
|
||||||
|
.param("type", request.kind().name())
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.update();
|
||||||
|
if (request.projectId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
|
||||||
|
+ " relation_type) VALUES (:type, :id, :projectId, 'PRIMARY')"
|
||||||
|
+ " ON CONFLICT (resource_type, resource_id, project_id)"
|
||||||
|
+ " DO UPDATE SET relation_type = 'PRIMARY'")
|
||||||
|
.param("type", request.kind().name())
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.param("projectId", request.projectId())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 15. PUBLISHED scope 의 asset_reference 교체. WORKING scope 는 건드리지 않는다. */
|
||||||
|
private void replacePublishedAssetReferences(PublishRequest request) {
|
||||||
|
String ownerType = ownerTypeOf(request.kind());
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"DELETE FROM asset_reference WHERE owner_type = :ownerType AND owner_id = :id"
|
||||||
|
+ " AND reference_scope = 'PUBLISHED'")
|
||||||
|
.param("ownerType", ownerType)
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.update();
|
||||||
|
for (AssetManifestEntry entry : request.assetManifest()) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO asset_reference (asset_id, owner_type, owner_id, reference_scope,"
|
||||||
|
+ " reference_role) VALUES (:assetId, :ownerType, :id, 'PUBLISHED', 'BODY')"
|
||||||
|
+ " ON CONFLICT DO NOTHING")
|
||||||
|
.param("assetId", entry.assetId())
|
||||||
|
.param("ownerType", ownerType)
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 16. 최초 공개 시각. 이미 값이 있으면 덮지 않는다 — "처음"은 한 번뿐이다. */
|
||||||
|
private void markAssetsFirstPublished(PublishRequest request) {
|
||||||
|
for (AssetManifestEntry entry : request.assetManifest()) {
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE asset SET first_published_at = now()"
|
||||||
|
+ " WHERE id = :assetId AND first_published_at IS NULL")
|
||||||
|
.param("assetId", entry.assetId())
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 18. source 쪽 게시 메타데이터. */
|
||||||
|
private void markSourcePublished(PublishRequest request) {
|
||||||
|
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 유형이 늘면 컴파일러가
|
||||||
|
// 여기서 막아 준다(문이면 조용히 아무것도 안 하고 지나간다).
|
||||||
|
int updated =
|
||||||
|
switch (request.kind()) {
|
||||||
|
case CASE, REFERENCE ->
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE document SET workflow_status = 'PUBLISHED',"
|
||||||
|
+ " first_published_at = COALESCE(first_published_at, now()),"
|
||||||
|
+ " last_published_at = now() WHERE id = :id")
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.update();
|
||||||
|
case QUESTION ->
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE open_question SET"
|
||||||
|
+ " first_published_at = COALESCE(first_published_at, now()),"
|
||||||
|
+ " last_published_at = now() WHERE id = :id")
|
||||||
|
.param("id", request.documentId())
|
||||||
|
.update();
|
||||||
|
// project_decision 에는 게시 시각 컬럼이 없다. 게시 사실은 publication 이 소유하므로
|
||||||
|
// 여기서 억지로 컬럼을 만들지 않는다.
|
||||||
|
case PROJECT_DECISION -> 0;
|
||||||
|
};
|
||||||
|
if (updated == 0 && request.kind() != RecordKind.PROJECT_DECISION) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.DOCUMENT_NOT_FOUND,
|
||||||
|
"the source record disappeared while publishing " + request.documentId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublishResultView result(UUID publicationId, UUID eventId) {
|
||||||
|
PublicationAggregateView aggregate =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT publication_id, source_id, status, published_version,"
|
||||||
|
+ " publication_revision, latest_event_id, public_path, updated_at"
|
||||||
|
+ " FROM publication WHERE publication_id = :id")
|
||||||
|
.param("id", publicationId)
|
||||||
|
.query(JdbcPublicationWriterAdapter::mapAggregate)
|
||||||
|
.single();
|
||||||
|
PublicationEventView event =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
|
||||||
|
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
|
||||||
|
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available"
|
||||||
|
+ " FROM publication_event e"
|
||||||
|
+ " LEFT JOIN publication_snapshot s"
|
||||||
|
+ " ON s.publication_event_id = e.publication_event_id"
|
||||||
|
+ " WHERE e.publication_event_id = :id")
|
||||||
|
.param("id", eventId)
|
||||||
|
.query(PublicationRowMappers::mapEvent)
|
||||||
|
.single();
|
||||||
|
return new PublishResultView(aggregate, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이 어댑터는 <b>열린 트랜잭션 안에서만</b> 올바르게 동작한다.
|
||||||
|
*
|
||||||
|
* <p>{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 가 서로를 가리키고, 그
|
||||||
|
* 순환은 {@code fk_publication_latest_event} 의 {@code DEFERRABLE INITIALLY DEFERRED} 로만 성립한다. 지연 검사는
|
||||||
|
* <b>트랜잭션 끝</b>에 일어나므로, autocommit 이면 각 구문이 곧 트랜잭션이라 첫 INSERT 에서 바로 위반이 된다.
|
||||||
|
*
|
||||||
|
* <p>이 사실을 주석으로만 남기면 트랜잭션 없이 호출한 코드가 "외래 키 위반"이라는, 원인과 한참 떨어진 오류를 만난다. 통합 테스트를 처음 돌렸을 때 실제로 그렇게
|
||||||
|
* 실패했다. 그래서 전제를 여기서 확인하고 무엇이 잘못됐는지 그대로 말한다.
|
||||||
|
*/
|
||||||
|
private static void requireTransaction(String operation) {
|
||||||
|
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"publication "
|
||||||
|
+ operation
|
||||||
|
+ " must run inside an active transaction: publication and publication_event"
|
||||||
|
+ " reference each other, and that cycle only resolves at commit through"
|
||||||
|
+ " fk_publication_latest_event's deferred check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static PublicationAggregateView mapAggregate(java.sql.ResultSet rs, int rowNum)
|
||||||
|
throws java.sql.SQLException {
|
||||||
|
return new PublicationAggregateView(
|
||||||
|
rs.getObject("publication_id", UUID.class),
|
||||||
|
rs.getObject("source_id", UUID.class),
|
||||||
|
PublicationAggregateStatus.valueOf(rs.getString("status")),
|
||||||
|
rs.getLong("published_version"),
|
||||||
|
rs.getLong("publication_revision"),
|
||||||
|
rs.getObject("latest_event_id", UUID.class),
|
||||||
|
rs.getString("public_path"),
|
||||||
|
rs.getTimestamp("updated_at").toInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String manifestJson(List<AssetManifestEntry> manifest) {
|
||||||
|
ArrayNode array = objectMapper.createArrayNode();
|
||||||
|
for (AssetManifestEntry entry : manifest) {
|
||||||
|
ObjectNode node = array.addObject();
|
||||||
|
node.put("assetId", entry.assetId() == null ? null : entry.assetId().toString());
|
||||||
|
node.put("assetKey", entry.assetKey());
|
||||||
|
node.put("mediaType", entry.mediaType());
|
||||||
|
node.put("publicPath", entry.publicPath());
|
||||||
|
node.put("width", entry.width());
|
||||||
|
node.put("height", entry.height());
|
||||||
|
node.put("decorative", entry.decorative());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(array);
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new MappingException("failed to serialise a publication asset manifest", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
|
||||||
|
private static String ownerTypeOf(RecordKind kind) {
|
||||||
|
return switch (kind) {
|
||||||
|
case CASE, REFERENCE -> "DOCUMENT";
|
||||||
|
case QUESTION -> "QUESTION";
|
||||||
|
case PROJECT_DECISION -> "DECISION";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code public_route.slug} 는 경로가 아니라 마지막 조각이다. */
|
||||||
|
private static String slugOf(String publicPath) {
|
||||||
|
if (publicPath == null || publicPath.isBlank()) {
|
||||||
|
throw StudioException.of(
|
||||||
|
StudioError.DOCUMENT_VALIDATION_FAILED, "a published record needs a public path");
|
||||||
|
}
|
||||||
|
return publicPath.substring(publicPath.lastIndexOf('/') + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha256(String value) {
|
||||||
|
try {
|
||||||
|
return HexFormat.of()
|
||||||
|
.formatHex(
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** {@code publication_event} 행 매핑. writer 와 조회 어댑터가 같은 모양을 쓰도록 한 곳에 둔다. */
|
||||||
|
final class PublicationRowMappers {
|
||||||
|
|
||||||
|
private PublicationRowMappers() {}
|
||||||
|
|
||||||
|
static PublicationEventView mapEvent(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new PublicationEventView(
|
||||||
|
rs.getObject("publication_event_id", UUID.class),
|
||||||
|
rs.getObject("publication_id", UUID.class),
|
||||||
|
rs.getObject("source_id", UUID.class),
|
||||||
|
PublicationEventTypeView.valueOf(rs.getString("event_type")),
|
||||||
|
rs.getTimestamp("occurred_at").toInstant(),
|
||||||
|
rs.getLong("published_version"),
|
||||||
|
rs.getObject("source_published_event_id", UUID.class),
|
||||||
|
rs.getBoolean("snapshot_available"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
|
||||||
|
|
||||||
|
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
|
||||||
|
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
|
||||||
|
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
|
||||||
|
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
|
||||||
|
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
|
||||||
|
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
|
||||||
|
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RecordKind;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.RelationView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
|
||||||
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code CASE} / {@code REFERENCE} 편집본. 둘 다 {@code document} 루트 + 유형별 detail 테이블이다 (ADR-003, 설계
|
||||||
|
* 07장).
|
||||||
|
*/
|
||||||
|
final class DocumentWorkingCopyStore {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
private final StudioRelationStore relations;
|
||||||
|
private final StudioJson json;
|
||||||
|
private final Supplier<UUID> idGenerator;
|
||||||
|
private final ProjectLinkStore projectLinks;
|
||||||
|
|
||||||
|
DocumentWorkingCopyStore(
|
||||||
|
JdbcClient jdbcClient,
|
||||||
|
StudioRelationStore relations,
|
||||||
|
StudioJson json,
|
||||||
|
Supplier<UUID> idGenerator,
|
||||||
|
ProjectLinkStore projectLinks) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
this.relations = relations;
|
||||||
|
this.json = json;
|
||||||
|
this.idGenerator = idGenerator;
|
||||||
|
this.projectLinks = projectLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<WorkingCopyView> find(RecordKind kind, UUID id) {
|
||||||
|
return jdbcClient
|
||||||
|
.sql(
|
||||||
|
"SELECT d.id, d.version, d.updated_at, d.title, d.slug, d.summary, d.primary_topic_id,"
|
||||||
|
+ " d.body_markdown, d.last_verified_at,"
|
||||||
|
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
|
||||||
|
+ " r.scope_summary, r.rules, r.applies_to, r.excluded_scope, r.examples"
|
||||||
|
+ " FROM document d"
|
||||||
|
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
|
||||||
|
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
|
||||||
|
+ " WHERE d.id = :id AND d.document_type = :type")
|
||||||
|
.param("id", id)
|
||||||
|
.param("type", kind.name())
|
||||||
|
.query(
|
||||||
|
(rs, rowNum) -> {
|
||||||
|
UUID documentId = rs.getObject("id", UUID.class);
|
||||||
|
WorkingCopyBaseInput base =
|
||||||
|
new WorkingCopyBaseInput(
|
||||||
|
kind,
|
||||||
|
rs.getString("title"),
|
||||||
|
slugFromColumn(rs.getString("slug")),
|
||||||
|
orEmpty(rs.getString("summary")),
|
||||||
|
rs.getObject("primary_topic_id", UUID.class),
|
||||||
|
projectLinks.findPrimaryProjectForDocument(documentId).orElse(null),
|
||||||
|
relations.findBySource(kind, documentId));
|
||||||
|
long version = rs.getLong("version");
|
||||||
|
var updatedAt = instant(rs, "updated_at");
|
||||||
|
if (kind == RecordKind.CASE) {
|
||||||
|
return (WorkingCopyView)
|
||||||
|
new WorkingCopyView.CaseWorkingCopyView(
|
||||||
|
documentId,
|
||||||
|
version,
|
||||||
|
updatedAt,
|
||||||
|
base,
|
||||||
|
orEmpty(rs.getString("problem_summary")),
|
||||||
|
orEmpty(rs.getString("conclusion_summary")),
|
||||||
|
orEmpty(rs.getString("environment")),
|
||||||
|
orEmpty(rs.getString("reproduction")),
|
||||||
|
dateFromTimestamp(rs, "last_verified_at"),
|
||||||
|
orEmpty(rs.getString("body_markdown")));
|
||||||
|
}
|
||||||
|
return (WorkingCopyView)
|
||||||
|
new WorkingCopyView.ReferenceWorkingCopyView(
|
||||||
|
documentId,
|
||||||
|
version,
|
||||||
|
updatedAt,
|
||||||
|
base,
|
||||||
|
orEmpty(rs.getString("scope_summary")),
|
||||||
|
json.rulesFromJson(rs.getString("rules")),
|
||||||
|
json.orderedTextFromJson(rs.getString("applies_to")),
|
||||||
|
json.orderedTextFromJson(rs.getString("excluded_scope")),
|
||||||
|
json.orderedTextFromJson(rs.getString("examples")),
|
||||||
|
dateFromTimestamp(rs, "last_verified_at"));
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID create(WorkingCopyInputView input, String principal) {
|
||||||
|
RecordKind kind = input.kind();
|
||||||
|
UUID id = idGenerator.get();
|
||||||
|
WorkingCopyBaseInput base = input.base();
|
||||||
|
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO document (id, document_type, slug, title, summary, body_markdown,"
|
||||||
|
+ " primary_topic_id, last_verified_at, version, created_by, updated_by)"
|
||||||
|
+ " VALUES (:id, :type, :slug, :title, :summary, :body, :topicId, :verifiedAt,"
|
||||||
|
// 계약의 WorkingCopyBase.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면
|
||||||
|
// 생성 직후 응답이 계약을 위반한다.
|
||||||
|
+ " 1, :principal, :principal)")
|
||||||
|
.param("id", id)
|
||||||
|
.param("type", kind.name())
|
||||||
|
.param("slug", slugToColumn(base.slug()))
|
||||||
|
.param("title", orEmpty(base.title()))
|
||||||
|
.param("summary", orEmpty(base.summary()))
|
||||||
|
.param("body", bodyMarkdownOf(input))
|
||||||
|
.param("topicId", base.topicId())
|
||||||
|
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
|
||||||
|
.param("principal", principal)
|
||||||
|
.update();
|
||||||
|
|
||||||
|
insertDetail(id, input);
|
||||||
|
relations.replace(kind, id, base.relations());
|
||||||
|
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 낙관적 잠금 저장.
|
||||||
|
*
|
||||||
|
* @return 갱신된 행이 없으면(= {@code expectedVersion} 불일치) {@code false}
|
||||||
|
*/
|
||||||
|
boolean save(UUID id, long expectedVersion, WorkingCopyInputView input, String principal) {
|
||||||
|
RecordKind kind = input.kind();
|
||||||
|
WorkingCopyBaseInput base = input.base();
|
||||||
|
int updated =
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE document SET slug = :slug, title = :title, summary = :summary,"
|
||||||
|
+ " body_markdown = :body, primary_topic_id = :topicId,"
|
||||||
|
+ " last_verified_at = :verifiedAt, version = version + 1,"
|
||||||
|
+ " updated_at = now(), updated_by = :principal"
|
||||||
|
+ " WHERE id = :id AND document_type = :type AND version = :expectedVersion")
|
||||||
|
.param("slug", slugToColumn(base.slug()))
|
||||||
|
.param("title", orEmpty(base.title()))
|
||||||
|
.param("summary", orEmpty(base.summary()))
|
||||||
|
.param("body", bodyMarkdownOf(input))
|
||||||
|
.param("topicId", base.topicId())
|
||||||
|
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
|
||||||
|
.param("principal", principal)
|
||||||
|
.param("id", id)
|
||||||
|
.param("type", kind.name())
|
||||||
|
.param("expectedVersion", expectedVersion)
|
||||||
|
.update();
|
||||||
|
if (updated == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
updateDetail(id, input);
|
||||||
|
relations.replace(kind, id, base.relations());
|
||||||
|
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertDetail(UUID id, WorkingCopyInputView input) {
|
||||||
|
switch (input) {
|
||||||
|
case WorkingCopyInputView.CaseInputView caseInput ->
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO case_detail (document_id, document_type, problem_summary,"
|
||||||
|
+ " conclusion_summary, environment, reproduction)"
|
||||||
|
+ " VALUES (:id, 'CASE', :problem, :conclusion, :environment, :reproduction)")
|
||||||
|
.param("id", id)
|
||||||
|
.param("problem", orEmpty(caseInput.problem()))
|
||||||
|
.param("conclusion", orEmpty(caseInput.conclusion()))
|
||||||
|
.param("environment", orEmpty(caseInput.environment()))
|
||||||
|
.param("reproduction", orEmpty(caseInput.reproduction()))
|
||||||
|
.update();
|
||||||
|
case WorkingCopyInputView.ReferenceInputView reference ->
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"INSERT INTO reference_detail (document_id, document_type, scope_summary,"
|
||||||
|
+ " rules, applies_to, excluded_scope, examples)"
|
||||||
|
+ " VALUES (:id, 'REFERENCE', :purpose, CAST(:rules AS jsonb),"
|
||||||
|
+ " CAST(:applyWhen AS jsonb), CAST(:exceptions AS jsonb),"
|
||||||
|
+ " CAST(:examples AS jsonb))")
|
||||||
|
.param("id", id)
|
||||||
|
.param("purpose", orEmpty(reference.purpose()))
|
||||||
|
.param("rules", json.rulesToJson(reference.rules()))
|
||||||
|
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
|
||||||
|
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
|
||||||
|
.param("examples", json.orderedTextToJson(reference.examples()))
|
||||||
|
.update();
|
||||||
|
default ->
|
||||||
|
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDetail(UUID id, WorkingCopyInputView input) {
|
||||||
|
switch (input) {
|
||||||
|
case WorkingCopyInputView.CaseInputView caseInput ->
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE case_detail SET problem_summary = :problem,"
|
||||||
|
+ " conclusion_summary = :conclusion, environment = :environment,"
|
||||||
|
+ " reproduction = :reproduction WHERE document_id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.param("problem", orEmpty(caseInput.problem()))
|
||||||
|
.param("conclusion", orEmpty(caseInput.conclusion()))
|
||||||
|
.param("environment", orEmpty(caseInput.environment()))
|
||||||
|
.param("reproduction", orEmpty(caseInput.reproduction()))
|
||||||
|
.update();
|
||||||
|
case WorkingCopyInputView.ReferenceInputView reference ->
|
||||||
|
jdbcClient
|
||||||
|
.sql(
|
||||||
|
"UPDATE reference_detail SET scope_summary = :purpose,"
|
||||||
|
+ " rules = CAST(:rules AS jsonb), applies_to = CAST(:applyWhen AS jsonb),"
|
||||||
|
+ " excluded_scope = CAST(:exceptions AS jsonb),"
|
||||||
|
+ " examples = CAST(:examples AS jsonb) WHERE document_id = :id")
|
||||||
|
.param("id", id)
|
||||||
|
.param("purpose", orEmpty(reference.purpose()))
|
||||||
|
.param("rules", json.rulesToJson(reference.rules()))
|
||||||
|
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
|
||||||
|
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
|
||||||
|
.param("examples", json.orderedTextToJson(reference.examples()))
|
||||||
|
.update();
|
||||||
|
default ->
|
||||||
|
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code REFERENCE}는 계약에 본문이 없다 — 컬럼이 NOT NULL이므로 빈 문자열을 유지한다. */
|
||||||
|
private static String bodyMarkdownOf(WorkingCopyInputView input) {
|
||||||
|
return input instanceof WorkingCopyInputView.CaseInputView caseInput
|
||||||
|
? orEmpty(caseInput.bodyMarkdown())
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 계약의 {@code lastVerifiedOn}(CASE) / {@code verifiedOn}(REFERENCE)은 같은 컬럼에 담긴다. */
|
||||||
|
private static java.time.LocalDate verifiedOnOf(WorkingCopyInputView input) {
|
||||||
|
return switch (input) {
|
||||||
|
case WorkingCopyInputView.CaseInputView caseInput -> caseInput.lastVerifiedOn();
|
||||||
|
case WorkingCopyInputView.ReferenceInputView reference -> reference.verifiedOn();
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RelationView> relationsOf(RecordKind kind, UUID id) {
|
||||||
|
return relations.findBySource(kind, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user