feat: Tech Log Studio 백엔드 — 남은 17개 operation 구현 (슬라이스 2~5)

studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD,
검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다.

Plan 01이 Plan 02로 미룬 생성기 union 차단 요인
- 계약 원본은 그대로 두고 prepareStudioCodegenSpec이 생성 직전에 사본을 파생시킨다.
  oneOf+discriminator를 가진 스키마의 하위 타입에 x-implements를 주입하고, union 자체는
  생성을 억제한 뒤 같은 package에 Jackson 다형성 인터페이스를 계약에서 파생해 써 넣는다
- 파생 규칙을 계약의 oneOf/discriminator.mapping에서 읽으므로 union 목록을 손으로
  관리하지 않는다. 계약에 union이 늘면 따라온다
- openApiNullable=false. JsonNullable을 읽는 모듈은 Jackson 2용인데 이 앱의 HTTP
  변환기는 Jackson 3(tools.jackson)다 — 등록될 수 없어 직렬화가 POJO로 새고
  역직렬화가 깨진다. 해당 필드는 계약상 required라 "없음"과 "null"을 구분할 필요도 없다
- 모든 분기가 type:string인 이름 없는 oneOf는 접는다. 안 접으면 필드 0개 껍데기
  클래스가 나와 slug가 {}로 직렬화된다
- oneOf:[X,null]도 접는다. 그대로 두면 같은 모양의 래퍼 타입이 7벌 더 생긴다
- StudioContractUnionJacksonTest가 이 배선을 지킨다. 파생이 깨지면 컴파일이 깨진다

설계 스키마의 구멍 — V8__techlog_studio_working_copy.sql
V7(설계 패키지 database/V1__init.sql)은 유형마다 다른 물리 모델인데 계약은 네 유형을
공통 base + 유형별 확장이라는 하나의 편집 흐름으로 다룬다. 계약이 요구하는데 없던 것:
- document.summary / case_detail.environment,reproduction / reference_detail.rules,examples
- open_question.options,resolution_evidence_target_id,resolution_link_label
- project_decision.title,slug,summary,primary_topic_id
- problem/conclusion/scope_summary/statement가 varchar라 계약의 100000자를 담을 수 없어 text로 넓힘
- project_decision.project_id NOT NULL은 계약이 명시적으로 허용한 초안 저장을
  구조적으로 막고 있었다(게시 필수 여부는 검증이 판단한다) — 풀었다
- studio_relation: 계약의 relations[]는 네 유형 공통이고 항목마다 자체 id와 reason이
  있다. document_relation은 복합 PK라 둘 다 없고 문서끼리만 성립한다.
  (source_kind, source_id) 다형 참조는 studio_validation/studio_preview가 이미 쓰는 방식이다

영속은 JdbcClient
spec §8.3은 쓰기에 JPA @Version을 적었지만 이 네 aggregate는 Studio 저장 경로에서만
쓰이고 UPDATE ... WHERE version = :expectedVersion의 갱신 행 수가 정확히 같은 의미를
준다. 여덟 개 넘는 테이블에 엔티티를 세우는 비용에 상응하는 이득이 없다. 포트 계약이
같으므로 나중에 JPA가 필요하면 어댑터만 바뀐다.

nextAction/dependencyRevision 계산은 SQL 한 벌(StudioDocumentSql)
목록과 상세가 각자 계산하면 "목록에선 게시하라더니 열어보니 검증하라"가 된다.
계약의 nextAction 필터도 SQL이라야 페이지네이션을 깨지 않고 걸 수 있다.

렌더러 (ADR-005)
- commonmark + GFM 확장. 설계 05장 §16대로 라이브러리는 render 패키지 밖으로 안 나간다.
  프론트가 remark 계열로 같은 CommonMark+GFM 기준을 쓰므로 동등성이 유지된다
- ::: directive는 줄 단위 스캔이다. v1 문법에서 중첩이 없고 줄 맨 앞에서만 열린다.
  코드 펜스 안의 :::는 directive로 보지 않는다
- 컨테이너/leaf 판정은 닫는 줄이 실제로 있는지로 한다. 이름 목록으로 정하면 directive를
  더할 때마다 목록을 고쳐야 하고, "닫는 줄 없으면 문서 끝까지"면 닫기를 빠뜨린
  directive 하나가 뒤 내용을 통째로 삼킨다
- 계약이 표현 못 하는 것은 조용히 바꾸지 않고 경고로 남긴다 — 수평선, 머리글 없는 표,
  알 수 없는 directive, 미해결 asset key(경로를 지어내지 않고 버린다)
- RenderModelPort 구현이 inbound web에 있다. 렌더 모델은 계약 DTO이고 그 타입을 소유한
  모듈이 거기다. application에 같은 모양을 한 벌 더 두면 두 정의가 갈라진다

검증 체인
판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 PublicRenderModel을 만들 수 있는가.
각 규칙은 렌더 모델의 required/minLength/minItems에서 나온다. 다른 기준을 쓰면 검증을
통과한 문서가 렌더 단계에서 계약을 위반한다. 첫 오류에서 멈추지 않고 끝까지 모은다.

게시 (spec §7.5 20단계)
- Snapshot의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의
  것을 그대로 쓴다. 다시 렌더링하면 승인한 화면과 공개된 화면이 달라질 수 있다
- 단계별 실패가 서로 다른 계약 코드로 나간다. DOCUMENT_VALIDATION_FAILED(지금 검증하면
  실패)와 VALIDATION_STALE(통과했으나 전제가 바뀜)은 다른 사건이고 할 일도 다르다
- 게시 취소는 route도 Snapshot도 지우지 않는다. 지우면 공개된 링크가 끊긴다

Asset
- 확장자와 클라이언트 Content-Type을 신뢰하지 않고 파일 시작 바이트로 판정한다.
  모르는 형식은 저장하지 않고 415로 거절한다
- 바이너리는 기존 object storage 어댑터에 위임한다(spec §9). ObjectStoragePort는
  deprecated지만 이 저장소에서 실제 구현이 붙어 있는 유일한 포트다 — 선택을 브리지
  한 클래스에 가뒀다. 저장 백엔드가 없는 배포는 업로드·삭제만 503이고 나머지는 동작한다
- 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다

검증 — "통과하는데 동작 안 함"을 세 겹으로 막았다
- StudioContractDriftTest에 반대 방향(계약 → published)을 추가했다. 기존 한 방향은
  사라진 operation을 못 잡는다. 양방향 모두 실제로 RED가 되는 것을 확인했다
- postgresqlTechLogStudioPersistenceIntegrationTest 신규 11개. 이 저장소의 check는
  Testcontainers를 돌리지 않아 이 테스트가 없으면 SQL이 한 번도 실행되지 않는다.
  첫 실행에서 실제 결함을 잡았다: fk_publication_latest_event의 지연 검사는 트랜잭션
  끝에 일어나므로 autocommit이면 첫 INSERT에서 위반된다 → 어댑터가 진입 시 활성
  트랜잭션을 확인하고 아니면 원인을 그대로 말하며 실패한다
- 실제 앱 부팅으로 두 건을 더 잡았다. check에 전체 앱 부팅 테스트가 없어 생긴 구멍이다
  1. 생성자 모호성 — 프로덕션/테스트 두 생성자에 표시가 없어 기본 생성자를 찾다 실패
  2. final 클래스 + AOP — @RequiresPermission은 CGLIB 프록시를 쓰는데 final은 subclass
     불가. 템플릿의 NotificationDispatchUseCase가 final이면서 무사한 것은 그 능력이 꺼진
     배포에서 빈으로 등록되지 않아서다. Studio use case는 항상 등록된다

가드레일이 잡은 것
- MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION → studio:write 부여, role 매핑은
  프로파일에. application.yml의 role-permissions:{} 기준선은 SampleRemovalSmokeContractTest가 지킨다
- NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE 223건 → 어댑터 패키지를 persistence.techlog.studio.*로
  옮겼다. 규칙을 고치지 않았고, 그 이름이 우회가 아니라 더 정확하다
- verifyEnvKeys → 새 APP_ 키 4건 등록

범위
studio-v1의 19개 전부. public-v1(18) / studio-management-v1(79)은 spec §2.2가 선언한
out of scope다 — 전자는 소비자가 아직 없고 후자는 secondary capability 보존 계약이다.

검증: ./gradlew check BUILD SUCCESSFUL (245 task), 전체 3,721 테스트 실패 0,
techlog PostgreSQL 통합 테스트 3종 통과, 실제 앱 부팅 확인.

AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "전부 커밋하고 머지
진행하세요"로 명시적으로 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 23:21:43 +09:00
co-authored by Claude Opus 5
parent e615c24152
commit 48ff648112
163 changed files with 11807 additions and 10 deletions
+63
View File
@@ -4249,3 +4249,66 @@ env_keys:
validation: positive_int_bounded
compatibility_impact: behavior-change
required_test: async-contract:executor-queue-bounded
# === Tech Log Studio (feature-techlog-studio-backend) ===
- name: APP_STUDIO_CURSOR_SIGNING_KEY
# source: studio-v1.yaml components.parameters.Cursor — "Opaque cursor bound to
# normalized filters and sort". 서명 키가 인스턴스마다 다르면 한 인스턴스가 발급한
# 커서를 다른 인스턴스가 거부한다. 비어 있으면 StudioSettings가 경고하고 개발용 값으로
# 대체한다(커서에 권한이 실리지 않으므로 부팅은 막지 않는다).
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: min_length_16_bytes
compatibility_impact: behavior-change
required_test: techlog-studio-contract:cursor-round-trip
- name: APP_STUDIO_VALIDATION_TTL
# source: 백엔드 설계 §7.3 — "now() < validUntil" 이 Validation 유효 조건의 하나다.
# studio_validation.valid_until 을 채우는 값.
type: duration
default: 1h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: techlog-studio-contract:validation-window
- name: APP_STUDIO_PREVIEW_TTL
# source: 백엔드 설계 §7.3 — Preview 가 EXPIRED 로 넘어가는 기준.
# studio_preview.expires_at 을 채우는 값.
type: duration
default: 24h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: techlog-studio-contract:preview-expiry
- name: APP_STUDIO_AUTHOR_ROLE
# source: 백엔드 설계 §9 — 권한은 기존 RolePermissionPolicy 를 재사용한다.
# ca-skeleton.authz.role-permissions 의 키로 쓰이는 IdP RAW role 이름. 배포마다 다르다.
# application.yml 이 아니라 프로파일(application-{local,dev,prod}.yml)에 있다 —
# SampleRemovalSmokeContractTest 가 application.yml 의 `role-permissions: {}` 기준선을
# 그대로 유지하도록 요구하기 때문이다.
type: string
default: studio-author
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: non_blank
compatibility_impact: behavior-change
required_test: techlog-studio-contract:author-permission
+244 -3
View File
@@ -22,6 +22,11 @@ plugins { id 'org.openapi.generator' }
sourceSets {
generatedOpenapi {
java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java'))
// 계약의 discriminator union을 Java interface로 파생한 소스(prepareStudioCodegenSpec).
// 생성 DTO와 같은 sourceSet이어야 한다 — 생성된 하위 타입이 `implements <Union>` 하므로
// 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다.
// main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java'))
}
// main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
@@ -64,6 +69,15 @@ dependencies {
// never a hand-maintained stale schema). The release-blocking drift gate is
// owned by feature-contract-verification-test-suite (planned).
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
// Studio Preview / Public Snapshot 의 CaseRenderBlock 을 만드는 Markdown 파서.
// 설계 05장 §16 이 "특정 라이브러리를 도메인 계약으로 만들지 않는다"고 정하므로 이 의존은
// techlog/studio/render 패키지 안에서만 쓰고 바깥에는 계약 DTO 만 내보낸다.
// 프론트가 remark 계열로 같은 문법을 다루므로(ADR-005 동등성) CommonMark + GFM 확장이라는
// 같은 기준을 쓴다 — 자체 파서를 쓰면 두 화면의 해석이 갈라진다.
implementation 'org.commonmark:commonmark:0.21.0'
implementation 'org.commonmark:commonmark-ext-gfm-tables:0.21.0'
implementation 'org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0'
implementation 'org.commonmark:commonmark-ext-autolink:0.21.0'
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
@@ -121,6 +135,225 @@ tasks.named('check') {
dependsOn tasks.named('webSecurityBoundaryTest')
}
// ---------------------------------------------------------------------------
// 계약 union -> 생성 코드 배선 (슬라이스 2 선행 / spec §5.2 보강).
//
// 문제: useOneOfInterfaces=false 는 컴파일은 통과시키지만 discriminator union이 Jackson
// 양방향 모두 계약을 위반한다(역직렬화 InvalidTypeIdException, 직렬화는 kind 대신 클래스
// simple name). useOneOfInterfaces=true 는 SpringCodegen이 union interface의 discriminator
// getter를 String으로 고정해 컴파일이 깨진다 — 계약 쪽으로 우회 불가(Plan 01 task-4-report).
//
// 해법: 계약 원본은 그대로 두고, 생성 직전에 사본을 파생시킨다.
// (1) oneOf + discriminator 를 가진 스키마 S 의 각 하위 타입에 `x-implements: [S]` 를 주입한다
// -> 생성된 하위 타입이 `implements S` 로 나온다(7.18.0에서 실측 확인).
// (2) S 자체의 생성은 ignore 시키고, 같은 package 에 Jackson 다형성 애너테이션을 단
// Java interface 로 이 태스크가 직접 써 넣는다.
// (3) 모든 분기가 `type: string` 인 이름 없는 oneOf 는 `type: string` 으로 접는다.
// 접지 않으면 생성기가 필드가 하나도 없는 껍데기 클래스를 만든다
// (WorkingCopyInputBase.slug -> WorkingCopyInputBaseSlug: 실측 확인). 그 결과
// slug 가 문자열이 아니라 `{}` 로 직렬화되어 계약을 위반한다.
//
// 파생 규칙은 계약의 `oneOf`/`discriminator.mapping` 에서 전부 읽어낸다 — union 목록을
// 손으로 관리하지 않으므로 계약에 union이 추가돼도 따라온다.
//
// snakeyaml 은 org.openapi.generator 플러그인이 buildscript classpath 로 이미 가져온다
// (swagger-parser 경유, 2.4 — 실측 확인).
// ---------------------------------------------------------------------------
ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml')
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
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 {
def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8'))
def schemas = doc.components.schemas
// (3) 전부 string 인 이름 없는 oneOf 접기
int[] collapsed = [0]
def collapseStringOneOf
collapseStringOneOf = { Object node ->
if (node instanceof Map) {
for (Object key : new ArrayList(node.keySet())) {
def value = node.get(key)
if (value instanceof Map && value.get('oneOf') instanceof List
&& !value.containsKey('discriminator')) {
def branches = value.get('oneOf')
if (!branches.isEmpty()
&& branches.every { it instanceof Map && it.get('type') == 'string' }) {
def folded = new LinkedHashMap()
folded.put('type', 'string')
if (value.containsKey('description')) {
folded.put('description', value.get('description'))
}
node.put(key, folded)
collapsed[0]++
continue
}
}
collapseStringOneOf(value)
}
} else if (node instanceof List) {
node.each { collapseStringOneOf(it) }
}
}
collapseStringOneOf(doc)
// (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면
// 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가
// DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌
// 생겨 매핑 코드가 그 사이를 오가게 된다. Java 참조는 어차피 nullable 이라 손실이 없으므로
// null 분기를 지우고 남은 하나로 접는다.
int[] nullable = [0]
def collapseNullableOneOf
collapseNullableOneOf = { Object node ->
if (node instanceof Map) {
for (Object key : new ArrayList(node.keySet())) {
def value = node.get(key)
if (value instanceof Map && value.get('oneOf') instanceof List
&& !value.containsKey('discriminator')) {
def branches = value.get('oneOf')
def nulls = branches.findAll { it instanceof Map && it.get('type') == 'null' }
def rest = branches - nulls
if (!nulls.isEmpty() && rest.size() == 1 && rest[0] instanceof Map) {
def folded = new LinkedHashMap(rest[0])
if (value.containsKey('description') && !folded.containsKey('description')) {
folded.put('description', value.get('description'))
}
node.put(key, folded)
nullable[0]++
continue
}
}
collapseNullableOneOf(value)
}
} else if (node instanceof List) {
node.each { collapseNullableOneOf(it) }
}
}
collapseNullableOneOf(doc)
// (1) x-implements 주입 + union 목록 수집
def unions = [:]
schemas.each { String name, Object schema ->
if (!(schema instanceof Map)) return
def disc = schema.get('discriminator')
if (!(schema.get('oneOf') instanceof List) || !(disc instanceof Map)) return
def property = disc.get('propertyName')
def mapping = disc.get('mapping')
if (!property || !(mapping instanceof Map) || mapping.isEmpty()) {
throw new GradleException(
"union ${name} 에 discriminator.propertyName 과 mapping 이 모두 있어야 한다 " +
"— 없으면 Jackson @JsonSubTypes 의 type id 를 계약에서 유도할 수 없다.")
}
def variants = new LinkedHashMap()
mapping.each { String typeId, String ref ->
def variant = ref.tokenize('/').last()
if (!schemas.containsKey(variant)) {
throw new GradleException("union ${name} 의 mapping 이 없는 스키마 ${variant} 를 가리킨다.")
}
variants.put(typeId, variant)
}
schema.get('oneOf').each { branch ->
if (branch instanceof Map && branch.get('$ref')) {
def variant = branch.get('$ref').tokenize('/').last()
if (!variants.containsValue(variant)) {
throw new GradleException(
"union ${name} 의 oneOf 분기 ${variant} 가 discriminator.mapping 에 없다 " +
"— type id 를 알 수 없어 Jackson 배선을 파생시킬 수 없다.")
}
}
}
variants.values().toSet().each { String variant ->
def target = schemas.get(variant)
def impls = target.get('x-implements')
if (impls == null) {
target.put('x-implements', [name])
} else if (!impls.contains(name)) {
target.put('x-implements', impls + [name])
}
}
unions.put(name, [property: property, variants: variants])
}
if (unions.isEmpty()) {
throw new GradleException('계약에서 discriminator union 을 하나도 찾지 못했다 — 파생 규칙이 깨졌다.')
}
// 파생 계약 쓰기
def dumperOptions = new org.yaml.snakeyaml.DumperOptions()
dumperOptions.defaultFlowStyle = org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK
dumperOptions.width = 8192
def specFile = specOut.get().asFile
specFile.parentFile.mkdirs()
specFile.setText(new org.yaml.snakeyaml.Yaml(dumperOptions).dump(doc), 'UTF-8')
// union 클래스 생성 억제
def ignoreFile = ignoreOut.get().asFile
ignoreFile.setText(
(['# prepareStudioCodegenSpec 가 생성한다 — 손으로 고치지 않는다.',
'# 이 파일들은 같은 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)
packageDir.mkdirs()
unions.each { String name, Object spec ->
def subtypes = spec.variants.collect { String typeId, String variant ->
" @JsonSubTypes.Type(value = ${variant}.class, name = \"${typeId}\")"
}.join(',\n')
def source = """package ${modelPackage};
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* {@code ${name}} — 계약의 discriminator union. prepareStudioCodegenSpec 가 계약의
* {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다.
*
* <p>{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로
* 이미 직렬화하므로 Jackson 이 판별 필드를 한 번 더 쓰면 키가 중복된다.
*/
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "${spec.property}",
visible = true)
@JsonSubTypes({
${subtypes}
})
public interface ${name} {}
"""
new File(packageDir, "${name}.java").setText(source, 'UTF-8')
}
logger.lifecycle(
"prepareStudioCodegenSpec: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), " +
"string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음")
}
}
// openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라
// dependsOn 을 받지 못한다. 태스크 쪽에 건다.
tasks.named('openApiGenerate') {
dependsOn tasks.named('prepareStudioCodegenSpec')
// openapi-generator 는 outputDir 를 비우지 않는다 — 계약에서 스키마가 사라져도 직전
// 실행의 .java 가 그대로 남아 컴파일에 성공하고, 그래서 드리프트가 아니라 정상으로 보인다
// (이 배선을 넣는 과정에서 실제로 겪음: 삭제됐어야 할 union 6개가 남아 있었다).
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi')) }
}
// ---------------------------------------------------------------------------
// Studio 계약 DTO 생성 (ADR-004 / ADR-006).
//
@@ -155,16 +388,24 @@ tasks.named('check') {
// ---------------------------------------------------------------------------
openApiGenerate {
generatorName = 'spring'
inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString()
// 원본이 아니라 prepareStudioCodegenSpec 가 파생한 사본을 먹인다 — 이유는 그 태스크의 주석 참고.
inputSpec = studioCodegenSpecFile.get().asFile.path
ignoreFileOverride = studioCodegenIgnoreFile.get().asFile.path
outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path
modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
modelPackage = studioModelPackage
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
openApiNullable: 'true',
// false 다. openApiNullable=true 는 nullable 필드를 JsonNullable<T> 로 만드는데,
// 그 타입을 읽는 모듈(org.openapitools:jackson-databind-nullable)은 Jackson 2 용이고
// 이 앱의 HTTP 변환기는 Jackson 3(tools.jackson, Spring Boot 4 기본)다 — 모듈이
// 등록될 수 없어 JsonNullable 이 그냥 POJO 로 직렬화되고 역직렬화는 깨진다.
// 계약이 이 필드들을 required 로 두므로(예: questionStatus, decisionStatus)
// "없음"과 "null" 을 구분할 필요도 없다. 평범한 nullable 필드로 생성한다.
openApiNullable: 'false',
useOneOfInterfaces: 'false',
]
}
+5
View File
@@ -91,6 +91,10 @@ org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.commonmark:commonmark-ext-autolink:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-tables:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -108,6 +112,7 @@ org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.nibor.autolink:autolink:0.10.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
@@ -0,0 +1,220 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UpdateAssetCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.DeleteAssetCommand;
import dev.caskeleton.application.techlog.studio.command.UploadAssetCommand;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetAssetQuery;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase;
import dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.io.IOException;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 계약 {@code studio-v1.yaml}의 Assets 다섯 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioAssetController {
private final ListStudioAssetsUseCase listAssets;
private final UploadStudioAssetUseCase uploadAsset;
private final GetStudioAssetUseCase getAsset;
private final UpdateStudioAssetUseCase updateAsset;
private final DeleteStudioAssetUseCase deleteAsset;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioAssetController(
ListStudioAssetsUseCase listAssets,
UploadStudioAssetUseCase uploadAsset,
GetStudioAssetUseCase getAsset,
UpdateStudioAssetUseCase updateAsset,
DeleteStudioAssetUseCase deleteAsset,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.listAssets = listAssets;
this.uploadAsset = uploadAsset;
this.getAsset = getAsset;
this.updateAsset = updateAsset;
this.deleteAsset = deleteAsset;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/assets")
public AssetPage listStudioAssets(
@RequestParam(value = "q", required = false) String query,
@RequestParam(value = "kind", required = false) AssetKind kind,
@RequestParam(value = "managementStatus", required = false) AssetManagementStatus status,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
String fingerprint =
StudioCursors.fingerprint(
query,
kind == null ? null : kind.getValue(),
status == null ? null : status.getValue());
DocumentCursorPosition position =
cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false);
var page =
listAssets.handle(
new ListAssetsQuery(
query,
kind == null ? null : AssetKindView.valueOf(kind.getValue()),
status == null ? null : AssetManagementStatusView.valueOf(status.getValue()),
position == null ? null : position.updatedAt(),
position == null ? null : position.id(),
limit));
AssetPage body = detailMapper.toApi(page);
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping(value = "/v1/studio/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Asset> uploadStudioAsset(
@RequestParam("file") MultipartFile file,
@RequestParam("kind") AssetKind kind,
@RequestParam(value = "altText", required = false) String altText,
@RequestParam(value = "decorative", defaultValue = "false") boolean decorative,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
byte[] content;
try {
content = file.getBytes();
} catch (IOException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "the uploaded file could not be read");
}
StudioIdempotency.Outcome<Asset> outcome =
idempotency.run(
request,
"uploadStudioAsset",
// 바이트 전체가 아니라 파일명·크기·종류로 지문을 만든다 — 20MB 를 다시 직렬화해 해시하면
// 업로드마다 그만큼을 한 번 더 읽고 쓰는 셈이 된다.
java.util.List.of(
String.valueOf(file.getOriginalFilename()),
String.valueOf(file.getSize()),
kind.getValue()),
Asset.class,
() ->
detailMapper.toApi(
uploadAsset.handle(
new UploadAssetCommand(
file.getOriginalFilename(),
file.getContentType(),
content,
AssetKindView.valueOf(kind.getValue()),
altText,
decorative,
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/assets/{assetId}")
public AssetDetail getStudioAsset(@PathVariable("assetId") UUID assetId) {
return detailMapper.toApi(getAsset.handle(new GetAssetQuery(assetId)));
}
@PutMapping("/v1/studio/assets/{assetId}")
public ResponseEntity<Asset> updateStudioAsset(
@PathVariable("assetId") UUID assetId,
@Valid @RequestBody UpdateAssetCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<Asset> outcome =
idempotency.run(
request,
"updateStudioAsset",
command,
Asset.class,
() ->
detailMapper.toApi(
updateAsset.handle(
new dev.caskeleton.application.techlog.studio.command.UpdateAssetCommand(
assetId,
command.getExpectedVersion() == null
? 0L
: command.getExpectedVersion(),
command.getKind() == null
? null
: AssetKindView.valueOf(command.getKind().getValue()),
command.getAltText(),
command.getAltText() != null,
command.getDecorative(),
command.getManagementStatus() == null
? null
: AssetManagementStatusView.valueOf(
command.getManagementStatus().getValue()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@DeleteMapping("/v1/studio/assets/{assetId}")
public ResponseEntity<Void> deleteStudioAsset(
@PathVariable("assetId") UUID assetId,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
idempotency.run(
request,
"deleteStudioAsset",
assetId.toString(),
Void.class,
() -> {
deleteAsset.handle(new DeleteAssetCommand(assetId, StudioPrincipals.require(principal)));
return null;
});
// 계약은 204 다. 본문이 없으므로 EnvelopeBodyAdvice 도 감쌀 것이 없다.
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,226 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.SaveDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioRequestMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.CreateDocumentCommand;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetDocumentQuery;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase;
import dev.caskeleton.application.techlog.studio.service.SaveOutcome;
import dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Documents 네 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 springdoc이 게시한 이름과 계약을 대조한다.
* 이름을 바꾸면 그 게이트가 빨간불이 된다.
*
* <p>반환값을 봉투로 감싸지 않는다 — {@code EnvelopeBodyAdvice}가 감싼다(ADR-006).
*
* <p>경로에 {@code /api}를 쓰지 않는다 — {@code PresentationWebConfig}가 {@code
* ca-skeleton.presentation.api-base-path}를 모든 매핑에 붙인다.
*/
@RestController
public class StudioDocumentController {
private final ListStudioDocumentsUseCase listDocuments;
private final CreateStudioDocumentUseCase createDocument;
private final GetStudioDocumentUseCase getDocument;
private final SaveStudioDocumentUseCase saveDocument;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioDocumentController(
ListStudioDocumentsUseCase listDocuments,
CreateStudioDocumentUseCase createDocument,
GetStudioDocumentUseCase getDocument,
SaveStudioDocumentUseCase saveDocument,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.listDocuments = listDocuments;
this.createDocument = createDocument;
this.getDocument = getDocument;
this.saveDocument = saveDocument;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/documents")
public DocumentPage listStudioDocuments(
@RequestParam(value = "q", required = false) String query,
@RequestParam(value = "kind", required = false) RecordKind kind,
@RequestParam(value = "publicationStatus", required = false)
PublicationStatus publicationStatus,
@RequestParam(value = "nextAction", required = false) NextAction nextAction,
@RequestParam(value = "projectId", required = false) UUID projectId,
@RequestParam(value = "sort", defaultValue = "UPDATED_DESC") String sort,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
DocumentSort documentSort = documentSort(sort);
// 지문에 정렬까지 넣는다 — 정렬만 바꾸고 커서를 재사용하면 커서가 가리키는 키의 의미가 달라진다.
String fingerprint =
StudioCursors.fingerprint(
query,
kind == null ? null : kind.getValue(),
publicationStatus == null ? null : publicationStatus.getValue(),
nextAction == null ? null : nextAction.getValue(),
projectId == null ? null : projectId.toString(),
documentSort.name());
DocumentCursorPosition position =
cursor == null || cursor.isBlank()
? null
: cursors.decode(cursor, fingerprint, documentSort == DocumentSort.TITLE_ASC);
DocumentPageView page =
listDocuments.handle(
new ListDocumentsQuery(
query,
kind == null
? null
: dev.caskeleton.application.techlog.studio.model.RecordKind.valueOf(
kind.getValue()),
publicationStatus == null
? null
: PublicationStatusView.valueOf(publicationStatus.getValue()),
nextAction == null
? null
: dev.caskeleton.application.techlog.studio.model.NextAction.valueOf(
nextAction.getValue()),
projectId,
documentSort,
position,
limit));
DocumentPage body = StudioResponseMapper.toApi(page);
// 어댑터가 준 것은 다음 쪽의 시작 위치일 뿐이다. 클라이언트에 나가는 것은 서명된 opaque 값이어야 한다.
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping("/v1/studio/documents")
public ResponseEntity<WorkingCopy> createStudioDocument(
@Valid @RequestBody WorkingCopyInput document,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<WorkingCopy> outcome =
idempotency.run(
request,
"createStudioDocument",
document,
WorkingCopy.class,
() -> {
WorkingCopyView created =
createDocument.handle(
new CreateDocumentCommand(
StudioRequestMapper.toApplication(document),
StudioPrincipals.require(principal)));
return StudioResponseMapper.toApi(created);
});
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/documents/{documentId}")
public WorkingCopyDetail getStudioDocument(@PathVariable("documentId") UUID documentId) {
return detailMapper.toApi(getDocument.handle(new GetDocumentQuery(documentId)));
}
@PutMapping("/v1/studio/documents/{documentId}")
public ResponseEntity<WorkingCopyDetail> saveStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody SaveDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<WorkingCopyDetail> outcome =
idempotency.run(
request,
"saveStudioDocument",
command,
WorkingCopyDetail.class,
() -> {
SaveOutcome saved =
saveDocument.handle(
new dev.caskeleton.application.techlog.studio.command.SaveDocumentCommand(
documentId,
command.getExpectedVersion() == null ? 0 : command.getExpectedVersion(),
StudioRequestMapper.toApplication(command.getDocument()),
StudioPrincipals.require(principal)));
return switch (saved) {
case SaveOutcome.Saved value -> detailMapper.toApi(value.detail());
case SaveOutcome.VersionConflict value -> throw versionConflict(value.latest());
};
});
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
/**
* 계약의 {@code VersionConflictDetails}는 {@code latestDocument}로 현재 상태 전체를 함께 준다 — 클라이언트가 다시 조회하지
* 않고도 충돌 화면을 그릴 수 있어야 한다.
*/
private StudioException versionConflict(WorkingCopyDetailView latest) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("latestDocument", detailMapper.toApi(latest));
return StudioException.withDetails(
StudioError.VERSION_CONFLICT, "expectedVersion does not match the stored version", details);
}
private static DocumentSort documentSort(String sort) {
try {
return DocumentSort.valueOf(sort);
} catch (IllegalArgumentException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"sort must be one of UPDATED_DESC, UPDATED_ASC, TITLE_ASC");
}
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CreatePreviewCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidateDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.studio.query.GetPreviewQuery;
import dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Validation / Preview 세 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioPreviewController {
private final ValidateStudioDocumentUseCase validateDocument;
private final CreateStudioPreviewUseCase createPreview;
private final GetCurrentStudioPreviewUseCase getPreview;
private final StudioDetailMapper detailMapper;
private final StudioIdempotency idempotency;
public StudioPreviewController(
ValidateStudioDocumentUseCase validateDocument,
CreateStudioPreviewUseCase createPreview,
GetCurrentStudioPreviewUseCase getPreview,
StudioDetailMapper detailMapper,
StudioIdempotency idempotency) {
this.validateDocument = validateDocument;
this.createPreview = createPreview;
this.getPreview = getPreview;
this.detailMapper = detailMapper;
this.idempotency = idempotency;
}
@PostMapping("/v1/studio/documents/{documentId}/validate")
public ResponseEntity<ValidationReport> validateStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody ValidateDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<ValidationReport> outcome =
idempotency.run(
request,
"validateStudioDocument",
command,
ValidationReport.class,
() ->
detailMapper.toApi(
validateDocument.handle(
new dev.caskeleton.application.techlog.studio.command
.ValidateDocumentCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/documents/{documentId}/preview")
public PreviewDetail getCurrentStudioPreview(@PathVariable("documentId") UUID documentId) {
return detailMapper.toApi(getPreview.handle(new GetPreviewQuery(documentId)));
}
@PostMapping("/v1/studio/documents/{documentId}/preview")
public ResponseEntity<PublicPreview> createStudioPreview(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody CreatePreviewCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<PublicPreview> outcome =
idempotency.run(
request,
"createStudioPreview",
command,
PublicPreview.class,
() ->
detailMapper.toApi(
createPreview.handle(
new dev.caskeleton.application.techlog.studio.command.CreatePreviewCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
command.getValidationId(),
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
/**
* 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다 — 0 은 어떤 저장된 버전과도 일치하지 않아
* VERSION_CONFLICT 로 나간다.
*/
private static long expectedVersion(Integer value) {
return value == null ? 0L : value;
}
}
@@ -0,0 +1,177 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnpublishCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.studio.command.UnpublishPublicationCommand;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetDashboardQuery;
import dev.caskeleton.application.techlog.studio.query.GetSnapshotQuery;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase;
import dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Publication / Dashboard 다섯 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioPublicationController {
private final PublishStudioDocumentUseCase publishDocument;
private final UnpublishStudioPublicationUseCase unpublishPublication;
private final ListStudioPublicationsUseCase listPublications;
private final GetStudioPublicationSnapshotUseCase getSnapshot;
private final GetStudioDashboardUseCase getDashboard;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioPublicationController(
PublishStudioDocumentUseCase publishDocument,
UnpublishStudioPublicationUseCase unpublishPublication,
ListStudioPublicationsUseCase listPublications,
GetStudioPublicationSnapshotUseCase getSnapshot,
GetStudioDashboardUseCase getDashboard,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.publishDocument = publishDocument;
this.unpublishPublication = unpublishPublication;
this.listPublications = listPublications;
this.getSnapshot = getSnapshot;
this.getDashboard = getDashboard;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/dashboard")
public StudioDashboard getStudioDashboard() {
return detailMapper.toApi(getDashboard.handle(new GetDashboardQuery()));
}
@PostMapping("/v1/studio/documents/{documentId}/publish")
public ResponseEntity<PublishResult> publishStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody PublishDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
// 게시 Event 에 최초 요청의 key 를 남긴다 — 재시도가 중복 Event 를 만들지 않았음을 이력에서
// 되짚을 수 있어야 한다(V7 publication_event.idempotency_key 주석).
String idempotencyKey = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY);
StudioIdempotency.Outcome<PublishResult> outcome =
idempotency.run(
request,
"publishStudioDocument",
command,
PublishResult.class,
() ->
detailMapper.toApi(
publishDocument.handle(
new dev.caskeleton.application.techlog.studio.command
.PublishDocumentCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
command.getValidationId(),
command.getPreviewId(),
List.copyOf(command.getAcknowledgedWarningCodes()),
idempotencyKey,
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/publications")
public PublicationPage listStudioPublications(
@RequestParam(value = "type", required = false) PublicationEventType type,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
String fingerprint = StudioCursors.fingerprint(type == null ? null : type.getValue());
DocumentCursorPosition position =
cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false);
var page =
listPublications.handle(
new ListPublicationsQuery(
type == null ? null : PublicationEventTypeView.valueOf(type.getValue()),
position == null ? null : position.updatedAt(),
position == null ? null : position.id(),
limit));
PublicationPage body = detailMapper.toApi(page);
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping("/v1/studio/publications/{publicationId}/unpublish")
public ResponseEntity<PublishResult> unpublishStudioPublication(
@PathVariable("publicationId") UUID publicationId,
@Valid @RequestBody UnpublishCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<PublishResult> outcome =
idempotency.run(
request,
"unpublishStudioPublication",
command,
PublishResult.class,
() ->
detailMapper.toApi(
unpublishPublication.handle(
new UnpublishPublicationCommand(
publicationId,
expectedVersion(command.getExpectedPublicationRevision()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/publications/{publicationEventId}/preview")
public PublicationSnapshot getStudioPublicationSnapshot(
@PathVariable("publicationEventId") UUID publicationEventId) {
return detailMapper.toApi(getSnapshot.handle(new GetSnapshotQuery(publicationEventId)));
}
/** 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다. */
private static long expectedVersion(Integer value) {
return value == null ? 0L : value;
}
}
@@ -0,0 +1,262 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetUsage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DashboardTotals;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAggregate;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEvent;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationListItem;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationIssue;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.DashboardView;
import dev.caskeleton.application.techlog.studio.model.PreviewDetailView;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
import dev.caskeleton.shared.error.MappingException;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* {@code WorkingCopyDetail} 조립. {@code getStudioDocument}, {@code saveStudioDocument}, 그리고 낙관적 잠금
* 충돌의 {@code VersionConflictDetails.latestDocument}가 모두 이 결과를 쓴다.
*
* <p>Spring 컴포넌트인 이유는 하나뿐이다 — 미리보기의 {@code renderModel}이 DB에 문자열로 저장되어 있어 계약 DTO로 되살리려면 매퍼가 필요하다.
*/
@Component
public class StudioDetailMapper {
private final ObjectMapper objectMapper;
public StudioDetailMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public WorkingCopyDetail toApi(WorkingCopyDetailView view) {
WorkingCopyDetail detail = new WorkingCopyDetail();
detail.setDocument(StudioResponseMapper.toApi(view.document()));
detail.setCurrentValidation(toApi(view.currentValidation()));
detail.setLatestPreview(toApi(view.latestPreview()));
detail.setCurrentPublication(toApi(view.currentPublication()));
detail.setDependencyRevision(view.dependencyRevision());
detail.setNextAction(NextAction.fromValue(view.nextAction().name()));
return detail;
}
public ValidationReport toApi(ValidationReportView view) {
if (view == null) {
return null;
}
ValidationReport report = new ValidationReport();
report.setValidationId(view.validationId());
report.setDocumentId(view.documentId());
report.setValidatedVersion(Math.toIntExact(view.validatedVersion()));
report.setStatus(ValidationReport.StatusEnum.fromValue(view.status().name()));
report.setIssues(
view.issues().stream()
.map(
issue -> {
ValidationIssue dto = new ValidationIssue();
dto.setCode(issue.code());
dto.setSeverity(ValidationIssue.SeverityEnum.fromValue(issue.severity().name()));
dto.setPath(issue.path());
dto.setMessage(issue.message());
return dto;
})
.toList());
report.setValidatedAt(StudioResponseMapper.offsetDateTime(view.validatedAt()));
report.setValidUntil(StudioResponseMapper.offsetDateTime(view.validUntil()));
report.setDependencyRevision(view.dependencyRevision());
return report;
}
public PublicPreview toApi(PublicPreviewView view) {
if (view == null) {
return null;
}
PublicPreview preview = new PublicPreview();
preview.setPreviewId(view.previewId());
preview.setDocumentId(view.documentId());
preview.setPreviewVersion(Math.toIntExact(view.previewVersion()));
preview.setValidationId(view.validationId());
preview.setDependencyRevision(view.dependencyRevision());
preview.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt()));
preview.setExpiresAt(StudioResponseMapper.offsetDateTime(view.expiresAt()));
preview.setRenderModel(renderModel(view.renderModelJson()));
return preview;
}
public PublicationAggregate toApi(PublicationAggregateView view) {
if (view == null) {
return null;
}
PublicationAggregate aggregate = new PublicationAggregate();
aggregate.setPublicationId(view.publicationId());
aggregate.setDocumentId(view.documentId());
aggregate.setStatus(PublicationAggregate.StatusEnum.fromValue(view.status().name()));
aggregate.setPublishedVersion(Math.toIntExact(view.publishedVersion()));
aggregate.setPublicationRevision(Math.toIntExact(view.publicationRevision()));
aggregate.setLatestEventId(view.latestEventId());
aggregate.setPublicPath(view.publicPath());
aggregate.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt()));
return aggregate;
}
public PreviewDetail toApi(PreviewDetailView view) {
PreviewDetail detail = new PreviewDetail();
detail.setPreview(toApi(view.preview()));
detail.setState(PreviewDetail.StateEnum.fromValue(view.state().name()));
detail.setCurrentDocumentVersion(Math.toIntExact(view.currentDocumentVersion()));
detail.setCurrentValidationId(view.currentValidationId());
return detail;
}
public PublicationEvent toApi(PublicationEventView view) {
PublicationEvent event = new PublicationEvent();
event.setPublicationEventId(view.publicationEventId());
event.setPublicationId(view.publicationId());
event.setDocumentId(view.documentId());
event.setType(PublicationEventType.fromValue(view.type().name()));
event.setOccurredAt(StudioResponseMapper.offsetDateTime(view.occurredAt()));
event.setPublishedVersion(Math.toIntExact(view.publishedVersion()));
event.setSourcePublishedEventId(view.sourcePublishedEventId());
event.setSnapshotAvailable(view.snapshotAvailable());
return event;
}
public PublishResult toApi(PublishResultView view) {
PublishResult result = new PublishResult();
result.setPublication(toApi(view.publication()));
result.setEvent(toApi(view.event()));
return result;
}
public PublicationListItem toApi(PublicationListItemView view) {
PublicationListItem item = new PublicationListItem();
item.setEvent(toApi(view.event()));
item.setPublication(toApi(view.publication()));
item.setDocument(view.document() == null ? null : StudioResponseMapper.toApi(view.document()));
item.setAvailableActions(
view.availableActions().stream()
.map(action -> PublicationAction.fromValue(action.name()))
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)));
return item;
}
public PublicationPage toApi(PublicationPageView view) {
PublicationPage page = new PublicationPage();
page.setItems(view.items().stream().map(this::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public PublicationSnapshot toApi(PublicationSnapshotView view) {
PublicationSnapshot snapshot = new PublicationSnapshot();
snapshot.setEvent(toApi(view.event()));
snapshot.setRenderModel(renderModel(view.renderModelJson()));
snapshot.setContentFormatVersion(view.contentFormatVersion());
snapshot.setRendererContractVersion(view.rendererContractVersion());
return snapshot;
}
public StudioDashboard toApi(DashboardView view) {
StudioDashboard dashboard = new StudioDashboard();
dashboard.setContinueWriting(
view.continueWriting().stream().map(StudioResponseMapper::toApi).toList());
dashboard.setReadyToPublish(
view.readyToPublish().stream().map(StudioResponseMapper::toApi).toList());
dashboard.setRecentPublications(view.recentPublications().stream().map(this::toApi).toList());
DashboardTotals totals = new DashboardTotals();
totals.setDocuments(view.totals().documents());
totals.setNeedsValidation(view.totals().needsValidation());
totals.setReadyToPublish(view.totals().readyToPublish());
totals.setPublications(view.totals().publications());
dashboard.setTotals(totals);
return dashboard;
}
public Asset toApi(AssetView view) {
Asset asset = new Asset();
asset.setId(view.id());
asset.setAssetKey(view.assetKey());
asset.setKind(AssetKind.fromValue(view.kind().name()));
asset.setMediaType(view.mediaType());
asset.setOriginalFilename(view.originalFilename());
asset.setByteSize(Math.toIntExact(view.byteSize()));
asset.setWidth(view.width());
asset.setHeight(view.height());
asset.setAltText(view.altText());
asset.setDecorative(view.decorative());
asset.setManagementStatus(AssetManagementStatus.fromValue(view.managementStatus().name()));
asset.setPublicPath(view.publicPath());
asset.setUsageCount(view.usageCount());
asset.setVersion(Math.toIntExact(view.version()));
asset.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt()));
asset.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt()));
return asset;
}
public AssetPage toApi(AssetPageView view) {
AssetPage page = new AssetPage();
page.setItems(view.items().stream().map(this::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public AssetDetail toApi(AssetDetailView view) {
AssetDetail detail = new AssetDetail();
detail.setAsset(toApi(view.asset()));
detail.setUsages(
view.usages().stream()
.map(
usage -> {
AssetUsage dto = new AssetUsage();
dto.setDocumentId(usage.documentId());
dto.setDocumentKind(RecordKind.fromValue(usage.documentKind().name()));
dto.setTitle(usage.title());
dto.setPublished(usage.published());
return dto;
})
.toList());
detail.setHasPublicationHistory(view.hasPublicationHistory());
return detail;
}
/** 저장된 렌더 모델 JSON을 계약 union 타입으로 되살린다. */
private PublicRenderModel renderModel(String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
return objectMapper.readValue(json, PublicRenderModel.class);
} catch (JacksonException e) {
throw new MappingException("a stored preview render model no longer matches the contract", e);
}
}
}
@@ -0,0 +1,203 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RelationInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import java.util.List;
import java.util.UUID;
/**
* 계약 DTO({@code WorkingCopyInput} union) → application 입력 모델.
*
* <p>계약 union 의 네 분기를 {@code switch} 로 남김없이 다룬다 — 계약에 다섯 번째 유형이 생기면 생성 DTO 가 늘어나고 여기서 {@code
* default} 가 없는 채로 컴파일이 깨져 알려준다.
*/
public final class StudioRequestMapper {
private StudioRequestMapper() {}
public static WorkingCopyInputView toApplication(WorkingCopyInput input) {
if (input == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document is required");
}
return switch (input) {
case CaseInput value ->
new WorkingCopyInputView.CaseInputView(
base(
RecordKind.CASE,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
value.getProblem(),
value.getConclusion(),
value.getEnvironment(),
value.getReproduction(),
value.getLastVerifiedOn(),
value.getBodyMarkdown());
case ReferenceInput value ->
new WorkingCopyInputView.ReferenceInputView(
base(
RecordKind.REFERENCE,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
value.getPurpose(),
rules(value.getRules()),
orderedText(value.getApplyWhen()),
orderedText(value.getExceptions()),
orderedText(value.getExamples()),
value.getVerifiedOn());
case QuestionInput value ->
new WorkingCopyInputView.QuestionInputView(
base(
RecordKind.QUESTION,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
questionStatus(value.getQuestionStatus()),
orderedText(value.getFacts()),
orderedText(value.getAssumptions()),
orderedText(value.getUnknowns()),
orderedText(value.getConstraints()),
options(value.getOptions()),
value.getNextValidation(),
resolution(value.getResolution()));
case ProjectDecisionInput value ->
new WorkingCopyInputView.ProjectDecisionInputView(
base(
RecordKind.PROJECT_DECISION,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
decisionStatus(value.getDecisionStatus()),
value.getDecidedOn(),
value.getStatement(),
value.getRationale(),
orderedText(value.getConsequences()));
default ->
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"unsupported document kind: " + input.getClass().getSimpleName());
};
}
private static WorkingCopyBaseInput base(
RecordKind kind,
String title,
String slug,
String summary,
UUID topicId,
UUID projectId,
List<RelationInput> relations) {
return new WorkingCopyBaseInput(
kind, title, slug, summary, topicId, projectId, relations(relations));
}
private static List<RelationView> relations(List<RelationInput> relations) {
return relations == null
? List.of()
: relations.stream()
.map(
relation ->
new RelationView(
relation.getId(),
relation.getTargetId(),
relation.getReason(),
order(relation.getOrder())))
.toList();
}
private static List<OrderedTextView> orderedText(List<OrderedText> items) {
return items == null
? List.of()
: items.stream()
.map(item -> new OrderedTextView(item.getId(), item.getText(), order(item.getOrder())))
.toList();
}
private static List<ReferenceRuleView> rules(List<ReferenceRule> items) {
return items == null
? List.of()
: items.stream()
.map(
item ->
new ReferenceRuleView(
item.getId(), item.getTitle(), item.getBody(), order(item.getOrder())))
.toList();
}
private static List<QuestionOptionView> options(List<QuestionOption> items) {
return items == null
? List.of()
: items.stream()
.map(
item ->
new QuestionOptionView(
item.getId(),
item.getTitle(),
item.getDescription(),
order(item.getOrder())))
.toList();
}
private static QuestionResolutionView resolution(QuestionResolution resolution) {
return resolution == null
? null
: new QuestionResolutionView(
resolution.getSummary(), resolution.getEvidenceTargetId(), resolution.getLinkLabel());
}
private static QuestionStatusView questionStatus(QuestionInput.QuestionStatusEnum status) {
if (status == null) {
return null;
}
return status == QuestionInput.QuestionStatusEnum.RESOLVED
? QuestionStatusView.RESOLVED
: QuestionStatusView.OPEN;
}
private static DecisionStatusView decisionStatus(ProjectDecisionInput.DecisionStatusEnum status) {
if (status == null) {
return null;
}
return status == ProjectDecisionInput.DecisionStatusEnum.ADOPTED
? DecisionStatusView.ADOPTED
: DecisionStatusView.PROPOSED;
}
/** 계약상 {@code order}는 required지만 null 을 실어 보내는 클라이언트를 500으로 떨어뜨리지 않는다. */
private static int order(Integer order) {
return order == null ? 0 : order;
}
}
@@ -0,0 +1,258 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentSummary;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Relation;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.function.Function;
/** application 모델 → 계약 DTO. */
public final class StudioResponseMapper {
private StudioResponseMapper() {}
public static WorkingCopy toApi(WorkingCopyView view) {
return switch (view) {
case WorkingCopyView.CaseWorkingCopyView value -> {
CaseWorkingCopy dto = new CaseWorkingCopy();
dto.setKind(CaseWorkingCopy.KindEnum.CASE);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setProblem(value.problem());
dto.setConclusion(value.conclusion());
dto.setEnvironment(value.environment());
dto.setReproduction(value.reproduction());
dto.setLastVerifiedOn(value.lastVerifiedOn());
dto.setBodyMarkdown(value.bodyMarkdown());
yield dto;
}
case WorkingCopyView.ReferenceWorkingCopyView value -> {
ReferenceWorkingCopy dto = new ReferenceWorkingCopy();
dto.setKind(ReferenceWorkingCopy.KindEnum.REFERENCE);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setPurpose(value.purpose());
dto.setRules(rulesToApi(value.rules()));
dto.setApplyWhen(orderedTextToApi(value.applyWhen()));
dto.setExceptions(orderedTextToApi(value.exceptions()));
dto.setExamples(orderedTextToApi(value.examples()));
dto.setVerifiedOn(value.verifiedOn());
yield dto;
}
case WorkingCopyView.QuestionWorkingCopyView value -> {
QuestionWorkingCopy dto = new QuestionWorkingCopy();
dto.setKind(QuestionWorkingCopy.KindEnum.QUESTION);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setQuestionStatus(questionStatus(value.questionStatus()));
dto.setFacts(orderedTextToApi(value.facts()));
dto.setAssumptions(orderedTextToApi(value.assumptions()));
dto.setUnknowns(orderedTextToApi(value.unknowns()));
dto.setConstraints(orderedTextToApi(value.constraints()));
dto.setOptions(optionsToApi(value.options()));
dto.setNextValidation(value.nextValidation());
dto.setResolution(resolution(value.resolution()));
yield dto;
}
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
ProjectDecisionWorkingCopy dto = new ProjectDecisionWorkingCopy();
dto.setKind(ProjectDecisionWorkingCopy.KindEnum.PROJECT_DECISION);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setDecisionStatus(decisionStatus(value.decisionStatus()));
dto.setDecidedOn(value.decidedOn());
dto.setStatement(value.statement());
dto.setRationale(value.rationale());
dto.setConsequences(orderedTextToApi(value.consequences()));
yield dto;
}
};
}
public static DocumentPage toApi(DocumentPageView view) {
DocumentPage page = new DocumentPage();
page.setItems(view.items().stream().map(StudioResponseMapper::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public static DocumentSummary toApi(DocumentSummaryView view) {
DocumentSummary summary = new DocumentSummary();
summary.setId(view.id());
summary.setTitle(view.title());
summary.setKind(RecordKind.fromValue(view.kind().name()));
summary.setProject(displayTarget(view.project()));
summary.setUpdatedAt(offsetDateTime(view.updatedAt()));
summary.setPublicationStatus(PublicationStatus.fromValue(view.publicationStatus().name()));
summary.setPublishedVersion(
view.publishedVersion() == null ? null : Math.toIntExact(view.publishedVersion()));
summary.setHasUnpublishedChanges(view.hasUnpublishedChanges());
summary.setNextAction(NextAction.fromValue(view.nextAction().name()));
return summary;
}
public static DisplayTarget displayTarget(DisplayTargetView view) {
if (view == null) {
return null;
}
DisplayTarget target = new DisplayTarget();
target.setId(view.id());
target.setLabel(view.label());
target.setPublicPath(view.publicPath());
return target;
}
public static OffsetDateTime offsetDateTime(Instant instant) {
return instant == null ? null : instant.atOffset(ZoneOffset.UTC);
}
/**
* 계약의 {@code version}은 {@code integer}이고 컬럼은 {@code bigint}다. 넘치는 값을 조용히 잘라내면 클라이언트가 보내는 {@code
* expectedVersion}이 영영 맞지 않게 되므로 예외로 드러낸다.
*/
private static Integer version(long version) {
return Math.toIntExact(version);
}
private static void applyBase(
WorkingCopyBaseInput base,
java.util.function.Consumer<String> title,
java.util.function.Consumer<String> slug,
java.util.function.Consumer<String> summary) {
title.accept(base.title());
slug.accept(base.slug());
summary.accept(base.summary());
}
private static List<Relation> relations(List<RelationView> views) {
return map(
views,
view -> {
Relation relation = new Relation();
relation.setId(view.id());
relation.setTargetId(view.targetId());
relation.setReason(view.reason());
relation.setOrder(view.order());
return relation;
});
}
public static List<OrderedText> orderedTextToApi(List<OrderedTextView> views) {
return map(
views,
view -> {
OrderedText text = new OrderedText();
text.setId(view.id());
text.setText(view.text());
text.setOrder(view.order());
return text;
});
}
public static List<ReferenceRule> rulesToApi(List<ReferenceRuleView> views) {
return map(
views,
view -> {
ReferenceRule rule = new ReferenceRule();
rule.setId(view.id());
rule.setTitle(view.title());
rule.setBody(view.body());
rule.setOrder(view.order());
return rule;
});
}
public static List<QuestionOption> optionsToApi(List<QuestionOptionView> views) {
return map(
views,
view -> {
QuestionOption option = new QuestionOption();
option.setId(view.id());
option.setTitle(view.title());
option.setDescription(view.description());
option.setOrder(view.order());
return option;
});
}
private static QuestionResolution resolution(QuestionResolutionView view) {
if (view == null) {
return null;
}
QuestionResolution resolution = new QuestionResolution();
resolution.setSummary(view.summary());
resolution.setEvidenceTargetId(view.evidenceTargetId());
resolution.setLinkLabel(view.linkLabel());
return resolution;
}
private static QuestionWorkingCopy.QuestionStatusEnum questionStatus(QuestionStatusView status) {
if (status == null) {
return null;
}
return status == QuestionStatusView.RESOLVED
? QuestionWorkingCopy.QuestionStatusEnum.RESOLVED
: QuestionWorkingCopy.QuestionStatusEnum.OPEN;
}
private static ProjectDecisionWorkingCopy.DecisionStatusEnum decisionStatus(
dev.caskeleton.application.techlog.studio.model.DecisionStatusView status) {
if (status == null) {
return null;
}
return status == dev.caskeleton.application.techlog.studio.model.DecisionStatusView.ADOPTED
? ProjectDecisionWorkingCopy.DecisionStatusEnum.ADOPTED
: ProjectDecisionWorkingCopy.DecisionStatusEnum.PROPOSED;
}
private static <S, T> List<T> map(List<S> source, Function<S, T> mapper) {
return source == null ? List.of() : source.stream().map(mapper).toList();
}
}
@@ -0,0 +1,236 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
import java.util.ArrayList;
import java.util.List;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TableBody;
import org.commonmark.ext.gfm.tables.TableCell;
import org.commonmark.ext.gfm.tables.TableHead;
import org.commonmark.ext.gfm.tables.TableRow;
import org.commonmark.node.BlockQuote;
import org.commonmark.node.BulletList;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.Heading;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Node;
import org.commonmark.node.OrderedList;
import org.commonmark.node.Paragraph;
import org.commonmark.node.ThematicBreak;
/**
* commonmark 블록 노드를 계약의 {@code CaseRenderBlock} union 으로 옮긴다.
*
* <p>계약이 표현할 수 없는 것은 조용히 다른 것으로 바꾸지 않고 경고로 남긴다(설계 05장 §12) — 렌더러가 지원하지 않는 문법을 그럴듯하게 잘못 해석하면 작성자는 게시
* 결과를 신뢰할 수 없다.
*/
final class BlockRenderer {
/** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */
private static final int MIN_HEADING_LEVEL = 2;
private static final int MAX_HEADING_LEVEL = 4;
private final HeadingIds headingIds;
private final List<String> warnings;
private int tableSequence;
private int listItemSequence;
BlockRenderer(HeadingIds headingIds, List<String> warnings) {
this.headingIds = headingIds;
this.warnings = warnings;
}
List<CaseRenderBlock> render(Node document) {
List<CaseRenderBlock> blocks = new ArrayList<>();
for (Node node = document.getFirstChild(); node != null; node = node.getNext()) {
CaseRenderBlock block = renderBlock(node);
if (block != null) {
blocks.add(block);
}
}
return blocks;
}
private CaseRenderBlock renderBlock(Node node) {
return switch (node) {
case Heading value -> heading(value);
case Paragraph value -> paragraph(InlineRenderer.render(value));
case BlockQuote value -> blockquote(value);
case BulletList value -> bulletList(value);
case OrderedList value -> orderedList(value);
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
case IndentedCodeBlock value -> code(value.getLiteral(), null);
case TableBlock value -> table(value);
case ThematicBreak ignored -> {
// 계약의 CaseRenderBlock 에 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던
// 구조가 생기므로 버리고 경고한다.
warnings.add("THEMATIC_BREAK_NOT_RENDERABLE");
yield null;
}
default -> {
List<Inline> content = InlineRenderer.render(node);
yield content.isEmpty() ? null : paragraph(content);
}
};
}
private CaseRenderBlock heading(Heading value) {
HeadingBlock block = new HeadingBlock();
block.setType(HeadingBlock.TypeEnum.HEADING);
block.setId(headingIds.nextFor(InlineRenderer.plainText(value)));
block.setLevel(Math.clamp(value.getLevel(), MIN_HEADING_LEVEL, MAX_HEADING_LEVEL));
block.setContent(InlineRenderer.render(value));
return block;
}
private static CaseRenderBlock paragraph(List<Inline> content) {
ParagraphBlock block = new ParagraphBlock();
block.setType(ParagraphBlock.TypeEnum.PARAGRAPH);
block.setContent(content);
return block;
}
/** 계약의 {@code BlockquoteBlock.content} 는 블록이 아니라 inline 배열이라 안쪽 문단을 이어 붙인다. */
private CaseRenderBlock blockquote(BlockQuote value) {
BlockquoteBlock block = new BlockquoteBlock();
block.setType(BlockquoteBlock.TypeEnum.BLOCKQUOTE);
List<Inline> content = new ArrayList<>();
for (Node child = value.getFirstChild(); child != null; child = child.getNext()) {
List<Inline> rendered = InlineRenderer.render(child);
if (rendered.isEmpty()) {
continue;
}
if (!content.isEmpty()) {
// 인용 안의 문단 경계. 이어 붙이기만 하면 앞 문단의 마지막 낱말과 다음 문단의 첫 낱말이
// 한 낱말로 붙어 읽힌다.
content.add(InlineRenderer.spacer());
}
content.addAll(rendered);
}
block.setContent(content);
return block;
}
private CaseRenderBlock bulletList(BulletList value) {
UnorderedListBlock block = new UnorderedListBlock();
block.setType(UnorderedListBlock.TypeEnum.UNORDERED_LIST);
block.setItems(listItems(value));
return block;
}
private CaseRenderBlock orderedList(OrderedList value) {
OrderedListBlock block = new OrderedListBlock();
block.setType(OrderedListBlock.TypeEnum.ORDERED_LIST);
block.setItems(listItems(value));
return block;
}
private List<ListItem> listItems(Node list) {
List<ListItem> items = new ArrayList<>();
for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
ListItem item = new ListItem();
listItemSequence++;
item.setId("li-" + listItemSequence);
List<Inline> content = new ArrayList<>();
for (Node paragraph = child.getFirstChild();
paragraph != null;
paragraph = paragraph.getNext()) {
content.addAll(InlineRenderer.render(paragraph));
}
item.setContent(content);
items.add(item);
}
return items;
}
private static CaseRenderBlock code(String literal, String info) {
CodeBlock block = new CodeBlock();
block.setType(CodeBlock.TypeEnum.CODE_BLOCK);
block.setCode(literal == null ? "" : literal);
block.setLanguage(info == null || info.isBlank() ? null : info.strip());
block.setLabel(null);
return block;
}
private CaseRenderBlock table(TableBlock value) {
DataTableBlock block = new DataTableBlock();
block.setType(DataTableBlock.TypeEnum.DATA_TABLE);
tableSequence++;
block.setId("table-" + tableSequence);
block.setCaption("");
// 계약은 rowHeaderColumn 을 1-based 로 정의한다. GFM 표에는 행 머리글 개념이 없으므로 비운다.
block.setRowHeaderColumn(null);
List<DataTableColumn> columns = new ArrayList<>();
List<DataTableRow> rows = new ArrayList<>();
int rowSequence = 0;
for (Node section = value.getFirstChild(); section != null; section = section.getNext()) {
for (Node row = section.getFirstChild(); row != null; row = row.getNext()) {
if (!(row instanceof TableRow tableRow)) {
continue;
}
if (section instanceof TableHead) {
int index = 0;
for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) {
DataTableColumn column = new DataTableColumn();
index++;
column.setId("col-" + index);
String label = InlineRenderer.plainText(cell);
// 계약의 label 은 minLength 1 이다. 빈 머리글 칸은 열 번호로 대신한다.
column.setLabel(label.isBlank() ? "col-" + index : label);
column.setAlignment(alignment(cell));
columns.add(column);
}
} else if (section instanceof TableBody) {
DataTableRow dataRow = new DataTableRow();
rowSequence++;
dataRow.setId("row-" + rowSequence);
List<DataTableCell> cells = new ArrayList<>();
int index = 0;
for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) {
DataTableCell dataCell = new DataTableCell();
index++;
dataCell.setColumnId("col-" + index);
dataCell.setContent(InlineRenderer.render(cell));
cells.add(dataCell);
}
dataRow.setCells(cells);
rows.add(dataRow);
}
}
}
if (columns.isEmpty()) {
// 계약의 columns 는 minItems 1 이다. 머리글 없는 표는 계약상 표현할 수 없다.
warnings.add("DATA_TABLE_WITHOUT_HEADER_NOT_RENDERABLE");
return null;
}
block.setColumns(columns);
block.setRows(rows);
return block;
}
private static DataTableColumn.AlignmentEnum alignment(Node cell) {
if (!(cell instanceof TableCell tableCell) || tableCell.getAlignment() == null) {
return DataTableColumn.AlignmentEnum.LEFT;
}
return switch (tableCell.getAlignment()) {
case CENTER -> DataTableColumn.AlignmentEnum.CENTER;
case RIGHT -> DataTableColumn.AlignmentEnum.RIGHT;
case LEFT -> DataTableColumn.AlignmentEnum.LEFT;
};
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
/**
* 설계 05장 §6의 heading ID 알고리즘. TOC와 anchor가 Public과 Studio에서 같아야 하므로 구현은 한 곳에만 둔다.
*
* <pre>{@code
* "Authorization Code Flow" -> authorization-code-flow
* "JPA N+1 문제" -> jpa-n-1-문제
* "결론" -> 결론
* "결론" (두 번째) -> 결론-2
* }</pre>
*/
final class HeadingIds {
private static final String FALLBACK = "section";
private final Map<String, Integer> used = new HashMap<>();
/** 같은 문서 안에서 중복되면 {@code -2}, {@code -3} 순으로 suffix 를 붙인다. */
String nextFor(String headingPlainText) {
String base = slugify(headingPlainText);
int seen = used.merge(base, 1, Integer::sum);
return seen == 1 ? base : base + "-" + seen;
}
private static String slugify(String text) {
String normalized =
Normalizer.normalize(text == null ? "" : text, Normalizer.Form.NFKC)
.trim()
.toLowerCase(Locale.ROOT);
StringBuilder out = new StringBuilder(normalized.length());
boolean pendingSeparator = false;
for (int i = 0; i < normalized.length(); i++) {
char ch = normalized.charAt(i);
if (isKept(ch)) {
// 구분자는 실제로 유지 문자가 뒤따를 때만 쓴다 — 그래야 끝에 하이픈이 남지 않는다.
if (pendingSeparator && !out.isEmpty()) {
out.append('-');
}
pendingSeparator = false;
out.append(ch);
} else {
pendingSeparator = true;
}
}
return out.isEmpty() ? FALLBACK : out.toString();
}
/** 한글·영문·숫자·하이픈만 남긴다(설계 05장 §6 6단계). */
private static boolean isKept(char ch) {
if (ch == '-') {
return true;
}
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
return true;
}
return isHangul(ch);
}
private static boolean isHangul(char ch) {
return (ch >= 0xAC00 && ch <= 0xD7A3) // 완성형 음절
|| (ch >= 0x1100 && ch <= 0x11FF) // 초·중·종성 자모
|| (ch >= 0x3130 && ch <= 0x318F); // 호환용 자모
}
}
@@ -0,0 +1,117 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineEmphasis;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineLink;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import java.util.ArrayList;
import java.util.List;
import org.commonmark.node.Code;
import org.commonmark.node.Emphasis;
import org.commonmark.node.HardLineBreak;
import org.commonmark.node.Image;
import org.commonmark.node.Link;
import org.commonmark.node.Node;
import org.commonmark.node.SoftLineBreak;
import org.commonmark.node.StrongEmphasis;
import org.commonmark.node.Text;
/** commonmark inline 노드를 계약의 {@code Inline} union 으로 옮긴다. */
final class InlineRenderer {
private InlineRenderer() {}
static List<Inline> render(Node parent) {
List<Inline> out = new ArrayList<>();
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
Inline rendered = renderNode(child);
if (rendered != null) {
out.add(rendered);
}
}
return out;
}
/** heading id 계산과 alt 추출에 쓰는 평문. */
static String plainText(Node parent) {
StringBuilder text = new StringBuilder();
appendPlainText(parent, text);
return text.toString();
}
private static void appendPlainText(Node parent, StringBuilder text) {
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
switch (child) {
case Text value -> text.append(value.getLiteral());
case Code value -> text.append(value.getLiteral());
case SoftLineBreak ignored -> text.append(' ');
case HardLineBreak ignored -> text.append(' ');
default -> appendPlainText(child, text);
}
}
}
private static Inline renderNode(Node node) {
return switch (node) {
case Text value -> text(value.getLiteral());
case Code value -> {
// 계약의 InlineCode.code 는 minLength 1 이다. 빈 백틱은 보낼 값이 없으므로 버린다.
if (value.getLiteral().isEmpty()) {
yield null;
}
InlineCode code = new InlineCode();
code.setType(InlineCode.TypeEnum.INLINE_CODE);
code.setCode(value.getLiteral());
yield code;
}
case Emphasis value -> {
InlineEmphasis emphasis = new InlineEmphasis();
emphasis.setType(InlineEmphasis.TypeEnum.EMPHASIS);
emphasis.setChildren(render(value));
yield emphasis;
}
case StrongEmphasis value -> {
InlineStrong strong = new InlineStrong();
strong.setType(InlineStrong.TypeEnum.STRONG);
strong.setChildren(render(value));
yield strong;
}
case Link value -> {
InlineLink link = new InlineLink();
link.setType(InlineLink.TypeEnum.LINK);
String label = plainText(value);
// 계약의 label 은 minLength 1 이다. 라벨 없는 링크는 주소 자체를 라벨로 쓴다 —
// 버리면 사용자가 쓴 링크가 통째로 사라진다.
link.setLabel(label.isBlank() ? value.getDestination() : label);
link.setHref(java.net.URI.create(value.getDestination()));
yield link;
}
// 이미지는 EvidenceFigure 로만 다룬다(설계 05장 §3). 인라인 이미지는 계약의 Inline union 에
// 대응 타입이 없으므로 alt 를 글자로 남긴다 — 조용히 사라지게 두지 않는다.
case Image value -> text(plainText(value));
case SoftLineBreak ignored -> text(" ");
case HardLineBreak ignored -> text(" ");
default -> {
String plain = plainText(node);
yield plain.isEmpty() ? null : text(plain);
}
};
}
/** 블록 경계를 한 칸 띄우는 조각. 계약의 Inline union 에 줄바꿈 타입이 없어 공백으로 표현한다. */
static Inline spacer() {
return text(" ");
}
private static Inline text(String literal) {
if (literal == null || literal.isEmpty()) {
return null;
}
InlineText inlineText = new InlineText();
inlineText.setType(InlineText.TypeEnum.TEXT);
inlineText.setText(literal);
return inlineText;
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.util.ArrayList;
import java.util.List;
/**
* 본문을 "평범한 Markdown" 조각과 "directive" 조각으로 순서대로 자른다.
*
* <p>fenced code block 안의 {@code :::} 는 directive 가 아니다 — 코드 예시로 directive 문법 자체를 적는 문서가 그 자리에서 잘리면
* 안 된다. 그래서 코드 펜스 안에 있는 동안은 directive 를 찾지 않는다.
*/
final class MarkdownSegments {
private MarkdownSegments() {}
/** 조각 하나. {@code directive} 가 null 이면 평범한 Markdown 이다. */
record Segment(String markdown, StudioDirective directive, String directiveBody) {}
static List<Segment> split(String source) {
List<Segment> segments = new ArrayList<>();
if (source == null || source.isBlank()) {
return segments;
}
String[] lines = source.split("\n", -1);
StringBuilder markdown = new StringBuilder();
String codeFence = null;
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
String trimmed = line.strip();
if (codeFence != null) {
markdown.append(line).append('\n');
if (trimmed.startsWith(codeFence)) {
codeFence = null;
}
continue;
}
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
codeFence = trimmed.startsWith("```") ? "```" : "~~~";
markdown.append(line).append('\n');
continue;
}
StudioDirective directive = StudioDirective.parse(line);
if (directive == null || directive.name().isEmpty()) {
markdown.append(line).append('\n');
continue;
}
flush(segments, markdown);
// 컨테이너형(:::name ... 내용 ... :::)인지 leaf형(:::evidence ...)인지는 <b>닫는 줄이
// 실제로 있는지</b>로 정한다. 이름으로 정하면 새 directive 를 더할 때마다 목록을 고쳐야 하고,
// "닫는 줄이 없으면 문서 끝까지 본문"으로 정하면 닫기를 빠뜨린 directive 하나가 뒤 내용을
// 통째로 삼킨다. 닫는 줄은 다음 directive 가 열리기 전까지만 찾는다.
int closing = -1;
for (int j = i + 1; j < lines.length; j++) {
String candidate = lines[j].strip();
if (":::".equals(candidate)) {
closing = j;
break;
}
if (StudioDirective.parse(lines[j]) != null) {
break;
}
}
if (closing < 0) {
segments.add(new Segment(null, directive, ""));
} else {
StringBuilder body = new StringBuilder();
for (int j = i + 1; j < closing; j++) {
body.append(lines[j]).append('\n');
}
segments.add(new Segment(null, directive, body.toString()));
i = closing;
}
}
flush(segments, markdown);
return segments;
}
private static void flush(List<Segment> segments, StringBuilder markdown) {
if (!markdown.isEmpty()) {
String text = markdown.toString();
if (!text.isBlank()) {
segments.add(new Segment(text, null, null));
}
markdown.setLength(0);
}
}
}
@@ -0,0 +1,166 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionPublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionPublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferencePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RenderContext;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedQuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedRelation;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RenderInput;
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import org.springframework.stereotype.Component;
/**
* 편집본 + 해석된 의존 상태 → 계약 {@code PublicRenderModel}.
*
* <p>본문 Markdown 을 블록으로 바꾸는 것은 {@code CASE} 뿐이다 — 나머지 세 유형의 공개 모델은 구조화된 필드로만 이루어져 있다(계약 {@code
* *PublicRenderModel}).
*/
@Component
public class PublicRenderModelFactory {
private final StudioContentRenderer contentRenderer;
public PublicRenderModelFactory(StudioContentRenderer contentRenderer) {
this.contentRenderer = contentRenderer;
}
/**
* 렌더 결과와 그 과정에서 생긴 경고.
*
* @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들(설계 05장 §12)
*/
public record Rendered(PublicRenderModel model, List<String> warnings) {}
public Rendered create(RenderInput input) {
RenderContext context = new RenderContext();
context.setGeneratedAt(StudioResponseMapper.offsetDateTime(input.generatedAt()));
context.setDependencyRevision(input.dependencyRevision());
List<ResolvedRelation> relations =
input.relations().stream().map(PublicRenderModelFactory::relation).toList();
DisplayTarget topic = StudioResponseMapper.displayTarget(input.topic());
DisplayTarget project = StudioResponseMapper.displayTarget(input.project());
return switch (input.document()) {
case WorkingCopyView.CaseWorkingCopyView value -> {
StudioContentRenderer.RenderedContent body =
contentRenderer.render(value.bodyMarkdown(), input.assetsByKey());
CasePublicRenderModel model = new CasePublicRenderModel();
model.setKind(CasePublicRenderModel.KindEnum.CASE);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setProblem(value.problem());
model.setConclusion(value.conclusion());
model.setEnvironment(value.environment());
model.setReproduction(value.reproduction());
model.setLastVerifiedOn(value.lastVerifiedOn());
model.setBodyBlocks(body.blocks());
yield new Rendered(model, body.warnings());
}
case WorkingCopyView.ReferenceWorkingCopyView value -> {
ReferencePublicRenderModel model = new ReferencePublicRenderModel();
model.setKind(ReferencePublicRenderModel.KindEnum.REFERENCE);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setPurpose(value.purpose());
model.setRules(StudioResponseMapper.rulesToApi(value.rules()));
model.setApplyWhen(StudioResponseMapper.orderedTextToApi(value.applyWhen()));
model.setExceptions(StudioResponseMapper.orderedTextToApi(value.exceptions()));
model.setExamples(StudioResponseMapper.orderedTextToApi(value.examples()));
model.setVerifiedOn(value.verifiedOn());
yield new Rendered(model, List.of());
}
case WorkingCopyView.QuestionWorkingCopyView value -> {
QuestionPublicRenderModel model = new QuestionPublicRenderModel();
model.setKind(QuestionPublicRenderModel.KindEnum.QUESTION);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setStatus(
value.questionStatus() == QuestionStatusView.RESOLVED
? QuestionPublicRenderModel.StatusEnum.RESOLVED
: QuestionPublicRenderModel.StatusEnum.OPEN);
model.setFacts(StudioResponseMapper.orderedTextToApi(value.facts()));
model.setAssumptions(StudioResponseMapper.orderedTextToApi(value.assumptions()));
model.setUnknowns(StudioResponseMapper.orderedTextToApi(value.unknowns()));
model.setConstraints(StudioResponseMapper.orderedTextToApi(value.constraints()));
model.setOptions(StudioResponseMapper.optionsToApi(value.options()));
model.setNextValidation(value.nextValidation());
model.setResolution(resolution(value, input));
yield new Rendered(model, List.of());
}
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
ProjectDecisionPublicRenderModel model = new ProjectDecisionPublicRenderModel();
model.setKind(ProjectDecisionPublicRenderModel.KindEnum.PROJECT_DECISION);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setStatus(
value.decisionStatus() == DecisionStatusView.ADOPTED
? ProjectDecisionPublicRenderModel.StatusEnum.ADOPTED
: ProjectDecisionPublicRenderModel.StatusEnum.PROPOSED);
model.setDecidedOn(value.decidedOn());
model.setStatement(value.statement());
model.setRationale(value.rationale());
model.setConsequences(StudioResponseMapper.orderedTextToApi(value.consequences()));
yield new Rendered(model, List.of());
}
};
}
private static ResolvedRelation relation(ResolvedRelationView view) {
ResolvedRelation relation = new ResolvedRelation();
relation.setId(view.id());
relation.setTargetId(view.targetId());
relation.setTargetKind(ResolvedRelation.TargetKindEnum.fromValue(view.targetKind()));
relation.setTitle(view.title());
relation.setPublicPath(view.publicPath());
relation.setReason(view.reason());
relation.setOrder(view.order());
return relation;
}
private static ResolvedQuestionResolution resolution(
WorkingCopyView.QuestionWorkingCopyView value, RenderInput input) {
if (value.resolution() == null) {
return null;
}
ResolvedQuestionResolution resolution = new ResolvedQuestionResolution();
resolution.setSummary(value.resolution().summary());
resolution.setEvidenceTarget(
StudioResponseMapper.displayTarget(input.resolutionEvidenceTarget()));
resolution.setLinkLabel(value.resolution().linkLabel());
return resolution;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.application.techlog.studio.model.RenderInput;
import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort;
import dev.caskeleton.shared.error.MappingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* 렌더 결과를 {@code studio_preview.render_model} 에 그대로 들어갈 JSON 으로 만든다.
*
* <p>렌더 경고는 여기서 버리지 않고 로그로 남긴다 — 계약의 {@code PublicPreview} 에 경고를 실을 자리가 없지만, 경고가 생겼다는 사실 자체가 "작성자가
* 쓴 문법 일부가 계약으로 표현되지 못했다"는 신호라 흔적 없이 사라지면 안 된다. 게시를 막아야 하는 종류(미해결 Asset 등)는 검증이 별도로 잡는다.
*/
@Component
public class RenderModelJsonAdapter implements RenderModelPort {
private static final Logger log = LoggerFactory.getLogger(RenderModelJsonAdapter.class);
private final PublicRenderModelFactory factory;
private final ObjectMapper objectMapper;
public RenderModelJsonAdapter(PublicRenderModelFactory factory, ObjectMapper objectMapper) {
this.factory = factory;
this.objectMapper = objectMapper;
}
@Override
public String renderToJson(RenderInput input) {
PublicRenderModelFactory.Rendered rendered = factory.create(input);
if (!rendered.warnings().isEmpty()) {
log.warn(
"studio render produced {} warning(s) for document {}: {}",
rendered.warnings().size(),
input.document().id(),
rendered.warnings());
}
try {
return objectMapper.writeValueAsString(rendered.model());
} catch (JacksonException e) {
throw new MappingException("failed to serialise a Studio render model", e);
}
}
}
@@ -0,0 +1,181 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedAsset;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.commonmark.ext.autolink.AutolinkExtension;
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.parser.Parser;
import org.springframework.stereotype.Component;
/**
* 본문 Markdown → 계약의 {@code CaseRenderBlock} 목록. Preview·공개·Snapshot 세 화면이 이 한 구현을 공유한다(ADR-005) —
* 화면마다 다른 경로를 두면 작성자가 확인한 것과 공개된 것이 달라진다.
*
* <p>Asset 해석은 렌더러가 직접 조회하지 않고 {@code assetsByKey} 로 주입받는다. Snapshot 만 게시 시점에 고정된 manifest 를 넣고
* 나머지는 현재 상태를 넣으며, 그것이 ADR-002 가 요구하는 유일하게 허용된 차이다.
*/
@Component
public class StudioContentRenderer implements ContentAnalyzerPort {
/** 설계 05장 §4가 허용한 callout 종류. 그 밖의 이름은 경고를 만들고 일반 인용으로 처리한다. */
private static final Set<String> INFO_CALLOUTS = Set.of("note", "tip");
private static final Set<String> WARNING_CALLOUTS = Set.of("warning", "danger");
private static final String EVIDENCE = "evidence";
private final Parser parser =
Parser.builder()
.extensions(
List.of(
TablesExtension.create(),
StrikethroughExtension.create(),
AutolinkExtension.create()))
.build();
/**
* 렌더 결과.
*
* @param blocks 계약 모양의 본문 블록
* @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들의 코드
*/
public record RenderedContent(List<CaseRenderBlock> blocks, List<String> warnings) {}
public RenderedContent render(String bodyMarkdown, Map<String, ResolvedAssetView> assetsByKey) {
List<CaseRenderBlock> blocks = new ArrayList<>();
List<String> warnings = new ArrayList<>();
HeadingIds headingIds = new HeadingIds();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
if (segment.directive() == null) {
blocks.addAll(
new BlockRenderer(headingIds, warnings).render(parser.parse(segment.markdown())));
continue;
}
CaseRenderBlock block = renderDirective(segment, assetsByKey, headingIds, warnings);
if (block != null) {
blocks.add(block);
}
}
return new RenderedContent(blocks, warnings);
}
@Override
public ContentAnalysis analyze(String bodyMarkdown) {
List<AssetUsage> usages = new ArrayList<>();
Set<String> unsupported = new LinkedHashSet<>();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
StudioDirective directive = segment.directive();
if (directive == null) {
continue;
}
if (EVIDENCE.equals(directive.name())) {
usages.add(new AssetUsage(directive.attribute("key", ""), directive.attribute("alt", "")));
} else if (!INFO_CALLOUTS.contains(directive.name())
&& !WARNING_CALLOUTS.contains(directive.name())) {
unsupported.add(directive.name());
}
}
return new ContentAnalysis(usages, List.copyOf(unsupported), plainText(bodyMarkdown));
}
/** 검색 색인용 평문. 렌더러가 이미 파싱한 것을 다시 쓴다 — 정규식으로 마크업을 지우는 별도 구현을 두면 두 해석이 갈라져 색인이 본문과 어긋난다. */
private String plainText(String bodyMarkdown) {
StringBuilder text = new StringBuilder();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
String source = segment.directive() == null ? segment.markdown() : segment.directiveBody();
if (source == null || source.isBlank()) {
continue;
}
String plain = InlineRenderer.plainText(parser.parse(source));
if (!plain.isBlank()) {
if (!text.isEmpty()) {
text.append(' ');
}
text.append(plain.strip());
}
}
return text.toString();
}
private CaseRenderBlock renderDirective(
MarkdownSegments.Segment segment,
Map<String, ResolvedAssetView> assetsByKey,
HeadingIds headingIds,
List<String> warnings) {
StudioDirective directive = segment.directive();
if (EVIDENCE.equals(directive.name())) {
return evidence(directive, assetsByKey, warnings);
}
if (INFO_CALLOUTS.contains(directive.name()) || WARNING_CALLOUTS.contains(directive.name())) {
CalloutBlock callout = new CalloutBlock();
callout.setType(CalloutBlock.TypeEnum.CALLOUT);
callout.setTone(
WARNING_CALLOUTS.contains(directive.name())
? CalloutBlock.ToneEnum.WARNING
: CalloutBlock.ToneEnum.INFO);
callout.setLabel(directive.argument());
callout.setContent(
InlineRenderer.render(
parser.parse(segment.directiveBody()).getFirstChild() == null
? parser.parse("")
: parser.parse(segment.directiveBody()).getFirstChild()));
return callout;
}
// 설계 05장 §4: 알 수 없는 종류는 경고를 만들고 일반 blockquote 로 안전하게 처리한다.
warnings.add("UNSUPPORTED_DIRECTIVE:" + directive.name());
List<CaseRenderBlock> fallback =
new BlockRenderer(headingIds, warnings)
.render(parser.parse("> " + segment.directiveBody().replace("\n", "\n> ")));
return fallback.isEmpty() ? null : fallback.getFirst();
}
private static CaseRenderBlock evidence(
StudioDirective directive,
Map<String, ResolvedAssetView> assetsByKey,
List<String> warnings) {
String key = directive.attribute("key", "");
if (key.isBlank()) {
warnings.add("EVIDENCE_WITHOUT_KEY");
return null;
}
ResolvedAssetView resolved = assetsByKey.get(key);
if (resolved == null) {
// 미해결 key 를 임의 경로로 채워 넣지 않는다 — 렌더 결과가 존재하지 않는 파일을 가리키게 된다.
// 게시는 검증이 막고, 미리보기에서는 이 경고가 사용자에게 무엇이 빠졌는지 알려준다.
warnings.add("UNRESOLVED_ASSET_KEY:" + key);
return null;
}
EvidenceFigureBlock block = new EvidenceFigureBlock();
block.setType(EvidenceFigureBlock.TypeEnum.EVIDENCE_FIGURE);
block.setKey(key);
block.setAlt(directive.attribute("alt", ""));
block.setCaption(directive.attribute("caption", ""));
block.setZoom(directive.booleanAttribute("zoom"));
ResolvedAsset asset = new ResolvedAsset();
asset.setAssetId(resolved.assetId());
asset.setAssetKey(resolved.assetKey());
asset.setMediaType(resolved.mediaType());
asset.setPublicPath(resolved.publicPath());
asset.setWidth(resolved.width());
asset.setHeight(resolved.height());
asset.setDecorative(resolved.decorative());
block.setAsset(asset);
return block;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* {@code :::name key="value" ...} 한 줄을 이름과 속성으로 나눈다.
*
* <p>directive 를 commonmark 확장이 아니라 줄 단위 스캔으로 다루는 이유: v1 문법에서 directive 는 중첩이 없고 줄 맨 앞에서만 열린다(설계
* 05장 §3.2, §4). 줄 스캔이면 동작이 눈으로 확인되고, 지원하지 않는 directive 를 "조용히 다른 것으로 해석"하는 일이 구조적으로 생기지 않는다.
*/
record StudioDirective(String name, String argument, Map<String, String> attributes) {
private static final Pattern OPENING = Pattern.compile("^:::([A-Za-z][A-Za-z0-9_-]*)\\s*(.*)$");
private static final Pattern ATTRIBUTE = Pattern.compile("([A-Za-z][A-Za-z0-9_-]*)=\"([^\"]*)\"");
static StudioDirective parse(String line) {
Matcher opening = OPENING.matcher(line.strip());
if (!opening.matches()) {
return null;
}
String rest = opening.group(2).strip();
Map<String, String> attributes = new LinkedHashMap<>();
Matcher attribute = ATTRIBUTE.matcher(rest);
int firstAttributeStart = rest.length();
while (attribute.find()) {
if (attribute.start() < firstAttributeStart) {
firstAttributeStart = attribute.start();
}
attributes.put(attribute.group(1), attribute.group(2));
}
String argument = rest.substring(0, firstAttributeStart).strip();
return new StudioDirective(opening.group(1), argument, attributes);
}
String attribute(String name, String fallback) {
String value = attributes.get(name);
return value == null ? fallback : value;
}
boolean booleanAttribute(String name) {
return "true".equalsIgnoreCase(attributes.get(name));
}
}
@@ -0,0 +1,122 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.cursor.CursorCodec;
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
import org.springframework.stereotype.Component;
/**
* 계약이 "opaque 하고 정규화된 필터·정렬에 결합된 커서"라고 정한 것을 실제로 그렇게 만든다.
*
* <p>필터 지문을 커서 안에 함께 서명한다 — 그래야 필터를 바꾼 뒤 옛 커서를 재사용하는 요청을 거절할 수 있다. 거절하지 않으면 정렬 키의 의미가 달라진 채로 페이지가
* 이어져 사용자에게는 항목이 조용히 사라지거나 중복돼 보인다.
*/
@Component
public class StudioCursors {
private static final String SEPARATOR = "~";
/**
* 지문 입력의 필드 구분자(ASCII unit separator). 사용자 입력에 나타나지 않는 제어문자라 인접한 필드가 서로 섞여 같은 지문을 만드는 일이 없다 — 예를
* 들어 구분자가 없으면 (kind="A", q="B")와 (kind="AB", q="")가 같은 값이 된다.
*/
private static final char FIELD_SEPARATOR = (char) 0x1f;
private final CursorCodec codec;
private final Clock clock;
public StudioCursors(StudioSettings settings, Clock clock) {
this.codec =
new CursorCodec(
settings.cursorSigningKey().getBytes(StandardCharsets.UTF_8), CursorCodec.DEFAULT_TTL);
this.clock = clock;
}
/** 이 페이지 요청의 필터·정렬을 대표하는 값. 커서에 함께 실린다. */
public static String fingerprint(String... normalizedFilterParts) {
StringBuilder joined = new StringBuilder();
for (String part : normalizedFilterParts) {
joined.append(part == null ? "" : part).append(FIELD_SEPARATOR);
}
try {
byte[] digest =
MessageDigest.getInstance("SHA-256")
.digest(joined.toString().getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
public String encode(String payload, String fingerprint) {
String body =
fingerprint
+ SEPARATOR
+ Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(payload.getBytes(StandardCharsets.UTF_8));
return codec.encode(body, clock.instant());
}
/**
* 커서를 풀어 페이지 위치로 돌려준다.
*
* @throws StudioException 서명·만료·필터 지문 중 하나라도 맞지 않으면 {@code REQUEST_VALIDATION_FAILED}
*/
public DocumentCursorPosition decode(String cursor, String fingerprint, boolean titleSort) {
String body;
try {
body = codec.decode(cursor, clock.instant());
} catch (CursorException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "cursor is not usable: " + e.getMessage());
}
int separator = body.indexOf(SEPARATOR);
if (separator <= 0) {
throw malformed();
}
if (!fingerprint.equals(body.substring(0, separator))) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"cursor was issued for a different filter or sort; start from the first page");
}
String payload =
new String(
Base64.getUrlDecoder().decode(body.substring(separator + SEPARATOR.length())),
StandardCharsets.UTF_8);
int pipe = payload.lastIndexOf('|');
if (pipe <= 0) {
throw malformed();
}
String head = payload.substring(0, pipe);
UUID id;
try {
id = UUID.fromString(payload.substring(pipe + 1));
} catch (IllegalArgumentException e) {
throw malformed();
}
if (titleSort) {
return new DocumentCursorPosition(null, head, id);
}
try {
return new DocumentCursorPosition(Instant.parse(head), null, id);
} catch (DateTimeParseException e) {
throw malformed();
}
}
private static StudioException malformed() {
return StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "cursor is malformed");
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport;
import dev.caskeleton.application.idempotency.IdempotencyContext;
import dev.caskeleton.application.idempotency.IdempotencyExecutor;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
/**
* 계약이 모든 mutation 에 요구하는 {@code Idempotency-Key} 처리(spec §8.2).
*
* <p>재생 여부를 스스로 판단하지 않고 <b>동작이 실제로 실행됐는지</b>로 안다 — 저장소를 미리 들여다보고 판정하면 그 사이에 다른 요청이 끼어들 수 있어 헤더가
* 거짓말을 하게 된다. 실행되지 않았다면 결과는 재생된 것이다.
*
* <p>{@code IdempotencyExecutor} 는 provider 가 {@code disabled} 인 배포에는 빈이 없다. 그때는 키의 존재만 계약대로 강제하고
* 실행은 그대로 통과시킨다 — 여기서 빈을 필수로 요구하면 그런 배포는 Studio 컨트롤러 때문에 부팅 자체가 실패한다.
*/
@Component
public class StudioIdempotency {
/**
* 계약 {@code components.headers.IdempotencyReplayed}. {@code ApiHeaders}에 두지 않는 이유는 그 파일이 템플릿
* SSOT({@code wiki/projects/ca-tmpl/registries/headers.yaml}) 소유라 이 기능이 손대면 다음 동기화에서 충돌하기 때문이다.
*/
public static final String IDEMPOTENCY_REPLAYED = "Idempotency-Replayed";
/** 계약 {@code components.parameters.IdempotencyKey.schema.maxLength}. */
private static final int MAX_KEY_LENGTH = 200;
private final ObjectProvider<IdempotencyExecutor> executors;
private final IdempotencyKeySupport keys;
public StudioIdempotency(
ObjectProvider<IdempotencyExecutor> executors, IdempotencyKeySupport keys) {
this.executors = executors;
this.keys = keys;
}
/**
* 결과와 그 결과가 재생된 것인지 여부.
*
* @param <R> 동작의 결과 타입
*/
public record Outcome<R>(R result, boolean replayed) {}
public <R> Outcome<R> run(
HttpServletRequest request,
String operationId,
Object requestPayload,
Class<R> responseType,
Supplier<R> action) {
String key = requireKey(request);
IdempotencyExecutor executor = executors.getIfAvailable();
if (executor == null) {
return new Outcome<>(action.get(), false);
}
AtomicBoolean executed = new AtomicBoolean(false);
R result =
executor.execute(
IdempotencyContext.of(keys.scope(key, operationId), keys.fingerprint(requestPayload)),
() -> {
executed.set(true);
return action.get();
},
keys.codec(responseType));
return new Outcome<>(result, !executed.get());
}
private String requireKey(HttpServletRequest request) {
Optional<String> key = keys.idempotencyKey(request);
if (key.isEmpty()) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "the Idempotency-Key header is required");
}
if (key.get().length() > MAX_KEY_LENGTH) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"the Idempotency-Key header must be at most " + MAX_KEY_LENGTH + " characters");
}
return key.get();
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
/**
* 감사 컬럼({@code created_by}/{@code updated_by})에 남길 주체를 뽑는다.
*
* <p>{@code idpUserId} 를 쓴다 — 이메일이나 표시 이름은 사용자가 바꿀 수 있어 과거 기록의 주체를 되짚을 수 없게 된다.
*/
public final class StudioPrincipals {
private StudioPrincipals() {}
public static String require(AuthenticatedPrincipal principal) {
if (principal == null || principal.idpUserId() == null || principal.idpUserId().isBlank()) {
throw StudioException.of(
StudioError.AUTHENTICATION_REQUIRED, "the request has no usable authenticated principal");
}
return principal.idpUserId();
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* {@code ca-skeleton.techlog.studio.*}. Studio 고유 설정을 템플릿 소유 파일({@code PresentationSettings} 등)에 섞지
* 않고 여기 모은다 — 그 파일들은 template sync 대상이라 이 기능이 손대면 다음 동기화에서 충돌한다.
*
* @param cursorSigningKey 목록 커서 서명 키. 비어 있으면 개발용 값으로 대체하고 경고한다 — 커서에는 권한이 실리지 않으므로 부팅을 막을 사유는 아니지만,
* 인스턴스마다 값이 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다.
* @param validationTtl 검증 결과가 유효한 기간({@code studio_validation.valid_until})
* @param previewTtl 미리보기가 유효한 기간({@code studio_preview.expires_at})
*/
@ConfigurationProperties(prefix = "ca-skeleton.techlog.studio")
public record StudioSettings(String cursorSigningKey, Duration validationTtl, Duration previewTtl) {
private static final Logger log = LoggerFactory.getLogger(StudioSettings.class);
private static final String DEV_CURSOR_KEY = "__LOCAL_DEV_techlog_studio_cursor_signing_key";
private static final int MIN_KEY_BYTES = 16;
private static final Duration DEFAULT_VALIDATION_TTL = Duration.ofHours(1);
private static final Duration DEFAULT_PREVIEW_TTL = Duration.ofHours(24);
public StudioSettings {
if (cursorSigningKey == null
|| cursorSigningKey.getBytes(StandardCharsets.UTF_8).length < MIN_KEY_BYTES) {
log.warn(
"APP_STUDIO_CURSOR_SIGNING_KEY is missing or shorter than {} bytes; using a development"
+ " key. Cursors issued by one instance verify on another only while every instance"
+ " falls back to the same value.",
MIN_KEY_BYTES);
cursorSigningKey = DEV_CURSOR_KEY;
}
if (validationTtl == null || validationTtl.isZero() || validationTtl.isNegative()) {
validationTtl = DEFAULT_VALIDATION_TTL;
}
if (previewTtl == null || previewTtl.isZero() || previewTtl.isNegative()) {
previewTtl = DEFAULT_PREVIEW_TTL;
}
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.contract;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.ObjectMapper;
/**
* 계약의 discriminator union이 Jackson 양방향으로 계약대로 동작하는지 고정한다.
*
* <p>이 게이트가 필요한 이유는 <b>컴파일이 이걸 못 잡기 때문</b>이다. Plan 01에서 {@code useOneOfInterfaces=false}로 생성한
* union은 컴파일 오류 0개였지만 런타임에는 양방향 모두 계약을 위반했다 — 역직렬화는 {@code InvalidTypeIdException}("CaseInput not
* subtype of WorkingCopyInput"), 직렬화는 판별 필드에 {@code kind} 값 대신 클래스 simple name. 지금은 {@code
* prepareStudioCodegenSpec}이 계약에서 {@code x-implements}와 union interface를 파생시켜 고쳤고, 이 테스트가 그 파생 배선이
* 살아 있는지를 지킨다. 파생이 깨지면 여기서 빨간불이 난다.
*
* <p>{@code new ObjectMapper()}는 이 모듈의 다른 테스트와 같은 관용구다 — Jackson 3({@code tools.jackson})이며 앱의 HTTP
* 변환기와 같은 계열이다. Jackson 2 ({@code com.fasterxml.jackson.databind})로 검증하면 프로덕션에서 실제로 쓰이지 않는 경로를 재는
* 셈이라 의미가 없다.
*/
class StudioContractUnionJacksonTest {
private final ObjectMapper mapper = new ObjectMapper();
private static CaseWorkingCopy caseWorkingCopy() {
CaseWorkingCopy document = new CaseWorkingCopy();
document.setKind(CaseWorkingCopy.KindEnum.CASE);
document.setId(UUID.fromString("00000000-0000-4000-8000-000000000001"));
document.setVersion(1);
document.setTitle("제목");
document.setSlug("some-slug");
document.setSummary("요약");
document.setProblem("문제");
document.setConclusion("결론");
document.setEnvironment("환경");
document.setReproduction("재현");
document.setBodyMarkdown("본문");
return document;
}
@Test
void workingCopyUnionSerializesTheContractDiscriminatorAndRoundTrips() {
WorkingCopyDetail detail = new WorkingCopyDetail();
detail.setDocument(caseWorkingCopy());
detail.setDependencyRevision("rev-1");
detail.setNextAction(NextAction.VALIDATE);
String json = mapper.writeValueAsString(detail);
// 판별 필드는 계약이 정한 값이어야 한다. 클래스 이름("CaseWorkingCopy")이 나가면 프론트가 깨진다.
assertThat(json).contains("\"kind\":\"CASE\"");
assertThat(json).doesNotContain("CaseWorkingCopy");
// 판별 필드가 두 번 나가면 안 된다 — union interface가 As.EXISTING_PROPERTY인 이유다.
assertThat(json.split("\"kind\":", -1)).hasSize(2);
// slug는 문자열이다. 계약의 문자열 oneOf를 접지 않으면 여기서 {} 가 나간다.
assertThat(json).contains("\"slug\":\"some-slug\"");
WorkingCopyDetail back = mapper.readValue(json, WorkingCopyDetail.class);
assertThat(back.getDocument()).isInstanceOf(CaseWorkingCopy.class);
assertThat(((CaseWorkingCopy) back.getDocument()).getProblem()).isEqualTo("문제");
}
@Test
void workingCopyInputUnionRoundTripsThroughTheDeclaredUnionType() {
QuestionInput input = new QuestionInput();
input.setKind(QuestionInput.KindEnum.QUESTION);
input.setTitle("질문");
input.setSlug("");
input.setSummary("요약");
input.setNextValidation("다음 검증");
WorkingCopyInput declared = input;
String json = mapper.writeValueAsString(declared);
assertThat(json).contains("\"kind\":\"QUESTION\"");
WorkingCopyInput back = mapper.readValue(json, WorkingCopyInput.class);
assertThat(back).isInstanceOf(QuestionInput.class);
assertThat(((QuestionInput) back).getNextValidation()).isEqualTo("다음 검증");
}
@Test
void renderBlockAndInlineUnionsRoundTripInsideCollections() {
InlineText text = new InlineText();
text.setType(InlineText.TypeEnum.TEXT);
text.setText("본문 조각");
HeadingBlock heading = new HeadingBlock();
heading.setType(HeadingBlock.TypeEnum.HEADING);
heading.setId("h-1");
heading.setLevel(2);
heading.setContent(List.of(text));
CasePublicRenderModel model = new CasePublicRenderModel();
model.setKind(CasePublicRenderModel.KindEnum.CASE);
model.setSlug("some-slug");
model.setTitle("제목");
model.setSummary("요약");
model.setPublicPath("/case/some-slug");
model.setBodyBlocks(List.of(heading));
PublicRenderModel declared = model;
String json = mapper.writeValueAsString(declared);
assertThat(json).contains("\"kind\":\"CASE\"");
assertThat(json).contains("\"type\":\"HEADING\"");
assertThat(json).contains("\"type\":\"TEXT\"");
PublicRenderModel back = mapper.readValue(json, PublicRenderModel.class);
assertThat(back).isInstanceOf(CasePublicRenderModel.class);
List<CaseRenderBlock> blocks = ((CasePublicRenderModel) back).getBodyBlocks();
assertThat(blocks).hasSize(1).first().isInstanceOf(HeadingBlock.class);
List<Inline> content = ((HeadingBlock) blocks.get(0)).getContent();
assertThat(content).hasSize(1).first().isInstanceOf(InlineText.class);
assertThat(((InlineText) content.get(0)).getText()).isEqualTo("본문 조각");
}
@Test
void unionDeserializationRejectsAnUnknownDiscriminatorInsteadOfSilentlyDroppingIt() {
String json = "{\"kind\":\"NOT_A_KIND\",\"title\":\"제목\"}";
assertThat(
org.assertj.core.api.Assertions.catchThrowable(
() -> mapper.readValue(json, WorkingCopy.class)))
.isNotNull();
}
}
@@ -0,0 +1,223 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
/**
* 렌더러가 설계 05장이 정한 문법을 계약 블록으로 옮기는지 고정한다.
*
* <p>여기서 지키는 것은 "그럴듯하게 렌더링된다"가 아니라 <b>계약 제약을 어기지 않는다</b>이다 — heading level 범위, 표의 최소 열 수, 미해결 Asset
* 을 지어내지 않는 것. 이것들이 깨지면 미리보기는 화면에 나오지만 게시된 문서가 계약을 위반한다.
*/
class StudioContentRendererTest {
private final StudioContentRenderer renderer = new StudioContentRenderer();
private static ResolvedAssetView asset(String key, boolean decorative) {
return new ResolvedAssetView(
UUID.fromString("00000000-0000-4000-8000-000000000009"),
key,
"image/png",
"/media/00000000-0000-4000-8000-000000000009",
800,
600,
decorative);
}
@Test
void headingsGetContractLevelsAndStableIds() {
var rendered =
renderer.render(
"""
# Authorization Code Flow
###### 아주 깊은 제목
## 결론
## 결론
""",
Map.of());
List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
// 계약의 HeadingBlock.level 은 2..4 다. h1 과 h6 를 그대로 내보내면 계약 위반이다.
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2);
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4);
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
// 같은 제목이 두 번이면 두 번째부터 suffix 가 붙는다(설계 05장 §6 8단계).
assertThat(((HeadingBlock) blocks.get(3)).getId()).isEqualTo("결론-2");
}
@Test
void headingIdFollowsTheDesignedNormalisation() {
var rendered = renderer.render("## JPA N+1 문제\n", Map.of());
assertThat(((HeadingBlock) rendered.blocks().getFirst()).getId()).isEqualTo("jpa-n-1-문제");
}
@Test
void inlineMarkupBecomesTheContractInlineUnion() {
var rendered = renderer.render("본문 **강조** 와 `code` 조각\n", Map.of());
ParagraphBlock paragraph = (ParagraphBlock) rendered.blocks().getFirst();
assertThat(paragraph.getContent()).hasSize(5);
assertThat(paragraph.getContent().get(0)).isInstanceOf(InlineText.class);
assertThat(paragraph.getContent().get(1)).isInstanceOf(InlineStrong.class);
assertThat(paragraph.getContent().get(3)).isInstanceOf(InlineCode.class);
assertThat(((InlineCode) paragraph.getContent().get(3)).getCode()).isEqualTo("code");
}
@Test
void listsCodeAndTablesBecomeTheirContractBlocks() {
var rendered =
renderer.render(
"""
- 첫째
- 둘째
```java
int x = 1;
```
| 이름 | 값 |
| --- | ---: |
| a | 1 |
""",
Map.of());
List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks.get(0)).isInstanceOf(UnorderedListBlock.class);
assertThat(((UnorderedListBlock) blocks.get(0)).getItems()).hasSize(2);
CodeBlock code = (CodeBlock) blocks.get(1);
assertThat(code.getLanguage()).isEqualTo("java");
assertThat(code.getCode()).contains("int x = 1;");
DataTableBlock table = (DataTableBlock) blocks.get(2);
assertThat(table.getColumns()).hasSize(2);
assertThat(table.getColumns().get(1).getAlignment())
.isEqualTo(
dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn
.AlignmentEnum.RIGHT);
assertThat(table.getRows()).hasSize(1);
assertThat(table.getRows().getFirst().getCells().getFirst().getColumnId()).isEqualTo("col-1");
}
@Test
void calloutDirectivesBecomeCalloutBlocksWithTheContractTone() {
var rendered =
renderer.render(
"""
:::warning 주의
Presigned URL 을 본문에 저장하지 않습니다.
:::
:::note
확인했습니다.
:::
""",
Map.of());
CalloutBlock warning = (CalloutBlock) rendered.blocks().get(0);
assertThat(warning.getTone()).isEqualTo(CalloutBlock.ToneEnum.WARNING);
assertThat(warning.getLabel()).isEqualTo("주의");
CalloutBlock note = (CalloutBlock) rendered.blocks().get(1);
// 설계는 note/tip/warning/danger 네 가지를 허용하지만 계약의 tone 은 두 가지다.
assertThat(note.getTone()).isEqualTo(CalloutBlock.ToneEnum.INFO);
}
@Test
void anUnknownDirectiveDegradesToAQuoteAndWarnsInsteadOfBeingSilentlyReinterpreted() {
var rendered =
renderer.render(
"""
:::mermaid
graph TD;
:::
""",
Map.of());
assertThat(rendered.blocks().getFirst()).isInstanceOf(BlockquoteBlock.class);
assertThat(rendered.warnings()).contains("UNSUPPORTED_DIRECTIVE:mermaid");
}
@Test
void evidenceDirectiveResolvesThroughTheInjectedAssetManifest() {
var rendered =
renderer.render(
":::evidence key=\"flow\" alt=\"요청 흐름\" caption=\"흐름도\" zoom=\"true\"\n",
Map.of("flow", asset("flow", false)));
EvidenceFigureBlock figure = (EvidenceFigureBlock) rendered.blocks().getFirst();
assertThat(figure.getKey()).isEqualTo("flow");
assertThat(figure.getAlt()).isEqualTo("요청 흐름");
assertThat(figure.getCaption()).isEqualTo("흐름도");
assertThat(figure.getZoom()).isTrue();
assertThat(figure.getAsset().getPublicPath())
.isEqualTo("/media/00000000-0000-4000-8000-000000000009");
}
@Test
void anUnresolvedAssetKeyIsDroppedWithAWarningRatherThanPointedAtNothing() {
var rendered = renderer.render(":::evidence key=\"missing\" alt=\"x\"\n", Map.of());
// 임의 경로를 지어내면 렌더 결과가 존재하지 않는 파일을 가리킨다.
assertThat(rendered.blocks()).isEmpty();
assertThat(rendered.warnings()).contains("UNRESOLVED_ASSET_KEY:missing");
}
@Test
void directiveSyntaxInsideACodeFenceIsNotADirective() {
var rendered =
renderer.render(
"""
```markdown
:::evidence key="example"
```
""",
Map.of());
assertThat(rendered.blocks().getFirst()).isInstanceOf(CodeBlock.class);
assertThat(rendered.warnings()).isEmpty();
}
@Test
void analysisReportsEveryUsageSiteSeparatelyBecauseAltIsPerUsage() {
ContentAnalyzerPort.ContentAnalysis analysis =
renderer.analyze(
"""
:::evidence key="flow" alt="첫 번째"
:::evidence key="flow" alt=""
:::mermaid
:::
""");
assertThat(analysis.assetUsages())
.extracting(ContentAnalyzerPort.AssetUsage::assetKey)
.containsExactly("flow", "flow");
assertThat(analysis.assetUsages().get(0).alt()).isEqualTo("첫 번째");
assertThat(analysis.assetUsages().get(1).alt()).isEmpty();
assertThat(analysis.unsupportedDirectives()).containsExactly("mermaid");
}
}
@@ -26,6 +26,12 @@ dependencies {
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Studio 편집본의 jsonb 컬럼(reference_detail.rules/examples, open_question.options,
// project_decision.consequences, applies_to/excluded_scope)을 읽고 쓰려면 이 모듈에 JSON 매퍼가
// 필요하다. Jackson 2 databind 가 data-jpa 경유로 이미 classpath 에 딸려오지만 그건 선언하지 않은
// 우연한 가용성이고, 이 저장소는 dependency locking 을 쓴다 — 앱의 다른 계층과 같은
// Jackson 3(tools.jackson)을 명시적으로 선언한다.
implementation 'org.springframework.boot:spring-boot-starter-jackson'
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
implementation 'org.springframework.integration:spring-integration-jdbc'
@@ -117,6 +123,14 @@ def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTe
'postgresqlTechLogCatalogQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
// 슬라이스 2~5: Studio 영속 경로 전체(편집본 4종 왕복, 낙관적 잠금, union 목록, 의존 해석,
// validation/preview artifact, 게시 20단계, 게시 취소, Asset)를 실제 PostgreSQL 위에서 돌린다.
// 표준 check 는 Testcontainers 를 돌리지 않으므로, 이 태스크가 없으면 그 SQL 은 한 번도 실행되지
// 않은 채로 빌드가 통과한다.
def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogStudioPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
@@ -152,7 +152,7 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlInt
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -163,7 +163,7 @@ org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegr
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -205,7 +205,7 @@ org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspat
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,91 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code studio_preview} 접근.
*
* <p>{@code render_model}은 렌더러가 만든 계약 모양 그대로를 문자열로 저장하고 그대로 돌려준다 — 중간에서 파싱했다 다시 직렬화하면 사용자가 확인한 화면과
* 저장된 화면이 미묘하게 달라질 수 있고, 게시 시점에 그대로 snapshot 으로 옮겨야 하는 값이라 그 차이가 공개 결과까지 간다.
*/
@Repository
public class JdbcPreviewArtifactAdapter implements PreviewArtifactPort {
private final JdbcClient jdbcClient;
public JdbcPreviewArtifactAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Optional<PublicPreviewView> latestFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
selectColumns()
+ " WHERE source_kind = :kind AND source_id = :id"
+ " ORDER BY created_at DESC LIMIT 1")
.param("kind", kind.name())
.param("id", documentId)
.query(JdbcPreviewArtifactAdapter::mapRow)
.optional();
}
@Override
public Optional<PublicPreviewView> findById(UUID previewId) {
return jdbcClient
.sql(selectColumns() + " WHERE preview_id = :id")
.param("id", previewId)
.query(JdbcPreviewArtifactAdapter::mapRow)
.optional();
}
@Override
public PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal) {
jdbcClient
.sql(
"INSERT INTO studio_preview (preview_id, source_kind, source_id, source_version,"
+ " validation_id, dependency_revision, render_model, created_at, expires_at,"
+ " created_by)"
+ " VALUES (:previewId, :kind, :sourceId, :sourceVersion, :validationId,"
+ " :dependencyRevision, CAST(:renderModel AS jsonb), :createdAt, :expiresAt,"
+ " :principal)")
.param("previewId", preview.previewId())
.param("kind", kind.name())
.param("sourceId", preview.documentId())
.param("sourceVersion", preview.previewVersion())
.param("validationId", preview.validationId())
.param("dependencyRevision", preview.dependencyRevision())
.param("renderModel", preview.renderModelJson())
.param("createdAt", Timestamp.from(preview.createdAt()))
.param("expiresAt", Timestamp.from(preview.expiresAt()))
.param("principal", principal)
.update();
return preview;
}
private static String selectColumns() {
return "SELECT preview_id, source_id, source_version, validation_id, dependency_revision,"
+ " render_model, created_at, expires_at FROM studio_preview";
}
private static PublicPreviewView mapRow(ResultSet rs, int rowNum) throws SQLException {
return new PublicPreviewView(
rs.getObject("preview_id", UUID.class),
rs.getObject("source_id", UUID.class),
rs.getLong("source_version"),
rs.getObject("validation_id", UUID.class),
rs.getString("dependency_revision"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("expires_at").toInstant(),
rs.getString("render_model"));
}
}
@@ -0,0 +1,143 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ValidationIssueView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationSeverity;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.shared.error.MappingException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* {@code studio_validation} 접근. 검증 결과는 일급 artifact이며 실행 후 버리지 않는다(spec §7.3).
*
* <p>{@code studio_validation}에는 UPDATE가 없다. 재검증은 새 행이며, 이전 결과는 "그때 이 버전은 이런 상태였다"는 사실로 남는다 — 덮어쓰면
* 게시 시점에 어떤 근거로 통과했는지 되짚을 수 없다.
*/
@Repository
public class JdbcValidationArtifactAdapter implements ValidationArtifactPort {
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
public JdbcValidationArtifactAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
}
@Override
public Optional<ValidationReportView> latestFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
selectColumns()
+ " WHERE source_kind = :kind AND source_id = :id"
+ " ORDER BY validated_at DESC LIMIT 1")
.param("kind", kind.name())
.param("id", documentId)
.query(this::mapRow)
.optional();
}
@Override
public Optional<ValidationReportView> findById(UUID validationId) {
return jdbcClient
.sql(selectColumns() + " WHERE validation_id = :id")
.param("id", validationId)
.query(this::mapRow)
.optional();
}
@Override
public ValidationReportView save(RecordKind kind, ValidationReportView report, String principal) {
jdbcClient
.sql(
"INSERT INTO studio_validation (validation_id, source_kind, source_id,"
+ " validated_version, status, issues, dependency_revision, validated_at,"
+ " valid_until, created_by)"
+ " VALUES (:validationId, :kind, :sourceId, :validatedVersion, :status,"
+ " CAST(:issues AS jsonb), :dependencyRevision, :validatedAt, :validUntil,"
+ " :principal)")
.param("validationId", report.validationId())
.param("kind", kind.name())
.param("sourceId", report.documentId())
.param("validatedVersion", report.validatedVersion())
.param("status", report.status().name())
.param("issues", issuesToJson(report.issues()))
.param("dependencyRevision", report.dependencyRevision())
.param("validatedAt", Timestamp.from(report.validatedAt()))
.param("validUntil", Timestamp.from(report.validUntil()))
.param("principal", principal)
.update();
return report;
}
private static String selectColumns() {
return "SELECT validation_id, source_id, validated_version, status, issues,"
+ " dependency_revision, validated_at, valid_until FROM studio_validation";
}
private ValidationReportView mapRow(ResultSet rs, int rowNum) throws SQLException {
return new ValidationReportView(
rs.getObject("validation_id", UUID.class),
rs.getObject("source_id", UUID.class),
rs.getLong("validated_version"),
ValidationStatus.valueOf(rs.getString("status")),
issuesFromJson(rs.getString("issues")),
rs.getTimestamp("validated_at").toInstant(),
rs.getTimestamp("valid_until").toInstant(),
rs.getString("dependency_revision"));
}
private String issuesToJson(List<ValidationIssueView> issues) {
ArrayNode array = objectMapper.createArrayNode();
for (ValidationIssueView issue : issues) {
ObjectNode node = array.addObject();
node.put("code", issue.code());
node.put("severity", issue.severity().name());
node.put("path", issue.path());
node.put("message", issue.message());
}
try {
return objectMapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise validation issues", e);
}
}
private List<ValidationIssueView> issuesFromJson(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode array = objectMapper.readTree(json);
if (!array.isArray()) {
return List.of();
}
return array
.valueStream()
.map(
node ->
new ValidationIssueView(
node.path("code").asString(""),
ValidationSeverity.valueOf(node.path("severity").asString("ERROR")),
node.path("path").asString(""),
node.path("message").asString("")))
.toList();
} catch (JacksonException e) {
throw new MappingException("failed to read validation issues", e);
}
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 의존 상태 해시를 {@link StudioDocumentSql}의 정의로 계산한다.
*
* <p>목록이 쓰는 SQL과 <b>같은 식</b>을 쓴다. 여기서 다른 식을 쓰면 상세 화면이 계산한 값과 목록이 계산한 값이 달라져, 검증을 막 통과한 문서가 목록에서는
* "다시 검증하라"로 보인다.
*/
@Repository
public class JdbcDependencyRevisionAdapter implements DependencyRevisionPort {
private final JdbcClient jdbcClient;
public JdbcDependencyRevisionAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public String revisionFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT dependency_revision FROM studio_document WHERE id = :id")
.param("id", documentId)
.query(String.class)
.optional()
.orElseThrow(
() ->
StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"cannot compute a dependency revision for unknown document " + documentId));
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 현재 게시 상태 조회. {@code publication}은 (source_kind, source_id)당 한 행이다. */
@Repository
public class JdbcPublicationQueryAdapter implements PublicationQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicationQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Optional<PublicationAggregateView> currentFor(UUID documentId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at"
+ " FROM publication WHERE source_id = :id")
.param("id", documentId)
.query(
(rs, rowNum) ->
new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("published_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("updated_at").toInstant()))
.optional();
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DashboardTotalsView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 대시보드 집계. 목록과 <b>같은</b> {@code studio_document} 정의를 쓴다 — 대시보드가 "게시 준비됨"이라고 센 문서와 목록에서 그 필터로 나오는
* 문서가 달라지면 숫자를 믿을 수 없다.
*/
@Repository
public class JdbcStudioDashboardQueryAdapter implements StudioDashboardQueryPort {
private final JdbcClient jdbcClient;
public JdbcStudioDashboardQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public List<DocumentSummaryView> topByNextAction(List<NextAction> actions, int limit) {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT * FROM studio_document WHERE next_action IN (:actions)"
+ " ORDER BY updated_at DESC, id DESC LIMIT :limit")
.param("actions", actions.stream().map(Enum::name).toList())
.param("limit", limit)
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
.list();
}
@Override
public DashboardTotalsView totals() {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT count(*) AS documents,"
+ " count(*) FILTER (WHERE next_action IN ('VALIDATE', 'FIX_VALIDATION'))"
+ " AS needs_validation,"
+ " count(*) FILTER (WHERE next_action = 'PUBLISH') AS ready_to_publish,"
+ " (SELECT count(*) FROM publication WHERE status = 'PUBLISHED') AS publications"
+ " FROM studio_document")
.query(
(rs, rowNum) ->
new DashboardTotalsView(
rs.getInt("documents"),
rs.getInt("needs_validation"),
rs.getInt("ready_to_publish"),
rs.getInt("publications")))
.single();
}
}
@@ -0,0 +1,225 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.PublicPaths;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 편집본이 의존하는 바깥 상태를 한 번에 읽는다. 검증과 렌더링이 같은 결과를 공유하도록 조회는 이 한 곳에서만 한다. */
@Repository
public class JdbcStudioDependencyResolverAdapter implements StudioDependencyResolverPort {
private final JdbcClient jdbcClient;
public JdbcStudioDependencyResolverAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Resolved resolve(WorkingCopyView document, Set<String> referencedAssetKeys) {
UUID topicId = document.base().topicId();
UUID projectId = document.base().projectId();
DisplayTargetView topic = topicId == null ? null : findTopic(topicId);
DisplayTargetView project = projectId == null ? null : findProject(projectId);
List<ResolvedRelationView> relations = new ArrayList<>();
List<UUID> missingTargets = new ArrayList<>();
resolveRelations(document, relations, missingTargets);
Map<String, ResolvedAssetView> assetsByKey = new LinkedHashMap<>();
Map<String, String> assetStatusByKey = new LinkedHashMap<>();
resolveAssets(referencedAssetKeys, assetsByKey, assetStatusByKey);
String projectSlug = projectId == null ? null : findProjectSlug(projectId);
String publicPath = PublicPaths.forKind(document.kind(), document.base().slug(), projectSlug);
return new Resolved(
topic,
topicId != null && topic == null,
project,
projectId != null && project == null,
relations,
missingTargets,
assetsByKey,
assetStatusByKey,
resolveEvidenceTarget(document),
publicPath,
findSlugOwner(document));
}
private DisplayTargetView findTopic(UUID topicId) {
return jdbcClient
.sql("SELECT id, name, slug FROM topic WHERE id = :id")
.param("id", topicId)
.query(
(rs, rowNum) ->
new DisplayTargetView(
rs.getObject("id", UUID.class),
rs.getString("name"),
"/topics/" + rs.getString("slug")))
.optional()
.orElse(null);
}
private DisplayTargetView findProject(UUID projectId) {
return jdbcClient
.sql("SELECT id, name, slug FROM project WHERE id = :id")
.param("id", projectId)
.query(
(rs, rowNum) ->
new DisplayTargetView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("slug") == null ? null : "/projects/" + rs.getString("slug")))
.optional()
.orElse(null);
}
private String findProjectSlug(UUID projectId) {
return jdbcClient
.sql("SELECT slug FROM project WHERE id = :id")
.param("id", projectId)
.query(String.class)
.optional()
.orElse(null);
}
/**
* 관계 대상은 네 유형 어디에도 있을 수 있고 프로젝트일 수도 있다(계약 {@code ResolvedRelation.targetKind} 가 {@code
* RecordKind} 보다 하나 넓다). UNION 으로 한 번에 찾는다.
*/
private void resolveRelations(
WorkingCopyView document, List<ResolvedRelationView> resolved, List<UUID> missing) {
for (RelationView relation : document.base().relations()) {
if (relation.targetId() == null) {
continue;
}
TargetRow row = findTarget(relation.targetId());
if (row == null) {
missing.add(relation.targetId());
continue;
}
resolved.add(
new ResolvedRelationView(
relation.id(),
relation.targetId(),
row.kind(),
row.title(),
row.publicPath(),
relation.reason() == null ? "" : relation.reason(),
relation.order()));
}
}
private record TargetRow(String kind, String title, String publicPath) {}
private TargetRow findTarget(UUID targetId) {
return jdbcClient
.sql(
"SELECT document_type AS kind, title, slug, NULL::text AS project_slug FROM document"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'QUESTION', question, slug, NULL::text FROM open_question"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT', name, slug, NULL::text FROM project"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT_DECISION', pd.title, pd.slug, p.slug"
+ " FROM project_decision pd LEFT JOIN project p ON p.id = pd.project_id"
+ " WHERE pd.id = :id")
.param("id", targetId)
.query(
(rs, rowNum) -> {
String kind = rs.getString("kind");
String slug = rs.getString("slug");
String publicPath =
"PROJECT".equals(kind)
? (slug == null ? null : "/projects/" + slug)
: PublicPaths.forKind(
RecordKind.valueOf(kind), slug, rs.getString("project_slug"));
return new TargetRow(kind, rs.getString("title"), publicPath);
})
.optional()
.orElse(null);
}
private void resolveAssets(
Set<String> keys, Map<String, ResolvedAssetView> assets, Map<String, String> statuses) {
if (keys.isEmpty()) {
return;
}
jdbcClient
.sql(
"SELECT id, asset_key, content_type, object_key, width, height, decorative,"
+ " management_status FROM asset WHERE asset_key IN (:keys)")
.param("keys", keys)
.query(
(rs, rowNum) -> {
String key = rs.getString("asset_key");
statuses.put(key, rs.getString("management_status"));
assets.put(
key,
new ResolvedAssetView(
rs.getObject("id", UUID.class),
key,
rs.getString("content_type"),
// 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다
// (설계 05장 §3.1).
"/media/" + rs.getString("id"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getBoolean("decorative")));
return key;
})
.list();
}
private DisplayTargetView resolveEvidenceTarget(WorkingCopyView document) {
if (!(document instanceof WorkingCopyView.QuestionWorkingCopyView value)
|| value.resolution() == null
|| value.resolution().evidenceTargetId() == null) {
return null;
}
TargetRow row = findTarget(value.resolution().evidenceTargetId());
return row == null
? null
: new DisplayTargetView(
value.resolution().evidenceTargetId(), row.title(), row.publicPath());
}
/**
* 같은 공개 경로 이름공간(= 같은 유형)에서 이 slug 를 이미 쓰는 다른 기록. 유형이 다르면 경로 접두사가 달라 충돌하지 않는다({@code /cases/x} 와
* {@code /questions/x} 는 다른 주소다).
*/
private UUID findSlugOwner(WorkingCopyView document) {
String slug = document.base().slug();
if (slug == null || slug.isBlank()) {
return null;
}
String sql =
switch (document.kind()) {
case CASE, REFERENCE ->
"SELECT id FROM document WHERE slug = :slug AND document_type = :kind AND id <> :id";
case QUESTION -> "SELECT id FROM open_question WHERE slug = :slug AND id <> :id";
case PROJECT_DECISION ->
"SELECT id FROM project_decision WHERE slug = :slug AND id <> :id";
};
var spec = jdbcClient.sql(sql).param("slug", slug).param("id", document.id());
if (document.kind() == RecordKind.CASE || document.kind() == RecordKind.REFERENCE) {
spec = spec.param("kind", document.kind().name());
}
return spec.query(UUID.class).optional().orElse(null);
}
}
@@ -0,0 +1,141 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code listStudioDocuments}. 네 유형이 서로 다른 테이블에 살기 때문에 공통 repository 대신 union projection을 돌린다(계약
* 설명, spec §8.3).
*
* <p>정렬 키에 항상 {@code id}를 붙인다. {@code updated_at}만으로 자르면 같은 시각의 행들이 페이지 경계에서 중복되거나 누락된다 — 대량 저장 직후에
* 실제로 일어나는 일이다.
*/
@Repository
public class JdbcStudioDocumentQueryAdapter implements StudioDocumentQueryPort {
private final JdbcClient jdbcClient;
public JdbcStudioDocumentQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public DocumentPageView list(ListDocumentsQuery query) {
StringBuilder sql = new StringBuilder(StudioDocumentSql.documentProjectionCte());
sql.append(" SELECT * FROM studio_document WHERE 1 = 1");
Map<String, Object> params = new java.util.HashMap<>();
if (query.kind() != null) {
sql.append(" AND kind = :kind");
params.put("kind", query.kind().name());
}
if (query.projectId() != null) {
sql.append(" AND project_id = :projectId");
params.put("projectId", query.projectId());
}
if (query.nextAction() != null) {
sql.append(" AND next_action = :nextAction");
params.put("nextAction", query.nextAction().name());
}
if (query.publicationStatus() != null) {
sql.append(publicationStatusPredicate());
params.put("publicationStatus", query.publicationStatus().name());
}
if (query.query() != null && !query.query().isBlank()) {
sql.append(" AND lower(title) LIKE :titlePattern");
params.put("titlePattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
}
appendCursorPredicate(sql, params, query);
sql.append(orderBy(query.sort()));
// limit + 1 을 읽어 "다음 쪽이 있는가"를 별도 count 없이 판정한다.
sql.append(" LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
boolean hasMore = rows.size() > query.limit();
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
List<DocumentSummaryView> items = new ArrayList<>(page.size());
for (Row row : page) {
items.add(row.summary());
}
return new DocumentPageView(
items, hasMore ? cursorPayload(page.getLast(), query.sort()) : null);
}
/**
* 게시 이력이 없는 문서는 {@code publication} 행 자체가 없다 — {@code NEVER_PUBLISHED}는 "행이 없음"이지 특정 status 값이
* 아니다.
*/
private static String publicationStatusPredicate() {
return " AND ((:publicationStatus = 'NEVER_PUBLISHED' AND publication_status IS NULL)"
+ " OR publication_status = :publicationStatus)";
}
private static String orderBy(DocumentSort sort) {
return switch (sort) {
case UPDATED_DESC -> " ORDER BY updated_at DESC, id DESC";
case UPDATED_ASC -> " ORDER BY updated_at ASC, id ASC";
case TITLE_ASC -> " ORDER BY title ASC, id ASC";
};
}
private static void appendCursorPredicate(
StringBuilder sql, Map<String, Object> params, ListDocumentsQuery query) {
DocumentCursorPosition position = query.position();
if (position == null) {
return;
}
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 정렬이 늘면 컴파일러가
// 여기서 막아 준다(문이면 커서 조건 없이 조용히 첫 페이지를 다시 준다).
String predicate =
switch (query.sort()) {
case UPDATED_DESC -> {
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
yield " AND (updated_at, id) < (:cursorUpdatedAt, :cursorId)";
}
case UPDATED_ASC -> {
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
yield " AND (updated_at, id) > (:cursorUpdatedAt, :cursorId)";
}
case TITLE_ASC -> {
params.put("cursorTitle", position.title());
yield " AND (title, id) > (:cursorTitle, :cursorId)";
}
};
params.put("cursorId", position.id());
sql.append(predicate);
}
/** 다음 쪽의 시작 위치. web 계층이 이 값을 서명해 opaque cursor 로 만든다. */
private static String cursorPayload(Row last, DocumentSort sort) {
return switch (sort) {
case UPDATED_DESC, UPDATED_ASC -> last.updatedAt().toInstant() + "|" + last.summary().id();
case TITLE_ASC -> last.title() + "|" + last.summary().id();
};
}
/** 커서 계산에 필요한 정렬 키만 요약과 함께 들고 다닌다. */
private record Row(DocumentSummaryView summary, java.sql.Timestamp updatedAt, String title) {}
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new Row(
StudioDocumentRowMapper.read(rs), rs.getTimestamp("updated_at"), rs.getString("title"));
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
/**
* {@code studio_document} 한 행 → 계약 {@code DocumentSummary}.
*
* <p>목록·대시보드·게시 이력이 모두 이 매퍼를 쓴다. 화면마다 따로 만들면 같은 문서가 화면마다 다른 {@code nextAction} 이나 {@code
* hasUnpublishedChanges} 로 보인다.
*/
public final class StudioDocumentRowMapper {
private StudioDocumentRowMapper() {}
public static DocumentSummaryView read(ResultSet rs) throws SQLException {
UUID projectId = rs.getObject("project_id", UUID.class);
String projectSlug = rs.getString("project_slug");
Long publishedVersion = (Long) rs.getObject("published_version");
long version = rs.getLong("version");
String publicationStatus = rs.getString("publication_status");
return new DocumentSummaryView(
rs.getObject("id", UUID.class),
rs.getString("title"),
RecordKind.valueOf(rs.getString("kind")),
projectId == null
? null
: new DisplayTargetView(
projectId,
rs.getString("project_name"),
projectSlug == null ? null : "/projects/" + projectSlug),
rs.getTimestamp("updated_at").toInstant(),
publicationStatus == null
? PublicationStatusView.NEVER_PUBLISHED
: PublicationStatusView.valueOf(publicationStatus),
publishedVersion,
// 계약: "게시 취소 상태에서도 과거 publishedVersion 과 비교한다."
publishedVersion != null && publishedVersion != version,
NextAction.valueOf(rs.getString("next_action")));
}
}
@@ -0,0 +1,130 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
/**
* Studio 문서 union projection의 SQL 정의. 목록·대시보드·단건 조회가 <b>같은</b> 정의를 쓴다.
*
* <p>{@code dependencyRevision}과 {@code nextAction}은 저장하지 않고 계산하는 값이다(spec §6.3, §7.3). 계산식이 SQL 한
* 곳과 Java 한 곳에 따로 있으면 목록의 {@code nextAction}과 상세의 {@code nextAction}이 조용히 갈라진다 — 사용자에게는 "목록에서는
* 게시하라더니 열어 보니 검증하라"는 모순으로 보인다. 그래서 계산은 여기 SQL 한 벌만 둔다.
*/
public final class StudioDocumentSql {
/**
* 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 — 올리는 순간 기존 validation/preview가 전부 stale이 되어 다시 검증·미리보기를 거치게 된다.
*/
public static final String RENDERER_CONTRACT_VERSION = "1";
private StudioDocumentSql() {}
/**
* 네 유형을 하나의 행 모양으로 모으고, 그 위에 의존 상태 해시와 최신 artifact를 붙인 CTE 묶음.
*
* <p>마지막 CTE {@code studio_document}가 최종 결과이며 컬럼은 다음과 같다.
*
* <pre>{@code
* id kind title version updated_at topic_id project_id project_name project_slug
* dependency_revision
* validation_version validation_status validation_valid_until validation_revision
* preview_version preview_expires_at preview_revision
* publication_status published_version
* next_action
* }</pre>
*/
public static String documentProjectionCte() {
return """
WITH studio_source AS (
SELECT d.id,
d.document_type AS kind,
d.title,
d.version,
d.updated_at,
d.primary_topic_id AS topic_id,
(SELECT l.project_id FROM project_document_link l
WHERE l.document_id = d.id AND l.relation_type = 'PRIMARY') AS project_id
FROM document d
UNION ALL
SELECT q.id, 'QUESTION', q.question, q.version, q.updated_at, q.primary_topic_id,
(SELECT l.project_id FROM project_question_link l
WHERE l.question_id = q.id AND l.relation_type = 'PRIMARY')
FROM open_question q
UNION ALL
SELECT pd.id, 'PROJECT_DECISION', pd.title, pd.version, pd.updated_at,
pd.primary_topic_id, pd.project_id
FROM project_decision pd
),
studio_dependency AS (
SELECT s.id,
md5(concat_ws('|',
coalesce(t.id::text || ':' || t.version::text, '-'),
coalesce(p.id::text || ':' || p.version::text, '-'),
coalesce((
SELECT md5(string_agg(rel.sig, ',' ORDER BY rel.sig))
FROM (
SELECT r.target_id::text || ':'
|| coalesce(rd.version, rq.version, rp.version, 0)::text AS sig
FROM studio_relation r
LEFT JOIN document rd ON rd.id = r.target_id
LEFT JOIN open_question rq ON rq.id = r.target_id
LEFT JOIN project_decision rp ON rp.id = r.target_id
WHERE r.source_kind = s.kind AND r.source_id = s.id
) rel
), '-'),
'renderer:%s'
)) AS dependency_revision
FROM studio_source s
LEFT JOIN topic t ON t.id = s.topic_id
LEFT JOIN project p ON p.id = s.project_id
),
studio_latest_validation AS (
SELECT DISTINCT ON (v.source_id)
v.source_id, v.validation_id, v.validated_version, v.status,
v.valid_until, v.dependency_revision
FROM studio_validation v
ORDER BY v.source_id, v.validated_at DESC
),
studio_latest_preview AS (
SELECT DISTINCT ON (pv.source_id)
pv.source_id, pv.preview_id, pv.source_version, pv.expires_at,
pv.dependency_revision
FROM studio_preview pv
ORDER BY pv.source_id, pv.created_at DESC
),
studio_document AS (
SELECT s.id, s.kind, s.title, s.version, s.updated_at, s.topic_id, s.project_id,
pr.name AS project_name, pr.slug AS project_slug,
dep.dependency_revision,
val.validated_version AS validation_version,
val.status AS validation_status,
val.valid_until AS validation_valid_until,
val.dependency_revision AS validation_revision,
prev.source_version AS preview_version,
prev.expires_at AS preview_expires_at,
prev.dependency_revision AS preview_revision,
pub.status AS publication_status,
pub.published_version,
CASE
WHEN s.title IS NULL OR btrim(s.title) = '' THEN 'CONTINUE_EDITING'
WHEN val.validated_version IS NULL
OR val.validated_version <> s.version
OR val.dependency_revision IS DISTINCT FROM dep.dependency_revision
OR val.valid_until <= now() THEN 'VALIDATE'
WHEN val.status = 'INVALID' THEN 'FIX_VALIDATION'
WHEN prev.source_version IS NULL
OR prev.source_version <> s.version
OR prev.dependency_revision IS DISTINCT FROM dep.dependency_revision
OR prev.expires_at <= now() THEN 'CREATE_PREVIEW'
WHEN pub.status IS DISTINCT FROM 'PUBLISHED'
OR pub.published_version <> s.version THEN 'PUBLISH'
ELSE 'NONE'
END AS next_action
FROM studio_source s
JOIN studio_dependency dep ON dep.id = s.id
LEFT JOIN project pr ON pr.id = s.project_id
LEFT JOIN studio_latest_validation val ON val.source_id = s.id
LEFT JOIN studio_latest_preview prev ON prev.source_id = s.id
LEFT JOIN publication pub ON pub.source_id = s.id AND pub.source_kind = s.kind
)
"""
.formatted(RENDERER_CONTRACT_VERSION);
}
}
@@ -0,0 +1,244 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.model.AssetUsageView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code asset} 메타데이터 접근.
*
* <p>{@code usageCount} 는 {@code asset_reference} 에서 센다 — Asset 행에 캐시해 두면 참조가 바뀔 때마다 두 곳을 맞춰야 하고,
* 어긋나면 "쓰이고 있는데 삭제 가능"으로 보인다.
*/
@Repository
public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort {
private final JdbcClient jdbcClient;
public JdbcAssetRepositoryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public AssetPageView list(ListAssetsQuery query) {
StringBuilder sql = new StringBuilder(selectColumns() + " WHERE 1 = 1");
Map<String, Object> params = new HashMap<>();
if (query.kind() != null) {
sql.append(" AND a.asset_kind = :kind");
params.put("kind", query.kind().name());
}
if (query.managementStatus() != null) {
sql.append(" AND a.management_status = :status");
params.put("status", query.managementStatus().name());
}
if (query.query() != null && !query.query().isBlank()) {
sql.append(" AND (lower(a.asset_key) LIKE :pattern OR lower(a.original_name) LIKE :pattern)");
params.put("pattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
}
if (query.beforeCreatedAt() != null && query.beforeId() != null) {
sql.append(" AND (a.created_at, a.id) < (:before, :beforeId)");
params.put("before", Timestamp.from(query.beforeCreatedAt()));
params.put("beforeId", query.beforeId());
}
sql.append(" ORDER BY a.created_at DESC, a.id DESC LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<AssetView> rows = spec.query(JdbcAssetRepositoryAdapter::mapAsset).list();
boolean hasMore = rows.size() > query.limit();
List<AssetView> page = hasMore ? rows.subList(0, query.limit()) : rows;
String nextCursor = hasMore ? page.getLast().createdAt() + "|" + page.getLast().id() : null;
return new AssetPageView(page, nextCursor);
}
@Override
public Optional<AssetView> find(UUID assetId) {
return jdbcClient
.sql(selectColumns() + " WHERE a.id = :id")
.param("id", assetId)
.query(JdbcAssetRepositoryAdapter::mapAsset)
.optional();
}
@Override
public Optional<AssetDetailView> findDetail(UUID assetId) {
return find(assetId)
.map(
asset -> new AssetDetailView(asset, usagesOf(assetId), hasPublicationHistory(assetId)));
}
@Override
public AssetView create(NewAsset asset, String principal) {
jdbcClient
.sql(
"INSERT INTO asset (id, asset_key, asset_kind, management_status, object_key,"
+ " original_name, display_name, content_type, size_bytes, width, height,"
+ " checksum_sha256, alt_text, decorative, version, created_by, updated_by)"
+ " VALUES (:id, :assetKey, :kind, :status, :objectKey, :originalName,"
+ " :originalName, :contentType, :size, :width, :height, :checksum, :altText,"
// 계약의 Asset.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면 생성 직후
// 응답이 계약을 위반하고, 클라이언트가 보내는 expectedVersion 도 맞출 수 없다.
+ " :decorative, 1, :principal, :principal)")
.param("id", asset.id())
.param("assetKey", asset.assetKey())
.param("kind", asset.kind().name())
.param("status", asset.managementStatus().name())
.param("objectKey", asset.objectKey())
.param("originalName", asset.originalFilename())
.param("contentType", asset.mediaType())
.param("size", asset.byteSize())
.param("width", asset.width())
.param("height", asset.height())
.param("checksum", asset.checksumSha256())
.param("altText", asset.altText())
.param("decorative", asset.decorative())
.param("principal", principal)
.update();
return find(asset.id()).orElseThrow();
}
@Override
public Optional<AssetView> update(
UUID assetId,
long expectedVersion,
AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
AssetManagementStatusView managementStatus,
String principal) {
int updated =
jdbcClient
.sql(
"UPDATE asset SET"
// 보내지 않은 필드는 그대로 둔다 — PUT 이지만 계약의 UpdateAssetCommand 는
// expectedVersion 외 전부 optional 이라 부분 갱신 의미다.
+ " asset_kind = COALESCE(:kind, asset_kind),"
+ " alt_text = CASE WHEN :altTextProvided THEN :altText ELSE alt_text END,"
+ " decorative = COALESCE(:decorative, decorative),"
+ " management_status = COALESCE(:status, management_status),"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("kind", kind == null ? null : kind.name())
.param("altTextProvided", altTextProvided)
.param("altText", altText)
.param("decorative", decorative)
.param("status", managementStatus == null ? null : managementStatus.name())
.param("principal", principal)
.param("id", assetId)
.param("expectedVersion", expectedVersion)
.update();
return updated == 0 ? Optional.empty() : find(assetId);
}
@Override
public Optional<String> findObjectKey(UUID assetId) {
return jdbcClient
.sql("SELECT object_key FROM asset WHERE id = :id")
.param("id", assetId)
.query(String.class)
.optional();
}
@Override
public void delete(UUID assetId) {
jdbcClient.sql("DELETE FROM asset WHERE id = :id").param("id", assetId).update();
}
private static String selectColumns() {
return "SELECT a.id, a.asset_key, a.asset_kind, a.management_status, a.original_name,"
+ " a.content_type, a.size_bytes, a.width, a.height, a.alt_text, a.decorative,"
+ " a.version, a.created_at, a.updated_at, a.first_published_at,"
+ " (SELECT count(*) FROM asset_reference r WHERE r.asset_id = a.id) AS usage_count"
+ " FROM asset a";
}
private static AssetView mapAsset(ResultSet rs, int rowNum) throws SQLException {
return new AssetView(
rs.getObject("id", UUID.class),
rs.getString("asset_key"),
AssetKindView.valueOf(rs.getString("asset_kind")),
rs.getString("content_type"),
rs.getString("original_name"),
rs.getLong("size_bytes"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getString("alt_text"),
rs.getBoolean("decorative"),
AssetManagementStatusView.valueOf(rs.getString("management_status")),
// 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다.
"/media/" + rs.getString("id"),
rs.getInt("usage_count"),
rs.getLong("version"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("updated_at").toInstant());
}
private List<AssetUsageView> usagesOf(UUID assetId) {
return jdbcClient
.sql(
"SELECT r.owner_id, r.owner_type, r.reference_scope,"
+ " COALESCE(d.title, q.question, pd.title) AS title,"
+ " COALESCE(d.document_type, 'QUESTION') AS document_kind"
+ " FROM asset_reference r"
+ " LEFT JOIN document d ON d.id = r.owner_id"
+ " LEFT JOIN open_question q ON q.id = r.owner_id"
+ " LEFT JOIN project_decision pd ON pd.id = r.owner_id"
+ " WHERE r.asset_id = :id")
.param("id", assetId)
.query(
(rs, rowNum) ->
new AssetUsageView(
rs.getObject("owner_id", UUID.class),
kindOf(rs.getString("owner_type"), rs.getString("document_kind")),
rs.getString("title") == null ? "(제목 없음)" : rs.getString("title"),
"PUBLISHED".equals(rs.getString("reference_scope"))))
.list();
}
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
private static RecordKind kindOf(String ownerType, String documentKind) {
return switch (ownerType) {
case "DOCUMENT" -> RecordKind.valueOf(documentKind);
case "DECISION" -> RecordKind.PROJECT_DECISION;
default -> RecordKind.QUESTION;
};
}
/** 한 번이라도 공개된 적이 있으면 hard delete 를 금지한다(계약 {@code AssetDetail} 설명). */
private boolean hasPublicationHistory(UUID assetId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT (first_published_at IS NOT NULL"
+ " OR EXISTS (SELECT 1 FROM asset_reference r"
+ " WHERE r.asset_id = :id AND r.reference_scope = 'PUBLISHED'))"
+ " FROM asset WHERE id = :id")
.param("id", assetId)
.query(Boolean.class)
.optional()
.orElse(Boolean.FALSE));
}
}
@@ -0,0 +1,192 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentRowMapper;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentSql;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.PublicationActionView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 게시 이력 조회. 이력은 항상 최신순이며 정렬 선택지가 없다 — 계약에도 정렬 파라미터가 없다.
*
* <p>목록의 {@code document} 요약은 {@link StudioDocumentSql} 의 같은 정의에서 가져온다. 여기서 따로 만들면 목록 화면과 이력 화면의
* {@code nextAction} 이 갈라진다.
*/
@Repository
public class JdbcPublicationHistoryQueryAdapter implements PublicationHistoryQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicationHistoryQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public PublicationPageView list(ListPublicationsQuery query) {
StringBuilder sql =
new StringBuilder(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available,"
+ " p.status, p.publication_revision, p.latest_event_id, p.public_path,"
+ " p.updated_at AS publication_updated_at, p.published_version AS current_version"
+ " FROM publication_event e"
+ " JOIN publication p ON p.publication_id = e.publication_id"
+ " LEFT JOIN publication_snapshot s"
+ " ON s.publication_event_id = e.publication_event_id"
+ " WHERE 1 = 1");
Map<String, Object> params = new HashMap<>();
if (query.type() != null) {
sql.append(" AND e.event_type = :type");
params.put("type", query.type().name());
}
if (query.beforeOccurredAt() != null && query.beforeEventId() != null) {
sql.append(" AND (e.occurred_at, e.publication_event_id) < (:before, :beforeId)");
params.put("before", Timestamp.from(query.beforeOccurredAt()));
params.put("beforeId", query.beforeEventId());
}
sql.append(" ORDER BY e.occurred_at DESC, e.publication_event_id DESC LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
boolean hasMore = rows.size() > query.limit();
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
Map<UUID, DocumentSummaryView> summaries = summariesFor(page);
List<PublicationListItemView> items = new ArrayList<>(page.size());
for (Row row : page) {
items.add(
new PublicationListItemView(
row.event(),
row.publication(),
summaries.get(row.event().documentId()),
actionsFor(row)));
}
String nextCursor =
hasMore
? page.getLast().event().occurredAt()
+ "|"
+ page.getLast().event().publicationEventId()
: null;
return new PublicationPageView(items, nextCursor);
}
@Override
public Optional<PublicationSnapshotView> findSnapshot(UUID publicationEventId) {
return jdbcClient
.sql(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " true AS snapshot_available,"
+ " s.render_model, s.content_format_version, s.renderer_contract_version"
+ " FROM publication_snapshot s"
+ " JOIN publication_event e"
+ " ON e.publication_event_id = s.publication_event_id"
+ " WHERE s.publication_event_id = :id")
.param("id", publicationEventId)
.query(
(rs, rowNum) ->
new PublicationSnapshotView(
PublicationRowMappers.mapEvent(rs, rowNum),
rs.getString("render_model"),
rs.getString("content_format_version"),
rs.getString("renderer_contract_version")))
.optional();
}
@Override
public Optional<PublicationAggregateView> findById(UUID publicationId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at FROM publication"
+ " WHERE publication_id = :id")
.param("id", publicationId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.optional();
}
private Map<UUID, DocumentSummaryView> summariesFor(List<Row> rows) {
if (rows.isEmpty()) {
return Map.of();
}
List<UUID> ids = rows.stream().map(row -> row.event().documentId()).distinct().toList();
Map<UUID, DocumentSummaryView> summaries = new HashMap<>();
jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT * FROM studio_document WHERE id IN (:ids)")
.param("ids", ids)
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
.list()
.forEach(summary -> summaries.put(summary.id(), summary));
return summaries;
}
/**
* 계약 {@code PublicationListItem.availableActions}. 지금 상태에서 실제로 할 수 있는 것만 담는다 — 화면이 눌러도 실패할 버튼을
* 그리지 않게 하려는 값이다.
*/
private static List<PublicationActionView> actionsFor(Row row) {
List<PublicationActionView> actions = new ArrayList<>();
if (row.event().snapshotAvailable()) {
actions.add(PublicationActionView.VIEW_SNAPSHOT);
}
if (row.event().sourcePublishedEventId() != null) {
actions.add(PublicationActionView.VIEW_SOURCE_SNAPSHOT);
}
if (row.publication().status() == PublicationAggregateStatus.PUBLISHED
&& row.publication().latestEventId().equals(row.event().publicationEventId())) {
actions.add(PublicationActionView.UNPUBLISH);
}
return actions;
}
private record Row(PublicationEventView event, PublicationAggregateView publication) {}
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
PublicationEventView event =
new PublicationEventView(
rs.getObject("publication_event_id", UUID.class),
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationEventTypeView.valueOf(rs.getString("event_type")),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("published_version"),
rs.getObject("source_published_event_id", UUID.class),
rs.getBoolean("snapshot_available"));
PublicationAggregateView publication =
new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("current_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("publication_updated_at").toInstant());
return new Row(event, publication);
}
}
@@ -0,0 +1,514 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.shared.error.MappingException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* spec §7.5 의 게시 트랜잭션 10~19단계.
*
* <p>{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 는 서로를 가리킨다. 첫
* 게시는 publication INSERT → event INSERT → publication UPDATE 순서로 한 트랜잭션 안에서 끝나며, 그 순환을 허용하는 것이 V7 의
* {@code DEFERRABLE INITIALLY DEFERRED} 다. 즉시 검사로 바꾸면 첫 게시가 구조적으로 불가능해진다.
*/
@Repository
public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
/** 공개 projection payload 의 스키마 버전. 모양이 바뀌면 올린다. */
private static final short PAYLOAD_SCHEMA_VERSION = 1;
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
private final Supplier<UUID> idGenerator;
/**
* 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
* 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
*/
@Autowired
public JdbcPublicationWriterAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this(jdbcClient, objectMapper, UUID::randomUUID);
}
JdbcPublicationWriterAdapter(
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
this.idGenerator = idGenerator;
}
@Override
public Optional<PublicationAggregateView> lockCurrentPublication(
RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at FROM publication"
+ " WHERE source_kind = :kind AND source_id = :id FOR UPDATE")
.param("kind", kind.name())
.param("id", documentId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.optional();
}
@Override
public PublishResultView publish(PublishRequest request) {
requireTransaction("publish");
Optional<PublicationAggregateView> existing =
lockCurrentPublication(request.kind(), request.documentId());
UUID publicationId =
existing.map(PublicationAggregateView::publicationId).orElseGet(idGenerator);
UUID eventId = idGenerator.get();
PublicationEventTypeView eventType =
existing.isEmpty()
? PublicationEventTypeView.PUBLISHED
: PublicationEventTypeView.REPUBLISHED;
if (existing.isEmpty()) {
// 10 이전: aggregate 를 먼저 만든다. latest_event_id 는 아직 없는 event 를 가리키지만
// 지연 검사라 커밋 시점에만 확인된다.
jdbcClient
.sql(
"INSERT INTO publication (publication_id, source_kind, source_id, status,"
+ " published_version, publication_revision, latest_event_id, public_path)"
+ " VALUES (:publicationId, :kind, :id, 'PUBLISHED', :version, 1, :eventId,"
+ " :publicPath)")
.param("publicationId", publicationId)
.param("kind", request.kind().name())
.param("id", request.documentId())
.param("version", request.version())
.param("eventId", eventId)
.param("publicPath", request.publicPath())
.update();
}
// 10. Event
jdbcClient
.sql(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, occurred_at, idempotency_key,"
+ " created_by)"
+ " VALUES (:eventId, :publicationId, :kind, :id, :type, :version, now(),"
+ " :idempotencyKey, :principal)")
.param("eventId", eventId)
.param("publicationId", publicationId)
.param("kind", request.kind().name())
.param("id", request.documentId())
.param("type", eventType.name())
.param("version", request.version())
.param("idempotencyKey", request.idempotencyKey())
.param("principal", request.principal())
.update();
// 11. Snapshot — 게시 시점의 렌더 모델을 그대로 고정한다. 다시 렌더링하지 않는다.
jdbcClient
.sql(
"INSERT INTO publication_snapshot (publication_event_id, render_model,"
+ " content_format_version, renderer_contract_version, asset_manifest)"
+ " VALUES (:eventId, CAST(:renderModel AS jsonb), :contentFormatVersion,"
+ " :rendererContractVersion, CAST(:assetManifest AS jsonb))")
.param("eventId", eventId)
.param("renderModel", request.renderModelJson())
.param("contentFormatVersion", request.contentFormatVersion())
.param("rendererContractVersion", request.rendererContractVersion())
.param("assetManifest", manifestJson(request.assetManifest()))
.update();
upsertProjection(request);
replaceRoute(request);
replaceProjectLink(request);
replacePublishedAssetReferences(request);
markAssetsFirstPublished(request);
// 17. aggregate 갱신
jdbcClient
.sql(
"UPDATE publication SET status = 'PUBLISHED', published_version = :version,"
+ " publication_revision = publication_revision + :bump,"
+ " latest_event_id = :eventId, public_path = :publicPath, updated_at = now()"
+ " WHERE publication_id = :publicationId")
.param("version", request.version())
// 첫 게시는 INSERT 가 이미 revision 1 을 넣었다. 여기서 또 올리면 클라이언트가 받은 값과
// 다음 unpublish 가 요구하는 값이 어긋난다.
.param("bump", existing.isEmpty() ? 0 : 1)
.param("eventId", eventId)
.param("publicPath", request.publicPath())
.param("publicationId", publicationId)
.update();
// 18. Document publish metadata
markSourcePublished(request);
return result(publicationId, eventId);
}
@Override
public PublishResultView unpublish(UnpublishRequest request) {
requireTransaction("unpublish");
UUID lastPublishedEventId =
jdbcClient
.sql(
"SELECT publication_event_id FROM publication_event"
+ " WHERE publication_id = :publicationId"
+ " AND event_type IN ('PUBLISHED', 'REPUBLISHED')"
+ " ORDER BY occurred_at DESC LIMIT 1")
.param("publicationId", request.publicationId())
.query(UUID.class)
.optional()
.orElseThrow(
() ->
StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"this publication has no published event to withdraw"));
UUID eventId = idGenerator.get();
// UNPUBLISHED Event 는 자체 snapshot 을 만들지 않고 마지막 공개 Snapshot 을 참조한다(V7 주석).
jdbcClient
.sql(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, source_published_event_id,"
+ " occurred_at, created_by)"
+ " SELECT :eventId, p.publication_id, p.source_kind, p.source_id, 'UNPUBLISHED',"
+ " p.published_version, :sourceEventId, now(), :principal"
+ " FROM publication p WHERE p.publication_id = :publicationId")
.param("eventId", eventId)
.param("sourceEventId", lastPublishedEventId)
.param("principal", request.principal())
.param("publicationId", request.publicationId())
.update();
int updated =
jdbcClient
.sql(
"UPDATE publication SET status = 'UNPUBLISHED',"
+ " publication_revision = publication_revision + 1,"
+ " latest_event_id = :eventId, updated_at = now()"
+ " WHERE publication_id = :publicationId"
+ " AND publication_revision = :expectedRevision")
.param("eventId", eventId)
.param("publicationId", request.publicationId())
.param("expectedRevision", request.expectedRevision())
.update();
if (updated == 0) {
throw StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"the publication revision changed while withdrawing it");
}
// Projection ACTIVE -> WITHDRAWN. route 는 유지한다 — 주소가 사라지면 링크가 끊긴다.
jdbcClient
.sql(
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
+ " updated_at = now() WHERE resource_type = :type AND resource_id = :id")
.param("type", request.kind().name())
.param("id", request.documentId())
.update();
// Working copy 는 다시 초안으로 돌아간다.
if (request.kind() == RecordKind.CASE || request.kind() == RecordKind.REFERENCE) {
jdbcClient
.sql("UPDATE document SET workflow_status = 'DRAFT' WHERE id = :id")
.param("id", request.documentId())
.update();
}
return result(request.publicationId(), eventId);
}
/** 12. 공개 projection upsert. */
private void upsertProjection(PublishRequest request) {
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, state_code, primary_topic_id,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, navigation_path)"
+ " VALUES (:type, :id, :version, 'ACTIVE', 'PUBLIC', :title, :summary,"
+ " :stateCode, :topicId, :schemaVersion, CAST(:payload AS jsonb), :bodyPlainText,"
+ " :searchText, :contentHash, now(), now(), :navigationPath)"
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
+ " source_version = EXCLUDED.source_version,"
+ " publication_state = 'ACTIVE',"
+ " visibility = EXCLUDED.visibility,"
+ " title = EXCLUDED.title,"
+ " summary = EXCLUDED.summary,"
+ " state_code = EXCLUDED.state_code,"
+ " primary_topic_id = EXCLUDED.primary_topic_id,"
+ " payload = EXCLUDED.payload,"
+ " body_plain_text = EXCLUDED.body_plain_text,"
+ " search_text = EXCLUDED.search_text,"
+ " content_hash = EXCLUDED.content_hash,"
+ " updated_at = now(),"
+ " navigation_path = EXCLUDED.navigation_path")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("version", request.version())
.param("title", request.title())
.param("summary", request.summary())
.param("stateCode", request.stateCode())
.param("topicId", request.topicId())
.param("schemaVersion", PAYLOAD_SCHEMA_VERSION)
.param("payload", request.renderModelJson())
.param("bodyPlainText", request.bodyPlainText())
.param(
"searchText",
String.join(
" ",
nullToEmpty(request.title()),
nullToEmpty(request.summary()),
nullToEmpty(request.bodyPlainText())))
.param("contentHash", sha256(request.renderModelJson()))
.param("navigationPath", request.publicPath())
.update();
}
/** 13. canonical route. 이전 slug 의 route 는 alias 로 남긴다 — 지우면 공개된 링크가 끊긴다. */
private void replaceRoute(PublishRequest request) {
jdbcClient
.sql(
"UPDATE public_route SET route_role = 'ALIAS'"
+ " WHERE resource_type = :type AND resource_id = :id AND slug <> :slug")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("slug", slugOf(request.publicPath()))
.update();
jdbcClient
.sql(
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
+ " VALUES (:type, :slug, :id, 'CANONICAL')"
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
.param("type", request.kind().name())
.param("slug", slugOf(request.publicPath()))
.param("id", request.documentId())
.update();
}
/** 14. 공개 projection 의 프로젝트 링크. Studio 는 PRIMARY 하나만 소유한다. */
private void replaceProjectLink(PublishRequest request) {
jdbcClient
.sql(
"DELETE FROM public_resource_project_link"
+ " WHERE resource_type = :type AND resource_id = :id AND relation_type = 'PRIMARY'")
.param("type", request.kind().name())
.param("id", request.documentId())
.update();
if (request.projectId() == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
+ " relation_type) VALUES (:type, :id, :projectId, 'PRIMARY')"
+ " ON CONFLICT (resource_type, resource_id, project_id)"
+ " DO UPDATE SET relation_type = 'PRIMARY'")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("projectId", request.projectId())
.update();
}
/** 15. PUBLISHED scope 의 asset_reference 교체. WORKING scope 는 건드리지 않는다. */
private void replacePublishedAssetReferences(PublishRequest request) {
String ownerType = ownerTypeOf(request.kind());
jdbcClient
.sql(
"DELETE FROM asset_reference WHERE owner_type = :ownerType AND owner_id = :id"
+ " AND reference_scope = 'PUBLISHED'")
.param("ownerType", ownerType)
.param("id", request.documentId())
.update();
for (AssetManifestEntry entry : request.assetManifest()) {
jdbcClient
.sql(
"INSERT INTO asset_reference (asset_id, owner_type, owner_id, reference_scope,"
+ " reference_role) VALUES (:assetId, :ownerType, :id, 'PUBLISHED', 'BODY')"
+ " ON CONFLICT DO NOTHING")
.param("assetId", entry.assetId())
.param("ownerType", ownerType)
.param("id", request.documentId())
.update();
}
}
/** 16. 최초 공개 시각. 이미 값이 있으면 덮지 않는다 — "처음"은 한 번뿐이다. */
private void markAssetsFirstPublished(PublishRequest request) {
for (AssetManifestEntry entry : request.assetManifest()) {
jdbcClient
.sql(
"UPDATE asset SET first_published_at = now()"
+ " WHERE id = :assetId AND first_published_at IS NULL")
.param("assetId", entry.assetId())
.update();
}
}
/** 18. source 쪽 게시 메타데이터. */
private void markSourcePublished(PublishRequest request) {
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 유형이 늘면 컴파일러가
// 여기서 막아 준다(문이면 조용히 아무것도 안 하고 지나간다).
int updated =
switch (request.kind()) {
case CASE, REFERENCE ->
jdbcClient
.sql(
"UPDATE document SET workflow_status = 'PUBLISHED',"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now() WHERE id = :id")
.param("id", request.documentId())
.update();
case QUESTION ->
jdbcClient
.sql(
"UPDATE open_question SET"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now() WHERE id = :id")
.param("id", request.documentId())
.update();
// project_decision 에는 게시 시각 컬럼이 없다. 게시 사실은 publication 이 소유하므로
// 여기서 억지로 컬럼을 만들지 않는다.
case PROJECT_DECISION -> 0;
};
if (updated == 0 && request.kind() != RecordKind.PROJECT_DECISION) {
throw StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"the source record disappeared while publishing " + request.documentId());
}
}
private PublishResultView result(UUID publicationId, UUID eventId) {
PublicationAggregateView aggregate =
jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version,"
+ " publication_revision, latest_event_id, public_path, updated_at"
+ " FROM publication WHERE publication_id = :id")
.param("id", publicationId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.single();
PublicationEventView event =
jdbcClient
.sql(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available"
+ " FROM publication_event e"
+ " LEFT JOIN publication_snapshot s"
+ " ON s.publication_event_id = e.publication_event_id"
+ " WHERE e.publication_event_id = :id")
.param("id", eventId)
.query(PublicationRowMappers::mapEvent)
.single();
return new PublishResultView(aggregate, event);
}
/**
* 이 어댑터는 <b>열린 트랜잭션 안에서만</b> 올바르게 동작한다.
*
* <p>{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 가 서로를 가리키고, 그
* 순환은 {@code fk_publication_latest_event} 의 {@code DEFERRABLE INITIALLY DEFERRED} 로만 성립한다. 지연 검사는
* <b>트랜잭션 끝</b>에 일어나므로, autocommit 이면 각 구문이 곧 트랜잭션이라 첫 INSERT 에서 바로 위반이 된다.
*
* <p>이 사실을 주석으로만 남기면 트랜잭션 없이 호출한 코드가 "외래 키 위반"이라는, 원인과 한참 떨어진 오류를 만난다. 통합 테스트를 처음 돌렸을 때 실제로 그렇게
* 실패했다. 그래서 전제를 여기서 확인하고 무엇이 잘못됐는지 그대로 말한다.
*/
private static void requireTransaction(String operation) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"publication "
+ operation
+ " must run inside an active transaction: publication and publication_event"
+ " reference each other, and that cycle only resolves at commit through"
+ " fk_publication_latest_event's deferred check");
}
}
static PublicationAggregateView mapAggregate(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("published_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("updated_at").toInstant());
}
private String manifestJson(List<AssetManifestEntry> manifest) {
ArrayNode array = objectMapper.createArrayNode();
for (AssetManifestEntry entry : manifest) {
ObjectNode node = array.addObject();
node.put("assetId", entry.assetId() == null ? null : entry.assetId().toString());
node.put("assetKey", entry.assetKey());
node.put("mediaType", entry.mediaType());
node.put("publicPath", entry.publicPath());
node.put("width", entry.width());
node.put("height", entry.height());
node.put("decorative", entry.decorative());
}
try {
return objectMapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise a publication asset manifest", e);
}
}
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
private static String ownerTypeOf(RecordKind kind) {
return switch (kind) {
case CASE, REFERENCE -> "DOCUMENT";
case QUESTION -> "QUESTION";
case PROJECT_DECISION -> "DECISION";
};
}
/** {@code public_route.slug} 는 경로가 아니라 마지막 조각이다. */
private static String slugOf(String publicPath) {
if (publicPath == null || publicPath.isBlank()) {
throw StudioException.of(
StudioError.DOCUMENT_VALIDATION_FAILED, "a published record needs a public path");
}
return publicPath.substring(publicPath.lastIndexOf('/') + 1);
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256")
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
/** {@code publication_event} 행 매핑. writer 와 조회 어댑터가 같은 모양을 쓰도록 한 곳에 둔다. */
final class PublicationRowMappers {
private PublicationRowMappers() {}
static PublicationEventView mapEvent(ResultSet rs, int rowNum) throws SQLException {
return new PublicationEventView(
rs.getObject("publication_event_id", UUID.class),
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationEventTypeView.valueOf(rs.getString("event_type")),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("published_version"),
rs.getObject("source_published_event_id", UUID.class),
rs.getBoolean("snapshot_available"));
}
}
@@ -0,0 +1,255 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code CASE} / {@code REFERENCE} 편집본. 둘 다 {@code document} 루트 + 유형별 detail 테이블이다 (ADR-003, 설계
* 07장).
*/
final class DocumentWorkingCopyStore {
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
private final ProjectLinkStore projectLinks;
DocumentWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator,
ProjectLinkStore projectLinks) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
this.projectLinks = projectLinks;
}
Optional<WorkingCopyView> find(RecordKind kind, UUID id) {
return jdbcClient
.sql(
"SELECT d.id, d.version, d.updated_at, d.title, d.slug, d.summary, d.primary_topic_id,"
+ " d.body_markdown, d.last_verified_at,"
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
+ " r.scope_summary, r.rules, r.applies_to, r.excluded_scope, r.examples"
+ " FROM document d"
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
+ " WHERE d.id = :id AND d.document_type = :type")
.param("id", id)
.param("type", kind.name())
.query(
(rs, rowNum) -> {
UUID documentId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
kind,
rs.getString("title"),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
projectLinks.findPrimaryProjectForDocument(documentId).orElse(null),
relations.findBySource(kind, documentId));
long version = rs.getLong("version");
var updatedAt = instant(rs, "updated_at");
if (kind == RecordKind.CASE) {
return (WorkingCopyView)
new WorkingCopyView.CaseWorkingCopyView(
documentId,
version,
updatedAt,
base,
orEmpty(rs.getString("problem_summary")),
orEmpty(rs.getString("conclusion_summary")),
orEmpty(rs.getString("environment")),
orEmpty(rs.getString("reproduction")),
dateFromTimestamp(rs, "last_verified_at"),
orEmpty(rs.getString("body_markdown")));
}
return (WorkingCopyView)
new WorkingCopyView.ReferenceWorkingCopyView(
documentId,
version,
updatedAt,
base,
orEmpty(rs.getString("scope_summary")),
json.rulesFromJson(rs.getString("rules")),
json.orderedTextFromJson(rs.getString("applies_to")),
json.orderedTextFromJson(rs.getString("excluded_scope")),
json.orderedTextFromJson(rs.getString("examples")),
dateFromTimestamp(rs, "last_verified_at"));
})
.optional();
}
UUID create(WorkingCopyInputView input, String principal) {
RecordKind kind = input.kind();
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
jdbcClient
.sql(
"INSERT INTO document (id, document_type, slug, title, summary, body_markdown,"
+ " primary_topic_id, last_verified_at, version, created_by, updated_by)"
+ " VALUES (:id, :type, :slug, :title, :summary, :body, :topicId, :verifiedAt,"
// 계약의 WorkingCopyBase.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면
// 생성 직후 응답이 계약을 위반한다.
+ " 1, :principal, :principal)")
.param("id", id)
.param("type", kind.name())
.param("slug", slugToColumn(base.slug()))
.param("title", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("body", bodyMarkdownOf(input))
.param("topicId", base.topicId())
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
.param("principal", principal)
.update();
insertDetail(id, input);
relations.replace(kind, id, base.relations());
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
return id;
}
/**
* 낙관적 잠금 저장.
*
* @return 갱신된 행이 없으면(= {@code expectedVersion} 불일치) {@code false}
*/
boolean save(UUID id, long expectedVersion, WorkingCopyInputView input, String principal) {
RecordKind kind = input.kind();
WorkingCopyBaseInput base = input.base();
int updated =
jdbcClient
.sql(
"UPDATE document SET slug = :slug, title = :title, summary = :summary,"
+ " body_markdown = :body, primary_topic_id = :topicId,"
+ " last_verified_at = :verifiedAt, version = version + 1,"
+ " updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND document_type = :type AND version = :expectedVersion")
.param("slug", slugToColumn(base.slug()))
.param("title", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("body", bodyMarkdownOf(input))
.param("topicId", base.topicId())
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
.param("principal", principal)
.param("id", id)
.param("type", kind.name())
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
updateDetail(id, input);
relations.replace(kind, id, base.relations());
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
return true;
}
private void insertDetail(UUID id, WorkingCopyInputView input) {
switch (input) {
case WorkingCopyInputView.CaseInputView caseInput ->
jdbcClient
.sql(
"INSERT INTO case_detail (document_id, document_type, problem_summary,"
+ " conclusion_summary, environment, reproduction)"
+ " VALUES (:id, 'CASE', :problem, :conclusion, :environment, :reproduction)")
.param("id", id)
.param("problem", orEmpty(caseInput.problem()))
.param("conclusion", orEmpty(caseInput.conclusion()))
.param("environment", orEmpty(caseInput.environment()))
.param("reproduction", orEmpty(caseInput.reproduction()))
.update();
case WorkingCopyInputView.ReferenceInputView reference ->
jdbcClient
.sql(
"INSERT INTO reference_detail (document_id, document_type, scope_summary,"
+ " rules, applies_to, excluded_scope, examples)"
+ " VALUES (:id, 'REFERENCE', :purpose, CAST(:rules AS jsonb),"
+ " CAST(:applyWhen AS jsonb), CAST(:exceptions AS jsonb),"
+ " CAST(:examples AS jsonb))")
.param("id", id)
.param("purpose", orEmpty(reference.purpose()))
.param("rules", json.rulesToJson(reference.rules()))
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
.param("examples", json.orderedTextToJson(reference.examples()))
.update();
default ->
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
}
}
private void updateDetail(UUID id, WorkingCopyInputView input) {
switch (input) {
case WorkingCopyInputView.CaseInputView caseInput ->
jdbcClient
.sql(
"UPDATE case_detail SET problem_summary = :problem,"
+ " conclusion_summary = :conclusion, environment = :environment,"
+ " reproduction = :reproduction WHERE document_id = :id")
.param("id", id)
.param("problem", orEmpty(caseInput.problem()))
.param("conclusion", orEmpty(caseInput.conclusion()))
.param("environment", orEmpty(caseInput.environment()))
.param("reproduction", orEmpty(caseInput.reproduction()))
.update();
case WorkingCopyInputView.ReferenceInputView reference ->
jdbcClient
.sql(
"UPDATE reference_detail SET scope_summary = :purpose,"
+ " rules = CAST(:rules AS jsonb), applies_to = CAST(:applyWhen AS jsonb),"
+ " excluded_scope = CAST(:exceptions AS jsonb),"
+ " examples = CAST(:examples AS jsonb) WHERE document_id = :id")
.param("id", id)
.param("purpose", orEmpty(reference.purpose()))
.param("rules", json.rulesToJson(reference.rules()))
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
.param("examples", json.orderedTextToJson(reference.examples()))
.update();
default ->
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
}
}
/** {@code REFERENCE}는 계약에 본문이 없다 — 컬럼이 NOT NULL이므로 빈 문자열을 유지한다. */
private static String bodyMarkdownOf(WorkingCopyInputView input) {
return input instanceof WorkingCopyInputView.CaseInputView caseInput
? orEmpty(caseInput.bodyMarkdown())
: "";
}
/** 계약의 {@code lastVerifiedOn}(CASE) / {@code verifiedOn}(REFERENCE)은 같은 컬럼에 담긴다. */
private static java.time.LocalDate verifiedOnOf(WorkingCopyInputView input) {
return switch (input) {
case WorkingCopyInputView.CaseInputView caseInput -> caseInput.lastVerifiedOn();
case WorkingCopyInputView.ReferenceInputView reference -> reference.verifiedOn();
default -> null;
};
}
List<RelationView> relationsOf(RecordKind kind, UUID id) {
return relations.findBySource(kind, id);
}
}
@@ -0,0 +1,128 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 계약의 통합 {@code WorkingCopy}를 네 source aggregate로 dispatch한다.
*
* <p>공통 CRUD repository가 아니다 — {@code kind}마다 소유 테이블이 다르고, 그 구분을 유지하는 것이 ADR-003의 결정이다. 여기서 하는 일은
* "어느 저장소로 보낼지" 뿐이다.
*
* <p>JPA 엔티티가 아니라 {@code JdbcClient}를 쓴다. 이 네 aggregate는 Studio 저장 경로에서만 쓰이고, 낙관적 잠금은 {@code UPDATE
* ... WHERE version = :expectedVersion}의 갱신 행 수로 정확히 같은 의미를 얻는다 — 여덟 개 넘는 테이블에 엔티티와 매핑을 세우는 비용에
* 상응하는 이득이 없다. 포트 계약이 같으므로 나중에 JPA 가 필요해지면 이 어댑터만 바뀐다.
*/
@Repository
public class JdbcWorkingCopyRepositoryAdapter implements WorkingCopyRepositoryPort {
private final JdbcClient jdbcClient;
private final DocumentWorkingCopyStore documents;
private final QuestionWorkingCopyStore questions;
private final ProjectDecisionWorkingCopyStore decisions;
/**
* 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
* 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
*/
@Autowired
public JdbcWorkingCopyRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this(jdbcClient, objectMapper, UUID::randomUUID);
}
JdbcWorkingCopyRepositoryAdapter(
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
StudioJson json = new StudioJson(objectMapper);
StudioRelationStore relations = new StudioRelationStore(jdbcClient, idGenerator);
ProjectLinkStore projectLinks = new ProjectLinkStore(jdbcClient);
this.documents =
new DocumentWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks);
this.questions =
new QuestionWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks);
this.decisions = new ProjectDecisionWorkingCopyStore(jdbcClient, relations, json, idGenerator);
}
/**
* 계약상 {@code documentId}는 source aggregate id 그대로다 — 어느 테이블에 있는지 먼저 찾아야 한다 (spec §7.2
* StudioDocumentLocator). 세 테이블을 UNION 으로 한 번에 본다.
*/
@Override
public Optional<RecordKind> findKind(UUID documentId) {
if (documentId == null) {
return Optional.empty();
}
return jdbcClient
.sql(
"SELECT document_type AS kind FROM document WHERE id = :id"
+ " UNION ALL SELECT 'QUESTION' FROM open_question WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT_DECISION' FROM project_decision WHERE id = :id")
.param("id", documentId)
.query(String.class)
.optional()
.map(RecordKind::valueOf);
}
@Override
public Optional<WorkingCopyView> find(UUID documentId) {
return findKind(documentId).flatMap(kind -> load(kind, documentId));
}
@Override
public WorkingCopyView create(WorkingCopyInputView input, String principal) {
UUID id =
switch (input) {
case WorkingCopyInputView.CaseInputView ignored -> documents.create(input, principal);
case WorkingCopyInputView.ReferenceInputView ignored ->
documents.create(input, principal);
case WorkingCopyInputView.QuestionInputView question ->
questions.create(question, principal);
case WorkingCopyInputView.ProjectDecisionInputView decision ->
decisions.create(decision, principal);
};
return load(input.kind(), id)
.orElseThrow(
() ->
StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"the working copy "
+ id
+ " could not be read back right after it was created"));
}
@Override
public Optional<WorkingCopyView> save(
UUID documentId, long expectedVersion, WorkingCopyInputView input, String principal) {
boolean saved =
switch (input) {
case WorkingCopyInputView.CaseInputView ignored ->
documents.save(documentId, expectedVersion, input, principal);
case WorkingCopyInputView.ReferenceInputView ignored ->
documents.save(documentId, expectedVersion, input, principal);
case WorkingCopyInputView.QuestionInputView question ->
questions.save(documentId, expectedVersion, question, principal);
case WorkingCopyInputView.ProjectDecisionInputView decision ->
decisions.save(documentId, expectedVersion, decision, principal);
};
return saved ? load(input.kind(), documentId) : Optional.empty();
}
private Optional<WorkingCopyView> load(RecordKind kind, UUID id) {
return switch (kind) {
case CASE, REFERENCE -> documents.find(kind, id);
case QUESTION -> questions.find(id);
case PROJECT_DECISION -> decisions.find(id);
};
}
}
@@ -0,0 +1,180 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code PROJECT_DECISION} 편집본 ({@code project_decision}).
*
* <p>계약의 {@code ADOPTED}는 Domain의 {@code ACCEPTED}다(ADR-003) — UI 용어 때문에 Domain enum을 바꾸지 않고 여기서
* 변환한다. {@code supersede}/{@code reject}는 secondary management 계약이 소유하므로 이 저장 경로가 그 두 상태를 만들지도,
* 건드리지도 않는다.
*/
final class ProjectDecisionWorkingCopyStore {
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
ProjectDecisionWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
}
Optional<WorkingCopyView> find(UUID id) {
return jdbcClient
.sql(
"SELECT id, version, updated_at, title, slug, summary, primary_topic_id, project_id,"
+ " decision_status, decided_at, statement, rationale_markdown, consequences"
+ " FROM project_decision WHERE id = :id")
.param("id", id)
.query(
(rs, rowNum) -> {
UUID decisionId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
RecordKind.PROJECT_DECISION,
orEmpty(rs.getString("title")),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
rs.getObject("project_id", UUID.class),
relations.findBySource(RecordKind.PROJECT_DECISION, decisionId));
return (WorkingCopyView)
new WorkingCopyView.ProjectDecisionWorkingCopyView(
decisionId,
rs.getLong("version"),
instant(rs, "updated_at"),
base,
toContractStatus(rs.getString("decision_status")),
dateFromTimestamp(rs, "decided_at"),
orEmpty(rs.getString("statement")),
orEmpty(rs.getString("rationale_markdown")),
json.orderedTextFromJson(rs.getString("consequences")));
})
.optional();
}
UUID create(WorkingCopyInputView.ProjectDecisionInputView input, String principal) {
requireDecidedOnWhenAdopted(input);
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
jdbcClient
.sql(
"INSERT INTO project_decision (id, project_id, title, slug, summary,"
+ " primary_topic_id, statement, rationale_markdown, consequences,"
+ " decision_status, decided_at, version, created_by, updated_by)"
+ " VALUES (:id, :projectId, :title, :slug, :summary, :topicId, :statement,"
+ " :rationale, CAST(:consequences AS jsonb), :status, :decidedAt, 1,"
+ " :principal, :principal)")
.param("id", id)
.param("projectId", base.projectId())
.param("title", orEmpty(base.title()))
.param("slug", slugToColumn(base.slug()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("statement", orEmpty(input.statement()))
.param("rationale", orEmpty(input.rationale()))
.param("consequences", json.orderedTextToJson(input.consequences()))
.param("status", toDomainStatus(input.decisionStatus()))
.param("decidedAt", dateToTimestamp(input.decidedOn()))
.param("principal", principal)
.update();
relations.replace(RecordKind.PROJECT_DECISION, id, base.relations());
return id;
}
boolean save(
UUID id,
long expectedVersion,
WorkingCopyInputView.ProjectDecisionInputView input,
String principal) {
requireDecidedOnWhenAdopted(input);
WorkingCopyBaseInput base = input.base();
int updated =
jdbcClient
.sql(
"UPDATE project_decision SET project_id = :projectId, title = :title,"
+ " slug = :slug, summary = :summary, primary_topic_id = :topicId,"
+ " statement = :statement, rationale_markdown = :rationale,"
+ " consequences = CAST(:consequences AS jsonb),"
// SUPERSEDED/REJECTED 는 secondary management 계약이 소유한다. Studio 저장이
// 그 상태를 PROPOSED/ACCEPTED 로 되돌리면 그쪽 lifecycle 이 조용히 무효화된다.
+ " decision_status = CASE WHEN decision_status IN ('PROPOSED', 'ACCEPTED')"
+ " THEN :status ELSE decision_status END,"
+ " decided_at = :decidedAt,"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("projectId", base.projectId())
.param("title", orEmpty(base.title()))
.param("slug", slugToColumn(base.slug()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("statement", orEmpty(input.statement()))
.param("rationale", orEmpty(input.rationale()))
.param("consequences", json.orderedTextToJson(input.consequences()))
.param("status", toDomainStatus(input.decisionStatus()))
.param("decidedAt", dateToTimestamp(input.decidedOn()))
.param("principal", principal)
.param("id", id)
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
relations.replace(RecordKind.PROJECT_DECISION, id, base.relations());
return true;
}
/**
* {@code ck_project_decision_status_fields}는 {@code ACCEPTED}에 {@code decided_at}을 요구한다. 값을 지어내
* 채우면 사용자가 정하지 않은 날짜가 기록되고, 그냥 보내면 DB 제약 위반이 500으로 나간다 — 무엇이 빠졌는지 알려주고 거절한다.
*/
private static void requireDecidedOnWhenAdopted(
WorkingCopyInputView.ProjectDecisionInputView input) {
if (input.decisionStatus() == DecisionStatusView.ADOPTED && input.decidedOn() == null) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"document.decidedOn is required when decisionStatus is ADOPTED");
}
}
private static String toDomainStatus(DecisionStatusView status) {
return status == DecisionStatusView.ADOPTED ? "ACCEPTED" : "PROPOSED";
}
private static DecisionStatusView toContractStatus(String domainStatus) {
return switch (domainStatus) {
case "ACCEPTED" -> DecisionStatusView.ADOPTED;
case "PROPOSED" -> DecisionStatusView.PROPOSED;
// SUPERSEDED / REJECTED 는 계약의 두 값 어디에도 대응하지 않는다. 계약은 null 을 허용하므로
// 억지로 가장 가까운 값으로 접지 않고 "이 축약 view 로는 표현할 수 없음"을 null 로 알린다.
default -> null;
};
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 계약의 단일 {@code projectId}를 설계 스키마의 링크 테이블({@code project_document_link} / {@code
* project_question_link})에 옮긴다.
*
* <p>링크 테이블은 한 문서가 여러 프로젝트에 붙는 것을 허용하지만 Studio 편집기는 프로젝트 하나만 다룬다. Studio가 소유하는 것은 {@code PRIMARY}
* 링크 하나뿐이며, {@code RELATED} 링크는 건드리지 않는다 — 그건 다른 화면의 데이터이고 Studio 저장이 지워도 되는 것이 아니다.
*/
final class ProjectLinkStore {
private static final String PRIMARY = "PRIMARY";
private final JdbcClient jdbcClient;
ProjectLinkStore(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
Optional<UUID> findPrimaryProjectForDocument(UUID documentId) {
return findPrimary("project_document_link", "document_id", documentId);
}
Optional<UUID> findPrimaryProjectForQuestion(UUID questionId) {
return findPrimary("project_question_link", "question_id", questionId);
}
void setPrimaryProjectForDocument(UUID documentId, UUID projectId) {
setPrimary("project_document_link", "document_id", documentId, projectId);
}
void setPrimaryProjectForQuestion(UUID questionId, UUID projectId) {
setPrimary("project_question_link", "question_id", questionId, projectId);
}
private Optional<UUID> findPrimary(String table, String column, UUID id) {
return jdbcClient
.sql(
"SELECT project_id FROM "
+ table
+ " WHERE "
+ column
+ " = :id AND relation_type = :type")
.param("id", id)
.param("type", PRIMARY)
.query(UUID.class)
.optional();
}
private void setPrimary(String table, String column, UUID id, UUID projectId) {
jdbcClient
.sql("DELETE FROM " + table + " WHERE " + column + " = :id AND relation_type = :type")
.param("id", id)
.param("type", PRIMARY)
.update();
if (projectId == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO "
+ table
+ " (project_id, "
+ column
+ ", relation_type)"
+ " VALUES (:projectId, :id, :type)"
// PK 는 (project_id, <column>) 이라 같은 쌍이 RELATED 로 이미 있으면 INSERT 가 깨진다.
// Studio 가 소유하는 것은 PRIMARY 이므로 그 경우 관계 종류를 올려준다.
+ " ON CONFLICT (project_id, "
+ column
+ ") DO UPDATE SET relation_type = :type")
.param("projectId", projectId)
.param("id", id)
.param("type", PRIMARY)
.update();
}
}
@@ -0,0 +1,296 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code QUESTION} 편집본 ({@code open_question} + {@code question_point}).
*
* <p>계약의 {@code questionStatus}는 Domain lifecycle의 축약 view다(ADR-003) — {@code OPEN}을 받았다고 Domain의
* {@code INVESTIGATING}/{@code PAUSED}를 덮어쓰지 않는다. 그렇게 하면 조사 이력이 사라진다.
*/
final class QuestionWorkingCopyStore {
/**
* 계약의 {@code QuestionResolution}에는 resolution_type이 없는데 {@code
* ck_question_resolution_consistency}는 RESOLVED에 그 값을 요구한다. Studio 편집으로 해결 처리된 질문은 "판단을 내렸다"로
* 기록한다 — 나머지 세 유형(가정 기각/질문 재정의/무의미해짐)은 secondary management 계약의 명시적 action이 소유한다.
*/
private static final String DEFAULT_RESOLUTION_TYPE = "DECISION_MADE";
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
private final ProjectLinkStore projectLinks;
QuestionWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator,
ProjectLinkStore projectLinks) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
this.projectLinks = projectLinks;
}
Optional<WorkingCopyView> find(UUID id) {
return jdbcClient
.sql(
"SELECT id, version, updated_at, question, slug, summary, primary_topic_id,"
+ " question_status, next_verification, options, resolution_summary,"
+ " resolution_evidence_target_id, resolution_link_label"
+ " FROM open_question WHERE id = :id")
.param("id", id)
.query(
(rs, rowNum) -> {
UUID questionId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
RecordKind.QUESTION,
rs.getString("question"),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
projectLinks.findPrimaryProjectForQuestion(questionId).orElse(null),
relations.findBySource(RecordKind.QUESTION, questionId));
String domainStatus = rs.getString("question_status");
return (WorkingCopyView)
new WorkingCopyView.QuestionWorkingCopyView(
questionId,
rs.getLong("version"),
instant(rs, "updated_at"),
base,
toContractStatus(domainStatus),
points(questionId, "FACT"),
points(questionId, "ASSUMPTION"),
points(questionId, "UNKNOWN"),
points(questionId, "CONSTRAINT"),
json.optionsFromJson(rs.getString("options")),
orEmpty(rs.getString("next_verification")),
resolutionOf(
domainStatus,
rs.getString("resolution_summary"),
rs.getObject("resolution_evidence_target_id", UUID.class),
rs.getString("resolution_link_label")));
})
.optional();
}
UUID create(WorkingCopyInputView.QuestionInputView input, String principal) {
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
boolean resolved = input.questionStatus() == QuestionStatusView.RESOLVED;
requireUsableResolution(input, resolved);
jdbcClient
.sql(
"INSERT INTO open_question (id, slug, question, summary, primary_topic_id,"
+ " next_verification, options, question_status, resolution_type,"
+ " resolution_summary, resolution_evidence_target_id, resolution_link_label,"
+ " resolved_at, version, created_by, updated_by)"
+ " VALUES (:id, :slug, :question, :summary, :topicId, :nextValidation,"
+ " CAST(:options AS jsonb), :status, :resolutionType, :resolutionSummary,"
+ " :evidenceTargetId, :linkLabel,"
// 해결 시각은 애플리케이션 시계가 아니라 DB 시계로 찍는다 — 같은 트랜잭션의
// 다른 타임스탬프(created_at/updated_at)와 기준이 같아야 순서가 뒤집히지 않는다.
+ " CASE WHEN :resolved THEN now() ELSE NULL END, 1, :principal, :principal)")
.param("id", id)
.param("slug", slugToColumn(base.slug()))
.param("question", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("nextValidation", orEmpty(input.nextValidation()))
.param("options", json.optionsToJson(input.options()))
.param("status", resolved ? "RESOLVED" : "OPEN")
.param("resolutionType", resolved ? DEFAULT_RESOLUTION_TYPE : null)
.param("resolutionSummary", resolved ? input.resolution().summary() : null)
.param("evidenceTargetId", resolved ? input.resolution().evidenceTargetId() : null)
.param("linkLabel", resolved ? orEmpty(input.resolution().linkLabel()) : "")
.param("resolved", resolved)
.param("principal", principal)
.update();
replacePoints(id, input);
relations.replace(RecordKind.QUESTION, id, base.relations());
projectLinks.setPrimaryProjectForQuestion(id, base.projectId());
return id;
}
boolean save(
UUID id,
long expectedVersion,
WorkingCopyInputView.QuestionInputView input,
String principal) {
WorkingCopyBaseInput base = input.base();
String storedStatus =
jdbcClient
.sql("SELECT question_status FROM open_question WHERE id = :id")
.param("id", id)
.query(String.class)
.optional()
.orElse("OPEN");
boolean resolveNow =
input.questionStatus() == QuestionStatusView.RESOLVED && !"RESOLVED".equals(storedStatus);
requireUsableResolution(input, input.questionStatus() == QuestionStatusView.RESOLVED);
// ADR-003: 축약 상태가 Domain lifecycle 을 덮어쓰지 않는다. OPEN 계열 안에서의 값 변화는 무시하고,
// 이미 RESOLVED 인 질문을 OPEN 으로 되돌리는 것도 저장이 할 일이 아니다 — reopen 은 secondary
// management 계약의 명시적 action 이다.
String nextStatus = resolveNow ? "RESOLVED" : storedStatus;
boolean resolvedAfter = "RESOLVED".equals(nextStatus);
int updated =
jdbcClient
.sql(
"UPDATE open_question SET slug = :slug, question = :question, summary = :summary,"
+ " primary_topic_id = :topicId, next_verification = :nextValidation,"
+ " options = CAST(:options AS jsonb), question_status = :status,"
+ " resolution_type = CASE WHEN :resolved THEN"
+ " COALESCE(resolution_type, :resolutionType) ELSE resolution_type END,"
+ " resolution_summary = CASE WHEN :resolved THEN :resolutionSummary"
+ " ELSE resolution_summary END,"
+ " resolution_evidence_target_id = CASE WHEN :resolved THEN :evidenceTargetId"
+ " ELSE resolution_evidence_target_id END,"
+ " resolution_link_label = CASE WHEN :resolved THEN :linkLabel"
+ " ELSE resolution_link_label END,"
+ " resolved_at = CASE WHEN :resolved THEN COALESCE(resolved_at, now())"
+ " ELSE resolved_at END,"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("slug", slugToColumn(base.slug()))
.param("question", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("nextValidation", orEmpty(input.nextValidation()))
.param("options", json.optionsToJson(input.options()))
.param("status", nextStatus)
.param("resolved", resolvedAfter)
.param("resolutionType", DEFAULT_RESOLUTION_TYPE)
.param("resolutionSummary", resolvedAfter ? input.resolution().summary() : null)
.param("evidenceTargetId", resolvedAfter ? input.resolution().evidenceTargetId() : null)
.param("linkLabel", resolvedAfter ? orEmpty(input.resolution().linkLabel()) : "")
.param("principal", principal)
.param("id", id)
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
replacePoints(id, input);
relations.replace(RecordKind.QUESTION, id, base.relations());
projectLinks.setPrimaryProjectForQuestion(id, base.projectId());
return true;
}
/**
* {@code RESOLVED}는 {@code ck_question_resolution_consistency}가 요약을 요구한다. 요약 없이 해결 처리된 질문은 "왜
* 끝났는지 모르는 종료"라 저장을 거부한다 — 여기서 거부하지 않으면 DB 제약 위반이 500으로 나가 클라이언트가 무엇이 잘못됐는지 알 수 없다.
*/
private static void requireUsableResolution(
WorkingCopyInputView.QuestionInputView input, boolean resolved) {
if (!resolved) {
return;
}
QuestionResolutionView resolution = input.resolution();
if (resolution == null || resolution.summary() == null || resolution.summary().isBlank()) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"document.resolution.summary is required when questionStatus is RESOLVED");
}
}
private static QuestionStatusView toContractStatus(String domainStatus) {
return "RESOLVED".equals(domainStatus) ? QuestionStatusView.RESOLVED : QuestionStatusView.OPEN;
}
private static QuestionResolutionView resolutionOf(
String domainStatus, String summary, UUID evidenceTargetId, String linkLabel) {
if (!"RESOLVED".equals(domainStatus)) {
return null;
}
return new QuestionResolutionView(orEmpty(summary), evidenceTargetId, orEmpty(linkLabel));
}
private List<OrderedTextView> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT id, content, display_order FROM question_point"
+ " WHERE question_id = :id AND point_kind = :kind ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(
(rs, rowNum) ->
new OrderedTextView(
rs.getObject("id", UUID.class),
rs.getString("content"),
rs.getInt("display_order")))
.list();
}
private void replacePoints(UUID questionId, WorkingCopyInputView.QuestionInputView input) {
// 이 질문이 지금 소유한 point id. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다 — 남의 질문 것을
// 그대로 쓰면 PK 가 충돌하고, 매번 새로 부여하면 편집기의 줄 식별자가 저장마다 바뀐다.
Set<UUID> owned =
Set.copyOf(
jdbcClient
.sql("SELECT id FROM question_point WHERE question_id = :id")
.param("id", questionId)
.query(UUID.class)
.list());
jdbcClient
.sql("DELETE FROM question_point WHERE question_id = :id")
.param("id", questionId)
.update();
insertPoints(questionId, "FACT", input.facts(), owned);
insertPoints(questionId, "ASSUMPTION", input.assumptions(), owned);
insertPoints(questionId, "UNKNOWN", input.unknowns(), owned);
insertPoints(questionId, "CONSTRAINT", input.constraints(), owned);
}
private void insertPoints(
UUID questionId, String pointKind, List<OrderedTextView> items, Set<UUID> owned) {
int order = 0;
for (OrderedTextView item : items) {
// content 는 CHECK (length(trim(content)) > 0) 다. 빈 줄은 편집 중 흔한 상태이므로 거부하는 대신
// 저장하지 않는다 — 계약도 OrderedText.text 에 minLength 1 을 두어 빈 항목을 보내지 말라고 한다.
if (item.text() == null || item.text().isBlank()) {
continue;
}
jdbcClient
.sql(
"INSERT INTO question_point (id, question_id, point_kind, content, display_order)"
+ " VALUES (:id, :questionId, :kind, :content, :order)")
.param(
"id",
(item.id() != null && owned.contains(item.id())) ? item.id() : idGenerator.get())
.param("questionId", questionId)
.param("kind", pointKind)
.param("content", item.text())
.param("order", order++)
.update();
}
}
}
@@ -0,0 +1,131 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.shared.error.MappingException;
import java.util.List;
import java.util.UUID;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* 설계 스키마가 jsonb 배열로 정한 순서 있는 항목들을 읽고 쓴다.
*
* <p>Jackson의 자동 POJO 바인딩을 쓰지 않고 필드를 직접 읽고 쓴다. 이 값들은 <b>DB에 영속되는 형태</b>라 application record의 필드 이름이
* 바뀌면 이미 저장된 행을 읽지 못하게 된다 — 그 결합을 만들지 않으려고 컬럼 안의 key 이름을 여기서 명시적으로 고정한다.
*/
final class StudioJson {
private final ObjectMapper mapper;
StudioJson(ObjectMapper mapper) {
this.mapper = mapper;
}
String orderedTextToJson(List<OrderedTextView> items) {
ArrayNode array = mapper.createArrayNode();
for (OrderedTextView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("text", item.text());
node.put("order", item.order());
}
return write(array);
}
List<OrderedTextView> orderedTextFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new OrderedTextView(
uuid(node, "id"), text(node, "text"), node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(OrderedTextView::order))
.toList();
}
String rulesToJson(List<ReferenceRuleView> items) {
ArrayNode array = mapper.createArrayNode();
for (ReferenceRuleView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("title", item.title());
node.put("body", item.body());
node.put("order", item.order());
}
return write(array);
}
List<ReferenceRuleView> rulesFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new ReferenceRuleView(
uuid(node, "id"),
text(node, "title"),
text(node, "body"),
node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(ReferenceRuleView::order))
.toList();
}
String optionsToJson(List<QuestionOptionView> items) {
ArrayNode array = mapper.createArrayNode();
for (QuestionOptionView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("title", item.title());
node.put("description", item.description());
node.put("order", item.order());
}
return write(array);
}
List<QuestionOptionView> optionsFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new QuestionOptionView(
uuid(node, "id"),
text(node, "title"),
text(node, "description"),
node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(QuestionOptionView::order))
.toList();
}
private String write(ArrayNode array) {
try {
return mapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise a Studio jsonb column", e);
}
}
private JsonNode read(String json) {
if (json == null || json.isBlank()) {
return mapper.createArrayNode();
}
try {
JsonNode node = mapper.readTree(json);
return node.isArray() ? node : mapper.createArrayNode();
} catch (JacksonException e) {
throw new MappingException("failed to read a Studio jsonb column", e);
}
}
private static UUID uuid(JsonNode node, String field) {
String value = node.path(field).asString(null);
return (value == null || value.isBlank()) ? null : UUID.fromString(value);
}
private static String text(JsonNode node, String field) {
return node.path(field).asString("");
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 네 유형이 공유하는 편집용 관계({@code studio_relation})를 읽고 통째로 교체한다.
*
* <p>부분 갱신이 아니라 교체인 이유: 계약의 {@code relations}는 배열 전체가 편집 대상이고, 클라이언트가 보낸 배열이 곧 최종 상태다. 지운 줄을 알아내려고
* diff를 뜨면 순서 재배열과 삭제를 구분하지 못한다.
*/
final class StudioRelationStore {
private final JdbcClient jdbcClient;
private final Supplier<UUID> idGenerator;
StudioRelationStore(JdbcClient jdbcClient, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.idGenerator = idGenerator;
}
List<RelationView> findBySource(RecordKind kind, UUID sourceId) {
return jdbcClient
.sql(
"SELECT id, target_id, reason, display_order FROM studio_relation "
+ "WHERE source_kind = :kind AND source_id = :sourceId ORDER BY display_order")
.param("kind", kind.name())
.param("sourceId", sourceId)
.query(
(rs, rowNum) ->
new RelationView(
rs.getObject("id", UUID.class),
rs.getObject("target_id", UUID.class),
rs.getString("reason"),
rs.getInt("display_order")))
.list();
}
void replace(RecordKind kind, UUID sourceId, List<RelationView> relations) {
// 이 source 가 지금 소유한 관계 id 집합. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다.
Set<UUID> owned =
Set.copyOf(
jdbcClient
.sql(
"SELECT id FROM studio_relation "
+ "WHERE source_kind = :kind AND source_id = :sourceId")
.param("kind", kind.name())
.param("sourceId", sourceId)
.query(UUID.class)
.list());
jdbcClient
.sql("DELETE FROM studio_relation WHERE source_kind = :kind AND source_id = :sourceId")
.param("kind", kind.name())
.param("sourceId", sourceId)
.update();
for (RelationView relation : relations) {
// 이 source 가 원래 갖고 있던 id 는 그대로 둔다 — 편집기가 줄을 식별하는 키라 매 저장마다 바뀌면
// 화면의 줄이 통째로 갈아엎어진 것처럼 보인다. 그 밖의 id(남의 문서 것이거나 클라이언트가 지어낸
// 것)는 신뢰하지 않고 새로 부여한다 — 그대로 쓰면 다른 문서의 관계 행과 PK 가 충돌한다.
UUID id =
(relation.id() != null && owned.contains(relation.id()))
? relation.id()
: idGenerator.get();
jdbcClient
.sql(
"INSERT INTO studio_relation "
+ "(id, source_kind, source_id, target_id, reason, display_order) "
+ "VALUES (:id, :kind, :sourceId, :targetId, :reason, :order)")
.param("id", id)
.param("kind", kind.name())
.param("sourceId", sourceId)
.param("targetId", relation.targetId())
.param("reason", relation.reason() == null ? "" : relation.reason())
.param("order", relation.order())
.update();
}
}
void deleteBySource(RecordKind kind, UUID sourceId) {
replace(kind, sourceId, List.of());
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
/** 계약 타입과 컬럼 타입 사이의 되풀이되는 변환. */
final class StudioSqlSupport {
private StudioSqlSupport() {}
/**
* 계약의 빈 slug("아직 정하지 않음")를 NULL로 옮긴다. 빈 문자열을 그대로 넣으면 {@code ck_document_slug_non_blank} 계열 제약에
* 걸리고, 무엇보다 여러 초안이 같은 빈 slug를 갖는 순간 slug UNIQUE 제약이 두 번째 초안을 거부한다.
*/
static String slugToColumn(String slug) {
return (slug == null || slug.isBlank()) ? null : slug;
}
/** 반대 방향. 계약은 slug가 null이 아니라 빈 문자열이어야 한다고 정한다. */
static String slugFromColumn(String slug) {
return slug == null ? "" : slug;
}
static String orEmpty(String value) {
return value == null ? "" : value;
}
/**
* 계약의 {@code format: date}를 {@code timestamptz} 컬럼에 담는다. 자정 UTC로 고정한다 — 저장 시각의 로컬 타임존을 쓰면 같은 날짜가
* 서버 위치에 따라 다른 순간이 되고, 다시 읽을 때 하루가 밀린다.
*/
static Timestamp dateToTimestamp(LocalDate date) {
return date == null ? null : Timestamp.from(date.atStartOfDay(ZoneOffset.UTC).toInstant());
}
static LocalDate dateFromTimestamp(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toInstant().atZone(ZoneOffset.UTC).toLocalDate();
}
static Instant instant(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toInstant();
}
}
@@ -0,0 +1,92 @@
-- Studio WorkingCopy 계약(studio-v1.yaml)이 요구하는 편집 필드를 네 source aggregate에 채운다.
--
-- 배경: V7(= 설계 패키지 database/V1__init.sql)의 물리 스키마는 유형마다 다른 모델을 갖는데,
-- Studio 계약은 네 유형을 공통 base(WorkingCopyInputBase) + 유형별 확장이라는 하나의 편집
-- 흐름으로 다룬다. 그 공통 base와 유형별 필드 중 V7에 대응 컬럼이 없는 것만 여기서 채운다.
-- 계약에 없는 V7 컬럼(environment_items, alternatives_markdown, freshness_status 등)은
-- 건드리지 않는다 — 설계 패키지의 정본 스키마이고 public-v1 구현이 쓸 수 있다.
--
-- ADR-003과 충돌하지 않는다. ADR-003이 금지한 것은 네 Aggregate를 하나의 범용
-- `working_copy` 테이블 + 통짜 JSON으로 뭉개는 것이다. 여기서는 각 Aggregate가 자기
-- 테이블을 그대로 소유한 채 필요한 컬럼만 얻는다.
-- ── 공통 base ───────────────────────────────────────────────────────────────
-- 계약 WorkingCopyInputBase.summary (maxLength 300). open_question.summary(varchar 600)과
-- project_decision(아래)에는 각각 대응 컬럼이 있거나 새로 만든다.
ALTER TABLE document ADD COLUMN summary varchar(300) NOT NULL DEFAULT '';
-- ── CASE ────────────────────────────────────────────────────────────────────
-- 계약의 problem/conclusion은 maxLength 100000인 본문이다. V7의 *_summary는 varchar(600)이라
-- 그대로 쓰면 잘린다 — text로 넓힌다(폭을 늘리는 변경이라 기존 행에 무손실).
ALTER TABLE case_detail ALTER COLUMN problem_summary TYPE text;
ALTER TABLE case_detail ALTER COLUMN conclusion_summary TYPE text;
-- environment_items(jsonb 배열)는 계약의 environment(단일 문자열)와 다른 모양이다.
-- 계약 쪽을 담을 컬럼을 따로 둔다. 기존 컬럼은 그대로 남긴다.
ALTER TABLE case_detail ADD COLUMN environment text NOT NULL DEFAULT '';
ALTER TABLE case_detail ADD COLUMN reproduction text NOT NULL DEFAULT '';
-- ── REFERENCE ───────────────────────────────────────────────────────────────
ALTER TABLE reference_detail ALTER COLUMN scope_summary TYPE text;
-- rules[] = ReferenceRule{id,title,body,order}, examples[] = OrderedText{id,text,order}.
-- applyWhen[]/exceptions[]는 기존 applies_to/excluded_scope를 그대로 쓴다.
ALTER TABLE reference_detail ADD COLUMN rules jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(rules) = 'array');
ALTER TABLE reference_detail ADD COLUMN examples jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(examples) = 'array');
-- ── QUESTION ────────────────────────────────────────────────────────────────
-- options[] = QuestionOption{id,title,description,order}.
-- facts/assumptions/unknowns/constraints는 기존 question_point(point_kind)로 간다.
ALTER TABLE open_question ADD COLUMN options jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(options) = 'array');
-- QuestionResolution{summary, evidenceTargetId, linkLabel} 중 summary만 V7에 있다.
ALTER TABLE open_question ADD COLUMN resolution_evidence_target_id uuid;
ALTER TABLE open_question ADD COLUMN resolution_link_label varchar(120) NOT NULL DEFAULT '';
-- ── PROJECT_DECISION ────────────────────────────────────────────────────────
-- 계약은 PROJECT_DECISION도 다른 셋과 같은 공통 base(title/slug/summary/topicId)로 편집한다.
-- V7의 project_decision에는 statement/rationale/consequences만 있다.
ALTER TABLE project_decision ADD COLUMN title varchar(120) NOT NULL DEFAULT '';
ALTER TABLE project_decision ADD COLUMN slug varchar(180);
ALTER TABLE project_decision ADD COLUMN summary varchar(300) NOT NULL DEFAULT '';
ALTER TABLE project_decision ADD COLUMN primary_topic_id uuid REFERENCES topic(id);
-- 계약 statement는 maxLength 100000이다. varchar(1000)이면 잘린다.
ALTER TABLE project_decision ALTER COLUMN statement TYPE text;
-- 계약은 PROJECT_DECISION 의 projectId 를 "게시 시점에 non-null, 저장 시점에는 강제하지 않음"으로
-- 정한다(studio-v1.yaml WorkingCopyInputBase.projectId). V7 의 NOT NULL 은 그 초안 저장을
-- 구조적으로 불가능하게 만든다 — 게시 필수 여부는 검증이 판단하도록 컬럼 제약을 푼다.
ALTER TABLE project_decision ALTER COLUMN project_id DROP NOT NULL;
ALTER TABLE project_decision ADD CONSTRAINT uq_project_decision_slug UNIQUE (slug);
ALTER TABLE project_decision ADD CONSTRAINT ck_project_decision_slug_non_blank
CHECK (slug IS NULL OR length(trim(slug)) > 0);
-- ── relations ───────────────────────────────────────────────────────────────
-- 계약의 relations[]는 네 유형 공통 base에 있고 항목이 {id, targetId, reason, order}다.
-- V7의 document_relation은 (source, target, relation_type) 복합 PK라 항목 자체의 id도
-- reason도 없고 document끼리만 성립한다 — QUESTION/PROJECT_DECISION의 relations를 담을 수
-- 없다. 그래서 Studio 편집용 관계는 전용 테이블에 둔다. document_relation은 설계의 공개
-- 렌더링용 유형 관계로 그대로 남는다.
--
-- (source_kind, source_id) 다형 참조는 이 스키마가 studio_validation/studio_preview에서
-- 이미 쓰는 방식과 같다. 다형 참조라 FK를 걸 수 없으므로 부모 삭제 시 정리는 application이
-- 책임진다.
--
-- target_id가 nullable인 것은 계약(RelationInput.targetId: [string,"null"])을 따른 것이다 —
-- 아직 대상을 고르지 않은 관계 줄도 저장할 수 있어야 한다. 게시 가능 여부는 검증이 판단한다.
CREATE TABLE studio_relation (
id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
target_id uuid,
reason text NOT NULL DEFAULT '',
display_order integer NOT NULL CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_studio_relation_order UNIQUE (source_kind, source_id, display_order),
CONSTRAINT ck_studio_relation_not_self CHECK (target_id IS NULL OR target_id <> source_id)
);
CREATE INDEX idx_studio_relation_source ON studio_relation (source_kind, source_id);
CREATE INDEX idx_studio_relation_target ON studio_relation (target_id)
WHERE target_id IS NOT NULL;
@@ -0,0 +1,716 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcPreviewArtifactAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcValidationArtifactAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcDependencyRevisionAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDashboardQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDependencyResolverAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDocumentQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset.JdbcAssetRepositoryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationHistoryQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationWriterAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.JdbcWorkingCopyRepositoryAdapter;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.Set;
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;
/**
* Studio 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
*
* <p>이 테스트가 필요한 이유는 분명하다 — 이 저장소의 표준 {@code check} 는 Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이
* 테스트 없이는 <b>한 번도 실행되지 않은 채</b> 통과한다. 컴파일과 단위 테스트는 컬럼 이름 오타도, jsonb 캐스팅 누락도, 순환 FK 의 지연 검사도 검증하지
* 못한다.
*
* <p>{@code JdbcCatalogQueryAdapterTest} 와 같은 형제 패턴을 쓴다 — 이 모듈에는 {@code @SpringBootConfiguration} 이
* 없으므로 Testcontainers + 순수 Flyway + 직접 조립이다.
*/
class StudioPersistenceIntegrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcWorkingCopyRepositoryAdapter workingCopies;
private static JdbcStudioDocumentQueryAdapter documents;
private static JdbcStudioDependencyResolverAdapter dependencies;
private static JdbcDependencyRevisionAdapter dependencyRevisions;
private static JdbcValidationArtifactAdapter validations;
private static JdbcPreviewArtifactAdapter previews;
private static JdbcPublicationWriterAdapter publicationWriter;
private static JdbcPublicationHistoryQueryAdapter publicationHistory;
private static JdbcStudioDashboardQueryAdapter dashboard;
private static JdbcAssetRepositoryAdapter assets;
private static org.springframework.transaction.support.TransactionTemplate transactions;
private static UUID topicId;
private static UUID projectId;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the Studio 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();
workingCopies = new JdbcWorkingCopyRepositoryAdapter(jdbcClient, objectMapper);
documents = new JdbcStudioDocumentQueryAdapter(jdbcClient);
dependencies = new JdbcStudioDependencyResolverAdapter(jdbcClient);
dependencyRevisions = new JdbcDependencyRevisionAdapter(jdbcClient);
validations = new JdbcValidationArtifactAdapter(jdbcClient, objectMapper);
previews = new JdbcPreviewArtifactAdapter(jdbcClient);
publicationWriter = new JdbcPublicationWriterAdapter(jdbcClient, objectMapper);
publicationHistory = new JdbcPublicationHistoryQueryAdapter(jdbcClient);
dashboard = new JdbcStudioDashboardQueryAdapter(jdbcClient);
assets = new JdbcAssetRepositoryAdapter(jdbcClient);
// 게시는 프로덕션에서 TransactionPort.inWrite 안에서 돈다. 순환 FK 의 지연 검사가 성립하려면
// 트랜잭션이 반드시 있어야 하므로 테스트도 같은 조건에서 호출한다.
transactions =
new org.springframework.transaction.support.TransactionTemplate(
new org.springframework.jdbc.support.JdbcTransactionManager(dataSource));
topicId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, 'Kafka', 'kafka', 'kafka', 'test', 'test')")
.param("id", topicId)
.update();
projectId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project (id, slug, name, created_by, updated_by)"
+ " VALUES (:id, 'tech-log', 'Tech Log', 'test', 'test')")
.param("id", projectId)
.update();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
private static WorkingCopyBaseInput base(RecordKind kind, String title, String slug) {
return new WorkingCopyBaseInput(kind, title, slug, "요약", topicId, projectId, List.of());
}
@Test
void v8AddsEveryColumnTheStudioContractNeeds() {
List<String> caseColumns = columnsOf("case_detail");
assertThat(caseColumns).contains("environment", "reproduction");
assertThat(columnsOf("reference_detail")).contains("rules", "examples");
assertThat(columnsOf("open_question"))
.contains("options", "resolution_evidence_target_id", "resolution_link_label");
assertThat(columnsOf("project_decision"))
.contains("title", "slug", "summary", "primary_topic_id");
assertThat(columnsOf("document")).contains("summary");
assertThat(columnsOf("studio_relation")).contains("source_kind", "source_id", "target_id");
// 계약이 허용한 "프로젝트 없는 결정 초안" 저장이 가능해야 한다.
assertThat(isNullable("project_decision", "project_id")).isTrue();
// 계약의 본문 길이는 100000 이라 varchar(600)/varchar(1000) 로는 담을 수 없다.
assertThat(typeOf("case_detail", "problem_summary")).isEqualTo("text");
assertThat(typeOf("project_decision", "statement")).isEqualTo("text");
}
@Test
void aCaseWorkingCopyRoundTripsThroughEveryColumnItTouches() {
WorkingCopyView created =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "장애 사례", "outage-case"),
"문제",
"결론",
"환경",
"재현",
LocalDate.of(2026, 8, 1),
"본문 :::evidence key=\"x\""),
"tester");
assertThat(created.version()).as("계약의 version 은 minimum 1 이다").isEqualTo(1L);
WorkingCopyView loaded = workingCopies.find(created.id()).orElseThrow();
assertThat(loaded).isInstanceOf(WorkingCopyView.CaseWorkingCopyView.class);
WorkingCopyView.CaseWorkingCopyView value = (WorkingCopyView.CaseWorkingCopyView) loaded;
assertThat(value.problem()).isEqualTo("문제");
assertThat(value.reproduction()).isEqualTo("재현");
assertThat(value.lastVerifiedOn()).isEqualTo(LocalDate.of(2026, 8, 1));
assertThat(value.base().topicId()).isEqualTo(topicId);
assertThat(value.base().projectId()).as("프로젝트 링크 테이블 왕복").isEqualTo(projectId);
assertThat(workingCopies.findKind(created.id())).contains(RecordKind.CASE);
}
@Test
void savingWithTheWrongVersionChangesNothing() {
WorkingCopyView created =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "낙관적 잠금", "optimistic-lock"), "", "", "", "", null, ""),
"tester");
Optional<WorkingCopyView> conflict =
workingCopies.save(
created.id(),
created.version() + 99,
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "덮어쓰기 시도", "optimistic-lock"), "", "", "", "", null, ""),
"tester");
assertThat(conflict).isEmpty();
assertThat(workingCopies.find(created.id()).orElseThrow().base().title()).isEqualTo("낙관적 잠금");
WorkingCopyView saved =
workingCopies
.save(
created.id(),
created.version(),
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "정상 저장", "optimistic-lock"), "", "", "", "", null, ""),
"tester")
.orElseThrow();
assertThat(saved.version()).isEqualTo(created.version() + 1);
assertThat(saved.base().title()).isEqualTo("정상 저장");
}
@Test
void theOtherThreeKindsRoundTripThroughTheirOwnTables() {
WorkingCopyView reference =
workingCopies.create(
new WorkingCopyInputView.ReferenceInputView(
base(RecordKind.REFERENCE, "기준 문서", "reference-doc"),
"목적",
List.of(new ReferenceRuleView(UUID.randomUUID(), "규칙", "본문", 0)),
List.of(new OrderedTextView(UUID.randomUUID(), "적용", 0)),
List.of(),
List.of(new OrderedTextView(UUID.randomUUID(), "예시", 0)),
LocalDate.of(2026, 7, 1)),
"tester");
WorkingCopyView.ReferenceWorkingCopyView loadedReference =
(WorkingCopyView.ReferenceWorkingCopyView) workingCopies.find(reference.id()).orElseThrow();
assertThat(loadedReference.rules())
.singleElement()
.extracting(ReferenceRuleView::title)
.isEqualTo("규칙");
assertThat(loadedReference.examples())
.singleElement()
.extracting(OrderedTextView::text)
.isEqualTo("예시");
WorkingCopyView question =
workingCopies.create(
new WorkingCopyInputView.QuestionInputView(
base(RecordKind.QUESTION, "미해결 질문", "open-question"),
QuestionStatusView.OPEN,
List.of(new OrderedTextView(UUID.randomUUID(), "사실", 0)),
List.of(),
List.of(new OrderedTextView(UUID.randomUUID(), "모르는 것", 0)),
List.of(),
List.of(),
"다음 검증",
null),
"tester");
WorkingCopyView.QuestionWorkingCopyView loadedQuestion =
(WorkingCopyView.QuestionWorkingCopyView) workingCopies.find(question.id()).orElseThrow();
assertThat(loadedQuestion.facts())
.singleElement()
.extracting(OrderedTextView::text)
.isEqualTo("사실");
assertThat(loadedQuestion.unknowns()).hasSize(1);
assertThat(loadedQuestion.nextValidation()).isEqualTo("다음 검증");
assertThat(loadedQuestion.base().projectId()).isEqualTo(projectId);
WorkingCopyView decision =
workingCopies.create(
new WorkingCopyInputView.ProjectDecisionInputView(
base(RecordKind.PROJECT_DECISION, "결정", "a-decision"),
DecisionStatusView.ADOPTED,
LocalDate.of(2026, 6, 1),
"결정문",
"근거",
List.of(new OrderedTextView(UUID.randomUUID(), "결과", 0))),
"tester");
WorkingCopyView.ProjectDecisionWorkingCopyView loadedDecision =
(WorkingCopyView.ProjectDecisionWorkingCopyView)
workingCopies.find(decision.id()).orElseThrow();
// 계약의 ADOPTED 는 Domain 의 ACCEPTED 다(ADR-003).
assertThat(loadedDecision.decisionStatus()).isEqualTo(DecisionStatusView.ADOPTED);
assertThat(storedDecisionStatus(decision.id())).isEqualTo("ACCEPTED");
assertThat(loadedDecision.consequences()).hasSize(1);
}
@Test
void relationsSurviveAReplaceAndKeepTheirIdentity() {
WorkingCopyView target =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "관계 대상", "relation-target"), "", "", "", "", null, ""),
"tester");
RelationView relation = new RelationView(null, target.id(), "왜 관련 있는지", 0);
WorkingCopyView source =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
new WorkingCopyBaseInput(
RecordKind.CASE,
"관계 원본",
"relation-source",
"요약",
topicId,
projectId,
List.of(relation)),
"",
"",
"",
"",
null,
""),
"tester");
List<RelationView> stored = workingCopies.find(source.id()).orElseThrow().base().relations();
assertThat(stored).singleElement().extracting(RelationView::reason).isEqualTo("왜 관련 있는지");
UUID relationId = stored.getFirst().id();
assertThat(relationId).isNotNull();
WorkingCopyView saved =
workingCopies
.save(
source.id(),
source.version(),
new WorkingCopyInputView.CaseInputView(
new WorkingCopyBaseInput(
RecordKind.CASE,
"관계 원본",
"relation-source",
"요약",
topicId,
projectId,
List.of(new RelationView(relationId, target.id(), "이유 수정", 0))),
"",
"",
"",
"",
null,
""),
"tester")
.orElseThrow();
List<RelationView> afterSave = saved.base().relations();
assertThat(afterSave).singleElement().extracting(RelationView::reason).isEqualTo("이유 수정");
assertThat(afterSave.getFirst().id()).as("이 문서가 소유하던 관계 id 는 저장해도 유지된다").isEqualTo(relationId);
}
@Test
void theUnionQueryComputesDependencyRevisionAndNextAction() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "목록 대상", "list-target"), "", "", "", "", null, ""),
"tester");
var page =
documents.list(
new ListDocumentsQuery(
"목록", RecordKind.CASE, null, null, projectId, DocumentSort.UPDATED_DESC, null, 20));
assertThat(page.items()).extracting(item -> item.id()).contains(document.id());
assertThat(page.items().getFirst().nextAction())
.as("검증한 적이 없으면 다음 행동은 VALIDATE 다")
.isEqualTo(NextAction.VALIDATE);
String revision = dependencyRevisions.revisionFor(RecordKind.CASE, document.id());
assertThat(revision).isNotBlank();
assertThat(dependencyRevisions.revisionFor(RecordKind.CASE, document.id()))
.as("같은 상태면 같은 값이어야 한다")
.isEqualTo(revision);
// 제목이 비면 검증할 의미가 없으므로 CONTINUE_EDITING 이다.
workingCopies.save(
document.id(),
document.version(),
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "", "list-target"), "", "", "", "", null, ""),
"tester");
var blankTitlePage =
documents.list(
new ListDocumentsQuery(
null,
RecordKind.CASE,
null,
NextAction.CONTINUE_EDITING,
projectId,
DocumentSort.UPDATED_DESC,
null,
20));
assertThat(blankTitlePage.items()).extracting(item -> item.id()).contains(document.id());
}
@Test
void dependencyResolutionFindsTopicProjectRelationsAndSlugOwners() {
WorkingCopyView first =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "슬러그 주인", "shared-slug"), "", "", "", "", null, ""),
"tester");
WorkingCopyView second =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "슬러그 경쟁", ""), "", "", "", "", null, ""),
"tester");
StudioDependencyResolverPort.Resolved resolved =
dependencies.resolve(workingCopies.find(first.id()).orElseThrow(), Set.of());
assertThat(resolved.topic()).isNotNull();
assertThat(resolved.topic().publicPath()).isEqualTo("/topics/kafka");
assertThat(resolved.project().publicPath()).isEqualTo("/projects/tech-log");
assertThat(resolved.publicPath()).isEqualTo("/cases/shared-slug");
assertThat(resolved.topicMissing()).isFalse();
assertThat(resolved.slugOwnerId()).as("자기 자신은 slug 충돌이 아니다").isNull();
// 빈 slug 는 NULL 로 저장되므로 UNIQUE 제약을 여러 초안이 함께 지날 수 있다.
assertThat(workingCopies.find(second.id()).orElseThrow().base().slug()).isEmpty();
}
@Test
void validationAndPreviewArtifactsPersistAndReadBack() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "artifact", "artifact-case"), "문제", "결론", "", "", null, ""),
"tester");
Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);
ValidationReportView report =
new ValidationReportView(
UUID.randomUUID(),
document.id(),
document.version(),
ValidationStatus.WARNINGS,
List.of(
new dev.caskeleton.application.techlog.studio.model.ValidationIssueView(
"BODY_EMPTY",
dev.caskeleton.application.techlog.studio.model.ValidationSeverity.WARNING,
"/bodyMarkdown",
"본문이 비어 있다")),
now,
now.plusSeconds(3600),
"rev-1");
validations.save(RecordKind.CASE, report, "tester");
ValidationReportView loaded = validations.findById(report.validationId()).orElseThrow();
assertThat(loaded.status()).isEqualTo(ValidationStatus.WARNINGS);
assertThat(loaded.issues())
.singleElement()
.extracting(issue -> issue.code())
.isEqualTo("BODY_EMPTY");
assertThat(validations.latestFor(RecordKind.CASE, document.id())).isPresent();
PublicPreviewView preview =
new PublicPreviewView(
UUID.randomUUID(),
document.id(),
document.version(),
report.validationId(),
"rev-1",
now,
now.plusSeconds(3600),
"{\"kind\":\"CASE\"}");
previews.save(RecordKind.CASE, preview, "tester");
// jsonb 컬럼은 PostgreSQL 이 정규화해 되돌려준다(공백·키 순서가 입력과 같지 않다). 계약 DTO 로
// 다시 파싱해 쓰는 값이라 의미는 보존되지만, 바이트 동일성을 기대하면 안 된다.
String storedRenderModel =
previews.findById(preview.previewId()).orElseThrow().renderModelJson();
assertThat(new ObjectMapper().readTree(storedRenderModel).path("kind").asString(""))
.isEqualTo("CASE");
}
@Test
void publishWritesTheWholeAggregateAndUnpublishWithdrawsIt() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "게시 대상", "publish-target"), "문제", "결론", "", "", null, "본문"),
"tester");
PublishResultView published =
transactions.execute(
status ->
publicationWriter.publish(
new PublicationWriterPort.PublishRequest(
RecordKind.CASE,
document.id(),
document.version(),
"게시 대상",
"요약",
topicId,
projectId,
"/cases/publish-target",
null,
"{\"kind\":\"CASE\"}",
"본문 평문",
List.of(),
"1",
"1",
"idem-1",
"tester")));
assertThat(published.publication().status()).isEqualTo(PublicationAggregateStatus.PUBLISHED);
assertThat(published.publication().publicationRevision())
.as("첫 게시의 revision 은 1 이다 — INSERT 가 넣은 값을 UPDATE 가 또 올리면 안 된다")
.isEqualTo(1L);
assertThat(published.event().snapshotAvailable()).isTrue();
// 순환 FK(publication.latest_event_id <-> publication_event)가 지연 검사로 통과해야 한다.
assertThat(published.publication().latestEventId())
.isEqualTo(published.event().publicationEventId());
assertThat(count("public_resource_projection", "resource_id", document.id())).isEqualTo(1);
assertThat(count("public_route", "resource_id", document.id())).isEqualTo(1);
assertThat(
jdbcClient
.sql("SELECT workflow_status FROM document WHERE id = :id")
.param("id", document.id())
.query(String.class)
.single())
.isEqualTo("PUBLISHED");
// 재게시는 REPUBLISHED 이벤트와 revision 증가를 만든다.
PublishResultView republished =
transactions.execute(
status ->
publicationWriter.publish(
new PublicationWriterPort.PublishRequest(
RecordKind.CASE,
document.id(),
document.version(),
"게시 대상",
"요약",
topicId,
projectId,
"/cases/publish-target",
null,
"{\"kind\":\"CASE\"}",
"본문 평문",
List.of(),
"1",
"1",
"idem-2",
"tester")));
assertThat(republished.event().type())
.isEqualTo(
dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView.REPUBLISHED);
assertThat(republished.publication().publicationRevision()).isEqualTo(2L);
var page = publicationHistory.list(new ListPublicationsQuery(null, null, null, 20));
assertThat(page.items()).isNotEmpty();
assertThat(page.items().getFirst().document()).isNotNull();
assertThat(publicationHistory.findSnapshot(published.event().publicationEventId())).isPresent();
PublishResultView withdrawn =
transactions.execute(
status ->
publicationWriter.unpublish(
new PublicationWriterPort.UnpublishRequest(
published.publication().publicationId(),
RecordKind.CASE,
document.id(),
republished.publication().publicationRevision(),
"tester")));
assertThat(withdrawn.publication().status()).isEqualTo(PublicationAggregateStatus.UNPUBLISHED);
assertThat(withdrawn.event().sourcePublishedEventId())
.as("UNPUBLISHED 이벤트는 마지막 공개 Snapshot 을 반드시 참조한다")
.isNotNull();
assertThat(
jdbcClient
.sql(
"SELECT publication_state FROM public_resource_projection"
+ " WHERE resource_id = :id")
.param("id", document.id())
.query(String.class)
.single())
.isEqualTo("WITHDRAWN");
assertThat(count("public_route", "resource_id", document.id()))
.as("게시 취소는 주소를 지우지 않는다 — 지우면 공개된 링크가 끊긴다")
.isEqualTo(1);
}
@Test
void dashboardCountsUseTheSameProjectionAsTheList() {
var totals = dashboard.totals();
assertThat(totals.documents()).isPositive();
assertThat(dashboard.topByNextAction(List.of(NextAction.VALIDATE), 5)).isNotNull();
}
@Test
void assetsRoundTripAndReportTheirUsage() {
UUID assetId = UUID.randomUUID();
AssetView created =
assets.create(
new AssetRepositoryPort.NewAsset(
assetId,
"diagram-key",
AssetKindView.DIAGRAM,
"image/png",
"techlog/assets/" + assetId,
"diagram.png",
1024L,
800,
600,
"a".repeat(64),
"설명",
false,
AssetManagementStatusView.READY),
"tester");
assertThat(created.version()).as("계약의 Asset.version 은 minimum 1 이다").isEqualTo(1L);
assertThat(created.publicPath()).isEqualTo("/media/" + assetId);
assertThat(created.usageCount()).isZero();
var page =
assets.list(new ListAssetsQuery("diagram", AssetKindView.DIAGRAM, null, null, null, 20));
assertThat(page.items()).extracting(AssetView::id).contains(assetId);
AssetView updated =
assets
.update(
assetId,
created.version(),
null,
null,
true,
Boolean.TRUE,
AssetManagementStatusView.ARCHIVED,
"tester")
.orElseThrow();
assertThat(updated.altText()).as("altTextProvided=true 는 null 로 지우는 것을 뜻한다").isNull();
assertThat(updated.decorative()).isTrue();
assertThat(updated.managementStatus()).isEqualTo(AssetManagementStatusView.ARCHIVED);
assertThat(updated.version()).isEqualTo(2L);
assertThat(assets.update(assetId, 99L, null, null, false, null, null, "tester")).isEmpty();
assertThat(assets.findObjectKey(assetId)).contains("techlog/assets/" + assetId);
var detail = assets.findDetail(assetId).orElseThrow();
assertThat(detail.hasPublicationHistory()).isFalse();
assertThat(detail.usages()).isEmpty();
assets.delete(assetId);
assertThat(assets.find(assetId)).isEmpty();
}
private static List<String> columnsOf(String table) {
return jdbcClient
.sql("SELECT column_name FROM information_schema.columns WHERE table_name = :table")
.param("table", table)
.query(String.class)
.list();
}
private static boolean isNullable(String table, String column) {
return "YES"
.equals(
jdbcClient
.sql(
"SELECT is_nullable FROM information_schema.columns"
+ " WHERE table_name = :table AND column_name = :column")
.param("table", table)
.param("column", column)
.query(String.class)
.single());
}
private static String typeOf(String table, String column) {
return jdbcClient
.sql(
"SELECT data_type FROM information_schema.columns"
+ " WHERE table_name = :table AND column_name = :column")
.param("table", table)
.param("column", column)
.query(String.class)
.single();
}
private static String storedDecisionStatus(UUID id) {
return jdbcClient
.sql("SELECT decision_status FROM project_decision WHERE id = :id")
.param("id", id)
.query(String.class)
.single();
}
private static int count(String table, String column, UUID id) {
return jdbcClient
.sql("SELECT count(*) FROM " + table + " WHERE " + column + " = :id")
.param("id", id)
.query(Integer.class)
.single();
}
}
+5
View File
@@ -210,6 +210,10 @@ org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.commonmark:commonmark-ext-autolink:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-tables:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.commonmark:commonmark:0.21.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.dom4j:dom4j:2.2.0=spotbugs
org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-common:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -248,6 +252,7 @@ org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.nibor.autolink:autolink:0.10.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -200,6 +200,39 @@ class StudioContractDriftTest {
.isNotEmpty();
}
/**
* 반대 방향 — 계약의 operation 이 전부 published 표면에 있는가.
*
* <p>슬라이스 2~5 가 끝나 19개 operation 이 모두 구현됐으므로 이제 "published ⊆ 계약" 한 방향만으로는 부족하다. 그 방향은
* <b>사라진</b> operation 을 잡지 못한다 — 컨트롤러를 지우거나 매핑을 잘못 옮겨도 남은 것들이 계약과 맞으면 통과한다. 양방향이 되어야 게이트가
* 완성된다.
*
* <p>새 operation 을 계약에 추가하면 이 테스트가 먼저 빨간불이 된다. 그게 의도다 — 계약이 약속한 것을 서버가 아직 제공하지 않는다는 사실이 배포 전에
* 드러나야 한다.
*/
@Test
void everyContractOperationIsPublished() throws Exception {
JsonNode contract = readContract();
JsonNode published = readPublishedApiDocs();
List<String> missing = new ArrayList<>();
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;
}
JsonNode publishedOperation =
published.path("paths").path(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();
}
/**
* SnakeYaml(이미 {@code StudioErrorRegistryTest}가 error-codes.yaml에 쓰는 라이브러리)로 읽은 뒤 {@code
* ObjectMapper#valueToTree}로 {@link JsonNode}로 옮긴다 — {@code jackson-dataformat-yaml}을 새 컴파일
@@ -245,7 +278,7 @@ class StudioContractDriftTest {
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@Import(PresentationWebConfig.class)
@Import({PresentationWebConfig.class, StudioContractDriftTest.StudioDocumentTestBeans.class})
static class ContractSurfaceApp {
/**
@@ -301,7 +334,11 @@ class StudioContractDriftTest {
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@Import({EnvelopeBodyAdvice.class, PresentationWebConfig.class})
@Import({
EnvelopeBodyAdvice.class,
PresentationWebConfig.class,
StudioContractDriftTest.StudioDocumentTestBeans.class
})
static class EnvelopeApp {
/**
@@ -350,6 +387,482 @@ class StudioContractDriftTest {
return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort());
}
/**
* 슬라이스 2의 {@code StudioDocumentController}가 {@code @ComponentScan}에 걸리면서 필요해진 협력자들.
*
* <p>이 비용은 이 게이트가 "패키지를 스캔한다"는 성질의 뒷면이다 — 새 컨트롤러가 자동으로 감시 대상이 되는 대신, 그 컨트롤러의 협력자를 여기에 채워야 컨텍스트가
* 뜬다. 채우지 않으면 게이트가 통과가 아니라 실패로 알려준다.
*
* <p>포트 구현은 전부 빈 stub 이다. 첫 번째 테스트는 springdoc 리플렉션이라 컨트롤러 메서드를 아예 호출하지 않고, 두 번째 테스트는 catalog
* 엔드포인트만 두드린다.
*/
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
static class StudioDocumentTestBeans {
@Bean
java.time.Clock studioTestClock() {
return java.time.Clock.systemUTC();
}
@Bean
dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings studioSettings() {
return new dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings(
"functional-test-cursor-signing-key", null, null);
}
@Bean
dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport idempotencyKeySupport(
tools.jackson.databind.ObjectMapper objectMapper) {
return new dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport(objectMapper);
}
@Bean
dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase
listStudioDocumentsUseCase() {
return new dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase(
query ->
new dev.caskeleton.application.techlog.studio.model.DocumentPageView(List.of(), null),
new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase
createStudioDocumentUseCase() {
return new dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase(
new StubWorkingCopyRepositoryPort(), new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase
getStudioDocumentUseCase() {
return new dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase(
new StubWorkingCopyRepositoryPort(), assembler(), new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase
saveStudioDocumentUseCase() {
return new dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase(
new StubWorkingCopyRepositoryPort(), assembler(), new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader studioDocumentLoader() {
return new dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader(
new StubWorkingCopyRepositoryPort());
}
@Bean
dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
studioDependencyResolverPort() {
return (document, keys) ->
new dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
.Resolved(
null,
false,
null,
false,
List.of(),
List.of(),
java.util.Map.of(),
java.util.Map.of(),
null,
null,
null);
}
@Bean
dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase
validateStudioDocumentUseCase(
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents,
dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
dependencies,
dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort
contentAnalyzer) {
return new dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase(
documents,
dependencies,
contentAnalyzer,
(kind, id) -> "test-dependency-revision",
new StubValidationArtifactPort(),
new PassThroughTransactionPort(),
java.util.UUID::randomUUID,
java.time.Clock.systemUTC(),
java.time.Duration.ofHours(1));
}
@Bean
dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase
createStudioPreviewUseCase(
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents,
dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
dependencies,
dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort contentAnalyzer,
dev.caskeleton.application.techlog.studio.port.out.RenderModelPort renderer) {
return new dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase(
documents,
dependencies,
contentAnalyzer,
(kind, id) -> "test-dependency-revision",
new StubValidationArtifactPort(),
new StubPreviewArtifactPort(),
renderer,
new PassThroughTransactionPort(),
java.util.UUID::randomUUID,
java.time.Clock.systemUTC(),
java.time.Duration.ofHours(24));
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase
getCurrentStudioPreviewUseCase(
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents) {
return new dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase(
documents,
new StubPreviewArtifactPort(),
new StubValidationArtifactPort(),
(kind, id) -> "test-dependency-revision",
new PassThroughTransactionPort(),
java.time.Clock.systemUTC());
}
@Bean
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort
publicationHistoryQueryPort() {
return new StubPublicationHistoryQueryPort();
}
@Bean
dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase
publishStudioDocumentUseCase(
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents,
dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort
dependencies,
dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort
contentAnalyzer) {
return new dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase(
documents,
dependencies,
contentAnalyzer,
(kind, id) -> "test-dependency-revision",
new StubValidationArtifactPort(),
new StubPreviewArtifactPort(),
new StubPublicationWriterPort(),
new PassThroughTransactionPort(),
java.time.Clock.systemUTC());
}
@Bean
dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase
unpublishStudioPublicationUseCase(
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort history,
dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader documents) {
return new dev.caskeleton.application.techlog.studio.service
.UnpublishStudioPublicationUseCase(
history, new StubPublicationWriterPort(), documents, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase
listStudioPublicationsUseCase(
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort
history) {
return new dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase(
history, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase
getStudioPublicationSnapshotUseCase(
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort
history) {
return new dev.caskeleton.application.techlog.studio.service
.GetStudioPublicationSnapshotUseCase(history, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase
getStudioDashboardUseCase(
dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort
history) {
return new dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase(
new StubStudioDashboardQueryPort(), history, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assetRepositoryPort() {
return new StubAssetRepositoryPort();
}
@Bean
dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort
assetBinaryStoragePort() {
return new dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort() {
@Override
public String store(String objectKey, byte[] content, String mediaType) {
return objectKey;
}
@Override
public void delete(String objectKey) {
// 이 게이트는 바이너리를 다루지 않는다.
}
};
}
@Bean
dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase
listStudioAssetsUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) {
return new dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase(
assets, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase
uploadStudioAssetUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets,
dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries) {
return new dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase(
assets, binaries, new PassThroughTransactionPort(), java.util.UUID::randomUUID);
}
@Bean
dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase getStudioAssetUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) {
return new dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase(
assets, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase
updateStudioAssetUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets) {
return new dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase(
assets, new PassThroughTransactionPort());
}
@Bean
dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase
deleteStudioAssetUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets,
dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries) {
return new dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase(
assets, binaries, new PassThroughTransactionPort());
}
private static dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler
assembler() {
return new dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler(
new StubValidationArtifactPort(),
new StubPreviewArtifactPort(),
documentId -> java.util.Optional.empty(),
(kind, documentId) -> "test-dependency-revision",
java.time.Clock.systemUTC());
}
}
private static final class StubWorkingCopyRepositoryPort
implements dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort {
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.RecordKind> findKind(
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.WorkingCopyView> find(
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.WorkingCopyView create(
dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView input,
String principal) {
throw new UnsupportedOperationException("this contract-shape gate never creates a document");
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.WorkingCopyView> save(
java.util.UUID documentId,
long expectedVersion,
dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView input,
String principal) {
return java.util.Optional.empty();
}
}
private static final class StubValidationArtifactPort
implements dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort {
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.ValidationReportView>
latestFor(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.ValidationReportView>
findById(java.util.UUID validationId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.ValidationReportView save(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
dev.caskeleton.application.techlog.studio.model.ValidationReportView report,
String principal) {
return report;
}
}
private static final class StubPreviewArtifactPort
implements dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort {
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.PublicPreviewView>
latestFor(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.PublicPreviewView>
findById(java.util.UUID previewId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.PublicPreviewView save(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
dev.caskeleton.application.techlog.studio.model.PublicPreviewView preview,
String principal) {
return preview;
}
}
private static final class StubPublicationWriterPort
implements dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort {
@Override
public java.util.Optional<
dev.caskeleton.application.techlog.studio.model.PublicationAggregateView>
lockCurrentPublication(
dev.caskeleton.application.techlog.studio.model.RecordKind kind,
java.util.UUID documentId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.PublishResultView publish(
PublishRequest request) {
throw new UnsupportedOperationException("this contract-shape gate never publishes");
}
@Override
public dev.caskeleton.application.techlog.studio.model.PublishResultView unpublish(
UnpublishRequest request) {
throw new UnsupportedOperationException("this contract-shape gate never unpublishes");
}
}
private static final class StubPublicationHistoryQueryPort
implements dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort {
@Override
public dev.caskeleton.application.techlog.studio.model.PublicationPageView list(
dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery query) {
return new dev.caskeleton.application.techlog.studio.model.PublicationPageView(
List.of(), null);
}
@Override
public java.util.Optional<
dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView>
findSnapshot(java.util.UUID publicationEventId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<
dev.caskeleton.application.techlog.studio.model.PublicationAggregateView>
findById(java.util.UUID publicationId) {
return java.util.Optional.empty();
}
}
private static final class StubStudioDashboardQueryPort
implements dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort {
@Override
public List<dev.caskeleton.application.techlog.studio.model.DocumentSummaryView>
topByNextAction(
List<dev.caskeleton.application.techlog.studio.model.NextAction> actions, int limit) {
return List.of();
}
@Override
public dev.caskeleton.application.techlog.studio.model.DashboardTotalsView totals() {
return new dev.caskeleton.application.techlog.studio.model.DashboardTotalsView(0, 0, 0, 0);
}
}
private static final class StubAssetRepositoryPort
implements dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort {
@Override
public dev.caskeleton.application.techlog.studio.model.AssetPageView list(
dev.caskeleton.application.techlog.studio.query.ListAssetsQuery query) {
return new dev.caskeleton.application.techlog.studio.model.AssetPageView(List.of(), null);
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetView> find(
java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetDetailView>
findDetail(java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.AssetView create(
NewAsset asset, String principal) {
throw new UnsupportedOperationException("this contract-shape gate never stores an asset");
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetView> update(
java.util.UUID assetId,
long expectedVersion,
dev.caskeleton.application.techlog.studio.model.AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView managementStatus,
String principal) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<String> findObjectKey(java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public void delete(java.util.UUID assetId) {
// 이 게이트는 삭제하지 않는다.
}
}
private static final class StubCatalogQueryPort implements CatalogQueryPort {
@Override
public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) {
@@ -0,0 +1,51 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.storage.ObjectStoragePort;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort;
import org.springframework.beans.factory.ObjectProvider;
/**
* Studio Asset 바이너리를 기존 object storage 어댑터에 위임한다(spec §9 — 새 저장 계층을 만들지 않는다).
*
* <p>이 브리지가 app-bootstrap 에 있는 이유: 두 기존 포트를 잇는 <b>구성</b>이라 어느 한쪽 어댑터 모듈의 소유가 아니다. objectstorage 모듈은
* techlog 를 모르고, techlog 영속 모듈은 저장 백엔드를 모른다.
*
* <p>{@code ObjectStoragePort} 는 {@code @Deprecated(forRemoval = true)} 다. 그럼에도 쓰는 이유는 이 저장소에서 실제로
* 동작하는 어댑터(filesystem/S3)가 붙어 있는 유일한 포트이기 때문이다 — 후속 {@code objectstorage.port.*} 계열에는 아직 구현이
* 없다(실측). 선택을 이 한 클래스에 가둬 두었으므로 새 API 로 옮길 때 바뀌는 것은 여기뿐이다.
*
* <p>저장 백엔드가 구성되지 않은 배포에서는 빈이 없다. 그때는 업로드·삭제만 {@code STUDIO_UNAVAILABLE} 로 거절하고 목록·조회·메타데이터 수정은 그대로
* 동작한다 — 없는 기능 때문에 있는 기능까지 막지 않는다.
*/
@SuppressWarnings("removal")
final class ObjectStorageAssetBinaryAdapter implements AssetBinaryStoragePort {
private final ObjectProvider<ObjectStoragePort> objectStorage;
ObjectStorageAssetBinaryAdapter(ObjectProvider<ObjectStoragePort> objectStorage) {
this.objectStorage = objectStorage;
}
@Override
public String store(String objectKey, byte[] content, String mediaType) {
return require().put(objectKey, content, mediaType).key();
}
@Override
public void delete(String objectKey) {
require().delete(objectKey);
}
private ObjectStoragePort require() {
ObjectStoragePort port = objectStorage.getIfAvailable();
if (port == null) {
throw StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"no object storage backend is configured; set ca-skeleton.objectstorage.* to enable"
+ " Studio asset uploads");
}
return port;
}
}
@@ -1,8 +1,46 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioSettings;
import dev.caskeleton.application.storage.ObjectStoragePort;
import dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase;
import dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.StudioDocumentLoader;
import dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase;
import dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.WorkingCopyDetailAssembler;
import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.util.UUID;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -15,4 +53,201 @@ public class TechLogStudioConfig {
CatalogQueryPort catalogQueryPort, TransactionPort transactionPort) {
return new ListCatalogUseCase(catalogQueryPort, transactionPort);
}
@Bean
WorkingCopyDetailAssembler workingCopyDetailAssembler(
ValidationArtifactPort validations,
PreviewArtifactPort previews,
PublicationQueryPort publications,
DependencyRevisionPort dependencyRevisions,
Clock clock) {
return new WorkingCopyDetailAssembler(
validations, previews, publications, dependencyRevisions, clock);
}
@Bean
ListStudioDocumentsUseCase listStudioDocumentsUseCase(
StudioDocumentQueryPort documents, TransactionPort transactionPort) {
return new ListStudioDocumentsUseCase(documents, transactionPort);
}
@Bean
GetStudioDocumentUseCase getStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies,
WorkingCopyDetailAssembler assembler,
TransactionPort transactionPort) {
return new GetStudioDocumentUseCase(workingCopies, assembler, transactionPort);
}
@Bean
CreateStudioDocumentUseCase createStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies, TransactionPort transactionPort) {
return new CreateStudioDocumentUseCase(workingCopies, transactionPort);
}
@Bean
StudioDocumentLoader studioDocumentLoader(WorkingCopyRepositoryPort workingCopies) {
return new StudioDocumentLoader(workingCopies);
}
@Bean
ValidateStudioDocumentUseCase validateStudioDocumentUseCase(
StudioDocumentLoader documents,
StudioDependencyResolverPort dependencies,
ContentAnalyzerPort contentAnalyzer,
DependencyRevisionPort dependencyRevisions,
ValidationArtifactPort validations,
TransactionPort transactionPort,
Clock clock,
StudioSettings settings) {
return new ValidateStudioDocumentUseCase(
documents,
dependencies,
contentAnalyzer,
dependencyRevisions,
validations,
transactionPort,
UUID::randomUUID,
clock,
settings.validationTtl());
}
@Bean
CreateStudioPreviewUseCase createStudioPreviewUseCase(
StudioDocumentLoader documents,
StudioDependencyResolverPort dependencies,
ContentAnalyzerPort contentAnalyzer,
DependencyRevisionPort dependencyRevisions,
ValidationArtifactPort validations,
PreviewArtifactPort previews,
RenderModelPort renderer,
TransactionPort transactionPort,
Clock clock,
StudioSettings settings) {
return new CreateStudioPreviewUseCase(
documents,
dependencies,
contentAnalyzer,
dependencyRevisions,
validations,
previews,
renderer,
transactionPort,
UUID::randomUUID,
clock,
settings.previewTtl());
}
@Bean
GetCurrentStudioPreviewUseCase getCurrentStudioPreviewUseCase(
StudioDocumentLoader documents,
PreviewArtifactPort previews,
ValidationArtifactPort validations,
DependencyRevisionPort dependencyRevisions,
TransactionPort transactionPort,
Clock clock) {
return new GetCurrentStudioPreviewUseCase(
documents, previews, validations, dependencyRevisions, transactionPort, clock);
}
@Bean
SaveStudioDocumentUseCase saveStudioDocumentUseCase(
WorkingCopyRepositoryPort workingCopies,
WorkingCopyDetailAssembler assembler,
TransactionPort transactionPort) {
return new SaveStudioDocumentUseCase(workingCopies, assembler, transactionPort);
}
@Bean
PublishStudioDocumentUseCase publishStudioDocumentUseCase(
StudioDocumentLoader documents,
StudioDependencyResolverPort dependencies,
ContentAnalyzerPort contentAnalyzer,
DependencyRevisionPort dependencyRevisions,
ValidationArtifactPort validations,
PreviewArtifactPort previews,
PublicationWriterPort publications,
TransactionPort transactionPort,
Clock clock) {
return new PublishStudioDocumentUseCase(
documents,
dependencies,
contentAnalyzer,
dependencyRevisions,
validations,
previews,
publications,
transactionPort,
clock);
}
@Bean
UnpublishStudioPublicationUseCase unpublishStudioPublicationUseCase(
PublicationHistoryQueryPort history,
PublicationWriterPort publications,
StudioDocumentLoader documents,
TransactionPort transactionPort) {
return new UnpublishStudioPublicationUseCase(history, publications, documents, transactionPort);
}
@Bean
ListStudioPublicationsUseCase listStudioPublicationsUseCase(
PublicationHistoryQueryPort history, TransactionPort transactionPort) {
return new ListStudioPublicationsUseCase(history, transactionPort);
}
@Bean
GetStudioPublicationSnapshotUseCase getStudioPublicationSnapshotUseCase(
PublicationHistoryQueryPort history, TransactionPort transactionPort) {
return new GetStudioPublicationSnapshotUseCase(history, transactionPort);
}
@Bean
GetStudioDashboardUseCase getStudioDashboardUseCase(
StudioDashboardQueryPort dashboard,
PublicationHistoryQueryPort history,
TransactionPort transactionPort) {
return new GetStudioDashboardUseCase(dashboard, history, transactionPort);
}
/** spec §9 — Asset 은 새 저장 계층을 만들지 않고 기존 object storage 어댑터를 재사용한다. */
@Bean
@SuppressWarnings("removal")
AssetBinaryStoragePort assetBinaryStoragePort(ObjectProvider<ObjectStoragePort> objectStorage) {
return new ObjectStorageAssetBinaryAdapter(objectStorage);
}
@Bean
UploadStudioAssetUseCase uploadStudioAssetUseCase(
AssetRepositoryPort assets,
AssetBinaryStoragePort binaries,
TransactionPort transactionPort) {
return new UploadStudioAssetUseCase(assets, binaries, transactionPort, UUID::randomUUID);
}
@Bean
ListStudioAssetsUseCase listStudioAssetsUseCase(
AssetRepositoryPort assets, TransactionPort transactionPort) {
return new ListStudioAssetsUseCase(assets, transactionPort);
}
@Bean
GetStudioAssetUseCase getStudioAssetUseCase(
AssetRepositoryPort assets, TransactionPort transactionPort) {
return new GetStudioAssetUseCase(assets, transactionPort);
}
@Bean
UpdateStudioAssetUseCase updateStudioAssetUseCase(
AssetRepositoryPort assets, TransactionPort transactionPort) {
return new UpdateStudioAssetUseCase(assets, transactionPort);
}
@Bean
DeleteStudioAssetUseCase deleteStudioAssetUseCase(
AssetRepositoryPort assets,
AssetBinaryStoragePort binaries,
TransactionPort transactionPort) {
return new DeleteStudioAssetUseCase(assets, binaries, transactionPort);
}
}
@@ -22,6 +22,17 @@ 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
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match.
@@ -65,6 +65,25 @@ spring:
properties:
hibernate:
format_sql: false
objectstorage:
# Studio Asset 바이너리 저장소. ObjectStorageConfig 는 이 prefix 의 property 가 하나라도 있어야
# 활성화된다(LegacyObjectStorageActivationGuard) — application.yml 이 아니라 프로파일에 두는 이유는,
# 저장 백엔드 선택이 배포마다 다른 결정이라 템플릿 기준선이 그것을 대신 정해서는 안 되기 때문이다.
# 이 값이 없는 배포에서는 업로드·삭제만 STUDIO_UNAVAILABLE 로 거절되고 나머지 Asset operation 은
# 그대로 동작한다.
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
security:
oauth2:
resourceserver:
@@ -26,6 +26,17 @@ 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
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN; Studio needs X-CSRF-TOKEN to match.
@@ -446,6 +446,16 @@ ca-skeleton:
# prefix "/v1" (major-version path, AIP-185); override via env, or set "" for
# no prefix. The supplemental "X-Api-Version" header never overrides the path.
api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}
techlog:
studio:
# Studio 문서 목록 커서 서명 키. 값이 없거나 16바이트 미만이면 StudioSettings가 경고하고 개발용
# 값으로 대체한다 — 커서에 권한이 실리지 않아 부팅을 막을 사유는 아니지만, 인스턴스마다 값이
# 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다.
cursor-signing-key: ${APP_STUDIO_CURSOR_SIGNING_KEY:}
# 검증 결과가 유효한 기간(studio_validation.valid_until).
validation-ttl: ${APP_STUDIO_VALIDATION_TTL:1h}
# 미리보기가 유효한 기간(studio_preview.expires_at).
preview-ttl: ${APP_STUDIO_PREVIEW_TTL:24h}
idempotency:
# feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h,
# validated in IdempotencyProperties); reaper-interval is literal operational tuning.
@@ -28,6 +28,13 @@ class TechLogBoundaryArchTest {
// (publication과 같은 성격) NO_CONTEXT_DEPENDS_ON_PUBLICATION_DOMAIN과 같은 모양의
// 전용 규칙도 검토한다.
// 이 규칙들의 패키지 패턴(`..techlog.<context>..`)은 계층을 가리지 않는다 — application 뿐 아니라
// adapter 쪽 패키지도 같은 이름을 쓰면 걸린다. 그래서 <b>어댑터 패키지를 bounded context 이름으로
// 짓지 않는다</b>: Studio 의 outbound 포트를 구현하는 영속 어댑터는
// `...persistence.techlog.studio.<area>` 에 둔다. 그 이름이 규칙을 피하려는 우회가 아니라 실제로 더
// 정확하다 — 그 어댑터들은 asset/publication context 의 소유물이 아니라 Studio 포트의 구현이다.
// (슬라이스 4~5 에서 `...persistence.techlog.asset` 로 지었다가 이 규칙이 223건을 잡아냈다.)
@ArchTest
static final ArchRule CONTENT_DOES_NOT_DEPEND_ON_SIBLING_CONTEXTS =
noClasses()
@@ -0,0 +1,12 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
/**
* {@code createStudioDocument}의 입력.
*
* @param principal 감사 컬럼({@code created_by}/{@code updated_by})에 남길 주체
*/
public record CreateDocumentCommand(WorkingCopyInputView document, String principal)
implements Command {}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.UUID;
/** {@code createStudioPreview}의 입력. */
public record CreatePreviewCommand(
UUID documentId, long expectedVersion, UUID validationId, String principal)
implements Command {}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.UUID;
/** {@code deleteStudioAsset}의 입력. */
public record DeleteAssetCommand(UUID assetId, String principal) implements Command {}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.List;
import java.util.UUID;
/** {@code publishStudioDocument}의 입력. */
public record PublishDocumentCommand(
UUID documentId,
long expectedVersion,
UUID validationId,
UUID previewId,
List<String> acknowledgedWarningCodes,
String idempotencyKey,
String principal)
implements Command {
public PublishDocumentCommand {
acknowledgedWarningCodes =
acknowledgedWarningCodes == null ? List.of() : List.copyOf(acknowledgedWarningCodes);
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import java.util.UUID;
/** {@code saveStudioDocument}의 입력. */
public record SaveDocumentCommand(
UUID documentId, long expectedVersion, WorkingCopyInputView document, String principal)
implements Command {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.UUID;
/** {@code unpublishStudioPublication}의 입력. */
public record UnpublishPublicationCommand(
UUID publicationId, long expectedPublicationRevision, String principal) implements Command {}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import java.util.UUID;
/**
* {@code updateStudioAsset}의 입력. 계약이 허용하는 것은 {@code altText}, {@code decorative}, {@code kind}, 그리고
* {@code READY ↔ ARCHIVED} 전환뿐이다.
*
* @param altTextProvided {@code altText}가 요청에 실렸는지. null 로 지우는 것과 아예 안 보낸 것을 구분한다.
*/
public record UpdateAssetCommand(
UUID assetId,
long expectedVersion,
AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
AssetManagementStatusView managementStatus,
String principal)
implements Command {}
@@ -0,0 +1,74 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import java.util.Objects;
/**
* {@code uploadStudioAsset}의 입력.
*
* <p>record 가 아니라 class 인 이유는 바이트 배열 때문이다 — 배열을 record 구성요소로 두면 {@code equals} 가 내용이 아니라 참조를 비교해
* "같은 파일"을 다르다고 판정한다.
*/
public final class UploadAssetCommand implements Command {
private final String originalFilename;
private final String declaredMediaType;
private final byte[] content;
private final AssetKindView kind;
private final String altText;
private final boolean decorative;
private final String principal;
/**
* @param declaredMediaType 클라이언트가 말한 것. Backend 는 이 값을 신뢰하지 않고 내용으로 다시 판정한다 (계약 설명).
*/
public UploadAssetCommand(
String originalFilename,
String declaredMediaType,
byte[] content,
AssetKindView kind,
String altText,
boolean decorative,
String principal) {
this.originalFilename = originalFilename;
this.declaredMediaType = declaredMediaType;
this.content = Objects.requireNonNull(content, "content").clone();
this.kind = kind;
this.altText = altText;
this.decorative = decorative;
this.principal = principal;
}
public String originalFilename() {
return originalFilename;
}
public String declaredMediaType() {
return declaredMediaType;
}
public byte[] content() {
return content.clone();
}
public int byteSize() {
return content.length;
}
public AssetKindView kind() {
return kind;
}
public String altText() {
return altText;
}
public boolean decorative() {
return decorative;
}
public String principal() {
return principal;
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.command;
import dev.caskeleton.application.command.Command;
import java.util.UUID;
/** {@code validateStudioDocument}의 입력. */
public record ValidateDocumentCommand(UUID documentId, long expectedVersion, String principal)
implements Command {}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/**
* 계약 {@code AssetDetail}.
*
* @param hasPublicationHistory 참이면 hard delete 를 금지하고 {@code ARCHIVED} 전환만 허용한다
*/
public record AssetDetailView(
AssetView asset, List<AssetUsageView> usages, boolean hasPublicationHistory) {
public AssetDetailView {
usages = usages == null ? List.of() : List.copyOf(usages);
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code AssetKind}. */
public enum AssetKindView {
IMAGE,
DIAGRAM,
ATTACHMENT
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 계약 {@code AssetManagementStatus}.
*
* <p>{@link #REJECTED}/{@link #QUARANTINED}는 서버 검증 결과이며 클라이언트가 지정할 수 없다.
*/
public enum AssetManagementStatusView {
READY,
ARCHIVED,
REJECTED,
QUARANTINED
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/**
* 게시 시점에 고정하는 Asset descriptor 한 건({@code publication_snapshot.asset_manifest}).
*
* <p>이후 Asset 이 교체돼도 과거 Snapshot 의 표현이 변하지 않게 하는 장치다 — ADR-002 가 요구하는 역사적 불변성이며, ADR-005 가 인정한 "네
* 화면 중 Snapshot 만 다른 유일한 지점"이다.
*/
public record AssetManifestEntry(
UUID assetId,
String assetKey,
String mediaType,
String publicPath,
Integer width,
Integer height,
boolean decorative) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code AssetPage}. */
public record AssetPageView(List<AssetView> items, String nextCursor) {
public AssetPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code AssetUsage}. 이 Asset 을 쓰는 문서 한 건. */
public record AssetUsageView(
UUID documentId, RecordKind documentKind, String title, boolean published) {}
@@ -0,0 +1,28 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code Asset}.
*
* @param assetKey Public content 가 쓰는 안정적인 key. object storage key 도 raw URL 도 아니며 immutable 이다 —
* 공개 이력이 있는 key 의 재사용은 금지한다.
*/
public record AssetView(
UUID id,
String assetKey,
AssetKindView kind,
String mediaType,
String originalFilename,
long byteSize,
Integer width,
Integer height,
String altText,
boolean decorative,
AssetManagementStatusView managementStatus,
String publicPath,
int usageCount,
long version,
Instant createdAt,
Instant updatedAt) {}
@@ -0,0 +1,5 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code DashboardTotals}. */
public record DashboardTotalsView(
int documents, int needsValidation, int readyToPublish, int publications) {}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code StudioDashboard}. */
public record DashboardView(
List<DocumentSummaryView> continueWriting,
List<DocumentSummaryView> readyToPublish,
List<PublicationListItemView> recentPublications,
DashboardTotalsView totals) {
public DashboardView {
continueWriting = continueWriting == null ? List.of() : List.copyOf(continueWriting);
readyToPublish = readyToPublish == null ? List.of() : List.copyOf(readyToPublish);
recentPublications = recentPublications == null ? List.of() : List.copyOf(recentPublications);
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약의 UI 용어(ADR-003). Domain의 {@code ACCEPTED}가 {@link #ADOPTED}로 보인다. */
public enum DecisionStatusView {
PROPOSED,
ADOPTED
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code DisplayTarget}. */
public record DisplayTargetView(UUID id, String label, String publicPath) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code DocumentPage}. */
public record DocumentPageView(List<DocumentSummaryView> items, String nextCursor) {
public DocumentPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 파라미터 {@code sort} (studio-v1.yaml components.parameters.DocumentSort). */
public enum DocumentSort {
UPDATED_DESC,
UPDATED_ASC,
TITLE_ASC
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code DocumentSummary}. 목록·대시보드가 쓰는 요약이다.
*
* @param publishedVersion 게시한 적이 없으면 null
* @param hasUnpublishedChanges {@code version != publishedVersion}. 게시 취소 상태에서도 과거
* publishedVersion과 비교한다(계약 주석).
*/
public record DocumentSummaryView(
UUID id,
String title,
RecordKind kind,
DisplayTargetView project,
Instant updatedAt,
PublicationStatusView publicationStatus,
Long publishedVersion,
boolean hasUnpublishedChanges,
NextAction nextAction) {}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code NextAction}. 서버가 조회 시점에 계산하는 Studio projection이며 domain 컬럼에 저장하지 않는다 (spec §6.3). */
public enum NextAction {
CONTINUE_EDITING,
VALIDATE,
FIX_VALIDATION,
CREATE_PREVIEW,
PUBLISH,
NONE
}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code OrderedText}. */
public record OrderedTextView(UUID id, String text, int order) {}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code PreviewDetail}. */
public record PreviewDetailView(
PublicPreviewView preview,
PreviewState state,
long currentDocumentVersion,
UUID currentValidationId) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PreviewDetail.state}. 저장하지 않고 조회 시 계산한다(spec §7.3). */
public enum PreviewState {
CURRENT,
STALE,
EXPIRED
}
@@ -0,0 +1,33 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 공개 경로 규칙(설계 01장 §3, {@code public-v1.yaml}의 path).
*
* <p>한 곳에 둔다 — 렌더 모델의 {@code publicPath}, 카탈로그의 {@code publicPath}, 게시 시 만드는 {@code public_route}가
* 서로 다른 규칙으로 만들어지면 미리보기의 링크와 실제 공개 주소가 달라진다.
*/
public final class PublicPaths {
private PublicPaths() {}
/**
* 이 유형과 slug 의 공개 경로.
*
* @param projectSlug {@code PROJECT_DECISION}에만 쓰인다. 없으면 결정 경로를 만들 수 없어 null을 준다.
* @return slug가 비어 있으면 null — 아직 공개 주소가 없는 초안이다.
*/
public static String forKind(RecordKind kind, String slug, String projectSlug) {
if (slug == null || slug.isBlank()) {
return null;
}
return switch (kind) {
case CASE -> "/cases/" + slug;
case REFERENCE -> "/references/" + slug;
case QUESTION -> "/questions/" + slug;
case PROJECT_DECISION ->
(projectSlug == null || projectSlug.isBlank())
? null
: "/projects/" + projectSlug + "/decisions/" + slug;
};
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code PublicPreview}.
*
* <p>{@code renderModel}을 application 계층에서 구조화된 타입으로 다시 모델링하지 않고 직렬화된 JSON 문자열로 들고 다닌다. 이유: 이 값은
* {@code studio_preview.render_model}(jsonb)에 그대로 저장되고 웹 계층에서 계약 DTO로 그대로 나가는 통과 데이터이며, 중간에 한 번 더
* 도메인 타입으로 접었다 펴면 렌더러가 만든 모양과 계약 모양 사이에 조용한 손실이 생길 수 있다. 렌더링 자체의 타입 안전성은 렌더러가 계약 DTO를 직접 만들며 책임진다.
*/
public record PublicPreviewView(
UUID previewId,
UUID documentId,
long previewVersion,
UUID validationId,
String dependencyRevision,
Instant createdAt,
Instant expiresAt,
String renderModelJson) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublicationAction} (studio-v1.yaml:1661). */
public enum PublicationActionView {
VIEW_SNAPSHOT,
VIEW_SOURCE_SNAPSHOT,
UNPUBLISH
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublicationAggregate.status}. */
public enum PublicationAggregateStatus {
PUBLISHED,
UNPUBLISHED
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/** 계약 {@code PublicationAggregate}. 현재 게시 상태이며 게시 이력과 구분한다. */
public record PublicationAggregateView(
UUID publicationId,
UUID documentId,
PublicationAggregateStatus status,
long publishedVersion,
long publicationRevision,
UUID latestEventId,
String publicPath,
Instant updatedAt) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublicationEventType}. */
public enum PublicationEventTypeView {
PUBLISHED,
REPUBLISHED,
UNPUBLISHED
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.techlog.studio.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code PublicationEvent}. 불변 이력이며 생성 후 수정하지 않는다.
*
* @param sourcePublishedEventId {@code UNPUBLISHED} 이벤트가 참조하는 마지막 공개 Snapshot의 Event id
*/
public record PublicationEventView(
UUID publicationEventId,
UUID publicationId,
UUID documentId,
PublicationEventTypeView type,
Instant occurredAt,
long publishedVersion,
UUID sourcePublishedEventId,
boolean snapshotAvailable) {}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code PublicationListItem}. */
public record PublicationListItemView(
PublicationEventView event,
PublicationAggregateView publication,
DocumentSummaryView document,
List<PublicationActionView> availableActions) {
public PublicationListItemView {
availableActions = availableActions == null ? List.of() : List.copyOf(availableActions);
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.List;
/** 계약 {@code PublicationPage}. */
public record PublicationPageView(List<PublicationListItemView> items, String nextCursor) {
public PublicationPageView {
items = items == null ? List.of() : List.copyOf(items);
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 계약 {@code PublicationSnapshot}. 게시 시점의 불변 {@code PublicRenderModel} 이며 현재 source 로 다시 만들지 않는다.
*/
public record PublicationSnapshotView(
PublicationEventView event,
String renderModelJson,
String contentFormatVersion,
String rendererContractVersion) {}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublicationStatus}. */
public enum PublicationStatusView {
NEVER_PUBLISHED,
PUBLISHED,
UNPUBLISHED
}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.studio.model;
/** 계약 {@code PublishResult}. */
public record PublishResultView(PublicationAggregateView publication, PublicationEventView event) {}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code QuestionOption}. */
public record QuestionOptionView(UUID id, String title, String description, int order) {}
@@ -0,0 +1,6 @@
package dev.caskeleton.application.techlog.studio.model;
import java.util.UUID;
/** 계약 {@code QuestionResolution}. */
public record QuestionResolutionView(String summary, UUID evidenceTargetId, String linkLabel) {}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.techlog.studio.model;
/**
* 계약의 축약 상태(ADR-003). Domain의 {@code OPEN}/{@code INVESTIGATING}/{@code PAUSED}가 모두 {@link #OPEN}으로
* 보이며, 그 역방향 변환은 Domain 상태를 덮어쓰지 않는다.
*/
public enum QuestionStatusView {
OPEN,
RESOLVED
}

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