Compare commits
| 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 |
@@ -112,6 +112,22 @@ env_keys:
|
||||
compatibility_impact: behavior-change
|
||||
required_test: env-contract:server-shutdown-timeout-aligned
|
||||
|
||||
- name: APP_SESSION_TIMEOUT
|
||||
# source: Studio 작성 세션. Spring 기본 30분 유휴 만료는 한 기록을 여러 번 저장하며
|
||||
# 오래 머무는 작성 리듬보다 짧아, 저장하지 않은 편집을 잃는 원인이 되었다.
|
||||
# 늘릴수록 훔친 session cookie 가 유효한 창도 같이 늘어나므로 Keycloak realm 의
|
||||
# SSO idle 과 함께 맞춘다.
|
||||
type: duration
|
||||
default: 8h
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: feature-env-driven-runtime-configuration
|
||||
validation: spring_duration_shorthand
|
||||
compatibility_impact: behavior-change
|
||||
required_test: env-contract:session-timeout
|
||||
|
||||
- name: APP_SERVER_FORWARD_HEADERS_STRATEGY
|
||||
# source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification
|
||||
# (trust X-Forwarded-* when behind LB/proxy)
|
||||
|
||||
@@ -960,7 +960,12 @@ errors:
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND)
|
||||
# source: studio-v1.yaml + studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND
|
||||
# (StudioError.DOCUMENT_NOT_FOUND, ManagementError.DOCUMENT_NOT_FOUND)
|
||||
#
|
||||
# 두 표면이 같은 코드를 쓴다. 이 레지스트리의 식별자는 code 하나뿐이므로 행도 하나다 —
|
||||
# 한때 표면마다 행을 두고 문구를 다르게 적었고("작업본을 찾을 수 없습니다"), 그 중복이
|
||||
# 레지스트리 로딩 자체를 깨뜨렸다. 같은 코드는 같은 말을 해야 한다.
|
||||
- code: DOCUMENT_NOT_FOUND
|
||||
category: NOT_FOUND
|
||||
http_status: 404
|
||||
@@ -1228,3 +1233,256 @@ errors:
|
||||
runbook_link: "runbook://studio/unavailable"
|
||||
compatibility_impact: additive
|
||||
required_test: StudioErrorTest
|
||||
|
||||
# === Tech Log Public (feature-techlog-public-v1) ===
|
||||
#
|
||||
# public-v1.yaml 의 ApiError.code 는 세 값이다. 나머지 하나 INTERNAL_ERROR 는 스켈레톤
|
||||
# 공통 코드로 이미 이 레지스트리에 있으므로 여기서 다시 선언하지 않는다.
|
||||
#
|
||||
# Studio 와 이름을 겹치지 않게 한 이유: 이 레지스트리는 코드 하나에 http_status 하나만
|
||||
# 담는다. public 의 400 과 studio 의 422 를 같은 이름으로 쓸 수 없다.
|
||||
|
||||
# source: public-v1.yaml ApiError.code — PUBLIC_REQUEST_INVALID (PublicError.PUBLIC_REQUEST_INVALID)
|
||||
- code: PUBLIC_REQUEST_INVALID
|
||||
category: VALIDATION
|
||||
http_status: 400
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-public-v1
|
||||
owner_layer: application
|
||||
client_safe_message: "요청 값이 올바르지 않습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: PublicErrorRegistryTest
|
||||
|
||||
# source: public-v1.yaml ApiError.code — PUBLIC_RESOURCE_NOT_FOUND (PublicError.PUBLIC_RESOURCE_NOT_FOUND)
|
||||
- code: PUBLIC_RESOURCE_NOT_FOUND
|
||||
category: NOT_FOUND
|
||||
http_status: 404
|
||||
retryable: false
|
||||
retry_after_seconds: null
|
||||
owner_branch: feature-techlog-public-v1
|
||||
owner_layer: application
|
||||
client_safe_message: "요청한 자료를 찾을 수 없습니다"
|
||||
log_level: INFO
|
||||
runbook_link: null
|
||||
compatibility_impact: additive
|
||||
required_test: PublicErrorRegistryTest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
|
||||
/api/healthcheck
|
||||
/api/v1/public/**
|
||||
|
||||
@@ -115,7 +115,7 @@ PRESENTATION_API_BASE_PATH=/api
|
||||
APP_SECURITY_AUTH_MODE=jwt
|
||||
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
|
||||
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
|
||||
SECURITY_PUBLIC_PATHS=/api/healthcheck
|
||||
SECURITY_PUBLIC_PATHS=/api/healthcheck, /api/v1/public/**
|
||||
APP_SESSION_COOKIE_NAME=CA_SESSION
|
||||
APP_SESSION_COOKIE_SECURE=true
|
||||
APP_SESSION_COOKIE_HTTP_ONLY=true
|
||||
|
||||
@@ -27,6 +27,16 @@ sourceSets {
|
||||
// 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다.
|
||||
// main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다.
|
||||
java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java'))
|
||||
// public-v1 도 같은 방식으로 model 만 생성한다. 별도 sourceSet 을 만들지 않는 이유는
|
||||
// 두 계약의 생성물이 같은 성질(생성 코드, 품질 게이트 제외 대상, jar/test 클래스패스에
|
||||
// 얹어야 함)을 갖기 때문이다 — sourceSet 을 늘리면 그 배선을 한 벌 더 복제하게 된다.
|
||||
java.srcDir(layout.buildDirectory.dir('generated/openapi-public/src/main/java'))
|
||||
java.srcDir(layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java'))
|
||||
// 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
|
||||
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
|
||||
@@ -164,18 +174,10 @@ ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codege
|
||||
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
|
||||
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
|
||||
|
||||
tasks.register('prepareStudioCodegenSpec') {
|
||||
description = '계약에서 discriminator union 배선을 파생시켜 생성기 입력을 만든다.'
|
||||
def specSource = file("${rootDir}/config/openapi/studio-v1.yaml")
|
||||
def specOut = studioCodegenSpecFile
|
||||
def ignoreOut = studioCodegenIgnoreFile
|
||||
def unionDir = studioUnionSrcDir
|
||||
def modelPackage = studioModelPackage
|
||||
inputs.file(specSource)
|
||||
outputs.file(specOut)
|
||||
outputs.file(ignoreOut)
|
||||
outputs.dir(unionDir)
|
||||
doLast {
|
||||
// 이 파생은 계약 두 벌(studio-v1, public-v1)에 똑같이 적용된다. 두 벌을 각자 복사해 두면
|
||||
// 한쪽만 고쳐지는 날이 오므로 클로저 하나로 두고 태스크가 인자만 바꿔 호출한다.
|
||||
ext.prepareTechLogCodegenSpec = { String label, File specSource, File specTarget,
|
||||
File ignoreTarget, File unionDir, String modelPackage ->
|
||||
def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8'))
|
||||
def schemas = doc.components.schemas
|
||||
|
||||
@@ -209,6 +211,32 @@ tasks.register('prepareStudioCodegenSpec') {
|
||||
}
|
||||
collapseStringOneOf(doc)
|
||||
|
||||
// boolean 프로퍼티의 `const` 를 코드젠 사본에서만 걷어낸다.
|
||||
//
|
||||
// 봉투의 success 는 계약상 `{type: boolean, const: true}` 다. 생성기는 이 문서를 검증
|
||||
// 경로 없이 읽으면 그 const 를 단일값 enum 으로 취급해 `enum SuccessEnum { TRUE("true") }`
|
||||
// 를 만드는데, 그 enum 의 필드 타입은 Boolean 이고 생성자에는 String 을 넘겨 컴파일이
|
||||
// 깨진다(실측). 검증 경로를 타는 studio 쪽에서는 같은 계약이 평범한 Boolean 으로 나온다 —
|
||||
// 즉 계약이 아니라 생성기의 경로 차이가 원인이다.
|
||||
//
|
||||
// 값이 하나로 고정된다는 사실은 소비자에게 의미가 있으므로 정본 계약에는 그대로 두고,
|
||||
// 여기서만 뗀다. 서버가 이 값을 잘못 넣을 위험은 없다 — 봉투는 EnvelopeBodyAdvice 가
|
||||
// 만들고 컨트롤러가 손대지 않는다.
|
||||
int[] consts = [0]
|
||||
def dropBooleanConst
|
||||
dropBooleanConst = { Object node ->
|
||||
if (node instanceof Map) {
|
||||
if (node.get('type') == 'boolean' && node.containsKey('const')) {
|
||||
node.remove('const')
|
||||
consts[0]++
|
||||
}
|
||||
new ArrayList(node.values()).each { dropBooleanConst(it) }
|
||||
} else if (node instanceof List) {
|
||||
node.each { dropBooleanConst(it) }
|
||||
}
|
||||
}
|
||||
dropBooleanConst(doc)
|
||||
|
||||
// (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면
|
||||
// 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가
|
||||
// DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌
|
||||
@@ -243,6 +271,47 @@ tasks.register('prepareStudioCodegenSpec') {
|
||||
}
|
||||
collapseNullableOneOf(doc)
|
||||
|
||||
// (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다.
|
||||
//
|
||||
// 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가
|
||||
// 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수
|
||||
// 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required
|
||||
// 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/
|
||||
// decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이
|
||||
// 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing").
|
||||
//
|
||||
// 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠
|
||||
// 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은
|
||||
// 그대로 남는다.
|
||||
int[] relaxed = [0]
|
||||
def relaxNullableRequired
|
||||
relaxNullableRequired = { Object node ->
|
||||
if (node instanceof Map) {
|
||||
def props = node.get('properties')
|
||||
def required = node.get('required')
|
||||
if (props instanceof Map && required instanceof List) {
|
||||
def drop = []
|
||||
props.each { Object name, Object schema ->
|
||||
if (!(schema instanceof Map)) return
|
||||
def type = schema.get('type')
|
||||
if (type instanceof List && type.contains('null') && required.contains(name)) {
|
||||
drop << name
|
||||
}
|
||||
}
|
||||
if (!drop.isEmpty()) {
|
||||
required.removeAll(drop)
|
||||
relaxed[0] += drop.size()
|
||||
if (required.isEmpty()) node.remove('required')
|
||||
}
|
||||
}
|
||||
new ArrayList(node.values()).each { relaxNullableRequired(it) }
|
||||
} else if (node instanceof List) {
|
||||
node.each { relaxNullableRequired(it) }
|
||||
}
|
||||
}
|
||||
relaxNullableRequired(doc)
|
||||
logger.lifecycle("${label}: nullable required 해제 ${relaxed[0]}건")
|
||||
|
||||
// (1) x-implements 주입 + union 목록 수집
|
||||
def unions = [:]
|
||||
schemas.each { String name, Object schema ->
|
||||
@@ -285,29 +354,68 @@ tasks.register('prepareStudioCodegenSpec') {
|
||||
}
|
||||
unions.put(name, [property: property, variants: variants])
|
||||
}
|
||||
if (unions.isEmpty()) {
|
||||
throw new GradleException('계약에서 discriminator union 을 하나도 찾지 못했다 — 파생 규칙이 깨졌다.')
|
||||
// 이 가드의 목적은 "union 이 있어야 한다"가 아니라 "계약에 있는 union 을 하나도 빠뜨리지
|
||||
// 않았다"이다. public-v1 처럼 union 이 애초에 없는 계약도 있으므로 개수를 계약에서 세어
|
||||
// 대조한다. 원래 studio 전용으로 "0개면 실패"로 썼다가 public-v1 에서 걸렸다.
|
||||
int declaredUnions = schemas.count { String name, Object schema ->
|
||||
schema instanceof Map && schema.get('oneOf') instanceof List &&
|
||||
schema.get('discriminator') instanceof Map
|
||||
}
|
||||
if (unions.size() != declaredUnions) {
|
||||
throw new GradleException(
|
||||
"계약의 discriminator union ${declaredUnions}개 중 ${unions.size()}개만 파생했다 — " +
|
||||
"파생 규칙이 계약을 따라가지 못한다.")
|
||||
}
|
||||
|
||||
// 파생 계약 쓰기
|
||||
//
|
||||
// deep copy 가 반드시 선행한다. 위 변환들이 같은 Map/List 인스턴스를 여러 위치에
|
||||
// 재사용하면 snakeyaml 이 그 지점을 YAML anchor/alias(&id001 / *id001)로 덤프한다.
|
||||
// swagger-parser 는 alias 노드를 해석하지 못해 그 스키마를
|
||||
// "is not of type `object`" 로 거부하고, validateSpec 을 끄면 generator 가 해당
|
||||
// property 를 **조용히 누락한 채** 모델을 만든다(publishedAt, matchedFields 등이
|
||||
// 실제로 사라졌다). 노드 identity 를 전부 끊어 alias 자체를 원천 차단한다.
|
||||
def deepCopy
|
||||
deepCopy = { Object node ->
|
||||
if (node instanceof Map) {
|
||||
def copy = new LinkedHashMap<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 = specOut.get().asFile
|
||||
def specFile = specTarget
|
||||
specFile.parentFile.mkdirs()
|
||||
specFile.setText(new org.yaml.snakeyaml.Yaml(dumperOptions).dump(doc), 'UTF-8')
|
||||
def rendered = new org.yaml.snakeyaml.Yaml(dumperOptions).dump(deepCopy(doc))
|
||||
|
||||
// fail-closed: alias 가 하나라도 남으면 생성물이 조용히 불완전해진다.
|
||||
def aliasLines = rendered.readLines().findAll { it =~ /(?:&|\*)id\d{3}\b/ }
|
||||
if (!aliasLines.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"${label}: 파생 계약에 YAML alias 가 남았다 — swagger-parser 가 해당 스키마를 " +
|
||||
"거부하고 property 가 조용히 누락된다. 위반 ${aliasLines.size()}줄, 예: " +
|
||||
aliasLines.take(3).join(' | '))
|
||||
}
|
||||
specFile.setText(rendered, 'UTF-8')
|
||||
|
||||
// union 클래스 생성 억제
|
||||
def ignoreFile = ignoreOut.get().asFile
|
||||
def ignoreFile = ignoreTarget
|
||||
ignoreFile.setText(
|
||||
(['# prepareStudioCodegenSpec 가 생성한다 — 손으로 고치지 않는다.',
|
||||
(["# ${label} 가 생성한다 — 손으로 고치지 않는다.",
|
||||
'# 이 파일들은 같은 package 의 Java interface 로 대체된다.']
|
||||
+ unions.keySet().collect { "**/${it}.java" }).join('\n') + '\n',
|
||||
'UTF-8')
|
||||
|
||||
// union interface 쓰기
|
||||
def packageDir = new File(unionDir.get().asFile, modelPackage.replace('.', '/'))
|
||||
project.delete(unionDir.get().asFile)
|
||||
def packageDir = new File(unionDir, modelPackage.replace('.', '/'))
|
||||
project.delete(unionDir)
|
||||
packageDir.mkdirs()
|
||||
unions.each { String name, Object spec ->
|
||||
def subtypes = spec.variants.collect { String typeId, String variant ->
|
||||
@@ -319,7 +427,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
|
||||
/**
|
||||
* {@code ${name}} — 계약의 discriminator union. prepareStudioCodegenSpec 가 계약의
|
||||
* {@code ${name}} — 계약의 discriminator union. ${label} 가 계약의
|
||||
* {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다.
|
||||
*
|
||||
* <p>{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로
|
||||
@@ -339,11 +447,247 @@ public interface ${name} {}
|
||||
}
|
||||
|
||||
logger.lifecycle(
|
||||
"prepareStudioCodegenSpec: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), " +
|
||||
"${label}: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), boolean const ${consts[0]}건 제거, " +
|
||||
"string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음")
|
||||
}
|
||||
|
||||
ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
|
||||
ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml')
|
||||
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
|
||||
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
|
||||
|
||||
// `public` 은 Java 예약어라 패키지 조각으로 쓸 수 없다 — publicapi 로 둔다.
|
||||
ext.publicModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model'
|
||||
ext.publicCodegenSpecFile = layout.buildDirectory.file('openapi/public-v1-codegen.yaml')
|
||||
ext.publicCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-public')
|
||||
ext.publicUnionSrcDir = layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java')
|
||||
|
||||
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') {
|
||||
|
||||
+27
-1
@@ -70,7 +70,10 @@ public class SecurityConfig {
|
||||
AccessDeniedHandler accessDeniedHandler,
|
||||
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
|
||||
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 {
|
||||
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
|
||||
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
|
||||
@@ -137,6 +140,29 @@ public class SecurityConfig {
|
||||
securityContext
|
||||
.securityContextRepository(sessionSecurityContextRepository.getObject())
|
||||
.requireExplicitSave(false));
|
||||
|
||||
// BFF 로그인. 세션을 만들 수 있는 유일한 경로다 — 이것이 없으면 auth-mode=redis-session 은
|
||||
// 아무도 인증할 수 없는 모드가 된다. SPA 는 401 을 받으면 브라우저를 /oauth2/authorization/{id}
|
||||
// 로 이동시키고, 콜백이 세션 쿠키를 심은 뒤 SPA 진입점으로 되돌린다.
|
||||
//
|
||||
// 진입점은 바꾸지 않는다: API 요청이 302 로 답하면 XHR 이 따라갈 수 없으므로, 미인증 API 호출은
|
||||
// 그대로 봉투 401 이어야 한다. 아래 defaultSuccessUrl 대신 주입된 핸들러를 쓰는 이유는
|
||||
// OidcUser 를 세션이 담을 수 있는 AuthenticatedPrincipal 로 바꿔야 하기 때문이다.
|
||||
org.springframework.security.web.authentication.AuthenticationSuccessHandler onSuccess =
|
||||
loginSuccessHandler.getIfAvailable();
|
||||
if (onSuccess != null) {
|
||||
http.oauth2Login(login -> login.successHandler(onSuccess));
|
||||
}
|
||||
http.logout(
|
||||
logout ->
|
||||
logout
|
||||
.logoutUrl("/logout")
|
||||
.invalidateHttpSession(true)
|
||||
.deleteCookies(securitySettings.session().cookieName())
|
||||
.logoutSuccessHandler(
|
||||
(request, response, authentication) ->
|
||||
response.setStatus(
|
||||
jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT)));
|
||||
}
|
||||
return http.build();
|
||||
}
|
||||
|
||||
+52
-6
@@ -6,11 +6,14 @@ import dev.caskeleton.application.techlog.error.StudioException;
|
||||
import dev.caskeleton.shared.response.Envelope;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authorization.AuthorizationDeniedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@@ -26,14 +29,17 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
|
||||
* handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다.
|
||||
*
|
||||
* <p><b>{@code basePackages} 스코프 (final whole-branch review B4).</b> 이 advice는 {@code
|
||||
* dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code
|
||||
* studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring
|
||||
* MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그
|
||||
* 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이
|
||||
* 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
|
||||
* dev.caskeleton.adapter.inbound.web.techlog.studio} 아래의 컨트롤러(studio 컨트롤러 전부가 여기 산다, {@code
|
||||
* studio.controller})에만 적용된다. 원래는 한 단계 위인 {@code ...web.techlog}였는데, 공개 조회 컨트롤러가 {@code
|
||||
* ...web.techlog.publicapi}에 들어오면서 그 스코프가 남의 기능까지 덮게 되었다 — 아래 바인딩 예외 처리기들이 공개 조회의 파라미터 오류를 Studio
|
||||
* 계약 코드로 바꿔 내보냈을 것이고, 그 코드는 public-v1 계약의 enum 에 없어서 프론트엔드의 응답 파싱을 깨뜨린다. 그래서 studio 로 좁혔다. {@link
|
||||
* #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이
|
||||
* 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을
|
||||
* 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이 없는 기능이다. {@code StudioException}
|
||||
* 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
|
||||
*/
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")
|
||||
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.studio")
|
||||
public class StudioExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class);
|
||||
@@ -76,6 +82,46 @@ public class StudioExceptionHandler {
|
||||
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 본문 bean validation 실패(예: {@code title} 120자 초과). {@code GlobalExceptionHandler}도 이 예외를 처리하지만
|
||||
* 400 {@code OperationalError.VALIDATION_FAILED}를 낸다 — Studio 계약에 없는 코드이고 (계약이 아는 것은 {@code
|
||||
* REQUEST_VALIDATION_FAILED}와 {@code DOCUMENT_VALIDATION_FAILED}뿐이다), 상태도 계약이 본문 검증 실패에 배정한 422가
|
||||
* 아니다. 프론트엔드는 봉투의 {@code code}를 enum으로 검증하므로 계약 밖 코드는 응답 파싱 자체를 깨뜨린다. studio 스코프에서 계약 코드로 옮긴다.
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<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 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;
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -28,7 +28,6 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -109,7 +108,14 @@ public class StudioPublicationController {
|
||||
idempotencyKey,
|
||||
StudioPrincipals.require(principal)))));
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
// 계약이 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());
|
||||
}
|
||||
|
||||
+55
-8
@@ -8,10 +8,12 @@ import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ImageBlock;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ThematicBreakBlock;
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -24,6 +26,7 @@ import org.commonmark.node.BlockQuote;
|
||||
import org.commonmark.node.BulletList;
|
||||
import org.commonmark.node.FencedCodeBlock;
|
||||
import org.commonmark.node.Heading;
|
||||
import org.commonmark.node.Image;
|
||||
import org.commonmark.node.IndentedCodeBlock;
|
||||
import org.commonmark.node.Node;
|
||||
import org.commonmark.node.OrderedList;
|
||||
@@ -38,10 +41,13 @@ import org.commonmark.node.ThematicBreak;
|
||||
*/
|
||||
final class BlockRenderer {
|
||||
|
||||
/** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */
|
||||
private static final int MIN_HEADING_LEVEL = 2;
|
||||
/**
|
||||
* 계약 {@code HeadingBlock.level} 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 였고 h1/h5/h6 를 이 범위로 접었는데,
|
||||
* 그러면 작성자가 쓴 위계가 화면에서 달라진다. 지금은 그대로 담는다.
|
||||
*/
|
||||
private static final int MIN_HEADING_LEVEL = 1;
|
||||
|
||||
private static final int MAX_HEADING_LEVEL = 4;
|
||||
private static final int MAX_HEADING_LEVEL = 6;
|
||||
|
||||
private final HeadingIds headingIds;
|
||||
private final List<String> warnings;
|
||||
@@ -67,18 +73,18 @@ final class BlockRenderer {
|
||||
private CaseRenderBlock renderBlock(Node node) {
|
||||
return switch (node) {
|
||||
case Heading value -> heading(value);
|
||||
case Paragraph value -> paragraph(InlineRenderer.render(value));
|
||||
case Paragraph value -> paragraphOrImage(value);
|
||||
case BlockQuote value -> blockquote(value);
|
||||
case BulletList value -> bulletList(value);
|
||||
case OrderedList value -> orderedList(value);
|
||||
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
|
||||
case IndentedCodeBlock value -> code(value.getLiteral(), null);
|
||||
case TableBlock value -> table(value);
|
||||
// 계약에 수평선 타입이 생겼다(studio-v1 3.1.0). 예전에는 담을 곳이 없어 버리고 경고했다.
|
||||
case ThematicBreak ignored -> {
|
||||
// 계약의 CaseRenderBlock 에 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던
|
||||
// 구조가 생기므로 버리고 경고한다.
|
||||
warnings.add("THEMATIC_BREAK_NOT_RENDERABLE");
|
||||
yield null;
|
||||
ThematicBreakBlock block = new ThematicBreakBlock();
|
||||
block.setType(ThematicBreakBlock.TypeEnum.THEMATIC_BREAK);
|
||||
yield block;
|
||||
}
|
||||
default -> {
|
||||
List<Inline> content = InlineRenderer.render(node);
|
||||
@@ -87,6 +93,47 @@ final class BlockRenderer {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
|
||||
*
|
||||
* <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);
|
||||
|
||||
+4
-3
@@ -57,9 +57,10 @@ class StudioContentRendererTest {
|
||||
List<CaseRenderBlock> blocks = rendered.blocks();
|
||||
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
|
||||
|
||||
// 계약의 HeadingBlock.level 은 2..4 다. h1 과 h6 를 그대로 내보내면 계약 위반이다.
|
||||
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2);
|
||||
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4);
|
||||
// 계약의 HeadingBlock.level 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 라 h1/h6 를 접었고,
|
||||
// 그래서 작성자가 쓴 위계가 화면에서 달라졌다. 지금은 그대로 내보낸다.
|
||||
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(1);
|
||||
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(6);
|
||||
|
||||
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
|
||||
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
|
||||
|
||||
@@ -131,6 +131,21 @@ def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadin
|
||||
'postgresqlTechLogStudioPersistenceIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
|
||||
|
||||
// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종,
|
||||
// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를
|
||||
// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다.
|
||||
def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlTechLogPublicPersistenceIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest')
|
||||
|
||||
// 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') {
|
||||
group = 'verification'
|
||||
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
|
||||
|
||||
+9
@@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope}
|
||||
@@ -162,7 +164,14 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort {
|
||||
row.getExpiresAt()));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code deleteByScope} 는 {@code @Modifying} 벌크 delete 이므로 활성 트랜잭션을 요구한다. 이 메서드는 {@code
|
||||
* IdempotencyExecutor} 의 실패 경로에서 호출되는데 그 지점에는 트랜잭션이 없다 — 예약 레코드를 지우려다 {@code
|
||||
* TransactionRequiredException} 을 던져 원래 실패를 덮고 있었다(403 이 500 으로 바뀌고 로그에 원인이 남지 않았다). REQUIRES_NEW
|
||||
* 인 이유: 정리는 실패한 작업의 롤백에 휩쓸리면 안 된다.
|
||||
*/
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void discard(IdempotencyScope scope) {
|
||||
repository.deleteByScope(
|
||||
IdempotencyRecordEntityMapper.tenantColumn(scope),
|
||||
|
||||
+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).
|
||||
*
|
||||
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다.
|
||||
* <p>RELATION 과 EVIDENCE 는 같은 기록을 서로 다른 시점에서 본다. RELATION 은 <em>작성 중</em>에 고르는 것이라 아직 게시되지 않은 작업본까지
|
||||
* 포함한다 — 두 문서를 같이 쓰면서 서로 잇는 것이 정상적인 순서이고, 게시된 것만 보이면 그 순서를 쓸 수 없다. EVIDENCE 는 공개된 기록을 근거로 인용하는 것이므로
|
||||
* 공개 투영에 살아 있는 행만 포함한다. 읽는 사람이 따라갈 수 없는 근거는 근거가 아니다.
|
||||
*/
|
||||
@Repository
|
||||
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
||||
|
||||
/** 공개 노출 조건. {@code PublicSql.ACTIVE} 와 같은 정의다 — 그쪽은 package-private 이라 여기에 다시 적는다. */
|
||||
private static final String PUBLICLY_VISIBLE =
|
||||
" p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
|
||||
|
||||
/** 연결·근거 대상이 될 수 있는 기록 유형. PROJECT 는 관계가 아니라 소속이라 여기에 없다. */
|
||||
private static final String LINKABLE_TYPES =
|
||||
" ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION') ";
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
|
||||
@@ -32,7 +42,8 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
||||
switch (type) {
|
||||
case TOPIC -> searchTopics(pattern, limit);
|
||||
case PROJECT -> searchProjects(pattern, limit);
|
||||
case RELATION, EVIDENCE -> List.of();
|
||||
case RELATION -> searchRelations(pattern, limit);
|
||||
case EVIDENCE -> searchEvidence(pattern, limit);
|
||||
};
|
||||
return new CatalogPageView(items, null);
|
||||
}
|
||||
@@ -57,11 +68,25 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
||||
.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) {
|
||||
return jdbcClient
|
||||
.sql(
|
||||
"SELECT id, name, updated_at FROM project "
|
||||
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
|
||||
"SELECT pr.id, pr.name, pr.updated_at, p.navigation_path"
|
||||
+ " 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("limit", limit)
|
||||
.query(
|
||||
@@ -71,8 +96,83 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
|
||||
CatalogEntryType.PROJECT,
|
||||
rs.getString("name"),
|
||||
"PROJECT",
|
||||
null,
|
||||
rs.getString("navigation_path"),
|
||||
"project:" + rs.getTimestamp("updated_at").toInstant()))
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ public class JdbcStudioDependencyResolverAdapter implements StudioDependencyReso
|
||||
rs.getString("content_type"),
|
||||
// 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다
|
||||
// (설계 05장 §3.1).
|
||||
"/media/" + rs.getString("id"),
|
||||
"/api/v1/public/media/" + rs.getString("id"),
|
||||
(Integer) rs.getObject("width"),
|
||||
(Integer) rs.getObject("height"),
|
||||
rs.getBoolean("decorative")));
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort {
|
||||
rs.getBoolean("decorative"),
|
||||
AssetManagementStatusView.valueOf(rs.getString("management_status")),
|
||||
// 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다.
|
||||
"/media/" + rs.getString("id"),
|
||||
"/api/v1/public/media/" + rs.getString("id"),
|
||||
rs.getInt("usage_count"),
|
||||
rs.getLong("version"),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
|
||||
+47
@@ -164,9 +164,56 @@ public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
|
||||
// 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");
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
-- 프로젝트 활동을 게시 로그로 되돌린다.
|
||||
--
|
||||
-- 이 표는 원래 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로
|
||||
-- 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 이제 게시가 이 줄을 남긴다
|
||||
-- (JdbcPublicationWriterAdapter 19단계).
|
||||
--
|
||||
-- 이 마이그레이션은 그 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는
|
||||
-- 상태를 남겨 두면, 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다.
|
||||
--
|
||||
-- occurred_at 은 최초 PUBLISHED 사건의 시각이다 -- now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼
|
||||
-- 보인다. operation_key 는 애플리케이션이 쓰는 것과 같은 규칙이라, 나중에 같은 문서를 재게시해도
|
||||
-- 줄이 늘지 않는다.
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
link.project_id,
|
||||
CASE publication.source_kind
|
||||
WHEN 'CASE' THEN 'CASE_PUBLISHED'
|
||||
WHEN 'REFERENCE' THEN 'REFERENCE_PUBLISHED'
|
||||
WHEN 'QUESTION' THEN 'QUESTION_OPENED'
|
||||
ELSE 'DECISION_ACCEPTED'
|
||||
END,
|
||||
projection.title,
|
||||
'',
|
||||
'PUBLIC',
|
||||
'AUTO',
|
||||
publication.source_kind,
|
||||
publication.source_id,
|
||||
first_published.occurred_at,
|
||||
'publication:' || publication.source_id,
|
||||
'system:migration',
|
||||
'system:migration'
|
||||
FROM publication
|
||||
JOIN public_resource_project_link link
|
||||
ON link.resource_type = publication.source_kind
|
||||
AND link.resource_id = publication.source_id
|
||||
AND link.relation_type = 'PRIMARY'
|
||||
JOIN public_resource_projection projection
|
||||
ON projection.resource_type = publication.source_kind
|
||||
AND projection.resource_id = publication.source_id
|
||||
JOIN LATERAL (
|
||||
SELECT min(event.occurred_at) AS occurred_at
|
||||
FROM publication_event event
|
||||
WHERE event.publication_id = publication.publication_id
|
||||
AND event.event_type = 'PUBLISHED'
|
||||
) first_published ON true
|
||||
WHERE publication.status = 'PUBLISHED'
|
||||
AND first_published.occurred_at IS NOT NULL
|
||||
ON CONFLICT (project_id, operation_key) DO NOTHING;
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
-- public-v1 계약이 요구하는 나머지 테이블.
|
||||
--
|
||||
-- 원본: tech-log-design-package/database/V1__init.sql (설계 패키지 커밋 55a9599 기준)
|
||||
--
|
||||
-- V7 이 이 여섯을 제외하며 남긴 이유는 "이번 범위 밖(spec §2.2)" 이었다. 그 §2.2 가
|
||||
-- 미룬 것이 바로 public-v1 이고, 여섯 테이블은 전부 public-v1 전용이다.
|
||||
--
|
||||
-- release -> listPublicReleases / getPublicRelease
|
||||
-- site_config -> getPublicSite
|
||||
-- profile_page -> getPublicProfile
|
||||
-- home_focus_config -> getPublicHome (focus)
|
||||
-- project_topic -> getPublicTopic (relatedProjects)
|
||||
-- topic_featured_document -> getPublicTopic (featuredReference / featuredCases)
|
||||
--
|
||||
-- 원본 DDL 을 그대로 옮긴다. V7 이 tech_log 전용 스키마를 쓰지 않고 public 스키마에
|
||||
-- 만들기로 한 결정만 이어받는다(원본의 CREATE SCHEMA / SET search_path 는 V7 이 이미 제외했다).
|
||||
--
|
||||
-- 시딩 INSERT 3건도 원본 그대로 가져온다. site_config / profile_page /
|
||||
-- home_focus_config 는 단일 행 테이블이고(PK 가 고정 UUID 로 CHECK 되어 있다) 그 행이
|
||||
-- 없으면 getPublicSite / getPublicProfile / getPublicHome 이 줄 것이 없다. 이 세 값을
|
||||
-- 편집하는 API 는 studio-management-v1 이 소유하며 아직 구현 범위 밖이라, 지금은 이
|
||||
-- 시딩이 유일한 공급원이다.
|
||||
|
||||
|
||||
CREATE TABLE release (
|
||||
id uuid PRIMARY KEY,
|
||||
version_label varchar(32) NOT NULL,
|
||||
title varchar(180) NOT NULL,
|
||||
summary varchar(600) NOT NULL DEFAULT '',
|
||||
released_on date,
|
||||
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
|
||||
CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
|
||||
change_types jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(change_types) = 'array'),
|
||||
reason_markdown text NOT NULL DEFAULT '',
|
||||
changes_markdown text NOT NULL DEFAULT '',
|
||||
user_impact_markdown text NOT NULL DEFAULT '',
|
||||
implementation_impact_markdown text NOT NULL DEFAULT '',
|
||||
verification_markdown text NOT NULL DEFAULT '',
|
||||
known_limitations_markdown text NOT NULL DEFAULT '',
|
||||
related_resources jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(related_resources) = 'array'),
|
||||
first_published_at timestamptz,
|
||||
last_published_at timestamptz,
|
||||
version bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by varchar(255) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by varchar(255) NOT NULL,
|
||||
CONSTRAINT uq_release_version_label UNIQUE (version_label)
|
||||
);
|
||||
|
||||
CREATE TABLE site_config (
|
||||
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000001'::uuid),
|
||||
brand_title varchar(80) NOT NULL DEFAULT 'Tech Log',
|
||||
identity_statement varchar(600) NOT NULL DEFAULT '',
|
||||
operator_display_name varchar(80) NOT NULL DEFAULT '',
|
||||
short_identity varchar(120),
|
||||
avatar_asset_id uuid REFERENCES asset(id),
|
||||
contacts jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(contacts) = 'array'),
|
||||
version bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by varchar(255) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by varchar(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE profile_page (
|
||||
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000002'::uuid),
|
||||
headline varchar(300) NOT NULL DEFAULT '',
|
||||
introduction_markdown text NOT NULL DEFAULT '',
|
||||
working_model jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(working_model) = 'array'),
|
||||
territories jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(territories) = 'array'),
|
||||
selected_evidence jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(selected_evidence) = 'array'),
|
||||
trajectory jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(trajectory) = 'array'),
|
||||
contacts jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
CHECK (jsonb_typeof(contacts) = 'array'),
|
||||
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
|
||||
CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')),
|
||||
first_published_at timestamptz,
|
||||
last_published_at timestamptz,
|
||||
version bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by varchar(255) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by varchar(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE home_focus_config (
|
||||
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000003'::uuid),
|
||||
default_focus_type varchar(30)
|
||||
CHECK (default_focus_type IS NULL OR default_focus_type IN (
|
||||
'CURRENT_WORK', 'OPEN_QUESTION', 'RECENT_DECISION'
|
||||
)),
|
||||
current_project_id uuid REFERENCES project(id),
|
||||
open_question_id uuid REFERENCES open_question(id),
|
||||
recent_decision_id uuid REFERENCES project_decision(id),
|
||||
version bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by varchar(255) NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by varchar(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE project_topic (
|
||||
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
topic_id uuid NOT NULL REFERENCES topic(id),
|
||||
display_order integer NOT NULL CHECK (display_order >= 0),
|
||||
PRIMARY KEY (project_id, topic_id),
|
||||
CONSTRAINT uq_project_topic_order UNIQUE (project_id, display_order)
|
||||
);
|
||||
|
||||
CREATE TABLE topic_featured_document (
|
||||
topic_id uuid NOT NULL REFERENCES topic(id) ON DELETE CASCADE,
|
||||
document_id uuid NOT NULL REFERENCES document(id),
|
||||
feature_role varchar(30) NOT NULL
|
||||
CHECK (feature_role IN ('START_HERE', 'FEATURED_CASE')),
|
||||
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
|
||||
PRIMARY KEY (topic_id, document_id, feature_role)
|
||||
);
|
||||
|
||||
-- 한 Topic 의 START_HERE 는 하나뿐이다.
|
||||
CREATE UNIQUE INDEX uq_topic_start_here
|
||||
ON topic_featured_document(topic_id)
|
||||
WHERE feature_role = 'START_HERE';
|
||||
|
||||
-- 단일 행 시딩. 이미 있으면 건드리지 않는다.
|
||||
|
||||
INSERT INTO site_config (
|
||||
id,
|
||||
brand_title,
|
||||
identity_statement,
|
||||
operator_display_name,
|
||||
short_identity,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000001'::uuid,
|
||||
'Tech Log',
|
||||
'문제를 재현하고 검증하여 운영 가능한 시스템 설계로 연결합니다.',
|
||||
'동현',
|
||||
'Backend · Platform',
|
||||
'system:migration',
|
||||
'system:migration'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO profile_page (
|
||||
id,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000002'::uuid,
|
||||
'system:migration',
|
||||
'system:migration'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO home_focus_config (
|
||||
id,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000000003'::uuid,
|
||||
'system:migration',
|
||||
'system:migration'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
+9
-1
@@ -43,8 +43,16 @@ class PostgreSqlMigrationIntegrationTest {
|
||||
.load()
|
||||
.migrate();
|
||||
|
||||
/*
|
||||
TechLog 가 들어오면서 8·9·10 이 붙었는데 이 목록은 7 에서 멈춰 있었다. 마이그레이션을 더한
|
||||
사람이 여기를 같이 고치지 않으면 이 테스트만 빨개지고, 그 빨간색은 "스키마가 잘못됐다" 가
|
||||
아니라 "목록을 안 고쳤다" 를 뜻한다 — 정확히 그 상태로 두 번 지나갔다.
|
||||
|
||||
목록을 고정해 두는 이유는 남아 있다: 마이그레이션이 순서대로, 빠짐없이 적용되는지 확인한다.
|
||||
그래서 개수를 세는 것이 아니라 버전을 그대로 적는다.
|
||||
*/
|
||||
assertThat(appliedVersions(postgres, "flyway_schema_history"))
|
||||
.containsExactly("1", "3", "4", "5", "6", "7");
|
||||
.containsExactly("1", "3", "4", "5", "6", "7", "8", "9", "10");
|
||||
|
||||
Flyway coreStream =
|
||||
Flyway.configure()
|
||||
|
||||
+818
@@ -0,0 +1,818 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapter;
|
||||
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
|
||||
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
|
||||
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
|
||||
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
|
||||
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
|
||||
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.studio.query.CatalogEntryType;
|
||||
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 작업본 삭제 SQL 을 실제 PostgreSQL 위에서 돌린다.
|
||||
*
|
||||
* <p>이 파일은 사고 하나에서 나왔다. {@code documentReferenced} 가 {@code public_resource_projection.document_id}
|
||||
* 를 읽었는데 그 열은 없다 — 그 테이블은 {@code (resource_type, resource_id)} 로 기록을 가리킨다. 컴파일도 단위 테스트도 통과했고, 작성자가
|
||||
* 삭제를 누른 순간 500 이 됐다. 옆 패키지의 통합 테스트가 그 위험을 정확히 예고하고 있었는데 (<i>"컴파일도 단위 테스트도 컬럼 이름 오타를 검증하지 못한다"</i>)
|
||||
* 삭제 경로만 그 밖에 있었다.
|
||||
*
|
||||
* <p>그래서 여기서 겨냥하는 것은 결과값이 아니라 <b>SQL 이 실행되는가</b> 다. 열 이름, 조인 조건, 그리고 CASCADE 가 실제로 어디까지 따라오는지.
|
||||
*/
|
||||
class ManagementPersistenceIntegrationTest {
|
||||
|
||||
private static final String IMAGE =
|
||||
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
|
||||
|
||||
private static PostgreSQLContainer postgres;
|
||||
private static HikariDataSource dataSource;
|
||||
private static JdbcClient jdbcClient;
|
||||
private static JdbcDocumentDeletionAdapter deletion;
|
||||
private static JdbcProjectRepositoryAdapter projects;
|
||||
private static JdbcHomeFocusConfigAdapter homeFocus;
|
||||
private static JdbcCatalogQueryAdapter catalog;
|
||||
private static JdbcProjectActivityRepositoryAdapter activities;
|
||||
|
||||
@BeforeAll
|
||||
static void migrate() {
|
||||
if (!DockerClientFactory.instance().isDockerAvailable()) {
|
||||
throw new IllegalStateException(
|
||||
"Docker is required for the management persistence integration test;"
|
||||
+ " skipping is forbidden");
|
||||
}
|
||||
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
|
||||
postgres.start();
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(postgres.getJdbcUrl());
|
||||
config.setUsername(postgres.getUsername());
|
||||
config.setPassword(postgres.getPassword());
|
||||
config.setMaximumPoolSize(5);
|
||||
config.setMinimumIdle(1);
|
||||
dataSource = new HikariDataSource(config);
|
||||
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration/postgresql")
|
||||
.baselineOnMigrate(false)
|
||||
.outOfOrder(false)
|
||||
.load()
|
||||
.migrate();
|
||||
|
||||
jdbcClient = JdbcClient.create(dataSource);
|
||||
deletion = new JdbcDocumentDeletionAdapter(jdbcClient);
|
||||
projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper());
|
||||
homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient);
|
||||
catalog = new JdbcCatalogQueryAdapter(jdbcClient);
|
||||
activities = new JdbcProjectActivityRepositoryAdapter(jdbcClient);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopPostgreSql() {
|
||||
if (dataSource != null) {
|
||||
dataSource.close();
|
||||
}
|
||||
if (postgres != null) {
|
||||
postgres.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static UUID insertCase(String title) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO document (id, document_type, title, created_by, updated_by)"
|
||||
+ " VALUES (:id, 'CASE', :title, 'test', 'test')")
|
||||
.param("id", id)
|
||||
.param("title", title)
|
||||
.update();
|
||||
jdbcClient.sql("INSERT INTO case_detail (document_id) VALUES (:id)").param("id", id).update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static UUID insertQuestion(String question) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO open_question (id, question, created_by, updated_by)"
|
||||
+ " VALUES (:id, :question, 'test', 'test')")
|
||||
.param("id", id)
|
||||
.param("question", question)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 이 테이블은 NOT NULL 이 많다 — 참조 검사에 필요한 열만으로는 행을 만들 수 없다. */
|
||||
private static void insertProjection(String resourceType, UUID id, String state, String title) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
|
||||
+ " publication_state, visibility, title, payload_schema_version, payload,"
|
||||
+ " content_hash, published_at, updated_at, navigation_path)"
|
||||
+ " VALUES (:type, :id, 0, :state, 'PUBLIC', :title, 1, '{}'::jsonb,"
|
||||
+ " :hash, now(), now(), :path)")
|
||||
.param("type", resourceType)
|
||||
.param("id", id)
|
||||
.param("state", state)
|
||||
.param("title", title)
|
||||
.param("hash", "0".repeat(64))
|
||||
.param("path", "/test/" + id)
|
||||
.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code publication.latest_event_id} 와 {@code publication_event.publication_id} 는 서로를 가리키고, 앞의
|
||||
* 것만 {@code DEFERRABLE INITIALLY DEFERRED} 다. 그래서 두 삽입이 한 transaction 안에 있어야 한다 — 문장마다 커밋하면 지연
|
||||
* 검사가 그 자리에서 터진다.
|
||||
*/
|
||||
private static void insertPublication(UUID sourceId) {
|
||||
UUID publicationId = UUID.randomUUID();
|
||||
UUID eventId = UUID.randomUUID();
|
||||
try (java.sql.Connection connection = dataSource.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (java.sql.PreparedStatement publication =
|
||||
connection.prepareStatement(
|
||||
"INSERT INTO publication (publication_id, source_kind, source_id, status,"
|
||||
+ " published_version, latest_event_id, public_path)"
|
||||
+ " VALUES (?, 'CASE', ?, 'PUBLISHED', 0, ?, ?)")) {
|
||||
publication.setObject(1, publicationId);
|
||||
publication.setObject(2, sourceId);
|
||||
publication.setObject(3, eventId);
|
||||
publication.setString(4, "/cases/published-" + sourceId);
|
||||
publication.executeUpdate();
|
||||
}
|
||||
try (java.sql.PreparedStatement event =
|
||||
connection.prepareStatement(
|
||||
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
|
||||
+ " source_id, event_type, published_version, created_by)"
|
||||
+ " VALUES (?, ?, 'CASE', ?, 'PUBLISHED', 0, 'test')")) {
|
||||
event.setObject(1, eventId);
|
||||
event.setObject(2, publicationId);
|
||||
event.setObject(3, sourceId);
|
||||
event.executeUpdate();
|
||||
}
|
||||
connection.commit();
|
||||
} catch (java.sql.SQLException failure) {
|
||||
throw new IllegalStateException("failed to seed publication history", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static int countIn(String table, String column, UUID id) {
|
||||
return jdbcClient
|
||||
.sql("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = :id")
|
||||
.param("id", id)
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findsADocumentOnlyUnderItsOwnType() {
|
||||
UUID id = insertCase("타입이 맞아야 찾힌다");
|
||||
|
||||
assertThat(deletion.findDocument(id, "CASE")).isPresent();
|
||||
// Case 주소로 Reference 를 지울 수 없어야 한다 — 한 테이블을 나눠 쓰기 때문에 이것이 유일한 방어다.
|
||||
assertThat(deletion.findDocument(id, "REFERENCE")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsNoReferencesForAFreshDraft() {
|
||||
// 작성자가 방금 연 초안. 이 경로가 막히면 기능 자체가 무의미해진다.
|
||||
assertThat(deletion.documentReferenced(insertCase("갓 만든 초안"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsEveryNonCascadingReferrer() {
|
||||
UUID target = insertCase("가리켜지는 쪽");
|
||||
UUID source = insertCase("가리키는 쪽");
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO document_relation (source_document_id, target_document_id, relation_type)"
|
||||
+ " VALUES (:source, :target, 'RELATED')")
|
||||
.param("source", source)
|
||||
.param("target", target)
|
||||
.update();
|
||||
|
||||
assertThat(deletion.documentReferenced(target)).isTrue();
|
||||
// 나가는 관계는 자기 것이라 CASCADE 로 따라간다 — 가리키는 쪽은 자유롭게 지울 수 있어야 한다.
|
||||
assertThat(deletion.documentReferenced(source)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsThePublicProjectionByResourceTypeAndId() {
|
||||
// 이 테스트가 존재하는 이유. 이 쿼리가 열 이름을 틀렸고 아무도 잡지 못했다.
|
||||
UUID id = insertCase("공개된 기록");
|
||||
assertThat(deletion.publiclyProjected("CASE", id)).isFalse();
|
||||
|
||||
insertProjection("CASE", id, "ACTIVE", "공개된 기록");
|
||||
|
||||
assertThat(deletion.publiclyProjected("CASE", id)).isTrue();
|
||||
assertThat(deletion.publiclyProjected("QUESTION", id)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingCarriesTheDocumentsOwnRowsAndItsWithdrawnProjection() {
|
||||
UUID id = insertCase("지워질 기록");
|
||||
insertProjection("CASE", id, "WITHDRAWN", "지워질 기록");
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
|
||||
+ " VALUES ('CASE', :slug, :id, 'CANONICAL')")
|
||||
.param("slug", "deleted-" + id)
|
||||
.param("id", id)
|
||||
.update();
|
||||
|
||||
assertThat(deletion.deleteProjection("CASE", id)).isEqualTo(1);
|
||||
// 공개 경로는 투영을 CASCADE 로 따라간다 — 손으로 지우지 않는 것이 옳은지 여기서 확인한다.
|
||||
assertThat(countIn("public_route", "resource_id", id)).isZero();
|
||||
|
||||
assertThat(deletion.deleteDocument(id, 0L)).isEqualTo(1);
|
||||
assertThat(countIn("case_detail", "document_id", id)).isZero();
|
||||
assertThat(deletion.findDocument(id, "CASE")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesToDeleteWhenTheExpectedVersionHasMoved() {
|
||||
UUID id = insertCase("먼저 수정된 기록");
|
||||
jdbcClient.sql("UPDATE document SET version = 3 WHERE id = :id").param("id", id).update();
|
||||
|
||||
assertThat(deletion.deleteDocument(id, 0L)).isZero();
|
||||
assertThat(deletion.findDocument(id, "CASE")).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsQuestionReferencesFromItsOwnTables() {
|
||||
UUID id = insertQuestion("질문은 다른 테이블이다");
|
||||
assertThat(deletion.findQuestion(id)).isPresent();
|
||||
assertThat(deletion.questionReferenced(id)).isFalse();
|
||||
|
||||
// 이 테이블은 CHECK 로 id 가 고정된 단일 행이다 — 테스트마다 새로 넣을 수 없다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO home_focus_config (id, open_question_id, created_by, updated_by)"
|
||||
+ " VALUES (:configId, :id, 'test', 'test')"
|
||||
+ " ON CONFLICT (id) DO UPDATE SET open_question_id = EXCLUDED.open_question_id")
|
||||
.param("configId", UUID.fromString("00000000-0000-0000-0000-000000000003"))
|
||||
.param("id", id)
|
||||
.update();
|
||||
|
||||
assertThat(deletion.questionReferenced(id)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletesAQuestionAndItsOwnChildren() {
|
||||
UUID id = insertQuestion("지워질 질문");
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO question_point (id, question_id, point_kind, content, display_order)"
|
||||
+ " VALUES (:pointId, :id, 'ASSUMPTION', '가설', 0)")
|
||||
.param("pointId", UUID.randomUUID())
|
||||
.param("id", id)
|
||||
.update();
|
||||
|
||||
assertThat(deletion.deleteQuestion(id, 0L)).isEqualTo(1);
|
||||
assertThat(countIn("question_point", "question_id", id)).isZero();
|
||||
assertThat(deletion.findQuestion(id)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void seesOnlyAPublicationThatIsStillLive() {
|
||||
// 막아야 하는 것은 "게시한 적이 있다" 가 아니라 "지금 읽히고 있다" 다 — 게시를 취소한 기록을
|
||||
// 영영 지울 수 없게 하면, 작성자가 공개를 취소하는 이유 자체가 막힌다.
|
||||
UUID id = insertCase("게시된 적 있는 기록");
|
||||
assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse();
|
||||
|
||||
insertPublication(id);
|
||||
assertThat(deletion.isCurrentlyPublished("CASE", id)).isTrue();
|
||||
assertThat(deletion.isCurrentlyPublished("QUESTION", id)).isFalse();
|
||||
|
||||
jdbcClient
|
||||
.sql("UPDATE publication SET status = 'UNPUBLISHED' WHERE source_id = :id")
|
||||
.param("id", id)
|
||||
.update();
|
||||
assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearsThePublicationHistoryInDependencyOrder() {
|
||||
// 이력은 기록에 무슨 일이 있었는지 말하는 것이라, 기록이 사라지면 아무것도 가리키지 않는다.
|
||||
// 외래키가 없어 DB 가 대신 지워 주지 않으므로, 순서까지 여기서 확인한다.
|
||||
UUID id = insertCase("이력을 남긴 기록");
|
||||
insertPublication(id);
|
||||
assertThat(countIn("publication", "source_id", id)).isEqualTo(1);
|
||||
assertThat(countIn("publication_event", "source_id", id)).isEqualTo(1);
|
||||
|
||||
// 반환값은 지운 publication 행 수다 — 이벤트는 같은 문장에서 함께 사라지므로 따로 세지 않는다.
|
||||
assertThat(deletion.deletePublicationHistory("CASE", id)).isEqualTo(1);
|
||||
assertThat(countIn("publication", "source_id", id)).isZero();
|
||||
assertThat(countIn("publication_event", "source_id", id)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearsTheValidationAndPreviewArtifactsInDependencyOrder() {
|
||||
UUID id = insertCase("검증과 미리보기를 남긴 기록");
|
||||
UUID validationId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO studio_validation (validation_id, source_kind, source_id,"
|
||||
+ " validated_version, status, dependency_revision, valid_until, created_by)"
|
||||
+ " VALUES (:vid, 'CASE', :id, 0, 'VALID', 'rev',"
|
||||
+ " now() + interval '1 hour', 'test')")
|
||||
.param("vid", validationId)
|
||||
.param("id", id)
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO studio_preview (preview_id, validation_id, source_kind, source_id,"
|
||||
+ " source_version, dependency_revision, render_model, expires_at, created_by)"
|
||||
+ " VALUES (:pid, :vid, 'CASE', :id, 0, 'rev', '{}'::jsonb,"
|
||||
+ " now() + interval '1 hour', 'test')")
|
||||
.param("pid", UUID.randomUUID())
|
||||
.param("vid", validationId)
|
||||
.param("id", id)
|
||||
.update();
|
||||
|
||||
// 미리보기가 검증을 참조하므로 순서가 틀리면 외래키 위반이다.
|
||||
assertThat(deletion.deleteWorkArtifacts("CASE", id)).isEqualTo(2);
|
||||
assertThat(countIn("studio_validation", "source_id", id)).isZero();
|
||||
assertThat(countIn("studio_preview", "source_id", id)).isZero();
|
||||
}
|
||||
|
||||
private static UUID insertDecision(String statement) {
|
||||
UUID projectId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project (id, name, created_by, updated_by) VALUES (:id, :name, 'test', 'test')")
|
||||
.param("id", projectId)
|
||||
.param("name", "결정이 속한 프로젝트 " + projectId)
|
||||
.update();
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project_decision (id, project_id, statement, created_by, updated_by)"
|
||||
+ " VALUES (:id, :projectId, :statement, 'test', 'test')")
|
||||
.param("id", id)
|
||||
.param("projectId", projectId)
|
||||
.param("statement", statement)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static UUID projectOf(UUID decisionId) {
|
||||
return jdbcClient
|
||||
.sql("SELECT project_id FROM project_decision WHERE id = :id")
|
||||
.param("id", decisionId)
|
||||
.query(UUID.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findsADecisionOnlyUnderItsOwnProject() {
|
||||
UUID id = insertDecision("이 프로젝트의 결정");
|
||||
assertThat(deletion.findDecision(projectOf(id), id)).isPresent();
|
||||
// 경로가 프로젝트를 들고 있으므로, 다른 프로젝트 주소로는 닿지 않아야 한다.
|
||||
assertThat(deletion.findDecision(UUID.randomUUID(), id)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsDecisionReferencesFromItsOwnTables() {
|
||||
UUID id = insertDecision("가리켜지는 결정");
|
||||
assertThat(deletion.decisionReferenced(id)).isFalse();
|
||||
|
||||
UUID successor = insertDecision("대체하는 결정");
|
||||
jdbcClient
|
||||
.sql("UPDATE project_decision SET superseded_by_id = :successor WHERE id = :id")
|
||||
.param("successor", successor)
|
||||
.param("id", id)
|
||||
.update();
|
||||
// superseded_by_id 는 자기 테이블을 CASCADE 없이 가리킨다 — 가리켜지는 쪽(successor)을
|
||||
// 그대로 지우면 외래키 위반이 되므로 그쪽이 "쓰이고 있음" 이어야 한다.
|
||||
assertThat(deletion.decisionReferenced(successor)).isTrue();
|
||||
// 가리키는 쪽은 자유롭게 지울 수 있다.
|
||||
assertThat(deletion.decisionReferenced(id)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletesADecisionAndRefusesAStaleVersion() {
|
||||
UUID id = insertDecision("지워질 결정");
|
||||
// 지운 뒤에는 프로젝트를 되물을 수 없으므로 먼저 붙잡아 둔다.
|
||||
UUID projectId = projectOf(id);
|
||||
assertThat(deletion.deleteDecision(id, 3L)).isZero();
|
||||
assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1);
|
||||
assertThat(deletion.findDecision(projectId, id)).isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 프로젝트 게시
|
||||
|
||||
/**
|
||||
* 프로젝트 게시는 원본 상태·공개 투영·canonical route 세 곳을 함께 세운다. 어느 하나가 빠지면 증상이 제각각이라 (투영이 없으면 화면이 조용히 비고,
|
||||
* route 가 없으면 주소만 404) 세 곳을 한꺼번에 확인한다.
|
||||
*/
|
||||
@Test
|
||||
void publishingAProjectRaisesTheProjection() {
|
||||
ProjectEditView draft = projects.create(new CreateProjectCommand("Publish Me", "test"));
|
||||
jdbcClient
|
||||
.sql("UPDATE project SET slug = 'publish-me' WHERE id = :id")
|
||||
.param("id", draft.id())
|
||||
.update();
|
||||
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
|
||||
|
||||
ProjectEditView published =
|
||||
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
|
||||
|
||||
assertThat(published.targetVisibility()).isEqualTo("PUBLIC");
|
||||
assertThat(published.firstPublishedAt()).isNotNull();
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT navigation_path FROM public_resource_projection"
|
||||
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id"
|
||||
+ " AND publication_state = 'ACTIVE' AND visibility = 'PUBLIC'")
|
||||
.param("id", loaded.id())
|
||||
.query(String.class)
|
||||
.optional())
|
||||
.contains("/projects/publish-me");
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT route_role FROM public_route"
|
||||
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
|
||||
.param("id", loaded.id())
|
||||
.query(String.class)
|
||||
.list())
|
||||
.containsExactly("CANONICAL");
|
||||
}
|
||||
|
||||
/** 게시 취소는 투영 행을 지우지 않고 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */
|
||||
@Test
|
||||
void unpublishingWithdrawsTheProjectionInsteadOfDeletingIt() {
|
||||
ProjectEditView draft = projects.create(new CreateProjectCommand("Withdraw Me", "test"));
|
||||
jdbcClient
|
||||
.sql("UPDATE project SET slug = 'withdraw-me' WHERE id = :id")
|
||||
.param("id", draft.id())
|
||||
.update();
|
||||
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
|
||||
ProjectEditView published =
|
||||
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
|
||||
|
||||
assertThat(projects.unpublish(published.id(), published.version(), "test")).isPresent();
|
||||
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT publication_state FROM public_resource_projection"
|
||||
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
|
||||
.param("id", published.id())
|
||||
.query(String.class)
|
||||
.optional())
|
||||
.contains("WITHDRAWN");
|
||||
}
|
||||
|
||||
/** 낙관적 잠금. 어긋난 버전으로는 아무것도 세우지 않는다. */
|
||||
@Test
|
||||
void publishingWithAStaleVersionChangesNothing() {
|
||||
ProjectEditView draft = projects.create(new CreateProjectCommand("Stale", "test"));
|
||||
jdbcClient
|
||||
.sql("UPDATE project SET slug = 'stale' WHERE id = :id")
|
||||
.param("id", draft.id())
|
||||
.update();
|
||||
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
|
||||
|
||||
assertThat(projects.publish(loaded.id(), loaded.version() + 99, "PUBLIC", "test")).isEmpty();
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT count(*) FROM public_resource_projection"
|
||||
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
|
||||
.param("id", loaded.id())
|
||||
.query(Integer.class)
|
||||
.single())
|
||||
.isZero();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 홈 focus
|
||||
|
||||
/** V9 가 빈 행 하나를 넣어 둔다. 조회가 실패하면 그 전제가 깨진 것이다. */
|
||||
@Test
|
||||
void homeFocusStartsAsASingleEmptyRow() {
|
||||
HomeFocusConfigView current = homeFocus.load();
|
||||
assertThat(current.currentProjectId()).isNull();
|
||||
assertThat(current.openQuestionId()).isNull();
|
||||
assertThat(current.recentDecisionId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void homeFocusRoundTripsAndRefusesAStaleVersion() {
|
||||
ProjectEditView project = projects.create(new CreateProjectCommand("Focus Target", "test"));
|
||||
HomeFocusConfigView before = homeFocus.load();
|
||||
|
||||
HomeFocusConfigView saved =
|
||||
homeFocus
|
||||
.update(
|
||||
new UpdateHomeFocusCommand(
|
||||
before.version(), "CURRENT_WORK", project.id(), null, null, "test"))
|
||||
.orElseThrow();
|
||||
|
||||
assertThat(saved.currentProjectId()).isEqualTo(project.id());
|
||||
assertThat(saved.defaultType()).isEqualTo("CURRENT_WORK");
|
||||
assertThat(saved.version()).isEqualTo(before.version() + 1);
|
||||
assertThat(homeFocus.projectExists(project.id())).isTrue();
|
||||
assertThat(homeFocus.projectExists(UUID.randomUUID())).isFalse();
|
||||
|
||||
assertThat(
|
||||
homeFocus.update(
|
||||
new UpdateHomeFocusCommand(before.version(), null, null, null, null, "test")))
|
||||
.isEmpty();
|
||||
|
||||
// null 은 "이 슬롯을 비운다" 다 — 부분 수정이 아니라 전체 교체.
|
||||
HomeFocusConfigView cleared =
|
||||
homeFocus
|
||||
.update(new UpdateHomeFocusCommand(saved.version(), null, null, null, null, "test"))
|
||||
.orElseThrow();
|
||||
assertThat(cleared.currentProjectId()).isNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- catalog
|
||||
|
||||
/** RELATION 은 작성 중에 고르는 것이므로 게시 여부와 무관하게 작업본까지 포함한다. */
|
||||
@Test
|
||||
void relationCatalogIncludesUnpublishedWorkingCopies() {
|
||||
UUID caseId = insertCase("Relation Target Case");
|
||||
UUID questionId = insertQuestion("Relation Target Question");
|
||||
|
||||
CatalogPageView page = catalog.search(CatalogEntryType.RELATION, null, null, 50);
|
||||
|
||||
assertThat(page.items()).extracting("id").contains(caseId, questionId);
|
||||
assertThat(page.items())
|
||||
.filteredOn(entry -> entry.id().equals(caseId))
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
entry -> {
|
||||
assertThat(entry.kind()).isEqualTo("CASE");
|
||||
assertThat(entry.publicPath()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
/** EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. */
|
||||
@Test
|
||||
void evidenceCatalogIncludesOnlyPubliclyVisibleRecords() {
|
||||
UUID publishedId = insertCase("Evidence Published");
|
||||
UUID draftId = insertCase("Evidence Draft");
|
||||
UUID withdrawnId = insertCase("Evidence Withdrawn");
|
||||
insertProjection("CASE", publishedId, "ACTIVE", "Evidence Published");
|
||||
insertProjection("CASE", withdrawnId, "WITHDRAWN", "Evidence Withdrawn");
|
||||
|
||||
CatalogPageView page = catalog.search(CatalogEntryType.EVIDENCE, "evidence", null, 50);
|
||||
|
||||
assertThat(page.items()).extracting("id").contains(publishedId);
|
||||
assertThat(page.items()).extracting("id").doesNotContain(draftId, withdrawnId);
|
||||
}
|
||||
|
||||
/** 검색어는 대소문자를 가리지 않는다. */
|
||||
@Test
|
||||
void relationCatalogSearchIsCaseInsensitive() {
|
||||
UUID caseId = insertCase("MixedCase Needle");
|
||||
|
||||
assertThat(catalog.search(CatalogEntryType.RELATION, "mixedcase", null, 50).items())
|
||||
.extracting("id")
|
||||
.contains(caseId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 프로젝트 활동
|
||||
|
||||
private static ProjectActivityView addActivity(UUID projectId, String title, String visibility) {
|
||||
return activities.create(
|
||||
new CreateProjectActivityCommand(
|
||||
projectId,
|
||||
0L,
|
||||
"MILESTONE_REACHED",
|
||||
title,
|
||||
"요약",
|
||||
visibility,
|
||||
null,
|
||||
null,
|
||||
Instant.parse("2026-08-23T00:00:00Z"),
|
||||
"test"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 프로젝트 화면의 "활동" 은 이 테이블을 투영 없이 직접 읽는다. 그래서 여기서 만든 줄이 곧 그 화면이고, 편집기가 읽는 목록과 화면이 읽는 목록이 같은 정렬·같은
|
||||
* 조건을 써야 한다.
|
||||
*/
|
||||
@Test
|
||||
void createdActivitiesAreVisibleToTheEditorAndToThePublicQuery() {
|
||||
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Host", "test"));
|
||||
ProjectActivityView pub = addActivity(project.id(), "공개 활동", "PUBLIC");
|
||||
addActivity(project.id(), "비공개 활동", "PRIVATE");
|
||||
|
||||
assertThat(pub.origin()).isEqualTo("MANUAL");
|
||||
assertThat(activities.listByProject(project.id())).hasSize(2);
|
||||
|
||||
// 공개 조회가 쓰는 조건 그대로.
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT title FROM project_activity"
|
||||
+ " WHERE project_id = :id AND visibility = 'PUBLIC'"
|
||||
+ " ORDER BY occurred_at DESC, id DESC")
|
||||
.param("id", project.id())
|
||||
.query(String.class)
|
||||
.list())
|
||||
.containsExactly("공개 활동");
|
||||
}
|
||||
|
||||
@Test
|
||||
void activityUpdatesRoundTripAndRefuseAStaleVersion() {
|
||||
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Edit", "test"));
|
||||
ProjectActivityView created = addActivity(project.id(), "처음 제목", "PRIVATE");
|
||||
|
||||
ProjectActivityView updated =
|
||||
activities
|
||||
.update(
|
||||
new UpdateProjectActivityCommand(
|
||||
project.id(),
|
||||
created.id(),
|
||||
created.version(),
|
||||
"고친 제목",
|
||||
"고친 요약",
|
||||
"PUBLIC",
|
||||
Instant.parse("2026-08-24T00:00:00Z"),
|
||||
"test"))
|
||||
.orElseThrow();
|
||||
|
||||
assertThat(updated.title()).isEqualTo("고친 제목");
|
||||
assertThat(updated.visibility()).isEqualTo("PUBLIC");
|
||||
assertThat(updated.version()).isEqualTo(created.version() + 1);
|
||||
|
||||
assertThat(
|
||||
activities.update(
|
||||
new UpdateProjectActivityCommand(
|
||||
project.id(),
|
||||
created.id(),
|
||||
created.version(),
|
||||
"다시",
|
||||
null,
|
||||
"PUBLIC",
|
||||
Instant.parse("2026-08-24T00:00:00Z"),
|
||||
"test")))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/** 경로가 프로젝트와 활동을 함께 요구하므로 저장소도 둘을 함께 본다 — 남의 프로젝트 줄을 고치거나 지울 수 없어야 한다. */
|
||||
@Test
|
||||
void activityWritesAreScopedToTheirOwnProject() {
|
||||
ProjectEditView mine = projects.create(new CreateProjectCommand("Mine", "test"));
|
||||
ProjectEditView theirs = projects.create(new CreateProjectCommand("Theirs", "test"));
|
||||
ProjectActivityView activity = addActivity(theirs.id(), "남의 활동", "PUBLIC");
|
||||
|
||||
assertThat(activities.find(mine.id(), activity.id())).isEmpty();
|
||||
assertThat(activities.delete(mine.id(), activity.id(), activity.version())).isZero();
|
||||
assertThat(activities.find(theirs.id(), activity.id())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingAnActivityHonoursTheExpectedVersion() {
|
||||
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Delete", "test"));
|
||||
ProjectActivityView activity = addActivity(project.id(), "지울 활동", "PUBLIC");
|
||||
|
||||
assertThat(activities.delete(project.id(), activity.id(), activity.version() + 9)).isZero();
|
||||
assertThat(activities.delete(project.id(), activity.id(), activity.version())).isEqualTo(1);
|
||||
assertThat(activities.listByProject(project.id())).isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 프로젝트 주제
|
||||
|
||||
private static UUID insertTopic(String name, String slug) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
|
||||
+ " VALUES (:id, :name, :normalized, :slug, 'test', 'test')")
|
||||
.param("id", id)
|
||||
.param("name", name)
|
||||
.param("normalized", name.toLowerCase(java.util.Locale.ROOT))
|
||||
.param("slug", slug)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static UpdateProjectCommand updateWithTopics(
|
||||
ProjectEditView project, java.util.List<UUID> topicIds) {
|
||||
return new UpdateProjectCommand(
|
||||
project.id(),
|
||||
project.version(),
|
||||
project.name(),
|
||||
project.slug() == null ? "project-" + project.id() : project.slug(),
|
||||
"목적 한 줄",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
project.phase(),
|
||||
"지금 목표",
|
||||
"다음 작업",
|
||||
java.util.List.of("Keycloak"),
|
||||
topicIds,
|
||||
"PRIVATE",
|
||||
null,
|
||||
"test");
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 화면의 "주요 주제" 는 이 링크 테이블을 읽는다. 화면은 처음부터 주제를 읽고 있었지만 저장할 경로가 없어 늘 비어 있었다 — 여기서 확인하는 것은 고른 순서가
|
||||
* 그대로 남는가와, 통째로 교체되는가다.
|
||||
*/
|
||||
@Test
|
||||
void projectTopicsRoundTripInTheChosenOrder() {
|
||||
ProjectEditView project = projects.create(new CreateProjectCommand("Topic Host", "test"));
|
||||
UUID first = insertTopic("주제 하나", "topic-one");
|
||||
UUID second = insertTopic("주제 둘", "topic-two");
|
||||
|
||||
ProjectEditView saved =
|
||||
projects.update(updateWithTopics(project, java.util.List.of(second, first))).orElseThrow();
|
||||
|
||||
assertThat(saved.topicIds()).containsExactly(second, first);
|
||||
assertThat(saved.currentObjective()).isEqualTo("지금 목표");
|
||||
assertThat(saved.nextStep()).isEqualTo("다음 작업");
|
||||
|
||||
// 공개 조회가 쓰는 조건 그대로 — ACTIVE 인 주제만, display_order 순서로.
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT t.name FROM project_topic pt"
|
||||
+ " JOIN topic t ON t.id = pt.topic_id"
|
||||
+ " WHERE pt.project_id = :id AND t.status = 'ACTIVE'"
|
||||
+ " ORDER BY pt.display_order, t.name")
|
||||
.param("id", project.id())
|
||||
.query(String.class)
|
||||
.list())
|
||||
.containsExactly("주제 둘", "주제 하나");
|
||||
}
|
||||
|
||||
/** 부분 수정이 아니라 전체 교체다 — 빈 목록은 "주제를 전부 뗀다" 는 뜻이어야 한다. */
|
||||
@Test
|
||||
void savingAnEmptyTopicListDetachesEveryTopic() {
|
||||
ProjectEditView project = projects.create(new CreateProjectCommand("Detach Host", "test"));
|
||||
UUID topic = insertTopic("뗄 주제", "topic-detach");
|
||||
ProjectEditView withTopic =
|
||||
projects.update(updateWithTopics(project, java.util.List.of(topic))).orElseThrow();
|
||||
assertThat(withTopic.topicIds()).containsExactly(topic);
|
||||
|
||||
ProjectEditView cleared =
|
||||
projects.update(updateWithTopics(withTopic, java.util.List.of())).orElseThrow();
|
||||
|
||||
assertThat(cleared.topicIds()).isEmpty();
|
||||
assertThat(
|
||||
jdbcClient
|
||||
.sql("SELECT count(*) FROM project_topic WHERE project_id = :id")
|
||||
.param("id", project.id())
|
||||
.query(Integer.class)
|
||||
.single())
|
||||
.isZero();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 편집기가 Decision 을 미리 그리려면 이 목록이 프로젝트의 공개 경로를
|
||||
* 알고 있어야 한다 — 한동안 늘 null 이었고, 그동안 Decision 은 어떤 문서에서도 즉시 미리보기가 열리지 않았다.
|
||||
*/
|
||||
@Test
|
||||
void projectCatalogCarriesThePublicPathOfPublishedProjectsOnly() {
|
||||
ProjectEditView open = projects.create(new CreateProjectCommand("Catalog Published", "test"));
|
||||
jdbcClient
|
||||
.sql("UPDATE project SET slug = 'catalog-published' WHERE id = :id")
|
||||
.param("id", open.id())
|
||||
.update();
|
||||
ProjectEditView loaded = projects.find(open.id()).orElseThrow();
|
||||
assertThat(projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test")).isPresent();
|
||||
|
||||
ProjectEditView hidden = projects.create(new CreateProjectCommand("Catalog Hidden", "test"));
|
||||
|
||||
var entries = catalog.search(CatalogEntryType.PROJECT, "catalog", null, 50).items();
|
||||
|
||||
assertThat(entries)
|
||||
.filteredOn(entry -> entry.id().equals(open.id()))
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
entry -> assertThat(entry.publicPath()).isEqualTo("/projects/catalog-published"));
|
||||
// 게시되지 않은 프로젝트는 공개 주소가 실제로 없다. 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.
|
||||
assertThat(entries)
|
||||
.filteredOn(entry -> entry.id().equals(hidden.id()))
|
||||
.singleElement()
|
||||
.satisfies(entry -> assertThat(entry.publicPath()).isNull());
|
||||
}
|
||||
}
|
||||
+980
@@ -0,0 +1,980 @@
|
||||
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 공개 조회 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
|
||||
*
|
||||
* <p>{@code StudioPersistenceIntegrationTest} 와 같은 이유로 존재한다 — 이 저장소의 표준 {@code check} 는
|
||||
* Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이 테스트 없이는 <b>한 번도 실행되지 않은 채</b> 통과한다. 컴파일도 단위 테스트도
|
||||
* 컬럼 이름 오타, jsonb 캐스팅, {@code EXISTS} 서브쿼리의 상관 조건을 검증하지 못한다.
|
||||
*
|
||||
* <p>특히 두 가지를 겨냥한다.
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>공개 조건</b>({@code PublicSql#ACTIVE}) 이 모든 경로에 걸려 있는가 — 게시 취소({@code WITHDRAWN})나
|
||||
* 비공개({@code UNLISTED}) 자료가 어느 한 쿼리에서라도 새면 사고다. 그래서 모든 목록/상세 테스트에 "새면 안 되는 행"을 함께 심는다.
|
||||
* <li><b>총계와 목록이 같은 조건을 쓰는가</b> — 페이지네이션이 있는 여섯 operation 은 count 쿼리와 목록 쿼리를 따로 만든다. 조건이 갈라지면 마지막
|
||||
* 페이지가 비어 보이거나 없는 페이지 번호가 생긴다.
|
||||
* </ol>
|
||||
*/
|
||||
class PublicSitePersistenceIntegrationTest {
|
||||
|
||||
private static final String IMAGE =
|
||||
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
|
||||
|
||||
private static final UUID SITE_CONFIG_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID PROFILE_PAGE_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
private static final UUID HOME_FOCUS_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||
|
||||
private static PostgreSQLContainer postgres;
|
||||
private static HikariDataSource dataSource;
|
||||
private static JdbcClient jdbcClient;
|
||||
|
||||
private static JdbcPublicSiteQueryAdapter site;
|
||||
private static JdbcPublicExploreQueryAdapter explore;
|
||||
private static JdbcPublicTopicQueryAdapter topics;
|
||||
private static JdbcPublicDocumentQueryAdapter documents;
|
||||
private static JdbcPublicProjectQueryAdapter projects;
|
||||
private static JdbcPublicReleaseQueryAdapter releases;
|
||||
private static JdbcPublicSearchQueryAdapter search;
|
||||
|
||||
private static UUID topicId;
|
||||
private static UUID projectId;
|
||||
private static UUID tagId;
|
||||
private static UUID caseId;
|
||||
private static UUID referenceId;
|
||||
private static UUID questionId;
|
||||
private static UUID decisionId;
|
||||
private static UUID hiddenCaseId;
|
||||
|
||||
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
|
||||
@BeforeAll
|
||||
static void migrateAndSeed() {
|
||||
if (!DockerClientFactory.instance().isDockerAvailable()) {
|
||||
throw new IllegalStateException(
|
||||
"Docker is required for the public-site persistence integration test;"
|
||||
+ " skipping is forbidden");
|
||||
}
|
||||
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
|
||||
postgres.start();
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(postgres.getJdbcUrl());
|
||||
config.setUsername(postgres.getUsername());
|
||||
config.setPassword(postgres.getPassword());
|
||||
config.setMaximumPoolSize(5);
|
||||
config.setMinimumIdle(1);
|
||||
dataSource = new HikariDataSource(config);
|
||||
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration/postgresql")
|
||||
.baselineOnMigrate(false)
|
||||
.outOfOrder(false)
|
||||
.load()
|
||||
.migrate();
|
||||
|
||||
jdbcClient = JdbcClient.create(dataSource);
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
site = new JdbcPublicSiteQueryAdapter(jdbcClient, objectMapper);
|
||||
explore = new JdbcPublicExploreQueryAdapter(jdbcClient);
|
||||
topics = new JdbcPublicTopicQueryAdapter(jdbcClient);
|
||||
documents = new JdbcPublicDocumentQueryAdapter(jdbcClient, objectMapper);
|
||||
projects = new JdbcPublicProjectQueryAdapter(jdbcClient, objectMapper);
|
||||
releases = new JdbcPublicReleaseQueryAdapter(jdbcClient, objectMapper);
|
||||
search = new JdbcPublicSearchQueryAdapter(jdbcClient);
|
||||
|
||||
seed();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopPostgreSql() {
|
||||
if (dataSource != null) {
|
||||
dataSource.close();
|
||||
}
|
||||
if (postgres != null) {
|
||||
postgres.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- V9 스키마
|
||||
|
||||
@Test
|
||||
void v9CreatesEveryTableThePublicContractReads() {
|
||||
assertThat(tableExists("release")).isTrue();
|
||||
assertThat(tableExists("site_config")).isTrue();
|
||||
assertThat(tableExists("profile_page")).isTrue();
|
||||
assertThat(tableExists("home_focus_config")).isTrue();
|
||||
assertThat(tableExists("project_topic")).isTrue();
|
||||
assertThat(tableExists("topic_featured_document")).isTrue();
|
||||
}
|
||||
|
||||
/** 한 Topic 의 {@code START_HERE} 는 하나뿐이라는 부분 유니크 인덱스가 실제로 강제되는지. */
|
||||
@Test
|
||||
void aTopicCanOnlyHaveOneStartHereDocument() {
|
||||
UUID scratchTopic = insertTopic("start-here-probe", "Start Here Probe");
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
|
||||
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
|
||||
.param("t", scratchTopic)
|
||||
.param("d", referenceId)
|
||||
.update();
|
||||
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(
|
||||
() ->
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
|
||||
+ " display_order) VALUES (:t, :d, 'START_HERE', 1)")
|
||||
.param("t", scratchTopic)
|
||||
.param("d", caseId)
|
||||
.update())
|
||||
.as("uq_topic_start_here 가 한 주제의 두 번째 START_HERE 를 막아야 한다")
|
||||
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
|
||||
|
||||
jdbcClient
|
||||
.sql("DELETE FROM topic_featured_document WHERE topic_id = :t")
|
||||
.param("t", scratchTopic)
|
||||
.update();
|
||||
jdbcClient.sql("DELETE FROM topic WHERE id = :id").param("id", scratchTopic).update();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 사이트 · 홈 · 프로필
|
||||
|
||||
@Test
|
||||
void siteReadsTheSingleRowConfigWithItsContacts() {
|
||||
SiteView view = site.site().orElseThrow();
|
||||
|
||||
assertThat(view.brandTitle()).isEqualTo("Tech Log");
|
||||
assertThat(view.operatorDisplayName()).isEqualTo("동현");
|
||||
assertThat(view.operatorProfilePath()).isEqualTo("/profile");
|
||||
assertThat(view.contacts()).hasSize(1);
|
||||
assertThat(view.contacts().getFirst().type()).isEqualTo("GITHUB");
|
||||
assertThat(view.contacts().getFirst().url()).isEqualTo("https://github.com/example");
|
||||
}
|
||||
|
||||
@Test
|
||||
void homeResolvesTheConfiguredFocusAndTheLatestEntries() {
|
||||
HomeView view = site.home(10);
|
||||
|
||||
assertThat(view.focus().defaultType()).isEqualTo("CURRENT_WORK");
|
||||
assertThat(view.focus().currentWork()).isNotNull();
|
||||
assertThat(view.focus().currentWork().projectPath()).isEqualTo("/projects/tech-log");
|
||||
assertThat(view.latestEntries()).isNotEmpty();
|
||||
assertThat(view.latestEntries())
|
||||
.as("게시 취소된 자료는 최신 목록에 없어야 한다")
|
||||
.noneMatch(entry -> entry.title().contains("숨김"));
|
||||
assertThat(view.latestEntries())
|
||||
.as(
|
||||
"계약 LatestEntry.entryType 이 허용하는 값만 나와야 한다 — projection 의 PROJECT/PROJECT_DECISION"
|
||||
+ " 등이 섞이면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다")
|
||||
.extracting("entryType")
|
||||
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
|
||||
.allSatisfy(
|
||||
type ->
|
||||
assertThat(type)
|
||||
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
|
||||
assertThat(view.latestEntries())
|
||||
.as("게시한 Open Question 도 최근 기록에 나와야 한다 — 목록에서 빠지면 게시한 사실이 어디에도 보이지 않는다")
|
||||
.anySatisfy(
|
||||
entry -> {
|
||||
assertThat(entry.entryType()).isEqualTo("QUESTION");
|
||||
assertThat(entry.path()).isEqualTo("/questions/reprocessing-latency");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 갓 마이그레이션한 상태에서 {@code default_focus_type} 은 NULL 이다. 계약은 이 필드를 required 로 선언하고 값 셋만 허용하므로, NULL
|
||||
* 이 그대로 나가면 홈 화면 전체가 500 이 된다 — 실제 앱 기동 후 첫 요청에서 그렇게 깨졌다. 설정이 비어도 계약이 아는 값 하나로 정해져야 한다.
|
||||
*/
|
||||
@Test
|
||||
void homeFocusFallsBackToAContractValueWhenNothingIsConfigured() {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE home_focus_config SET default_focus_type = NULL,"
|
||||
+ " current_project_id = NULL, open_question_id = NULL,"
|
||||
+ " recent_decision_id = NULL WHERE id = :id")
|
||||
.param("id", HOME_FOCUS_ID)
|
||||
.update();
|
||||
try {
|
||||
HomeView view = site.home(10);
|
||||
assertThat(view.focus().defaultType())
|
||||
.isIn("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION");
|
||||
assertThat(view.focus().currentWork()).isNull();
|
||||
assertThat(view.focus().openQuestion()).isNull();
|
||||
assertThat(view.focus().recentDecision()).isNull();
|
||||
|
||||
// 설정은 비어 있지만 내용이 있는 갈래가 있으면 그쪽을 고른다.
|
||||
jdbcClient
|
||||
.sql("UPDATE home_focus_config SET open_question_id = :q WHERE id = :id")
|
||||
.param("q", questionId)
|
||||
.param("id", HOME_FOCUS_ID)
|
||||
.update();
|
||||
assertThat(site.home(10).focus().defaultType()).isEqualTo("OPEN_QUESTION");
|
||||
} finally {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
|
||||
+ " current_project_id = :project, open_question_id = :question,"
|
||||
+ " recent_decision_id = :decision WHERE id = :id")
|
||||
.param("id", HOME_FOCUS_ID)
|
||||
.param("project", projectId)
|
||||
.param("question", questionId)
|
||||
.param("decision", decisionId)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileReadsItsJsonbColumnsIntoTypedViews() {
|
||||
ProfileView view = site.profile().orElseThrow();
|
||||
|
||||
assertThat(view.headline()).isEqualTo("문제를 재현해 검증한다");
|
||||
assertThat(view.workingModel()).extracting(ProfileView.NamedDescription::name).contains("재현");
|
||||
assertThat(view.territories()).extracting(ProfileView.Territory::name).contains("Kafka");
|
||||
assertThat(view.contacts()).hasSize(1);
|
||||
assertThat(view.selectedEvidence())
|
||||
.as("selected_evidence 는 공개된 것만 되살린다")
|
||||
.extracting("title")
|
||||
.containsExactly("Kafka 재처리");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 탐색
|
||||
|
||||
@Test
|
||||
void knowledgeListsOnlyPublishedCasesAndReferences() {
|
||||
KnowledgePageView page =
|
||||
explore.knowledge(
|
||||
new ExploreKnowledgeQuery(null, null, null, null, null, null, page(1, 20)));
|
||||
|
||||
assertThat(page.items()).extracting("title").contains("Kafka 재처리", "Kafka 운영 기준");
|
||||
assertThat(page.items()).extracting("title").doesNotContain("숨김 Case");
|
||||
assertThat(page.page().totalElements())
|
||||
.as("총계와 목록이 같은 조건을 써야 한다")
|
||||
.isEqualTo(page.items().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void knowledgeAppliesEveryContractFilter() {
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery("CASE", null, null, null, null, null, page(1, 20)))
|
||||
.items())
|
||||
.extracting("type")
|
||||
.containsOnly("CASE");
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(null, "kafka", null, null, null, null, page(1, 20)))
|
||||
.items())
|
||||
.isNotEmpty();
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(
|
||||
null, "no-such-topic", null, null, null, null, page(1, 20)))
|
||||
.items())
|
||||
.isEmpty();
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(
|
||||
null, null, null, "reprocessing", null, null, page(1, 20)))
|
||||
.items())
|
||||
.as("tag 필터의 상관 EXISTS 서브쿼리")
|
||||
.extracting("title")
|
||||
.containsExactly("Kafka 재처리");
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(null, null, null, null, 1999, null, page(1, 20)))
|
||||
.items())
|
||||
.as("year 필터는 date_part 로 건다")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/** 계약의 정렬 세 값이 전부 유효한 SQL 이어야 한다 — 오타는 문법 오류로만 드러난다. */
|
||||
@Test
|
||||
void knowledgeAcceptsEveryContractSort() {
|
||||
for (String sort : List.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC")) {
|
||||
assertThat(
|
||||
explore
|
||||
.knowledge(
|
||||
new ExploreKnowledgeQuery(null, null, null, null, null, sort, page(1, 20)))
|
||||
.items())
|
||||
.as("sort=%s", sort)
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void questionsListAppliesStatusTagAndEverySort() {
|
||||
QuestionPageView all =
|
||||
explore.questions(new ExploreQuestionsQuery(null, null, null, null, null, page(1, 20)));
|
||||
assertThat(all.items()).extracting("question").contains("재처리 지연을 어떻게 줄일까");
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.questions(
|
||||
new ExploreQuestionsQuery("RESOLVED", null, null, null, null, page(1, 20)))
|
||||
.items())
|
||||
.isEmpty();
|
||||
|
||||
assertThat(
|
||||
explore
|
||||
.questions(
|
||||
new ExploreQuestionsQuery(null, null, null, "reprocessing", null, page(1, 20)))
|
||||
.items())
|
||||
.as("질문에도 tag 필터가 걸려야 한다")
|
||||
.isNotEmpty();
|
||||
|
||||
for (String sort : List.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC")) {
|
||||
assertThat(
|
||||
explore
|
||||
.questions(new ExploreQuestionsQuery(null, null, null, null, sort, page(1, 20)))
|
||||
.items())
|
||||
.as("sort=%s", sort)
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 주제
|
||||
|
||||
@Test
|
||||
void topicListCountsOnlyPublishedRecords() {
|
||||
assertThat(topics.list()).extracting("slug").contains("kafka");
|
||||
|
||||
var kafka =
|
||||
topics.list().stream().filter(t -> t.slug().equals("kafka")).findFirst().orElseThrow();
|
||||
// Case · Reference · Question 셋만 이 주제를 primary 로 가지며, 게시 취소된 Case 는 세지 않는다.
|
||||
assertThat(kafka.recordCount()).as("게시 취소된 자료는 세지 않는다").isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void topicDetailResolvesEverySection() {
|
||||
TopicDetailView view = topics.findBySlug("kafka").orElseThrow();
|
||||
|
||||
assertThat(view.name()).isEqualTo("Kafka");
|
||||
assertThat(view.featuredReference()).isNotNull();
|
||||
assertThat(view.featuredReference().title()).isEqualTo("Kafka 운영 기준");
|
||||
assertThat(view.activeQuestions()).isNotEmpty();
|
||||
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
|
||||
assertThat(view.latestRecords()).isNotEmpty();
|
||||
assertThat(view.latestRecords())
|
||||
.as("주제 상세의 최신 기록도 계약의 entryType 을 벗어나면 안 된다")
|
||||
.extracting("entryType")
|
||||
.allSatisfy(
|
||||
type ->
|
||||
assertThat(type)
|
||||
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
|
||||
assertThat(view.latestRecords())
|
||||
.as("주제와 홈은 같은 목록 의미를 쓴다 — 홈에 나오는 Open Question 이 여기서 빠지면 두 화면이 어긋난다")
|
||||
.extracting("entryType")
|
||||
.contains("QUESTION");
|
||||
}
|
||||
|
||||
@Test
|
||||
void topicDetailIsAbsentForAnUnknownSlug() {
|
||||
assertThat(topics.findBySlug("no-such-topic")).isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 문서 상세
|
||||
|
||||
@Test
|
||||
void caseDetailReadsTheOriginalTableNotTheProjectionPayload() {
|
||||
CaseDetailView view = documents.findCase("kafka-reprocessing").orElseThrow();
|
||||
|
||||
assertThat(view.canonicalPath()).isEqualTo("/cases/kafka-reprocessing");
|
||||
assertThat(view.document().title()).isEqualTo("Kafka 재처리");
|
||||
assertThat(view.document().primarySummary()).isEqualTo("재처리가 지연된다");
|
||||
assertThat(view.document().secondarySummary()).isEqualTo("컨슈머 랙을 먼저 본다");
|
||||
assertThat(view.document().content()).contains("# 재처리");
|
||||
assertThat(view.document().contentFormat()).isEqualTo("MARKDOWN");
|
||||
assertThat(view.document().environmentSummary()).containsExactly("Kafka 3.7");
|
||||
assertThat(view.document().primaryTopic().slug()).isEqualTo("kafka");
|
||||
assertThat(view.document().primaryProject().slug()).isEqualTo("tech-log");
|
||||
assertThat(view.document().tags()).extracting("slug").containsExactly("reprocessing");
|
||||
}
|
||||
|
||||
@Test
|
||||
void referenceDetailReadsItsOwnScopeColumns() {
|
||||
ReferenceDetailView view = documents.findReference("kafka-operations").orElseThrow();
|
||||
|
||||
assertThat(view.document().primarySummary()).isEqualTo("운영 기준을 정한다");
|
||||
assertThat(view.document().appliesTo()).containsExactly("Kafka 3.x");
|
||||
assertThat(view.document().excludedScope()).containsExactly("Kinesis");
|
||||
assertThat(view.document().freshnessStatus()).isEqualTo("CURRENT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void questionDetailReadsItsTimelineAndResolutionColumns() {
|
||||
QuestionDetailView view = documents.findQuestion("reprocessing-latency").orElseThrow();
|
||||
|
||||
assertThat(view.question().question()).isEqualTo("재처리 지연을 어떻게 줄일까");
|
||||
assertThat(view.question().status()).isEqualTo("OPEN");
|
||||
assertThat(view.question().resolvedAt()).isNull();
|
||||
assertThat(view.question().points()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWithdrawnDocumentIsNotReadable() {
|
||||
assertThat(documents.findCase("hidden-case")).as("게시 취소된 문서는 상세로도 열리면 안 된다").isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 프로젝트
|
||||
|
||||
@Test
|
||||
void projectListAndDetailReadEveryPublishedColumn() {
|
||||
assertThat(projects.list()).extracting("slug").containsExactly("tech-log");
|
||||
|
||||
ProjectDetailView view = projects.findBySlug("tech-log").orElseThrow();
|
||||
assertThat(view.project().name()).isEqualTo("Tech Log");
|
||||
assertThat(view.project().technologies()).contains("Spring Boot");
|
||||
assertThat(view.canonicalPath()).isEqualTo("/projects/tech-log");
|
||||
assertThat(view.featuredDecision()).isNotNull();
|
||||
assertThat(view.selectedRecords()).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectSubListsReturnEmptyOptionalForAnUnknownProject() {
|
||||
assertThat(projects.decisions(new ProjectDecisionPageQuery("nope", null, page(1, 20))))
|
||||
.isEmpty();
|
||||
assertThat(projects.records(new ProjectRecordPageQuery("nope", null, null, page(1, 20))))
|
||||
.isEmpty();
|
||||
assertThat(projects.activities(new ProjectPageQuery("nope", page(1, 20)))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectDecisionsApplyTheStatusFilterToBothCountAndPage() {
|
||||
var all =
|
||||
projects
|
||||
.decisions(new ProjectDecisionPageQuery("tech-log", null, page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(all.items()).hasSize(1);
|
||||
assertThat(all.page().totalElements()).isEqualTo(1);
|
||||
|
||||
var accepted =
|
||||
projects
|
||||
.decisions(new ProjectDecisionPageQuery("tech-log", "ACCEPTED", page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(accepted.items()).hasSize(1);
|
||||
assertThat(accepted.page().totalElements()).isEqualTo(1);
|
||||
|
||||
var proposed =
|
||||
projects
|
||||
.decisions(new ProjectDecisionPageQuery("tech-log", "PROPOSED", page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(proposed.items()).isEmpty();
|
||||
assertThat(proposed.page().totalElements()).as("필터가 목록에만 걸리고 총계에 안 걸리면 여기서 드러난다").isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectRecordsApplyTypeAndRelationFilters() {
|
||||
var all =
|
||||
projects
|
||||
.records(new ProjectRecordPageQuery("tech-log", null, null, page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(all.items()).isNotEmpty();
|
||||
assertThat(all.page().totalElements()).isEqualTo(all.items().size());
|
||||
|
||||
var cases =
|
||||
projects
|
||||
.records(new ProjectRecordPageQuery("tech-log", "CASE", null, page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(cases.items()).extracting("type").containsOnly("CASE");
|
||||
|
||||
var related =
|
||||
projects
|
||||
.records(new ProjectRecordPageQuery("tech-log", null, "RELATED", page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(related.page().totalElements()).isEqualTo(related.items().size());
|
||||
|
||||
var none =
|
||||
projects
|
||||
.records(new ProjectRecordPageQuery("tech-log", "QUESTION", "RELATED", page(1, 20)))
|
||||
.orElseThrow();
|
||||
assertThat(none.page().totalElements()).isEqualTo(none.items().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectActivitiesListOnlyPublicOnes() {
|
||||
var activities =
|
||||
projects.activities(new ProjectPageQuery("tech-log", page(1, 20))).orElseThrow();
|
||||
|
||||
assertThat(activities.items()).extracting("title").containsExactly("첫 게시");
|
||||
assertThat(activities.page().totalElements()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 릴리스
|
||||
|
||||
@Test
|
||||
void releasesListOnlyPublishedOnesAndResolveRelatedRecords() {
|
||||
assertThat(releases.list()).extracting("version").containsExactly("1.0.0");
|
||||
|
||||
ReleaseDetailView detail = releases.findByVersion("1.0.0").orElseThrow();
|
||||
assertThat(detail.title()).isEqualTo("첫 공개");
|
||||
assertThat(detail.changeTypes()).containsExactly("ADDED");
|
||||
assertThat(detail.relatedRecords())
|
||||
.as("related_resources 는 공개된 것만 되살린다")
|
||||
.extracting("title")
|
||||
.containsExactly("Kafka 재처리");
|
||||
|
||||
assertThat(releases.findByVersion("0.9.0")).as("DRAFT 릴리스는 열리면 안 된다").isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 검색
|
||||
|
||||
@Test
|
||||
void searchMatchesOnSearchTextAndAppliesFilters() {
|
||||
SearchResultPageView hits = search.search(new SearchQuery("재처리", null, null, page(1, 20)));
|
||||
|
||||
assertThat(hits.query()).isEqualTo("재처리");
|
||||
assertThat(hits.items()).isNotEmpty();
|
||||
assertThat(hits.page().totalElements()).isEqualTo(hits.items().size());
|
||||
assertThat(hits.items()).extracting("title").doesNotContain("숨김 Case");
|
||||
|
||||
assertThat(search.search(new SearchQuery("기준", "REFERENCE", null, page(1, 20))).items())
|
||||
.extracting("contentType")
|
||||
.containsOnly("REFERENCE");
|
||||
assertThat(search.search(new SearchQuery("기준", "CASE", null, page(1, 20))).items())
|
||||
.as("type 필터가 실제로 걸려야 한다")
|
||||
.isEmpty();
|
||||
|
||||
assertThat(search.search(new SearchQuery("존재하지않는단어", null, null, page(1, 20))).items())
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 시딩
|
||||
|
||||
/**
|
||||
* 삽입 순서가 곧 제약이다. {@code public_resource_project_link}/{@code public_resource_tag} 는 {@code
|
||||
* public_resource_projection} 을 복합 FK 로 참조하므로 원본 테이블 → projection → 링크/태그 순서를 지킨다.
|
||||
*/
|
||||
private static void seed() {
|
||||
topicId = insertTopic("kafka", "Kafka");
|
||||
|
||||
projectId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project (id, slug, name, one_line_purpose, purpose_markdown,"
|
||||
+ " boundary_markdown, system_overview_markdown, phase, current_objective,"
|
||||
+ " next_step, technology_labels, workflow_status, target_visibility,"
|
||||
+ " created_by, updated_by)"
|
||||
+ " VALUES (:id, 'tech-log', 'Tech Log', '기록을 남긴다', '목적', '경계', '개요',"
|
||||
+ " 'IMPLEMENTATION', '공개 API 완성', '통합 테스트',"
|
||||
+ " '[\"Spring Boot\", \"PostgreSQL\"]'::jsonb, 'PUBLISHED', 'PUBLIC',"
|
||||
+ " 'test', 'test')")
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
|
||||
tagId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO tag (id, name, normalized_name, slug, created_by, updated_by)"
|
||||
+ " VALUES (:id, 'reprocessing', 'reprocessing', 'reprocessing', 'test', 'test')")
|
||||
.param("id", tagId)
|
||||
.update();
|
||||
|
||||
// --- Case (공개) ---
|
||||
caseId = UUID.randomUUID();
|
||||
insertDocument(caseId, "CASE", "kafka-reprocessing", "Kafka 재처리", topicId);
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO case_detail (document_id, problem_summary, conclusion_summary,"
|
||||
+ " environment_items) VALUES (:id, '재처리가 지연된다', '컨슈머 랙을 먼저 본다',"
|
||||
+ " '[\"Kafka 3.7\"]'::jsonb)")
|
||||
.param("id", caseId)
|
||||
.update();
|
||||
publish(
|
||||
"CASE",
|
||||
caseId,
|
||||
"Kafka 재처리",
|
||||
"재처리가 지연된다",
|
||||
"/cases/kafka-reprocessing",
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
topicId);
|
||||
link(caseId, "CASE", "PRIMARY", 0);
|
||||
tag(caseId, "CASE");
|
||||
// 목록의 tag 필터는 projection(public_resource_tag)을, 상세는 원본(document_tag)을 읽는다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO document_tag (document_id, tag_id, display_order)" + " VALUES (:d, :t, 0)")
|
||||
.param("d", caseId)
|
||||
.param("t", tagId)
|
||||
.update();
|
||||
|
||||
// --- Reference (공개) ---
|
||||
referenceId = UUID.randomUUID();
|
||||
insertDocument(referenceId, "REFERENCE", "kafka-operations", "Kafka 운영 기준", topicId);
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO reference_detail (document_id, scope_summary, applies_to,"
|
||||
+ " excluded_scope, freshness_status) VALUES (:id, '운영 기준을 정한다',"
|
||||
+ " '[\"Kafka 3.x\"]'::jsonb, '[\"Kinesis\"]'::jsonb, 'CURRENT')")
|
||||
.param("id", referenceId)
|
||||
.update();
|
||||
publish(
|
||||
"REFERENCE",
|
||||
referenceId,
|
||||
"Kafka 운영 기준",
|
||||
"운영 기준을 정한다",
|
||||
"/references/kafka-operations",
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
topicId);
|
||||
link(referenceId, "REFERENCE", "RELATED", null);
|
||||
|
||||
// --- Case (게시 취소) — 어느 경로로도 새면 안 된다 ---
|
||||
hiddenCaseId = UUID.randomUUID();
|
||||
insertDocument(hiddenCaseId, "CASE", "hidden-case", "숨김 Case", topicId);
|
||||
jdbcClient
|
||||
.sql("INSERT INTO case_detail (document_id, problem_summary) VALUES (:id, '재처리 비밀')")
|
||||
.param("id", hiddenCaseId)
|
||||
.update();
|
||||
publish(
|
||||
"CASE",
|
||||
hiddenCaseId,
|
||||
"숨김 Case",
|
||||
"재처리 비밀",
|
||||
"/cases/hidden-case",
|
||||
"WITHDRAWN",
|
||||
"PUBLIC",
|
||||
topicId);
|
||||
|
||||
// --- OpenQuestion (공개) ---
|
||||
questionId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO open_question (id, slug, question, summary, context_markdown,"
|
||||
+ " importance_markdown, next_verification, question_status, target_visibility,"
|
||||
+ " primary_topic_id, opened_at, created_by, updated_by)"
|
||||
+ " VALUES (:id, 'reprocessing-latency', '재처리 지연을 어떻게 줄일까',"
|
||||
+ " '지연 원인을 좁힌다', '맥락', '중요도', '컨슈머 랙 측정', 'OPEN', 'PUBLIC', :topic,"
|
||||
+ " :openedAt, 'test', 'test')")
|
||||
.param("id", questionId)
|
||||
.param("topic", topicId)
|
||||
.param("openedAt", java.sql.Timestamp.from(NOW.minus(10, ChronoUnit.DAYS)))
|
||||
.update();
|
||||
publish(
|
||||
"QUESTION",
|
||||
questionId,
|
||||
"재처리 지연을 어떻게 줄일까",
|
||||
"지연 원인을 좁힌다",
|
||||
"/questions/reprocessing-latency",
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
topicId);
|
||||
// 질문 목록의 status 필터는 projection 의 state_code 를 본다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE public_resource_projection SET state_code = 'OPEN'"
|
||||
+ " WHERE resource_type = 'QUESTION' AND resource_id = :id")
|
||||
.param("id", questionId)
|
||||
.update();
|
||||
link(questionId, "QUESTION", "PRIMARY", 1);
|
||||
tag(questionId, "QUESTION");
|
||||
|
||||
// --- ProjectDecision (공개) ---
|
||||
decisionId = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project_decision (id, project_id, statement, rationale_markdown,"
|
||||
+ " consequences, decision_status, target_visibility, source_question_id,"
|
||||
+ " source_case_id, is_featured, decided_at, created_by, updated_by)"
|
||||
+ " VALUES (:id, :project, '재처리는 별도 토픽으로 분리한다', '격리해야 관측이 쉬워진다',"
|
||||
+ " '[\"운영 토픽 증가\"]'::jsonb, 'ACCEPTED', 'PUBLIC', :question, :sourceCase,"
|
||||
+ " true, :decidedAt, 'test', 'test')")
|
||||
.param("id", decisionId)
|
||||
.param("project", projectId)
|
||||
.param("question", questionId)
|
||||
.param("sourceCase", caseId)
|
||||
.param("decidedAt", java.sql.Timestamp.from(NOW.minus(2, ChronoUnit.DAYS)))
|
||||
.update();
|
||||
publish(
|
||||
"PROJECT_DECISION",
|
||||
decisionId,
|
||||
"재처리는 별도 토픽으로 분리한다",
|
||||
"격리해야 관측이 쉬워진다",
|
||||
"/projects/tech-log/decisions/" + decisionId,
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
null);
|
||||
|
||||
// --- Project (공개) ---
|
||||
publish(
|
||||
"PROJECT",
|
||||
projectId,
|
||||
"Tech Log",
|
||||
"기록을 남긴다",
|
||||
"/projects/tech-log",
|
||||
"ACTIVE",
|
||||
"PUBLIC",
|
||||
null);
|
||||
|
||||
// --- 활동: 공개 하나 · 비공개 하나 ---
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
|
||||
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
|
||||
+ " created_by, updated_by)"
|
||||
+ " VALUES (gen_random_uuid(), :project, 'CASE_PUBLISHED', '첫 게시',"
|
||||
+ " '첫 문서를 공개했다', 'PUBLIC', 'AUTO', 'CASE', :relatedCase, :at,"
|
||||
+ " 'test', 'test')")
|
||||
.param("project", projectId)
|
||||
.param("relatedCase", caseId)
|
||||
.param("at", java.sql.Timestamp.from(NOW.minus(1, ChronoUnit.DAYS)))
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO project_activity (id, project_id, activity_type, title, visibility,"
|
||||
+ " origin, occurred_at, created_by, updated_by)"
|
||||
+ " VALUES (gen_random_uuid(), :project, 'MILESTONE_REACHED', '비공개 메모', 'PRIVATE',"
|
||||
+ " 'MANUAL', :at, 'test', 'test')")
|
||||
.param("project", projectId)
|
||||
.param("at", java.sql.Timestamp.from(NOW))
|
||||
.update();
|
||||
|
||||
// --- V9 연결 테이블 ---
|
||||
jdbcClient
|
||||
.sql("INSERT INTO project_topic (project_id, topic_id, display_order) VALUES (:p, :t, 0)")
|
||||
.param("p", projectId)
|
||||
.param("t", topicId)
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
|
||||
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
|
||||
.param("t", topicId)
|
||||
.param("d", referenceId)
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
|
||||
+ " display_order) VALUES (:t, :d, 'FEATURED_CASE', 0)")
|
||||
.param("t", topicId)
|
||||
.param("d", caseId)
|
||||
.update();
|
||||
|
||||
// --- 단일 행 설정 ---
|
||||
jdbcClient
|
||||
.sql("UPDATE site_config SET contacts = :contacts::jsonb WHERE id = :id")
|
||||
.param("id", SITE_CONFIG_ID)
|
||||
.param(
|
||||
"contacts",
|
||||
"[{\"type\":\"GITHUB\",\"label\":\"GitHub\","
|
||||
+ "\"url\":\"https://github.com/example\"}]")
|
||||
.update();
|
||||
|
||||
// V9 가 단일 행을 이미 시딩했으므로(INSERT ... ON CONFLICT DO NOTHING) 값 채우기는 UPDATE 다.
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE profile_page SET headline = '문제를 재현해 검증한다',"
|
||||
+ " introduction_markdown = '소개', working_model = :workingModel::jsonb,"
|
||||
+ " territories = :territories::jsonb, selected_evidence = :evidence::jsonb,"
|
||||
+ " trajectory = :trajectory::jsonb, contacts = :contacts::jsonb,"
|
||||
+ " target_visibility = 'PUBLIC' WHERE id = :id")
|
||||
.param("id", PROFILE_PAGE_ID)
|
||||
.param("workingModel", "[{\"name\":\"재현\",\"description\":\"먼저 재현한다\"}]")
|
||||
.param(
|
||||
"territories",
|
||||
"[{\"name\":\"Kafka\",\"currentQuestion\":\"재처리 지연\","
|
||||
+ "\"topicPath\":\"/topics/kafka\"}]")
|
||||
// 게시 취소된 자료 id 를 함께 넣는다 — 공개된 것만 되살아나야 한다.
|
||||
.param("evidence", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
|
||||
.param("trajectory", "[{\"title\":\"2026\",\"description\":\"Tech Log 시작\"}]")
|
||||
.param(
|
||||
"contacts",
|
||||
"[{\"type\":\"EMAIL\",\"label\":\"Email\"," + "\"url\":\"mailto:a@example.com\"}]")
|
||||
.update();
|
||||
|
||||
jdbcClient
|
||||
.sql(
|
||||
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
|
||||
+ " current_project_id = :project, open_question_id = :question,"
|
||||
+ " recent_decision_id = :decision WHERE id = :id")
|
||||
.param("id", HOME_FOCUS_ID)
|
||||
.param("project", projectId)
|
||||
.param("question", questionId)
|
||||
.param("decision", decisionId)
|
||||
.update();
|
||||
|
||||
// --- 릴리스: 공개 하나 · 초안 하나 ---
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO release (id, version_label, title, summary, released_on,"
|
||||
+ " workflow_status, change_types, changes_markdown, verification_markdown,"
|
||||
+ " related_resources, created_by, updated_by)"
|
||||
+ " VALUES (gen_random_uuid(), '1.0.0', '첫 공개', '공개 API 를 열었다',"
|
||||
+ " DATE '2026-08-01', 'PUBLISHED', '[\"ADDED\"]'::jsonb, '변경', '검증',"
|
||||
+ " :related::jsonb, 'test', 'test')")
|
||||
// 게시 취소된 자료 id 를 일부러 함께 넣는다 — 공개된 것만 되살아나야 한다.
|
||||
.param("related", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
|
||||
.update();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO release (id, version_label, title, summary, released_on,"
|
||||
+ " workflow_status, created_by, updated_by)"
|
||||
+ " VALUES (gen_random_uuid(), '0.9.0', '초안', '아직 공개 전', DATE '2026-07-01',"
|
||||
+ " 'DRAFT', 'test', 'test')")
|
||||
.update();
|
||||
}
|
||||
|
||||
private static UUID insertTopic(String slug, String name) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO topic (id, name, normalized_name, slug, description, scope,"
|
||||
+ " status, created_by, updated_by)"
|
||||
+ " VALUES (:id, :name, lower(:name), :slug, :name || ' 설명', '범위',"
|
||||
+ " 'ACTIVE', 'test', 'test')")
|
||||
.param("id", id)
|
||||
.param("name", name)
|
||||
.param("slug", slug)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static void insertDocument(UUID id, String type, String slug, String title, UUID topic) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO document (id, document_type, slug, title, body_markdown,"
|
||||
+ " content_format, content_format_version, workflow_status, target_visibility,"
|
||||
+ " primary_topic_id, last_verified_at, created_by, updated_by)"
|
||||
+ " VALUES (:id, :type, :slug, :title, '# 재처리\n본문', 'MARKDOWN', 1,"
|
||||
+ " 'PUBLISHED', 'PUBLIC', :topic, :verifiedAt, 'test', 'test')")
|
||||
.param("id", id)
|
||||
.param("type", type)
|
||||
.param("slug", slug)
|
||||
.param("title", title)
|
||||
.param("topic", topic)
|
||||
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
|
||||
.update();
|
||||
}
|
||||
|
||||
private static void link(UUID resourceId, String type, String relation, Integer order) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
|
||||
+ " relation_type, featured_order)"
|
||||
+ " VALUES (:type, :id, :project, :relation, :order)")
|
||||
.param("type", type)
|
||||
.param("id", resourceId)
|
||||
.param("project", projectId)
|
||||
.param("relation", relation)
|
||||
.param("order", order)
|
||||
.update();
|
||||
}
|
||||
|
||||
private static void tag(UUID resourceId, String type) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_tag (resource_type, resource_id, tag_id, display_order)"
|
||||
+ " VALUES (:type, :id, :tag, 0)")
|
||||
.param("type", type)
|
||||
.param("id", resourceId)
|
||||
.param("tag", tagId)
|
||||
.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* projection 행을 만든다. {@code public_resource_project_link} 와 {@code public_resource_tag} 가 이 행을
|
||||
* (resource_type, resource_id) 복합 FK 로 참조하므로 <b>반드시 링크·태그보다 먼저</b> 삽입해야 한다.
|
||||
*
|
||||
* <p>{@code topic} 을 인자로 받는 이유는 주제별 record 수를 세는 쿼리가 {@code primary_topic_id} 를 보기 때문이다. 모든
|
||||
* projection 에 같은 주제를 박아 두면 Project 나 Decision 까지 그 주제의 기록으로 세어져, 실제 값과 다른 숫자에 테스트를 맞추게 된다.
|
||||
*/
|
||||
private static void publish(
|
||||
String type,
|
||||
UUID id,
|
||||
String title,
|
||||
String summary,
|
||||
String path,
|
||||
String state,
|
||||
String visibility,
|
||||
UUID topic) {
|
||||
jdbcClient
|
||||
.sql(
|
||||
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
|
||||
+ " publication_state, visibility, title, summary, primary_topic_id,"
|
||||
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
|
||||
+ " published_at, updated_at, last_verified_at, navigation_path)"
|
||||
+ " VALUES (:type, :id, 1, :state, :visibility, :title, :summary, :topic, 1,"
|
||||
+ " '{}'::jsonb, :body, :search, repeat('a', 64), :publishedAt, :updatedAt,"
|
||||
+ " :verifiedAt, :path)")
|
||||
.param("type", type)
|
||||
.param("id", id)
|
||||
.param("state", state)
|
||||
.param("visibility", visibility)
|
||||
.param("title", title)
|
||||
.param("summary", summary)
|
||||
.param("topic", topic)
|
||||
.param("body", title + " " + summary)
|
||||
.param("search", title + " " + summary)
|
||||
.param("publishedAt", java.sql.Timestamp.from(NOW.minus(5, ChronoUnit.DAYS)))
|
||||
.param("updatedAt", java.sql.Timestamp.from(NOW.minus(4, ChronoUnit.DAYS)))
|
||||
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
|
||||
.param("path", path)
|
||||
.update();
|
||||
}
|
||||
|
||||
private static PublicPageRequest page(int page, int size) {
|
||||
return new PublicPageRequest(page, size);
|
||||
}
|
||||
|
||||
private static boolean tableExists(String table) {
|
||||
return Boolean.TRUE.equals(
|
||||
jdbcClient
|
||||
.sql(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables"
|
||||
+ " WHERE table_schema = 'public' AND table_name = :t)")
|
||||
.param("t", table)
|
||||
.query(Boolean.class)
|
||||
.single());
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,16 @@ sourceSets {
|
||||
}
|
||||
}
|
||||
|
||||
// objectstorage 모듈은 AWS SDK BOM 을 자기 스코프로만 import 한다(그 모듈 주석 참고 —
|
||||
// strict locking 의 영향 범위를 좁히려는 의도다). Spring 의 dependency-management 는
|
||||
// 소비자에게 전파되지 않으므로, 런타임을 조립하는 이쪽에서 같은 SSOT 로 한 번 더 선언한다.
|
||||
// 이것이 없으면 s3/netty-nio-client 가 버전 없이 남아 bootJar 가 해석에 실패한다.
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}"
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
sampleOffTestImplementation.extendsFrom testImplementation
|
||||
sampleOffTestCompileOnly.extendsFrom testCompileOnly
|
||||
@@ -48,6 +58,10 @@ dependencies {
|
||||
implementation project(':adapter:outbound:fileserver')
|
||||
implementation project(':adapter:outbound:httpclient')
|
||||
implementation project(':adapter:outbound:identifier')
|
||||
// Studio Asset 바이너리의 실제 저장 구현. 이것이 없으면 ObjectStorageAssetBinaryAdapter 의
|
||||
// ObjectProvider<ObjectStoragePort> 가 항상 비어서 업로드·삭제가 STUDIO_UNAVAILABLE 로
|
||||
// 거절된다 — 컴파일은 통과하므로 빌드로는 드러나지 않고 런타임에만 나타난다.
|
||||
implementation project(':adapter:outbound:objectstorage')
|
||||
implementation project(':adapter:inbound:web')
|
||||
implementation project(':shared-contract')
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
@@ -76,6 +90,17 @@ dependencies {
|
||||
// Security types for ManagementSecurityConfig (not reachable via adapter-web's implementation dep). See README.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
|
||||
// Redis-backed HTTP session for auth-mode=redis-session (the BFF surface the Studio contract
|
||||
// declares: sessionCookie TECHLOG_SESSION + X-CSRF-TOKEN). AuthenticationModeCompositionConfig
|
||||
// requires the `redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair once
|
||||
// that mode is active; StudioSessionInfrastructureConfig supplies the first, Spring Session's
|
||||
// SpringHttpSessionConfiguration the second.
|
||||
// OIDC Authorization Code 로그인 자동설정(ClientRegistrationRepository 등). 기존의
|
||||
// spring-security-oauth2-client 는 라이브러리만 주고 Boot 자동설정은 스타터가 준다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
||||
implementation 'org.springframework.session:spring-session-data-redis'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
// test-only: ArchUnit needs actuator types to verify the health-shape guardrail. See README.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
// test-only: @WithMockUser for the actuator security authorization tests. See README.
|
||||
|
||||
@@ -74,7 +74,7 @@ com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestR
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-codec:commons-codec:1.19.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
@@ -99,7 +99,7 @@ io.grpc:grpc-protobuf:1.68.1=conditionalTransportTestRuntimeClasspath
|
||||
io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath
|
||||
io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath
|
||||
io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -108,27 +108,30 @@ io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTranspor
|
||||
io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-buffer:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-buffer:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-classes-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-compression:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-http2:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-http3:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-http:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-marshalling:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-native-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-protobuf:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-socks:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-handler-proxy:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-handler:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-handler:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver-dns-native-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-classes-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-native-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-native-unix-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -147,7 +150,7 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas
|
||||
io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath
|
||||
io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
@@ -185,8 +188,8 @@ org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
@@ -268,7 +271,7 @@ org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -281,9 +284,10 @@ org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspa
|
||||
org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -297,15 +301,18 @@ org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspat
|
||||
org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-netty:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -314,6 +321,7 @@ org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspa
|
||||
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -336,8 +344,10 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntim
|
||||
org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath
|
||||
org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -346,20 +356,23 @@ org.springframework.security:spring-security-core:7.0.0=compileClasspath,functio
|
||||
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.session:spring-session-core:4.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context-support:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-oxm:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -374,7 +387,37 @@ org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffT
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:annotations:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:apache-client:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:arns:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:auth:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-query-protocol:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-xml-protocol:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:checksums-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:checksums:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:crt-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:endpoints-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-aws:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-client-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:identity-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:json-utils:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:metrics-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:netty-nio-client:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:profiles:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:protocol-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:regions:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:retries-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:retries:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:s3:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:sdk-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:third-party-jackson-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:utils:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
software.amazon.eventstream:eventstream:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
|
||||
+624
@@ -0,0 +1,624 @@
|
||||
package dev.caskeleton.bootstrap.contract;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
|
||||
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
|
||||
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
|
||||
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* {@code PublicContractDriftTest} 는 {@code StudioContractDriftTest} 가 studio-v1 에 대해 하는 일을
|
||||
* public-v1 에 대해 한다: springdoc 이 실제로 게시하는 표면과 vendored {@code src/config/openapi/public-v1.yaml} 을
|
||||
* <b>양방향</b>으로 대조한다.
|
||||
*
|
||||
* <p>스캔 범위는 {@code dev.caskeleton.adapter.inbound.web.techlog.publicapi} 다. 이 아래에 새 컨트롤러를 두면 이 파일을
|
||||
* 고치지 않아도 자동으로 감시 대상이 되고, 밖에 두면 게이트가 그것을 보지 못한다 — 그 성질과 함정은 형제 테스트의 클래스 javadoc 에 자세히 적혀 있다.
|
||||
*
|
||||
* <h2>계약의 {@code servers} 를 경로에 더해야 한다</h2>
|
||||
*
|
||||
* <p>studio-v1 은 {@code servers: "/"} 라 계약의 path 가 그대로 최종 주소지만, public-v1 은 {@code servers:
|
||||
* "/api/v1/public"} 이고 path 는 {@code /site} 처럼 짧다. 그래서 대조 전에 server prefix 를 붙인다 — 이걸 빠뜨리면
|
||||
* published 와 계약이 한 건도 겹치지 않는데도 "published ⊆ 계약" 방향은 비교 대상이 0건이라 통과해 버린다. 아래 {@code compared} 비어있지
|
||||
* 않음 단언이 그 상태를 실패로 만든다.
|
||||
*/
|
||||
class PublicContractDriftTest {
|
||||
|
||||
@Nested
|
||||
@SpringBootTest(classes = ContractSurface.ContractSurfaceApp.class)
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
class ContractSurface {
|
||||
|
||||
@Autowired private MockMvc mvc;
|
||||
|
||||
@Test
|
||||
void publishedPublicOperationsMatchTheContract() throws Exception {
|
||||
JsonNode contract = readContract();
|
||||
String prefix = serverPrefix(contract);
|
||||
JsonNode published = readPublishedApiDocs();
|
||||
|
||||
List<String> problems = new ArrayList<>();
|
||||
List<String> compared = new ArrayList<>();
|
||||
JsonNode publishedPaths = published.path("paths");
|
||||
for (Map.Entry<String, JsonNode> path : publishedPaths.properties()) {
|
||||
if (!path.getKey().startsWith(prefix + "/")) {
|
||||
continue;
|
||||
}
|
||||
compared.add(path.getKey());
|
||||
String contractKey = path.getKey().substring(prefix.length());
|
||||
JsonNode contractPath = contract.path("paths").path(contractKey);
|
||||
if (contractPath.isMissingNode()) {
|
||||
problems.add("계약에 없는 path: " + path.getKey());
|
||||
continue;
|
||||
}
|
||||
for (Map.Entry<String, JsonNode> method : path.getValue().properties()) {
|
||||
JsonNode contractOp = contractPath.path(method.getKey());
|
||||
if (contractOp.isMissingNode()) {
|
||||
problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey());
|
||||
continue;
|
||||
}
|
||||
String publishedId = method.getValue().path("operationId").asText("");
|
||||
String contractId = contractOp.path("operationId").asText("");
|
||||
if (!publishedId.equals(contractId)) {
|
||||
problems.add(
|
||||
"operationId 불일치 "
|
||||
+ method.getKey()
|
||||
+ " "
|
||||
+ path.getKey()
|
||||
+ ": published="
|
||||
+ publishedId
|
||||
+ " contract="
|
||||
+ contractId);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(problems).isEmpty();
|
||||
assertThat(compared)
|
||||
.as(
|
||||
"published 표면에서 "
|
||||
+ prefix
|
||||
+ " 경로를 하나도 대조하지 못했다 —"
|
||||
+ " PresentationWebConfig 의 api-base-path 배선이나 컨트롤러 매핑을 확인하라."
|
||||
+ " published paths="
|
||||
+ publishedPaths.properties().stream().map(Map.Entry::getKey).toList())
|
||||
.isNotEmpty();
|
||||
}
|
||||
|
||||
/** 반대 방향 — 계약의 19 operation 이 전부 published 표면에 있는가. */
|
||||
@Test
|
||||
void everyContractOperationIsPublished() throws Exception {
|
||||
JsonNode contract = readContract();
|
||||
String prefix = serverPrefix(contract);
|
||||
JsonNode published = readPublishedApiDocs();
|
||||
|
||||
List<String> missing = new ArrayList<>();
|
||||
int contractOperations = 0;
|
||||
for (Map.Entry<String, JsonNode> path : contract.path("paths").properties()) {
|
||||
for (Map.Entry<String, JsonNode> method : path.getValue().properties()) {
|
||||
JsonNode operationId = method.getValue().path("operationId");
|
||||
if (operationId.isMissingNode()) {
|
||||
continue;
|
||||
}
|
||||
contractOperations++;
|
||||
JsonNode publishedOperation =
|
||||
published.path("paths").path(prefix + path.getKey()).path(method.getKey());
|
||||
if (publishedOperation.isMissingNode()
|
||||
|| !operationId.asText().equals(publishedOperation.path("operationId").asText(""))) {
|
||||
missing.add(operationId.asText() + " (" + method.getKey() + " " + path.getKey() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(missing).as("계약이 약속했는데 서버가 제공하지 않는 operation").isEmpty();
|
||||
// 계약이 통째로 비거나 잘못 읽혀도 위 단언은 통과한다 — 순회할 게 없으면 missing 도 비니까.
|
||||
assertThat(contractOperations).as("public-v1 계약의 operation 수").isEqualTo(19);
|
||||
}
|
||||
|
||||
private static String serverPrefix(JsonNode contract) {
|
||||
String url = contract.path("servers").path(0).path("url").asText("");
|
||||
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
|
||||
}
|
||||
|
||||
private static JsonNode readContract() throws Exception {
|
||||
Path contractFile =
|
||||
RepositoryContractResources.fromSystemProperty()
|
||||
.requireTrackedFile("src/config/openapi/public-v1.yaml");
|
||||
Map<String, Object> contractYaml;
|
||||
try (InputStream in = Files.newInputStream(contractFile)) {
|
||||
contractYaml = new Yaml().load(in);
|
||||
}
|
||||
return new ObjectMapper().valueToTree(contractYaml);
|
||||
}
|
||||
|
||||
private JsonNode readPublishedApiDocs() throws Exception {
|
||||
String body =
|
||||
mvc.perform(get("/api/v3/api-docs"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
return new ObjectMapper().readTree(body);
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
|
||||
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.publicapi")
|
||||
@Import({PresentationWebConfig.class, PublicContractDriftTest.PublicPortStubs.class})
|
||||
static class ContractSurfaceApp {
|
||||
|
||||
@Bean
|
||||
PresentationSettings presentationSettings() {
|
||||
return new PresentationSettings("/api");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest(classes = EnvelopeWrapping.EnvelopeApp.class)
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
class EnvelopeWrapping {
|
||||
|
||||
@Autowired private MockMvc mvc;
|
||||
|
||||
/**
|
||||
* ADR-006: 공개 조회 응답도 봉투로 나간다. {@code /topics} 를 고른 이유는 반환 타입이 평범한 POJO 라 {@code
|
||||
* EnvelopeBodyAdvice} 가 감싸기 전후로 같은 JSON 컨버터가 처리하기 때문이다(형제 테스트가 {@code byte[]} 반환 컨트롤러에서 겪은
|
||||
* {@code ClassCastException} 을 피한다).
|
||||
*/
|
||||
@Test
|
||||
void everyPublicResponseIsWrappedInTheEnvelope() throws Exception {
|
||||
String body =
|
||||
mvc.perform(get("/api/v1/public/topics"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\"");
|
||||
assertThat(body).doesNotContain("\"data\":{\"success\"");
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
|
||||
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.publicapi")
|
||||
@Import({
|
||||
EnvelopeBodyAdvice.class,
|
||||
PresentationWebConfig.class,
|
||||
PublicContractDriftTest.PublicPortStubs.class
|
||||
})
|
||||
static class EnvelopeApp {
|
||||
|
||||
@Bean
|
||||
PresentationSettings presentationSettings() {
|
||||
return new PresentationSettings("/api");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 7개 outbound port 의 빈 stub 과 그 위에 올린 18개 use case. 첫 번째 테스트는 springdoc 리플렉션이라 컨트롤러 메서드를 아예 호출하지
|
||||
* 않고, 두 번째 테스트는 {@code /topics} 하나만 두드리며 감싸는 모양만 본다 — 실제 영속성 어댑터를 끌어오면 이 게이트가 말하려는 것(계약 표면과 봉투)과
|
||||
* 무관한 DB 인프라가 딸려 온다.
|
||||
*
|
||||
* <p>production 의 {@code TechLogPublicConfig} 를 그대로 {@code @Import} 하지 않는 이유는 그 클래스가
|
||||
* app-bootstrap 의 {@code main} 소스셋에 있고, 이 functionalTest 소스셋은 {@code main} output 을 클래스패스에 두지 않기
|
||||
* 때문이다. 억지로 넣으면 app-bootstrap 의 {@code AutoConfiguration.imports}(fileserver / httpclient)까지 함께
|
||||
* 활성화되어, 계약 표면만 보려는 최소 컨텍스트가 무관한 인프라를 요구하게 된다. 대신 같은 조립을 여기서 반복한다 — production 배선 자체는 앱 기동으로
|
||||
* 확인한다.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class PublicPortStubs {
|
||||
|
||||
@Bean
|
||||
TransactionPort transactionPort() {
|
||||
return new PassThroughTransactionPort();
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublicSiteQueryPort publicSiteQueryPort() {
|
||||
return new PublicSiteQueryPort() {
|
||||
@Override
|
||||
public Optional<SiteView> site() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HomeView home(int latestEntryLimit) {
|
||||
return new HomeView(null, List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProfileView> profile() {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublicExploreQueryPort publicExploreQueryPort() {
|
||||
return new PublicExploreQueryPort() {
|
||||
@Override
|
||||
public KnowledgePageView knowledge(ExploreKnowledgeQuery query) {
|
||||
return new KnowledgePageView(List.of(), PageMetadataView.of(1, 20, 0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuestionPageView questions(ExploreQuestionsQuery query) {
|
||||
return new QuestionPageView(List.of(), PageMetadataView.of(1, 20, 0));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublicTopicQueryPort publicTopicQueryPort() {
|
||||
return new PublicTopicQueryPort() {
|
||||
@Override
|
||||
public List<TopicListItemView> list() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TopicDetailView> findBySlug(String slug) {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublicDocumentQueryPort publicDocumentQueryPort() {
|
||||
return new PublicDocumentQueryPort() {
|
||||
@Override
|
||||
public Optional<CaseDetailView> findCase(String slug) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ReferenceDetailView> findReference(String slug) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<QuestionDetailView> findQuestion(String slug) {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublicProjectQueryPort publicProjectQueryPort() {
|
||||
return new PublicProjectQueryPort() {
|
||||
@Override
|
||||
public List<ProjectListItemView> list() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectDetailView> findBySlug(String slug) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectDecisionPageView> decisions(ProjectDecisionPageQuery query) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectRecordPageView> records(ProjectRecordPageQuery query) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProjectActivityPageView> activities(ProjectPageQuery query) {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublicReleaseQueryPort publicReleaseQueryPort() {
|
||||
return new PublicReleaseQueryPort() {
|
||||
@Override
|
||||
public List<ReleaseListItemView> list() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ReleaseDetailView> findByVersion(String version) {
|
||||
return Optional.empty();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublicSearchQueryPort publicSearchQueryPort() {
|
||||
return new PublicSearchQueryPort() {
|
||||
@Override
|
||||
public SearchResultPageView search(SearchQuery query) {
|
||||
return new SearchResultPageView(query.query(), List.of(), PageMetadataView.of(1, 20, 0));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 표면은 Studio 의 Asset 저장소를 읽는다. 이 게이트는 계약 표면만 확인하므로 저장소 두 개를 비어 있는 것으로 세워 컨트롤러가 조립되게만 한다.
|
||||
*/
|
||||
@Bean
|
||||
dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase
|
||||
getPublicMediaUseCase(TransactionPort tx) {
|
||||
return new dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase(
|
||||
new StubAssetRepositoryPortForMedia(),
|
||||
new dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort() {
|
||||
@Override
|
||||
public String store(String objectKey, byte[] content, String mediaType) {
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<byte[]> read(String objectKey) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String objectKey) {
|
||||
// 이 게이트는 바이너리를 다루지 않는다.
|
||||
}
|
||||
},
|
||||
tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicSiteUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicHomeUseCase getPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicHomeUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicProfileUseCase getPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicProfileUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ExploreKnowledgeUseCase exploreKnowledgeUseCase(
|
||||
PublicExploreQueryPort port, TransactionPort tx) {
|
||||
return new ExploreKnowledgeUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ExploreQuestionsUseCase exploreQuestionsUseCase(
|
||||
PublicExploreQueryPort port, TransactionPort tx) {
|
||||
return new ExploreQuestionsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicTopicsUseCase listPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicTopicsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicTopicUseCase getPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicTopicUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicCaseUseCase getPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicCaseUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicReferenceUseCase getPublicReferenceUseCase(
|
||||
PublicDocumentQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicReferenceUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicQuestionUseCase getPublicQuestionUseCase(
|
||||
PublicDocumentQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicQuestionUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicProjectsUseCase listPublicProjectsUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicProjectsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicProjectUseCase getPublicProjectUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicProjectUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicProjectDecisionsUseCase listPublicProjectDecisionsUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicProjectDecisionsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicProjectRecordsUseCase listPublicProjectRecordsUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicProjectRecordsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicProjectActivitiesUseCase listPublicProjectActivitiesUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicProjectActivitiesUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicReleasesUseCase listPublicReleasesUseCase(
|
||||
PublicReleaseQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicReleasesUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicReleaseUseCase getPublicReleaseUseCase(
|
||||
PublicReleaseQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicReleaseUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SearchPublicResourcesUseCase searchPublicResourcesUseCase(
|
||||
PublicSearchQueryPort port, TransactionPort tx) {
|
||||
return new SearchPublicResourcesUseCase(port, tx);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PassThroughTransactionPort implements TransactionPort {
|
||||
@Override
|
||||
public <T> T inWrite(Supplier<T> action) {
|
||||
return action.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inRootWrite(Supplier<T> action) {
|
||||
return action.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inRead(Supplier<T> action) {
|
||||
return action.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inNew(Supplier<T> action) {
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 컨트롤러를 조립하기 위한 최소 Asset 저장소. {@code StudioContractDriftTest} 의 것과 같은 모양이며, 이 게이트는 계약 표면만 보므로
|
||||
* 어떤 Asset 도 돌려주지 않는다.
|
||||
*/
|
||||
private static final class StubAssetRepositoryPortForMedia
|
||||
implements dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort {
|
||||
|
||||
@Override
|
||||
public dev.caskeleton.application.techlog.studio.model.AssetPageView list(
|
||||
dev.caskeleton.application.techlog.studio.query.ListAssetsQuery query) {
|
||||
return new dev.caskeleton.application.techlog.studio.model.AssetPageView(List.of(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetView> find(
|
||||
java.util.UUID assetId) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetDetailView>
|
||||
findDetail(java.util.UUID assetId) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public dev.caskeleton.application.techlog.studio.model.AssetView create(
|
||||
NewAsset asset, String principal) {
|
||||
throw new UnsupportedOperationException("this contract-shape gate never stores an asset");
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetView> update(
|
||||
java.util.UUID assetId,
|
||||
long expectedVersion,
|
||||
dev.caskeleton.application.techlog.studio.model.AssetKindView kind,
|
||||
String altText,
|
||||
boolean altTextProvided,
|
||||
Boolean decorative,
|
||||
dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView managementStatus,
|
||||
String principal) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<String> findObjectKey(java.util.UUID assetId) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(java.util.UUID assetId) {
|
||||
// 이 게이트는 삭제하지 않는다.
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-13
@@ -46,18 +46,24 @@ import org.yaml.snakeyaml.Yaml;
|
||||
* <em>implemented</em> operations are checked (direction is "published ⊆ contract", never the
|
||||
* reverse), so this stays green as slices 2-5 add the other 17 operations — <b>on one
|
||||
* condition</b>: the new controllers must live somewhere under {@code
|
||||
* dev.caskeleton.adapter.inbound.web.techlog}, the package {@link
|
||||
* dev.caskeleton.adapter.inbound.web.techlog.studio}, the package {@link
|
||||
* ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp}
|
||||
* {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to
|
||||
* this file. A controller placed <em>outside</em> that package tree is invisible to both minimal
|
||||
* contexts — springdoc never sees it, so this gate stays green even if its path/method/operationId
|
||||
* contradicts the contract — and the {@code @ComponentScan} base package below must be widened (or
|
||||
* the new controller moved) before this gate can be trusted again. (An earlier draft of this class
|
||||
* named the two controllers directly via {@code @Import} instead of scanning; that hardcoded list
|
||||
* had exactly this blind spot — confirmed by temporarily reintroducing it and observing a
|
||||
* controller with an out-of-contract mapping pass silently, see task-10-report.md.) This test also
|
||||
* fails the moment an in-scan controller's method name drifts from its {@code operationId} or ships
|
||||
* an endpoint outside the contract.
|
||||
* {@code @ComponentScan}. (The scan sat one level higher — {@code ...web.techlog} — until the
|
||||
* public-v1 controllers arrived under {@code ...web.techlog.publicapi}: scanning those pulled a
|
||||
* second contract's controllers into a Studio-only context, which then needs their use-case beans
|
||||
* and has nothing to say about their contract. {@code PublicContractDriftTest} is this same gate
|
||||
* for that tree, scanning {@code ...web.techlog.publicapi} against {@code public-v1.yaml}, so each
|
||||
* contract keeps the automatic-pickup property inside its own package.) A controller placed there
|
||||
* is picked up automatically, with no edit to this file. A controller placed <em>outside</em> that
|
||||
* package tree is invisible to both minimal contexts — springdoc never sees it, so this gate stays
|
||||
* green even if its path/method/operationId contradicts the contract — and the
|
||||
* {@code @ComponentScan} base package below must be widened (or the new controller moved) before
|
||||
* this gate can be trusted again. (An earlier draft of this class named the two controllers
|
||||
* directly via {@code @Import} instead of scanning; that hardcoded list had exactly this blind spot
|
||||
* — confirmed by temporarily reintroducing it and observing a controller with an out-of-contract
|
||||
* mapping pass silently, see task-10-report.md.) This test also fails the moment an in-scan
|
||||
* controller's method name drifts from its {@code operationId} or ships an endpoint outside the
|
||||
* contract.
|
||||
*
|
||||
* <h2>Why a hand-built minimal context rather than {@code CaSkeletonApplication}</h2>
|
||||
*
|
||||
@@ -277,7 +283,7 @@ class StudioContractDriftTest {
|
||||
*/
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
|
||||
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
|
||||
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.studio")
|
||||
@Import({PresentationWebConfig.class, StudioContractDriftTest.StudioDocumentTestBeans.class})
|
||||
static class ContractSurfaceApp {
|
||||
|
||||
@@ -333,7 +339,7 @@ class StudioContractDriftTest {
|
||||
*/
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
|
||||
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
|
||||
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.studio")
|
||||
@Import({
|
||||
EnvelopeBodyAdvice.class,
|
||||
PresentationWebConfig.class,
|
||||
@@ -603,6 +609,12 @@ class StudioContractDriftTest {
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<byte[]> read(String objectKey) {
|
||||
// 이 게이트는 바이너리를 다루지 않는다.
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String objectKey) {
|
||||
// 이 게이트는 바이너리를 다루지 않는다.
|
||||
|
||||
+15
-3
@@ -3,14 +3,16 @@ package dev.caskeleton.bootstrap.runtime;
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
|
||||
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Prevents Hibernate from becoming a production schema writer. Flyway owns the physical schema;
|
||||
* production may only disable Hibernate DDL or validate the schema.
|
||||
*/
|
||||
public class JpaSchemaSafetyValidator implements SmartInitializingSingleton {
|
||||
public class JpaSchemaSafetyValidator implements BeanFactoryPostProcessor {
|
||||
|
||||
static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto";
|
||||
static final String DDL_AUTO_ENV_KEY = "APP_DATASOURCE_DDL_AUTO";
|
||||
@@ -24,8 +26,18 @@ public class JpaSchemaSafetyValidator implements SmartInitializingSingleton {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code BeanFactoryPostProcessor} 이지 {@code SmartInitializingSingleton} 이 아닌 이유: 후자는 모든 싱글턴이
|
||||
* 만들어진 <em>뒤</em>에 돈다. {@code entityManagerFactory} 도 그 싱글턴 중 하나이고, Hibernate 는 그것을 만들면서 {@code
|
||||
* ddl-auto} 를 이미 적용한다 — 실측으로 확인했다: {@code ddl-auto=update} 로 prod 를 띄우면 로그에 "Initialized JPA
|
||||
* EntityManagerFactory" 가 먼저, 그 다음에 이 가드의 PROFILE_MISMATCH 가 찍히고, 스키마에는 그 사이에 만들어진 테이블이 남는다.
|
||||
*
|
||||
* <p>즉 가드가 트래픽은 막았지만 스키마 변조는 못 막고 있었다. 잘못 설정된 배포가 운영 DB 를 이미 바꿔 놓고 실패하는 셈이라, 검사를 빈 인스턴스화 이전으로
|
||||
* 옮긴다.
|
||||
*/
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
|
||||
throws BeansException {
|
||||
if (!isProdActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
+5
-1
@@ -29,8 +29,12 @@ public class RuntimeSafetyConfig {
|
||||
return new OpenInViewSafetyValidator(environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code static} 이어야 한다 — {@code BeanFactoryPostProcessor} 는 다른 빈보다 먼저 만들어지므로, 인스턴스 메서드로 두면 이 설정
|
||||
* 클래스 전체가 too-early 로 초기화되어 경고가 뜬다.
|
||||
*/
|
||||
@Bean
|
||||
JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
|
||||
static JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
|
||||
return new JpaSchemaSafetyValidator(environment);
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -33,6 +33,11 @@ final class ObjectStorageAssetBinaryAdapter implements AssetBinaryStoragePort {
|
||||
return require().put(objectKey, content, mediaType).key();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<byte[]> read(String objectKey) {
|
||||
return require().get(objectKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String objectKey) {
|
||||
require().delete(objectKey);
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.bootstrap.techlog;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.EnvironmentPostProcessor;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
|
||||
/**
|
||||
* Studio 의 IdP 역할을 {@code studio:read} / {@code studio:write} 에 잇는다.
|
||||
*
|
||||
* <p>왜 YAML 이 아니라 여기인가: 프로파일 YAML 은 이 매핑을
|
||||
*
|
||||
* <pre>
|
||||
* role-permissions:
|
||||
* ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
|
||||
* - studio:write
|
||||
* </pre>
|
||||
*
|
||||
* 로 적고 있었다. Spring Boot 는 {@code @ConfigurationProperties} 의 <em>값</em>에서는 플레이스홀더를 풀지만 <em>Map
|
||||
* 키</em>에서는 풀지 않는다 — 키는 리터럴 {@code "${APP_STUDIO_AUTHOR_ROLE:studio-author}"} 로 바인딩된다. 어떤 실제 역할과도
|
||||
* 일치하지 않으므로 {@link dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry} 는 빈 레지스트리가 되고,
|
||||
* Studio 의 모든 쓰기가 403 이 된다. 실측으로 확인했다: {@code APP_STUDIO_AUTHOR_ROLE=studio-author} 를 명시해도 403, 리터럴
|
||||
* 키로 바꾸면 즉시 201.
|
||||
*
|
||||
* <p>역할 이름은 배포마다 다르므로(계약도 {@code StudioSession.roles} 를 고정하지 않는다) 코드에 박을 수 없다. 스칼라 프로퍼티는 플레이스홀더가 정상
|
||||
* 동작하므로, 여기서 이름을 해석한 뒤 리터럴 키로 매핑을 심는다.
|
||||
*
|
||||
* <p>{@code addLast} 로 넣으므로 운영자가 같은 키를 직접 주면 그쪽이 이긴다. {@code META-INF/spring.factories} 에 등록된다.
|
||||
*/
|
||||
public class StudioAuthzEnvironmentPostProcessor implements EnvironmentPostProcessor {
|
||||
|
||||
static final String ROLE_KEY = "app.studio.author-role";
|
||||
static final String DEFAULT_ROLE = "studio-author";
|
||||
private static final String PREFIX = "ca-skeleton.authz.role-permissions.";
|
||||
|
||||
@Override
|
||||
public void postProcessEnvironment(
|
||||
ConfigurableEnvironment environment, SpringApplication application) {
|
||||
String role = resolveRole(environment);
|
||||
Map<String, Object> mapping = new LinkedHashMap<>();
|
||||
mapping.put(PREFIX + role + "[0]", "studio:read");
|
||||
mapping.put(PREFIX + role + "[1]", "studio:write");
|
||||
environment.getPropertySources().addLast(new MapPropertySource("studioAuthzMapping", mapping));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code APP_STUDIO_AUTHOR_ROLE} 은 relaxed binding 으로 {@code app.studio.author-role} 에 닿는다. 값이 비어
|
||||
* 있으면 기본값을 쓴다 — 빈 문자열을 키로 심으면 역할 없는 호출자에게 권한이 붙는다.
|
||||
*/
|
||||
private static String resolveRole(ConfigurableEnvironment environment) {
|
||||
String configured = environment.getProperty(ROLE_KEY);
|
||||
return (configured == null || configured.isBlank()) ? DEFAULT_ROLE : configured.trim();
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.bootstrap.techlog;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
import org.springframework.session.data.redis.RedisSessionRepository;
|
||||
|
||||
/**
|
||||
* {@code auth-mode=redis-session} 의 세션 저장소.
|
||||
*
|
||||
* <p>이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation 은 추가로
|
||||
* {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF 구성이며, {@code SecurityConfig}
|
||||
* 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code RedisSessionWebConfig}(서블릿 세션 필터 +
|
||||
* host-only 쿠키)는 이미 그 전제로 쓰여 있었다.
|
||||
*
|
||||
* <p>빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 {@code
|
||||
* redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 <em>이름으로</em> 요구하는데,
|
||||
* 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 있었고 앞의 것이 어디에도
|
||||
* 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")
|
||||
public class StudioSessionInfrastructureConfig {
|
||||
|
||||
/**
|
||||
* 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 바꾸면 부팅이
|
||||
* "Redis Session repository/filter is incomplete" 로 실패한다.
|
||||
*
|
||||
* <p>{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 {@code sessionRepository} 로
|
||||
* 고정한다.
|
||||
*/
|
||||
@Bean
|
||||
public RedisSessionRepository redisVersionedSessionRepository(
|
||||
RedisConnectionFactory connectionFactory) {
|
||||
return new RedisSessionRepository(sessionRedisTemplate(connectionFactory));
|
||||
}
|
||||
|
||||
/**
|
||||
* 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code PrimitiveSessionSecurityContextRepository} 가 만든
|
||||
* 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다.
|
||||
*/
|
||||
private static RedisTemplate<String, Object> sessionRedisTemplate(
|
||||
RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package dev.caskeleton.bootstrap.techlog;
|
||||
|
||||
import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
|
||||
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
|
||||
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
|
||||
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
|
||||
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
|
||||
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
|
||||
import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectActivityUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.UpdateProjectActivityUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 관리 표면(`studio-management-v1.yaml`)의 use case 배선. 지금은 topics 4개와 projects 5개만 있다 — 그 둘이 문서 작성을 막고
|
||||
* 있던 선행 조건이기 때문이다.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class TechLogManagementConfig {
|
||||
|
||||
@Bean
|
||||
ListStudioTopicsUseCase listStudioTopicsUseCase(TopicRepositoryPort topics, TransactionPort tx) {
|
||||
return new ListStudioTopicsUseCase(topics, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SaveTopicUseCase saveTopicUseCase(TopicRepositoryPort topics, TransactionPort tx) {
|
||||
return new SaveTopicUseCase(topics, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteTopicUseCase deleteTopicUseCase(TopicRepositoryPort topics, TransactionPort tx) {
|
||||
return new DeleteTopicUseCase(topics, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListStudioProjectsUseCase listStudioProjectsUseCase(
|
||||
ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new ListStudioProjectsUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetProjectForEditUseCase getProjectForEditUseCase(
|
||||
ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new GetProjectForEditUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
CreateProjectUseCase createProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new CreateProjectUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
UpdateProjectUseCase updateProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new UpdateProjectUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new DeleteProjectUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListStudioReleasesUseCase listStudioReleasesUseCase(
|
||||
ReleaseRepositoryPort releases, TransactionPort tx) {
|
||||
return new ListStudioReleasesUseCase(releases, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetReleaseForEditUseCase getReleaseForEditUseCase(
|
||||
ReleaseRepositoryPort releases, TransactionPort tx) {
|
||||
return new GetReleaseForEditUseCase(releases, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
CreateReleaseUseCase createReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
|
||||
return new CreateReleaseUseCase(releases, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
UpdateReleaseUseCase updateReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
|
||||
return new UpdateReleaseUseCase(releases, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteReleaseUseCase deleteReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
|
||||
return new DeleteReleaseUseCase(releases, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublishReleaseUseCase publishReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
|
||||
return new PublishReleaseUseCase(releases, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ArchiveReleaseUseCase archiveReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
|
||||
return new ArchiveReleaseUseCase(releases, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteDocumentDraftUseCase deleteDocumentDraftUseCase(
|
||||
DocumentDeletionPort documents, TransactionPort tx) {
|
||||
return new DeleteDocumentDraftUseCase(documents, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteQuestionUseCase deleteQuestionUseCase(DocumentDeletionPort documents, TransactionPort tx) {
|
||||
return new DeleteQuestionUseCase(documents, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteProjectDecisionUseCase deleteProjectDecisionUseCase(
|
||||
DocumentDeletionPort documents, TransactionPort tx) {
|
||||
return new DeleteProjectDecisionUseCase(documents, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
PublishProjectUseCase publishProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new PublishProjectUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
UnpublishProjectUseCase unpublishProjectUseCase(
|
||||
ProjectRepositoryPort projects, TransactionPort tx) {
|
||||
return new UnpublishProjectUseCase(projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetHomeFocusUseCase getHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
|
||||
return new GetHomeFocusUseCase(config, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
UpdateHomeFocusUseCase updateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
|
||||
return new UpdateHomeFocusUseCase(config, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListStudioProjectActivitiesUseCase listStudioProjectActivitiesUseCase(
|
||||
ProjectActivityRepositoryPort activities, TransactionPort tx) {
|
||||
return new ListStudioProjectActivitiesUseCase(activities, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
CreateProjectActivityUseCase createProjectActivityUseCase(
|
||||
ProjectActivityRepositoryPort activities,
|
||||
ProjectRepositoryPort projects,
|
||||
TransactionPort tx) {
|
||||
return new CreateProjectActivityUseCase(activities, projects, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
UpdateProjectActivityUseCase updateProjectActivityUseCase(
|
||||
ProjectActivityRepositoryPort activities, TransactionPort tx) {
|
||||
return new UpdateProjectActivityUseCase(activities, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DeleteProjectActivityUseCase deleteProjectActivityUseCase(
|
||||
ProjectActivityRepositoryPort activities, TransactionPort tx) {
|
||||
return new DeleteProjectActivityUseCase(activities, tx);
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package dev.caskeleton.bootstrap.techlog;
|
||||
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase;
|
||||
import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Tech Log 공개 조회(public-v1) 조립. application-core 는 Spring 을 보지 않으므로 여기서 배선한다 — {@link
|
||||
* TechLogStudioConfig} 와 같은 이유·같은 모양이다.
|
||||
*
|
||||
* <p>계약의 18 operation 이 18 use case 와 1:1 이다. 배선을 한 파일에 모아 두면 operation 이 늘거나 줄 때 어디를 고쳐야 하는지가 한
|
||||
* 곳으로 정해진다.
|
||||
*/
|
||||
@Configuration
|
||||
public class TechLogPublicConfig {
|
||||
|
||||
@Bean
|
||||
GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicSiteUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicHomeUseCase getPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicHomeUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicProfileUseCase getPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicProfileUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ExploreKnowledgeUseCase exploreKnowledgeUseCase(PublicExploreQueryPort port, TransactionPort tx) {
|
||||
return new ExploreKnowledgeUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ExploreQuestionsUseCase exploreQuestionsUseCase(PublicExploreQueryPort port, TransactionPort tx) {
|
||||
return new ExploreQuestionsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicTopicsUseCase listPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicTopicsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicTopicUseCase getPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicTopicUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicCaseUseCase getPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicCaseUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicReferenceUseCase getPublicReferenceUseCase(
|
||||
PublicDocumentQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicReferenceUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicQuestionUseCase getPublicQuestionUseCase(
|
||||
PublicDocumentQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicQuestionUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicProjectsUseCase listPublicProjectsUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicProjectsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicProjectUseCase getPublicProjectUseCase(PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicProjectUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicProjectDecisionsUseCase listPublicProjectDecisionsUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicProjectDecisionsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicProjectRecordsUseCase listPublicProjectRecordsUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicProjectRecordsUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicProjectActivitiesUseCase listPublicProjectActivitiesUseCase(
|
||||
PublicProjectQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicProjectActivitiesUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ListPublicReleasesUseCase listPublicReleasesUseCase(
|
||||
PublicReleaseQueryPort port, TransactionPort tx) {
|
||||
return new ListPublicReleasesUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
GetPublicReleaseUseCase getPublicReleaseUseCase(PublicReleaseQueryPort port, TransactionPort tx) {
|
||||
return new GetPublicReleaseUseCase(port, tx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SearchPublicResourcesUseCase searchPublicResourcesUseCase(
|
||||
PublicSearchQueryPort port, TransactionPort tx) {
|
||||
return new SearchPublicResourcesUseCase(port, tx);
|
||||
}
|
||||
|
||||
/** 공개 미디어. Studio 의 Asset 저장소를 읽지만 표면은 공개 쪽이다 — 업로드는 인증이 필요하고 읽기는 누구나 해야 하기 때문이다. */
|
||||
@Bean
|
||||
GetPublicMediaUseCase getPublicMediaUseCase(
|
||||
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets,
|
||||
dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries,
|
||||
dev.caskeleton.application.transaction.TransactionPort transactions) {
|
||||
return new GetPublicMediaUseCase(assets, binaries, transactions);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
org.springframework.boot.EnvironmentPostProcessor=\
|
||||
dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor,\
|
||||
dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor
|
||||
dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor,\
|
||||
dev.caskeleton.bootstrap.techlog.StudioAuthzEnvironmentPostProcessor
|
||||
|
||||
org.springframework.boot.SpringBootExceptionReporter=\
|
||||
dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter
|
||||
|
||||
@@ -22,16 +22,9 @@ spring:
|
||||
ca-skeleton:
|
||||
persistence:
|
||||
vendor: postgresql
|
||||
authz:
|
||||
role-permissions:
|
||||
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
|
||||
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
|
||||
#
|
||||
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
|
||||
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
|
||||
# 흔들지 않고 프로파일에서 더한다.
|
||||
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
|
||||
- studio:write
|
||||
# authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
|
||||
# YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
|
||||
# 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
|
||||
|
||||
security:
|
||||
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
|
||||
|
||||
@@ -73,16 +73,9 @@ spring:
|
||||
# 그대로 동작한다.
|
||||
backend: filesystem
|
||||
|
||||
authz:
|
||||
role-permissions:
|
||||
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
|
||||
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
|
||||
#
|
||||
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
|
||||
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
|
||||
# 흔들지 않고 프로파일에서 더한다.
|
||||
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
|
||||
- studio:write
|
||||
# authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
|
||||
# YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
|
||||
# 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
|
||||
|
||||
security:
|
||||
oauth2:
|
||||
@@ -161,7 +154,10 @@ ca-skeleton:
|
||||
security:
|
||||
issuer-uri: http://localhost:8081/realms/ca-skeleton
|
||||
audience: ca-skeleton-api
|
||||
public-paths: /api/healthcheck
|
||||
# public-v1(공개 조회 계약)은 인증이 없다 — 계약의 security 가 비어 있고 서문이
|
||||
# "인증이 필요하지 않다"고 명시한다. deny-by-default 기준선을 넓히는 변경이라
|
||||
# docs/security/public-paths-snapshot.txt 가 함께 갱신되어야 통과한다.
|
||||
public-paths: /api/healthcheck, /api/v1/public/**
|
||||
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
|
||||
# `const`. The template default is X-XSRF-TOKEN (application.yml:498, restated verbatim by
|
||||
# src/.env:125, the profile src/.env:8 activates) — StudioSessionController's constructor
|
||||
|
||||
@@ -26,16 +26,9 @@ spring:
|
||||
ca-skeleton:
|
||||
persistence:
|
||||
vendor: postgresql
|
||||
authz:
|
||||
role-permissions:
|
||||
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
|
||||
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
|
||||
#
|
||||
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
|
||||
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
|
||||
# 흔들지 않고 프로파일에서 더한다.
|
||||
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
|
||||
- studio:write
|
||||
# authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
|
||||
# YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
|
||||
# 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
|
||||
|
||||
security:
|
||||
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
|
||||
|
||||
@@ -173,6 +173,14 @@ spring:
|
||||
# true | false (Java 21 virtual threads for Tomcat request handlers)
|
||||
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
|
||||
servlet:
|
||||
session:
|
||||
# Studio 작성자는 한 기록을 여러 번 저장하며 오래 머문다. Spring 기본 30분 유휴 만료는 그
|
||||
# 리듬보다 짧아 작성 도중 로그인 화면으로 돌아가는 일이 잦았다 — 저장하지 않은 편집이
|
||||
# 있으면 그 시점에 잃는다.
|
||||
#
|
||||
# 값은 배포가 정한다. 늘릴수록 훔친 session cookie가 유효한 창도 같이 늘어나므로,
|
||||
# Keycloak realm 의 SSO idle 과 따로 놀지 않게 함께 맞춘다.
|
||||
timeout: ${APP_SESSION_TIMEOUT:8h}
|
||||
multipart:
|
||||
# feature-api-contract-baseline D8: bound request body size so an oversized
|
||||
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
|
||||
|
||||
+63
@@ -2158,4 +2158,67 @@ class CleanArchitectureTest {
|
||||
.filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID)
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 이 생성자를 고를 수 있어야 한다.
|
||||
*
|
||||
* <p>이 규칙은 사고 하나에서 나왔다. 한 어댑터가 생성자를 둘 갖고 있었고 — 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것 — 둘 중 어느 것에도
|
||||
* {@code @Autowired} 가 없었다. 컴포넌트 스캔은 고르지 못하고 기본 생성자를 찾다가 실패한다. 컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서
|
||||
* 도는 통합 테스트도 전부 통과했다. 그 어느 것도 컨텍스트를 띄우지 않기 때문이다. 운영에서 기동만 못 했다.
|
||||
*
|
||||
* <p>그래서 여기서 막는다: 스캔되는 스프링 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 {@code @Autowired} 가 붙어 있어야 한다.
|
||||
*/
|
||||
@ArchTest
|
||||
static final ArchRule SPRING_COMPONENTS_HAVE_AN_UNAMBIGUOUS_CONSTRUCTOR =
|
||||
classes()
|
||||
.that()
|
||||
.areAnnotatedWith("org.springframework.stereotype.Repository")
|
||||
.or()
|
||||
.areAnnotatedWith("org.springframework.stereotype.Service")
|
||||
.or()
|
||||
.areAnnotatedWith("org.springframework.stereotype.Component")
|
||||
.or()
|
||||
.areAnnotatedWith("org.springframework.web.bind.annotation.RestController")
|
||||
.should(haveAConstructorSpringCanChoose())
|
||||
.as(
|
||||
"D20: a scanned Spring component must have exactly one constructor, or mark one"
|
||||
+ " @Autowired — otherwise component scan falls back to a no-arg constructor that"
|
||||
+ " does not exist and the application fails to start");
|
||||
|
||||
private static ArchCondition<JavaClass> haveAConstructorSpringCanChoose() {
|
||||
return new ArchCondition<>("have a constructor Spring can choose") {
|
||||
@Override
|
||||
public void check(JavaClass item, ConditionEvents events) {
|
||||
var constructors =
|
||||
item.getConstructors().stream()
|
||||
.filter(constructor -> !constructor.getModifiers().contains(JavaModifier.SYNTHETIC))
|
||||
.toList();
|
||||
if (constructors.size() <= 1) {
|
||||
return;
|
||||
}
|
||||
boolean autowired =
|
||||
constructors.stream()
|
||||
.anyMatch(
|
||||
constructor ->
|
||||
constructor.getAnnotations().stream()
|
||||
.anyMatch(
|
||||
annotation ->
|
||||
annotation
|
||||
.getRawType()
|
||||
.getName()
|
||||
.equals(
|
||||
"org.springframework.beans.factory.annotation.Autowired")));
|
||||
if (!autowired) {
|
||||
events.add(
|
||||
SimpleConditionEvent.violated(
|
||||
item,
|
||||
item.getName()
|
||||
+ " declares "
|
||||
+ constructors.size()
|
||||
+ " constructors and marks none @Autowired;"
|
||||
+ " component scan cannot choose one"));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementClientSafeMessages;
|
||||
import dev.caskeleton.application.techlog.error.ManagementError;
|
||||
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* {@code PublicErrorRegistryTest} 가 {@code PublicError} 에 대해 하는 일을 {@link ManagementError} 에 대해 한다
|
||||
* — row 존재 / 값 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조.
|
||||
*
|
||||
* <p>계약은 12종을 열거하는데 enum 은 11종이다. 나머지 {@code INTERNAL_ERROR} 는 스켈레톤 공통 처리기가 소유하며({@link
|
||||
* OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 — 그쪽은 {@code retryable=true} 라 같은 code 를 두 곳에서
|
||||
* 선언하면 레지스트리가 어느 값을 따라야 할지 알 수 없다.
|
||||
*/
|
||||
class ManagementErrorRegistryTest {
|
||||
|
||||
private static final String SKELETON_OWNED_CODE = OperationalError.INTERNAL_ERROR.code();
|
||||
|
||||
private static Map<String, Map<String, Object>> registryRowsByCode;
|
||||
private static Set<String> contractCodes;
|
||||
|
||||
@BeforeAll
|
||||
@SuppressWarnings("unchecked")
|
||||
static void load() throws Exception {
|
||||
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
|
||||
|
||||
Path registry = resources.requireTrackedFile("docs/registries/error-codes.yaml");
|
||||
registryRowsByCode = new LinkedHashMap<>();
|
||||
try (InputStream in = Files.newInputStream(registry)) {
|
||||
Map<String, Object> root = new Yaml().load(in);
|
||||
for (Map<String, Object> row : (List<Map<String, Object>>) root.get("errors")) {
|
||||
registryRowsByCode.put((String) row.get("code"), row);
|
||||
}
|
||||
}
|
||||
|
||||
Path contract = resources.requireTrackedFile("src/config/openapi/studio-management-v1.yaml");
|
||||
try (InputStream in = Files.newInputStream(contract)) {
|
||||
Map<String, Object> doc = new Yaml().load(in);
|
||||
Map<String, Object> components = (Map<String, Object>) doc.get("components");
|
||||
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
|
||||
Map<String, Object> apiError = (Map<String, Object>) schemas.get("ApiError");
|
||||
Map<String, Object> properties = (Map<String, Object>) apiError.get("properties");
|
||||
Map<String, Object> code = (Map<String, Object>) properties.get("code");
|
||||
contractCodes = new TreeSet<>((List<String>) code.get("enum"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyManagementErrorHasARegistryRow() {
|
||||
Set<String> declared =
|
||||
Arrays.stream(ManagementError.values())
|
||||
.map(ManagementError::code)
|
||||
.collect(Collectors.toSet());
|
||||
assertThat(registryRowsByCode.keySet()).containsAll(declared);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRowMatchesCategoryHttpStatusAndRetryable() {
|
||||
for (ManagementError error : ManagementError.values()) {
|
||||
Map<String, Object> row = registryRowsByCode.get(error.code());
|
||||
assertThat(row).as("registry row for %s", error.code()).isNotNull();
|
||||
assertThat(row.get("category"))
|
||||
.as("category of %s", error.code())
|
||||
.isEqualTo(error.category().name());
|
||||
assertThat(((Number) row.get("http_status")).intValue())
|
||||
.as("http_status of %s", error.code())
|
||||
.isEqualTo(error.httpStatus());
|
||||
assertThat(row.get("retryable"))
|
||||
.as("retryable of %s", error.code())
|
||||
.isEqualTo(error.retryable());
|
||||
}
|
||||
}
|
||||
|
||||
/** 계약이 열거하는 code = enum 이 소유하는 code ∪ 스켈레톤 소유 code. 어느 쪽이 늘어도 여기서 걸린다. */
|
||||
@Test
|
||||
void contractCodeSetMatchesTheEnumPlusTheSkeletonOwnedCode() {
|
||||
Set<String> owned =
|
||||
Arrays.stream(ManagementError.values())
|
||||
.map(ManagementError::code)
|
||||
.collect(Collectors.toCollection(TreeSet::new));
|
||||
owned.add(SKELETON_OWNED_CODE);
|
||||
assertThat(contractCodes).isEqualTo(owned);
|
||||
}
|
||||
|
||||
/** 모든 code 가 문구를 갖는다. switch 가 전부를 덮지 않으면 컴파일이 막지만, 빈 문구는 막지 못한다. */
|
||||
@Test
|
||||
void everyErrorHasANonBlankClientSafeMessage() {
|
||||
for (ManagementError error : ManagementError.values()) {
|
||||
assertThat(ManagementClientSafeMessages.forError(error))
|
||||
.as("client-safe message for %s", error.code())
|
||||
.isNotBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.PublicClientSafeMessages;
|
||||
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
|
||||
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* {@code StudioErrorRegistryTest} 가 {@link dev.caskeleton.application.techlog.error.StudioError} 에
|
||||
* 대해 하는 일을 {@link PublicError} 에 대해 한다 — 같은 세 축(row 존재 / 값 드리프트 / client-safe 문구)에 계약 code 집합 대조와
|
||||
* vendored 계약 해시까지.
|
||||
*
|
||||
* <p>계약의 {@code ApiError.code} enum 은 세 값인데 {@link PublicError} 는 둘뿐이다. 나머지 하나 {@code
|
||||
* INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통 처리기가 내는 코드({@link OperationalError#INTERNAL_ERROR}) 이며, 같은
|
||||
* code 를 두 enum 이 각자 status 와 함께 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없어 일부러 재선언하지 않았다. 그래서 code 집합 대조는
|
||||
* "정확히 일치"가 아니라 "계약 = public 소유 ∪ {@code INTERNAL_ERROR}" 를 고정한다 — 어느 쪽에 새 code 가 생기든 이 테스트가 먼저
|
||||
* 빨간불이 된다.
|
||||
*/
|
||||
class PublicErrorRegistryTest {
|
||||
|
||||
/** 계약이 열거하지만 이 기능이 소유하지 않는 code. 근거는 클래스 javadoc. */
|
||||
private static final String SKELETON_OWNED_CODE = OperationalError.INTERNAL_ERROR.code();
|
||||
|
||||
private static Map<String, Map<String, Object>> registryRowsByCode;
|
||||
|
||||
@BeforeAll
|
||||
@SuppressWarnings("unchecked")
|
||||
static void loadRegistry() throws Exception {
|
||||
Path registry =
|
||||
RepositoryContractResources.fromSystemProperty()
|
||||
.requireTrackedFile("docs/registries/error-codes.yaml");
|
||||
registryRowsByCode = new LinkedHashMap<>();
|
||||
try (InputStream in = Files.newInputStream(registry)) {
|
||||
Map<String, Object> root = new Yaml().load(in);
|
||||
List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors");
|
||||
for (Map<String, Object> row : errors) {
|
||||
registryRowsByCode.put((String) row.get("code"), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyPublicErrorHasARegistryRow() {
|
||||
Set<String> declared =
|
||||
Arrays.stream(PublicError.values()).map(PublicError::code).collect(Collectors.toSet());
|
||||
|
||||
assertThat(registryRowsByCode.keySet()).containsAll(declared);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyPublicErrorRowMatchesCategoryHttpStatusAndRetryable() {
|
||||
for (PublicError error : PublicError.values()) {
|
||||
Map<String, Object> row = registryRowsByCode.get(error.code());
|
||||
assertThat(row).as("registry row for %s", error.code()).isNotNull();
|
||||
|
||||
assertThat(row.get("category"))
|
||||
.as("category for %s", error.code())
|
||||
.isEqualTo(error.category().name());
|
||||
assertThat(((Number) row.get("http_status")).intValue())
|
||||
.as("http_status for %s", error.code())
|
||||
.isEqualTo(error.httpStatus());
|
||||
assertThat(row.get("retryable"))
|
||||
.as("retryable for %s", error.code())
|
||||
.isEqualTo(error.retryable());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyPublicErrorClientSafeMessageMatchesRegistry() {
|
||||
for (PublicError error : PublicError.values()) {
|
||||
Map<String, Object> row = registryRowsByCode.get(error.code());
|
||||
assertThat(row).as("registry row for %s", error.code()).isNotNull();
|
||||
|
||||
assertThat(PublicClientSafeMessages.forError(error))
|
||||
.as("client_safe_message for %s", error.code())
|
||||
.isEqualTo(row.get("client_safe_message"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void enumPlusTheSkeletonOwnedCodeMatchesTheContractCodeSet() throws Exception {
|
||||
Path contract =
|
||||
RepositoryContractResources.fromSystemProperty()
|
||||
.requireTrackedFile("src/config/openapi/public-v1.yaml");
|
||||
Set<String> contractCodes = contractApiErrorCodes(contract);
|
||||
|
||||
Set<String> enumCodes =
|
||||
Arrays.stream(PublicError.values()).map(PublicError::code).collect(Collectors.toSet());
|
||||
|
||||
assertThat(contractCodes)
|
||||
.as("public-v1.yaml ApiError.code enum vs PublicError + %s", SKELETON_OWNED_CODE)
|
||||
.containsExactlyInAnyOrderElementsOf(
|
||||
java.util.stream.Stream.concat(
|
||||
enumCodes.stream(), java.util.stream.Stream.of(SKELETON_OWNED_CODE))
|
||||
.collect(Collectors.toSet()));
|
||||
assertThat(enumCodes)
|
||||
.as("PublicError 는 스켈레톤 소유 code 를 재선언하지 않는다")
|
||||
.doesNotContain(SKELETON_OWNED_CODE);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Set<String> contractApiErrorCodes(Path contract) throws IOException {
|
||||
try (InputStream in = Files.newInputStream(contract)) {
|
||||
Map<String, Object> root = new Yaml().load(in);
|
||||
Map<String, Object> components = (Map<String, Object>) root.get("components");
|
||||
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
|
||||
Map<String, Object> apiError = (Map<String, Object>) schemas.get("ApiError");
|
||||
Map<String, Object> properties = (Map<String, Object>) apiError.get("properties");
|
||||
Map<String, Object> code = (Map<String, Object>) properties.get("code");
|
||||
List<String> enumValues = (List<String>) code.get("enum");
|
||||
return Set.copyOf(enumValues);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code src/config/openapi/public-v1.yaml} 은 설계 패키지 계약의 vendored 사본이다({@code MANIFEST.sha256} 의
|
||||
* {@code # source:} 줄이 출처를 기록한다). 이 단언이 없으면 vendor 사본을 손으로 고쳐도 아무도 알아채지 못한다 — studio 쪽과 같은 이유의 같은
|
||||
* 게이트다.
|
||||
*/
|
||||
@Test
|
||||
void vendoredContractMatchesTheRecordedManifestHash() throws Exception {
|
||||
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
|
||||
Path contract = resources.requireTrackedFile("src/config/openapi/public-v1.yaml");
|
||||
Path manifest = resources.requireTrackedFile("src/config/openapi/MANIFEST.sha256");
|
||||
|
||||
assertThat(sha256Hex(contract))
|
||||
.as(
|
||||
"src/config/openapi/public-v1.yaml sha256 must match the value MANIFEST.sha256 recorded"
|
||||
+ " for it (local edit or vendoring drift)")
|
||||
.isEqualTo(recordedSha256(manifest, "public-v1.yaml"));
|
||||
}
|
||||
|
||||
private static String recordedSha256(Path manifest, String filename) throws IOException {
|
||||
return Files.readAllLines(manifest).stream()
|
||||
.map(String::strip)
|
||||
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||
.filter(line -> line.endsWith(filename))
|
||||
.map(line -> line.substring(0, line.indexOf(' ')).strip())
|
||||
.findFirst()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"MANIFEST.sha256 has no hash row for " + filename + ": " + manifest));
|
||||
}
|
||||
|
||||
private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(Files.readAllBytes(file));
|
||||
StringBuilder hex = new StringBuilder(hash.length * 2);
|
||||
for (byte b : hash) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
return hex.toString();
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -11,6 +11,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration;
|
||||
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
|
||||
import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
@@ -117,13 +119,21 @@ class ActuatorSecurityHttpTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
/*
|
||||
* Redis 도 함께 제외한다. 빼지 않으면 reactive Redis health indicator 가 이 슬라이스에 그대로
|
||||
* 올라와 localhost:6379 로 붙으려 하고, 실패하면 /actuator/health 가 503 이 된다. 그러면 이
|
||||
* 테스트는 "probe 가 permitAll 인가" 가 아니라 "이 기계에 Redis 가 떠 있는가" 를 재게 된다 —
|
||||
* 보안 태세와 아무 상관 없는 이유로 빨개진다.
|
||||
*/
|
||||
@EnableAutoConfiguration(
|
||||
exclude = {
|
||||
DataSourceAutoConfiguration.class,
|
||||
DataSourceTransactionManagerAutoConfiguration.class,
|
||||
HibernateJpaAutoConfiguration.class,
|
||||
DataJpaRepositoriesAutoConfiguration.class,
|
||||
FlywayAutoConfiguration.class
|
||||
FlywayAutoConfiguration.class,
|
||||
DataRedisAutoConfiguration.class,
|
||||
DataRedisReactiveAutoConfiguration.class
|
||||
})
|
||||
@Import(ManagementSecurityConfig.class)
|
||||
static class MinimalActuatorApp {}
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.bootstrap.techlog;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.context.properties.bind.Bindable;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
/**
|
||||
* 이 후처리기의 산출물은 프로퍼티가 아니라 <em>바인딩 결과</em>다. 프로퍼티가 environment 에 들어갔는지만 보면 통과하면서도 실제 앱에서는 매핑이 죽는 경우가
|
||||
* 있다 — application.yml 이 {@code role-permissions: {}} 로 같은 이름을 이미 선언하고 있고, 그 소스가 {@code addLast} 보다
|
||||
* 우선순위가 높기 때문이다. 그래서 여기서는 {@code Map<String, List<String>>} 로 실제 바인딩해서 확인한다.
|
||||
*/
|
||||
class StudioAuthzEnvironmentPostProcessorTest {
|
||||
|
||||
private final StudioAuthzEnvironmentPostProcessor epp = new StudioAuthzEnvironmentPostProcessor();
|
||||
|
||||
private static final Bindable<Map<String, List<String>>> ROLE_PERMISSIONS =
|
||||
Bindable.of(
|
||||
ResolvableType.forClassWithGenerics(
|
||||
Map.class,
|
||||
ResolvableType.forClass(String.class),
|
||||
ResolvableType.forClassWithGenerics(List.class, String.class)));
|
||||
|
||||
private static Map<String, List<String>> bind(MockEnvironment env) {
|
||||
return Binder.get(env)
|
||||
.bind("ca-skeleton.authz.role-permissions", ROLE_PERMISSIONS)
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱이 실제로 바인딩하는 대상은 {@code Map} 이 아니라 {@code RolePermissionPolicy} 레코드다(생성자 바인딩). 맵으로만 확인하면 레코드
|
||||
* 경로에서만 나타나는 차이를 놓친다.
|
||||
*/
|
||||
@Test
|
||||
void bindsThroughTheRecordTheApplicationActuallyUses() {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.setProperty("app.studio.author-role", "site-admin");
|
||||
env.getPropertySources()
|
||||
.addLast(
|
||||
new MapPropertySource(
|
||||
"applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", "")));
|
||||
|
||||
epp.postProcessEnvironment(env, new SpringApplication());
|
||||
|
||||
Map<String, List<String>> bound =
|
||||
Binder.get(env)
|
||||
.bind("ca-skeleton.authz", Bindable.of(RolePermissionPolicy.class))
|
||||
.map(RolePermissionPolicy::rolePermissions)
|
||||
.orElse(Map.of());
|
||||
assertThat(bound).containsEntry("site-admin", List.of("studio:read", "studio:write"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsBothReadAndWriteToTheConfiguredRole() {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.setProperty("app.studio.author-role", "site-admin");
|
||||
|
||||
epp.postProcessEnvironment(env, new SpringApplication());
|
||||
|
||||
assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write"));
|
||||
}
|
||||
|
||||
/**
|
||||
* application.yml 이 선언하는 빈 맵을 재현한다. 이것이 매핑을 가리면 Studio 의 모든 쓰기가 403 이 된다 — 읽기는 통과하는데 쓰기만 막히는,
|
||||
* 진단하기 어려운 모양으로 나타난다.
|
||||
*/
|
||||
@Test
|
||||
void survivesAnEmptyMapDeclaredByTheApplicationDefaults() {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.setProperty("app.studio.author-role", "site-admin");
|
||||
env.getPropertySources()
|
||||
.addLast(
|
||||
new MapPropertySource(
|
||||
"applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", "")));
|
||||
|
||||
epp.postProcessEnvironment(env, new SpringApplication());
|
||||
|
||||
assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write"));
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.bootstrap.techlog;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter;
|
||||
import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy;
|
||||
import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry;
|
||||
import dev.caskeleton.application.security.AuthorizationPort;
|
||||
import dev.caskeleton.shared.security.Permission;
|
||||
import java.util.Set;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.Advisor;
|
||||
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Role;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import org.springframework.security.authorization.AuthorizationManager;
|
||||
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
|
||||
|
||||
/**
|
||||
* 후처리기가 심은 매핑이 <em>실행 중인 컨텍스트에서도</em> 살아 있는지 본다.
|
||||
*
|
||||
* <p>바인딩만 따로 확인하면 통과하지만 앱에서는 죽는 경우가 있어서다. {@code MethodSecurityConfig} 의 advisor 는 auto-proxy 보다 먼저
|
||||
* 만들어져야 하는 인프라 빈인데 {@link AuthorizationPort} 를 생성자 파라미터로 받는다. 그래서 {@code AuthorizationAdapter →
|
||||
* RolePermissionRegistry → RolePermissionPolicy} 가 BeanPostProcessor 등록이 끝나기 전에 끌려 올라온다 — 운영 로그가 이
|
||||
* 세 빈에 대해 "not eligible for getting processed by all BeanPostProcessors" 를 정확히 그렇게 찍고 있다.
|
||||
*/
|
||||
class StudioAuthzWiringTest {
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(RolePermissionPolicy.class)
|
||||
static class Wiring {
|
||||
|
||||
@Bean
|
||||
RolePermissionRegistry rolePermissionRegistry(RolePermissionPolicy policy) {
|
||||
return new RolePermissionRegistry(policy);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AuthorizationPort authorizationAdapter(RolePermissionRegistry registry) {
|
||||
return new AuthorizationAdapter(registry);
|
||||
}
|
||||
|
||||
/** MethodSecurityConfig 와 같은 모양: 인프라 advisor 가 포트를 직접 받는다. */
|
||||
@Bean
|
||||
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||
static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) {
|
||||
AuthorizationManager<MethodInvocation> manager = (authentication, invocation) -> null;
|
||||
return new AuthorizationManagerBeforeMethodInterceptor(
|
||||
AnnotationMatchingPointcut.forMethodAnnotation(Deprecated.class), manager);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theConfiguredRoleKeepsBothPermissionsInsideARunningContext() {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.setProperty("app.studio.author-role", "site-admin");
|
||||
new StudioAuthzEnvironmentPostProcessor().postProcessEnvironment(env, new SpringApplication());
|
||||
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.setEnvironment(env);
|
||||
context.register(Wiring.class);
|
||||
context.refresh();
|
||||
|
||||
RolePermissionRegistry registry = context.getBean(RolePermissionRegistry.class);
|
||||
assertThat(registry.effectivePermissions(Set.of("site-admin")))
|
||||
.contains(Permission.parse("studio:read"), Permission.parse("studio:write"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -105,7 +105,11 @@ public final class IdempotencyExecutor {
|
||||
store.complete(scope, new StoredResponse(codec.serialize(result)));
|
||||
return result;
|
||||
} catch (RuntimeException e) {
|
||||
store.discard(scope);
|
||||
try {
|
||||
store.discard(scope);
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
e.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.application.techlog.error;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
|
||||
/**
|
||||
* 관리 계약(`studio-management-v1.yaml`)의 `ApiError.code` enum. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과
|
||||
* `docs/registries/error-codes.yaml`을 함께 고쳐야 한다.
|
||||
*
|
||||
* <p>{@link StudioError} 와 합치지 않는다. 두 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 쪽 응답으로 낼 수 있게 되면 그
|
||||
* 순간 두 계약 모두 거짓이 된다.
|
||||
*
|
||||
* <p>계약의 {@code ApiError.code} 는 22종인데 여기는 21종이다. 나머지 하나 {@code INTERNAL_ERROR} 는 이 기능이 아니라 스켈레톤 공통
|
||||
* 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code 를 두 enum 이 각자 status 와 retryable
|
||||
* 을 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 알 수 없다 — 실제로 그쪽은 {@code retryable=true} 다. {@code PublicError} 가 같은
|
||||
* 이유로 같은 선택을 했다.
|
||||
*/
|
||||
public enum ManagementError implements ApiErrorCode {
|
||||
AUTHENTICATION_REQUIRED(Category.AUTH, 401, false),
|
||||
STUDIO_ACCESS_DENIED(Category.AUTHZ, 403, false),
|
||||
REQUEST_VALIDATION_FAILED(Category.VALIDATION, 422, false),
|
||||
VERSION_CONFLICT(Category.CONFLICT, 409, false),
|
||||
TOPIC_NOT_FOUND(Category.NOT_FOUND, 404, false),
|
||||
TOPIC_NAME_TAKEN(Category.CONFLICT, 409, false),
|
||||
TOPIC_SLUG_TAKEN(Category.CONFLICT, 409, false),
|
||||
TOPIC_IN_USE(Category.CONFLICT, 409, false),
|
||||
PROJECT_NOT_FOUND(Category.NOT_FOUND, 404, false),
|
||||
PROJECT_SLUG_TAKEN(Category.CONFLICT, 409, false),
|
||||
PROJECT_IN_USE(Category.CONFLICT, 409, false),
|
||||
RELEASE_NOT_FOUND(Category.NOT_FOUND, 404, false),
|
||||
RELEASE_VERSION_TAKEN(Category.CONFLICT, 409, false),
|
||||
RELEASE_NOT_PUBLISHABLE(Category.CONFLICT, 409, false),
|
||||
DOCUMENT_NOT_FOUND(Category.NOT_FOUND, 404, false),
|
||||
DOCUMENT_PUBLISHED(Category.CONFLICT, 409, false),
|
||||
DOCUMENT_IN_USE(Category.CONFLICT, 409, false),
|
||||
QUESTION_NOT_FOUND(Category.NOT_FOUND, 404, false),
|
||||
QUESTION_IN_USE(Category.CONFLICT, 409, false),
|
||||
DECISION_NOT_FOUND(Category.NOT_FOUND, 404, false),
|
||||
DECISION_IN_USE(Category.CONFLICT, 409, false);
|
||||
|
||||
private final Category category;
|
||||
private final int httpStatus;
|
||||
private final boolean retryable;
|
||||
|
||||
ManagementError(Category category, int httpStatus, boolean retryable) {
|
||||
this.category = category;
|
||||
this.httpStatus = httpStatus;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String code() {
|
||||
return name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Category category() {
|
||||
return category;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int httpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retryable() {
|
||||
return retryable;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.application.techlog.error;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
|
||||
/**
|
||||
* 관리 use case 가 던지는 유일한 실패 표현. {@link StudioException} 과 같은 모양이되 code 집합만 다르다 — 전송 계층은 {@link
|
||||
* ApiErrorCarrier} 만 보므로 두 예외를 따로 처리할 필요가 없다.
|
||||
*/
|
||||
public final class ManagementException extends RuntimeException implements ApiErrorCarrier {
|
||||
|
||||
private final transient ManagementError error;
|
||||
private final transient Object details;
|
||||
|
||||
private ManagementException(ManagementError error, String message, Object details) {
|
||||
super(message);
|
||||
this.error = error;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public static ManagementException of(ManagementError error, String message) {
|
||||
return new ManagementException(error, message, null);
|
||||
}
|
||||
|
||||
public static ManagementException withDetails(
|
||||
ManagementError error, String message, Object details) {
|
||||
return new ManagementException(error, message, details);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiErrorCode errorCode() {
|
||||
return error;
|
||||
}
|
||||
|
||||
public ManagementError managementError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
public Object details() {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* {@code createProjectActivity}. {@code origin} 은 명령이 정하지 않는다 — 이 경로로 들어온 것은 언제나 {@code MANUAL} 이다.
|
||||
*/
|
||||
public record CreateProjectActivityCommand(
|
||||
UUID projectId,
|
||||
long expectedProjectVersion,
|
||||
String activityType,
|
||||
String title,
|
||||
String summary,
|
||||
String visibility,
|
||||
String relatedResourceType,
|
||||
UUID relatedResourceId,
|
||||
Instant occurredAt,
|
||||
String actor) {}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
/** 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다. */
|
||||
public record CreateProjectCommand(String title, String actor) {}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
/** 계약 {@code CreateDraftRequest}. 제목 하나로 초안을 연다. */
|
||||
public record CreateReleaseCommand(String title, String actor) {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 계약 {@code ExpectedVersionRequest}. Case / Reference / Question 삭제가 모두 같은 모양이라 하나로 둔다 — 대상 테이블만
|
||||
* 다르고 요청은 "이 버전의 이 작업본을 지운다"로 동일하다.
|
||||
*/
|
||||
public record DeleteDocumentCommand(UUID id, long expectedVersion, String actor) {}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record DeleteProjectCommand(UUID id, long expectedVersion, String actor) {}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record DeleteTopicCommand(UUID id, long expectedVersion, String actor) {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 계약 {@code ExpectedVersionRequest}. delete / publish / archive 가 같은 모양이라 하나로 둔다 — 셋 다 "이 버전의 이
|
||||
* 릴리즈에 상태 전이를 건다"는 같은 요청이고, 세 개의 동일한 record 를 두면 셋이 갈라졌을 때 어느 것이 옳은지 알 수 없다.
|
||||
*/
|
||||
public record ReleaseLifecycleCommand(UUID id, long expectedVersion, String actor) {}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 생성과 수정이 같은 명령을 쓴다. 계약이 두 경우 모두 {@code TopicEdit} 를 본문으로 받기 때문이고, 구분은 {@code id} 의 유무다 — {@code
|
||||
* null} 이면 생성이다.
|
||||
*
|
||||
* <p>{@code expectedVersion} 은 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지 않는 이유는, 계약이 그 필드를 optional 로 두고
|
||||
* 있어 클라이언트가 보내는 것이 위반이 아니기 때문이다.
|
||||
*/
|
||||
public record SaveTopicCommand(
|
||||
UUID id,
|
||||
String name,
|
||||
String slug,
|
||||
String description,
|
||||
String scope,
|
||||
String status,
|
||||
Long expectedVersion,
|
||||
String actor) {}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* {@code updateHomeFocus}. 세 슬롯은 전부 nullable 이고, null 은 "이 슬롯을 비운다" 는 뜻이다 — 부분 수정이 아니라 전체 교체다.
|
||||
*
|
||||
* <p>부분 수정으로 두면 "비우기" 를 표현할 방법이 없어진다. 화면이 세 칸을 한 번에 보여 주고 한 번에 저장하므로 전체 교체가 화면과도 맞다.
|
||||
*/
|
||||
public record UpdateHomeFocusCommand(
|
||||
long expectedVersion,
|
||||
String defaultType,
|
||||
UUID currentProjectId,
|
||||
UUID openQuestionId,
|
||||
UUID recentDecisionId,
|
||||
String actor) {}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.application.techlog.management.command;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* {@code updateProjectActivity}.
|
||||
*
|
||||
* <p>{@code activityType} 과 관련 자원은 고칠 수 없다 — 그것을 바꾸면 같은 줄이 다른 사건을 가리키게 된다. 바꿀 수 있는 것은 사람이 읽는
|
||||
* 부분(제목·요약)과 노출 여부, 그리고 언제 일어난 일인지다.
|
||||
*/
|
||||
public record UpdateProjectActivityCommand(
|
||||
UUID projectId,
|
||||
UUID activityId,
|
||||
long expectedVersion,
|
||||
String title,
|
||||
String summary,
|
||||
String visibility,
|
||||
Instant occurredAt,
|
||||
String actor) {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user