Compare commits

...
14 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 edb0890dc8 feat: 홈과 주제의 최근 기록이 게시된 Open Question 을 담는다
질문을 게시해도 홈 최근 기록에 나오지 않았다. 백엔드가 담지 않은 것이 아니라 담을 수
없었다 — 계약의 `LatestEntry.entryType` 이 CASE/REFERENCE/PROJECT_ACTIVITY/RELEASE 넷만
허용했고, 계약 밖 값을 응답 매퍼에 넘기면 500 이 되어 홈 화면 전체를 못 쓰게 만들기 때문에
질의가 먼저 걸러 내고 있었다.

계약을 넓혔으므로(design-package ef49d3a) 걸러 낼 이유가 사라졌다. `LATEST_ENTRY_TYPES` 에
`QUESTION` 을 더한다. `pathOf` 는 이미 `/questions/{slug}` 를 만들고 있었고, projection 에도
질문 행이 `ACTIVE`/`PUBLIC` 으로 `navigation_path` 까지 채워진 채 들어 있었다 — 막고 있던
것은 이 `IN` 목록 하나였다.

`LATEST_ENTRY_TYPES` 는 홈과 주제 상세가 함께 쓴다. 두 목록의 의미가 같으므로 한 곳만
넓히면 둘 다 따라오고, 그것이 의도다. 두 화면 각각에 "게시한 Open Question 이 목록에
나온다"는 단언을 세워 둔다 — 유형 허용 목록만 검사하면 담기지 않아도 통과한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:43:56 +09:00
DongHyeonkaandClaude Opus 5 c6d9d2d675 feat: 공개 Case·Reference 응답에 문서 요약을 싣는다
문서가 스스로 밝히는 한 줄 요약(`document.summary`)을 공개 응답이 내보내지 않았다.
화면은 제목 바로 아래에 그것을 그려야 하는데 자리가 없어 유형별 요약을 대신 썼고, 그러면
머리말이 바로 아래의 "문제"나 "이 기준을 쓰는 이유"와 같은 글을 두 번 말한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 21:12:40 +09:00
DongHyeonkaandClaude Opus 5 a5f93b9b75 feat: 공개 Reference 응답에 판단 기준과 예시를 싣는다
Reference 의 본문은 `body_markdown` 이 아니라 `reference_detail.rules` 와 `examples` 에
있다. Studio 의 Reference 편집기가 규칙(제목+본문)과 예시를 따로 받고 마크다운 본문은
비워 두기 때문이다.

공개 조회는 그 두 칸을 읽지 않고 `body_markdown` 만 봤다. 그래서 `content: ""` 를
내보냈고, 공개 화면의 "판단 기준"과 "예시"가 통째로 비었다 — Studio 에서는 다 보이는데
공개 쪽만 빈 이유가 이것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 19:11:01 +09:00
DongHyeonka f1fd56fcb5 chore: decision 미리보기기 계약 수정 2026-08-24 18:12:38 +09:00
DongHyeonkaandClaude Opus 5 bd66fb3610 fix: Decision 미리보기가 열리도록 프로젝트 공개 경로를 catalog 에 싣는다
Decision 문서는 즉시 미리보기가 어떤 문서에서도 열리지 않았다.

Decision 의 공개 주소는 자기 slug 가 아니라 `<프로젝트 경로>/decisions#<slug>` 다.
그래서 렌더 모델이 프로젝트의 공개 경로를 요구하는데, PROJECT catalog 는 그 자리를
언제나 null 로 돌려주고 있었다 — 게시된 프로젝트인지 아닌지와 무관하게 상수 null
이었다. 미리보기는 "PROJECT public path is required" 로 멈췄고, 화면에는 무엇이
모자란지 나오지 않았다.

게시된 프로젝트만 경로를 싣는다. 게시되지 않았으면 공개 주소가 실제로 없고, 없는
주소를 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 17:25:31 +09:00
DongHyeonkaandClaude Opus 5 a7e2b7d7fe feat: 게시가 프로젝트 활동을 남기게 한다
프로젝트 활동은 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린
사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 게시가 곧 사건이므로
게시가 기록한다 (publish 19단계).

문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로
타임라인에 다시 나타나지 않아야 한다 — `operation_key` 를
`publication:<documentId>` 로 두고 `uq_project_activity_operation_key` 충돌을 무시한다.

`origin` 은 `AUTO` 다. 손으로 적은 줄과 구분해 두면, 삭제 금지 규칙이 실제 사건의
흔적만 지킨다.

V10 은 이 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는 상태를
남겨 두면 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다. `occurred_at` 은 최초
PUBLISHED 사건의 시각이다 — now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼 보인다.

함께 고친 것: 마이그레이션 버전 목록이 7 에서 멈춰 있었다. TechLog 가 들어오며 8·9 가
붙었는데 이 테스트를 같이 고치지 않아, 그 빨간색은 "스키마가 잘못됐다" 가 아니라
"목록을 안 고쳤다" 를 뜻하는 상태로 두 번 지나갔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 15:45:36 +09:00
DongHyeonkaandClaude Opus 5 6aa140077d feat: 프로젝트 주제를 저장하고 공개 응답에 싣는다
프로젝트 화면의 "주요 주제" 가 늘 비어 있었다. `project_topic` 은 테이블도 있고
공개 조회가 조인할 수도 있었지만, 응답에 실을 자리가 없었고 저장할 경로도 없었다 —
`ProjectUpdateRequest.topicIds` 는 계약에 있었지만 명령이 그 값을 들고 다니지 않았다.

주제는 통째로 교체한다. 부분 수정으로 두면 "주제를 전부 뗀다" 를 표현할 방법이 없고,
화면도 목록 하나를 한 번에 저장하므로 그쪽과도 맞는다. 고른 순서가 곧 화면 순서이므로
목록의 자리를 display_order 에 그대로 적는다.

공개 조회는 ACTIVE 인 주제만 내보낸다 — 보관된 주제를 링크로 내보내면 따라간 곳이
비어 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:33:41 +09:00
DongHyeonkaandClaude Opus 5 ca63d7d3ec fix: 스캔되는 스프링 컴포넌트의 생성자를 하나로 고정한다
새 활동 어댑터가 생성자를 둘 갖고 있었다 — 하나는 운영용, 하나는 테스트가 id 생성기를
넣기 위한 것. 둘 중 어느 것에도 @Autowired 가 없어 컴포넌트 스캔은 고르지 못하고
기본 생성자를 찾다가 실패했다.

컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서 도는 통합 테스트 26개도 전부
통과했다. 그 어느 것도 애플리케이션 컨텍스트를 띄우지 않기 때문이다. 운영에서 파드가
CrashLoopBackOff 로 들어갔고, 그때서야 드러났다.

생성자를 하나로 줄이고 — id 는 어댑터가 만들면 되고 통합 테스트는 그 값을 볼 필요가
없다 — 같은 실수를 다시 못 하게 D20 규칙을 세운다: 스캔되는 컴포넌트는 생성자가
하나이거나, 여럿이면 그중 하나에 @Autowired 가 붙어야 한다. 규칙이 실제로 잡는지
결함을 되돌려 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 20:14:47 +09:00
DongHyeonkaandClaude Opus 5 4c14f1eb8f feat: 프로젝트 활동을 만들고 고치고 지울 수 있게 한다
공개 프로젝트 화면의 "활동" 이 언제나 비어 있었다. 계약에는 목록·생성·수정이
선언돼 있었지만 구현이 없었고, `project_activity` 는 0행이었다.

이 테이블은 투영을 거치지 않는다 — 공개 조회가 `visibility = 'PUBLIC'` 조건으로
직접 읽는다. 그래서 여기서 만든 줄이 곧 그 화면이다.

`origin` 은 어댑터가 정한다. 이 경로로 들어오는 것은 언제나 `MANUAL` 이고 `AUTO` 는
게시 파이프라인의 몫이다. 클라이언트가 값을 정하게 두면 손으로 적은 줄에 `AUTO` 를
붙여 삭제 금지를 우회할 수 있다.

생성이 붙잡는 버전은 활동이 아니라 프로젝트의 것이다. 활동은 아직 없으므로 자기
버전을 가질 수 없고, 두 사람이 같은 타임라인을 동시에 고치는 것을 막으려면 붙잡을
것이 프로젝트뿐이다.

모든 쓰기에 `project_id` 조건이 붙는다. 경로가 둘을 함께 요구하므로 활동 id 만으로
수정하면 남의 프로젝트 줄을 고칠 수 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:33:12 +09:00
DongHyeonkaandClaude Opus 5 561d02ae3a feat: 프로젝트 게시와 홈 focus, 그리고 기록 사이 연결을 실제로 가능하게 한다
계약에 선언만 되어 있고 구현이 없던 네 오퍼레이션과, 의도된 스텁으로 남아 있던
catalog 두 종류가 공개 화면 다섯 곳을 조용히 비워 두고 있었다.

프로젝트 게시 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데,
공개 조회들(프로젝트 목록·상세·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을 세우는
경로가 없었으므로 프로젝트는 만들 수는 있어도 공개될 수는 없었다. 릴리스가 자체 경로를
갖는 것과 같은 이유로, 문서 파이프라인에 끼워 넣지 않고 원본 상태·투영·canonical
route 세 곳을 한 트랜잭션에서 함께 세운다.

홈 focus — `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을
읽고 쓸 use case 가 없었다. 지목한 대상이 실제로 있는지는 여기서 확인한다. 테이블에
FK 가 없어(설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 도 저장되고,
그러면 공개 화면은 조용히 빈 focus 를 그린다 — 저장은 성공했는데 아무것도 안 나오는,
이유를 알 수 없는 실패가 된다. 대상이 공개인지는 확인하지 않는다: 미리 지목해 두고
게시와 동시에 뜨게 하는 것이 정상적인 순서다.

catalog RELATION/EVIDENCE — 「슬라이스 2·5에서 채운다」는 주석과 함께 `List.of()` 로
남아 있었다. 그래서 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은 작성
중에 고르는 것이라 작업본까지 포함하고(두 문서를 같이 쓰면서 서로 잇는 것이 정상적인
순서다), EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다.

계약은 이 네 오퍼레이션을 ProblemDetails 모양으로 두고 있었다. 백엔드가 모든 JSON
응답을 envelope 으로 감싸므로 구현하는 순간 어긋난다 — 나머지와 같은 모양으로 옮겼다.
`/home-focus` 는 케밥 세그먼트라 D19(AIP-122)를 위반해 `/home/focus` 로 나눴다.

함께 고친 것들 (모두 이 작업 전부터 빨간 상태였다):
- `error-codes.yaml` 의 DOCUMENT_NOT_FOUND 가 표면마다 하나씩 두 행이었다. 이
  레지스트리의 식별자는 code 하나뿐이라 로딩 자체가 깨졌고, 그 여파로 거버넌스 테스트
  네 개와 outbox 계약 테스트가 함께 넘어졌다. 같은 코드는 같은 말을 해야 한다.
- 벤더된 계약 세 개의 MANIFEST.sha256 이 실제 파일과 어긋나 있었다.
- ActuatorSecurityHttpTest 는 "DB·Redis 없는 슬라이스"라고 적어 두고 Redis 자동설정을
  막지 않아, localhost:6379 연결 실패가 /actuator/health 를 503 으로 만들었다. 보안
  태세와 무관한 이유로 빨개지던 테스트다.
- 테스트 JVM 힙이 Gradle 기본 512m 이라 Spring context 캐시 + ArchUnit +
  Testcontainers 조합에서 OOM 이 났다. 증상이 테스트 실패가 아니라 "Executor 를 완료할
  수 없음"이어서 원인을 가리켰다.
- APP_SESSION_TIMEOUT 이 env-keys 레지스트리에 없었다.

새 SQL 은 실제 PostgreSQL 위에서 돌린다 — 컴파일도 단위 테스트도 컬럼 이름을 검증하지
못한다는 것이 이 파일이 존재하는 이유다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 18:19:07 +09:00
DongHyeonka 23d82bd250 fix: 오류 수정 2026-08-22 14:41:19 +09:00
DongHyeonka 857e6a9c08 fix: refuse deletion only while a record is live, and say why
Deleting from Studio was unpredictable: some records went, others answered "in
use", and the reasons did not match what the author saw on screen.

Publication history was the wrong gate. A record that had ever been published
could never be deleted — including one the author had just unpublished, which
is usually the first half of removing it. Now only a record that is currently
published is refused, and an unpublished one takes its history with it. That
history describes what happened to a record; once the record is gone it
describes nothing, and the rows left behind are exactly what crashed the
dashboard and the publication list earlier.

Publishing itself stopped demanding a finished document. A Case required seven
filled fields, so an author with something worth showing could not show it.
Title and slug remain — a record with no title cannot be listed and one with no
path cannot be addressed — and everything else became a warning. The render
models had to move with it: they declared the author's prose non-empty, so
relaxing the validator alone would have turned a friendly warning into a schema
failure at publish time.

Publication rows are removed in one statement rather than in sequence. The
publication and its events point at each other and only one direction is
deferred, so any order leaves a moment where one constraint is broken; inside a
single statement that moment does not exist.
2026-08-21 19:28:52 +09:00
donghyeon-ka 8d228255e9 style: apply the formatter to the image dimension reader
Spotless normalization only; no behaviour change.
2026-08-21 18:10:35 +09:00
DongHyeonka e65b9e2c33 fix: record an uploaded image's dimensions
Every uploaded image was invisible, and the endpoint that serves them was not
the reason — it answers 200 with the right bytes. The browser never asked for
them.

Upload stored null for width and height, so the renderer had nothing to lay out
with and fell back to 1x1. A 1x1 box with `loading="lazy"` never intersects the
viewport, so the fetch is never made: the figure was not slow or broken, it was
never requested.

Dimensions now come from the file header — PNG, GIF and JPEG, read directly
rather than decoded. ImageIO would pull in `java.desktop`, and a runtime image
without that module would fail every upload rather than one figure. WebP and
SVG are left unread and answer "unknown", which is true: WebP has three header
shapes and an SVG may carry no pixel size at all.

The offsets are covered by tests against real bytes. They are not something
review can check by eye, and the JPEG case walks past an earlier segment —
reading the first one it finds would have produced a confident wrong answer.
2026-08-21 17:57:09 +09:00
68 changed files with 3141 additions and 224 deletions
+16
View File
@@ -112,6 +112,22 @@ env_keys:
compatibility_impact: behavior-change
required_test: env-contract:server-shutdown-timeout-aligned
- name: APP_SESSION_TIMEOUT
# source: Studio 작성 세션. Spring 기본 30분 유휴 만료는 한 기록을 여러 번 저장하며
# 오래 머무는 작성 리듬보다 짧아, 저장하지 않은 편집을 잃는 원인이 되었다.
# 늘릴수록 훔친 session cookie 가 유효한 창도 같이 늘어나므로 Keycloak realm 의
# SSO idle 과 함께 맞춘다.
type: duration
default: 8h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-env-driven-runtime-configuration
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: env-contract:session-timeout
- name: APP_SERVER_FORWARD_HEADERS_STRATEGY
# source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification
# (trust X-Forwarded-* when behind LB/proxy)
+6 -14
View File
@@ -960,7 +960,12 @@ errors:
compatibility_impact: additive
required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND)
# source: studio-v1.yaml + studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND
# (StudioError.DOCUMENT_NOT_FOUND, ManagementError.DOCUMENT_NOT_FOUND)
#
# 두 표면이 같은 코드를 쓴다. 이 레지스트리의 식별자는 code 하나뿐이므로 행도 하나다 —
# 한때 표면마다 행을 두고 문구를 다르게 적었고("작업본을 찾을 수 없습니다"), 그 중복이
# 레지스트리 로딩 자체를 깨뜨렸다. 같은 코드는 같은 말을 해야 한다.
- code: DOCUMENT_NOT_FOUND
category: NOT_FOUND
http_status: 404
@@ -1403,19 +1408,6 @@ errors:
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (ManagementError.DOCUMENT_NOT_FOUND)
- code: DOCUMENT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "작업본을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED)
- code: DOCUMENT_PUBLISHED
category: CONFLICT
@@ -26,7 +26,7 @@ public final class ManagementClientSafeMessages {
case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다";
case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다";
case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다";
case DOCUMENT_NOT_FOUND -> "작업본을 찾을 수 없습니다";
case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요";
case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다";
case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다";
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* 홈 focus 설정. 계약 {@code getHomeFocus}/{@code updateHomeFocus}.
*
* <p>단일 행이라 경로에 id 가 없다. 낙관적 잠금은 본문의 {@code expectedVersion} 으로만 한다.
*/
@RestController
public class ManagementHomeFocusController {
private final GetHomeFocusUseCase getHomeFocus;
private final UpdateHomeFocusUseCase updateHomeFocus;
public ManagementHomeFocusController(
GetHomeFocusUseCase getHomeFocus, UpdateHomeFocusUseCase updateHomeFocus) {
this.getHomeFocus = getHomeFocus;
this.updateHomeFocus = updateHomeFocus;
}
@GetMapping("/v1/studio/home/focus")
public HomeFocusResponse getHomeFocus() {
return ManagementResponseMapper.homeFocus(getHomeFocus.handle());
}
@PutMapping("/v1/studio/home/focus")
public HomeFocusResponse updateHomeFocus(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@RequestBody HomeFocusRequest body) {
return ManagementResponseMapper.homeFocus(
updateHomeFocus.handle(
new UpdateHomeFocusCommand(
body.getExpectedVersion(),
body.getDefaultType() == null ? null : body.getDefaultType().getValue(),
body.getCurrentProjectId(),
body.getOpenQuestionId(),
body.getRecentDecisionId(),
ManagementPrincipals.require(principal))));
}
}
@@ -0,0 +1,114 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.UpdateProjectActivityRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectActivityUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectActivityUseCase;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 프로젝트 활동. 계약 {@code listStudioProjectActivities}/{@code createProjectActivity}/{@code
* updateProjectActivity}/{@code deleteProjectActivity}.
*
* <p>공개 프로젝트 화면의 "활동" 은 {@code project_activity} 를 직접 읽는다 — 투영을 거치지 않으므로 여기서 만든 줄이 곧 그 화면이다.
*/
@RestController
public class ManagementProjectActivityController {
private final ListStudioProjectActivitiesUseCase listActivities;
private final CreateProjectActivityUseCase createActivity;
private final UpdateProjectActivityUseCase updateActivity;
private final DeleteProjectActivityUseCase deleteActivity;
public ManagementProjectActivityController(
ListStudioProjectActivitiesUseCase listActivities,
CreateProjectActivityUseCase createActivity,
UpdateProjectActivityUseCase updateActivity,
DeleteProjectActivityUseCase deleteActivity) {
this.listActivities = listActivities;
this.createActivity = createActivity;
this.updateActivity = updateActivity;
this.deleteActivity = deleteActivity;
}
@GetMapping("/v1/studio/projects/{id}/activities")
public List<ProjectActivityResponse> listStudioProjectActivities(@PathVariable("id") UUID id) {
return ManagementResponseMapper.projectActivities(listActivities.handle(id));
}
@PostMapping("/v1/studio/projects/{id}/activities")
@ResponseStatus(HttpStatus.CREATED)
public ProjectActivityResponse createProjectActivity(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ProjectActivityRequest body) {
return ManagementResponseMapper.projectActivity(
createActivity.handle(
new CreateProjectActivityCommand(
id,
body.getExpectedProjectVersion(),
body.getActivityType(),
body.getTitle(),
body.getSummary(),
body.getVisibility() == null ? null : body.getVisibility().getValue(),
body.getRelatedResourceType(),
body.getRelatedResourceId(),
instant(body.getOccurredAt()),
ManagementPrincipals.require(principal))));
}
@PutMapping("/v1/studio/projects/{id}/activities/{activityId}")
public ProjectActivityResponse updateProjectActivity(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@PathVariable("activityId") UUID activityId,
@RequestBody UpdateProjectActivityRequest body) {
return ManagementResponseMapper.projectActivity(
updateActivity.handle(
new UpdateProjectActivityCommand(
id,
activityId,
body.getExpectedVersion(),
body.getTitle(),
body.getSummary(),
body.getVisibility() == null ? null : body.getVisibility().getValue(),
instant(body.getOccurredAt()),
ManagementPrincipals.require(principal))));
}
@DeleteMapping("/v1/studio/projects/{id}/activities/{activityId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteProjectActivity(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@PathVariable("activityId") UUID activityId,
@RequestBody ExpectedVersionRequest body) {
deleteActivity.handle(
id, activityId, body.getExpectedVersion(), ManagementPrincipals.require(principal));
}
private static java.time.Instant instant(OffsetDateTime value) {
return value == null ? null : value.toInstant();
}
}
@@ -8,6 +8,8 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedV
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand;
@@ -16,6 +18,8 @@ import dev.caskeleton.application.techlog.management.service.CreateProjectUseCas
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
import java.util.List;
import java.util.UUID;
@@ -41,17 +45,24 @@ public class ManagementProjectController {
private final UpdateProjectUseCase updateProject;
private final DeleteProjectUseCase deleteProject;
private final PublishProjectUseCase publishProject;
private final UnpublishProjectUseCase unpublishProject;
public ManagementProjectController(
ListStudioProjectsUseCase listProjects,
GetProjectForEditUseCase getProject,
CreateProjectUseCase createProject,
UpdateProjectUseCase updateProject,
DeleteProjectUseCase deleteProject) {
DeleteProjectUseCase deleteProject,
PublishProjectUseCase publishProject,
UnpublishProjectUseCase unpublishProject) {
this.listProjects = listProjects;
this.getProject = getProject;
this.createProject = createProject;
this.updateProject = updateProject;
this.deleteProject = deleteProject;
this.publishProject = publishProject;
this.unpublishProject = unpublishProject;
}
@GetMapping("/v1/studio/projects")
@@ -98,6 +109,7 @@ public class ManagementProjectController {
body.getCurrentObjective(),
body.getNextStep(),
labels,
body.getTopicIds() == null ? List.of() : List.copyOf(body.getTopicIds()),
body.getTargetVisibility().getValue(),
body.getFeaturedOrder(),
ManagementPrincipals.require(principal))));
@@ -113,4 +125,27 @@ public class ManagementProjectController {
new DeleteProjectCommand(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
}
@PostMapping("/v1/studio/projects/{id}/publish")
public PublishResponse publishProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody PublishRequest body) {
return ManagementResponseMapper.projectPublication(
publishProject.handle(
id,
body.getExpectedVersion(),
body.getVisibility() == null ? null : body.getVisibility().getValue(),
ManagementPrincipals.require(principal)));
}
@PostMapping("/v1/studio/projects/{id}/unpublish")
public ProjectEditResponse unpublishProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
return ManagementResponseMapper.project(
unpublishProject.handle(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
}
}
@@ -1,7 +1,9 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
@@ -11,6 +13,8 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEd
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
@@ -85,7 +89,7 @@ public final class ManagementResponseMapper {
model.setNextStep(view.nextStep());
model.setTechnologyLabels(List.copyOf(view.technologyLabels()));
model.setFeaturedOrder(view.featuredOrder());
model.setTopicIds(List.of());
model.setTopicIds(List.copyOf(view.topicIds()));
model.setDocumentLinks(List.of());
model.setQuestionLinks(List.of());
return model;
@@ -188,6 +192,52 @@ public final class ManagementResponseMapper {
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
}
/** 홈 focus 설정. 세 슬롯은 비어 있을 수 있고, 비어 있음이 곧 "고르지 않았다"는 뜻이다. */
public static HomeFocusResponse homeFocus(HomeFocusConfigView view) {
HomeFocusResponse response = new HomeFocusResponse(view.id(), view.version());
response.setDefaultType(view.defaultType());
response.setCurrentProjectId(view.currentProjectId());
response.setOpenQuestionId(view.openQuestionId());
response.setRecentDecisionId(view.recentDecisionId());
return response;
}
/** 프로젝트 게시 결과. canonical path 는 공개 조회가 쓰는 {@code /projects/<slug>} 와 같은 규칙으로 만든다. */
public static PublishResponse projectPublication(ProjectEditView view) {
return new PublishResponse(
view.id(),
PublishResponse.StatusEnum.PUBLISHED,
"UNLISTED".equals(view.targetVisibility())
? PublishResponse.VisibilityEnum.UNLISTED
: PublishResponse.VisibilityEnum.PUBLIC,
"/projects/" + view.slug(),
at(view.lastPublishedAt()),
view.version());
}
/** 프로젝트 활동 한 줄. {@code origin} 을 그대로 실어 보낸다 — 화면이 지울 수 있는 줄과 아닌 줄을 그것으로 가른다. */
public static ProjectActivityResponse projectActivity(ProjectActivityView view) {
ProjectActivityResponse response =
new ProjectActivityResponse(
view.id(),
view.projectId(),
view.activityType(),
view.title(),
view.visibility(),
view.origin(),
at(view.occurredAt()),
view.version());
response.setSummary(view.summary());
response.setRelatedResourceType(view.relatedResourceType());
response.setRelatedResourceId(view.relatedResourceId());
return response;
}
public static java.util.List<ProjectActivityResponse> projectActivities(
java.util.List<ProjectActivityView> views) {
return views.stream().map(ManagementResponseMapper::projectActivity).toList();
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
@@ -11,6 +11,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPo
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReferenceRulesInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
@@ -36,6 +37,7 @@ public final class DocumentResponseMapper {
PublishedDocumentView doc = view.document();
CaseDetailResponseCase body = new CaseDetailResponseCase();
body.setTitle(doc.title());
body.setSummary(doc.summary());
body.setProblemSummary(doc.primarySummary());
body.setConclusionSummary(doc.secondarySummary());
body.setEnvironmentSummary(doc.environmentSummary());
@@ -46,6 +48,7 @@ public final class DocumentResponseMapper {
body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag));
body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject()));
body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset()));
body.setBodyAssets(PublicResponseMapper.map(doc.bodyAssets(), PublicResponseMapper::bodyAsset));
body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt()));
body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt()));
body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt()));
@@ -70,9 +73,17 @@ public final class DocumentResponseMapper {
PublishedDocumentView doc = view.document();
ReferenceDetailResponseReference body = new ReferenceDetailResponseReference();
body.setTitle(doc.title());
body.setSummary(doc.summary());
body.setScopeSummary(doc.primarySummary());
body.setAppliesTo(doc.appliesTo());
body.setExcludedScope(doc.excludedScope());
// Reference 의 본문은 `content` 가 아니라 여기 있다 — 이것을 빼면 공개 화면에 판단 기준과
// 예시가 통째로 빠진다.
body.setRules(
doc.rules().stream()
.map(rule -> new ReferenceDetailResponseReferenceRulesInner(rule.title(), rule.body()))
.toList());
body.setExamples(doc.examples());
body.setFreshnessStatus(
ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus()));
body.setContent(doc.content());
@@ -9,6 +9,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDet
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
@@ -56,6 +57,8 @@ public final class ProjectResponseMapper {
body.setNextStep(p.nextStep());
body.setSystemOverviewMarkdown(p.systemOverviewMarkdown());
body.setTechnologies(p.technologies());
body.setTopics(
p.topics().stream().map(topic -> new TopicSummary(topic.name(), topic.slug())).toList());
body.setUpdatedAt(PublicResponseMapper.at(p.updatedAt()));
ProjectDetailResponse dto = new ProjectDetailResponse();
@@ -1,6 +1,7 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.AssetReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.BodyAsset;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ContactLink;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.LatestEntry;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.PageMetadata;
@@ -9,6 +10,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RelatedEnt
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TagSummary;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.BodyAssetView;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
@@ -86,6 +88,20 @@ public final class PublicResponseMapper {
return dto;
}
/** 본문이 {@code :::evidence key="..."} 로 가리키는 Asset. key 에서 주소를 만들 수 없어 함께 내려보낸다. */
public static BodyAsset bodyAsset(BodyAssetView view) {
BodyAsset dto = new BodyAsset();
dto.setAssetKey(view.assetKey());
dto.setAssetId(view.assetId());
dto.setUrl(view.url());
dto.setContentType(view.contentType());
dto.setAltText(view.altText());
dto.setWidth(view.width());
dto.setHeight(view.height());
dto.setDecorative(view.decorative());
return dto;
}
public static ContactLink contact(ContactLinkView view) {
ContactLink dto = new ContactLink();
dto.setType(view.type());
@@ -28,7 +28,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
@@ -109,7 +108,14 @@ public class StudioPublicationController {
idempotencyKey,
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
// 계약이 200 으로 선언한 응답이다 (studio-v1.yaml `publishStudioDocument` responses).
// 여기서 201 을 돌려주는 동안 클라이언트는 게시가 끝난 요청을 계약 위반으로 거절했다 —
// 서버는 Publication 을 만들고 공개 경로까지 내줬는데 화면에는 실패로 보였다.
//
// 계약 드리프트 테스트가 이것을 잡지 못한 이유는 `ResponseEntity.status(...)` 가 런타임
// 값이라 springdoc 이 읽는 published 문서에는 나타나지 않기 때문이다. 상태를 계약과 함께
// 두려면 애노테이션이나 반환 타입으로 드러나야 한다.
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@@ -8,10 +8,12 @@ import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ImageBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ThematicBreakBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
import java.util.ArrayList;
import java.util.List;
@@ -24,6 +26,7 @@ import org.commonmark.node.BlockQuote;
import org.commonmark.node.BulletList;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.Heading;
import org.commonmark.node.Image;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Node;
import org.commonmark.node.OrderedList;
@@ -38,10 +41,13 @@ import org.commonmark.node.ThematicBreak;
*/
final class BlockRenderer {
/** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */
private static final int MIN_HEADING_LEVEL = 2;
/**
* 계약 {@code HeadingBlock.level} 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 였고 h1/h5/h6 를 이 범위로 접었는데,
* 그러면 작성자가 쓴 위계가 화면에서 달라진다. 지금은 그대로 담는다.
*/
private static final int MIN_HEADING_LEVEL = 1;
private static final int MAX_HEADING_LEVEL = 4;
private static final int MAX_HEADING_LEVEL = 6;
private final HeadingIds headingIds;
private final List<String> warnings;
@@ -67,18 +73,18 @@ final class BlockRenderer {
private CaseRenderBlock renderBlock(Node node) {
return switch (node) {
case Heading value -> heading(value);
case Paragraph value -> paragraph(InlineRenderer.render(value));
case Paragraph value -> paragraphOrImage(value);
case BlockQuote value -> blockquote(value);
case BulletList value -> bulletList(value);
case OrderedList value -> orderedList(value);
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
case IndentedCodeBlock value -> code(value.getLiteral(), null);
case TableBlock value -> table(value);
// 계약에 수평선 타입이 생겼다(studio-v1 3.1.0). 예전에는 담을 곳이 없어 버리고 경고했다.
case ThematicBreak ignored -> {
// 계약의 CaseRenderBlock 에 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던
// 구조가 생기므로 버리고 경고한다.
warnings.add("THEMATIC_BREAK_NOT_RENDERABLE");
yield null;
ThematicBreakBlock block = new ThematicBreakBlock();
block.setType(ThematicBreakBlock.TypeEnum.THEMATIC_BREAK);
yield block;
}
default -> {
List<Inline> content = InlineRenderer.render(node);
@@ -87,6 +93,47 @@ final class BlockRenderer {
};
}
/**
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
*
* <p>Markdown 에서 {@code ![alt](/media/...)} 는 문단 안의 inline 인데 계약의 Inline union 에는 그림이 없다. 예전에는
* 그래서 대체 텍스트만 남기고 그림을 버렸다. 계약에 블록이 생겼으므로 (studio-v1 3.1.0) 문단이 그림 하나로만 이루어진 경우를 블록으로 올린다.
*
* <p>주소는 상대 경로이거나 http(s) 여야 한다 — {@code javascript:} 같은 스킴이 화면에 그대로 실리면 안 된다.
*/
private CaseRenderBlock paragraphOrImage(Paragraph value) {
Node only = value.getFirstChild();
if (only instanceof Image image && only == value.getLastChild()) {
String source = image.getDestination();
if (isRenderableImageSource(source)) {
ImageBlock block = new ImageBlock();
block.setType(ImageBlock.TypeEnum.IMAGE);
block.setSrc(source);
block.setAlt(InlineRenderer.plainText(image));
block.setTitle(image.getTitle());
return block;
}
warnings.add("IMAGE_SOURCE_NOT_RENDERABLE");
}
return paragraph(InlineRenderer.render(value));
}
private static boolean isRenderableImageSource(String source) {
if (source == null || source.isBlank()) {
return false;
}
// 프로토콜 상대 주소(`//host/…`)는 앱 경로가 아니다. 아래의 단일 슬래시 검사보다 먼저 걸러야
// 한다 — 순서를 바꾸면 외부 호스트를 내부 경로로 오인한다.
if (source.startsWith("//")) {
return false;
}
if (source.startsWith("/")) {
return true;
}
String lower = source.toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("http://") || lower.startsWith("https://");
}
private CaseRenderBlock heading(Heading value) {
HeadingBlock block = new HeadingBlock();
block.setType(HeadingBlock.TypeEnum.HEADING);
@@ -57,9 +57,10 @@ class StudioContentRendererTest {
List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
// 계약의 HeadingBlock.level 은 2..4 다. h1h6 를 그대로 내보내면 계약 위반이다.
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2);
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4);
// 계약의 HeadingBlock.level 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 h1/h6 를 접었고,
// 그래서 작성자가 쓴 위계가 화면에서 달라졌다. 지금은 그대로 내보낸다.
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(1);
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(6);
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
@@ -162,22 +162,50 @@ public class JdbcDocumentDeletionAdapter implements DocumentDeletionPort {
}
@Override
public boolean hasPublicationHistory(String sourceKind, UUID id) {
public boolean isCurrentlyPublished(String sourceKind, UUID id) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS ("
+ " SELECT 1 FROM publication"
+ " WHERE source_kind = :kind AND source_id = :id"
+ " UNION ALL SELECT 1 FROM publication_event"
+ " WHERE source_kind = :kind AND source_id = :id"
+ ")")
"SELECT EXISTS (SELECT 1 FROM publication"
+ " WHERE source_kind = :kind AND source_id = :id AND status = 'PUBLISHED')")
.param("kind", sourceKind)
.param("id", id)
.query(Boolean.class)
.single());
}
/**
* 이벤트와 게시를 한 문장으로 지운다.
*
* <p>둘은 서로를 가리키고, 한쪽 방향만 지연 검사다 — 어떤 순서로 나눠 지워도 중간 상태에서 한쪽 제약이 깨진다. 한 문장 안에서는 외래키 검사가 문장 끝에 한 번
* 도는 덕에 그 중간 상태가 존재하지 않는다. 순서를 맞추는 대신 순서가 필요 없게 만든다.
*/
@Override
public int deletePublicationHistory(String sourceKind, UUID id) {
int snapshots =
jdbcClient
.sql(
"DELETE FROM publication_snapshot WHERE publication_event_id IN"
+ " (SELECT publication_event_id FROM publication_event"
+ " WHERE source_kind = :kind AND source_id = :id)")
.param("kind", sourceKind)
.param("id", id)
.update();
int removed =
jdbcClient
.sql(
"WITH gone_events AS ("
+ " DELETE FROM publication_event"
+ " WHERE source_kind = :kind AND source_id = :id"
+ " RETURNING publication_event_id"
+ ")"
+ " DELETE FROM publication WHERE source_kind = :kind AND source_id = :id")
.param("kind", sourceKind)
.param("id", id)
.update();
return snapshots + removed;
}
/** 미리보기가 검증을 참조하므로 미리보기를 먼저 지운다. */
@Override
public int deleteWorkArtifacts(String sourceKind, UUID id) {
@@ -0,0 +1,104 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 홈 focus 설정 저장소.
*
* <p>{@code home_focus_config} 는 PK 가 고정 UUID 로 CHECK 되어 있는 단일 행 테이블이다. 그래서 조회에 WHERE 가 없고, 갱신은 그
* 고정 id 를 그대로 쓴다 — 행이 여러 개일 수 없으므로 "어느 행" 을 고를 일이 없다.
*
* <p>V9 마이그레이션이 세 슬롯이 모두 비어 있는 행을 미리 넣는다. 따라서 {@link #load()} 는 항상 값을 돌려주며, 빈 결과는 스키마가 깨진 경우뿐이라 그때는
* 예외가 맞다.
*/
@Repository
public class JdbcHomeFocusConfigAdapter implements HomeFocusConfigPort {
private static final UUID ROW_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
private static final String COLUMNS =
"id, version, default_focus_type, current_project_id, open_question_id, recent_decision_id";
private final JdbcClient jdbcClient;
public JdbcHomeFocusConfigAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
private static HomeFocusConfigView map(ResultSet rs, int rowNum) throws SQLException {
return new HomeFocusConfigView(
rs.getObject("id", UUID.class),
rs.getLong("version"),
rs.getString("default_focus_type"),
rs.getObject("current_project_id", UUID.class),
rs.getObject("open_question_id", UUID.class),
rs.getObject("recent_decision_id", UUID.class));
}
@Override
public HomeFocusConfigView load() {
return jdbcClient
.sql("SELECT " + COLUMNS + " FROM home_focus_config")
.query(JdbcHomeFocusConfigAdapter::map)
.optional()
.orElseThrow(
() ->
new IllegalStateException("home_focus_config has no row; migration V9 seeds one"));
}
@Override
public Optional<HomeFocusConfigView> update(UpdateHomeFocusCommand command) {
int updated =
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = :defaultType,"
+ " current_project_id = :projectId, open_question_id = :questionId,"
+ " recent_decision_id = :decisionId, version = version + 1,"
+ " updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", ROW_ID)
.param("expected", command.expectedVersion())
.param("defaultType", command.defaultType())
.param("projectId", command.currentProjectId())
.param("questionId", command.openQuestionId())
.param("decisionId", command.recentDecisionId())
.param("actor", command.actor())
.update();
return updated == 0 ? Optional.empty() : Optional.of(load());
}
@Override
public boolean projectExists(UUID id) {
return exists("project", id);
}
@Override
public boolean questionExists(UUID id) {
return exists("open_question", id);
}
@Override
public boolean decisionExists(UUID id) {
return exists("project_decision", id);
}
/*
* 테이블 이름은 이 클래스 안의 상수 셋에서만 온다 — 호출자가 문자열을 넘길 수 없으므로 연결해도 주입 경로가 없다.
*/
private boolean exists(String table, UUID id) {
return jdbcClient
.sql("SELECT 1 FROM " + table + " WHERE id = :id")
.param("id", id)
.query(Integer.class)
.optional()
.isPresent();
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 프로젝트 활동 편집 저장소.
*
* <p>{@code origin} 은 이 경로에서 언제나 {@code MANUAL} 이다. 이 어댑터로 들어오는 것은 작성자가 손으로 적은 줄뿐이고, {@code AUTO} 는
* 게시 파이프라인이 남기는 몫이다. 클라이언트가 값을 정하게 두면 손으로 적은 줄에 {@code AUTO} 를 붙여 삭제 금지를 우회할 수 있다.
*
* <p>모든 쓰기에 {@code project_id} 조건이 붙는다. 경로가 프로젝트와 활동을 함께 요구하므로, 활동 id 만으로 수정하면 남의 프로젝트에 달린 줄을 고칠 수
* 있다.
*/
@Repository
public class JdbcProjectActivityRepositoryAdapter implements ProjectActivityRepositoryPort {
private static final String COLUMNS =
"id, project_id, activity_type, title, summary, visibility, origin,"
+ " related_resource_type, related_resource_id, occurred_at, version";
private final JdbcClient jdbcClient;
/*
* 생성자는 하나뿐이어야 한다. 한때 테스트용 id 생성기를 받는 두 번째 생성자가 있었고, 그러면
* 컴포넌트 스캔이 어느 것을 쓸지 정하지 못해 기본 생성자를 찾다가 실패한다 — 컴파일도 테스트도
* 통과하고 운영에서 기동만 못 한다. id 는 여기서 만들면 되고, 통합 테스트는 그 값을 볼 필요가 없다.
*/
public JdbcProjectActivityRepositoryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
private static ProjectActivityView map(ResultSet rs, int rowNum) throws SQLException {
return new ProjectActivityView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getString("activity_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("visibility"),
rs.getString("origin"),
rs.getString("related_resource_type"),
rs.getObject("related_resource_id", UUID.class),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("version"));
}
@Override
public List<ProjectActivityView> listByProject(UUID projectId) {
return jdbcClient
.sql(
"SELECT "
+ COLUMNS
+ " FROM project_activity WHERE project_id = :projectId"
+ " ORDER BY occurred_at DESC, id DESC")
.param("projectId", projectId)
.query(JdbcProjectActivityRepositoryAdapter::map)
.list();
}
@Override
public Optional<ProjectActivityView> find(UUID projectId, UUID activityId) {
return jdbcClient
.sql(
"SELECT "
+ COLUMNS
+ " FROM project_activity"
+ " WHERE project_id = :projectId AND id = :id")
.param("projectId", projectId)
.param("id", activityId)
.query(JdbcProjectActivityRepositoryAdapter::map)
.optional();
}
@Override
public ProjectActivityView create(CreateProjectActivityCommand command) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
+ " created_by, updated_by)"
+ " VALUES (:id, :projectId, :type, :title, :summary, :visibility, 'MANUAL',"
+ " :relatedType, :relatedId, :occurredAt, :actor, :actor)")
.param("id", id)
.param("projectId", command.projectId())
.param("type", command.activityType())
.param("title", command.title().trim())
.param("summary", command.summary())
.param("visibility", command.visibility())
.param("relatedType", command.relatedResourceType())
.param("relatedId", command.relatedResourceId())
.param("occurredAt", java.sql.Timestamp.from(command.occurredAt()))
.param("actor", command.actor())
.update();
return find(command.projectId(), id)
.orElseThrow(
() -> new IllegalStateException("activity vanished right after insert: " + id));
}
@Override
public Optional<ProjectActivityView> update(UpdateProjectActivityCommand command) {
int updated =
jdbcClient
.sql(
"UPDATE project_activity SET title = :title, summary = :summary,"
+ " visibility = :visibility, occurred_at = :occurredAt,"
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND project_id = :projectId AND version = :expected")
.param("id", command.activityId())
.param("projectId", command.projectId())
.param("expected", command.expectedVersion())
.param("title", command.title().trim())
.param("summary", command.summary())
.param("visibility", command.visibility())
.param("occurredAt", java.sql.Timestamp.from(command.occurredAt()))
.param("actor", command.actor())
.update();
return updated == 0 ? Optional.empty() : find(command.projectId(), command.activityId());
}
@Override
public int delete(UUID projectId, UUID activityId, long expectedVersion) {
return jdbcClient
.sql(
"DELETE FROM project_activity"
+ " WHERE id = :id AND project_id = :projectId AND version = :expected")
.param("id", activityId)
.param("projectId", projectId)
.param("expected", expectedVersion)
.update();
}
}
@@ -5,11 +5,15 @@ import dev.caskeleton.application.techlog.management.command.UpdateProjectComman
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -89,6 +93,7 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
rs.getString("current_objective"),
rs.getString("next_step"),
labels(rs.getString("technology_labels")),
topicIdsOf(rs.getObject("id", UUID.class)),
rs.getString("workflow_status"),
rs.getString("target_visibility"),
(Integer) rs.getObject("featured_order"),
@@ -186,7 +191,44 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
.param("featured", command.featuredOrder())
.param("actor", command.actor())
.update();
return updated == 0 ? Optional.empty() : find(command.id());
if (updated == 0) {
return Optional.empty();
}
replaceTopics(command.id(), command.topicIds());
return find(command.id());
}
/** 편집기가 고른 주제. 순서가 곧 화면 순서이므로 목록의 자리를 {@code display_order} 에 그대로 적는다. */
private List<UUID> topicIdsOf(UUID projectId) {
return jdbcClient
.sql(
"SELECT topic_id FROM project_topic WHERE project_id = :projectId"
+ " ORDER BY display_order")
.param("projectId", projectId)
.query(UUID.class)
.list();
}
/*
* 통째로 교체한다. 부분 수정으로 두면 "주제를 전부 뗀다" 를 표현할 방법이 없고, 화면도 목록 하나를
* 한 번에 저장하므로 그쪽과도 맞는다.
*/
private void replaceTopics(UUID projectId, List<UUID> topicIds) {
jdbcClient
.sql("DELETE FROM project_topic WHERE project_id = :projectId")
.param("projectId", projectId)
.update();
int order = 0;
for (UUID topicId : topicIds) {
jdbcClient
.sql(
"INSERT INTO project_topic (project_id, topic_id, display_order)"
+ " VALUES (:projectId, :topicId, :order)")
.param("projectId", projectId)
.param("topicId", topicId)
.param("order", order++)
.update();
}
}
private static String nullToEmpty(String value) {
@@ -230,4 +272,142 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
.query(Boolean.class)
.single());
}
/**
* 게시는 세 곳을 한 트랜잭션 안에서 함께 세운다 — 원본의 상태, 공개 투영, canonical route. 어느 하나가 빠지면 증상이 제각각이다: 투영이 없으면 화면이
* 조용히 비고, route 가 없으면 주소만 404 가 되며, 원본 상태가 안 바뀌면 Studio 가 계속 "초안" 이라고 말한다.
*
* <p>낙관적 잠금이 먼저다. 버전이 어긋나면 아래 두 문장은 아예 실행하지 않는다.
*/
@Override
public Optional<ProjectEditView> publish(
UUID id, long expectedVersion, String visibility, String actor) {
int updated =
jdbcClient
.sql(
"UPDATE project SET workflow_status = 'PUBLISHED', target_visibility = :visibility,"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now(), version = version + 1,"
+ " updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.param("visibility", visibility)
.param("actor", actor)
.update();
if (updated == 0) {
return Optional.empty();
}
ProjectEditView project =
find(id)
.orElseThrow(() -> new IllegalStateException("project vanished mid-publish: " + id));
upsertProjection(project, visibility);
replaceRoute(project);
return Optional.of(project);
}
@Override
public Optional<ProjectEditView> unpublish(UUID id, long expectedVersion, String actor) {
int updated =
jdbcClient
.sql(
"UPDATE project SET workflow_status = 'DRAFT', target_visibility = 'PRIVATE',"
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.param("actor", actor)
.update();
if (updated == 0) {
return Optional.empty();
}
jdbcClient
.sql(
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
+ " updated_at = now() WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", id)
.update();
return find(id);
}
/**
* 공개 조회들은 실제 내용을 {@code project} 테이블에서 직접 읽고 이 행은 가시성 관문·주소·정렬 시각으로만 쓴다. 그래서 {@code payload} 는 빈
* 객체로 둔다 — 여기에 사본을 두면 원본이 바뀔 때마다 두 곳이 어긋난다.
*
* <p>{@code search_text} 만은 예외다. 검색은 이 테이블 하나만 훑으므로 여기에 없는 낱말은 영영 찾을 수 없다.
*/
private void upsertProjection(ProjectEditView project, String visibility) {
String searchText =
String.join(
" ",
nullToEmpty(project.name()),
nullToEmpty(project.oneLinePurpose()),
nullToEmpty(project.purposeMarkdown()),
nullToEmpty(project.currentObjective()),
nullToEmpty(project.nextStep()));
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, state_code,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, navigation_path)"
+ " VALUES ('PROJECT', :id, :version, 'ACTIVE', :visibility, :title, :summary,"
+ " :stateCode, 1, '{}'::jsonb, :bodyPlainText, :searchText, :contentHash,"
+ " now(), now(), :navigationPath)"
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
+ " source_version = EXCLUDED.source_version,"
+ " publication_state = 'ACTIVE',"
+ " visibility = EXCLUDED.visibility,"
+ " title = EXCLUDED.title,"
+ " summary = EXCLUDED.summary,"
+ " state_code = EXCLUDED.state_code,"
+ " body_plain_text = EXCLUDED.body_plain_text,"
+ " search_text = EXCLUDED.search_text,"
+ " content_hash = EXCLUDED.content_hash,"
+ " updated_at = now(),"
+ " navigation_path = EXCLUDED.navigation_path")
.param("id", project.id())
.param("version", project.version())
.param("visibility", visibility)
.param("title", project.name())
.param("summary", project.oneLinePurpose())
.param("stateCode", project.phase())
.param("bodyPlainText", nullToEmpty(project.purposeMarkdown()))
.param("searchText", searchText)
.param("contentHash", sha256(searchText))
.param("navigationPath", "/projects/" + project.slug())
.update();
}
/** 이전 slug 의 route 는 alias 로 남긴다 — 지우면 이미 공개된 링크가 끊긴다. */
private void replaceRoute(ProjectEditView project) {
jdbcClient
.sql(
"UPDATE public_route SET route_role = 'ALIAS'"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id AND slug <> :slug")
.param("id", project.id())
.param("slug", project.slug())
.update();
jdbcClient
.sql(
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
+ " VALUES ('PROJECT', :slug, :id, 'CANONICAL')"
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
.param("id", project.id())
.param("slug", project.slug())
.update();
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256")
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
}
@@ -1,6 +1,7 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.BodyAssetView;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
@@ -18,6 +19,7 @@ import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQuer
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -84,9 +86,19 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
.sql(
"SELECT d.id, d.title, d.body_markdown, d.content_format,"
+ " d.content_format_version, d.cover_asset_id,"
+ " c.problem_summary, c.conclusion_summary, c.environment_items,"
// `environment_items` 는 V7 이 만든 jsonb 배열이고, Studio 가 쓰는 것은 V8 이
// 따로 만든 `environment`/`reproduction`
// 문자열이다(V8__techlog_studio_working_copy.sql:23-26).
// 공개 조회가 배열 쪽을 읽는 동안 그 컬럼을 채우는 코드는 어디에도 없었고 —
// 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 두 칸은 늘 비어 있었다.
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
+ " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status,"
+ " p.navigation_path, p.published_at, p.updated_at, p.last_verified_at,"
// 검증일은 원본 `document.last_verified_at` 에서 읽는다. 투영 테이블의 같은 이름
// 컬럼(`public_resource_projection.last_verified_at`)은 `upsertProjection` 이
// 채우지 않아 언제나 null 이었다.
+ " p.navigation_path, p.published_at, p.updated_at, d.last_verified_at,"
+ " d.summary,"
+ " r.rules, r.examples,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug,"
+ " a.content_type AS cover_content_type, a.alt_text AS cover_alt,"
@@ -116,12 +128,16 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
type,
rs.getString("navigation_path"),
rs.getString("title"),
rs.getString("summary"),
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
isCase ? rs.getString("conclusion_summary") : null,
isCase ? json.strings(rs.getString("environment_items")) : List.of(),
isCase ? environment(rs) : List.of(),
isCase ? List.of() : json.strings(rs.getString("applies_to")),
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
// Reference 의 본문은 body_markdown 이 아니라 규칙과 예시에 있다.
isCase ? List.of() : json.referenceRules(rs.getString("rules")),
isCase ? List.of() : json.strings(rs.getString("examples")),
isCase ? null : rs.getString("freshness_status"),
rs.getString("body_markdown"),
rs.getString("content_format"),
@@ -130,6 +146,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
tags(id),
project(rs),
cover(rs),
// 본문에 evidence 를 담는 것은 CASE 뿐이다.
isCase ? bodyAssets(id) : List.of(),
instant(rs, "published_at"),
instant(rs, "updated_at"),
instant(rs, "last_verified_at")));
@@ -215,11 +233,69 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
.list();
}
/**
* 본문이 {@code :::evidence key="..."} 로 가리키는 Asset.
*
* <p>{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 하기
* 때문이다. {@code WORKING} scope 는 Studio 의 것이다.
*
* <p>{@code READY} 가 아닌 Asset 은 뺀다. {@code /media/{assetId}} 가 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를
* 가리키게 된다.
*/
private List<BodyAssetView> bodyAssets(UUID documentId) {
return jdbcClient
.sql(
"SELECT a.id, a.asset_key, a.content_type, a.alt_text, a.width, a.height, a.decorative"
+ " FROM asset_reference r"
+ " JOIN asset a ON a.id = r.asset_id"
+ " WHERE r.owner_type = 'DOCUMENT' AND r.owner_id = :id"
+ " AND r.reference_scope = 'PUBLISHED' AND r.reference_role = 'BODY'"
+ " AND a.management_status = 'READY'"
+ " ORDER BY a.asset_key")
.param("id", documentId)
.query(
(rs, rowNum) -> {
UUID assetId = rs.getObject("id", UUID.class);
return new BodyAssetView(
rs.getString("asset_key"),
assetId,
"/api/v1/public/media/" + assetId,
rs.getString("content_type"),
rs.getString("alt_text"),
integer(rs, "width"),
integer(rs, "height"),
rs.getBoolean("decorative"));
})
.list();
}
static Integer integer(ResultSet rs, String column) throws SQLException {
int value = rs.getInt(column);
return rs.wasNull() ? null : value;
}
static Instant instant(ResultSet rs, String column) throws SQLException {
var value = rs.getTimestamp(column);
return value == null ? null : value.toInstant();
}
/**
* 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다.
*
* <p>계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열 (`environment`,
* `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
*/
static List<String> environment(ResultSet rs) throws SQLException {
List<String> items = new ArrayList<>(2);
for (String column : List.of("environment", "reproduction")) {
String value = rs.getString(column);
if (value != null && !value.isBlank()) {
items.add(value);
}
}
return List.copyOf(items);
}
static TopicSummaryView topic(ResultSet rs) throws SQLException {
return rs.getString("topic_slug") == null
? null
@@ -10,6 +10,7 @@ import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
@@ -88,6 +89,7 @@ public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
rs.getString("next_step"),
rs.getString("system_overview_markdown"),
json.strings(rs.getString("technology_labels")),
topicsOf(projectId),
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"));
return new ProjectDetailView(
rs.getString("navigation_path"),
@@ -327,4 +329,22 @@ public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
.query(UUID.class)
.optional();
}
/**
* 프로젝트가 다루는 주제. 화면의 "주요 주제" 가 이 목록을 그린다.
*
* <p>{@code ACTIVE} 인 주제만 내보낸다 — 보관된 주제를 링크로 내보내면 따라간 곳이 비어 있다. 순서는 편집기가 정한 {@code display_order}
* 를 그대로 따른다.
*/
private List<TopicSummaryView> topicsOf(UUID projectId) {
return jdbcClient
.sql(
"SELECT t.name, t.slug FROM project_topic pt"
+ " JOIN topic t ON t.id = pt.topic_id"
+ " WHERE pt.project_id = :projectId AND t.status = 'ACTIVE'"
+ " ORDER BY pt.display_order, t.name")
.param("projectId", projectId)
.query((rs, rowNum) -> new TopicSummaryView(rs.getString("name"), rs.getString("slug")))
.list();
}
}
@@ -180,8 +180,8 @@ public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort {
}
/**
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 네 값만
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
* 계약 {@code LatestEntry.entryType} 은 {@code CASE / REFERENCE / QUESTION / PROJECT_ACTIVITY /
* RELEASE} 다섯 값을 허용한다. projection 에는 {@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE} 도
* 들어 있으므로 여기서 걸러야 한다 — 거르지 않으면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다.
*
* <p>{@code RELEASE} 가 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
@@ -2,6 +2,7 @@ package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRuleView;
import dev.caskeleton.shared.error.MappingException;
import java.util.ArrayList;
import java.util.List;
@@ -32,6 +33,16 @@ final class PublicJson {
return out;
}
/** Reference 의 판단 기준. 설계의 컬럼은 {@code {title, body, order}} 배열이다. */
List<ReferenceRuleView> referenceRules(String json) {
List<ReferenceRuleView> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ReferenceRuleView(node.path("title").asString(""), node.path("body").asString("")));
}
return out;
}
List<ContactLinkView> contacts(String json) {
List<ContactLinkView> out = new ArrayList<>();
for (JsonNode node : array(json)) {
@@ -16,7 +16,7 @@ final class PublicSql {
* 조건도 한 곳에서 정의한다.
*/
static final String LATEST_ENTRY_TYPES =
" p.resource_type IN ('CASE', 'REFERENCE', 'PROJECT_ACTIVITY') ";
" p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_ACTIVITY') ";
private PublicSql() {}
@@ -13,11 +13,21 @@ import org.springframework.stereotype.Repository;
/**
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
*
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다.
* <p>RELATION EVIDENCE 같은 기록을 서로 다른 시점에서 다. RELATION 은 <em>작성 중</em>에 고르는 것이라 아직 게시되지 않은 작업본까지
* 포함한다 — 두 문서를 같이 쓰면서 서로 잇는 것이 정상적인 순서이고, 게시된 것만 보이면 그 순서를 쓸 수 없다. EVIDENCE 는 공개된 기록을 근거로 인용하는 것이므로
* 공개 투영에 살아 있는 행만 포함한다. 읽는 사람이 따라갈 수 없는 근거는 근거가 아니다.
*/
@Repository
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
/** 공개 노출 조건. {@code PublicSql.ACTIVE} 와 같은 정의다 — 그쪽은 package-private 이라 여기에 다시 적는다. */
private static final String PUBLICLY_VISIBLE =
" p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
/** 연결·근거 대상이 될 수 있는 기록 유형. PROJECT 는 관계가 아니라 소속이라 여기에 없다. */
private static final String LINKABLE_TYPES =
" ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION') ";
private final JdbcClient jdbcClient;
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
@@ -32,7 +42,8 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
switch (type) {
case TOPIC -> searchTopics(pattern, limit);
case PROJECT -> searchProjects(pattern, limit);
case RELATION, EVIDENCE -> List.of();
case RELATION -> searchRelations(pattern, limit);
case EVIDENCE -> searchEvidence(pattern, limit);
};
return new CatalogPageView(items, null);
}
@@ -57,11 +68,25 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
.list();
}
/**
* 프로젝트의 공개 경로를 함께 싣는다.
*
* <p>여기서 {@code publicPath} 를 늘 null 로 두었더니 Decision 의 즉시 미리보기가 어떤 문서에서도 열리지 않았다. Decision 의 공개
* 주소는 자기 slug 가 아니라 {@code <프로젝트 경로>/decisions#<slug>} 라, 렌더 모델이 프로젝트 경로를 요구한다 — 그것이 비어 있으면
* "PROJECT public path is required" 로 미리보기 전체가 멈춘다. 화면에는 무엇이 모자란지 나오지 않는다.
*
* <p>게시되지 않은 프로젝트는 여전히 null 이다. 그때는 공개 주소가 실제로 없고, 없는 주소를 지어내면 미리보기가 보여 주는 링크가 게시 뒤에 달라진다.
*/
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM project "
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
"SELECT pr.id, pr.name, pr.updated_at, p.navigation_path"
+ " FROM project pr"
+ " LEFT JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " AND "
+ PUBLICLY_VISIBLE
+ " WHERE lower(pr.name) LIKE :pattern ORDER BY pr.name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
@@ -71,8 +96,83 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
CatalogEntryType.PROJECT,
rs.getString("name"),
"PROJECT",
null,
rs.getString("navigation_path"),
"project:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
/**
* 세 원천 테이블을 하나의 목록으로 합친다. {@code document} 는 {@code document_type} 이 그대로 계약의 {@code kind} 이고, 나머지
* 둘은 테이블 자체가 유형을 정한다.
*
* <p>공개 경로는 게시된 것에만 있으므로 LEFT JOIN 이다. 작업본은 {@code publicPath} 가 null 이고, 이는 "아직 공개 주소가 없다"는 뜻이지
* "고를 수 없다"는 뜻이 아니다.
*/
private List<CatalogEntryView> searchRelations(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, kind, label, public_path, updated_at FROM ("
+ " SELECT d.id AS id, d.document_type AS kind, d.title AS label,"
+ " p.navigation_path AS public_path, d.updated_at AS updated_at"
+ " FROM document d"
+ " LEFT JOIN public_resource_projection p"
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
+ " AND "
+ PUBLICLY_VISIBLE
+ " WHERE lower(d.title) LIKE :pattern"
+ " UNION ALL"
+ " SELECT q.id, 'QUESTION', q.question,"
+ " p.navigation_path, q.updated_at"
+ " FROM open_question q"
+ " LEFT JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
+ " AND "
+ PUBLICLY_VISIBLE
+ " WHERE lower(q.question) LIKE :pattern"
+ " UNION ALL"
+ " SELECT pd.id, 'PROJECT_DECISION', pd.title,"
+ " p.navigation_path, pd.updated_at"
+ " FROM project_decision pd"
+ " LEFT JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = pd.id"
+ " AND "
+ PUBLICLY_VISIBLE
+ " WHERE lower(pd.title) LIKE :pattern"
+ ") linkable ORDER BY label LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query((rs, rowNum) -> entry(rs, CatalogEntryType.RELATION, "relation"))
.list();
}
/** 공개 투영이 곧 "읽는 사람이 따라갈 수 있는 기록"의 정의다. 여기서는 그 테이블 하나면 충분하다. */
private List<CatalogEntryView> searchEvidence(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT p.resource_id AS id, p.resource_type AS kind, p.title AS label,"
+ " p.navigation_path AS public_path, p.updated_at AS updated_at"
+ " FROM public_resource_projection p"
+ " WHERE "
+ PUBLICLY_VISIBLE
+ " AND p.resource_type IN "
+ LINKABLE_TYPES
+ " AND lower(p.title) LIKE :pattern"
+ " ORDER BY label LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query((rs, rowNum) -> entry(rs, CatalogEntryType.EVIDENCE, "evidence"))
.list();
}
private static CatalogEntryView entry(
java.sql.ResultSet rs, CatalogEntryType type, String revisionPrefix)
throws java.sql.SQLException {
return new CatalogEntryView(
UUID.fromString(rs.getString("id")),
type,
rs.getString("label"),
rs.getString("kind"),
rs.getString("public_path"),
revisionPrefix + ":" + rs.getTimestamp("updated_at").toInstant());
}
}
@@ -164,9 +164,56 @@ public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
// 18. Document publish metadata
markSourcePublished(request);
// 19. 프로젝트 활동 로그
recordProjectActivity(request);
return result(publicationId, eventId);
}
/**
* 프로젝트 활동은 손으로 적는 것이 아니라 게시가 남기는 로그다.
*
* <p>한동안 이 줄을 Studio 에서 직접 써야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다.
* 게시가 곧 사건이므로 게시가 기록한다.
*
* <p>{@code operation_key} 로 문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로 타임라인에 다시 나타나지 않아야 한다
* — {@code uq_project_activity_operation_key} 가 그것을 보장하고, 여기서는 충돌을 무시한다.
*
* <p>프로젝트에 매달리지 않은 기록은 남길 자리가 없다. 그때는 아무것도 하지 않는다.
*/
private void recordProjectActivity(PublishRequest request) {
if (request.projectId() == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
+ " operation_key, created_by, updated_by)"
+ " VALUES (:id, :projectId, :type, :title, '', 'PUBLIC', 'AUTO',"
+ " :resourceType, :resourceId, now(), :operationKey, :actor, :actor)"
+ " ON CONFLICT (project_id, operation_key) DO NOTHING")
.param("id", idGenerator.get())
.param("projectId", request.projectId())
.param("type", activityTypeOf(request.kind()))
.param("title", request.title())
.param("resourceType", request.kind().name())
.param("resourceId", request.documentId())
.param("operationKey", "publication:" + request.documentId())
.param("actor", request.principal())
.update();
}
/** {@code project_activity_activity_type_check} 가 허용하는 값으로 옮긴다. */
private static String activityTypeOf(RecordKind kind) {
return switch (kind) {
case CASE -> "CASE_PUBLISHED";
case REFERENCE -> "REFERENCE_PUBLISHED";
case QUESTION -> "QUESTION_OPENED";
case PROJECT_DECISION -> "DECISION_ACCEPTED";
};
}
@Override
public PublishResultView unpublish(UnpublishRequest request) {
requireTransaction("unpublish");
@@ -0,0 +1,53 @@
-- 프로젝트 활동을 게시 로그로 되돌린다.
--
-- 이 표는 원래 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로
-- 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 이제 게시가 이 줄을 남긴다
-- (JdbcPublicationWriterAdapter 19단계).
--
-- 이 마이그레이션은 그 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는
-- 상태를 남겨 두면, 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다.
--
-- occurred_at 은 최초 PUBLISHED 사건의 시각이다 -- now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼
-- 보인다. operation_key 는 애플리케이션이 쓰는 것과 같은 규칙이라, 나중에 같은 문서를 재게시해도
-- 줄이 늘지 않는다.
INSERT INTO project_activity (
id, project_id, activity_type, title, summary, visibility, origin,
related_resource_type, related_resource_id, occurred_at,
operation_key, created_by, updated_by
)
SELECT
gen_random_uuid(),
link.project_id,
CASE publication.source_kind
WHEN 'CASE' THEN 'CASE_PUBLISHED'
WHEN 'REFERENCE' THEN 'REFERENCE_PUBLISHED'
WHEN 'QUESTION' THEN 'QUESTION_OPENED'
ELSE 'DECISION_ACCEPTED'
END,
projection.title,
'',
'PUBLIC',
'AUTO',
publication.source_kind,
publication.source_id,
first_published.occurred_at,
'publication:' || publication.source_id,
'system:migration',
'system:migration'
FROM publication
JOIN public_resource_project_link link
ON link.resource_type = publication.source_kind
AND link.resource_id = publication.source_id
AND link.relation_type = 'PRIMARY'
JOIN public_resource_projection projection
ON projection.resource_type = publication.source_kind
AND projection.resource_id = publication.source_id
JOIN LATERAL (
SELECT min(event.occurred_at) AS occurred_at
FROM publication_event event
WHERE event.publication_id = publication.publication_id
AND event.event_type = 'PUBLISHED'
) first_published ON true
WHERE publication.status = 'PUBLISHED'
AND first_published.occurred_at IS NOT NULL
ON CONFLICT (project_id, operation_key) DO NOTHING;
@@ -43,8 +43,16 @@ class PostgreSqlMigrationIntegrationTest {
.load()
.migrate();
/*
TechLog 가 들어오면서 8·9·10 이 붙었는데 이 목록은 7 에서 멈춰 있었다. 마이그레이션을 더한
사람이 여기를 같이 고치지 않으면 이 테스트만 빨개지고, 그 빨간색은 "스키마가 잘못됐다" 가
아니라 "목록을 안 고쳤다" 를 뜻한다 — 정확히 그 상태로 두 번 지나갔다.
목록을 고정해 두는 이유는 남아 있다: 마이그레이션이 순서대로, 빠짐없이 적용되는지 확인한다.
그래서 개수를 세는 것이 아니라 버전을 그대로 적는다.
*/
assertThat(appliedVersions(postgres, "flyway_schema_history"))
.containsExactly("1", "3", "4", "5", "6", "7");
.containsExactly("1", "3", "4", "5", "6", "7", "8", "9", "10");
Flyway coreStream =
Flyway.configure()
@@ -4,6 +4,18 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapter;
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import java.time.Instant;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
@@ -12,6 +24,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
import tools.jackson.databind.ObjectMapper;
/**
* 작업본 삭제 SQL 을 실제 PostgreSQL 위에서 돌린다.
@@ -32,6 +45,10 @@ class ManagementPersistenceIntegrationTest {
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcDocumentDeletionAdapter deletion;
private static JdbcProjectRepositoryAdapter projects;
private static JdbcHomeFocusConfigAdapter homeFocus;
private static JdbcCatalogQueryAdapter catalog;
private static JdbcProjectActivityRepositoryAdapter activities;
@BeforeAll
static void migrate() {
@@ -61,6 +78,10 @@ class ManagementPersistenceIntegrationTest {
jdbcClient = JdbcClient.create(dataSource);
deletion = new JdbcDocumentDeletionAdapter(jdbcClient);
projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper());
homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient);
catalog = new JdbcCatalogQueryAdapter(jdbcClient);
activities = new JdbcProjectActivityRepositoryAdapter(jdbcClient);
}
@AfterAll
@@ -271,16 +292,36 @@ class ManagementPersistenceIntegrationTest {
}
@Test
void seesPublicationHistoryThroughTheKindAndIdPair() {
// 게시 이력은 문서를 외래키 없이 (source_kind, source_id) 로 가리킨다. 이 쿼리가 없어서
// 지운 문서를 가리키는 이력이 남았고, 대시보드와 게시 기록 화면이 null 을 읽고 죽었다.
void seesOnlyAPublicationThatIsStillLive() {
// 막아야 하는 것은 "게시한 적이 있다" 가 아니라 "지금 읽히고 있다" 다 — 게시를 취소한 기록을
// 영영 지울 수 없게 하면, 작성자가 공개를 취소하는 이유 자체가 막힌다.
UUID id = insertCase("게시된 적 있는 기록");
assertThat(deletion.hasPublicationHistory("CASE", id)).isFalse();
assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse();
insertPublication(id);
assertThat(deletion.isCurrentlyPublished("CASE", id)).isTrue();
assertThat(deletion.isCurrentlyPublished("QUESTION", id)).isFalse();
assertThat(deletion.hasPublicationHistory("CASE", id)).isTrue();
assertThat(deletion.hasPublicationHistory("QUESTION", id)).isFalse();
jdbcClient
.sql("UPDATE publication SET status = 'UNPUBLISHED' WHERE source_id = :id")
.param("id", id)
.update();
assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse();
}
@Test
void clearsThePublicationHistoryInDependencyOrder() {
// 이력은 기록에 무슨 일이 있었는지 말하는 것이라, 기록이 사라지면 아무것도 가리키지 않는다.
// 외래키가 없어 DB 가 대신 지워 주지 않으므로, 순서까지 여기서 확인한다.
UUID id = insertCase("이력을 남긴 기록");
insertPublication(id);
assertThat(countIn("publication", "source_id", id)).isEqualTo(1);
assertThat(countIn("publication_event", "source_id", id)).isEqualTo(1);
// 반환값은 지운 publication 행 수다 — 이벤트는 같은 문장에서 함께 사라지므로 따로 세지 않는다.
assertThat(deletion.deletePublicationHistory("CASE", id)).isEqualTo(1);
assertThat(countIn("publication", "source_id", id)).isZero();
assertThat(countIn("publication_event", "source_id", id)).isZero();
}
@Test
@@ -376,4 +417,402 @@ class ManagementPersistenceIntegrationTest {
assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1);
assertThat(deletion.findDecision(projectId, id)).isEmpty();
}
// ---------------------------------------------------------------- 프로젝트 게시
/**
* 프로젝트 게시는 원본 상태·공개 투영·canonical route 세 곳을 함께 세운다. 어느 하나가 빠지면 증상이 제각각이라 (투영이 없으면 화면이 조용히 비고,
* route 가 없으면 주소만 404) 세 곳을 한꺼번에 확인한다.
*/
@Test
void publishingAProjectRaisesTheProjection() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Publish Me", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'publish-me' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
ProjectEditView published =
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
assertThat(published.targetVisibility()).isEqualTo("PUBLIC");
assertThat(published.firstPublishedAt()).isNotNull();
assertThat(
jdbcClient
.sql(
"SELECT navigation_path FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id"
+ " AND publication_state = 'ACTIVE' AND visibility = 'PUBLIC'")
.param("id", loaded.id())
.query(String.class)
.optional())
.contains("/projects/publish-me");
assertThat(
jdbcClient
.sql(
"SELECT route_role FROM public_route"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", loaded.id())
.query(String.class)
.list())
.containsExactly("CANONICAL");
}
/** 게시 취소는 투영 행을 지우지 않고 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */
@Test
void unpublishingWithdrawsTheProjectionInsteadOfDeletingIt() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Withdraw Me", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'withdraw-me' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
ProjectEditView published =
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
assertThat(projects.unpublish(published.id(), published.version(), "test")).isPresent();
assertThat(
jdbcClient
.sql(
"SELECT publication_state FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", published.id())
.query(String.class)
.optional())
.contains("WITHDRAWN");
}
/** 낙관적 잠금. 어긋난 버전으로는 아무것도 세우지 않는다. */
@Test
void publishingWithAStaleVersionChangesNothing() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Stale", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'stale' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
assertThat(projects.publish(loaded.id(), loaded.version() + 99, "PUBLIC", "test")).isEmpty();
assertThat(
jdbcClient
.sql(
"SELECT count(*) FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", loaded.id())
.query(Integer.class)
.single())
.isZero();
}
// ---------------------------------------------------------------- 홈 focus
/** V9 가 빈 행 하나를 넣어 둔다. 조회가 실패하면 그 전제가 깨진 것이다. */
@Test
void homeFocusStartsAsASingleEmptyRow() {
HomeFocusConfigView current = homeFocus.load();
assertThat(current.currentProjectId()).isNull();
assertThat(current.openQuestionId()).isNull();
assertThat(current.recentDecisionId()).isNull();
}
@Test
void homeFocusRoundTripsAndRefusesAStaleVersion() {
ProjectEditView project = projects.create(new CreateProjectCommand("Focus Target", "test"));
HomeFocusConfigView before = homeFocus.load();
HomeFocusConfigView saved =
homeFocus
.update(
new UpdateHomeFocusCommand(
before.version(), "CURRENT_WORK", project.id(), null, null, "test"))
.orElseThrow();
assertThat(saved.currentProjectId()).isEqualTo(project.id());
assertThat(saved.defaultType()).isEqualTo("CURRENT_WORK");
assertThat(saved.version()).isEqualTo(before.version() + 1);
assertThat(homeFocus.projectExists(project.id())).isTrue();
assertThat(homeFocus.projectExists(UUID.randomUUID())).isFalse();
assertThat(
homeFocus.update(
new UpdateHomeFocusCommand(before.version(), null, null, null, null, "test")))
.isEmpty();
// null 은 "이 슬롯을 비운다" 다 — 부분 수정이 아니라 전체 교체.
HomeFocusConfigView cleared =
homeFocus
.update(new UpdateHomeFocusCommand(saved.version(), null, null, null, null, "test"))
.orElseThrow();
assertThat(cleared.currentProjectId()).isNull();
}
// ---------------------------------------------------------------- catalog
/** RELATION 은 작성 중에 고르는 것이므로 게시 여부와 무관하게 작업본까지 포함한다. */
@Test
void relationCatalogIncludesUnpublishedWorkingCopies() {
UUID caseId = insertCase("Relation Target Case");
UUID questionId = insertQuestion("Relation Target Question");
CatalogPageView page = catalog.search(CatalogEntryType.RELATION, null, null, 50);
assertThat(page.items()).extracting("id").contains(caseId, questionId);
assertThat(page.items())
.filteredOn(entry -> entry.id().equals(caseId))
.singleElement()
.satisfies(
entry -> {
assertThat(entry.kind()).isEqualTo("CASE");
assertThat(entry.publicPath()).isNull();
});
}
/** EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. */
@Test
void evidenceCatalogIncludesOnlyPubliclyVisibleRecords() {
UUID publishedId = insertCase("Evidence Published");
UUID draftId = insertCase("Evidence Draft");
UUID withdrawnId = insertCase("Evidence Withdrawn");
insertProjection("CASE", publishedId, "ACTIVE", "Evidence Published");
insertProjection("CASE", withdrawnId, "WITHDRAWN", "Evidence Withdrawn");
CatalogPageView page = catalog.search(CatalogEntryType.EVIDENCE, "evidence", null, 50);
assertThat(page.items()).extracting("id").contains(publishedId);
assertThat(page.items()).extracting("id").doesNotContain(draftId, withdrawnId);
}
/** 검색어는 대소문자를 가리지 않는다. */
@Test
void relationCatalogSearchIsCaseInsensitive() {
UUID caseId = insertCase("MixedCase Needle");
assertThat(catalog.search(CatalogEntryType.RELATION, "mixedcase", null, 50).items())
.extracting("id")
.contains(caseId);
}
// ---------------------------------------------------------------- 프로젝트 활동
private static ProjectActivityView addActivity(UUID projectId, String title, String visibility) {
return activities.create(
new CreateProjectActivityCommand(
projectId,
0L,
"MILESTONE_REACHED",
title,
"요약",
visibility,
null,
null,
Instant.parse("2026-08-23T00:00:00Z"),
"test"));
}
/**
* 공개 프로젝트 화면의 "활동" 은 이 테이블을 투영 없이 직접 읽는다. 그래서 여기서 만든 줄이 곧 그 화면이고, 편집기가 읽는 목록과 화면이 읽는 목록이 같은 정렬·같은
* 조건을 써야 한다.
*/
@Test
void createdActivitiesAreVisibleToTheEditorAndToThePublicQuery() {
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Host", "test"));
ProjectActivityView pub = addActivity(project.id(), "공개 활동", "PUBLIC");
addActivity(project.id(), "비공개 활동", "PRIVATE");
assertThat(pub.origin()).isEqualTo("MANUAL");
assertThat(activities.listByProject(project.id())).hasSize(2);
// 공개 조회가 쓰는 조건 그대로.
assertThat(
jdbcClient
.sql(
"SELECT title FROM project_activity"
+ " WHERE project_id = :id AND visibility = 'PUBLIC'"
+ " ORDER BY occurred_at DESC, id DESC")
.param("id", project.id())
.query(String.class)
.list())
.containsExactly("공개 활동");
}
@Test
void activityUpdatesRoundTripAndRefuseAStaleVersion() {
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Edit", "test"));
ProjectActivityView created = addActivity(project.id(), "처음 제목", "PRIVATE");
ProjectActivityView updated =
activities
.update(
new UpdateProjectActivityCommand(
project.id(),
created.id(),
created.version(),
"고친 제목",
"고친 요약",
"PUBLIC",
Instant.parse("2026-08-24T00:00:00Z"),
"test"))
.orElseThrow();
assertThat(updated.title()).isEqualTo("고친 제목");
assertThat(updated.visibility()).isEqualTo("PUBLIC");
assertThat(updated.version()).isEqualTo(created.version() + 1);
assertThat(
activities.update(
new UpdateProjectActivityCommand(
project.id(),
created.id(),
created.version(),
"다시",
null,
"PUBLIC",
Instant.parse("2026-08-24T00:00:00Z"),
"test")))
.isEmpty();
}
/** 경로가 프로젝트와 활동을 함께 요구하므로 저장소도 둘을 함께 본다 — 남의 프로젝트 줄을 고치거나 지울 수 없어야 한다. */
@Test
void activityWritesAreScopedToTheirOwnProject() {
ProjectEditView mine = projects.create(new CreateProjectCommand("Mine", "test"));
ProjectEditView theirs = projects.create(new CreateProjectCommand("Theirs", "test"));
ProjectActivityView activity = addActivity(theirs.id(), "남의 활동", "PUBLIC");
assertThat(activities.find(mine.id(), activity.id())).isEmpty();
assertThat(activities.delete(mine.id(), activity.id(), activity.version())).isZero();
assertThat(activities.find(theirs.id(), activity.id())).isPresent();
}
@Test
void deletingAnActivityHonoursTheExpectedVersion() {
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Delete", "test"));
ProjectActivityView activity = addActivity(project.id(), "지울 활동", "PUBLIC");
assertThat(activities.delete(project.id(), activity.id(), activity.version() + 9)).isZero();
assertThat(activities.delete(project.id(), activity.id(), activity.version())).isEqualTo(1);
assertThat(activities.listByProject(project.id())).isEmpty();
}
// ---------------------------------------------------------------- 프로젝트 주제
private static UUID insertTopic(String name, String slug) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, :name, :normalized, :slug, 'test', 'test')")
.param("id", id)
.param("name", name)
.param("normalized", name.toLowerCase(java.util.Locale.ROOT))
.param("slug", slug)
.update();
return id;
}
private static UpdateProjectCommand updateWithTopics(
ProjectEditView project, java.util.List<UUID> topicIds) {
return new UpdateProjectCommand(
project.id(),
project.version(),
project.name(),
project.slug() == null ? "project-" + project.id() : project.slug(),
"목적 한 줄",
"",
"",
"",
project.phase(),
"지금 목표",
"다음 작업",
java.util.List.of("Keycloak"),
topicIds,
"PRIVATE",
null,
"test");
}
/**
* 프로젝트 화면의 "주요 주제" 는 이 링크 테이블을 읽는다. 화면은 처음부터 주제를 읽고 있었지만 저장할 경로가 없어 늘 비어 있었다 — 여기서 확인하는 것은 고른 순서가
* 그대로 남는가와, 통째로 교체되는가다.
*/
@Test
void projectTopicsRoundTripInTheChosenOrder() {
ProjectEditView project = projects.create(new CreateProjectCommand("Topic Host", "test"));
UUID first = insertTopic("주제 하나", "topic-one");
UUID second = insertTopic("주제 둘", "topic-two");
ProjectEditView saved =
projects.update(updateWithTopics(project, java.util.List.of(second, first))).orElseThrow();
assertThat(saved.topicIds()).containsExactly(second, first);
assertThat(saved.currentObjective()).isEqualTo("지금 목표");
assertThat(saved.nextStep()).isEqualTo("다음 작업");
// 공개 조회가 쓰는 조건 그대로 — ACTIVE 인 주제만, display_order 순서로.
assertThat(
jdbcClient
.sql(
"SELECT t.name FROM project_topic pt"
+ " JOIN topic t ON t.id = pt.topic_id"
+ " WHERE pt.project_id = :id AND t.status = 'ACTIVE'"
+ " ORDER BY pt.display_order, t.name")
.param("id", project.id())
.query(String.class)
.list())
.containsExactly("주제 둘", "주제 하나");
}
/** 부분 수정이 아니라 전체 교체다 — 빈 목록은 "주제를 전부 뗀다" 는 뜻이어야 한다. */
@Test
void savingAnEmptyTopicListDetachesEveryTopic() {
ProjectEditView project = projects.create(new CreateProjectCommand("Detach Host", "test"));
UUID topic = insertTopic("뗄 주제", "topic-detach");
ProjectEditView withTopic =
projects.update(updateWithTopics(project, java.util.List.of(topic))).orElseThrow();
assertThat(withTopic.topicIds()).containsExactly(topic);
ProjectEditView cleared =
projects.update(updateWithTopics(withTopic, java.util.List.of())).orElseThrow();
assertThat(cleared.topicIds()).isEmpty();
assertThat(
jdbcClient
.sql("SELECT count(*) FROM project_topic WHERE project_id = :id")
.param("id", project.id())
.query(Integer.class)
.single())
.isZero();
}
/**
* Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 편집기가 Decision 을 미리 그리려면 이 목록이 프로젝트의 공개 경로를
* 알고 있어야 한다 — 한동안 늘 null 이었고, 그동안 Decision 은 어떤 문서에서도 즉시 미리보기가 열리지 않았다.
*/
@Test
void projectCatalogCarriesThePublicPathOfPublishedProjectsOnly() {
ProjectEditView open = projects.create(new CreateProjectCommand("Catalog Published", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'catalog-published' WHERE id = :id")
.param("id", open.id())
.update();
ProjectEditView loaded = projects.find(open.id()).orElseThrow();
assertThat(projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test")).isPresent();
ProjectEditView hidden = projects.create(new CreateProjectCommand("Catalog Hidden", "test"));
var entries = catalog.search(CatalogEntryType.PROJECT, "catalog", null, 50).items();
assertThat(entries)
.filteredOn(entry -> entry.id().equals(open.id()))
.singleElement()
.satisfies(
entry -> assertThat(entry.publicPath()).isEqualTo("/projects/catalog-published"));
// 게시되지 않은 프로젝트는 공개 주소가 실제로 없다. 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.
assertThat(entries)
.filteredOn(entry -> entry.id().equals(hidden.id()))
.singleElement()
.satisfies(entry -> assertThat(entry.publicPath()).isNull());
}
}
@@ -206,12 +206,21 @@ class PublicSitePersistenceIntegrationTest {
.noneMatch(entry -> entry.title().contains("숨김"));
assertThat(view.latestEntries())
.as(
"계약 LatestEntry.entryType 은 네 값만 허용한다 — projection 의 QUESTION/PROJECT 등이 섞이면"
+ " 응답 매퍼가 계약 밖 값을 만나 500 이 ")
"계약 LatestEntry.entryType 이 허용하는 값만 나와야 한다 — projection 의 PROJECT/PROJECT_DECISION"
+ " 등이 섞이면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든")
.extracting("entryType")
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
.allSatisfy(
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
type ->
assertThat(type)
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
assertThat(view.latestEntries())
.as("게시한 Open Question 도 최근 기록에 나와야 한다 — 목록에서 빠지면 게시한 사실이 어디에도 보이지 않는다")
.anySatisfy(
entry -> {
assertThat(entry.entryType()).isEqualTo("QUESTION");
assertThat(entry.path()).isEqualTo("/questions/reprocessing-latency");
});
}
/**
@@ -397,10 +406,16 @@ class PublicSitePersistenceIntegrationTest {
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
assertThat(view.latestRecords()).isNotEmpty();
assertThat(view.latestRecords())
.as("주제 상세의 최신 기록도 계약의 entryType 네 값을 벗어나면 안 된다")
.as("주제 상세의 최신 기록도 계약의 entryType 을 벗어나면 안 된다")
.extracting("entryType")
.allSatisfy(
type -> assertThat(type).isIn("CASE", "REFERENCE", "PROJECT_ACTIVITY", "RELEASE"));
type ->
assertThat(type)
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
assertThat(view.latestRecords())
.as("주제와 홈은 같은 목록 의미를 쓴다 — 홈에 나오는 Open Question 이 여기서 빠지면 두 화면이 어긋난다")
.extracting("entryType")
.contains("QUESTION");
}
@Test
@@ -1,25 +1,35 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase;
import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase;
import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectActivityUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase;
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectActivityUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
@@ -128,4 +138,51 @@ public class TechLogManagementConfig {
DocumentDeletionPort documents, TransactionPort tx) {
return new DeleteProjectDecisionUseCase(documents, tx);
}
@Bean
PublishProjectUseCase publishProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
return new PublishProjectUseCase(projects, tx);
}
@Bean
UnpublishProjectUseCase unpublishProjectUseCase(
ProjectRepositoryPort projects, TransactionPort tx) {
return new UnpublishProjectUseCase(projects, tx);
}
@Bean
GetHomeFocusUseCase getHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
return new GetHomeFocusUseCase(config, tx);
}
@Bean
UpdateHomeFocusUseCase updateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
return new UpdateHomeFocusUseCase(config, tx);
}
@Bean
ListStudioProjectActivitiesUseCase listStudioProjectActivitiesUseCase(
ProjectActivityRepositoryPort activities, TransactionPort tx) {
return new ListStudioProjectActivitiesUseCase(activities, tx);
}
@Bean
CreateProjectActivityUseCase createProjectActivityUseCase(
ProjectActivityRepositoryPort activities,
ProjectRepositoryPort projects,
TransactionPort tx) {
return new CreateProjectActivityUseCase(activities, projects, tx);
}
@Bean
UpdateProjectActivityUseCase updateProjectActivityUseCase(
ProjectActivityRepositoryPort activities, TransactionPort tx) {
return new UpdateProjectActivityUseCase(activities, tx);
}
@Bean
DeleteProjectActivityUseCase deleteProjectActivityUseCase(
ProjectActivityRepositoryPort activities, TransactionPort tx) {
return new DeleteProjectActivityUseCase(activities, tx);
}
}
@@ -173,6 +173,14 @@ spring:
# true | false (Java 21 virtual threads for Tomcat request handlers)
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
servlet:
session:
# Studio 작성자는 한 기록을 여러 번 저장하며 오래 머문다. Spring 기본 30분 유휴 만료는 그
# 리듬보다 짧아 작성 도중 로그인 화면으로 돌아가는 일이 잦았다 — 저장하지 않은 편집이
# 있으면 그 시점에 잃는다.
#
# 값은 배포가 정한다. 늘릴수록 훔친 session cookie가 유효한 창도 같이 늘어나므로,
# Keycloak realm 의 SSO idle 과 따로 놀지 않게 함께 맞춘다.
timeout: ${APP_SESSION_TIMEOUT:8h}
multipart:
# feature-api-contract-baseline D8: bound request body size so an oversized
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
@@ -2158,4 +2158,67 @@ class CleanArchitectureTest {
.filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID)
.isPresent();
}
/**
* Spring 이 생성자를 고를 수 있어야 한다.
*
* <p>이 규칙은 사고 하나에서 나왔다. 한 어댑터가 생성자를 둘 갖고 있었고 — 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 것 — 둘 중 어느 것에도
* {@code @Autowired} 가 없었다. 컴포넌트 스캔은 고르지 못하고 기본 생성자를 찾다가 실패한다. 컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서
* 도는 통합 테스트도 전부 통과했다. 그 어느 것도 컨텍스트를 띄우지 않기 때문이다. 운영에서 기동만 못 했다.
*
* <p>그래서 여기서 막는다: 스캔되는 스프링 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 {@code @Autowired} 가 붙어 있어야 한다.
*/
@ArchTest
static final ArchRule SPRING_COMPONENTS_HAVE_AN_UNAMBIGUOUS_CONSTRUCTOR =
classes()
.that()
.areAnnotatedWith("org.springframework.stereotype.Repository")
.or()
.areAnnotatedWith("org.springframework.stereotype.Service")
.or()
.areAnnotatedWith("org.springframework.stereotype.Component")
.or()
.areAnnotatedWith("org.springframework.web.bind.annotation.RestController")
.should(haveAConstructorSpringCanChoose())
.as(
"D20: a scanned Spring component must have exactly one constructor, or mark one"
+ " @Autowired — otherwise component scan falls back to a no-arg constructor that"
+ " does not exist and the application fails to start");
private static ArchCondition<JavaClass> haveAConstructorSpringCanChoose() {
return new ArchCondition<>("have a constructor Spring can choose") {
@Override
public void check(JavaClass item, ConditionEvents events) {
var constructors =
item.getConstructors().stream()
.filter(constructor -> !constructor.getModifiers().contains(JavaModifier.SYNTHETIC))
.toList();
if (constructors.size() <= 1) {
return;
}
boolean autowired =
constructors.stream()
.anyMatch(
constructor ->
constructor.getAnnotations().stream()
.anyMatch(
annotation ->
annotation
.getRawType()
.getName()
.equals(
"org.springframework.beans.factory.annotation.Autowired")));
if (!autowired) {
events.add(
SimpleConditionEvent.violated(
item,
item.getName()
+ " declares "
+ constructors.size()
+ " constructors and marks none @Autowired;"
+ " component scan cannot choose one"));
}
}
};
}
}
@@ -11,6 +11,8 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration;
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
@@ -117,13 +119,21 @@ class ActuatorSecurityHttpTest {
.andExpect(status().isForbidden());
}
/*
* Redis 함께 제외한다. 빼지 않으면 reactive Redis health indicator 슬라이스에 그대로
* 올라와 localhost:6379 붙으려 하고, 실패하면 /actuator/health 503 된다. 그러면
* 테스트는 "probe 가 permitAll 인가" 아니라 "이 기계에 Redis 가 떠 있는가" 재게 된다
* 보안 태세와 아무 상관 없는 이유로 빨개진다.
*/
@EnableAutoConfiguration(
exclude = {
DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
HibernateJpaAutoConfiguration.class,
DataJpaRepositoriesAutoConfiguration.class,
FlywayAutoConfiguration.class
FlywayAutoConfiguration.class,
DataRedisAutoConfiguration.class,
DataRedisReactiveAutoConfiguration.class
})
@Import(ManagementSecurityConfig.class)
static class MinimalActuatorApp {}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.techlog.management.command;
import java.time.Instant;
import java.util.UUID;
/**
* {@code createProjectActivity}. {@code origin} 명령이 정하지 않는다 경로로 들어온 것은 언제나 {@code MANUAL} 이다.
*/
public record CreateProjectActivityCommand(
UUID projectId,
long expectedProjectVersion,
String activityType,
String title,
String summary,
String visibility,
String relatedResourceType,
UUID relatedResourceId,
Instant occurredAt,
String actor) {}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
/**
* {@code updateHomeFocus}. 슬롯은 전부 nullable 이고, null "이 슬롯을 비운다" 뜻이다 부분 수정이 아니라 전체 교체다.
*
* <p>부분 수정으로 두면 "비우기" 표현할 방법이 없어진다. 화면이 칸을 번에 보여 주고 번에 저장하므로 전체 교체가 화면과도 맞다.
*/
public record UpdateHomeFocusCommand(
long expectedVersion,
String defaultType,
UUID currentProjectId,
UUID openQuestionId,
UUID recentDecisionId,
String actor) {}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.techlog.management.command;
import java.time.Instant;
import java.util.UUID;
/**
* {@code updateProjectActivity}.
*
* <p>{@code activityType} 관련 자원은 고칠 없다 그것을 바꾸면 같은 줄이 다른 사건을 가리키게 된다. 바꿀 있는 것은 사람이 읽는
* 부분(제목·요약) 노출 여부, 그리고 언제 일어난 일인지다.
*/
public record UpdateProjectActivityCommand(
UUID projectId,
UUID activityId,
long expectedVersion,
String title,
String summary,
String visibility,
Instant occurredAt,
String actor) {}
@@ -17,11 +17,13 @@ public record UpdateProjectCommand(
String currentObjective,
String nextStep,
List<String> technologyLabels,
List<UUID> topicIds,
String targetVisibility,
Integer featuredOrder,
String actor) {
public UpdateProjectCommand {
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
topicIds = topicIds == null ? List.of() : List.copyOf(topicIds);
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.management.model;
import java.util.UUID;
/**
* 화면이 무엇을 앞에 둘지 정하는 단일 설정. 계약 {@code HomeFocusResponse} application 표현.
*
* <p> 슬롯은 모두 nullable 이다. 비어 있다는 것은 "고르지 않았다" 뜻이고, 공개 화면은 그때 focus 영역을 아예 그리지 않는다.
*/
public record HomeFocusConfigView(
UUID id,
long version,
String defaultType,
UUID currentProjectId,
UUID openQuestionId,
UUID recentDecisionId) {}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.techlog.management.model;
import java.time.Instant;
import java.util.UUID;
/**
* 계약 {@code ProjectActivityResponse}.
*
* <p>{@code origin} 줄이 어디서 왔는지다 게시 같은 실제 사건이 남긴 {@code AUTO} 인지, 작성자가 손으로 적은 {@code MANUAL}
* 인지. 지우기가 둘을 다르게 다루므로 뷰에도 실어 보낸다.
*/
public record ProjectActivityView(
UUID id,
UUID projectId,
String activityType,
String title,
String summary,
String visibility,
String origin,
String relatedResourceType,
UUID relatedResourceId,
Instant occurredAt,
long version) {}
@@ -7,8 +7,10 @@ import java.util.UUID;
/**
* 계약 {@code ProjectEditResponse}.
*
* <p>{@code topicIds}/{@code documentLinks}/{@code questionLinks} 링크 테이블이 소유한다. () 범위에서는 편집
* 화면이 없으므로 항상 비어 있고, 링크 다루는 화면이 생길 같은 뷰에 채운.
* <p>{@code topicIds} {@code project_topic} 소유한다. 프로젝트 편집 화면이 목록을 고르고, 공개 프로젝트 화면의 "주요 주제"
* 결과를 그린다 화면은 처음부터 주제 읽고 있었지만 저장할 경로가 없어 비어 있었.
*
* <p>{@code documentLinks}/{@code questionLinks} 아직 편집 화면이 없어 항상 비어 있다.
*/
public record ProjectEditView(
UUID id,
@@ -23,6 +25,7 @@ public record ProjectEditView(
String currentObjective,
String nextStep,
List<String> technologyLabels,
List<UUID> topicIds,
String workflowStatus,
String targetVisibility,
Integer featuredOrder,
@@ -32,5 +35,6 @@ public record ProjectEditView(
public ProjectEditView {
technologyLabels = technologyLabels == null ? List.of() : List.copyOf(technologyLabels);
topicIds = topicIds == null ? List.of() : List.copyOf(topicIds);
}
}
@@ -46,10 +46,18 @@ public interface DocumentDeletionPort {
boolean questionReferenced(UUID id);
/**
* 게시 이력이 남아 있는지. {@code publication}/{@code publication_event} 문서를 외래키 없이 {@code (source_kind,
* source_id)} 가리키므로 DB 막아 주지 않는다 실제로 그래서 지운 문서를 가리키는 이력이 남아 대시보드와 게시 기록 화면이 null 읽고 죽었다.
* 지금 공개돼 있는지.
*
* <p>처음에는 "게시한 적이 있는가" 막았는데, 그러면 게시를 취소한 기록을 영영 지울 없다 작성자가 공개를 취소하는 이유가 대개 없애려는 것인데도. 막아야
* 하는 것은 <b>지금 읽히고 있는 것이 소리 없이 사라지는 </b> 이므로 조건도 그것이다.
*/
boolean hasPublicationHistory(String sourceKind, UUID id);
boolean isCurrentlyPublished(String sourceKind, UUID id);
/**
* 게시 이력을 치운다. 이력은 어떤 기록에 무슨 일이 있었는지 말하는 것이라, 기록이 사라지면 아무것도 가리키지 않는다 실제로 그렇게 남은 행들이 대시보드와 게시
* 기록 화면을 죽였다. {@code publication} 계열은 외래키가 없어 DB 대신 주지 않는다.
*/
int deletePublicationHistory(String sourceKind, UUID id);
/** Decision 은 프로젝트에 속한다 — 경로가 둘 다 들고 있으므로 둘로 찾는다. */
record DeletableDecision(UUID id, String decisionStatus, long version) {}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.management.port.out;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import java.util.Optional;
import java.util.UUID;
/** 홈 focus 설정의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
public interface HomeFocusConfigPort {
/** 단일 행이므로 조회는 실패하지 않는다. 마이그레이션이 빈 행을 이미 넣어 두었다. */
HomeFocusConfigView load();
/** 낙관적 잠금. 버전이 어긋나면 {@code Optional.empty()}. */
Optional<HomeFocusConfigView> update(UpdateHomeFocusCommand command);
boolean projectExists(UUID id);
boolean questionExists(UUID id);
boolean decisionExists(UUID id);
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.techlog.management.port.out;
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** 프로젝트 활동의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
public interface ProjectActivityRepositoryPort {
/** 최근 것이 먼저다 — 공개 타임라인과 같은 정렬이라 편집기와 화면이 같은 순서를 본다. */
List<ProjectActivityView> listByProject(UUID projectId);
Optional<ProjectActivityView> find(UUID projectId, UUID activityId);
ProjectActivityView create(CreateProjectActivityCommand command);
/** 낙관적 잠금. 버전이 어긋나면 {@code Optional.empty()}. */
Optional<ProjectActivityView> update(UpdateProjectActivityCommand command);
/** 삭제된 행 수. 0 이면 없거나 버전이 어긋난 것이다. */
int delete(UUID projectId, UUID activityId, long expectedVersion);
}
@@ -25,5 +25,16 @@ public interface ProjectRepositoryPort {
boolean isReferenced(UUID id);
/**
* 공개 게시. 프로젝트는 Studio 문서가 아니라 {@code RecordKind} 없고, 따라서 문서 게시 파이프라인을 타지 않는다. 공개 화면들은 모두 {@code
* public_resource_projection} {@code PROJECT} 행을 가시성 관문으로 쓰므로, 행을 세우는 것이 게시다.
*
* <p>버전이 어긋나면 {@code Optional.empty()}.
*/
Optional<ProjectEditView> publish(UUID id, long expectedVersion, String visibility, String actor);
/** 게시 취소. 투영 행은 지우지 않고 {@code WITHDRAWN} 으로 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */
Optional<ProjectEditView> unpublish(UUID id, long expectedVersion, String actor);
boolean slugTaken(String slug, UUID exceptId);
}
@@ -0,0 +1,73 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/**
* {@code createProjectActivity}.
*
* <p>{@code expectedProjectVersion} 활동이 아니라 <em>프로젝트</em> 버전이다. 활동은 아직 없으므로 자기 버전을 가질 없고,
* 사람이 같은 프로젝트의 타임라인을 동시에 고치는 것을 막으려면 붙잡을 것이 프로젝트뿐이다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class CreateProjectActivityUseCase {
private final ProjectActivityRepositoryPort activities;
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public CreateProjectActivityUseCase(
ProjectActivityRepositoryPort activities,
ProjectRepositoryPort projects,
TransactionPort transactions) {
this.activities = Objects.requireNonNull(activities, "activities");
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectActivityView handle(CreateProjectActivityCommand command) {
Objects.requireNonNull(command, "command");
ProjectActivityUseCases.requireType(command.activityType());
ProjectActivityUseCases.requireVisibility(command.visibility());
ProjectActivityUseCases.requireTitle(command.title());
if (command.occurredAt() == null) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "occurredAt must not be null");
}
return transactions.inWrite(
() -> {
ProjectEditView project =
projects
.find(command.projectId())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND, "no such project"));
if (project.version() != command.expectedProjectVersion()) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the project changed since it was loaded",
new SaveTopicUseCase.VersionConflict(project.version()));
}
return activities.create(command);
});
}
}
@@ -49,19 +49,15 @@ public class DeleteDocumentDraftUseCase {
() ->
ManagementException.of(
ManagementError.DOCUMENT_NOT_FOUND, "no such working copy"));
// 지금 읽히고 있는 것만 막는다. 곳이 공개 여부를 따로 들고 있으므로 본다
// 어긋난 상태로 지우면 공개 화면이 없는 기록을 가리키게 된다.
if ("PUBLISHED".equals(current.workflowStatus())
|| documents.publiclyProjected(documentType, command.id())) {
|| documents.publiclyProjected(documentType, command.id())
|| documents.isCurrentlyPublished(documentType, command.id())) {
throw ManagementException.of(
ManagementError.DOCUMENT_PUBLISHED,
"the record is published; unpublish it before deleting");
}
// 게시된 적이 있으면 이력이 남아 있다. 이력은 무슨 일이 있었는지에 대한 기록이고,
// 문서만 지우면 아무것도 가리키지 않는 이력이 되어 화면을 깨뜨린다.
if (documents.hasPublicationHistory(documentType, command.id())) {
throw ManagementException.of(
ManagementError.DOCUMENT_IN_USE,
"the record has publication history; it cannot be deleted");
}
if (documents.documentReferenced(command.id())) {
throw ManagementException.of(
ManagementError.DOCUMENT_IN_USE,
@@ -71,6 +67,7 @@ public class DeleteDocumentDraftUseCase {
// 대신 주지 않고, 남겨 두면 없는 기록을 가리키는 행이 된다.
documents.deleteProjection(documentType, command.id());
documents.deleteWorkArtifacts(documentType, command.id());
documents.deletePublicationHistory(documentType, command.id());
if (documents.deleteDocument(command.id(), command.expectedVersion()) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
@@ -0,0 +1,68 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.UUID;
/**
* {@code deleteProjectActivity}.
*
* <p>{@code AUTO} 기록된 줄은 지우지 않는다. 그것은 게시 같은 실제 사건이 남긴 흔적이고, 지우면 무슨 일이 있었는지가 사라진다. 손으로 적은 {@code
* MANUAL} 줄만 지울 있다 잘못 적은 것을 되돌리는 것과 일어난 일을 없애는 것은 다르다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class DeleteProjectActivityUseCase {
private final ProjectActivityRepositoryPort activities;
private final TransactionPort transactions;
public DeleteProjectActivityUseCase(
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
this.activities = Objects.requireNonNull(activities, "activities");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public void handle(UUID projectId, UUID activityId, long expectedVersion, String actor) {
Objects.requireNonNull(projectId, "projectId");
Objects.requireNonNull(activityId, "activityId");
Objects.requireNonNull(actor, "actor");
transactions.inWrite(
() -> {
ProjectActivityView current =
activities
.find(projectId, activityId)
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND,
"no such activity on this project"));
if (!"MANUAL".equals(current.origin())) {
throw ManagementException.of(
ManagementError.PROJECT_IN_USE,
"an automatically recorded activity cannot be deleted");
}
if (activities.delete(projectId, activityId, expectedVersion) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the activity changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version()));
}
return null;
});
}
}
@@ -50,10 +50,10 @@ public class DeleteProjectDecisionUseCase {
() ->
ManagementException.of(
ManagementError.DECISION_NOT_FOUND, "no such decision"));
if (documents.hasPublicationHistory("PROJECT_DECISION", decisionId)) {
if (documents.isCurrentlyPublished("PROJECT_DECISION", decisionId)) {
throw ManagementException.of(
ManagementError.DECISION_IN_USE,
"the decision has publication history; it cannot be deleted");
"the decision is published; unpublish it before deleting");
}
if (documents.decisionReferenced(decisionId)) {
throw ManagementException.of(
@@ -62,6 +62,7 @@ public class DeleteProjectDecisionUseCase {
}
documents.deleteProjection("PROJECT_DECISION", decisionId);
documents.deleteWorkArtifacts("PROJECT_DECISION", decisionId);
documents.deletePublicationHistory("PROJECT_DECISION", decisionId);
if (documents.deleteDecision(decisionId, expectedVersion) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
@@ -43,16 +43,12 @@ public class DeleteQuestionUseCase {
() ->
ManagementException.of(
ManagementError.QUESTION_NOT_FOUND, "no such question"));
if (documents.publiclyProjected("QUESTION", command.id())) {
if (documents.publiclyProjected("QUESTION", command.id())
|| documents.isCurrentlyPublished("QUESTION", command.id())) {
throw ManagementException.of(
ManagementError.DOCUMENT_PUBLISHED,
"the question is published; unpublish it before deleting");
}
if (documents.hasPublicationHistory("QUESTION", command.id())) {
throw ManagementException.of(
ManagementError.QUESTION_IN_USE,
"the question has publication history; it cannot be deleted");
}
if (documents.questionReferenced(command.id())) {
throw ManagementException.of(
ManagementError.QUESTION_IN_USE,
@@ -60,6 +56,7 @@ public class DeleteQuestionUseCase {
}
documents.deleteProjection("QUESTION", command.id());
documents.deleteWorkArtifacts("QUESTION", command.id());
documents.deletePublicationHistory("QUESTION", command.id());
if (documents.deleteQuestion(command.id(), command.expectedVersion()) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
@@ -0,0 +1,35 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/*
* final 아닌 이유는 다른 use case 들과 같다 @RequiresPermission CGLIB 프록시로 강제된다.
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public class GetHomeFocusUseCase {
private final HomeFocusConfigPort config;
private final TransactionPort transactions;
public GetHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort transactions) {
this.config = Objects.requireNonNull(config, "config");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public HomeFocusConfigView handle() {
return transactions.inRead(config::load);
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public class ListStudioProjectActivitiesUseCase {
private final ProjectActivityRepositoryPort activities;
private final TransactionPort transactions;
public ListStudioProjectActivitiesUseCase(
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
this.activities = Objects.requireNonNull(activities, "activities");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public List<ProjectActivityView> handle(UUID projectId) {
Objects.requireNonNull(projectId, "projectId");
return transactions.inRead(() -> activities.listByProject(projectId));
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import java.util.Set;
/** 활동 use case 넷이 공유하는 규칙. 값의 허용 집합은 DB CHECK 제약과 같은 목록이다. */
final class ProjectActivityUseCases {
/** {@code project_activity_activity_type_check} 와 같은 목록이다. */
static final Set<String> TYPES =
Set.of(
"PHASE_CHANGED",
"QUESTION_OPENED",
"QUESTION_RESOLVED",
"DECISION_ACCEPTED",
"CASE_PUBLISHED",
"REFERENCE_PUBLISHED",
"MILESTONE_REACHED",
"PROJECT_PAUSED",
"PROJECT_RESUMED");
static final Set<String> VISIBILITIES = Set.of("PRIVATE", "PUBLIC");
private ProjectActivityUseCases() {}
/*
* 제약 위반을 그대로 터뜨리지 않고 여기서 먼저 거절한다. 드라이버가 던지는 것은 벤더 메시지 문자열이고,
* 그것을 화면에 그대로 내보내면 작성자는 무엇을 고쳐야 하는지 없다.
*/
static void requireType(String value) {
if (value == null || !TYPES.contains(value)) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "activityType is not a known activity type");
}
}
static void requireVisibility(String value) {
if (value == null || !VISIBILITIES.contains(value)) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "visibility must be PRIVATE or PUBLIC");
}
}
static void requireTitle(String value) {
if (value == null || value.isBlank()) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "title must not be blank");
}
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* {@code publishProject}.
*
* <p>프로젝트는 Studio 문서가 아니다 {@code RecordKind} 없고 본문도 검증 대상도 없다. 그래서 문서 게시 파이프라인 대신 여기서 직접 공개 상태를
* 세운다. 릴리스가 자체 경로를 갖는 것과 같은 이유다.
*
* <p>slug 없으면 거절한다. 공개 주소가 {@code /projects/<slug>} 이므로 slug 없이 게시하면 아무도 닿을 없는 페이지가 생긴다 저장은
* 성공했는데 링크는 없는, 이유를 없는 상태다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class PublishProjectUseCase {
private static final Set<String> VISIBILITIES = Set.of("PUBLIC", "UNLISTED");
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public PublishProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectEditView handle(UUID id, long expectedVersion, String visibility, String actor) {
Objects.requireNonNull(id, "id");
String requested = visibility == null ? "PUBLIC" : visibility;
if (!VISIBILITIES.contains(requested)) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "visibility must be PUBLIC or UNLISTED");
}
return transactions.inWrite(
() -> {
ProjectEditView current =
projects
.find(id)
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND, "no such project"));
if (current.slug() == null || current.slug().isBlank()) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED,
"a slug is required before a project can be published");
}
return projects
.publish(id, expectedVersion, requested, actor)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the project changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.UUID;
/** {@code unpublishProject}. 투영 행은 지우지 않고 {@code WITHDRAWN} 으로 내린다. */
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UnpublishProjectUseCase {
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public UnpublishProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectEditView handle(UUID id, long expectedVersion, String actor) {
Objects.requireNonNull(id, "id");
return transactions.inWrite(
() -> {
ProjectEditView current =
projects
.find(id)
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND, "no such project"));
return projects
.unpublish(id, expectedVersion, actor)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the project changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* {@code updateHomeFocus}.
*
* <p>지목한 대상이 실제로 있는지 여기서 먼저 확인한다. 테이블에는 FK 없어서 (설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 그대로 저장할
* 있고, 그러면 공개 화면은 조용히 focus 그린다 저장은 성공했는데 화면에는 아무것도 나오는, 이유를 없는 실패가 된다.
*
* <p>대상이 <em>공개</em>인지는 확인하지 않는다. 아직 게시하지 않은 프로젝트를 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인 순서다. 공개 여부는
* 공개 조회 쪽이 매번 다시 판단한다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UpdateHomeFocusUseCase {
private static final Set<String> DEFAULT_TYPES =
Set.of("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION");
private final HomeFocusConfigPort config;
private final TransactionPort transactions;
public UpdateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort transactions) {
this.config = Objects.requireNonNull(config, "config");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public HomeFocusConfigView handle(UpdateHomeFocusCommand command) {
Objects.requireNonNull(command, "command");
if (command.defaultType() != null && !DEFAULT_TYPES.contains(command.defaultType())) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "defaultType is not a known focus type");
}
return transactions.inWrite(
() -> {
requireExists(
command.currentProjectId(),
config::projectExists,
ManagementError.PROJECT_NOT_FOUND,
"no such project");
requireExists(
command.openQuestionId(),
config::questionExists,
ManagementError.QUESTION_NOT_FOUND,
"no such question");
requireExists(
command.recentDecisionId(),
config::decisionExists,
ManagementError.DECISION_NOT_FOUND,
"no such decision");
HomeFocusConfigView current = config.load();
return config
.update(command)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the home focus changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
private static void requireExists(
UUID id, java.util.function.Predicate<UUID> exists, ManagementError error, String message) {
if (id != null && !exists.test(id)) {
throw ManagementException.of(error, message);
}
}
}
@@ -0,0 +1,63 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/** {@code updateProjectActivity}. */
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UpdateProjectActivityUseCase {
private final ProjectActivityRepositoryPort activities;
private final TransactionPort transactions;
public UpdateProjectActivityUseCase(
ProjectActivityRepositoryPort activities, TransactionPort transactions) {
this.activities = Objects.requireNonNull(activities, "activities");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectActivityView handle(UpdateProjectActivityCommand command) {
Objects.requireNonNull(command, "command");
ProjectActivityUseCases.requireVisibility(command.visibility());
ProjectActivityUseCases.requireTitle(command.title());
if (command.occurredAt() == null) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "occurredAt must not be null");
}
return transactions.inWrite(
() -> {
ProjectActivityView current =
activities
.find(command.projectId(), command.activityId())
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND,
"no such activity on this project"));
return activities
.update(command)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the activity changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.techlog.publicsite.model;
import java.util.UUID;
/**
* 계약 {@code BodyAsset}.
*
* <p>본문이 {@code :::evidence key="..."} 가리키는 Asset 이다. 본문은 Markdown 원문으로 나가고 안에는 key 있는데
* {@code /media/{assetId}} UUID 로만 서빙하므로 주소가 추측 불가능한 것이 의도된 성질이다 key 에서 주소를 만들 없다. 공개 화면이
* key 해석할 있도록 게시된 기록이 실제로 참조하는 Asset 함께 준다.
*
* @param url 검증된 전송 경로다. object storage URL 아니다(설계 05장 §3.1).
* @param decorative 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 값으로 판정하므로 공개 화면도 같은 값을 보고 {@code alt} 정해야
* 판정과 표시가 어긋나지 않는다.
*/
public record BodyAssetView(
String assetKey,
UUID assetId,
String url,
String contentType,
String altText,
Integer width,
Integer height,
boolean decorative) {}
@@ -9,17 +9,23 @@ import java.util.List;
* <p>계약의 {@code CaseDetailResponse.case} {@code ReferenceDetailResponse.reference} 담는 필드가
* 다르지만(문제/결론 vs 범위/적용), 원천이 같은 {@code document} + 유형별 detail 이라 하나의 레코드로 읽고 계층에서 유형별 모양으로 나눈다.
*
* @param summary 문서가 스스로 밝히는 요약. 제목 바로 아래에 온다 유형별 요약({@code primarySummary}: Case 문제,
* Reference 범위) 다르다. 자리가 없던 동안 화면은 유형별 요약을 대신 썼고, 그러면 머리말이 바로 아래의 "문제" "이 기준을 쓰는 이유"
* 같은 글을 말했다.
* @param content Markdown 원문이다. studio 렌더 블록이 아니다 공개 계약은 {@code contentFormat} 함께 원문을 준다.
*/
public record PublishedDocumentView(
String type,
String canonicalPath,
String title,
String summary,
String primarySummary,
String secondarySummary,
List<String> environmentSummary,
List<String> appliesTo,
List<String> excludedScope,
List<ReferenceRuleView> rules,
List<String> examples,
String freshnessStatus,
String content,
String contentFormat,
@@ -28,6 +34,7 @@ public record PublishedDocumentView(
List<TagSummaryView> tags,
ProjectSummaryView primaryProject,
AssetReferenceView coverAsset,
List<BodyAssetView> bodyAssets,
Instant publishedAt,
Instant updatedAt,
Instant lastVerifiedAt) {
@@ -36,6 +43,10 @@ public record PublishedDocumentView(
environmentSummary = environmentSummary == null ? List.of() : List.copyOf(environmentSummary);
appliesTo = appliesTo == null ? List.of() : List.copyOf(appliesTo);
excludedScope = excludedScope == null ? List.of() : List.copyOf(excludedScope);
rules = rules == null ? List.of() : List.copyOf(rules);
examples = examples == null ? List.of() : List.copyOf(examples);
tags = tags == null ? List.of() : List.copyOf(tags);
// Reference 에는 evidence 담는 본문이 없다. 목록이 정상이며 없음과 구분하지 않는다.
bodyAssets = bodyAssets == null ? List.of() : List.copyOf(bodyAssets);
}
}
@@ -15,9 +15,11 @@ public record PublishedProjectView(
String nextStep,
String systemOverviewMarkdown,
List<String> technologies,
List<TopicSummaryView> topics,
Instant updatedAt) {
public PublishedProjectView {
technologies = technologies == null ? List.of() : List.copyOf(technologies);
topics = topics == null ? List.of() : List.copyOf(topics);
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.techlog.publicsite.model;
/**
* Reference 판단 기준 . 계약 {@code ReferenceDetailResponse.reference.rules[]}.
*
* <p>Reference 본문은 마크다운 덩어리가 아니라 제목이 붙은 규칙의 목록이다 Studio 편집기가 그렇게 받고, 공개 화면도 그렇게 그린다.
*/
public record ReferenceRuleView(String title, String body) {}
@@ -0,0 +1,86 @@
package dev.caskeleton.application.techlog.studio.service;
import java.util.Optional;
/**
* 업로드한 그림의 픽셀 크기를 헤더에서 읽는다.
*
* <p>업로드가 값을 기록하지 않아 모든 그림이 화면에서 사라졌다. 렌더러는 치수를 모르면 자리를 잡을 없고, 그때 쓰던 대체값 1×1 {@code
* loading="lazy"} 만나 브라우저가 영영 가져오지 않는 상자가 됐다. 화면 쪽은 모르는 치수를 정직하게 비우도록 고쳤고, 이쪽은 애초에 있는 값을
* 기록한다.
*
* <p>디코딩하지 않고 헤더만 읽는다. {@code ImageIO} {@code java.desktop} 모듈을 끌어오고 런타임 이미지가 그것을 담고 있으리라는 보장이 없다
* 없는 배포에서 업로드가 통째로 실패하는 것보다, 아는 형식의 헤더 바이트를 직접 읽는 편이 낫다. 모르는 형식은 비운다: 크기를 모르는 것과 크기가 0 것은
* 다르고, 화면은 둘을 구분한다.
*/
public final class ImageDimensions {
/** 픽셀 크기. 둘 다 양수일 때만 만든다. */
public record Size(int width, int height) {}
private ImageDimensions() {}
public static Optional<Size> of(String mediaType, byte[] content) {
if (mediaType == null || content == null) {
return Optional.empty();
}
return switch (mediaType) {
case "image/png" -> png(content);
case "image/gif" -> gif(content);
case "image/jpeg" -> jpeg(content);
// WebP SVG 읽지 않는다. WebP VP8/VP8L/VP8X 갈래로 형식이 갈리고, SVG
// 픽셀 크기가 없을 수도 있는 벡터다 "모른다" 정직한 답이다.
default -> Optional.empty();
};
}
/** IHDR 은 항상 첫 청크이고, 폭·높이가 그 앞 8바이트다. */
private static Optional<Size> png(byte[] c) {
if (c.length < 24) {
return Optional.empty();
}
return size(int32(c, 16), int32(c, 20));
}
/** 논리 화면 기술자. 리틀엔디언 16비트 둘. */
private static Optional<Size> gif(byte[] c) {
if (c.length < 10) {
return Optional.empty();
}
return size((c[6] & 0xFF) | ((c[7] & 0xFF) << 8), (c[8] & 0xFF) | ((c[9] & 0xFF) << 8));
}
/** SOF 마커까지 세그먼트를 건너뛴다. 어느 SOF 인지는 상관없다 — 어떤 것이든 그 안의 높이·폭이 그림의 크기다. */
private static Optional<Size> jpeg(byte[] c) {
int i = 2;
while (i + 9 < c.length) {
if ((c[i] & 0xFF) != 0xFF) {
i++;
continue;
}
int marker = c[i + 1] & 0xFF;
// SOF0..SOF15, DHT(C4)·JPG(C8)·DAC(CC) SOF 아니다.
if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) {
return size(
(c[i + 7] & 0xFF) << 8 | (c[i + 8] & 0xFF), (c[i + 5] & 0xFF) << 8 | (c[i + 6] & 0xFF));
}
int length = (c[i + 2] & 0xFF) << 8 | (c[i + 3] & 0xFF);
if (length < 2) {
return Optional.empty();
}
i += 2 + length;
}
return Optional.empty();
}
private static int int32(byte[] c, int at) {
return ((c[at] & 0xFF) << 24)
| ((c[at + 1] & 0xFF) << 16)
| ((c[at + 2] & 0xFF) << 8)
| (c[at + 3] & 0xFF);
}
private static Optional<Size> size(int width, int height) {
return width > 0 && height > 0 ? Optional.of(new Size(width, height)) : Optional.empty();
}
}
@@ -82,6 +82,10 @@ public class UploadStudioAssetUseCase implements CommandUseCase<UploadAssetComma
"the uploaded content is not one of the media types this Studio accepts");
}
// 저장하기 전에 읽는다. 바이트는 여기 이미 있고, 나중에 다시 받아 오면 저장소 왕복이
// 생긴다.
java.util.Optional<ImageDimensions.Size> dimensions = ImageDimensions.of(mediaType, content);
UUID assetId = idGenerator.get();
String assetKey = assetKeyFor(input.originalFilename(), assetId);
String objectKey = "techlog/assets/" + assetId;
@@ -107,8 +111,9 @@ public class UploadStudioAssetUseCase implements CommandUseCase<UploadAssetComma
storedKey,
input.originalFilename(),
input.byteSize(),
null,
null,
// 헤더에서 읽는다. 둘이 비어 있으면 화면이 그림의 자리를 잡지 못한다.
dimensions.map(ImageDimensions.Size::width).orElse(null),
dimensions.map(ImageDimensions.Size::height).orElse(null),
sha256(content),
input.altText(),
input.decorative(),
@@ -55,6 +55,15 @@ public final class StudioDocumentValidator {
return new Outcome(status, issues.toList());
}
/**
* 게시를 막는 것은 가지뿐이다: 제목과 slug.
*
* <p>예전에는 Case 하나에 일곱 칸을 요구했고, 보여 만한 초안을 가진 작성자가 그것을 보여 없었다 기록을 남기라고 만든 도구가 기록을 막고 있었다.
* 무엇을 얼마나 쓸지는 작성자가 정하고, 기록은 채로 공개된다.
*
* <p>남긴 둘은 취향이 아니라 도달 가능성이다. 제목이 없으면 목록에 실을 없고, slug 없으면 가리킬 주소가 없다. 밖에 오류로 남은 것들은 전부 "가리키는
* 대상이 없다" 는 문제다 — 없는 주제·프로젝트·관계·Asset 을 가리킨 채 공개하면 읽는 쪽에서 깨진다.
*/
private static void validateBase(WorkingCopyBaseInput base, ValidationIssues issues) {
requireText(issues, base.title(), "TITLE_REQUIRED", "/title", "a title is required to publish");
// 렌더 모델의 slug minLength 3 + 패턴이다. 저장은 slug 허용하지만 게시는 한다.
@@ -63,10 +72,10 @@ public final class StudioDocumentValidator {
} else if (base.slug().length() < 3) {
issues.error("SLUG_TOO_SHORT", "/slug", "a slug must be at least 3 characters");
}
requireText(
warnIfBlank(
issues, base.summary(), "SUMMARY_REQUIRED", "/summary", "a summary is required to publish");
if (base.topicId() == null) {
issues.error("TOPIC_REQUIRED", "/topicId", "a topic is required to publish");
issues.warning("TOPIC_REQUIRED", "/topicId", "a topic is required to publish");
}
}
@@ -85,7 +94,8 @@ public final class StudioDocumentValidator {
== dev.caskeleton.application.techlog.studio.model.RecordKind.PROJECT_DECISION
&& document.base().projectId() == null) {
// 계약: "kind=PROJECT_DECISION 은 게시 시점에 non-null 이어야 한다."
issues.error("PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project");
issues.warning(
"PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project");
}
List<RelationView> relations = document.base().relations();
@@ -112,33 +122,33 @@ public final class StudioDocumentValidator {
private static void validateKindSpecific(WorkingCopyView document, ValidationIssues issues) {
switch (document) {
case WorkingCopyView.CaseWorkingCopyView value -> {
requireText(
warnIfBlank(
issues, value.problem(), "PROBLEM_REQUIRED", "/problem", "a problem is required");
requireText(
warnIfBlank(
issues,
value.conclusion(),
"CONCLUSION_REQUIRED",
"/conclusion",
"a conclusion is required");
if (value.lastVerifiedOn() == null) {
issues.error(
issues.warning(
"LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "a verification date is required");
}
}
case WorkingCopyView.ReferenceWorkingCopyView value -> {
requireText(
warnIfBlank(
issues, value.purpose(), "PURPOSE_REQUIRED", "/purpose", "a purpose is required");
if (value.rules().isEmpty()) {
issues.error("RULES_REQUIRED", "/rules", "at least one rule is required");
issues.warning("RULES_REQUIRED", "/rules", "at least one rule is required");
}
if (value.applyWhen().isEmpty()) {
issues.error(
issues.warning(
"APPLY_WHEN_REQUIRED",
"/applyWhen",
"at least one application condition is required");
}
if (value.verifiedOn() == null) {
issues.error("VERIFIED_ON_REQUIRED", "/verifiedOn", "a verification date is required");
issues.warning("VERIFIED_ON_REQUIRED", "/verifiedOn", "a verification date is required");
}
requireOrderedText(issues, value.applyWhen(), "/applyWhen");
requireOrderedText(issues, value.exceptions(), "/exceptions");
@@ -146,12 +156,12 @@ public final class StudioDocumentValidator {
}
case WorkingCopyView.QuestionWorkingCopyView value -> {
if (value.questionStatus() == null) {
issues.error("QUESTION_STATUS_REQUIRED", "/questionStatus", "a status is required");
issues.warning("QUESTION_STATUS_REQUIRED", "/questionStatus", "a status is required");
}
if (value.facts().isEmpty()) {
issues.error("FACTS_REQUIRED", "/facts", "at least one established fact is required");
issues.warning("FACTS_REQUIRED", "/facts", "at least one established fact is required");
}
requireText(
warnIfBlank(
issues,
value.nextValidation(),
"NEXT_VALIDATION_REQUIRED",
@@ -159,7 +169,7 @@ public final class StudioDocumentValidator {
"the next validation step is required");
if (value.questionStatus() == QuestionStatusView.RESOLVED
&& (value.resolution() == null || isBlank(value.resolution().summary()))) {
issues.error(
issues.warning(
"RESOLUTION_REQUIRED", "/resolution", "a resolved question needs its resolution");
}
requireOrderedText(issues, value.facts(), "/facts");
@@ -169,18 +179,18 @@ public final class StudioDocumentValidator {
}
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
if (value.decisionStatus() == null) {
issues.error("DECISION_STATUS_REQUIRED", "/decisionStatus", "a status is required");
issues.warning("DECISION_STATUS_REQUIRED", "/decisionStatus", "a status is required");
}
if (value.decidedOn() == null) {
issues.error("DECIDED_ON_REQUIRED", "/decidedOn", "a decision date is required");
issues.warning("DECIDED_ON_REQUIRED", "/decidedOn", "a decision date is required");
}
requireText(
warnIfBlank(
issues,
value.statement(),
"STATEMENT_REQUIRED",
"/statement",
"a statement is required");
requireText(
warnIfBlank(
issues,
value.rationale(),
"RATIONALE_REQUIRED",
@@ -238,6 +248,14 @@ public final class StudioDocumentValidator {
}
}
/** 비어 있음을 경고로 알린다. 게시를 막지는 않는다 — 무엇을 얼마나 쓸지는 작성자가 정한다. */
private static void warnIfBlank(
ValidationIssues issues, String value, String code, String path, String message) {
if (value == null || value.isBlank()) {
issues.warning(code, path, message);
}
}
private static void requireText(
ValidationIssues issues, String value, String code, String path, String message) {
if (isBlank(value)) {
@@ -245,13 +263,12 @@ public final class StudioDocumentValidator {
}
}
/** 계약의 {@code OrderedText.text} 는 minLength 1 이다 — 빈 항목이 있으면 렌더 모델이 계약을 어긴다. */
/** 빈 항목은 빈 항목으로 그려진다. 작성자가 거기 그렇게 둔 것이므로 알리기만 한다. */
private static void requireOrderedText(
ValidationIssues issues, List<OrderedTextView> items, String path) {
for (int i = 0; i < items.size(); i++) {
if (isBlank(items.get(i).text())) {
issues.error(
"ORDERED_TEXT_EMPTY", path + "/" + i + "/text", "an empty item cannot publish");
issues.warning("ORDERED_TEXT_EMPTY", path + "/" + i + "/text", "this item is empty");
}
}
}
@@ -0,0 +1,107 @@
package dev.caskeleton.application.techlog.studio.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Optional;
import org.junit.jupiter.api.Test;
/** 헤더 오프셋은 눈으로 맞는지 알 수 없다. 업로드가 치수를 기록하지 않아 모든 그림이 화면에서 사라진 적이 있으므로, 이 값을 읽는 코드는 실제 바이트로 확인한다. */
class ImageDimensionsTest {
private static byte[] png(int width, int height) {
byte[] bytes = new byte[24];
bytes[0] = (byte) 0x89;
bytes[1] = 'P';
bytes[2] = 'N';
bytes[3] = 'G';
// 8..15 청크 길이와 "IHDR"; ·높이는 16 부터다.
writeInt(bytes, 16, width);
writeInt(bytes, 20, height);
return bytes;
}
private static void writeInt(byte[] bytes, int at, int value) {
bytes[at] = (byte) (value >>> 24);
bytes[at + 1] = (byte) (value >>> 16);
bytes[at + 2] = (byte) (value >>> 8);
bytes[at + 3] = (byte) value;
}
private static byte[] gif(int width, int height) {
byte[] bytes = new byte[10];
bytes[0] = 'G';
bytes[1] = 'I';
bytes[2] = 'F';
bytes[3] = '8';
// 리틀엔디언이다 PNG 반대다.
bytes[6] = (byte) (width & 0xFF);
bytes[7] = (byte) (width >>> 8);
bytes[8] = (byte) (height & 0xFF);
bytes[9] = (byte) (height >>> 8);
return bytes;
}
/** APP0 세그먼트 하나를 건너뛴 뒤 SOF0 이 오는, 흔한 배치. */
private static byte[] jpeg(int width, int height) {
byte[] bytes = new byte[2 + 4 + 12 + 11];
int i = 0;
bytes[i++] = (byte) 0xFF;
bytes[i++] = (byte) 0xD8;
bytes[i++] = (byte) 0xFF;
bytes[i++] = (byte) 0xE0;
bytes[i++] = 0;
bytes[i++] = 14; // 길이 = 자기 자신 2 + 내용 12
i += 12;
bytes[i++] = (byte) 0xFF;
bytes[i++] = (byte) 0xC0;
bytes[i++] = 0;
bytes[i++] = 11;
bytes[i++] = 8; // 정밀도
bytes[i++] = (byte) (height >>> 8);
bytes[i++] = (byte) height;
bytes[i++] = (byte) (width >>> 8);
bytes[i] = (byte) width;
return bytes;
}
@Test
void readsPngDimensionsFromTheHeader() {
assertThat(ImageDimensions.of("image/png", png(1920, 1080)))
.contains(new ImageDimensions.Size(1920, 1080));
}
@Test
void readsGifDimensionsLittleEndian() {
assertThat(ImageDimensions.of("image/gif", gif(640, 480)))
.contains(new ImageDimensions.Size(640, 480));
}
@Test
void readsJpegDimensionsPastAnEarlierSegment() {
// 세그먼트를 건너뛰지 못하면 APP0 내용을 크기로 읽는다 실수가 여기서 걸린다.
assertThat(ImageDimensions.of("image/jpeg", jpeg(800, 600)))
.contains(new ImageDimensions.Size(800, 600));
}
@Test
void answersEmptyForFormatsItDoesNotRead() {
// 모르는 것과 0 다르다. 화면이 둘을 구분하므로 여기서 섞으면 된다.
assertThat(ImageDimensions.of("image/webp", new byte[64])).isEqualTo(Optional.empty());
assertThat(
ImageDimensions.of(
"image/svg+xml", "<svg/>".getBytes(java.nio.charset.StandardCharsets.UTF_8)))
.isEqualTo(Optional.empty());
}
@Test
void answersEmptyForTruncatedOrAbsentContent() {
assertThat(ImageDimensions.of("image/png", new byte[8])).isEqualTo(Optional.empty());
assertThat(ImageDimensions.of("image/png", null)).isEqualTo(Optional.empty());
assertThat(ImageDimensions.of(null, png(10, 10))).isEqualTo(Optional.empty());
}
@Test
void answersEmptyWhenTheHeaderClaimsNoArea() {
assertThat(ImageDimensions.of("image/png", png(0, 480))).isEqualTo(Optional.empty());
}
}
+6
View File
@@ -455,6 +455,12 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
useJUnitPlatform {
excludeTags 'quarantine'
}
// Gradle 512m . JVM Spring context
// (@SpringBootTest ) ArchUnit
// Testcontainers . 512m OOM ,
// "Gradle Test Executor 를 완료할 수 없음"
// .
maxHeapSize = '2g'
}
// feature-ci-quality-gates-contract §4 (D7/D9) flaky quarantine bucket. Runs ONLY
+6 -6
View File
@@ -1,6 +1,6 @@
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006)
6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 55a9599 (feature/public-v1-response-envelope)
8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e public-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 6ef5c1c (master)
ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f studio-management-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ 0ffbc28 (master)
18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd studio-v1.yaml
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ ef49d3a (master)
7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e public-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 0ffbc28 (master)
72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55 studio-management-v1.yaml
+86 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Tech Log Public API
version: 2.0.0
version: 2.1.0
description: |
Tech Log 공개 조회 계약이다. 인증이 필요하지 않다.
@@ -815,6 +815,47 @@ components:
type: integer
contentType:
type: string
BodyAsset:
type: object
description: |
본문이 `:::evidence key="..."` 로 가리키는 Asset 이다.
본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID
로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수
없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을
함께 준다.
목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset
을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다.
required:
- assetKey
- assetId
- url
- contentType
- decorative
properties:
assetKey:
type: string
minLength: 1
maxLength: 200
assetId:
type: string
format: uuid
url:
type: string
contentType:
type: string
altText:
type: string
width:
type: integer
height:
type: integer
decorative:
type: boolean
description: |
장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개
화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다.
RelatedEntry:
type: object
required:
@@ -981,6 +1022,7 @@ components:
enum:
- CASE
- REFERENCE
- QUESTION
- PROJECT_ACTIVITY
- RELEASE
title:
@@ -1198,6 +1240,12 @@ components:
properties:
title:
type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
problemSummary:
type: string
conclusionSummary:
@@ -1224,6 +1272,13 @@ components:
$ref: '#/components/schemas/ProjectSummary'
coverAsset:
$ref: '#/components/schemas/AssetReference'
bodyAssets:
type: array
description: |
본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
Asset 이 더 이상 서빙되지 않는 경우다.
items:
$ref: '#/components/schemas/BodyAsset'
publishedAt: *id003
updatedAt: *id003
lastVerifiedAt: *id003
@@ -1277,6 +1332,12 @@ components:
properties:
title:
type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
scopeSummary:
type: string
appliesTo:
@@ -1287,6 +1348,23 @@ components:
type: array
items:
type: string
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
rules:
type: array
items:
type: object
required: [title, body]
properties:
title:
type: string
body:
type: string
examples:
type: array
items:
type: string
freshnessStatus:
type: string
enum:
@@ -1524,6 +1602,13 @@ components:
type: array
items:
type: string
topics:
type: array
description: |-
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
items:
$ref: '#/components/schemas/TopicSummary'
updatedAt: *id003
featuredDecision:
$ref: '#/components/schemas/RelatedEntry'
+220 -98
View File
@@ -3015,49 +3015,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/PublishResponse'
$ref: '#/components/schemas/PublishResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -3085,49 +3085,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectEditResponse'
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -4973,7 +4973,7 @@ paths:
$ref: '#/components/schemas/ExpectedVersionRequest'
security:
- sessionCookie: []
/api/v1/studio/home-focus:
/api/v1/studio/home/focus:
get:
operationId: getHomeFocus
tags:
@@ -4985,37 +4985,37 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/HomeFocusResponse'
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
security:
- sessionCookie: []
put:
@@ -5030,49 +5030,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/HomeFocusResponse'
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -5101,33 +5101,31 @@ paths:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityListEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
post:
operationId: createProjectActivity
tags:
@@ -5154,49 +5152,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
'400':
description: '400'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: '409'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: '422'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
/api/v1/studio/projects/{id}/activities/{activityId}:
put:
operationId: updateProjectActivity
@@ -5230,49 +5228,126 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
'400':
description: '400'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: '409'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: '422'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
delete:
operationId: deleteProjectActivity
tags:
- Projects
description: |-
프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
적은 줄이 공개 타임라인에 영구히 남았다.
`AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다.
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
- name: activityId
in: path
required: true
schema:
type: string
format: uuid
- $ref: '#/components/parameters/CsrfToken'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExpectedVersionRequest'
responses:
'204':
description: No Content
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
security:
- sessionCookie: []
components:
securitySchemes:
sessionCookie:
@@ -5510,6 +5585,53 @@ components:
$ref: '#/components/schemas/ReleaseEditResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
HomeFocusResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/HomeFocusResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
ProjectActivityResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/ProjectActivityResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
ProjectActivityListEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
type: array
items:
$ref: '#/components/schemas/ProjectActivityResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
PublishResponseEnvelope:
type: object
additionalProperties: false
+46 -13
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Tech Log Studio API
version: 3.0.0
version: 3.1.0
description: |
Tech Log Studio orchestration 계약이다.
@@ -871,7 +871,7 @@ components:
required: [id, text, order]
properties:
id: { type: string, format: uuid }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
ReferenceRule:
type: object
@@ -1242,7 +1242,7 @@ components:
kind: { $ref: "#/components/schemas/RecordKind" }
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
title: { type: string, minLength: 1, maxLength: 120 }
summary: { type: string, minLength: 1, maxLength: 300 }
summary: { type: string, maxLength: 300 }
publicPath: { type: string, minLength: 1, maxLength: 500 }
topic: { $ref: "#/components/schemas/DisplayTarget" }
project:
@@ -1257,7 +1257,7 @@ components:
required: [type, text]
properties:
type: { type: string, enum: [TEXT] }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
InlineContainer:
type: object
required: [type, children]
@@ -1321,7 +1321,10 @@ components:
properties:
type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 }
level: { type: integer, minimum: 2, maximum: 4 }
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
level: { type: integer, minimum: 1, maximum: 6 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ParagraphBlock:
type: object
@@ -1452,6 +1455,29 @@ components:
width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 }
decorative: { type: boolean }
ThematicBreakBlock:
type: object
additionalProperties: false
description: |
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
required: [type]
properties:
type: { type: string, enum: [THEMATIC_BREAK] }
ImageBlock:
type: object
additionalProperties: false
description: |
`![alt](/media/...)` 로 쓴 그림이다.
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
required: [type, src, alt, title]
properties:
type: { type: string, enum: [IMAGE] }
src: { type: string, minLength: 1, maxLength: 500 }
alt: { type: string, maxLength: 300 }
title: { type: [string, "null"], maxLength: 300 }
CaseRenderBlock:
oneOf:
- { $ref: "#/components/schemas/HeadingBlock" }
@@ -1463,6 +1489,8 @@ components:
- { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
- { $ref: "#/components/schemas/ThematicBreakBlock" }
- { $ref: "#/components/schemas/ImageBlock" }
discriminator:
propertyName: type
mapping:
@@ -1475,6 +1503,8 @@ components:
DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
IMAGE: "#/components/schemas/ImageBlock"
CasePublicRenderModel:
unevaluatedProperties: false
allOf:
@@ -1483,8 +1513,8 @@ components:
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
properties:
kind: { type: string, enum: [CASE] }
problem: { type: string, minLength: 1, maxLength: 100000 }
conclusion: { type: string, minLength: 1, maxLength: 100000 }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
reproduction: { type: string, maxLength: 100000 }
lastVerifiedOn: { type: string, format: date }
@@ -1497,7 +1527,7 @@ components:
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, minLength: 1, maxLength: 100000 }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -1508,7 +1538,7 @@ components:
additionalProperties: false
required: [summary, evidenceTarget, linkLabel]
properties:
summary: { type: string, minLength: 1, maxLength: 100000 }
summary: { type: string, maxLength: 100000 }
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
linkLabel: { type: string, minLength: 1, maxLength: 120 }
QuestionPublicRenderModel:
@@ -1528,7 +1558,7 @@ components:
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
nextValidation: { type: string, minLength: 1, maxLength: 100000 }
nextValidation: { type: string, maxLength: 100000 }
resolution:
oneOf:
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
@@ -1542,9 +1572,12 @@ components:
properties:
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
statement: { type: string, minLength: 1, maxLength: 100000 }
rationale: { type: string, minLength: 1, maxLength: 100000 }
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
PublicRenderModel:
oneOf: