merge: feature/techlog-public-v1 — Tech Log 공개 조회 백엔드 (public-v1 18/18)

public-v1.yaml의 18개 operation 전부와, 그 과정에서 드러난 결함들의 수정을 통합한다.
studio-v1(19/19)에 이어 public-v1도 18/18이 되어 프론트엔드가 소비할 두 계약이 모두
서버에 존재한다.

함께 들어오는 것
- 743fee3까지의 release-gate 수정 4건(Studio authz 배선, BFF 로그인 경로, 계약의
  nullable/오류 코드 정정, 로컬 체크리스트 마감)
- 그 수정을 지키던 미추적 테스트 2개

이 머지에 담긴 판단은 각 커밋 메시지에 있다. 요지는 하나다 — 코드를 쓴 것만으로는
드러나지 않고 실제 PostgreSQL과 실제 기동이 잡아낸 결함이 다섯 건 있었다.
생성기의 조용한 필드 누락, 계약이 선언했는데 무시되던 필터, 하드코딩된 빈 응답 필드,
계약 밖 enum 유출, 그리고 배포 직후 홈 화면을 깨뜨리던 NULL 기본값.

검증: ./gradlew check BUILD SUCCESSFUL (248 task), 공개 조회 통합 테스트 24/24,
실제 앱 기동 후 18개 operation 실호출 5xx 0건.

AGENTS.md의 commit 정책은 human-only다. 이 머지는 사용자가 "develop과 main에
반영하도록" 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-20 18:34:49 +09:00
co-authored by Claude Opus 5
145 changed files with 9864 additions and 128 deletions
+36
View File
@@ -1228,3 +1228,39 @@ errors:
runbook_link: "runbook://studio/unavailable"
compatibility_impact: additive
required_test: StudioErrorTest
# === Tech Log Public (feature-techlog-public-v1) ===
#
# public-v1.yaml 의 ApiError.code 는 세 값이다. 나머지 하나 INTERNAL_ERROR 는 스켈레톤
# 공통 코드로 이미 이 레지스트리에 있으므로 여기서 다시 선언하지 않는다.
#
# Studio 와 이름을 겹치지 않게 한 이유: 이 레지스트리는 코드 하나에 http_status 하나만
# 담는다. public 의 400 과 studio 의 422 를 같은 이름으로 쓸 수 없다.
# source: public-v1.yaml ApiError.code — PUBLIC_REQUEST_INVALID (PublicError.PUBLIC_REQUEST_INVALID)
- code: PUBLIC_REQUEST_INVALID
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-public-v1
owner_layer: application
client_safe_message: "요청 값이 올바르지 않습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: PublicErrorRegistryTest
# source: public-v1.yaml ApiError.code — PUBLIC_RESOURCE_NOT_FOUND (PublicError.PUBLIC_RESOURCE_NOT_FOUND)
- code: PUBLIC_RESOURCE_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-public-v1
owner_layer: application
client_safe_message: "요청한 자료를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: PublicErrorRegistryTest
+1
View File
@@ -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/**
+1 -1
View File
@@ -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
+271 -22
View File
@@ -27,6 +27,11 @@ sourceSets {
// 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다.
// main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java'))
// public-v1 도 같은 방식으로 model 만 생성한다. 별도 sourceSet 을 만들지 않는 이유는
// 두 계약의 생성물이 같은 성질(생성 코드, 품질 게이트 제외 대상, jar/test 클래스패스에
// 얹어야 함)을 갖기 때문이다 — sourceSet 을 늘리면 그 배선을 한 벌 더 복제하게 된다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-public/src/main/java'))
java.srcDir(layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java'))
}
// main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
@@ -164,18 +169,10 @@ ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codege
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
tasks.register('prepareStudioCodegenSpec') {
description = '계약에서 discriminator union 배선을 파생시켜 생성기 입력을 만든다.'
def specSource = file("${rootDir}/config/openapi/studio-v1.yaml")
def specOut = studioCodegenSpecFile
def ignoreOut = studioCodegenIgnoreFile
def unionDir = studioUnionSrcDir
def modelPackage = studioModelPackage
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
// 이 파생은 계약 두 벌(studio-v1, public-v1)에 똑같이 적용된다. 두 벌을 각자 복사해 두면
// 한쪽만 고쳐지는 날이 오므로 클로저 하나로 두고 태스크가 인자만 바꿔 호출한다.
ext.prepareTechLogCodegenSpec = { String label, File specSource, File specTarget,
File ignoreTarget, File unionDir, String modelPackage ->
def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8'))
def schemas = doc.components.schemas
@@ -209,6 +206,32 @@ tasks.register('prepareStudioCodegenSpec') {
}
collapseStringOneOf(doc)
// boolean 프로퍼티의 `const` 를 코드젠 사본에서만 걷어낸다.
//
// 봉투의 success 는 계약상 `{type: boolean, const: true}` 다. 생성기는 이 문서를 검증
// 경로 없이 읽으면 그 const 를 단일값 enum 으로 취급해 `enum SuccessEnum { TRUE("true") }`
// 를 만드는데, 그 enum 의 필드 타입은 Boolean 이고 생성자에는 String 을 넘겨 컴파일이
// 깨진다(실측). 검증 경로를 타는 studio 쪽에서는 같은 계약이 평범한 Boolean 으로 나온다 —
// 즉 계약이 아니라 생성기의 경로 차이가 원인이다.
//
// 값이 하나로 고정된다는 사실은 소비자에게 의미가 있으므로 정본 계약에는 그대로 두고,
// 여기서만 뗀다. 서버가 이 값을 잘못 넣을 위험은 없다 — 봉투는 EnvelopeBodyAdvice 가
// 만들고 컨트롤러가 손대지 않는다.
int[] consts = [0]
def dropBooleanConst
dropBooleanConst = { Object node ->
if (node instanceof Map) {
if (node.get('type') == 'boolean' && node.containsKey('const')) {
node.remove('const')
consts[0]++
}
new ArrayList(node.values()).each { dropBooleanConst(it) }
} else if (node instanceof List) {
node.each { dropBooleanConst(it) }
}
}
dropBooleanConst(doc)
// (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면
// 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가
// DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌
@@ -243,6 +266,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 +349,68 @@ tasks.register('prepareStudioCodegenSpec') {
}
unions.put(name, [property: property, variants: variants])
}
if (unions.isEmpty()) {
throw new GradleException('계약에서 discriminator union 을 하나도 찾지 못했다 — 파생 규칙이 깨졌다.')
// 이 가드의 목적은 "union 이 있어야 한다"가 아니라 "계약에 있는 union 을 하나도 빠뜨리지
// 않았다"이다. public-v1 처럼 union 이 애초에 없는 계약도 있으므로 개수를 계약에서 세어
// 대조한다. 원래 studio 전용으로 "0개면 실패"로 썼다가 public-v1 에서 걸렸다.
int declaredUnions = schemas.count { String name, Object schema ->
schema instanceof Map && schema.get('oneOf') instanceof List &&
schema.get('discriminator') instanceof Map
}
if (unions.size() != declaredUnions) {
throw new GradleException(
"계약의 discriminator union ${declaredUnions}개 중 ${unions.size()}개만 파생했다 — " +
"파생 규칙이 계약을 따라가지 못한다.")
}
// 파생 계약 쓰기
//
// deep copy 가 반드시 선행한다. 위 변환들이 같은 Map/List 인스턴스를 여러 위치에
// 재사용하면 snakeyaml 이 그 지점을 YAML anchor/alias(&id001 / *id001)로 덤프한다.
// swagger-parser 는 alias 노드를 해석하지 못해 그 스키마를
// "is not of type `object`" 로 거부하고, validateSpec 을 끄면 generator 가 해당
// property 를 **조용히 누락한 채** 모델을 만든다(publishedAt, matchedFields 등이
// 실제로 사라졌다). 노드 identity 를 전부 끊어 alias 자체를 원천 차단한다.
def deepCopy
deepCopy = { Object node ->
if (node instanceof Map) {
def copy = new LinkedHashMap<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 +422,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* {@code ${name}} — 계약의 discriminator union. prepareStudioCodegenSpec 가 계약의
* {@code ${name}} — 계약의 discriminator union. ${label} 가 계약의
* {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다.
*
* <p>{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로
@@ -339,11 +442,157 @@ public interface ${name} {}
}
logger.lifecycle(
"prepareStudioCodegenSpec: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), " +
"${label}: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), boolean const ${consts[0]}건 제거, " +
"string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음")
}
ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml')
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
// `public` 은 Java 예약어라 패키지 조각으로 쓸 수 없다 — publicapi 로 둔다.
ext.publicModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model'
ext.publicCodegenSpecFile = layout.buildDirectory.file('openapi/public-v1-codegen.yaml')
ext.publicCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-public')
ext.publicUnionSrcDir = layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java')
tasks.register('prepareStudioCodegenSpec') {
description = 'studio-v1 계약에서 생성기 입력을 파생시킨다.'
def specSource = file("${rootDir}/config/openapi/studio-v1.yaml")
def specOut = studioCodegenSpecFile
def ignoreOut = studioCodegenIgnoreFile
def unionDir = studioUnionSrcDir
def modelPackage = studioModelPackage
def prepare = prepareTechLogCodegenSpec
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
prepare('prepareStudioCodegenSpec', specSource, specOut.get().asFile,
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
}
}
tasks.register('preparePublicCodegenSpec') {
description = 'public-v1 계약에서 생성기 입력을 파생시킨다.'
def specSource = file("${rootDir}/config/openapi/public-v1.yaml")
def specOut = publicCodegenSpecFile
def ignoreOut = publicCodegenIgnoreFile
def unionDir = publicUnionSrcDir
def modelPackage = publicModelPackage
def prepare = prepareTechLogCodegenSpec
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
prepare('preparePublicCodegenSpec', specSource, specOut.get().asFile,
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
}
}
// public-v1 생성. openApiGenerate 확장은 계약 하나만 다루므로 두 번째 계약은 GenerateTask 를
// 직접 등록한다. 설정은 studio 쪽과 같은 근거를 따른다(model 만 생성, oneOf interface 미사용,
// openApiNullable=false) — 그 근거는 위 openApiGenerate 블록의 주석에 있다.
tasks.register('openApiGeneratePublic',
org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
dependsOn tasks.named('preparePublicCodegenSpec')
generatorName = 'spring'
inputSpec = publicCodegenSpecFile.get().asFile.path
ignoreFileOverride = publicCodegenIgnoreFile.get().asFile.path
outputDir = layout.buildDirectory.dir('generated/openapi-public').get().asFile.path
modelPackage = publicModelPackage
// 검증을 켠 채로 둔다. 한때 swagger-parser 가 이 문서의 스키마 15개를
// "is not of type `object`" 로 거절했는데, 원인은 계약이 아니라 파생 단계였다.
// preparePublicCodegenSpec 의 변환이 같은 Map 인스턴스를 여러 property 에 재사용해
// snakeyaml 이 YAML alias(*id001)로 덤프했고, swagger-parser 가 alias 노드를
// 해석하지 못해 그 스키마 전체를 거절했다. validateSpec 을 끄면 generator 는 문서를
// 받아들이되 alias 였던 property 를 **조용히 누락**한다 — publishedAt, updatedAt,
// matchedFields, changeTypes 가 실제로 모델에서 사라졌다. 파생 단계에서 deep copy 로
// alias 를 원천 차단했으므로 검증을 다시 켠다.
validateSpec = true
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
openApiNullable: 'false',
useOneOfInterfaces: 'false',
]
// 생성기는 outputDir 를 비우지 않는다 — 계약에서 사라진 스키마의 .java 가 남아 드리프트를
// 가린다(studio 쪽에서 실제로 겪었다).
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi-public')) }
}
// 생성기가 스키마나 property 를 조용히 빠뜨려도 컴파일은 그대로 통과한다(그 타입을 아직
// 아무도 안 쓰니까) — 나중에 컨트롤러를 쓸 때서야 드러난다. 실제로 파생 계약의 YAML alias
// 때문에 publishedAt / updatedAt / matchedFields / changeTypes 가 모델에서 사라진 채로
// 빌드가 성공한 적이 있고, 그때 이 게이트가 schema 이름만 봐서 놓쳤다. 그래서 property 까지
// 대조한다.
tasks.register('verifyPublicGeneratedModels') {
group = 'verification'
description = 'public-v1 계약의 schema 와 property 가 전부 모델로 생성됐는지 대조한다.'
dependsOn tasks.named('openApiGeneratePublic')
def specFile = publicCodegenSpecFile
def modelDirProvider = layout.buildDirectory.dir('generated/openapi-public/src/main/java')
def modelPackage = publicModelPackage
doLast {
def doc = new org.yaml.snakeyaml.Yaml().load(specFile.get().asFile.getText('UTF-8'))
Set<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.named('check') {
dependsOn tasks.named('verifyPublicGeneratedModels')
}
tasks.named('compileGeneratedOpenapiJava') {
dependsOn tasks.named('openApiGeneratePublic')
}
// openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라
// dependsOn 을 받지 못한다. 태스크 쪽에 건다.
tasks.named('openApiGenerate') {
@@ -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();
}
@@ -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} 제약을 위반한다.
@@ -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)));
}
}
}
@@ -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 -> "요청한 자료를 찾을 수 없습니다";
};
}
}
@@ -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);
}
}
@@ -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)));
}
}
@@ -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));
}
}
@@ -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))));
}
}
@@ -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)));
}
}
@@ -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;
}
}
@@ -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()));
}
}
@@ -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)));
}
}
@@ -0,0 +1,169 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseCase;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseRelations;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestion;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseRelations;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPointGroup;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
/**
* {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion} 의 응답 조립.
*
* <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.setProblemSummary(doc.primarySummary());
body.setConclusionSummary(doc.secondarySummary());
body.setEnvironmentSummary(doc.environmentSummary());
body.setContent(doc.content());
body.setContentFormat(CaseDetailResponseCase.ContentFormatEnum.fromValue(doc.contentFormat()));
body.setContentFormatVersion(doc.contentFormatVersion());
body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic()));
body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag));
body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject()));
body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset()));
body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt()));
body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt()));
body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt()));
CaseDetailResponseRelations relations = new CaseDetailResponseRelations();
relations.setOriginQuestion(PublicResponseMapper.related(view.relations().originQuestion()));
relations.setProjectDecisions(
PublicResponseMapper.relatedList(view.relations().projectDecisions()));
relations.setDerivedReferences(
PublicResponseMapper.relatedList(view.relations().derivedReferences()));
relations.setRelatedCases(PublicResponseMapper.relatedList(view.relations().relatedCases()));
CaseDetailResponse dto = new CaseDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setCase(body);
dto.setRelations(relations);
return dto;
}
public static ReferenceDetailResponse referenceDetail(ReferenceDetailView view) {
PublishedDocumentView doc = view.document();
ReferenceDetailResponseReference body = new ReferenceDetailResponseReference();
body.setTitle(doc.title());
body.setScopeSummary(doc.primarySummary());
body.setAppliesTo(doc.appliesTo());
body.setExcludedScope(doc.excludedScope());
body.setFreshnessStatus(
ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus()));
body.setContent(doc.content());
body.setContentFormat(
ReferenceDetailResponseReference.ContentFormatEnum.fromValue(doc.contentFormat()));
body.setContentFormatVersion(doc.contentFormatVersion());
body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic()));
body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag));
body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject()));
body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset()));
body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt()));
body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt()));
body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt()));
ReferenceDetailResponseRelations relations = new ReferenceDetailResponseRelations();
relations.setSupportingCases(
PublicResponseMapper.relatedList(view.relations().supportingCases()));
relations.setRelatedDecisions(
PublicResponseMapper.relatedList(view.relations().relatedDecisions()));
relations.setRelatedReferences(
PublicResponseMapper.relatedList(view.relations().relatedReferences()));
ReferenceDetailResponse dto = new ReferenceDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setReference(body);
dto.setRelations(relations);
return dto;
}
public static QuestionDetailResponse questionDetail(QuestionDetailView view) {
PublishedQuestionView q = view.question();
QuestionDetailResponseQuestion body = new QuestionDetailResponseQuestion();
body.setQuestion(q.question());
body.setSummary(q.summary());
body.setContext(q.context());
body.setImportance(q.importance());
body.setStatus(QuestionDetailResponseQuestion.StatusEnum.fromValue(q.status()));
body.setNextVerification(q.nextVerification());
body.setPoints(points(q.points()));
body.setUpdates(PublicResponseMapper.map(q.updates(), DocumentResponseMapper::update));
body.setResolution(resolution(q));
body.setOpenedAt(PublicResponseMapper.at(q.openedAt()));
body.setUpdatedAt(PublicResponseMapper.at(q.updatedAt()));
QuestionDetailResponseRelations relations = new QuestionDetailResponseRelations();
relations.setPrimaryProject(PublicResponseMapper.related(view.relations().primaryProject()));
relations.setResultCase(PublicResponseMapper.related(view.relations().resultCase()));
relations.setProducedDecision(
PublicResponseMapper.related(view.relations().producedDecision()));
relations.setDerivedReferences(
PublicResponseMapper.relatedList(view.relations().derivedReferences()));
QuestionDetailResponse dto = new QuestionDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setQuestion(body);
dto.setRelations(relations);
return dto;
}
private static QuestionPointGroup points(QuestionPointGroupView view) {
QuestionPointGroup dto = new QuestionPointGroup();
dto.setFacts(view.facts());
dto.setAssumptions(view.assumptions());
dto.setUnknowns(view.unknowns());
dto.setConstraints(view.constraints());
return dto;
}
private static QuestionUpdatePublic update(QuestionUpdateView view) {
QuestionUpdatePublic dto = new QuestionUpdatePublic();
dto.setType(view.type());
dto.setTitle(view.title());
dto.setBodyMarkdown(view.bodyMarkdown());
dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt()));
return dto;
}
/**
* 계약은 해결 정보를 별도 nullable object 로 묶었고 view 는 평평하게 들고 있다. 세 값이 전부 비어 있으면 빈 껍데기 object 대신 아예 내보내지
* 않는다 — 미해결 질문에 {@code resolution: {}} 이 붙으면 소비자가 "해결됐지만 내용이 없다"로 읽는다.
*/
private static QuestionDetailResponseQuestionResolution resolution(PublishedQuestionView q) {
if (q.resolutionType() == null && q.resolutionSummary() == null && q.resolvedAt() == null) {
return null;
}
QuestionDetailResponseQuestionResolution dto = new QuestionDetailResponseQuestionResolution();
dto.setType(q.resolutionType());
dto.setSummary(q.resolutionSummary());
dto.setResolvedAt(PublicResponseMapper.at(q.resolvedAt()));
return dto;
}
}
@@ -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;
}
}
@@ -0,0 +1,113 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponseProject;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import java.util.List;
/** {@code listPublicProjects} 와 프로젝트 상세·하위 목록 세 개의 응답 조립. */
public final class ProjectResponseMapper {
private ProjectResponseMapper() {}
public static ProjectListResponse list(List<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.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;
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.AssetReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ContactLink;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.LatestEntry;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.PageMetadata;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectSummary;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RelatedEntry;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TagSummary;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import java.net.URI;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.function.Function;
/** 여러 응답이 함께 쓰는 조각의 매핑. */
public final class PublicResponseMapper {
private PublicResponseMapper() {}
public static OffsetDateTime at(Instant instant) {
return instant == null ? null : instant.atOffset(ZoneOffset.UTC);
}
public static TopicSummary topic(TopicSummaryView view) {
if (view == null) {
return null;
}
TopicSummary dto = new TopicSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
return dto;
}
public static TagSummary tag(TagSummaryView view) {
TagSummary dto = new TagSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
return dto;
}
public static ProjectSummary project(ProjectSummaryView view) {
if (view == null) {
return null;
}
ProjectSummary dto = new ProjectSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
dto.setPath(view.path());
return dto;
}
public static RelatedEntry related(RelatedEntryView view) {
if (view == null) {
return null;
}
RelatedEntry dto = new RelatedEntry();
dto.setType(RelatedEntry.TypeEnum.fromValue(view.type()));
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setPath(view.path());
return dto;
}
public static AssetReference asset(AssetReferenceView view) {
if (view == null) {
return null;
}
AssetReference dto = new AssetReference();
dto.setAssetId(view.assetId());
dto.setUrl(view.url());
dto.setAltText(view.altText());
dto.setWidth(view.width());
dto.setHeight(view.height());
dto.setContentType(view.contentType());
return dto;
}
public static ContactLink contact(ContactLinkView view) {
ContactLink dto = new ContactLink();
dto.setType(view.type());
dto.setLabel(view.label());
dto.setUrl(uri(view.url()));
return dto;
}
public static LatestEntry latest(LatestEntryView view) {
LatestEntry dto = new LatestEntry();
dto.setEntryType(LatestEntry.EntryTypeEnum.fromValue(view.entryType()));
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setPath(view.path());
dto.setPrimaryTopic(topic(view.primaryTopic()));
dto.setPrimaryProject(project(view.primaryProject()));
dto.setPublishedAt(at(view.publishedAt()));
return dto;
}
public static PageMetadata page(PageMetadataView view) {
PageMetadata dto = new PageMetadata();
dto.setNumber(view.number());
dto.setSize(view.size());
dto.setTotalElements(view.totalElements());
dto.setTotalPages(view.totalPages());
dto.setHasPrevious(view.hasPrevious());
dto.setHasNext(view.hasNext());
return dto;
}
/**
* 계약이 {@code format: uri} 로 선언한 자리. 저장된 값이 URI 로 파싱되지 않으면 그 링크를 내보내지 않는다 — 깨진 주소를 넣는 것보다 없는 편이
* 낫고, 소비자는 이 필드가 optional 임을 안다.
*/
static URI uri(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return URI.create(value);
} catch (IllegalArgumentException e) {
return null;
}
}
public static <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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -131,6 +131,13 @@ def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadin
'postgresqlTechLogStudioPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종,
// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를
// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다.
def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogPublicPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
@@ -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),
@@ -0,0 +1,260 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 공개된 Case / Reference / Question 상세.
*
* <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,"
+ " c.problem_summary, c.conclusion_summary, c.environment_items,"
+ " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status,"
+ " p.navigation_path, p.published_at, p.updated_at, p.last_verified_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug,"
+ " a.content_type AS cover_content_type, a.alt_text AS cover_alt,"
+ " a.width AS cover_width, a.height AS cover_height"
+ " FROM document d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
+ " LEFT JOIN topic t ON t.id = d.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " LEFT JOIN asset a ON a.id = d.cover_asset_id"
+ " WHERE d.document_type = :type AND d.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("type", type)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID id = rs.getObject("id", UUID.class);
boolean isCase = "CASE".equals(type);
return new DocumentRow(
id,
new PublishedDocumentView(
type,
rs.getString("navigation_path"),
rs.getString("title"),
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
isCase ? rs.getString("conclusion_summary") : null,
isCase ? json.strings(rs.getString("environment_items")) : List.of(),
isCase ? List.of() : json.strings(rs.getString("applies_to")),
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
isCase ? null : rs.getString("freshness_status"),
rs.getString("body_markdown"),
rs.getString("content_format"),
rs.getInt("content_format_version"),
topic(rs),
tags(id),
project(rs),
cover(rs),
instant(rs, "published_at"),
instant(rs, "updated_at"),
instant(rs, "last_verified_at")));
})
.optional();
}
@Override
public Optional<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();
}
static Instant instant(ResultSet rs, String column) throws SQLException {
var value = rs.getTimestamp(column);
return value == null ? null : value.toInstant();
}
static TopicSummaryView topic(ResultSet rs) throws SQLException {
return rs.getString("topic_slug") == null
? null
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug"));
}
static ProjectSummaryView project(ResultSet rs) throws SQLException {
return rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug"));
}
static AssetReferenceView cover(ResultSet rs) throws SQLException {
UUID assetId = rs.getObject("cover_asset_id", UUID.class);
return assetId == null
? null
: new AssetReferenceView(
assetId,
"/media/" + assetId,
rs.getString("cover_alt"),
(Integer) rs.getObject("cover_width"),
(Integer) rs.getObject("cover_height"),
rs.getString("cover_content_type"));
}
List<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();
}
}
@@ -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();
}
}
@@ -0,0 +1,330 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/** 프로젝트 목록·상세와 그 하위 목록. */
@Repository
public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
private static final int SECTION_LIMIT = 10;
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicProjectQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public List<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")),
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();
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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).
"/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 / 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> 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();
}
}
@@ -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"));
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.shared.error.MappingException;
import java.util.ArrayList;
import java.util.List;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* 공개 조회가 읽는 jsonb 컬럼을 푼다.
*
* <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;
}
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);
}
}
}
@@ -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);
}
}
@@ -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', '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;
};
}
}
@@ -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;
@@ -0,0 +1,965 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
import tools.jackson.databind.ObjectMapper;
/**
* 공개 조회 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
*
* <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 의 QUESTION/PROJECT 등이 섞이면"
+ " 응답 매퍼가 계약 밖 값을 만나 500 이 된다")
.extracting("entryType")
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
.allSatisfy(
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
}
/**
* 마이그레이션한 상태에서 {@code default_focus_type} NULL 이다. 계약은 필드를 required 선언하고 셋만 허용하므로, NULL
* 그대로 나가면 화면 전체가 500 된다 실제 기동 요청에서 그렇게 깨졌다. 설정이 비어도 계약이 아는 하나로 정해져야 한다.
*/
@Test
void homeFocusFallsBackToAContractValueWhenNothingIsConfigured() {
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = NULL,"
+ " current_project_id = NULL, open_question_id = NULL,"
+ " recent_decision_id = NULL WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.update();
try {
HomeView view = site.home(10);
assertThat(view.focus().defaultType())
.isIn("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION");
assertThat(view.focus().currentWork()).isNull();
assertThat(view.focus().openQuestion()).isNull();
assertThat(view.focus().recentDecision()).isNull();
// 설정은 비어 있지만 내용이 있는 갈래가 있으면 그쪽을 고른다.
jdbcClient
.sql("UPDATE home_focus_config SET open_question_id = :q WHERE id = :id")
.param("q", questionId)
.param("id", HOME_FOCUS_ID)
.update();
assertThat(site.home(10).focus().defaultType()).isEqualTo("OPEN_QUESTION");
} finally {
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
+ " current_project_id = :project, open_question_id = :question,"
+ " recent_decision_id = :decision WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.param("project", projectId)
.param("question", questionId)
.param("decision", decisionId)
.update();
}
}
@Test
void profileReadsItsJsonbColumnsIntoTypedViews() {
ProfileView view = site.profile().orElseThrow();
assertThat(view.headline()).isEqualTo("문제를 재현해 검증한다");
assertThat(view.workingModel()).extracting(ProfileView.NamedDescription::name).contains("재현");
assertThat(view.territories()).extracting(ProfileView.Territory::name).contains("Kafka");
assertThat(view.contacts()).hasSize(1);
assertThat(view.selectedEvidence())
.as("selected_evidence 는 공개된 것만 되살린다")
.extracting("title")
.containsExactly("Kafka 재처리");
}
// ---------------------------------------------------------------- 탐색
@Test
void knowledgeListsOnlyPublishedCasesAndReferences() {
KnowledgePageView page =
explore.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, null, null, page(1, 20)));
assertThat(page.items()).extracting("title").contains("Kafka 재처리", "Kafka 운영 기준");
assertThat(page.items()).extracting("title").doesNotContain("숨김 Case");
assertThat(page.page().totalElements())
.as("총계와 목록이 같은 조건을 써야 한다")
.isEqualTo(page.items().size());
}
@Test
void knowledgeAppliesEveryContractFilter() {
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery("CASE", null, null, null, null, null, page(1, 20)))
.items())
.extracting("type")
.containsOnly("CASE");
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, "kafka", null, null, null, null, page(1, 20)))
.items())
.isNotEmpty();
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(
null, "no-such-topic", null, null, null, null, page(1, 20)))
.items())
.isEmpty();
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(
null, null, null, "reprocessing", null, null, page(1, 20)))
.items())
.as("tag 필터의 상관 EXISTS 서브쿼리")
.extracting("title")
.containsExactly("Kafka 재처리");
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, 1999, null, page(1, 20)))
.items())
.as("year 필터는 date_part 로 건다")
.isEmpty();
}
/** 계약의 정렬 세 값이 전부 유효한 SQL 이어야 한다 — 오타는 문법 오류로만 드러난다. */
@Test
void knowledgeAcceptsEveryContractSort() {
for (String sort : List.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC")) {
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, null, sort, page(1, 20)))
.items())
.as("sort=%s", sort)
.isNotEmpty();
}
}
@Test
void questionsListAppliesStatusTagAndEverySort() {
QuestionPageView all =
explore.questions(new ExploreQuestionsQuery(null, null, null, null, null, page(1, 20)));
assertThat(all.items()).extracting("question").contains("재처리 지연을 어떻게 줄일까");
assertThat(
explore
.questions(
new ExploreQuestionsQuery("RESOLVED", null, null, null, null, page(1, 20)))
.items())
.isEmpty();
assertThat(
explore
.questions(
new ExploreQuestionsQuery(null, null, null, "reprocessing", null, page(1, 20)))
.items())
.as("질문에도 tag 필터가 걸려야 한다")
.isNotEmpty();
for (String sort : List.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC")) {
assertThat(
explore
.questions(new ExploreQuestionsQuery(null, null, null, null, sort, page(1, 20)))
.items())
.as("sort=%s", sort)
.isNotEmpty();
}
}
// ---------------------------------------------------------------- 주제
@Test
void topicListCountsOnlyPublishedRecords() {
assertThat(topics.list()).extracting("slug").contains("kafka");
var kafka =
topics.list().stream().filter(t -> t.slug().equals("kafka")).findFirst().orElseThrow();
// Case · Reference · Question 셋만 주제를 primary 가지며, 게시 취소된 Case 세지 않는다.
assertThat(kafka.recordCount()).as("게시 취소된 자료는 세지 않는다").isEqualTo(3);
}
@Test
void topicDetailResolvesEverySection() {
TopicDetailView view = topics.findBySlug("kafka").orElseThrow();
assertThat(view.name()).isEqualTo("Kafka");
assertThat(view.featuredReference()).isNotNull();
assertThat(view.featuredReference().title()).isEqualTo("Kafka 운영 기준");
assertThat(view.activeQuestions()).isNotEmpty();
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
assertThat(view.latestRecords()).isNotEmpty();
assertThat(view.latestRecords())
.as("주제 상세의 최신 기록도 계약의 entryType 네 값을 벗어나면 안 된다")
.extracting("entryType")
.allSatisfy(
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
}
@Test
void topicDetailIsAbsentForAnUnknownSlug() {
assertThat(topics.findBySlug("no-such-topic")).isEmpty();
}
// ---------------------------------------------------------------- 문서 상세
@Test
void caseDetailReadsTheOriginalTableNotTheProjectionPayload() {
CaseDetailView view = documents.findCase("kafka-reprocessing").orElseThrow();
assertThat(view.canonicalPath()).isEqualTo("/cases/kafka-reprocessing");
assertThat(view.document().title()).isEqualTo("Kafka 재처리");
assertThat(view.document().primarySummary()).isEqualTo("재처리가 지연된다");
assertThat(view.document().secondarySummary()).isEqualTo("컨슈머 랙을 먼저 본다");
assertThat(view.document().content()).contains("# 재처리");
assertThat(view.document().contentFormat()).isEqualTo("MARKDOWN");
assertThat(view.document().environmentSummary()).containsExactly("Kafka 3.7");
assertThat(view.document().primaryTopic().slug()).isEqualTo("kafka");
assertThat(view.document().primaryProject().slug()).isEqualTo("tech-log");
assertThat(view.document().tags()).extracting("slug").containsExactly("reprocessing");
}
@Test
void referenceDetailReadsItsOwnScopeColumns() {
ReferenceDetailView view = documents.findReference("kafka-operations").orElseThrow();
assertThat(view.document().primarySummary()).isEqualTo("운영 기준을 정한다");
assertThat(view.document().appliesTo()).containsExactly("Kafka 3.x");
assertThat(view.document().excludedScope()).containsExactly("Kinesis");
assertThat(view.document().freshnessStatus()).isEqualTo("CURRENT");
}
@Test
void questionDetailReadsItsTimelineAndResolutionColumns() {
QuestionDetailView view = documents.findQuestion("reprocessing-latency").orElseThrow();
assertThat(view.question().question()).isEqualTo("재처리 지연을 어떻게 줄일까");
assertThat(view.question().status()).isEqualTo("OPEN");
assertThat(view.question().resolvedAt()).isNull();
assertThat(view.question().points()).isNotNull();
}
@Test
void aWithdrawnDocumentIsNotReadable() {
assertThat(documents.findCase("hidden-case")).as("게시 취소된 문서는 상세로도 열리면 안 된다").isEmpty();
}
// ---------------------------------------------------------------- 프로젝트
@Test
void projectListAndDetailReadEveryPublishedColumn() {
assertThat(projects.list()).extracting("slug").containsExactly("tech-log");
ProjectDetailView view = projects.findBySlug("tech-log").orElseThrow();
assertThat(view.project().name()).isEqualTo("Tech Log");
assertThat(view.project().technologies()).contains("Spring Boot");
assertThat(view.canonicalPath()).isEqualTo("/projects/tech-log");
assertThat(view.featuredDecision()).isNotNull();
assertThat(view.selectedRecords()).isNotEmpty();
}
@Test
void projectSubListsReturnEmptyOptionalForAnUnknownProject() {
assertThat(projects.decisions(new ProjectDecisionPageQuery("nope", null, page(1, 20))))
.isEmpty();
assertThat(projects.records(new ProjectRecordPageQuery("nope", null, null, page(1, 20))))
.isEmpty();
assertThat(projects.activities(new ProjectPageQuery("nope", page(1, 20)))).isEmpty();
}
@Test
void projectDecisionsApplyTheStatusFilterToBothCountAndPage() {
var all =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", null, page(1, 20)))
.orElseThrow();
assertThat(all.items()).hasSize(1);
assertThat(all.page().totalElements()).isEqualTo(1);
var accepted =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", "ACCEPTED", page(1, 20)))
.orElseThrow();
assertThat(accepted.items()).hasSize(1);
assertThat(accepted.page().totalElements()).isEqualTo(1);
var proposed =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", "PROPOSED", page(1, 20)))
.orElseThrow();
assertThat(proposed.items()).isEmpty();
assertThat(proposed.page().totalElements()).as("필터가 목록에만 걸리고 총계에 안 걸리면 여기서 드러난다").isZero();
}
@Test
void projectRecordsApplyTypeAndRelationFilters() {
var all =
projects
.records(new ProjectRecordPageQuery("tech-log", null, null, page(1, 20)))
.orElseThrow();
assertThat(all.items()).isNotEmpty();
assertThat(all.page().totalElements()).isEqualTo(all.items().size());
var cases =
projects
.records(new ProjectRecordPageQuery("tech-log", "CASE", null, page(1, 20)))
.orElseThrow();
assertThat(cases.items()).extracting("type").containsOnly("CASE");
var related =
projects
.records(new ProjectRecordPageQuery("tech-log", null, "RELATED", page(1, 20)))
.orElseThrow();
assertThat(related.page().totalElements()).isEqualTo(related.items().size());
var none =
projects
.records(new ProjectRecordPageQuery("tech-log", "QUESTION", "RELATED", page(1, 20)))
.orElseThrow();
assertThat(none.page().totalElements()).isEqualTo(none.items().size());
}
@Test
void projectActivitiesListOnlyPublicOnes() {
var activities =
projects.activities(new ProjectPageQuery("tech-log", page(1, 20))).orElseThrow();
assertThat(activities.items()).extracting("title").containsExactly("첫 게시");
assertThat(activities.page().totalElements()).isEqualTo(1);
}
// ---------------------------------------------------------------- 릴리스
@Test
void releasesListOnlyPublishedOnesAndResolveRelatedRecords() {
assertThat(releases.list()).extracting("version").containsExactly("1.0.0");
ReleaseDetailView detail = releases.findByVersion("1.0.0").orElseThrow();
assertThat(detail.title()).isEqualTo("첫 공개");
assertThat(detail.changeTypes()).containsExactly("ADDED");
assertThat(detail.relatedRecords())
.as("related_resources 는 공개된 것만 되살린다")
.extracting("title")
.containsExactly("Kafka 재처리");
assertThat(releases.findByVersion("0.9.0")).as("DRAFT 릴리스는 열리면 안 된다").isEmpty();
}
// ---------------------------------------------------------------- 검색
@Test
void searchMatchesOnSearchTextAndAppliesFilters() {
SearchResultPageView hits = search.search(new SearchQuery("재처리", null, null, page(1, 20)));
assertThat(hits.query()).isEqualTo("재처리");
assertThat(hits.items()).isNotEmpty();
assertThat(hits.page().totalElements()).isEqualTo(hits.items().size());
assertThat(hits.items()).extracting("title").doesNotContain("숨김 Case");
assertThat(search.search(new SearchQuery("기준", "REFERENCE", null, page(1, 20))).items())
.extracting("contentType")
.containsOnly("REFERENCE");
assertThat(search.search(new SearchQuery("기준", "CASE", null, page(1, 20))).items())
.as("type 필터가 실제로 걸려야 한다")
.isEmpty();
assertThat(search.search(new SearchQuery("존재하지않는단어", null, null, page(1, 20))).items())
.isEmpty();
}
// ---------------------------------------------------------------- 시딩
/**
* 삽입 순서가 제약이다. {@code public_resource_project_link}/{@code public_resource_tag} {@code
* public_resource_projection} 복합 FK 참조하므로 원본 테이블 projection 링크/태그 순서를 지킨다.
*/
private static void seed() {
topicId = insertTopic("kafka", "Kafka");
projectId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project (id, slug, name, one_line_purpose, purpose_markdown,"
+ " boundary_markdown, system_overview_markdown, phase, current_objective,"
+ " next_step, technology_labels, workflow_status, target_visibility,"
+ " created_by, updated_by)"
+ " VALUES (:id, 'tech-log', 'Tech Log', '기록을 남긴다', '목적', '경계', '개요',"
+ " 'IMPLEMENTATION', '공개 API 완성', '통합 테스트',"
+ " '[\"Spring Boot\", \"PostgreSQL\"]'::jsonb, 'PUBLISHED', 'PUBLIC',"
+ " 'test', 'test')")
.param("id", projectId)
.update();
tagId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO tag (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, 'reprocessing', 'reprocessing', 'reprocessing', 'test', 'test')")
.param("id", tagId)
.update();
// --- Case (공개) ---
caseId = UUID.randomUUID();
insertDocument(caseId, "CASE", "kafka-reprocessing", "Kafka 재처리", topicId);
jdbcClient
.sql(
"INSERT INTO case_detail (document_id, problem_summary, conclusion_summary,"
+ " environment_items) VALUES (:id, '재처리가 지연된다', '컨슈머 랙을 먼저 본다',"
+ " '[\"Kafka 3.7\"]'::jsonb)")
.param("id", caseId)
.update();
publish(
"CASE",
caseId,
"Kafka 재처리",
"재처리가 지연된다",
"/cases/kafka-reprocessing",
"ACTIVE",
"PUBLIC",
topicId);
link(caseId, "CASE", "PRIMARY", 0);
tag(caseId, "CASE");
// 목록의 tag 필터는 projection(public_resource_tag), 상세는 원본(document_tag) 읽는다.
jdbcClient
.sql(
"INSERT INTO document_tag (document_id, tag_id, display_order)" + " VALUES (:d, :t, 0)")
.param("d", caseId)
.param("t", tagId)
.update();
// --- Reference (공개) ---
referenceId = UUID.randomUUID();
insertDocument(referenceId, "REFERENCE", "kafka-operations", "Kafka 운영 기준", topicId);
jdbcClient
.sql(
"INSERT INTO reference_detail (document_id, scope_summary, applies_to,"
+ " excluded_scope, freshness_status) VALUES (:id, '운영 기준을 정한다',"
+ " '[\"Kafka 3.x\"]'::jsonb, '[\"Kinesis\"]'::jsonb, 'CURRENT')")
.param("id", referenceId)
.update();
publish(
"REFERENCE",
referenceId,
"Kafka 운영 기준",
"운영 기준을 정한다",
"/references/kafka-operations",
"ACTIVE",
"PUBLIC",
topicId);
link(referenceId, "REFERENCE", "RELATED", null);
// --- Case (게시 취소) 어느 경로로도 새면 된다 ---
hiddenCaseId = UUID.randomUUID();
insertDocument(hiddenCaseId, "CASE", "hidden-case", "숨김 Case", topicId);
jdbcClient
.sql("INSERT INTO case_detail (document_id, problem_summary) VALUES (:id, '재처리 비밀')")
.param("id", hiddenCaseId)
.update();
publish(
"CASE",
hiddenCaseId,
"숨김 Case",
"재처리 비밀",
"/cases/hidden-case",
"WITHDRAWN",
"PUBLIC",
topicId);
// --- OpenQuestion (공개) ---
questionId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO open_question (id, slug, question, summary, context_markdown,"
+ " importance_markdown, next_verification, question_status, target_visibility,"
+ " primary_topic_id, opened_at, created_by, updated_by)"
+ " VALUES (:id, 'reprocessing-latency', '재처리 지연을 어떻게 줄일까',"
+ " '지연 원인을 좁힌다', '맥락', '중요도', '컨슈머 랙 측정', 'OPEN', 'PUBLIC', :topic,"
+ " :openedAt, 'test', 'test')")
.param("id", questionId)
.param("topic", topicId)
.param("openedAt", java.sql.Timestamp.from(NOW.minus(10, ChronoUnit.DAYS)))
.update();
publish(
"QUESTION",
questionId,
"재처리 지연을 어떻게 줄일까",
"지연 원인을 좁힌다",
"/questions/reprocessing-latency",
"ACTIVE",
"PUBLIC",
topicId);
// 질문 목록의 status 필터는 projection state_code 본다.
jdbcClient
.sql(
"UPDATE public_resource_projection SET state_code = 'OPEN'"
+ " WHERE resource_type = 'QUESTION' AND resource_id = :id")
.param("id", questionId)
.update();
link(questionId, "QUESTION", "PRIMARY", 1);
tag(questionId, "QUESTION");
// --- ProjectDecision (공개) ---
decisionId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project_decision (id, project_id, statement, rationale_markdown,"
+ " consequences, decision_status, target_visibility, source_question_id,"
+ " source_case_id, is_featured, decided_at, created_by, updated_by)"
+ " VALUES (:id, :project, '재처리는 별도 토픽으로 분리한다', '격리해야 관측이 쉬워진다',"
+ " '[\"운영 토픽 증가\"]'::jsonb, 'ACCEPTED', 'PUBLIC', :question, :sourceCase,"
+ " true, :decidedAt, 'test', 'test')")
.param("id", decisionId)
.param("project", projectId)
.param("question", questionId)
.param("sourceCase", caseId)
.param("decidedAt", java.sql.Timestamp.from(NOW.minus(2, ChronoUnit.DAYS)))
.update();
publish(
"PROJECT_DECISION",
decisionId,
"재처리는 별도 토픽으로 분리한다",
"격리해야 관측이 쉬워진다",
"/projects/tech-log/decisions/" + decisionId,
"ACTIVE",
"PUBLIC",
null);
// --- Project (공개) ---
publish(
"PROJECT",
projectId,
"Tech Log",
"기록을 남긴다",
"/projects/tech-log",
"ACTIVE",
"PUBLIC",
null);
// --- 활동: 공개 하나 · 비공개 하나 ---
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
+ " created_by, updated_by)"
+ " VALUES (gen_random_uuid(), :project, 'CASE_PUBLISHED', '첫 게시',"
+ " '첫 문서를 공개했다', 'PUBLIC', 'AUTO', 'CASE', :relatedCase, :at,"
+ " 'test', 'test')")
.param("project", projectId)
.param("relatedCase", caseId)
.param("at", java.sql.Timestamp.from(NOW.minus(1, ChronoUnit.DAYS)))
.update();
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, visibility,"
+ " origin, occurred_at, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), :project, 'MILESTONE_REACHED', '비공개 메모', 'PRIVATE',"
+ " 'MANUAL', :at, 'test', 'test')")
.param("project", projectId)
.param("at", java.sql.Timestamp.from(NOW))
.update();
// --- V9 연결 테이블 ---
jdbcClient
.sql("INSERT INTO project_topic (project_id, topic_id, display_order) VALUES (:p, :t, 0)")
.param("p", projectId)
.param("t", topicId)
.update();
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
.param("t", topicId)
.param("d", referenceId)
.update();
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'FEATURED_CASE', 0)")
.param("t", topicId)
.param("d", caseId)
.update();
// --- 단일 설정 ---
jdbcClient
.sql("UPDATE site_config SET contacts = :contacts::jsonb WHERE id = :id")
.param("id", SITE_CONFIG_ID)
.param(
"contacts",
"[{\"type\":\"GITHUB\",\"label\":\"GitHub\","
+ "\"url\":\"https://github.com/example\"}]")
.update();
// V9 단일 행을 이미 시딩했으므로(INSERT ... ON CONFLICT DO NOTHING) 채우기는 UPDATE .
jdbcClient
.sql(
"UPDATE profile_page SET headline = '문제를 재현해 검증한다',"
+ " introduction_markdown = '소개', working_model = :workingModel::jsonb,"
+ " territories = :territories::jsonb, selected_evidence = :evidence::jsonb,"
+ " trajectory = :trajectory::jsonb, contacts = :contacts::jsonb,"
+ " target_visibility = 'PUBLIC' WHERE id = :id")
.param("id", PROFILE_PAGE_ID)
.param("workingModel", "[{\"name\":\"재현\",\"description\":\"먼저 재현한다\"}]")
.param(
"territories",
"[{\"name\":\"Kafka\",\"currentQuestion\":\"재처리 지연\","
+ "\"topicPath\":\"/topics/kafka\"}]")
// 게시 취소된 자료 id 함께 넣는다 공개된 것만 되살아나야 한다.
.param("evidence", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
.param("trajectory", "[{\"title\":\"2026\",\"description\":\"Tech Log 시작\"}]")
.param(
"contacts",
"[{\"type\":\"EMAIL\",\"label\":\"Email\"," + "\"url\":\"mailto:a@example.com\"}]")
.update();
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
+ " current_project_id = :project, open_question_id = :question,"
+ " recent_decision_id = :decision WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.param("project", projectId)
.param("question", questionId)
.param("decision", decisionId)
.update();
// --- 릴리스: 공개 하나 · 초안 하나 ---
jdbcClient
.sql(
"INSERT INTO release (id, version_label, title, summary, released_on,"
+ " workflow_status, change_types, changes_markdown, verification_markdown,"
+ " related_resources, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), '1.0.0', '첫 공개', '공개 API 를 열었다',"
+ " DATE '2026-08-01', 'PUBLISHED', '[\"ADDED\"]'::jsonb, '변경', '검증',"
+ " :related::jsonb, 'test', 'test')")
// 게시 취소된 자료 id 일부러 함께 넣는다 공개된 것만 되살아나야 한다.
.param("related", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
.update();
jdbcClient
.sql(
"INSERT INTO release (id, version_label, title, summary, released_on,"
+ " workflow_status, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), '0.9.0', '초안', '아직 공개 전', DATE '2026-07-01',"
+ " 'DRAFT', 'test', 'test')")
.update();
}
private static UUID insertTopic(String slug, String name) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, description, scope,"
+ " status, created_by, updated_by)"
+ " VALUES (:id, :name, lower(:name), :slug, :name || ' 설명', '범위',"
+ " 'ACTIVE', 'test', 'test')")
.param("id", id)
.param("name", name)
.param("slug", slug)
.update();
return id;
}
private static void insertDocument(UUID id, String type, String slug, String title, UUID topic) {
jdbcClient
.sql(
"INSERT INTO document (id, document_type, slug, title, body_markdown,"
+ " content_format, content_format_version, workflow_status, target_visibility,"
+ " primary_topic_id, last_verified_at, created_by, updated_by)"
+ " VALUES (:id, :type, :slug, :title, '# 재처리\n본문', 'MARKDOWN', 1,"
+ " 'PUBLISHED', 'PUBLIC', :topic, :verifiedAt, 'test', 'test')")
.param("id", id)
.param("type", type)
.param("slug", slug)
.param("title", title)
.param("topic", topic)
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
.update();
}
private static void link(UUID resourceId, String type, String relation, Integer order) {
jdbcClient
.sql(
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
+ " relation_type, featured_order)"
+ " VALUES (:type, :id, :project, :relation, :order)")
.param("type", type)
.param("id", resourceId)
.param("project", projectId)
.param("relation", relation)
.param("order", order)
.update();
}
private static void tag(UUID resourceId, String type) {
jdbcClient
.sql(
"INSERT INTO public_resource_tag (resource_type, resource_id, tag_id, display_order)"
+ " VALUES (:type, :id, :tag, 0)")
.param("type", type)
.param("id", resourceId)
.param("tag", tagId)
.update();
}
/**
* projection 행을 만든다. {@code public_resource_project_link} {@code public_resource_tag} 행을
* (resource_type, resource_id) 복합 FK 참조하므로 <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());
}
}
+11
View File
@@ -76,6 +76,17 @@ dependencies {
// Security types for ManagementSecurityConfig (not reachable via adapter-web's implementation dep). See README.
implementation 'org.springframework.boot:spring-boot-starter-security'
// Redis-backed HTTP session for auth-mode=redis-session (the BFF surface the Studio contract
// declares: sessionCookie TECHLOG_SESSION + X-CSRF-TOKEN). AuthenticationModeCompositionConfig
// requires the `redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair once
// that mode is active; StudioSessionInfrastructureConfig supplies the first, Spring Session's
// SpringHttpSessionConfiguration the second.
// OIDC Authorization Code (ClientRegistrationRepository ).
// spring-security-oauth2-client Boot .
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// test-only: ArchUnit needs actuator types to verify the health-shape guardrail. See README.
testImplementation 'org.springframework.boot:spring-boot-starter-actuator'
// test-only: @WithMockUser for the actuator security authorization tests. See README.
+27 -17
View File
@@ -99,7 +99,7 @@ io.grpc:grpc-protobuf:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath
io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -108,27 +108,27 @@ io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTranspor
io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-classes-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-compression:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http2:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-http3:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-http:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-native-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-socks:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler-proxy:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns-native-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-classes-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-transport-native-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -147,7 +147,7 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas
io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath
io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -268,7 +268,7 @@ org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -281,9 +281,10 @@ org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspa
org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -297,15 +298,18 @@ org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspat
org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-netty:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -314,6 +318,7 @@ org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspa
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -336,8 +341,10 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntim
org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath
org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -346,20 +353,23 @@ org.springframework.security:spring-security-core:7.0.0=compileClasspath,functio
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context-support:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-oxm:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -374,7 +384,7 @@ org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffT
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -0,0 +1,542 @@
package dev.caskeleton.bootstrap.contract;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.yaml.snakeyaml.Yaml;
/**
* {@code PublicContractDriftTest} {@code StudioContractDriftTest} studio-v1 대해 하는 일을
* public-v1 대해 한다: springdoc 실제로 게시하는 표면과 vendored {@code src/config/openapi/public-v1.yaml}
* <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();
}
/** 반대 방향 — 계약의 18 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(18);
}
private static String serverPrefix(JsonNode contract) {
String url = contract.path("servers").path(0).path("url").asText("");
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
private static JsonNode readContract() throws Exception {
Path contractFile =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/public-v1.yaml");
Map<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));
}
};
}
@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();
}
}
}
@@ -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,
@@ -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;
}
@@ -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);
}
@@ -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();
}
}
@@ -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;
}
}
@@ -0,0 +1,139 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Tech Log 공개 조회(public-v1) 조립. application-core Spring 보지 않으므로 여기서 배선한다 {@link
* TechLogStudioConfig} 같은 이유·같은 모양이다.
*
* <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);
}
}
@@ -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
@@ -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();
}
}
@@ -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"));
}
}
@@ -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"));
}
}
}
@@ -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;
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.application.techlog.publicsite.error;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/**
* 공개 조회 계약(`public-v1.yaml`) `ApiError.code`.
*
* <p>계약의 enum 값인데 여기엔 둘뿐이다. 나머지 하나 {@code INTERNAL_ERROR} 기능이 아니라 스켈레톤의 공통 예외 처리기가 내는 코드이므로
* 여기서 다시 선언하지 않는다 같은 코드를 enum 각자 status 함께 선언하면 레지스트리가 어느 쪽을 따라야 할지 없다.
*
* <p>Studio 코드와 이름을 겹치지 않게 것도 같은 이유다. 레지스트리는 코드 하나에 status 하나만 담을 있어서 public 400 studio
* 422 같은 이름으로 없다.
*/
public enum PublicError implements ApiErrorCode {
PUBLIC_REQUEST_INVALID(Category.VALIDATION, 400, false),
PUBLIC_RESOURCE_NOT_FOUND(Category.NOT_FOUND, 404, false);
private final Category category;
private final int httpStatus;
private final boolean retryable;
PublicError(Category category, int httpStatus, boolean retryable) {
this.category = category;
this.httpStatus = httpStatus;
this.retryable = retryable;
}
@Override
public String code() {
return name();
}
@Override
public Category category() {
return category;
}
@Override
public int httpStatus() {
return httpStatus;
}
@Override
public boolean retryable() {
return retryable;
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.application.techlog.publicsite.error;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
/**
* 공개 조회 실패. {@link #getMessage()} 진단용이며 클라이언트에게 그대로 나가지 않는다 응답 문구는 레지스트리의 client-safe message
* 쓴다({@code ApiErrorCarrier} javadoc).
*/
public final class PublicException extends RuntimeException implements ApiErrorCarrier {
private final transient PublicError error;
private PublicException(PublicError error, String message) {
super(message);
this.error = error;
}
public static PublicException of(PublicError error, String message) {
return new PublicException(error, message);
}
@Override
public ApiErrorCode errorCode() {
return error;
}
public PublicError publicError() {
return error;
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.UUID;
/**
* 계약 {@code AssetReference}.
*
* @param url 검증된 전송 경로다. object storage URL 아니다(설계 05장 §3.1).
*/
public record AssetReferenceView(
UUID assetId, String url, String altText, Integer width, Integer height, String contentType) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code CaseDetailResponse}. */
public record CaseDetailView(
String canonicalPath,
boolean indexable,
PublishedDocumentView document,
CaseRelationsView relations) {}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code CaseDetailResponse.relations}. */
public record CaseRelationsView(
RelatedEntryView originQuestion,
List<RelatedEntryView> projectDecisions,
List<RelatedEntryView> derivedReferences,
List<RelatedEntryView> relatedCases) {
public CaseRelationsView {
projectDecisions = projectDecisions == null ? List.of() : List.copyOf(projectDecisions);
derivedReferences = derivedReferences == null ? List.of() : List.copyOf(derivedReferences);
relatedCases = relatedCases == null ? List.of() : List.copyOf(relatedCases);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code ContactLink}. */
public record ContactLinkView(String type, String label, String url) {}
@@ -0,0 +1,106 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/**
* 계약 {@code HomeResponse.focus}. {@code defaultType} 어느 갈래를 보여줄지 정하고 갈래는 전부 optional 이다 계약이
* 그렇게 정했다. 설정된 갈래가 비어 있을 있으므로(: 지목한 질문이 비공개가 되었을 ) 타입으로 "반드시 하나는 있다" 강제하지 않는다.
*/
public record HomeFocusView(
String defaultType,
CurrentWork currentWork,
OpenQuestion openQuestion,
RecentDecision recentDecision) {
/**
* 설정된 값이 없거나 갈래가 비었을 순서. 계약이 {@code defaultType} required 선언했으므로 "정해진 게 없다" null 표현할
* 없다.
*/
private static final String[] FALLBACK_ORDER = {
"CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION"
};
/**
* 계약이 {@code focus.defaultType} required 선언하고 셋만 허용한다. 그런데 설정 테이블은 마이그레이션한 상태에서 {@code
* default_focus_type} NULL 이고, 지목한 갈래가 비공개로 바뀌어 비는 경우도 있다. 그대로 내보내면 응답 매퍼가 계약 값을 만나 화면 전체가
* 실패한다 실제로 배포 직후 요청이 그렇게 깨졌다.
*
* <p>그래서 자리에서 반드시 유효한 하나를 정한다.
*
* <ol>
* <li>설정된 값이 유효하고 갈래에 내용이 있으면 그대로 쓴다.
* <li>아니면 내용이 있는 갈래를 {@link #FALLBACK_ORDER} 순으로 고른다.
* <li> 비었으면 값을 쓴다 갈래는 전부 optional 이므로 비어 있어도 계약을 만족한다.
* </ol>
*/
public static HomeFocusView resolve(
String configuredType,
CurrentWork currentWork,
OpenQuestion openQuestion,
RecentDecision recentDecision) {
if (configuredType != null
&& hasContent(configuredType, currentWork, openQuestion, recentDecision)) {
return new HomeFocusView(configuredType, currentWork, openQuestion, recentDecision);
}
for (String candidate : FALLBACK_ORDER) {
if (hasContent(candidate, currentWork, openQuestion, recentDecision)) {
return new HomeFocusView(candidate, currentWork, openQuestion, recentDecision);
}
}
return new HomeFocusView(FALLBACK_ORDER[0], currentWork, openQuestion, recentDecision);
}
private static boolean hasContent(
String type,
CurrentWork currentWork,
OpenQuestion openQuestion,
RecentDecision recentDecision) {
return switch (type) {
case "CURRENT_WORK" -> currentWork != null;
case "OPEN_QUESTION" -> openQuestion != null;
case "RECENT_DECISION" -> recentDecision != null;
// 계약 값이 설정에 들어 있는 경우다. 그대로 쓰면 응답이 깨지므로 없는 것으로 친다.
default -> false;
};
}
/** 계약 {@code CurrentWorkFocus}. */
public record CurrentWork(
String projectName,
String projectPath,
String purpose,
String phase,
String currentObjective,
String nextStep,
Instant updatedAt) {}
/** 계약 {@code OpenQuestionFocus}. */
public record OpenQuestion(
String question,
String questionPath,
String summary,
List<String> knownFacts,
List<String> unresolvedPoints,
String nextVerification,
Instant updatedAt) {
public OpenQuestion {
knownFacts = knownFacts == null ? List.of() : List.copyOf(knownFacts);
unresolvedPoints = unresolvedPoints == null ? List.of() : List.copyOf(unresolvedPoints);
}
}
/** 계약 {@code RecentDecisionFocus}. */
public record RecentDecision(
String statement,
String decisionPath,
String rationale,
List<String> consequences,
Instant decidedAt) {
public RecentDecision {
consequences = consequences == null ? List.of() : List.copyOf(consequences);
}
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code HomeResponse}. */
public record HomeView(HomeFocusView focus, List<LatestEntryView> latestEntries) {
public HomeView {
latestEntries = latestEntries == null ? List.of() : List.copyOf(latestEntries);
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code KnowledgeListItem}. */
public record KnowledgeListItemView(
String type,
String title,
String path,
String primarySummary,
String secondarySummary,
TopicSummaryView primaryTopic,
ProjectSummaryView primaryProject,
Instant publishedAt,
Instant lastVerifiedAt,
String freshnessStatus) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code KnowledgePage}. */
public record KnowledgePageView(List<KnowledgeListItemView> items, PageMetadataView page) {
public KnowledgePageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code LatestEntry}. */
public record LatestEntryView(
String entryType,
String title,
String summary,
String path,
TopicSummaryView primaryTopic,
ProjectSummaryView primaryProject,
Instant publishedAt) {}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.techlog.publicsite.model;
/**
* 계약 {@code PageMetadata}. 공개 조회는 studio 달리 offset 페이지네이션이다 계약이 그렇게 정했고, 공개 목록은 "3페이지로 바로 가기"
* 필요한 화면이라 cursor 대체할 없다.
*/
public record PageMetadataView(
int number,
int size,
long totalElements,
int totalPages,
boolean hasPrevious,
boolean hasNext) {
public static PageMetadataView of(int page, int size, long total) {
int totalPages = size <= 0 ? 0 : (int) Math.ceil((double) total / size);
return new PageMetadataView(page, size, total, totalPages, page > 1, page < totalPages);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProfileResponse}. */
public record ProfileView(
String headline,
String description,
List<NamedDescription> workingModel,
List<Territory> territories,
List<RelatedEntryView> selectedEvidence,
List<NamedDescription> trajectory,
List<ContactLinkView> contacts) {
public ProfileView {
workingModel = workingModel == null ? List.of() : List.copyOf(workingModel);
territories = territories == null ? List.of() : List.copyOf(territories);
selectedEvidence = selectedEvidence == null ? List.of() : List.copyOf(selectedEvidence);
trajectory = trajectory == null ? List.of() : List.copyOf(trajectory);
contacts = contacts == null ? List.of() : List.copyOf(contacts);
}
/** {@code workingModel[]} 과 {@code trajectory[]} 가 같은 모양이라 하나로 쓴다. */
public record NamedDescription(String name, String description) {}
/** {@code territories[]}. */
public record Territory(String name, String currentQuestion, String topicPath) {}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code ProjectActivityItem}. */
public record ProjectActivityItemView(
String type, String title, String summary, Instant occurredAt, String relatedPath) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProjectActivityPage}. */
public record ProjectActivityPageView(List<ProjectActivityItemView> items, PageMetadataView page) {
public ProjectActivityPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.UUID;
/** 계약 {@code ProjectDecisionItem}. */
public record ProjectDecisionItemView(
UUID id,
String statement,
String status,
String rationaleSummary,
Instant decidedAt,
RelatedEntryView sourceQuestion,
RelatedEntryView sourceCase) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProjectDecisionPage}. */
public record ProjectDecisionPageView(List<ProjectDecisionItemView> items, PageMetadataView page) {
public ProjectDecisionPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProjectDetailResponse}. */
public record ProjectDetailView(
String canonicalPath,
boolean indexable,
PublishedProjectView project,
RelatedEntryView featuredDecision,
RelatedEntryView activeQuestion,
List<RelatedEntryView> selectedRecords) {
public ProjectDetailView {
selectedRecords = selectedRecords == null ? List.of() : List.copyOf(selectedRecords);
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code ProjectListItem}. */
public record ProjectListItemView(
String name,
String slug,
String path,
String oneLinePurpose,
String phase,
String currentObjective,
String nextStep,
Instant updatedAt) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ProjectRecordPage}. */
public record ProjectRecordPageView(List<RelatedEntryView> items, PageMetadataView page) {
public ProjectRecordPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code ProjectSummary}. */
public record ProjectSummaryView(String name, String slug, String path) {}
@@ -0,0 +1,41 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/**
* 공개된 Case / Reference 본문과 메타데이터.
*
* <p>계약의 {@code CaseDetailResponse.case} {@code ReferenceDetailResponse.reference} 담는 필드가
* 다르지만(문제/결론 vs 범위/적용), 원천이 같은 {@code document} + 유형별 detail 이라 하나의 레코드로 읽고 계층에서 유형별 모양으로 나눈다.
*
* @param content Markdown 원문이다. studio 렌더 블록이 아니다 공개 계약은 {@code contentFormat} 함께 원문을 준다.
*/
public record PublishedDocumentView(
String type,
String canonicalPath,
String title,
String primarySummary,
String secondarySummary,
List<String> environmentSummary,
List<String> appliesTo,
List<String> excludedScope,
String freshnessStatus,
String content,
String contentFormat,
int contentFormatVersion,
TopicSummaryView primaryTopic,
List<TagSummaryView> tags,
ProjectSummaryView primaryProject,
AssetReferenceView coverAsset,
Instant publishedAt,
Instant updatedAt,
Instant lastVerifiedAt) {
public PublishedDocumentView {
environmentSummary = environmentSummary == null ? List.of() : List.copyOf(environmentSummary);
appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo);
excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope);
tags = tags == null ? List.of() : List.copyOf(tags);
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/** 계약 {@code ProjectDetailResponse.project}. */
public record PublishedProjectView(
String name,
String slug,
String oneLinePurpose,
String purpose,
String boundary,
String phase,
String currentObjective,
String nextStep,
String systemOverviewMarkdown,
List<String> technologies,
Instant updatedAt) {
public PublishedProjectView {
technologies = technologies == null ? List.of() : List.copyOf(technologies);
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/** 계약 {@code QuestionDetailResponse.question}. */
public record PublishedQuestionView(
String question,
String summary,
String context,
String importance,
String status,
String nextVerification,
QuestionPointGroupView points,
List<QuestionUpdateView> updates,
String resolutionType,
String resolutionSummary,
Instant resolvedAt,
Instant openedAt,
Instant updatedAt) {
public PublishedQuestionView {
updates = updates == null ? List.of() : List.copyOf(updates);
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code QuestionDetailResponse}. */
public record QuestionDetailView(
String canonicalPath,
boolean indexable,
PublishedQuestionView question,
QuestionRelationsView relations) {}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code QuestionListItem}. */
public record QuestionListItemView(
String question,
String path,
String status,
String summary,
String currentUnderstanding,
String nextVerification,
ProjectSummaryView primaryProject,
Instant updatedAt) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code QuestionPage}. */
public record QuestionPageView(List<QuestionListItemView> items, PageMetadataView page) {
public QuestionPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code QuestionPointGroup}. */
public record QuestionPointGroupView(
List<String> facts, List<String> assumptions, List<String> unknowns, List<String> constraints) {
public QuestionPointGroupView {
facts = facts == null ? List.of() : List.copyOf(facts);
assumptions = assumptions == null ? List.of() : List.copyOf(assumptions);
unknowns = unknowns == null ? List.of() : List.copyOf(unknowns);
constraints = constraints == null ? List.of() : List.copyOf(constraints);
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code QuestionDetailResponse.relations}. */
public record QuestionRelationsView(
RelatedEntryView primaryProject,
RelatedEntryView resultCase,
RelatedEntryView producedDecision,
List<RelatedEntryView> derivedReferences) {
public QuestionRelationsView {
derivedReferences = derivedReferences == null ? List.of() : List.copyOf(derivedReferences);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
/** 계약 {@code QuestionUpdatePublic}. 공개된 조사 기록 한 건이다. */
public record QuestionUpdateView(
String type, String title, String bodyMarkdown, Instant occurredAt) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code ReferenceDetailResponse}. */
public record ReferenceDetailView(
String canonicalPath,
boolean indexable,
PublishedDocumentView document,
ReferenceRelationsView relations) {}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code ReferenceDetailResponse.relations}. Case 의 관계와 이름·구성이 다르다. */
public record ReferenceRelationsView(
List<RelatedEntryView> supportingCases,
List<RelatedEntryView> relatedDecisions,
List<RelatedEntryView> relatedReferences) {
public ReferenceRelationsView {
supportingCases = supportingCases == null ? List.of() : List.copyOf(supportingCases);
relatedDecisions = relatedDecisions == null ? List.of() : List.copyOf(relatedDecisions);
relatedReferences = relatedReferences == null ? List.of() : List.copyOf(relatedReferences);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code RelatedEntry}. */
public record RelatedEntryView(String type, String title, String summary, String path) {}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.LocalDate;
import java.util.List;
/** 계약 {@code ReleaseDetailResponse}. */
public record ReleaseDetailView(
String version,
String title,
String summary,
LocalDate releasedOn,
List<String> changeTypes,
String reasonMarkdown,
String changesMarkdown,
String userImpactMarkdown,
String implementationImpactMarkdown,
String verificationMarkdown,
String knownLimitationsMarkdown,
List<RelatedEntryView> relatedRecords) {
public ReleaseDetailView {
changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes);
relatedRecords = relatedRecords == null ? List.of() : List.copyOf(relatedRecords);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.LocalDate;
import java.util.List;
/** 계약 {@code ReleaseListItem}. */
public record ReleaseListItemView(
String version,
String title,
String summary,
LocalDate releasedOn,
List<String> changeTypes,
String path) {
public ReleaseListItemView {
changeTypes = changeTypes == null ? List.of() : List.copyOf(changeTypes);
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.time.Instant;
import java.util.List;
/** 계약 {@code SearchResultItem}. */
public record SearchResultItemView(
String contentType,
String title,
String path,
String snippet,
List<String> matchedFields,
TopicSummaryView primaryTopic,
ProjectSummaryView primaryProject,
Instant publishedAt,
Instant updatedAt) {
public SearchResultItemView {
matchedFields = matchedFields == null ? List.of() : List.copyOf(matchedFields);
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code SearchResultPage}. */
public record SearchResultPageView(
String query, List<SearchResultItemView> items, PageMetadataView page) {
public SearchResultPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code SiteResponse}. */
public record SiteView(
String brandTitle,
String identityStatement,
String operatorDisplayName,
String operatorShortIdentity,
AssetReferenceView operatorAvatar,
String operatorProfilePath,
List<ContactLinkView> contacts) {
public SiteView {
contacts = contacts == null ? List.of() : List.copyOf(contacts);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code TagSummary}. */
public record TagSummaryView(String name, String slug) {}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.List;
/** 계약 {@code TopicDetailResponse}. */
public record TopicDetailView(
String name,
String slug,
String description,
String scope,
RelatedEntryView featuredReference,
List<RelatedEntryView> featuredCases,
List<RelatedEntryView> activeQuestions,
List<RelatedEntryView> relatedProjects,
List<LatestEntryView> latestRecords) {
public TopicDetailView {
featuredCases = featuredCases == null ? List.of() : List.copyOf(featuredCases);
activeQuestions = activeQuestions == null ? List.of() : List.copyOf(activeQuestions);
relatedProjects = relatedProjects == null ? List.of() : List.copyOf(relatedProjects);
latestRecords = latestRecords == null ? List.of() : List.copyOf(latestRecords);
}
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code TopicListItem}. */
public record TopicListItemView(String name, String slug, String description, int recordCount) {}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.publicsite.model;
/** 계약 {@code TopicSummary}. */
public record TopicSummaryView(String name, String slug) {}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import java.util.Optional;
/**
* 공개된 기록 상세.
*
* <p>본문은 {@code public_resource_projection.payload}(studio 렌더 모델) 아니라 원본 테이블에서 읽는다 공개 계약의 상세 모양은
* 렌더 모델과 다르다(본문이 블록 배열이 아니라 Markdown 원문이고, environmentSummary 배열이며, tags/coverAsset 유형별 관계가 따로
* 있다). projection "무엇이 공개됐는가" 게시 시각을 정하는 쓴다.
*/
public interface PublicDocumentQueryPort {
Optional<CaseDetailView> findCase(String slug);
Optional<ReferenceDetailView> findReference(String slug);
Optional<QuestionDetailView> findQuestion(String slug);
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
/** 탐색 목록. 공개된 것만 본다 — {@code public_resource_projection.publication_state = 'ACTIVE'}. */
public interface PublicExploreQueryPort {
KnowledgePageView knowledge(ExploreKnowledgeQuery query);
QuestionPageView questions(ExploreQuestionsQuery query);
}
@@ -0,0 +1,27 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import java.util.List;
import java.util.Optional;
/** 프로젝트 목록·상세와 그 하위 목록. */
public interface PublicProjectQueryPort {
List<ProjectListItemView> list();
Optional<ProjectDetailView> findBySlug(String slug);
/** 프로젝트가 없으면 {@link Optional#empty()} — 빈 페이지와 404 를 호출자가 구분해야 한다. */
Optional<ProjectDecisionPageView> decisions(ProjectDecisionPageQuery query);
Optional<ProjectRecordPageView> records(ProjectRecordPageQuery query);
Optional<ProjectActivityPageView> activities(ProjectPageQuery query);
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.techlog.publicsite.port.out;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import java.util.List;
import java.util.Optional;
/** 릴리스 목록·상세. 공개된 것({@code workflow_status = 'PUBLISHED'})만 본다. */
public interface PublicReleaseQueryPort {
List<ReleaseListItemView> list();
Optional<ReleaseDetailView> findByVersion(String version);
}

Some files were not shown because too many files have changed in this diff Show More