feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종

설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고
슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과
listStudioCatalog를 구현했다.

계약과 생성
- src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록
- openapi-generator로 DTO(model)만 생성한다. generateApis 대신
  globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다
- useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이
  깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다
- 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated
  API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다

오류 계약
- StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier)
- StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을
  바꾸지 않기 위해서다
- 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는
  로그 전용이다(ApiErrorCarrier javadoc의 요구)
- 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다

게이트
- TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를
  늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다
- StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증
- StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라
  새 컨트롤러가 자동으로 걸린다
- StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의
  csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를
  테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다

스키마
- V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존
  idempotency_record 재사용)와 범위 밖 6종을 제외했다
- 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway
  세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다

알려진 제약
- getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을
  반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로
  200을 만들지 않았다
- 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다

이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로
작성됐다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 15:14:52 +09:00
co-authored by Claude Opus 5
parent 697fc740e6
commit 91e6d99654
48 changed files with 9495 additions and 220 deletions
+43 -1
View File
@@ -15,6 +15,12 @@ sourceSets {
functionalTest {
java.srcDir 'src/functionalTest/java'
resources.srcDir 'src/functionalTest/resources'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest reuses
// RepositoryContractResources (test-sourceSet-owned, see
// dev.caskeleton.bootstrap.contract.support) for fail-closed repo-root resolution instead
// of a hand-rolled relative Path.of(..), matching the sibling contract tests' convention.
compileClasspath += sourceSets.test.output
runtimeClasspath += sourceSets.test.output
}
conditionalTransportTest {
java.srcDir 'src/conditionalTransportTest/java'
@@ -121,6 +127,33 @@ dependencies {
functionalTestImplementation 'org.junit.jupiter:junit-jupiter'
functionalTestImplementation 'org.assertj:assertj-core'
functionalTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest boots a minimal Studio web
// slice (real StudioSessionController/StudioCatalogController, no persistence/messaging/cache)
// to diff springdoc's published /api/v1/studio/** surface against studio-v1.yaml. Only the two
// modules the slice actually needs — deliberately not the full app-bootstrap runtime graph, so
// no DataSource/Flyway/Redis auto-configuration is even on this classpath to exclude.
functionalTestImplementation project(':adapter:inbound:web')
functionalTestImplementation project(':application-core')
// test-only: @SpringBootTest/MockMvc/@AutoConfigureMockMvc — mirrors the root build.gradle
// subprojects{} pair every non-core module already gets on its ordinary `test` sourceSet.
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-test'
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
// test-only: classic Jackson (2.x) ObjectMapper/JsonNode to read /v3/api-docs and convert the
// SnakeYaml-parsed contract into a comparable tree. adapter-inbound-web/application-core pull
// this in only as a project-dependency `implementation` (hidden from a consumer's
// compileClasspath by Gradle's api/implementation split), so it must be declared directly here
// — mirrors the existing `testImplementation 'org.springframework.boot:spring-boot-starter-json'`
// pattern below for app-bootstrap's own `test` sourceSet.
functionalTestImplementation 'com.fasterxml.jackson.core:jackson-databind'
// test-only: org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration (excluded
// below) is part of spring-boot-security, only pulled in transitively by spring-boot-starter-security
// — same api/implementation-hiding reason as jackson-databind above. app-bootstrap declares this
// on `implementation` for its own main/test sourceSets, which functionalTest does not extend.
functionalTestImplementation 'org.springframework.boot:spring-boot-starter-security'
// test-only: parses config/openapi/studio-v1.yaml with the same library StudioErrorRegistryTest
// already uses for docs/registries/error-codes.yaml — avoids adding jackson-dataformat-yaml
// (present only on runtimeClasspath repo-wide, transitively via springdoc, not compileClasspath).
functionalTestImplementation 'org.yaml:snakeyaml'
// Explicit qualification-only composition. These projects remain absent from main
// api/implementation/compileOnly/runtimeOnly and therefore from both shipped runtime graphs.
conditionalTransportTestImplementation project(':adapter:inbound:graphql')
@@ -166,13 +199,22 @@ sampleOffQualification.configure {
tasks.register('functionalTest', Test) {
group = 'verification'
description = 'Runs isolated Gradle TestKit contracts for repository build behavior.'
description = 'Runs isolated Gradle TestKit contracts for repository build behavior, plus the ' +
'feature-techlog-studio-backend Studio contract drift gate.'
testClassesDirs = sourceSets.functionalTest.output.classesDirs
classpath = sourceSets.functionalTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
shouldRunAfter tasks.named('test')
jvmArgs '-Duser.timezone=UTC'
// feature-techlog-studio-backend Task 10 — StudioContractDriftTest boots a real (minimal)
// Spring Boot context that needs Logback, on the same classpath as gradleTestKit() (whose own
// SLF4J provider — org.gradle.internal.logging.slf4j.OutputEventListenerBackedLoggerContext —
// wins classpath scanning over the real one). Spring Boot's LogbackLoggingSystem then finds
// Logback's jar present but the bound ILoggerFactory is Gradle's fake context, and fails fast
// with IllegalStateException before the context even starts. LoggingSystem=none skips Boot's
// logging bootstrap entirely — this task doesn't assert on log output, so there is nothing lost.
systemProperty 'org.springframework.boot.logging.LoggingSystem', 'none'
}
def conditionalTransportCompositionQualification = registerStrictQualificationTest(