Compare commits

..
34 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
DongHyeonka 96521a94d4 feat: serve uploaded media, delete a decision, and stop orphaning publications
Three gaps that all showed up as something broken on screen.

An uploaded image could never be fetched. Every asset address the backend
builds pointed at a path nothing served, so a figure was missing in the Studio
preview and in the published page alike. There is now a public media endpoint
under the prefix that is already anonymous and already routed, so no edge
configuration has to change for an image to appear. Only READY assets are
served; narrowing it to assets a published record uses would break previewing,
which is the one thing an author does before publishing.

Deleting a working copy left wreckage. Publications, validations and previews
address a document by (kind, id) with no foreign key, so deleting one left rows
pointing at nothing — and the dashboard and publication history read a title
off that nothing and crashed. Both screens went down in production this way. A
record with publication history is now refused outright, because that history
says what was once public; validations and previews go with the record, because
they are its scaffolding.

A Decision could not be deleted at all while every other kind could. It can
now, under the same two rules, and refuses when another decision supersedes it
or the home page features it — neither cascades, so deleting would have been a
foreign-key violation reaching the author as a 500.

The persistence tests cover every one of these queries against real PostgreSQL.
That suite exists because a column name I assumed rather than checked reached
production once already; three more assumptions were caught here before it
could happen again.
2026-08-21 17:21:02 +09:00
DongHyeonka af91f7d768 fix: accept a Question working copy with no resolution
Creating a Question was the one document kind that failed: the request carried
`resolution: null`, exactly as the contract describes, and validation rejected
it with "must not be null".

The generated model is where the two disagree. The contract writes the field's
nullability as `oneOf: [$ref, {type: "null"}]`, which this generator does not
read as nullable, so it saw only the `required` list and emitted @NotNull.
`questionStatus` sits in the same required list and worked, because it spells
its nullability `type: [string, "null"]`. The contract now leaves `resolution`
out of the input's required list, and the regenerated getter is @Nullable.

Case, Reference and Decision were never affected — none of them has a nullable
$ref in a required list, which is why only Question broke.
2026-08-21 14:32:08 +09:00
DongHyeonka 37f474ade2 fix: read the public projection by the columns it actually has
Deleting a working copy returned 500. The reference check queried
`public_resource_projection.document_id`, and that column does not exist — the
table addresses records by `(resource_type, resource_id)` because one table
holds cases, questions, projects and releases alike. Five of the six columns in
that query were verified against the migrations; this one was assumed, and it
was the one that was wrong.

It also has no foreign key to `document`, so it was never going to block a
delete the way the check implied. What it can do is outlive the record: the
projection is derived data with nothing to cascade it away, and a row left
behind points the public site at something that is gone. So publication is now
checked directly on the projection as well as on `workflow_status` — the two
live in different tables and can disagree — and a withdrawn projection is
removed with the record, which cascades its public routes.

The real failure was that this SQL had never run. The neighbouring integration
test says so in its own header: the standard `check` does not start
Testcontainers, so persistence SQL passes the build without ever being
executed, and neither compilation nor a unit test catches a column name. The
delete path was simply outside it. It has its own task now, and eight scenarios
that run against real PostgreSQL — including the exact query that failed.
2026-08-21 14:17:46 +09:00
DongHyeonka 1befdc37a4 feat: let an author delete a working copy
An author who opens a draft and thinks better of it had no way out — the
working-copy list could create, edit, validate and publish, and that was all.
The three delete operations existed in the contract with no implementation.

Deleting is not a cascade, which is the part worth being careful about. A
document's own rows follow it: the detail row, its tags, the relations it
points outward. But five tables reference `document` without ON DELETE CASCADE
— another document's relation target, a question's link, a project's
membership, a topic's featured list, a decision's source case — and two do the
same for `open_question`. Deleting through any of them is a foreign-key
violation, which reaches the author as a 500 that explains nothing. So the
delete checks first and refuses with DOCUMENT_IN_USE, the same refusal
TOPIC_IN_USE already makes. Quietly editing someone else's record to make room
is the worse option.

A published record is refused outright. Public pages, search and other records
link to it, and one that vanishes leaves all of them pointing at nothing —
unpublishing is the way out, and it already exists.

Case and Reference share one table split by `document_type`, so the type is
part of the lookup: without it, the Case route would happily delete a
Reference. Decisions have no delete at all, and that is the contract's
judgment rather than an omission — accept, reject and supersede record what
happened instead of erasing it.
2026-08-21 13:30:37 +09:00
DongHyeonka 386f360122 feat: implement release authoring, so the changelog can be written
The public site has a Releases page and a footer link to the latest release,
and both were empty — the read side has existed since the public surface
landed, but nothing could ever create a row. The seven release operations were
in the contract with no implementation, so the changelog was a page that could
only ever be blank.

The release model is not a blob of prose. It splits into six markdown sections
because a release note answers fixed questions — why, what, what changes for a
reader, what it leaves in the code, how it was verified, what is still missing
— and a single text column cannot say which of those went unanswered.

Publishing is the only thing that makes a release public: the public query
filters on `workflow_status = 'PUBLISHED'` and nothing else. So publish is
where the contract's required fields are actually enforced. Saving stays
permissive — a draft you cannot save until it is complete is a draft you cannot
write — and the two demands are deliberately different.

`version_label` is NOT NULL UNIQUE but a draft has no version yet, so creation
writes a placeholder derived from the row id and publication refuses to ship
one. Relaxing the column instead would open a window where a published release
is publicly visible with no version at all.

A published release cannot be deleted, only archived: a public changelog entry
that vanishes leaves everyone who linked it with no way to learn what happened.

Also registers `adapter-outbound-objectstorage` as an app-bootstrap runtime
member. It was added as a dependency when asset upload was fixed but never
registered, and `verifyRuntimeModuleMembership` had not been run since.
2026-08-21 02:47:15 +09:00
DongHyeonka 48517b9e65 fix: cast the nullable uuid so Postgres can type the existence check
createTopic answered 500: 'could not determine data type of parameter $2'.
The uniqueness check reads

  WHERE normalized_name = :name AND (:except IS NULL OR id <> :except)

and on a create there is no id to exclude, so :except is null. Postgres infers
a parameter's type from how it is used, and `IS NULL` tells it nothing — with
the other use behind an OR it never gets a second chance.

Casting both uses to uuid gives it the type without changing the predicate.

Project creation passed the same code path only because it never reaches
slugTaken: a draft opens with no slug, so the check is skipped. The topic path
runs it on every create.
2026-08-21 00:06:40 +09:00
DongHyeonka 0da7c7e2db fix: use the Jackson 3 mapper the persistence module actually has
JdbcProjectRepositoryAdapter asked for com.fasterxml.jackson.databind.ObjectMapper
— Jackson 2. This build is on Jackson 3 (tools.jackson.databind), so no bean
of that type exists and the context failed to refresh: the pod crash-looped
with 'Parameter 1 ... required a bean of type ObjectMapper that could not be
found'.

Compilation could not catch it. The Jackson 2 types are still on the classpath
through some transitive dependency, so the wrong import resolves and only the
container tells you. PublicJson in the sibling package was already on Jackson 3
and is the shape this now follows.

readTree/asString rather than readValue with a TypeReference: Jackson 3 does
not throw a checked exception here, so the surrounding try/catch narrows to
RuntimeException and the method keeps its contract of degrading to an empty
list rather than failing the whole edit screen.
2026-08-20 23:54:18 +09:00
DongHyeonka bb6d2330bb feat: implement topic and project management, so documents can be authored
Publishing was impossible on an empty database. Validation requires a topic,
the studio catalog answered zero topics, and nothing in the two implemented
contracts could create one — `studio-management-v1.yaml` owned that surface
and none of its 79 operations existed. Every path to a published record ran
through a door with no handle.

This implements the nine that unblock authoring: topics (list/create/update/
delete) and projects (list/get/create/update/delete). The remaining seventy
stay unimplemented; each has its own consumer and its own moment.

The contract was converted to the response envelope first (ADR-006), which is
what its own header said to do at implementation time. Doing it after would
have meant changing the wire shape of endpoints the frontend had already been
written against.

ManagementError is a separate enum rather than an extension of StudioError.
Each contract enumerates its own ApiError.code set, so a code reachable from
the wrong surface makes that contract false. It deliberately omits
INTERNAL_ERROR: the skeleton's OperationalError owns that code with
retryable=true, and declaring it twice with different values leaves the
registry with no answer. PublicError made the same call for the same reason.

Two contract defects surfaced while implementing. TopicEdit had neither id nor
version, so a listed topic could not be addressed by the `/topics/{id}` path
and a client had no source for the expectedVersion the write operations
require; both are fixed in the design package. The AWS SDK BOM had to be
imported in app-bootstrap as well — module-scoped dependency management does
not propagate to consumers, and this is the first runtime consumer of that
pattern.

Topic and project deletion refuse while records still reference them rather
than cascading. A topic disappearing should not silently reclassify the
documents that used it; moving them first is the caller's decision to make.

ActuatorSecurityHttpTest.healthEndpointIsPermitAll fails on this branch before
this change as well; it is untouched here.
2026-08-20 22:54:24 +09:00
DongHyeonka bde5826cfd fix: ship the object storage adapter, so Studio asset uploads have a backend
`ObjectStorageAssetBinaryAdapter` bridges Studio's asset port to the object
storage port through an `ObjectProvider`, which means it compiles whether or
not an implementation is on the classpath. None was: app-bootstrap never
depended on `:adapter:outbound:objectstorage`, so the provider was always
empty and every upload and delete answered STUDIO_UNAVAILABLE with the message
"set ca-skeleton.objectstorage.* to enable" — configuration advice for a
missing dependency, which sends the reader looking in the wrong place.

The AWS SDK BOM has to be imported here as well. The objectstorage module
imports it at module scope on purpose (its comment explains: keep the strict
locking blast radius contained), and Spring's dependency management does not
propagate to consumers, so assembling the runtime here left s3 and
netty-nio-client without versions. The grpc module has the same shape and did
not surface it because app-bootstrap only consumes grpc from a test
configuration; this is the first runtime consumer of that pattern.

Lock state regenerated for the SDK's transitive set.

ActuatorSecurityHttpTest.healthEndpointIsPermitAll fails on this branch before
this change as well; it is untouched here.
2026-08-20 19:10:57 +09:00
DongHyeonkaandClaude Opus 5 55a71fb33d merge: develop — Tech Log 백엔드 계약 2종 완성 (studio-v1 19/19, public-v1 18/18)
main이 마지막으로 본 것은 스켈레톤 초기화(697fc74)까지였다. 그 뒤 develop에 쌓인
Tech Log 백엔드 전체를 가져온다.

- Studio 백엔드 기반(Plan 01)과 남은 17개 operation → studio-v1 19/19
- release-gate 수정 4건(authz 배선, BFF 로그인 경로, 계약 nullable/오류 코드, 체크리스트)
- 공개 조회 백엔드 → public-v1 18/18

각 판단의 근거는 해당 커밋 메시지에 있다.

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

알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다.
redis가 호스트 포트에 노출되지 않아 헬스가 DOWN인 환경 문제이며 코드와 무관하다
(기저 커밋에서도 동일하게 재현됨).

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:35:04 +09:00
DongHyeonkaandClaude Opus 5 0854d42c67 merge: feature/techlog-public-v1 — Tech Log 공개 조회 백엔드 (public-v1 18/18)
public-v1.yaml의 18개 operation 전부와, 그 과정에서 드러난 결함들의 수정을 통합한다.
studio-v1(19/19)에 이어 public-v1도 18/18이 되어 프론트엔드가 소비할 두 계약이 모두
서버에 존재한다.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:34:49 +09:00
DongHyeonkaandClaude Opus 5 365560efb6 feat: Tech Log 공개 조회 백엔드 — public-v1 18개 operation 구현
public-v1.yaml의 18개 operation 전부를 구현한다. 사이트·홈·프로필, 탐색 2종, 주제
2종, 문서 상세 3종, 프로젝트 5종, 릴리스 2종, 검색. studio-v1(19/19)에 이어
public-v1도 18/18이다.

생성기가 계약 필드를 조용히 빠뜨리고 있었다 — 근본 원인은 파생 단계의 YAML alias
swagger-parser가 이 문서의 스키마 15개를 "is not of type `object`"로 거절했다.
거절당한 스키마들은 전부 type: object를 명시하고 있어서 계약 결함처럼 보이지 않았고,
validateSpec을 끄면 생성은 성공했다. 그런데 그렇게 만든 모델에서 LatestEntry.publishedAt,
ProjectListItem.updatedAt, SearchResultItem.matchedFields, ReleaseListItem.changeTypes가
사라져 있었다. 컴파일은 통과한다 — 아직 아무도 그 필드를 안 쓰니까.

원인은 prepare 단계였다. 변환들이 같은 Map 인스턴스를 여러 property에 재사용했고
snakeyaml이 그 지점을 anchor/alias(&id001 / *id001)로 덤프했다. swagger-parser는
alias 노드를 해석하지 못해 그 스키마 전체를 거절하고, generator는 검증을 끄면 문서를
받아들이되 alias였던 property를 말없이 버린다. 파생 스펙에 alias가 34곳 있었다.

- 덤프 직전 deep copy로 노드 identity를 끊어 alias를 원천 차단하고, 남으면 빌드가
  실패하도록 fail-closed 게이트를 뒀다. validateSpec은 다시 켰다
- verifyPublicGeneratedModels를 schema 이름 대조에서 property 대조로 강화했다.
  이번 누락을 이 게이트가 통과시켰기 때문이다. 지금은 schema 62개 · property 250개를 센다

계약이 선언했는데 서버가 무시하던 필터를 채웠다
지정해도 오류가 아니라 "결과 0건"으로 보여서 소비자가 자기 요청이 틀렸다는 걸 알 수 없었다.
- exploreQuestions: tag 필터 없음, sort 3값이 SQL에 반영되지 않음
- listPublicProjectDecisions: status 필터 없음
- listPublicProjectRecords: type/relation 필터 없음, QUESTION이 대상에서 빠져 있었음
- 필터는 목록과 총계 두 쿼리에 같이 걸린다. 갈라지면 마지막 페이지가 비어 보인다
- enum 파라미터는 요청 경계에서 검사해 PUBLIC_REQUEST_INVALID로 거절한다

응답 봉투와 오류 경계
- 컨트롤러는 봉투를 반환하지 않는다. EnvelopeBodyAdvice가 감싼다(ADR-006)
- PublicExceptionHandler를 publicapi 스코프로 두고, StudioExceptionHandler의 스코프를
  ...web.techlog → ...web.techlog.studio로 좁혔다. 좁히지 않으면 공개 조회의 파라미터
  오류가 Studio 계약 코드(REQUEST_VALIDATION_FAILED, 422)로 나가는데, 그 코드는
  public-v1의 ApiError.code enum에 없어 프론트엔드의 응답 파싱 자체가 깨진다
- FieldError 모양이 studio({path,message})와 public({field,code,message})이 다르다

실행이 잡아낸 결함
컴파일과 단위 테스트로는 드러나지 않았고 실제 PostgreSQL과 실제 기동이 잡았다.
- profile()의 selectedEvidence가 List.of() 하드코딩이었다. 계약 필드가 항상 비어 있었다
- latestEntries/latestRecords가 projection의 모든 resource_type을 흘렸다. 계약의
  LatestEntry.entryType은 4값뿐이라 QUESTION이 섞이면 매퍼가 500을 낸다
- home_focus_config.default_focus_type은 마이그레이션 직후 NULL인데 계약은 이 필드를
  required + enum 3값으로 선언한다. 배포 직후 첫 요청부터 /home이 깨졌다.
  HomeFocusView.resolve가 반드시 유효한 값 하나를 정하도록 고쳤다

V9__techlog_public_surface.sql
설계 패키지 database/V1__init.sql이 정의한 공개 표면 6종(release, site_config,
profile_page, home_focus_config, project_topic, topic_featured_document)과 단일 행
시딩. 릴리스는 Publication 파이프라인을 거치지 않고 자체 workflow_status로 공개된다.

게이트
- PublicContractDriftTest: springdoc이 게시하는 표면과 계약을 양방향 대조한다.
  계약의 servers(/api/v1/public)를 경로에 더해 비교하며, operation 수 18을 함께 고정해
  "비교 대상이 0건이라 통과"를 실패로 만든다. 봉투 래핑도 확인한다
- PublicErrorRegistryTest: PublicError ↔ error-codes.yaml ↔ 계약 enum 3자 대조.
  INTERNAL_ERROR는 스켈레톤 소유라 재선언하지 않으므로 "계약 = public 소유 ∪ 그 하나"로
  고정한다. vendored 계약의 MANIFEST 해시도 확인한다
- postgresqlTechLogPublicPersistenceIntegrationTest: 어댑터 7종과 V9를 실제
  PostgreSQL에서 돌린다. 표준 check는 Testcontainers를 돌리지 않으므로 이 태스크가
  없으면 이 SQL은 한 번도 실행되지 않은 채 빌드가 통과한다. 게시 취소·비공개 자료를
  함께 심어 어느 경로로도 새지 않는지 확인한다

검증
./gradlew check BUILD SUCCESSFUL (248 task). 공개 조회 통합 테스트 24/24.
실제 PostgreSQL로 앱을 띄워 18개 operation 전부 실호출 — 5xx 0건, 파라미터 검증 5종
전부 계약 코드. 한때 사라졌던 publishedAt/matchedFields/changeTypes가 실응답에 있다.

알려진 선재 실패: ActuatorSecurityHttpTest가 /actuator/health 503으로 실패한다.
기저 커밋 743fee3에서도 동일하게 재현되며, 원인은 redis가 호스트 포트에 노출되지 않아
헬스가 DOWN인 환경 문제다. 이 커밋과 무관하다.

AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을
전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:34:18 +09:00
DongHyeonkaandClaude Opus 5 e3254def57 test: 미추적으로 남아 있던 Studio authz 배선 테스트 2개를 추적에 넣는다
37d5614("fix: make Studio authorization actually work, and stop it failing as a
500")가 고친 배선을 지키는 테스트인데 커밋에 들어가지 않아 작업 트리에만 있었다.
추적되지 않으면 브랜치를 옮길 때 조용히 사라지고, 다른 사람이 같은 저장소를 받아도
그 회귀 게이트를 갖지 못한다.

- StudioAuthzWiringTest: MethodSecurityConfig의 advisor가 AuthorizationPort를
  생성자로 받는 인프라 빈이라, auto-proxy보다 먼저 만들어지며 AuthorizationAdapter →
  RolePermissionRegistry → RolePermissionPolicy를 BeanPostProcessor 등록 전에 끌어
  올린다는 사실을 고정한다. 바인딩만 따로 보면 통과하지만 앱에서는 죽는 경우다
- StudioAuthzEnvironmentPostProcessorTest: 같은 수정의 환경 설정 쪽

내용은 손대지 않았다. spotless가 요구한 줄바꿈 두 곳만 정규화됐다(의미 변경 없음).

AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을
전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:33:32 +09:00
DongHyeonkaandClaude Opus 5 743fee3907 fix: close the last three local checklist items
Duplicate relations. Connecting the same target twice saved without a
word: the contract carries no uniqueItems on relations (only maxItems 20)
and the validator checked order uniqueness but not target. The document
then renders the same row twice publicly, and removing one leaves the
other behind — "삭제했는데 그대로". Rejected now, alongside the existing
order check.

  two distinct targets  201
  same target twice     422 REQUEST_VALIDATION_FAILED

The prod DDL guard ran too late. JpaSchemaSafetyValidator was a
SmartInitializingSingleton, which fires after every singleton exists —
including entityManagerFactory, which Hibernate builds by applying
ddl-auto. Booting prod with ddl-auto=update logged "Initialized JPA
EntityManagerFactory" first and the PROFILE_MISMATCH second, with the
tables Hibernate created in between still in the schema. The guard stopped
traffic but not schema mutation, so a misconfigured deploy had already
changed the production database by the time it refused to start. It is a
BeanFactoryPostProcessor now, before any bean is instantiated.

  fs_* tables dropped, prod booted with ddl-auto=update
  exit 71, no EntityManagerFactory line, 0 tables created

Object storage inside a database transaction. UploadStudioAssetUseCase
called binaries.store from inside inWrite, holding a connection and its
locks for the length of a network round-trip — a slow storage backend
becomes connection-pool exhaustion. It bought nothing: storage does not
join the transaction, so a failed commit leaves the bytes written either
way. Storage now happens first and the database write is a short
transaction; a failed write deletes the object it just uploaded, and a
failed delete is attached with addSuppressed rather than replacing the
error the caller needs to see.

Full build passes apart from one fileserver flake
(LocalPersistentControlPlaneTest.heldOperationReentrancyIsScopedToThe
AttestedRoot) that passes in isolation and touches none of these files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 10:36:47 +09:00
DongHyeonkaandClaude Opus 5 c8a891c407 fix: honour the contract's nullable fields and its error codes
Two contract mismatches, both found by driving the API and both invisible
from inside the repository because nothing compares the wire to the spec.

Nullable-but-required. The contract says required means "the key is
present", not "the value is set" — WorkingCopyInputBase spells it out:
"불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을
허용한다". The generator moves `required` straight to @NotNull, so
topicId, projectId, lastVerifiedOn, verifiedOn, decidedOn, decisionStatus
and questionStatus all became non-null, and saving a partial draft failed:

  {"projectId": null} → 400 NOT_NULL "Required value is missing"

prepareStudioCodegenSpec already derives a codegen-only copy of the spec,
so the relaxation happens there — 33 properties leave `required` in that
copy and the canonical file is untouched, which matters because the
frontend reads the same file and its reading is the correct one. Value
constraints stay: title still carries @NotNull @Size(max = 120).

  all-null / omitted / empty slug   201
  title 121 chars                   422
  slug "Bad Slug!"                  422

Error codes. Body validation fell through to the template's handler and
answered 400 VALIDATION_FAILED, a code the Studio contract does not
declare (it knows REQUEST_VALIDATION_FAILED and DOCUMENT_VALIDATION_
FAILED); denials answered AUTHZ_INSUFFICIENT_PERMISSION where the
contract assigns STUDIO_ACCESS_DENIED to 403. The frontend validates the
envelope's code against an enum, so an undeclared code breaks parsing
rather than surfacing as the error it is. Both now map in
StudioExceptionHandler, which is already scoped to the techlog package so
fileserver and healthcheck keep their existing shapes.

  body validation  422 REQUEST_VALIDATION_FAILED
  denial           403 STUDIO_ACCESS_DENIED

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:49:05 +09:00
DongHyeonkaandClaude Opus 5 a828b5d9fe feat: give redis-session mode a way to authenticate — the BFF login path
auth-mode=redis-session was unreachable: getStudioSession answered 503 on
every call because the CSRF token is null when CsrfFilter never runs, and
CsrfFilter only runs in the session branch, which could not be selected
because AuthenticationModeCompositionConfig requires a
`redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair
and only the second existed. Even with the pair present nothing could
create a session — there was no login endpoint and no client registration.

This is the surface the contract already describes: securitySchemes
declares a session cookie plus X-CSRF-TOKEN on mutations, not a bearer
token, and SecurityConfig's session branch (cookie CSRF repository,
session-fixation migration) plus RedisSessionWebConfig (servlet filter,
host-only cookie) were already written for it. The SPA never holds a
token; the backend owns the session.

  - StudioSessionInfrastructureConfig supplies the missing repository
    under the name the composition validator looks for. @EnableRedisHttp
    Session is not used because it pins the bean name to sessionRepository.
  - StudioOidcLoginSuccessHandler converts the OidcUser into an
    AuthenticatedPrincipal. PrimitiveSessionSecurityContextRepository
    rejects anything else on save — deliberately, so credentials and
    framework object graphs never cross the session boundary — and it
    restores the same type on load. Roles are unioned from realm_access
    and resource_access exactly as the JWT converter does, so both modes
    resolve the same set and the studio:read / studio:write mapping
    behaves identically.
  - SecurityConfig wires oauth2Login (only when a success handler bean is
    present, so JWT mode is untouched) and a /logout that invalidates the
    session. The envelope 401 stays the entry point: an unauthenticated
    API call must not answer 302, which an XHR cannot follow. The SPA
    navigates the browser to /oauth2/authorization/{id} instead.

Verified in a browser against a real Keycloak realm:

  /oauth2/authorization/keycloak → Keycloak → callback
  TECHLOG_SESSION cookie set, httpOnly
  GET  /api/v1/studio/session   200 {authenticated, displayName, roles,
                                     csrfToken, csrfHeaderName}
  POST /api/v1/studio/documents 403 without the CSRF header
                                201 with it
  GET  /api/v1/studio/documents 200

Also removes the same broken placeholder-in-map-key role mapping from the
dev profile that the previous commit fixed in local and prod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:19:28 +09:00
DongHyeonkaandClaude Opus 5 37d5614129 fix: make Studio authorization actually work, and stop it failing as a 500
Four linked defects, found by driving the 19 operations against a running
backend on PostgreSQL behind a real Keycloak realm.

1. The role→permission mapping never bound. Both profiles wrote it as

     role-permissions:
       ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
         - studio:write

   and Spring Boot resolves placeholders in @ConfigurationProperties
   *values*, not in Map *keys* — the key bound as the literal
   "${APP_STUDIO_AUTHOR_ROLE:studio-author}", matched no real role, and
   left RolePermissionRegistry empty. Every Studio write answered 403, in
   local and prod alike. Setting APP_STUDIO_AUTHOR_ROLE explicitly did not
   help; a literal key returned 201 immediately. StudioAuthzEnvironmentPost
   Processor now resolves the role name as a scalar (where placeholders do
   work) and contributes the mapping under a literal key, so the name stays
   deployment-configurable. Registered the same way the tracing bridge is.

2. Reads were unguarded. Only WRITE carried @RequiresPermission, so any
   authenticated caller could list every draft and fetch one by id:

     listStudioDocuments  200, 2 drafts     getStudioDocument  200
     getStudioDashboard   200               listStudioAssets   200

   The nine read use cases now declare studio:read. They lose `final` for
   the same CGLIB reason the write ones already document.

3. Failures were masked. IdempotencyExecutor's catch called store.discard,
   whose @Modifying bulk delete needs a transaction and had none, so it
   threw TransactionRequiredException over the original exception — the 403
   above surfaced as 500 INTERNAL_ERROR with no cause in the log, which is
   why this shipped. discard now runs REQUIRES_NEW (cleanup must survive
   the failed work's rollback) and a cleanup failure is attached with
   addSuppressed instead of replacing what actually went wrong.

4. Reservations leaked. With discard throwing every time, failed requests
   left their idempotency rows behind. After the fix only the successful
   call's COMPLETED row remains.

Verified end to end: studio-author writes with no extra configuration;
an unprivileged caller gets 403 on all five read operations and on write;
create → save → validate → preview → publish → unpublish all succeed;
optimistic lock returns 409 VERSION_CONFLICT; the publication reaches
public_resource_projection and flips to WITHDRAWN on unpublish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:14:18 +09:00
DongHyeonkaandClaude Opus 5 d5889d644a merge: feature/techlog-studio-slices-2-5 — Tech Log Studio 백엔드 17개 operation (슬라이스 2~5)
studio-v1.yaml 19개 operation을 19/19로 완성한다. Plan 01이 남긴 17개(문서 CRUD,
검증·미리보기, 게시, Asset)와 Plan 02로 미뤘던 생성기 union 차단 요인 해소.

fix/release-gate-backend의 e615c24(Studio를 계약 경로로 서빙)도 함께 들어온다 —
이 브랜치가 거기서 갈라져 나왔고, 컨트롤러가 /api 접두사를 중복하지 않는다는
그 수정이 이번 구현의 전제다.

범위는 spec §2.1의 studio-v1 19개다. public-v1(18)/studio-management-v1(79)은
spec §2.2가 선언한 out of scope로 남는다.

AGENTS.md의 commit 정책은 human-only다. 사용자가 명시적으로 지시해 예외로 수행한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:22:07 +09:00
DongHyeonkaandClaude Opus 5 48ff648112 feat: Tech Log Studio 백엔드 — 남은 17개 operation 구현 (슬라이스 2~5)
studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD,
검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:21:43 +09:00
DongHyeonkaandClaude Opus 5 e615c24152 fix: serve Studio at the contract path instead of /api/api/v1/...
PresentationWebConfig prefixes every controller mapping with
ca-skeleton.presentation.api-base-path ("/api"), which is why every other
controller in this repository declares its path without it — healthcheck
is "/healthcheck", uploads are "/v1/uploads", files are "/v1/files". The
two Studio controllers declared "/api/v1/studio/..." instead, so the
prefix landed on top of a path that already had it and both operations
were served at /api/api/v1/studio/... — nowhere near the address
studio-v1.yaml declares (servers: "/", paths: /api/v1/studio/...). The
frontend calls the contract path, so nothing connected.

Verified against a running backend on PostgreSQL behind a real Keycloak:

  /api/v1/studio/catalog?type=TOPIC      200, 3 items   (was 404)
  /api/api/v1/studio/catalog?type=TOPIC  404            (was 200)

Why the tests were green while production was broken: the three slice
tests and both nested apps in StudioContractDriftTest build contexts that
never include PresentationWebConfig, so no prefix was applied and the
controllers' literal "/api/v1/..." matched. They now import it and supply
the same "/api" the real app uses, which makes the paths they exercise the
effective ones. Re-introducing the bug fails five of them.

StudioContractDriftTest needed two more repairs to stay meaningful:
springdoc's own endpoint is prefixed too, so the published document is
read from /api/v3/api-docs; and publishedStudioOperationsMatchTheContract
skips any path not starting with /api/v1/studio/, so a missing prefix
would have made it compare nothing and pass. It now asserts it compared at
least one path — a gate that cannot see drift is not the same as no drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:26:34 +09:00
393 changed files with 36564 additions and 199 deletions
+79
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)
@@ -4249,3 +4265,66 @@ env_keys:
validation: positive_int_bounded
compatibility_impact: behavior-change
required_test: async-contract:executor-queue-bounded
# === Tech Log Studio (feature-techlog-studio-backend) ===
- name: APP_STUDIO_CURSOR_SIGNING_KEY
# source: studio-v1.yaml components.parameters.Cursor — "Opaque cursor bound to
# normalized filters and sort". 서명 키가 인스턴스마다 다르면 한 인스턴스가 발급한
# 커서를 다른 인스턴스가 거부한다. 비어 있으면 StudioSettings가 경고하고 개발용 값으로
# 대체한다(커서에 권한이 실리지 않으므로 부팅은 막지 않는다).
type: string
default: null
allowed_values: null
classification: secret
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: min_length_16_bytes
compatibility_impact: behavior-change
required_test: techlog-studio-contract:cursor-round-trip
- name: APP_STUDIO_VALIDATION_TTL
# source: 백엔드 설계 §7.3 — "now() < validUntil" 이 Validation 유효 조건의 하나다.
# studio_validation.valid_until 을 채우는 값.
type: duration
default: 1h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: techlog-studio-contract:validation-window
- name: APP_STUDIO_PREVIEW_TTL
# source: 백엔드 설계 §7.3 — Preview 가 EXPIRED 로 넘어가는 기준.
# studio_preview.expires_at 을 채우는 값.
type: duration
default: 24h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: techlog-studio-contract:preview-expiry
- name: APP_STUDIO_AUTHOR_ROLE
# source: 백엔드 설계 §9 — 권한은 기존 RolePermissionPolicy 를 재사용한다.
# ca-skeleton.authz.role-permissions 의 키로 쓰이는 IdP RAW role 이름. 배포마다 다르다.
# application.yml 이 아니라 프로파일(application-{local,dev,prod}.yml)에 있다 —
# SampleRemovalSmokeContractTest 가 application.yml 의 `role-permissions: {}` 기준선을
# 그대로 유지하도록 요구하기 때문이다.
type: string
default: studio-author
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-techlog-studio-backend
validation: non_blank
compatibility_impact: behavior-change
required_test: techlog-studio-contract:author-permission
+259 -1
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
@@ -1228,3 +1233,256 @@ errors:
runbook_link: "runbook://studio/unavailable"
compatibility_impact: additive
required_test: StudioErrorTest
# === Tech Log Public (feature-techlog-public-v1) ===
#
# public-v1.yaml 의 ApiError.code 는 세 값이다. 나머지 하나 INTERNAL_ERROR 는 스켈레톤
# 공통 코드로 이미 이 레지스트리에 있으므로 여기서 다시 선언하지 않는다.
#
# Studio 와 이름을 겹치지 않게 한 이유: 이 레지스트리는 코드 하나에 http_status 하나만
# 담는다. public 의 400 과 studio 의 422 를 같은 이름으로 쓸 수 없다.
# source: public-v1.yaml ApiError.code — PUBLIC_REQUEST_INVALID (PublicError.PUBLIC_REQUEST_INVALID)
- code: PUBLIC_REQUEST_INVALID
category: VALIDATION
http_status: 400
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-public-v1
owner_layer: application
client_safe_message: "요청 값이 올바르지 않습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: PublicErrorRegistryTest
# source: public-v1.yaml ApiError.code — PUBLIC_RESOURCE_NOT_FOUND (PublicError.PUBLIC_RESOURCE_NOT_FOUND)
- code: PUBLIC_RESOURCE_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-public-v1
owner_layer: application
client_safe_message: "요청한 자료를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: PublicErrorRegistryTest
# ---------------------------------------------------------------------------
# TECH LOG STUDIO MANAGEMENT (studio-management-v1.yaml ApiError.code)
#
# ManagementError(dev.caskeleton.application.techlog.error.ManagementError)와 1:1.
# AUTHENTICATION_REQUIRED / STUDIO_ACCESS_DENIED / REQUEST_VALIDATION_FAILED /
# VERSION_CONFLICT 는 StudioError 에도 있어 행이 이미 존재한다 — 이 파일의 identity
# column 은 `code` 이므로 중복 행을 만들지 않는다.
# ---------------------------------------------------------------------------
# source: studio-management-v1.yaml ApiError.code — TOPIC_NOT_FOUND (ManagementError.TOPIC_NOT_FOUND)
- code: TOPIC_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "주제를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — TOPIC_NAME_TAKEN (ManagementError.TOPIC_NAME_TAKEN)
- code: TOPIC_NAME_TAKEN
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "같은 이름의 주제가 이미 있습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — TOPIC_SLUG_TAKEN (ManagementError.TOPIC_SLUG_TAKEN)
- code: TOPIC_SLUG_TAKEN
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "같은 slug 의 주제가 이미 있습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — TOPIC_IN_USE (ManagementError.TOPIC_IN_USE)
- code: TOPIC_IN_USE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "이 주제를 쓰는 기록이 있어 삭제할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — PROJECT_NOT_FOUND (ManagementError.PROJECT_NOT_FOUND)
- code: PROJECT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "프로젝트를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — PROJECT_SLUG_TAKEN (ManagementError.PROJECT_SLUG_TAKEN)
- code: PROJECT_SLUG_TAKEN
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "같은 slug 의 프로젝트가 이미 있습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — PROJECT_IN_USE (ManagementError.PROJECT_IN_USE)
- code: PROJECT_IN_USE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — RELEASE_NOT_FOUND (ManagementError.RELEASE_NOT_FOUND)
- code: RELEASE_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "요청한 릴리즈를 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — RELEASE_VERSION_TAKEN (ManagementError.RELEASE_VERSION_TAKEN)
- code: RELEASE_VERSION_TAKEN
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "같은 버전의 릴리즈가 이미 있습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — RELEASE_NOT_PUBLISHABLE (ManagementError.RELEASE_NOT_PUBLISHABLE)
- code: RELEASE_NOT_PUBLISHABLE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "지금 상태에서는 발행할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED)
- code: DOCUMENT_PUBLISHED
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — QUESTION_NOT_FOUND (ManagementError.QUESTION_NOT_FOUND)
- code: QUESTION_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "질문을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_IN_USE (ManagementError.DOCUMENT_IN_USE)
- code: DOCUMENT_IN_USE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — QUESTION_IN_USE (ManagementError.QUESTION_IN_USE)
- code: QUESTION_IN_USE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "이 질문을 참조하는 곳이 있어 삭제할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — DECISION_NOT_FOUND (ManagementError.DECISION_NOT_FOUND)
- code: DECISION_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "결정을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — DECISION_IN_USE (ManagementError.DECISION_IN_USE)
- code: DECISION_IN_USE
category: CONFLICT
http_status: 409
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "이 결정을 참조하는 곳이 있어 삭제할 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
+1
View File
@@ -2,3 +2,4 @@
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
/api/healthcheck
/api/v1/public/**
+1 -1
View File
@@ -115,7 +115,7 @@ PRESENTATION_API_BASE_PATH=/api
APP_SECURITY_AUTH_MODE=jwt
APP_SECURITY_JWT_ISSUER=http://localhost:8081/realms/ca-skeleton
APP_SECURITY_JWT_AUDIENCE=ca-skeleton-api
SECURITY_PUBLIC_PATHS=/api/healthcheck
SECURITY_PUBLIC_PATHS=/api/healthcheck, /api/v1/public/**
APP_SESSION_COOKIE_NAME=CA_SESSION
APP_SESSION_COOKIE_SECURE=true
APP_SESSION_COOKIE_HTTP_ONLY=true
+588 -3
View File
@@ -22,6 +22,21 @@ plugins { id 'org.openapi.generator' }
sourceSets {
generatedOpenapi {
java.srcDir(layout.buildDirectory.dir('generated/openapi/src/main/java'))
// 계약의 discriminator union을 Java interface로 파생한 소스(prepareStudioCodegenSpec).
// 생성 DTO와 같은 sourceSet이어야 한다 — 생성된 하위 타입이 `implements <Union>` 하므로
// 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다.
// main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java'))
// public-v1 도 같은 방식으로 model 만 생성한다. 별도 sourceSet 을 만들지 않는 이유는
// 두 계약의 생성물이 같은 성질(생성 코드, 품질 게이트 제외 대상, jar/test 클래스패스에
// 얹어야 함)을 갖기 때문이다 — sourceSet 을 늘리면 그 배선을 한 벌 더 복제하게 된다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-public/src/main/java'))
java.srcDir(layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java'))
// studio-management-v1 도 같은 방식이다. 세 번째 계약이라 이 목록이 길어지는데,
// 계약마다 model 패키지를 분리하는 편이 이름 충돌보다 낫다 — 세 계약 모두
// TopicSummary 처럼 같은 이름의 서로 다른 스키마를 갖는다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-management/src/main/java'))
java.srcDir(layout.buildDirectory.dir('generated/openapi-management-unions/src/main/java'))
}
// main이 생성 DTO를 참조할 수 있어야 한다(Task 8/9 controller). implementation
// Configuration으로 연결하면(즉 main의 implementation에 generatedOpenapi.output을
@@ -64,6 +79,15 @@ dependencies {
// never a hand-maintained stale schema). The release-blocking drift gate is
// owned by feature-contract-verification-test-suite (planned).
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
// Studio Preview / Public Snapshot 의 CaseRenderBlock 을 만드는 Markdown 파서.
// 설계 05장 §16 이 "특정 라이브러리를 도메인 계약으로 만들지 않는다"고 정하므로 이 의존은
// techlog/studio/render 패키지 안에서만 쓰고 바깥에는 계약 DTO 만 내보낸다.
// 프론트가 remark 계열로 같은 문법을 다루므로(ADR-005 동등성) CommonMark + GFM 확장이라는
// 같은 기준을 쓴다 — 자체 파서를 쓰면 두 화면의 해석이 갈라진다.
implementation 'org.commonmark:commonmark:0.21.0'
implementation 'org.commonmark:commonmark-ext-gfm-tables:0.21.0'
implementation 'org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0'
implementation 'org.commonmark:commonmark-ext-autolink:0.21.0'
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
@@ -121,6 +145,559 @@ tasks.named('check') {
dependsOn tasks.named('webSecurityBoundaryTest')
}
// ---------------------------------------------------------------------------
// 계약 union -> 생성 코드 배선 (슬라이스 2 선행 / spec §5.2 보강).
//
// 문제: useOneOfInterfaces=false 는 컴파일은 통과시키지만 discriminator union이 Jackson
// 양방향 모두 계약을 위반한다(역직렬화 InvalidTypeIdException, 직렬화는 kind 대신 클래스
// simple name). useOneOfInterfaces=true 는 SpringCodegen이 union interface의 discriminator
// getter를 String으로 고정해 컴파일이 깨진다 — 계약 쪽으로 우회 불가(Plan 01 task-4-report).
//
// 해법: 계약 원본은 그대로 두고, 생성 직전에 사본을 파생시킨다.
// (1) oneOf + discriminator 를 가진 스키마 S 의 각 하위 타입에 `x-implements: [S]` 를 주입한다
// -> 생성된 하위 타입이 `implements S` 로 나온다(7.18.0에서 실측 확인).
// (2) S 자체의 생성은 ignore 시키고, 같은 package 에 Jackson 다형성 애너테이션을 단
// Java interface 로 이 태스크가 직접 써 넣는다.
// (3) 모든 분기가 `type: string` 인 이름 없는 oneOf 는 `type: string` 으로 접는다.
// 접지 않으면 생성기가 필드가 하나도 없는 껍데기 클래스를 만든다
// (WorkingCopyInputBase.slug -> WorkingCopyInputBaseSlug: 실측 확인). 그 결과
// slug 가 문자열이 아니라 `{}` 로 직렬화되어 계약을 위반한다.
//
// 파생 규칙은 계약의 `oneOf`/`discriminator.mapping` 에서 전부 읽어낸다 — union 목록을
// 손으로 관리하지 않으므로 계약에 union이 추가돼도 따라온다.
//
// snakeyaml 은 org.openapi.generator 플러그인이 buildscript classpath 로 이미 가져온다
// (swagger-parser 경유, 2.4 — 실측 확인).
// ---------------------------------------------------------------------------
ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml')
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
// 이 파생은 계약 두 벌(studio-v1, public-v1)에 똑같이 적용된다. 두 벌을 각자 복사해 두면
// 한쪽만 고쳐지는 날이 오므로 클로저 하나로 두고 태스크가 인자만 바꿔 호출한다.
ext.prepareTechLogCodegenSpec = { String label, File specSource, File specTarget,
File ignoreTarget, File unionDir, String modelPackage ->
def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8'))
def schemas = doc.components.schemas
// (3) 전부 string 인 이름 없는 oneOf 접기
int[] collapsed = [0]
def collapseStringOneOf
collapseStringOneOf = { Object node ->
if (node instanceof Map) {
for (Object key : new ArrayList(node.keySet())) {
def value = node.get(key)
if (value instanceof Map && value.get('oneOf') instanceof List
&& !value.containsKey('discriminator')) {
def branches = value.get('oneOf')
if (!branches.isEmpty()
&& branches.every { it instanceof Map && it.get('type') == 'string' }) {
def folded = new LinkedHashMap()
folded.put('type', 'string')
if (value.containsKey('description')) {
folded.put('description', value.get('description'))
}
node.put(key, folded)
collapsed[0]++
continue
}
}
collapseStringOneOf(value)
}
} else if (node instanceof List) {
node.each { collapseStringOneOf(it) }
}
}
collapseStringOneOf(doc)
// boolean 프로퍼티의 `const` 를 코드젠 사본에서만 걷어낸다.
//
// 봉투의 success 는 계약상 `{type: boolean, const: true}` 다. 생성기는 이 문서를 검증
// 경로 없이 읽으면 그 const 를 단일값 enum 으로 취급해 `enum SuccessEnum { TRUE("true") }`
// 를 만드는데, 그 enum 의 필드 타입은 Boolean 이고 생성자에는 String 을 넘겨 컴파일이
// 깨진다(실측). 검증 경로를 타는 studio 쪽에서는 같은 계약이 평범한 Boolean 으로 나온다 —
// 즉 계약이 아니라 생성기의 경로 차이가 원인이다.
//
// 값이 하나로 고정된다는 사실은 소비자에게 의미가 있으므로 정본 계약에는 그대로 두고,
// 여기서만 뗀다. 서버가 이 값을 잘못 넣을 위험은 없다 — 봉투는 EnvelopeBodyAdvice 가
// 만들고 컨트롤러가 손대지 않는다.
int[] consts = [0]
def dropBooleanConst
dropBooleanConst = { Object node ->
if (node instanceof Map) {
if (node.get('type') == 'boolean' && node.containsKey('const')) {
node.remove('const')
consts[0]++
}
new ArrayList(node.values()).each { dropBooleanConst(it) }
} else if (node instanceof List) {
node.each { dropBooleanConst(it) }
}
}
dropBooleanConst(doc)
// (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면
// 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가
// DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌
// 생겨 매핑 코드가 그 사이를 오가게 된다. Java 참조는 어차피 nullable 이라 손실이 없으므로
// null 분기를 지우고 남은 하나로 접는다.
int[] nullable = [0]
def collapseNullableOneOf
collapseNullableOneOf = { Object node ->
if (node instanceof Map) {
for (Object key : new ArrayList(node.keySet())) {
def value = node.get(key)
if (value instanceof Map && value.get('oneOf') instanceof List
&& !value.containsKey('discriminator')) {
def branches = value.get('oneOf')
def nulls = branches.findAll { it instanceof Map && it.get('type') == 'null' }
def rest = branches - nulls
if (!nulls.isEmpty() && rest.size() == 1 && rest[0] instanceof Map) {
def folded = new LinkedHashMap(rest[0])
if (value.containsKey('description') && !folded.containsKey('description')) {
folded.put('description', value.get('description'))
}
node.put(key, folded)
nullable[0]++
continue
}
}
collapseNullableOneOf(value)
}
} else if (node instanceof List) {
node.each { collapseNullableOneOf(it) }
}
}
collapseNullableOneOf(doc)
// (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다.
//
// 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가
// 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수
// 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required
// 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/
// decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이
// 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing").
//
// 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠
// 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은
// 그대로 남는다.
int[] relaxed = [0]
def relaxNullableRequired
relaxNullableRequired = { Object node ->
if (node instanceof Map) {
def props = node.get('properties')
def required = node.get('required')
if (props instanceof Map && required instanceof List) {
def drop = []
props.each { Object name, Object schema ->
if (!(schema instanceof Map)) return
def type = schema.get('type')
if (type instanceof List && type.contains('null') && required.contains(name)) {
drop << name
}
}
if (!drop.isEmpty()) {
required.removeAll(drop)
relaxed[0] += drop.size()
if (required.isEmpty()) node.remove('required')
}
}
new ArrayList(node.values()).each { relaxNullableRequired(it) }
} else if (node instanceof List) {
node.each { relaxNullableRequired(it) }
}
}
relaxNullableRequired(doc)
logger.lifecycle("${label}: nullable required 해제 ${relaxed[0]}건")
// (1) x-implements 주입 + union 목록 수집
def unions = [:]
schemas.each { String name, Object schema ->
if (!(schema instanceof Map)) return
def disc = schema.get('discriminator')
if (!(schema.get('oneOf') instanceof List) || !(disc instanceof Map)) return
def property = disc.get('propertyName')
def mapping = disc.get('mapping')
if (!property || !(mapping instanceof Map) || mapping.isEmpty()) {
throw new GradleException(
"union ${name} 에 discriminator.propertyName 과 mapping 이 모두 있어야 한다 " +
"— 없으면 Jackson @JsonSubTypes 의 type id 를 계약에서 유도할 수 없다.")
}
def variants = new LinkedHashMap()
mapping.each { String typeId, String ref ->
def variant = ref.tokenize('/').last()
if (!schemas.containsKey(variant)) {
throw new GradleException("union ${name} 의 mapping 이 없는 스키마 ${variant} 를 가리킨다.")
}
variants.put(typeId, variant)
}
schema.get('oneOf').each { branch ->
if (branch instanceof Map && branch.get('$ref')) {
def variant = branch.get('$ref').tokenize('/').last()
if (!variants.containsValue(variant)) {
throw new GradleException(
"union ${name} 의 oneOf 분기 ${variant} 가 discriminator.mapping 에 없다 " +
"— type id 를 알 수 없어 Jackson 배선을 파생시킬 수 없다.")
}
}
}
variants.values().toSet().each { String variant ->
def target = schemas.get(variant)
def impls = target.get('x-implements')
if (impls == null) {
target.put('x-implements', [name])
} else if (!impls.contains(name)) {
target.put('x-implements', impls + [name])
}
}
unions.put(name, [property: property, variants: variants])
}
// 이 가드의 목적은 "union 이 있어야 한다"가 아니라 "계약에 있는 union 을 하나도 빠뜨리지
// 않았다"이다. public-v1 처럼 union 이 애초에 없는 계약도 있으므로 개수를 계약에서 세어
// 대조한다. 원래 studio 전용으로 "0개면 실패"로 썼다가 public-v1 에서 걸렸다.
int declaredUnions = schemas.count { String name, Object schema ->
schema instanceof Map && schema.get('oneOf') instanceof List &&
schema.get('discriminator') instanceof Map
}
if (unions.size() != declaredUnions) {
throw new GradleException(
"계약의 discriminator union ${declaredUnions}개 중 ${unions.size()}개만 파생했다 — " +
"파생 규칙이 계약을 따라가지 못한다.")
}
// 파생 계약 쓰기
//
// deep copy 가 반드시 선행한다. 위 변환들이 같은 Map/List 인스턴스를 여러 위치에
// 재사용하면 snakeyaml 이 그 지점을 YAML anchor/alias(&id001 / *id001)로 덤프한다.
// swagger-parser 는 alias 노드를 해석하지 못해 그 스키마를
// "is not of type `object`" 로 거부하고, validateSpec 을 끄면 generator 가 해당
// property 를 **조용히 누락한 채** 모델을 만든다(publishedAt, matchedFields 등이
// 실제로 사라졌다). 노드 identity 를 전부 끊어 alias 자체를 원천 차단한다.
def deepCopy
deepCopy = { Object node ->
if (node instanceof Map) {
def copy = new LinkedHashMap<String, Object>()
node.each { k, v -> copy.put(k, deepCopy(v)) }
return copy
}
if (node instanceof List) {
return node.collect { deepCopy(it) }
}
return node
}
def dumperOptions = new org.yaml.snakeyaml.DumperOptions()
dumperOptions.defaultFlowStyle = org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK
dumperOptions.width = 8192
def specFile = specTarget
specFile.parentFile.mkdirs()
def rendered = new org.yaml.snakeyaml.Yaml(dumperOptions).dump(deepCopy(doc))
// fail-closed: alias 가 하나라도 남으면 생성물이 조용히 불완전해진다.
def aliasLines = rendered.readLines().findAll { it =~ /(?:&|\*)id\d{3}\b/ }
if (!aliasLines.isEmpty()) {
throw new GradleException(
"${label}: 파생 계약에 YAML alias 가 남았다 — swagger-parser 가 해당 스키마를 " +
"거부하고 property 가 조용히 누락된다. 위반 ${aliasLines.size()}줄, 예: " +
aliasLines.take(3).join(' | '))
}
specFile.setText(rendered, 'UTF-8')
// union 클래스 생성 억제
def ignoreFile = ignoreTarget
ignoreFile.setText(
(["# ${label} 가 생성한다 — 손으로 고치지 않는다.",
'# 이 파일들은 같은 package 의 Java interface 로 대체된다.']
+ unions.keySet().collect { "**/${it}.java" }).join('\n') + '\n',
'UTF-8')
// union interface 쓰기
def packageDir = new File(unionDir, modelPackage.replace('.', '/'))
project.delete(unionDir)
packageDir.mkdirs()
unions.each { String name, Object spec ->
def subtypes = spec.variants.collect { String typeId, String variant ->
" @JsonSubTypes.Type(value = ${variant}.class, name = \"${typeId}\")"
}.join(',\n')
def source = """package ${modelPackage};
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* {@code ${name}} — 계약의 discriminator union. ${label} 가 계약의
* {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다.
*
* <p>{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로
* 이미 직렬화하므로 Jackson 이 판별 필드를 한 번 더 쓰면 키가 중복된다.
*/
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "${spec.property}",
visible = true)
@JsonSubTypes({
${subtypes}
})
public interface ${name} {}
"""
new File(packageDir, "${name}.java").setText(source, 'UTF-8')
}
logger.lifecycle(
"${label}: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), boolean const ${consts[0]}건 제거, " +
"string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음")
}
ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml')
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
// `public` 은 Java 예약어라 패키지 조각으로 쓸 수 없다 — publicapi 로 둔다.
ext.publicModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model'
ext.publicCodegenSpecFile = layout.buildDirectory.file('openapi/public-v1-codegen.yaml')
ext.publicCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-public')
ext.publicUnionSrcDir = layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java')
ext.managementModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.management.api.model'
ext.managementCodegenSpecFile = layout.buildDirectory.file('openapi/studio-management-v1-codegen.yaml')
ext.managementCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-management')
ext.managementUnionSrcDir = layout.buildDirectory.dir('generated/openapi-management-unions/src/main/java')
tasks.register('prepareStudioCodegenSpec') {
description = 'studio-v1 계약에서 생성기 입력을 파생시킨다.'
def specSource = file("${rootDir}/config/openapi/studio-v1.yaml")
def specOut = studioCodegenSpecFile
def ignoreOut = studioCodegenIgnoreFile
def unionDir = studioUnionSrcDir
def modelPackage = studioModelPackage
def prepare = prepareTechLogCodegenSpec
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
prepare('prepareStudioCodegenSpec', specSource, specOut.get().asFile,
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
}
}
tasks.register('preparePublicCodegenSpec') {
description = 'public-v1 계약에서 생성기 입력을 파생시킨다.'
def specSource = file("${rootDir}/config/openapi/public-v1.yaml")
def specOut = publicCodegenSpecFile
def ignoreOut = publicCodegenIgnoreFile
def unionDir = publicUnionSrcDir
def modelPackage = publicModelPackage
def prepare = prepareTechLogCodegenSpec
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
prepare('preparePublicCodegenSpec', specSource, specOut.get().asFile,
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
}
}
// public-v1 생성. openApiGenerate 확장은 계약 하나만 다루므로 두 번째 계약은 GenerateTask 를
// 직접 등록한다. 설정은 studio 쪽과 같은 근거를 따른다(model 만 생성, oneOf interface 미사용,
// openApiNullable=false) — 그 근거는 위 openApiGenerate 블록의 주석에 있다.
tasks.register('openApiGeneratePublic',
org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
dependsOn tasks.named('preparePublicCodegenSpec')
generatorName = 'spring'
inputSpec = publicCodegenSpecFile.get().asFile.path
ignoreFileOverride = publicCodegenIgnoreFile.get().asFile.path
outputDir = layout.buildDirectory.dir('generated/openapi-public').get().asFile.path
modelPackage = publicModelPackage
// 검증을 켠 채로 둔다. 한때 swagger-parser 가 이 문서의 스키마 15개를
// "is not of type `object`" 로 거절했는데, 원인은 계약이 아니라 파생 단계였다.
// preparePublicCodegenSpec 의 변환이 같은 Map 인스턴스를 여러 property 에 재사용해
// snakeyaml 이 YAML alias(*id001)로 덤프했고, swagger-parser 가 alias 노드를
// 해석하지 못해 그 스키마 전체를 거절했다. validateSpec 을 끄면 generator 는 문서를
// 받아들이되 alias 였던 property 를 **조용히 누락**한다 — publishedAt, updatedAt,
// matchedFields, changeTypes 가 실제로 모델에서 사라졌다. 파생 단계에서 deep copy 로
// alias 를 원천 차단했으므로 검증을 다시 켠다.
validateSpec = true
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
openApiNullable: 'false',
useOneOfInterfaces: 'false',
]
// 생성기는 outputDir 를 비우지 않는다 — 계약에서 사라진 스키마의 .java 가 남아 드리프트를
// 가린다(studio 쪽에서 실제로 겪었다).
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi-public')) }
}
// 생성기가 스키마나 property 를 조용히 빠뜨려도 컴파일은 그대로 통과한다(그 타입을 아직
// 아무도 안 쓰니까) — 나중에 컨트롤러를 쓸 때서야 드러난다. 실제로 파생 계약의 YAML alias
// 때문에 publishedAt / updatedAt / matchedFields / changeTypes 가 모델에서 사라진 채로
// 빌드가 성공한 적이 있고, 그때 이 게이트가 schema 이름만 봐서 놓쳤다. 그래서 property 까지
// 대조한다.
tasks.register('verifyPublicGeneratedModels') {
group = 'verification'
description = 'public-v1 계약의 schema 와 property 가 전부 모델로 생성됐는지 대조한다.'
dependsOn tasks.named('openApiGeneratePublic')
def specFile = publicCodegenSpecFile
def modelDirProvider = layout.buildDirectory.dir('generated/openapi-public/src/main/java')
def modelPackage = publicModelPackage
doLast {
def doc = new org.yaml.snakeyaml.Yaml().load(specFile.get().asFile.getText('UTF-8'))
Set<String> declared = new TreeSet<>(((Map) doc.components.schemas).keySet())
File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/'))
Set<String> generated = new TreeSet<>()
if (packageDir.isDirectory()) {
packageDir.eachFile { File f ->
if (f.name.endsWith('.java')) generated << f.name[0..-6]
}
}
// 생성기는 이름 없는 중첩 object 에 <부모><필드> 형태의 모델을 더 만든다. 그건 초과분이라
// 문제가 아니고, 부족분만 문제다.
Set<String> missing = new TreeSet<>(declared - generated)
if (!missing.isEmpty()) {
throw new GradleException(
"public-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}")
}
// property 대조. 생성기는 @JsonProperty 에 계약의 원래 이름을 그대로 쓰므로
// 그 문자열 리터럴이 파일에 있는지로 판정한다.
int checkedProps = 0
List<String> lost = []
((Map) doc.components.schemas).each { String name, Object schema ->
if (!(schema instanceof Map)) return
Object props = ((Map) schema).get('properties')
if (!(props instanceof Map)) return
File modelFile = new File(packageDir, "${name}.java")
if (!modelFile.isFile()) return
String body = modelFile.getText('UTF-8')
((Map) props).keySet().each { Object prop ->
checkedProps++
if (!body.contains("\"${prop}\"")) lost << "${name}.${prop}"
}
}
if (!lost.isEmpty()) {
throw new GradleException(
"public-v1 계약의 property ${lost.size()}개가 모델에서 빠졌다 " +
"(생성기가 조용히 누락한다): ${lost.take(20)}")
}
logger.lifecycle(
"verifyPublicGeneratedModels: 계약 schema ${declared.size()}개 · " +
"property ${checkedProps}개 전부 생성 (생성 모델 ${generated.size()}개)")
}
}
tasks.register('prepareManagementCodegenSpec') {
description = 'studio-management-v1 계약에서 생성기 입력을 파생시킨다.'
def specSource = file("${rootDir}/config/openapi/studio-management-v1.yaml")
def specOut = managementCodegenSpecFile
def ignoreOut = managementCodegenIgnoreFile
def unionDir = managementUnionSrcDir
def modelPackage = managementModelPackage
def prepare = prepareTechLogCodegenSpec
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
prepare('prepareManagementCodegenSpec', specSource, specOut.get().asFile,
ignoreOut.get().asFile, unionDir.get().asFile, modelPackage)
}
}
tasks.register('openApiGenerateManagement',
org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
dependsOn tasks.named('prepareManagementCodegenSpec')
generatorName = 'spring'
inputSpec = managementCodegenSpecFile.get().asFile.path
ignoreFileOverride = managementCodegenIgnoreFile.get().asFile.path
outputDir = layout.buildDirectory.dir('generated/openapi-management').get().asFile.path
modelPackage = managementModelPackage
validateSpec = true
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
openApiNullable: 'false',
useOneOfInterfaces: 'false',
]
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi-management')) }
}
tasks.register('verifyManagementGeneratedModels') {
group = 'verification'
description = 'studio-management-v1 계약의 schema 와 property 가 전부 모델로 생성됐는지 대조한다.'
dependsOn tasks.named('openApiGenerateManagement')
def specFile = managementCodegenSpecFile
def modelDirProvider = layout.buildDirectory.dir('generated/openapi-management/src/main/java')
def modelPackage = managementModelPackage
doLast {
def doc = new org.yaml.snakeyaml.Yaml().load(specFile.get().asFile.getText('UTF-8'))
Set<String> declared = new TreeSet<>(((Map) doc.components.schemas).keySet())
File packageDir = new File(modelDirProvider.get().asFile, modelPackage.replace('.', '/'))
Set<String> generated = new TreeSet<>()
if (packageDir.isDirectory()) {
packageDir.eachFile { File f -> if (f.name.endsWith('.java')) generated << f.name[0..-6] }
}
Set<String> missing = new TreeSet<>(declared - generated)
if (!missing.isEmpty()) {
throw new GradleException(
"studio-management-v1 계약의 schema ${missing.size()}개가 모델로 생성되지 않았다: ${missing}")
}
int checkedProps = 0
List<String> lost = []
((Map) doc.components.schemas).each { String name, Object schema ->
if (!(schema instanceof Map)) return
Object props = ((Map) schema).get('properties')
if (!(props instanceof Map)) return
File modelFile = new File(packageDir, "${name}.java")
if (!modelFile.isFile()) return
String body = modelFile.getText('UTF-8')
((Map) props).keySet().each { Object prop ->
checkedProps++
if (!body.contains("\"${prop}\"")) lost << "${name}.${prop}"
}
}
if (!lost.isEmpty()) {
throw new GradleException(
"studio-management-v1 계약의 property ${lost.size()}개가 모델에서 빠졌다: ${lost.take(20)}")
}
logger.lifecycle(
"verifyManagementGeneratedModels: 계약 schema ${declared.size()}개 · " +
"property ${checkedProps}개 전부 생성 (생성 모델 ${generated.size()}개)")
}
}
tasks.named('check') {
dependsOn tasks.named('verifyPublicGeneratedModels')
dependsOn tasks.named('verifyManagementGeneratedModels')
}
tasks.named('compileGeneratedOpenapiJava') {
dependsOn tasks.named('openApiGeneratePublic')
dependsOn tasks.named('openApiGenerateManagement')
}
// openApiGenerate 는 확장(extension) 이름이자 태스크 이름이다 — 위 블록은 확장 설정이라
// dependsOn 을 받지 못한다. 태스크 쪽에 건다.
tasks.named('openApiGenerate') {
dependsOn tasks.named('prepareStudioCodegenSpec')
// openapi-generator 는 outputDir 를 비우지 않는다 — 계약에서 스키마가 사라져도 직전
// 실행의 .java 가 그대로 남아 컴파일에 성공하고, 그래서 드리프트가 아니라 정상으로 보인다
// (이 배선을 넣는 과정에서 실제로 겪음: 삭제됐어야 할 union 6개가 남아 있었다).
doFirst { project.delete(layout.buildDirectory.dir('generated/openapi')) }
}
// ---------------------------------------------------------------------------
// Studio 계약 DTO 생성 (ADR-004 / ADR-006).
//
@@ -155,16 +732,24 @@ tasks.named('check') {
// ---------------------------------------------------------------------------
openApiGenerate {
generatorName = 'spring'
inputSpec = "${rootDir}/config/openapi/studio-v1.yaml".toString()
// 원본이 아니라 prepareStudioCodegenSpec 가 파생한 사본을 먹인다 — 이유는 그 태스크의 주석 참고.
inputSpec = studioCodegenSpecFile.get().asFile.path
ignoreFileOverride = studioCodegenIgnoreFile.get().asFile.path
outputDir = layout.buildDirectory.dir('generated/openapi').get().asFile.path
modelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
modelPackage = studioModelPackage
globalProperties.set(['models': ''])
generateModelTests = false
generateModelDocumentation = false
configOptions = [
useSpringBoot3: 'true',
useJakartaEe: 'true',
openApiNullable: 'true',
// false 다. openApiNullable=true 는 nullable 필드를 JsonNullable<T> 로 만드는데,
// 그 타입을 읽는 모듈(org.openapitools:jackson-databind-nullable)은 Jackson 2 용이고
// 이 앱의 HTTP 변환기는 Jackson 3(tools.jackson, Spring Boot 4 기본)다 — 모듈이
// 등록될 수 없어 JsonNullable 이 그냥 POJO 로 직렬화되고 역직렬화는 깨진다.
// 계약이 이 필드들을 required 로 두므로(예: questionStatus, decisionStatus)
// "없음"과 "null" 을 구분할 필요도 없다. 평범한 nullable 필드로 생성한다.
openApiNullable: 'false',
useOneOfInterfaces: 'false',
]
}
+5
View File
@@ -91,6 +91,10 @@ org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.commonmark:commonmark-ext-autolink:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark-ext-gfm-tables:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.commonmark:commonmark:0.21.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -108,6 +112,7 @@ org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.nibor.autolink:autolink:0.10.0=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,generatedOpenapiCompileClasspath,generatedOpenapiRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
@@ -70,7 +70,10 @@ public class SecurityConfig {
AccessDeniedHandler accessDeniedHandler,
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
sessionSecurityContextRepository,
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths)
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths,
org.springframework.beans.factory.ObjectProvider<
org.springframework.security.web.authentication.AuthenticationSuccessHandler>
loginSuccessHandler)
throws Exception {
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
@@ -137,6 +140,29 @@ public class SecurityConfig {
securityContext
.securityContextRepository(sessionSecurityContextRepository.getObject())
.requireExplicitSave(false));
// BFF 로그인. 세션을 만들 수 있는 유일한 경로다 — 이것이 없으면 auth-mode=redis-session 은
// 아무도 인증할 수 없는 모드가 된다. SPA 는 401 을 받으면 브라우저를 /oauth2/authorization/{id}
// 로 이동시키고, 콜백이 세션 쿠키를 심은 뒤 SPA 진입점으로 되돌린다.
//
// 진입점은 바꾸지 않는다: API 요청이 302 로 답하면 XHR 이 따라갈 수 없으므로, 미인증 API 호출은
// 그대로 봉투 401 이어야 한다. 아래 defaultSuccessUrl 대신 주입된 핸들러를 쓰는 이유는
// OidcUser 를 세션이 담을 수 있는 AuthenticatedPrincipal 로 바꿔야 하기 때문이다.
org.springframework.security.web.authentication.AuthenticationSuccessHandler onSuccess =
loginSuccessHandler.getIfAvailable();
if (onSuccess != null) {
http.oauth2Login(login -> login.successHandler(onSuccess));
}
http.logout(
logout ->
logout
.logoutUrl("/logout")
.invalidateHttpSession(true)
.deleteCookies(securitySettings.session().cookieName())
.logoutSuccessHandler(
(request, response, authentication) ->
response.setStatus(
jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT)));
}
return http.build();
}
@@ -6,11 +6,14 @@ import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.shared.response.Envelope;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -26,14 +29,17 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
* handlePersistenceFailure}/{@code handleDependencyFailure}가 분류된 하위 계층 실패를 로깅하는 것과 같은 패턴이다.
*
* <p><b>{@code basePackages} 스코프 (final whole-branch review B4).</b> 이 advice는 {@code
* dev.caskeleton.adapter.inbound.web.techlog} 아래의 컨트롤러(현재 studio 컨트롤러 전부가 여기 산다, {@code
* studio.controller})에만 적용된다. {@link #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring
* MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그
* 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이
* 없는 기능이다. {@code StudioException} 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
* dev.caskeleton.adapter.inbound.web.techlog.studio} 아래의 컨트롤러(studio 컨트롤러 전부가 여기 산다, {@code
* studio.controller})에만 적용된다. 원래는 한 단계 위인 {@code ...web.techlog}였는데, 공개 조회 컨트롤러가 {@code
* ...web.techlog.publicapi}에 들어오면서 그 스코프가 남의 기능까지 덮게 되었다 — 아래 바인딩 예외 처리기들이 공개 조회의 파라미터 오류를 Studio
* 계약 코드로 바꿔 내보냈을 것이고, 그 코드는 public-v1 계약의 enum 에 없어서 프론트엔드의 응답 파싱을 깨뜨린다. 그래서 studio 로 좁혔다. {@link
* #handleMissingParameter}/{@link #handleTypeMismatch}는 Spring MVC 표준 바인딩 예외를 계약 코드로 옮기는데, 스코프 없이
* 전역으로 두면 fileserver·healthcheck 같은 studio 밖 컨트롤러의 같은 예외까지 가로채 그 기능들의 기존 오류 응답 모양(바로 이 advice가 없었을
* 때의 {@code GlobalExceptionHandler} 동작)을 바꿔버린다 — 이 브랜치가 건드릴 권한이 없는 기능이다. {@code StudioException}
* 처리는 애초에 studio 코드만 이 예외를 던지므로 스코프를 좁혀도 동작이 바뀌지 않는다.
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog")
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.studio")
public class StudioExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(StudioExceptionHandler.class);
@@ -76,6 +82,46 @@ public class StudioExceptionHandler {
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
}
/**
* 요청 본문 bean validation 실패(예: {@code title} 120자 초과). {@code GlobalExceptionHandler}도 이 예외를 처리하지만
* 400 {@code OperationalError.VALIDATION_FAILED}를 낸다 — Studio 계약에 없는 코드이고 (계약이 아는 것은 {@code
* REQUEST_VALIDATION_FAILED}와 {@code DOCUMENT_VALIDATION_FAILED}뿐이다), 상태도 계약이 본문 검증 실패에 배정한 422가
* 아니다. 프론트엔드는 봉투의 {@code code}를 enum으로 검증하므로 계약 밖 코드는 응답 파싱 자체를 깨뜨린다. studio 스코프에서 계약 코드로 옮긴다.
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Envelope<Void>> handleBodyValidation(MethodArgumentNotValidException ex) {
List<Map<String, Object>> fieldErrors =
ex.getBindingResult().getFieldErrors().stream()
.map(
error ->
Map.<String, Object>of(
"path",
"/" + error.getField(),
"message",
error.getDefaultMessage() == null
? "Value is invalid"
: error.getDefaultMessage()))
.collect(Collectors.toList());
return ErrorResponseFactory.envelope(
StudioError.REQUEST_VALIDATION_FAILED,
StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED),
Map.of("fieldErrors", fieldErrors));
}
/**
* 권한 부족. 스켈레톤의 분류기는 {@code AUTHZ_INSUFFICIENT_PERMISSION}을 내지만 계약이 403에 배정한 코드는 {@code
* STUDIO_ACCESS_DENIED}다({@code responses.AccessDenied.x-error-codes}). 상태는 그대로 403이고 코드만 계약 쪽으로
* 옮긴다.
*/
@ExceptionHandler(AuthorizationDeniedException.class)
public ResponseEntity<Envelope<Void>> handleAccessDenied(AuthorizationDeniedException ex) {
log.warn("studio access denied: {}", ex.getMessage());
return ErrorResponseFactory.envelope(
StudioError.STUDIO_ACCESS_DENIED,
StudioClientSafeMessages.forError(StudioError.STUDIO_ACCESS_DENIED),
null);
}
/**
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
@@ -0,0 +1,107 @@
package dev.caskeleton.adapter.inbound.web.techlog.auth;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
/**
* OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다.
*
* <p>{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다.
* 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link
* AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 넘지 못하게 하는 의도적인 제약이다. 그래서
* 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고
* ID/Access 토큰은 남지 않는다.
*
* <p>역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code
* realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 같은 역할
* 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다.
*/
@Component
@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")
public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandler {
private final SimpleUrlAuthenticationSuccessHandler redirect =
new SimpleUrlAuthenticationSuccessHandler();
public StudioOidcLoginSuccessHandler(
@Value("${app.studio.post-login-redirect:/}") String defaultTargetUrl) {
redirect.setDefaultTargetUrl(defaultTargetUrl);
// SPA 가 라우팅을 소유한다. 프레임워크의 SavedRequest 는 SecurityConfig 가 이미 꺼두었으므로
// 로그인 후에는 항상 SPA 진입점으로 보내고, 원래 가려던 화면 복원은 SPA 가 한다.
redirect.setAlwaysUseDefaultTargetUrl(true);
}
@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
if (authentication.getPrincipal() instanceof OidcUser user) {
Set<String> roles = extractRoles(user);
AuthenticatedPrincipal principal =
new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles);
Collection<GrantedAuthority> authorities =
roles.stream()
.map(
r ->
(GrantedAuthority)
new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT)))
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
SecurityContextHolder.setContext(context);
// requireExplicitSave(false) 이므로 SecurityContextHolderFilter 가 응답 커밋 시 저장한다.
authentication = context.getAuthentication();
}
redirect.onAuthenticationSuccess(request, response, authentication);
}
private static Set<String> extractRoles(OidcUser user) {
Set<String> roles = new HashSet<>();
addRoles(roles, user.getClaimAsMap("realm_access"));
Map<String, Object> resourceAccess = user.getClaimAsMap("resource_access");
if (resourceAccess != null) {
for (Object client : resourceAccess.values()) {
if (client instanceof Map<?, ?> map) {
addRoles(roles, map);
}
}
}
List<String> generic = user.getClaimAsStringList("roles");
if (generic != null) {
roles.addAll(generic);
}
return Set.copyOf(roles);
}
private static void addRoles(Set<String> sink, Map<?, ?> holder) {
if (holder == null) {
return;
}
if (holder.get("roles") instanceof Collection<?> values) {
values.forEach(value -> sink.add(String.valueOf(value)));
}
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.techlog.management;
import dev.caskeleton.application.techlog.error.ManagementError;
/**
* code 별 고정 문구. 예외의 원문 메시지는 진단용이라 그대로 내보내지 않는다 — 저장소 제약 이름이나 SQL 조각이 새어 나갈 수 있고, 그건 클라이언트가 분기할 값도
* 아니다.
*/
public final class ManagementClientSafeMessages {
private ManagementClientSafeMessages() {}
public static String forError(ManagementError error) {
return switch (error) {
case AUTHENTICATION_REQUIRED -> "로그인이 필요합니다";
case STUDIO_ACCESS_DENIED -> "권한이 없습니다";
case REQUEST_VALIDATION_FAILED -> "요청 값이 올바르지 않습니다";
case VERSION_CONFLICT -> "다른 곳에서 먼저 수정되었습니다. 새로 불러온 뒤 다시 시도해 주세요";
case TOPIC_NOT_FOUND -> "주제를 찾을 수 없습니다";
case TOPIC_NAME_TAKEN -> "같은 이름의 주제가 이미 있습니다";
case TOPIC_SLUG_TAKEN -> "같은 slug 의 주제가 이미 있습니다";
case TOPIC_IN_USE -> "이 주제를 쓰는 기록이 있어 삭제할 수 없습니다";
case PROJECT_NOT_FOUND -> "프로젝트를 찾을 수 없습니다";
case PROJECT_SLUG_TAKEN -> "같은 slug 의 프로젝트가 이미 있습니다";
case PROJECT_IN_USE -> "이 프로젝트에 연결된 기록이 있어 삭제할 수 없습니다";
case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다";
case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다";
case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다";
case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요";
case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다";
case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다";
case QUESTION_IN_USE -> "이 질문을 참조하는 곳이 있어 삭제할 수 없습니다";
case DECISION_NOT_FOUND -> "결정을 찾을 수 없습니다";
case DECISION_IN_USE -> "이 결정을 참조하는 곳이 있어 삭제할 수 없습니다";
};
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.inbound.web.techlog.management;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.shared.response.Envelope;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* 관리 표면의 실패를 봉투로 옮긴다. 스코프를 {@code ...web.techlog.management} 로 좁히는 이유는 형제 표면들과 같다 — 각 계약이 자기 {@code
* ApiError.code} 집합만 열거하고 있어서, 다른 표면의 코드가 새어 들어가면 그 계약이 거짓이 된다.
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.management")
public class ManagementExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ManagementExceptionHandler.class);
@ExceptionHandler(ManagementException.class)
public ResponseEntity<Envelope<Void>> handle(ManagementException ex) {
ManagementError error = ex.managementError();
if (error.httpStatus() >= 500) {
log.error("management request failed as {}: {}", error.code(), ex.getMessage(), ex);
} else {
log.warn("management request rejected as {}: {}", error.code(), ex.getMessage());
}
return ErrorResponseFactory.envelope(
error, ManagementClientSafeMessages.forError(error), ex.details());
}
/**
* 본문을 못 읽는 경우(빈 본문, 깨진 JSON, enum 값 불일치). 그냥 두면 부모 처리기가 RFC 7807 을 만들고 {@code EnvelopeBodyAdvice}
* 의 미디어타입 검사에 걸려 봉투가 안 씌워진다 — ADR-006 이 쓰지 않기로 한 모양이 그대로 나간다.
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Envelope<Void>> handleUnreadableBody(HttpMessageNotReadableException ex) {
log.warn("management request body was unreadable: {}", ex.getMessage());
return invalid("body", "MALFORMED", "Request body is malformed or unparsable");
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Envelope<Void>> handleMissingParameter(
MissingServletRequestParameterException ex) {
return invalid(ex.getParameterName(), "REQUIRED", "Required parameter is missing");
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return invalid(ex.getName(), "TYPE_MISMATCH", "Parameter value is invalid");
}
/** 계약의 {@code ValidationErrorDetails} — {@code field/code/message} 셋 다 required 다. */
private static ResponseEntity<Envelope<Void>> invalid(String field, String code, String message) {
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
return ErrorResponseFactory.envelope(
ManagementError.REQUEST_VALIDATION_FAILED,
ManagementClientSafeMessages.forError(ManagementError.REQUEST_VALIDATION_FAILED),
Map.of("fieldErrors", List.of(fieldError)));
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.web.techlog.management;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
/**
* 감사 컬럼에 남길 주체. {@code StudioPrincipals} 와 같은 일을 하되 이 표면의 error code 로 던진다 — 계약이 각자 code 집합을 열거하므로
* 예외까지 공유하면 한쪽 계약이 거짓이 된다.
*/
public final class ManagementPrincipals {
private ManagementPrincipals() {}
public static String require(AuthenticatedPrincipal principal) {
if (principal == null || principal.idpUserId() == null || principal.idpUserId().isBlank()) {
throw ManagementException.of(
ManagementError.AUTHENTICATION_REQUIRED,
"the request has no usable authenticated principal");
}
return principal.idpUserId();
}
}
@@ -0,0 +1,82 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
import dev.caskeleton.application.techlog.management.command.DeleteDocumentCommand;
import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 작업본 삭제. 계약의 delete 3개.
*
* <p>경로가 종류별로 갈리는 것은 계약이 그렇게 선언했기 때문이고, 그럴 이유도 있다 — Case 와 Reference 는 한 테이블을 나눠 쓰지만 Question 은 다른
* 테이블이고, 종류를 경로에 두면 Case 주소로 Reference 를 지우는 요청이 애초에 성립하지 않는다.
*/
@RestController
public class ManagementDocumentController {
private final DeleteDocumentDraftUseCase deleteDocument;
private final DeleteQuestionUseCase deleteQuestion;
private final DeleteProjectDecisionUseCase deleteDecision;
public ManagementDocumentController(
DeleteDocumentDraftUseCase deleteDocument,
DeleteQuestionUseCase deleteQuestion,
DeleteProjectDecisionUseCase deleteDecision) {
this.deleteDocument = deleteDocument;
this.deleteQuestion = deleteQuestion;
this.deleteDecision = deleteDecision;
}
@DeleteMapping("/v1/studio/cases/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteCaseDraft(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
deleteDocument.handle(command(id, body, principal), "CASE");
}
@DeleteMapping("/v1/studio/references/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteReferenceDraft(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
deleteDocument.handle(command(id, body, principal), "REFERENCE");
}
@DeleteMapping("/v1/studio/questions/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteQuestion(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
deleteQuestion.handle(command(id, body, principal));
}
@DeleteMapping("/v1/studio/projects/{id}/decisions/{decisionId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteProjectDecision(
@PathVariable("id") UUID id,
@PathVariable("decisionId") UUID decisionId,
@RequestBody ExpectedVersionRequest body) {
deleteDecision.handle(id, decisionId, body.getExpectedVersion());
}
private static DeleteDocumentCommand command(
UUID id, ExpectedVersionRequest body, AuthenticatedPrincipal principal) {
return new DeleteDocumentCommand(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal));
}
}
@@ -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();
}
}
@@ -0,0 +1,151 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/** 프로젝트 관리. 계약의 project CRUD 5개. */
@RestController
public class ManagementProjectController {
private final ListStudioProjectsUseCase listProjects;
private final GetProjectForEditUseCase getProject;
private final CreateProjectUseCase createProject;
private final UpdateProjectUseCase updateProject;
private final DeleteProjectUseCase deleteProject;
private final PublishProjectUseCase publishProject;
private final UnpublishProjectUseCase unpublishProject;
public ManagementProjectController(
ListStudioProjectsUseCase listProjects,
GetProjectForEditUseCase getProject,
CreateProjectUseCase createProject,
UpdateProjectUseCase updateProject,
DeleteProjectUseCase deleteProject,
PublishProjectUseCase publishProject,
UnpublishProjectUseCase unpublishProject) {
this.listProjects = listProjects;
this.getProject = getProject;
this.createProject = createProject;
this.updateProject = updateProject;
this.deleteProject = deleteProject;
this.publishProject = publishProject;
this.unpublishProject = unpublishProject;
}
@GetMapping("/v1/studio/projects")
public ProjectIndexPage listStudioProjects(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
return ManagementResponseMapper.projects(listProjects.handle(page, size));
}
@GetMapping("/v1/studio/projects/{id}")
public ProjectEditResponse getProjectForEdit(@PathVariable("id") UUID id) {
return ManagementResponseMapper.project(getProject.handle(id));
}
@PostMapping("/v1/studio/projects")
@ResponseStatus(HttpStatus.CREATED)
public CreateDraftResponse createProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@RequestBody CreateDraftRequest body) {
return ManagementResponseMapper.draft(
createProject.handle(
new CreateProjectCommand(body.getTitle(), ManagementPrincipals.require(principal))));
}
@PutMapping("/v1/studio/projects/{id}")
public ProjectEditResponse updateProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ProjectUpdateRequest body) {
List<String> labels =
body.getTechnologyLabels() == null ? List.of() : List.copyOf(body.getTechnologyLabels());
return ManagementResponseMapper.project(
updateProject.handle(
new UpdateProjectCommand(
id,
body.getExpectedVersion(),
body.getName(),
body.getSlug(),
body.getOneLinePurpose(),
body.getPurposeMarkdown(),
body.getBoundaryMarkdown(),
body.getSystemOverviewMarkdown(),
body.getPhase(),
body.getCurrentObjective(),
body.getNextStep(),
labels,
body.getTopicIds() == null ? List.of() : List.copyOf(body.getTopicIds()),
body.getTargetVisibility().getValue(),
body.getFeaturedOrder(),
ManagementPrincipals.require(principal))));
}
@DeleteMapping("/v1/studio/projects/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
deleteProject.handle(
new DeleteProjectCommand(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
}
@PostMapping("/v1/studio/projects/{id}/publish")
public PublishResponse publishProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody PublishRequest body) {
return ManagementResponseMapper.projectPublication(
publishProject.handle(
id,
body.getExpectedVersion(),
body.getVisibility() == null ? null : body.getVisibility().getValue(),
ManagementPrincipals.require(principal)));
}
@PostMapping("/v1/studio/projects/{id}/unpublish")
public ProjectEditResponse unpublishProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
return ManagementResponseMapper.project(
unpublishProject.handle(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
}
}
@@ -0,0 +1,145 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseUpdateRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand;
import dev.caskeleton.application.techlog.management.command.ReleaseLifecycleCommand;
import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand;
import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/** 릴리즈 관리. 계약의 release 7개. */
@RestController
public class ManagementReleaseController {
private final ListStudioReleasesUseCase listReleases;
private final GetReleaseForEditUseCase getRelease;
private final CreateReleaseUseCase createRelease;
private final UpdateReleaseUseCase updateRelease;
private final DeleteReleaseUseCase deleteRelease;
private final PublishReleaseUseCase publishRelease;
private final ArchiveReleaseUseCase archiveRelease;
public ManagementReleaseController(
ListStudioReleasesUseCase listReleases,
GetReleaseForEditUseCase getRelease,
CreateReleaseUseCase createRelease,
UpdateReleaseUseCase updateRelease,
DeleteReleaseUseCase deleteRelease,
PublishReleaseUseCase publishRelease,
ArchiveReleaseUseCase archiveRelease) {
this.listReleases = listReleases;
this.getRelease = getRelease;
this.createRelease = createRelease;
this.updateRelease = updateRelease;
this.deleteRelease = deleteRelease;
this.publishRelease = publishRelease;
this.archiveRelease = archiveRelease;
}
@GetMapping("/v1/studio/releases")
public ReleaseIndexPage listStudioReleases(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
return ManagementResponseMapper.releases(listReleases.handle(page, size));
}
@GetMapping("/v1/studio/releases/{id}")
public ReleaseEditResponse getReleaseForEdit(@PathVariable("id") UUID id) {
return ManagementResponseMapper.release(getRelease.handle(id));
}
@PostMapping("/v1/studio/releases")
@ResponseStatus(HttpStatus.CREATED)
public CreateDraftResponse createRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@RequestBody CreateDraftRequest body) {
return ManagementResponseMapper.releaseDraft(
createRelease.handle(
new CreateReleaseCommand(body.getTitle(), ManagementPrincipals.require(principal))));
}
@PutMapping("/v1/studio/releases/{id}")
public ReleaseEditResponse updateRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ReleaseUpdateRequest body) {
List<String> changeTypes =
body.getChangeTypes() == null ? List.of() : List.copyOf(body.getChangeTypes());
return ManagementResponseMapper.release(
updateRelease.handle(
new UpdateReleaseCommand(
id,
body.getExpectedVersion(),
body.getVersionLabel(),
body.getTitle(),
body.getSummary(),
body.getReleasedOn(),
changeTypes,
body.getReasonMarkdown(),
body.getChangesMarkdown(),
body.getUserImpactMarkdown(),
body.getImplementationImpactMarkdown(),
body.getVerificationMarkdown(),
body.getKnownLimitationsMarkdown(),
ManagementPrincipals.require(principal))));
}
@DeleteMapping("/v1/studio/releases/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
deleteRelease.handle(lifecycle(id, body, principal));
}
@PostMapping("/v1/studio/releases/{id}/publish")
public PublishResponse publishRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
return ManagementResponseMapper.releasePublication(
publishRelease.handle(lifecycle(id, body, principal)));
}
@PostMapping("/v1/studio/releases/{id}/archive")
public ReleaseEditResponse archiveRelease(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
return ManagementResponseMapper.release(archiveRelease.handle(lifecycle(id, body, principal)));
}
private static ReleaseLifecycleCommand lifecycle(
UUID id, ExpectedVersionRequest body, AuthenticatedPrincipal principal) {
return new ReleaseLifecycleCommand(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal));
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedVersionRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.DeleteTopicCommand;
import dev.caskeleton.application.techlog.management.command.SaveTopicCommand;
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 주제 관리. 계약 {@code listStudioTopics}/{@code createTopic}/{@code updateTopic}/{@code deleteTopic}.
*
* <p>쓰기 응답의 {@code id}/{@code version} 은 서버가 소유한다. 요청 본문에 실려 와도 무시하고 경로와 저장소가 정한 값을 쓴다 — 그러지 않으면
* 클라이언트가 남의 행을 덮어쓸 수 있다.
*/
@RestController
public class ManagementTopicController {
private final ListStudioTopicsUseCase listTopics;
private final SaveTopicUseCase saveTopic;
private final DeleteTopicUseCase deleteTopic;
public ManagementTopicController(
ListStudioTopicsUseCase listTopics,
SaveTopicUseCase saveTopic,
DeleteTopicUseCase deleteTopic) {
this.listTopics = listTopics;
this.saveTopic = saveTopic;
this.deleteTopic = deleteTopic;
}
@GetMapping("/v1/studio/topics")
public List<TopicEdit> listStudioTopics() {
return ManagementResponseMapper.topics(listTopics.handle());
}
@PostMapping("/v1/studio/topics")
@ResponseStatus(HttpStatus.CREATED)
public TopicEdit createTopic(
@AuthenticationPrincipal AuthenticatedPrincipal principal, @RequestBody TopicEdit body) {
return ManagementResponseMapper.topic(
saveTopic.handle(command(null, body, ManagementPrincipals.require(principal))));
}
@PutMapping("/v1/studio/topics/{id}")
public TopicEdit updateTopic(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody TopicEdit body) {
return ManagementResponseMapper.topic(
saveTopic.handle(command(id, body, ManagementPrincipals.require(principal))));
}
@DeleteMapping("/v1/studio/topics/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteTopic(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
deleteTopic.handle(
new DeleteTopicCommand(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
}
private static SaveTopicCommand command(UUID id, TopicEdit body, String actor) {
return new SaveTopicCommand(
id,
body.getName(),
body.getSlug(),
body.getDescription(),
body.getScope(),
body.getStatus() == null ? null : body.getStatus().getValue(),
body.getExpectedVersion(),
actor);
}
}
@@ -0,0 +1,244 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectActivityResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublicationStatus;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView;
import dev.caskeleton.application.techlog.management.model.TopicEditView;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
/** 애플리케이션 뷰 → 계약 모델. 반대 방향(요청 → command)은 컨트롤러가 직접 한다. */
public final class ManagementResponseMapper {
private ManagementResponseMapper() {}
private static OffsetDateTime at(Instant value) {
return value == null ? null : value.atOffset(ZoneOffset.UTC);
}
public static TopicEdit topic(TopicEditView view) {
TopicEdit model = new TopicEdit(view.name(), view.slug());
model.setId(view.id());
model.setVersion(view.version());
model.setDescription(view.description());
model.setScope(view.scope());
if (view.status() != null) {
model.setStatus(TopicEdit.StatusEnum.fromValue(view.status()));
}
model.setFeaturedReferenceId(view.featuredReferenceId());
model.setFeaturedCaseIds(List.copyOf(view.featuredCaseIds()));
return model;
}
public static List<TopicEdit> topics(List<TopicEditView> views) {
return views.stream().map(ManagementResponseMapper::topic).toList();
}
/** 발행 상태는 두 타임스탬프에서 파생한다 — 테이블에 상태 컬럼이 따로 없고, 그 둘이 사실의 출처이기 때문이다. */
private static PublicationStatus publication(Instant first, Instant last) {
// 상태 컬럼이 따로 없으므로 두 타임스탬프에서 파생한다. WITHDRAWN 은 발행 이력이 있는데
// 지금은 내려간 상태인데, 그 구분은 unpublish 를 구현할 때 생긴다 — 지금은 그 경로가
// 없으므로 발행된 적이 있으면 ACTIVE 다.
PublicationStatus status =
new PublicationStatus(
first == null
? PublicationStatus.StateEnum.NEVER_PUBLISHED
: PublicationStatus.StateEnum.ACTIVE,
false);
status.setPublishedAt(at(last));
return status;
}
public static ProjectEditResponse project(ProjectEditView view) {
ProjectEditResponse model =
new ProjectEditResponse(
view.id(),
view.version(),
view.name(),
view.phase(),
view.workflowStatus(),
view.targetVisibility(),
publication(view.firstPublishedAt(), view.lastPublishedAt()),
at(view.updatedAt()));
model.setSlug(view.slug());
model.setOneLinePurpose(view.oneLinePurpose());
model.setPurposeMarkdown(view.purposeMarkdown());
model.setBoundaryMarkdown(view.boundaryMarkdown());
model.setSystemOverviewMarkdown(view.systemOverviewMarkdown());
model.setCurrentObjective(view.currentObjective());
model.setNextStep(view.nextStep());
model.setTechnologyLabels(List.copyOf(view.technologyLabels()));
model.setFeaturedOrder(view.featuredOrder());
model.setTopicIds(List.copyOf(view.topicIds()));
model.setDocumentLinks(List.of());
model.setQuestionLinks(List.of());
return model;
}
public static CreateDraftResponse draft(ProjectEditView view) {
return new CreateDraftResponse(
view.id(), CreateDraftResponse.StatusEnum.DRAFT, view.version(), at(view.updatedAt()));
}
private static ProjectIndexItem indexItem(ProjectIndexItemView view) {
ProjectIndexItem item =
new ProjectIndexItem(
view.id(),
view.name(),
view.phase(),
view.workflowStatus(),
view.targetVisibility(),
at(view.updatedAt()),
view.version(),
publication(view.firstPublishedAt(), view.lastPublishedAt()));
item.setCurrentObjective(view.currentObjective());
item.setNextStep(view.nextStep());
return item;
}
private static PageMetadata pageMetadata(
int number, int size, int totalElements, int totalPages) {
return new PageMetadata(
number, size, (long) totalElements, totalPages, number > 0, number + 1 < totalPages);
}
public static ProjectIndexPage projects(ListStudioProjectsUseCase.Page page) {
return new ProjectIndexPage(
page.items().stream().map(ManagementResponseMapper::indexItem).toList(),
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
}
/**
* 릴리즈 응답은 {@code ReleaseUpdateRequest} 를 allOf 로 물고 있어 {@code expectedVersion} 을 함께 싣는다. 현재
* version 을 그대로 넣는다 — 그래야 편집 화면이 받은 응답을 그대로 다음 저장 요청으로 되돌려보낼 수 있고, 두 값이 갈라질 이유가 없다.
*/
public static ReleaseEditResponse release(ReleaseEditView view) {
ReleaseEditResponse model =
new ReleaseEditResponse(
view.version(),
view.versionLabel(),
view.title(),
nullToEmpty(view.summary()),
List.copyOf(view.changeTypes()),
nullToEmpty(view.changesMarkdown()),
nullToEmpty(view.verificationMarkdown()),
view.id(),
view.workflowStatus(),
view.version(),
publication(view.firstPublishedAt(), view.lastPublishedAt()));
model.setReleasedOn(view.releasedOn());
model.setReasonMarkdown(view.reasonMarkdown());
model.setUserImpactMarkdown(view.userImpactMarkdown());
model.setImplementationImpactMarkdown(view.implementationImpactMarkdown());
model.setKnownLimitationsMarkdown(view.knownLimitationsMarkdown());
// 관련 자료 링크는 별도 테이블이 소유하고 그 편집 화면이 아직 없다.
model.setRelatedResources(List.of());
return model;
}
public static CreateDraftResponse releaseDraft(ReleaseEditView view) {
return new CreateDraftResponse(
view.id(), CreateDraftResponse.StatusEnum.DRAFT, view.version(), at(view.updatedAt()));
}
/** 릴리즈에는 자체 공개 경로가 있다 — 공개 화면이 {@code version_label} 로 조회하므로 canonical path 도 그것으로 만든다. */
public static PublishResponse releasePublication(ReleaseEditView view) {
return new PublishResponse(
view.id(),
PublishResponse.StatusEnum.PUBLISHED,
PublishResponse.VisibilityEnum.PUBLIC,
"/releases/" + view.versionLabel(),
at(view.lastPublishedAt()),
view.version());
}
private static ReleaseIndexItem releaseIndexItem(ReleaseIndexItemView view) {
ReleaseIndexItem item =
new ReleaseIndexItem(
view.id(),
view.versionLabel(),
view.title(),
view.workflowStatus(),
at(view.updatedAt()),
view.version(),
publication(view.firstPublishedAt(), view.lastPublishedAt()));
item.setReleasedOn(view.releasedOn());
return item;
}
public static ReleaseIndexPage releases(ListStudioReleasesUseCase.Page page) {
return new ReleaseIndexPage(
page.items().stream().map(ManagementResponseMapper::releaseIndexItem).toList(),
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
}
/** 홈 focus 설정. 세 슬롯은 비어 있을 수 있고, 비어 있음이 곧 "고르지 않았다"는 뜻이다. */
public static HomeFocusResponse homeFocus(HomeFocusConfigView view) {
HomeFocusResponse response = new HomeFocusResponse(view.id(), view.version());
response.setDefaultType(view.defaultType());
response.setCurrentProjectId(view.currentProjectId());
response.setOpenQuestionId(view.openQuestionId());
response.setRecentDecisionId(view.recentDecisionId());
return response;
}
/** 프로젝트 게시 결과. canonical path 는 공개 조회가 쓰는 {@code /projects/<slug>} 와 같은 규칙으로 만든다. */
public static PublishResponse projectPublication(ProjectEditView view) {
return new PublishResponse(
view.id(),
PublishResponse.StatusEnum.PUBLISHED,
"UNLISTED".equals(view.targetVisibility())
? PublishResponse.VisibilityEnum.UNLISTED
: PublishResponse.VisibilityEnum.PUBLIC,
"/projects/" + view.slug(),
at(view.lastPublishedAt()),
view.version());
}
/** 프로젝트 활동 한 줄. {@code origin} 을 그대로 실어 보낸다 — 화면이 지울 수 있는 줄과 아닌 줄을 그것으로 가른다. */
public static ProjectActivityResponse projectActivity(ProjectActivityView view) {
ProjectActivityResponse response =
new ProjectActivityResponse(
view.id(),
view.projectId(),
view.activityType(),
view.title(),
view.visibility(),
view.origin(),
at(view.occurredAt()),
view.version());
response.setSummary(view.summary());
response.setRelatedResourceType(view.relatedResourceType());
response.setRelatedResourceId(view.relatedResourceId());
return response;
}
public static java.util.List<ProjectActivityResponse> projectActivities(
java.util.List<ProjectActivityView> views) {
return views.stream().map(ManagementResponseMapper::projectActivity).toList();
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
/**
* 공개 조회 실패의 client-safe {@code error.message} 단일 출처.
*
* <p>{@code PublicException#getMessage()}는 use case 가 진단용으로 채우는 원문이라 {@code ApiErrorCarrier}
* javadoc 이 경고하는 대로 저장소 내부 사정을 실을 수 있다. 그래서 응답에는 절대 흘리지 않고 이 클래스가 code 별 고정 문구만 내보낸다 — {@code
* StudioClientSafeMessages}가 {@code StudioError}에 대해 하는 것과 같은 역할이다.
*
* <p>문구는 {@code docs/registries/error-codes.yaml}의 각 row {@code client_safe_message}와 정확히 같아야 한다 —
* {@code PublicErrorRegistryTest}가 그 일치를 고정한다.
*
* <p>{@link PublicError}를 exhaustive switch 로 매핑하므로(default 없음) 새 상수를 추가하면 이 파일도 컴파일 타임에 고쳐야 한다 —
* 문구 누락이 생길 수 없다.
*/
public final class PublicClientSafeMessages {
private PublicClientSafeMessages() {}
public static String forError(PublicError error) {
return switch (error) {
case PUBLIC_REQUEST_INVALID -> "요청 값이 올바르지 않습니다";
case PUBLIC_RESOURCE_NOT_FOUND -> "요청한 자료를 찾을 수 없습니다";
};
}
}
@@ -0,0 +1,94 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi;
import dev.caskeleton.adapter.inbound.web.error.ErrorResponseFactory;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
import dev.caskeleton.application.techlog.publicsite.error.PublicException;
import dev.caskeleton.shared.response.Envelope;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* 공개 조회 실패를 스켈레톤 봉투로 옮긴다. 템플릿의 {@code GlobalExceptionHandler}를 수정하지 않기 위해 별도 advice 로 둔다 — 그 파일은
* template sync 대상이다.
*
* <p><b>{@code basePackages} 스코프.</b> 이 advice 는 {@code
* dev.caskeleton.adapter.inbound.web.techlog.publicapi} 아래의 컨트롤러에만 적용된다. 형제인 {@code
* StudioExceptionHandler}가 원래 {@code ...web.techlog} 전체를 잡고 있었는데, 그 스코프는 이 패키지까지 포함하므로 공개 조회의 파라미터
* 오류가 Studio 계약의 {@code REQUEST_VALIDATION_FAILED}(422)로 나갔을 것이다 — public-v1 계약의 {@code
* ApiError.code} enum 에 없는 코드라 프론트엔드의 응답 파싱 자체가 깨진다. 그래서 이 advice 를 추가하면서 Studio 쪽 스코프를 {@code
* ...web.techlog.studio}로 좁혔다. 두 스코프는 이제 겹치지 않는다.
*
* <p>{@code error.message}에는 {@link PublicClientSafeMessages}가 주는 code 별 고정 문구만 싣는다 — {@link
* PublicException#getMessage()}(진단용 원문)는 그대로 내보내지 않는다({@code ApiErrorCarrier} javadoc). 원문은 버리지 않고
* 서버 로그에만 남긴다.
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "dev.caskeleton.adapter.inbound.web.techlog.publicapi")
public class PublicExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(PublicExceptionHandler.class);
/**
* 공개 조회는 인증이 없고 열람자가 익명이다. 없는 slug 하나하나를 ERROR 로 남기면 크롤러가 만드는 404 가 로그를 덮어 실제 장애를 가린다 — {@code
* NOT_FOUND}는 WARN 이하로 남기고 나머지만 ERROR 로 올린다.
*/
@ExceptionHandler(PublicException.class)
public ResponseEntity<Envelope<Void>> handlePublic(PublicException ex) {
PublicError error = ex.publicError();
if (error == PublicError.PUBLIC_RESOURCE_NOT_FOUND) {
log.debug("public resource not found: {}", ex.getMessage());
} else {
log.warn(
"public request rejected as {} (category={}): {}",
error.code(),
error.category(),
ex.getMessage());
}
return ErrorResponseFactory.envelope(error, PublicClientSafeMessages.forError(error), null);
}
/**
* 필수 쿼리 파라미터 누락 — 계약에서 {@code GET /v1/public/search}의 {@code q}가 유일하다. 이 예외를 그냥 두면 부모 {@code
* ResponseEntityExceptionHandler}가 bare {@code ProblemDetail}(content-type {@code
* application/problem+json})을 만들고, {@code EnvelopeBodyAdvice}의 JSON 미디어타입 검사에 걸려 봉투를 못 씌운다 —
* ADR-006 이 쓰지 않기로 한 RFC 7807 이 그대로 나간다.
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Envelope<Void>> handleMissingParameter(
MissingServletRequestParameterException ex) {
return requestInvalid(ex.getParameterName(), "REQUIRED", "Required parameter is missing");
}
/**
* 쿼리 파라미터 타입 불일치(예: {@code page=abc}, {@code year=x}). {@code GlobalExceptionHandler}도 이 예외를
* 처리하지만 {@code OperationalError.BAD_PARAMETER}를 낸다 — public-v1 계약의 세 코드에 없다.
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Envelope<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return requestInvalid(ex.getName(), "TYPE_MISMATCH", "Parameter value is invalid");
}
/**
* {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{field, code,
* message}]}) 모양에 맞춰 싣는다. 세 필드 전부 {@code required}이므로 하나라도 빠지면 계약 위반이다 — Studio 계약의 {@code {path,
* message}}와 모양이 다르니 그 코드를 복사해 오면 안 된다.
*/
private static ResponseEntity<Envelope<Void>> requestInvalid(
String field, String code, String message) {
Map<String, Object> fieldError = Map.of("field", field, "code", code, "message", message);
Map<String, Object> details = Map.of("fieldErrors", List.of(fieldError));
return ErrorResponseFactory.envelope(
PublicError.PUBLIC_REQUEST_INVALID,
PublicClientSafeMessages.forError(PublicError.PUBLIC_REQUEST_INVALID),
details);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.DocumentResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* 문서 상세 세 종류. 계약 {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion}.
*/
@RestController
public class PublicDocumentController {
private final GetPublicCaseUseCase getCase;
private final GetPublicReferenceUseCase getReference;
private final GetPublicQuestionUseCase getQuestion;
public PublicDocumentController(
GetPublicCaseUseCase getCase,
GetPublicReferenceUseCase getReference,
GetPublicQuestionUseCase getQuestion) {
this.getCase = getCase;
this.getReference = getReference;
this.getQuestion = getQuestion;
}
@GetMapping("/v1/public/cases/{slug}")
public CaseDetailResponse getPublicCase(@PathVariable("slug") String slug) {
return DocumentResponseMapper.caseDetail(getCase.handle(new SlugQuery(slug)));
}
@GetMapping("/v1/public/references/{slug}")
public ReferenceDetailResponse getPublicReference(@PathVariable("slug") String slug) {
return DocumentResponseMapper.referenceDetail(getReference.handle(new SlugQuery(slug)));
}
@GetMapping("/v1/public/questions/{slug}")
public QuestionDetailResponse getPublicQuestion(@PathVariable("slug") String slug) {
return DocumentResponseMapper.questionDetail(getQuestion.handle(new SlugQuery(slug)));
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgePage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ExploreResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase;
import java.util.Set;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 탐색과 검색. 계약 {@code exploreKnowledge} / {@code exploreQuestions} / {@code searchPublicResources}.
*
* <p>계약이 enum 을 선언한 파라미터는 {@link PublicRequestParams} 로 검사한다 — 이유는 그 클래스 javadoc.
*/
@RestController
public class PublicExploreController {
private static final Set<String> KNOWLEDGE_TYPES = Set.of("CASE", "REFERENCE");
private static final Set<String> KNOWLEDGE_SORTS =
Set.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC");
private static final Set<String> QUESTION_STATUSES =
Set.of("OPEN", "INVESTIGATING", "PAUSED", "RESOLVED");
private static final Set<String> QUESTION_SORTS =
Set.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC");
private static final Set<String> SEARCH_TYPES =
Set.of("CASE", "REFERENCE", "QUESTION", "PROJECT", "RELEASE");
private final ExploreKnowledgeUseCase exploreKnowledge;
private final ExploreQuestionsUseCase exploreQuestions;
private final SearchPublicResourcesUseCase search;
public PublicExploreController(
ExploreKnowledgeUseCase exploreKnowledge,
ExploreQuestionsUseCase exploreQuestions,
SearchPublicResourcesUseCase search) {
this.exploreKnowledge = exploreKnowledge;
this.exploreQuestions = exploreQuestions;
this.search = search;
}
@GetMapping("/v1/public/explore/knowledge")
public KnowledgePage exploreKnowledge(
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "topic", required = false) String topic,
@RequestParam(value = "project", required = false) String project,
@RequestParam(value = "tag", required = false) String tag,
@RequestParam(value = "year", required = false) Integer year,
@RequestParam(value = "sort", required = false) String sort,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
ExploreKnowledgeQuery query =
new ExploreKnowledgeQuery(
PublicRequestParams.oneOf("type", type, KNOWLEDGE_TYPES),
topic,
project,
tag,
PublicRequestParams.year(year),
PublicRequestParams.sort("sort", sort, "PUBLISHED_DESC", KNOWLEDGE_SORTS),
PublicRequestParams.page(page, size));
return ExploreResponseMapper.knowledge(exploreKnowledge.handle(query));
}
@GetMapping("/v1/public/explore/questions")
public QuestionPage exploreQuestions(
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "topic", required = false) String topic,
@RequestParam(value = "project", required = false) String project,
@RequestParam(value = "tag", required = false) String tag,
@RequestParam(value = "sort", required = false) String sort,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
ExploreQuestionsQuery query =
new ExploreQuestionsQuery(
PublicRequestParams.oneOf("status", status, QUESTION_STATUSES),
topic,
project,
tag,
PublicRequestParams.sort("sort", sort, "UPDATED_DESC", QUESTION_SORTS),
PublicRequestParams.page(page, size));
return ExploreResponseMapper.questions(exploreQuestions.handle(query));
}
@GetMapping("/v1/public/search")
public SearchResultPage searchPublicResources(
@RequestParam("q") String q,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "topic", required = false) String topic,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
SearchQuery query =
new SearchQuery(
PublicRequestParams.searchTerm(q),
PublicRequestParams.oneOf("type", type, SEARCH_TYPES),
topic,
PublicRequestParams.page(page, size));
return ExploreResponseMapper.search(search.handle(query));
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.application.techlog.publicsite.model.PublicMediaView;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase;
import java.util.UUID;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* 업로드한 이미지를 실제로 내보내는 곳.
*
* <p>이 경로가 없어서 Asset 은 저장은 되지만 어디에서도 보이지 않았다 — 문서와 공개 화면이 모두 asset 주소를 가리키는데 그 주소를 서빙하는 코드가 없었다.
*
* <p>{@code /api/v1/public/} 아래에 두는 이유는 그 접두사가 이미 익명 허용({@code security.public-paths})이고 엣지가 이미
* 백엔드로 보내기 때문이다. 별도 접두사를 쓰면 nginx 를 함께 고쳐야 하고, 그 사이에 이미지는 계속 깨진다.
*
* <p>이 표면은 봉투를 쓰지 않는다. 바이트를 반환하므로 {@code EnvelopeBodyAdvice} 가 감쌀 것이 없고, {@code <img src>} 는 JSON 을
* 읽지 않는다.
*/
@RestController
public class PublicMediaController {
/** 내용이 바뀌면 주소도 바뀐다 — Asset 은 업로드 시점에 고정되고 같은 id 로 다른 바이트가 오지 않는다. 그래서 길게 캐시하고 immutable 을 붙인다. */
private static final CacheControl CACHE =
CacheControl.maxAge(java.time.Duration.ofDays(365)).cachePublic().immutable();
private final GetPublicMediaUseCase media;
public PublicMediaController(GetPublicMediaUseCase media) {
this.media = media;
}
@GetMapping("/v1/public/media/{assetId}")
public ResponseEntity<byte[]> getPublicMedia(@PathVariable("assetId") UUID assetId) {
PublicMediaView view = media.handle(assetId);
return ResponseEntity.ok()
.cacheControl(CACHE)
.contentType(MediaType.parseMediaType(view.mediaType()))
.contentLength(view.content().length)
.body(view.content());
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ProjectResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase;
import java.util.Set;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 프로젝트 허브. 계약 {@code listPublicProjects} / {@code getPublicProject} 와 하위 목록 셋({@code
* listPublicProjectDecisions} / {@code listPublicProjectRecords} / {@code
* listPublicProjectActivities}).
*
* <p>하위 목록은 프로젝트 자체가 공개가 아니면 빈 페이지가 아니라 404 다 — 비공개 프로젝트의 존재가 "결정이 0건인 프로젝트"로 새어 나가면 안 된다. 그 구분은
* port 가 {@code Optional} 로 표현하고 use case 가 404 로 옮긴다.
*/
@RestController
public class PublicProjectController {
private static final Set<String> RECORD_TYPES = Set.of("CASE", "REFERENCE", "QUESTION");
private static final Set<String> RECORD_RELATIONS = Set.of("PRIMARY", "RELATED");
private final ListPublicProjectsUseCase listProjects;
private final GetPublicProjectUseCase getProject;
private final ListPublicProjectDecisionsUseCase listDecisions;
private final ListPublicProjectRecordsUseCase listRecords;
private final ListPublicProjectActivitiesUseCase listActivities;
public PublicProjectController(
ListPublicProjectsUseCase listProjects,
GetPublicProjectUseCase getProject,
ListPublicProjectDecisionsUseCase listDecisions,
ListPublicProjectRecordsUseCase listRecords,
ListPublicProjectActivitiesUseCase listActivities) {
this.listProjects = listProjects;
this.getProject = getProject;
this.listDecisions = listDecisions;
this.listRecords = listRecords;
this.listActivities = listActivities;
}
@GetMapping("/v1/public/projects")
public ProjectListResponse listPublicProjects() {
return ProjectResponseMapper.list(listProjects.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/projects/{slug}")
public ProjectDetailResponse getPublicProject(@PathVariable("slug") String slug) {
return ProjectResponseMapper.detail(getProject.handle(new SlugQuery(slug)));
}
@GetMapping("/v1/public/projects/{slug}/decisions")
public ProjectDecisionPage listPublicProjectDecisions(
@PathVariable("slug") String slug,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
return ProjectResponseMapper.decisions(
listDecisions.handle(
new ProjectDecisionPageQuery(slug, status, PublicRequestParams.page(page, size))));
}
@GetMapping("/v1/public/projects/{slug}/records")
public ProjectRecordPage listPublicProjectRecords(
@PathVariable("slug") String slug,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "relation", required = false) String relation,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
ProjectRecordPageQuery query =
new ProjectRecordPageQuery(
slug,
PublicRequestParams.oneOf("type", type, RECORD_TYPES),
PublicRequestParams.oneOf("relation", relation, RECORD_RELATIONS),
PublicRequestParams.page(page, size));
return ProjectResponseMapper.records(listRecords.handle(query));
}
@GetMapping("/v1/public/projects/{slug}/activities")
public ProjectActivityPage listPublicProjectActivities(
@PathVariable("slug") String slug,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "20") int size) {
return ProjectResponseMapper.activities(
listActivities.handle(new ProjectPageQuery(slug, PublicRequestParams.page(page, size))));
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.ReleaseResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* Tech Log 자체 변경 기록. 계약 {@code listPublicReleases} / {@code getPublicRelease}.
*
* <p>{@code getPublicRelease} 의 path 변수는 slug 가 아니라 {@code version} 이다 — {@code SlugQuery} 를 그대로 쓰되
* 어댑터가 {@code release.version} 으로 조회한다({@code PublicReleaseQueryPort#findByVersion}). 값의 의미가 다르므로
* 이름을 그대로 옮겨 적는다.
*/
@RestController
public class PublicReleaseController {
private final ListPublicReleasesUseCase listReleases;
private final GetPublicReleaseUseCase getRelease;
public PublicReleaseController(
ListPublicReleasesUseCase listReleases, GetPublicReleaseUseCase getRelease) {
this.listReleases = listReleases;
this.getRelease = getRelease;
}
@GetMapping("/v1/public/releases")
public ReleaseListResponse listPublicReleases() {
return ReleaseResponseMapper.list(listReleases.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/releases/{version}")
public ReleaseDetailResponse getPublicRelease(@PathVariable("version") String version) {
return ReleaseResponseMapper.detail(getRelease.handle(new SlugQuery(version)));
}
}
@@ -0,0 +1,65 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
import dev.caskeleton.application.techlog.publicsite.error.PublicException;
import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest;
import java.util.List;
import java.util.Set;
/**
* 계약이 쿼리 파라미터에 건 제약을 요청 경계에서 강제한다.
*
* <p>enum 값을 검사하지 않고 그대로 SQL 필터로 넘기면 오타(`type=CASES`)가 오류가 아니라 "결과 0건"으로 보인다 — 소비자는 자기 요청이 틀렸다는 사실을
* 영영 알 수 없다. 계약이 enum 을 선언한 자리는 계약 밖 값을 {@code PUBLIC_REQUEST_INVALID} 로 거절한다.
*
* <p>파라미터를 생성 DTO 의 enum 타입으로 바인딩하지 않는 이유는, 그 경우 Spring 이 던지는 {@code
* MethodArgumentTypeMismatchException} 이 "어떤 값이 허용되는지"를 응답에 남기지 못하고 스택 상위에서 잡히기 때문이다. 여기서 검사하면 거절
* 이유를 계약의 {@code fieldErrors} 모양으로 정확히 실을 수 있다.
*/
final class PublicRequestParams {
private PublicRequestParams() {}
static PublicPageRequest page(int page, int size) {
return new PublicPageRequest(page, size);
}
/** null(=필터 없음)은 통과시키고, 값이 있으면 계약의 허용 집합에 있어야 한다. */
static String oneOf(String field, String value, Set<String> allowed) {
if (value == null) {
return null;
}
if (!allowed.contains(value)) {
throw PublicException.of(
PublicError.PUBLIC_REQUEST_INVALID,
field + " must be one of " + List.copyOf(allowed) + " but was '" + value + "'");
}
return value;
}
/** 값이 없으면 계약의 default 를 쓴다 — 정렬은 optional 이지만 항상 하나로 정해져야 한다. */
static String sort(String field, String value, String fallback, Set<String> allowed) {
return value == null ? fallback : oneOf(field, value, allowed);
}
/** 계약 {@code searchPublicResources.q}: minLength 1 / maxLength 100. */
static String searchTerm(String q) {
String trimmed = q == null ? "" : q.strip();
if (trimmed.isEmpty()) {
throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "q must not be blank");
}
if (trimmed.length() > 100) {
throw PublicException.of(
PublicError.PUBLIC_REQUEST_INVALID, "q must be at most 100 characters");
}
return trimmed;
}
/** 계약 {@code exploreKnowledge.year}: minimum 2000. */
static Integer year(Integer year) {
if (year != null && year < 2000) {
throw PublicException.of(PublicError.PUBLIC_REQUEST_INVALID, "year must be 2000 or later");
}
return year;
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.SiteResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 사이트 껍데기 · 홈 · 운영자 프로필. 계약 {@code getPublicSite} / {@code getPublicHome} / {@code
* getPublicProfile}.
*
* <p>반환값을 Envelope 로 감싸지 않는다 — {@code EnvelopeBodyAdvice} 가 감싼다. 계약의 {@code <Payload>Envelope} 스키마로
* 생성된 DTO 는 쓰지 않는다(그걸 반환하면 봉투가 두 번 씌워진다).
*
* <p>경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code
* ca-skeleton.presentation.api-base-path}("/api")를 모든 컨트롤러 매핑에 붙인다. 계약의 {@code servers} 가 {@code
* /api/v1/public} 이므로 여기 매핑은 {@code /v1/public/...} 이어야 최종 주소가 계약과 같아진다.
*/
@RestController
public class PublicSiteController {
private final GetPublicSiteUseCase getSite;
private final GetPublicHomeUseCase getHome;
private final GetPublicProfileUseCase getProfile;
public PublicSiteController(
GetPublicSiteUseCase getSite,
GetPublicHomeUseCase getHome,
GetPublicProfileUseCase getProfile) {
this.getSite = getSite;
this.getHome = getHome;
this.getProfile = getProfile;
}
@GetMapping("/v1/public/site")
public SiteResponse getPublicSite() {
return SiteResponseMapper.site(getSite.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/home")
public HomeResponse getPublicHome() {
return SiteResponseMapper.home(getHome.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/profile")
public ProfileResponse getPublicProfile() {
return SiteResponseMapper.profile(getProfile.handle(new EmptyQuery()));
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.controller;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper.TopicResponseMapper;
import dev.caskeleton.application.techlog.publicsite.query.EmptyQuery;
import dev.caskeleton.application.techlog.publicsite.query.SlugQuery;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/** 주제 목록과 상세. 계약 {@code listPublicTopics} / {@code getPublicTopic}. */
@RestController
public class PublicTopicController {
private final ListPublicTopicsUseCase listTopics;
private final GetPublicTopicUseCase getTopic;
public PublicTopicController(ListPublicTopicsUseCase listTopics, GetPublicTopicUseCase getTopic) {
this.listTopics = listTopics;
this.getTopic = getTopic;
}
@GetMapping("/v1/public/topics")
public TopicListResponse listPublicTopics() {
return TopicResponseMapper.list(listTopics.handle(new EmptyQuery()));
}
@GetMapping("/v1/public/topics/{topicSlug}")
public TopicDetailResponse getPublicTopic(@PathVariable("topicSlug") String topicSlug) {
return TopicResponseMapper.detail(getTopic.handle(new SlugQuery(topicSlug)));
}
}
@@ -0,0 +1,180 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseCase;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CaseDetailResponseRelations;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestion;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseQuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionDetailResponseRelations;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPointGroup;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionUpdatePublic;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseReferenceRulesInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReferenceDetailResponseRelations;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
/**
* {@code getPublicCase} / {@code getPublicReference} / {@code getPublicQuestion} 의 응답 조립.
*
* <p>Case 와 Reference 는 같은 {@link PublishedDocumentView} 를 읽지만 계약이 요약 필드를 서로 다르게 이름 붙였다 — Case 는
* {@code problemSummary}/{@code conclusionSummary}, Reference 는 {@code scopeSummary} 다. view 는
* {@code primarySummary}/{@code secondarySummary} 라는 중립 이름을 쓰고 그 매핑을 여기서 한 번만 한다. ADR-003 이 말하는
* "API 용어와 Domain 용어 분리"가 이 자리다.
*/
public final class DocumentResponseMapper {
private DocumentResponseMapper() {}
public static CaseDetailResponse caseDetail(CaseDetailView view) {
PublishedDocumentView doc = view.document();
CaseDetailResponseCase body = new CaseDetailResponseCase();
body.setTitle(doc.title());
body.setSummary(doc.summary());
body.setProblemSummary(doc.primarySummary());
body.setConclusionSummary(doc.secondarySummary());
body.setEnvironmentSummary(doc.environmentSummary());
body.setContent(doc.content());
body.setContentFormat(CaseDetailResponseCase.ContentFormatEnum.fromValue(doc.contentFormat()));
body.setContentFormatVersion(doc.contentFormatVersion());
body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic()));
body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag));
body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject()));
body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset()));
body.setBodyAssets(PublicResponseMapper.map(doc.bodyAssets(), PublicResponseMapper::bodyAsset));
body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt()));
body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt()));
body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt()));
CaseDetailResponseRelations relations = new CaseDetailResponseRelations();
relations.setOriginQuestion(PublicResponseMapper.related(view.relations().originQuestion()));
relations.setProjectDecisions(
PublicResponseMapper.relatedList(view.relations().projectDecisions()));
relations.setDerivedReferences(
PublicResponseMapper.relatedList(view.relations().derivedReferences()));
relations.setRelatedCases(PublicResponseMapper.relatedList(view.relations().relatedCases()));
CaseDetailResponse dto = new CaseDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setCase(body);
dto.setRelations(relations);
return dto;
}
public static ReferenceDetailResponse referenceDetail(ReferenceDetailView view) {
PublishedDocumentView doc = view.document();
ReferenceDetailResponseReference body = new ReferenceDetailResponseReference();
body.setTitle(doc.title());
body.setSummary(doc.summary());
body.setScopeSummary(doc.primarySummary());
body.setAppliesTo(doc.appliesTo());
body.setExcludedScope(doc.excludedScope());
// Reference 의 본문은 `content` 가 아니라 여기 있다 — 이것을 빼면 공개 화면에 판단 기준과
// 예시가 통째로 빠진다.
body.setRules(
doc.rules().stream()
.map(rule -> new ReferenceDetailResponseReferenceRulesInner(rule.title(), rule.body()))
.toList());
body.setExamples(doc.examples());
body.setFreshnessStatus(
ReferenceDetailResponseReference.FreshnessStatusEnum.fromValue(doc.freshnessStatus()));
body.setContent(doc.content());
body.setContentFormat(
ReferenceDetailResponseReference.ContentFormatEnum.fromValue(doc.contentFormat()));
body.setContentFormatVersion(doc.contentFormatVersion());
body.setPrimaryTopic(PublicResponseMapper.topic(doc.primaryTopic()));
body.setTags(PublicResponseMapper.map(doc.tags(), PublicResponseMapper::tag));
body.setPrimaryProject(PublicResponseMapper.project(doc.primaryProject()));
body.setCoverAsset(PublicResponseMapper.asset(doc.coverAsset()));
body.setPublishedAt(PublicResponseMapper.at(doc.publishedAt()));
body.setUpdatedAt(PublicResponseMapper.at(doc.updatedAt()));
body.setLastVerifiedAt(PublicResponseMapper.at(doc.lastVerifiedAt()));
ReferenceDetailResponseRelations relations = new ReferenceDetailResponseRelations();
relations.setSupportingCases(
PublicResponseMapper.relatedList(view.relations().supportingCases()));
relations.setRelatedDecisions(
PublicResponseMapper.relatedList(view.relations().relatedDecisions()));
relations.setRelatedReferences(
PublicResponseMapper.relatedList(view.relations().relatedReferences()));
ReferenceDetailResponse dto = new ReferenceDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setReference(body);
dto.setRelations(relations);
return dto;
}
public static QuestionDetailResponse questionDetail(QuestionDetailView view) {
PublishedQuestionView q = view.question();
QuestionDetailResponseQuestion body = new QuestionDetailResponseQuestion();
body.setQuestion(q.question());
body.setSummary(q.summary());
body.setContext(q.context());
body.setImportance(q.importance());
body.setStatus(QuestionDetailResponseQuestion.StatusEnum.fromValue(q.status()));
body.setNextVerification(q.nextVerification());
body.setPoints(points(q.points()));
body.setUpdates(PublicResponseMapper.map(q.updates(), DocumentResponseMapper::update));
body.setResolution(resolution(q));
body.setOpenedAt(PublicResponseMapper.at(q.openedAt()));
body.setUpdatedAt(PublicResponseMapper.at(q.updatedAt()));
QuestionDetailResponseRelations relations = new QuestionDetailResponseRelations();
relations.setPrimaryProject(PublicResponseMapper.related(view.relations().primaryProject()));
relations.setResultCase(PublicResponseMapper.related(view.relations().resultCase()));
relations.setProducedDecision(
PublicResponseMapper.related(view.relations().producedDecision()));
relations.setDerivedReferences(
PublicResponseMapper.relatedList(view.relations().derivedReferences()));
QuestionDetailResponse dto = new QuestionDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setQuestion(body);
dto.setRelations(relations);
return dto;
}
private static QuestionPointGroup points(QuestionPointGroupView view) {
QuestionPointGroup dto = new QuestionPointGroup();
dto.setFacts(view.facts());
dto.setAssumptions(view.assumptions());
dto.setUnknowns(view.unknowns());
dto.setConstraints(view.constraints());
return dto;
}
private static QuestionUpdatePublic update(QuestionUpdateView view) {
QuestionUpdatePublic dto = new QuestionUpdatePublic();
dto.setType(view.type());
dto.setTitle(view.title());
dto.setBodyMarkdown(view.bodyMarkdown());
dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt()));
return dto;
}
/**
* 계약은 해결 정보를 별도 nullable object 로 묶었고 view 는 평평하게 들고 있다. 세 값이 전부 비어 있으면 빈 껍데기 object 대신 아예 내보내지
* 않는다 — 미해결 질문에 {@code resolution: {}} 이 붙으면 소비자가 "해결됐지만 내용이 없다"로 읽는다.
*/
private static QuestionDetailResponseQuestionResolution resolution(PublishedQuestionView q) {
if (q.resolutionType() == null && q.resolutionSummary() == null && q.resolvedAt() == null) {
return null;
}
QuestionDetailResponseQuestionResolution dto = new QuestionDetailResponseQuestionResolution();
dto.setType(q.resolutionType());
dto.setSummary(q.resolutionSummary());
dto.setResolvedAt(PublicResponseMapper.at(q.resolvedAt()));
return dto;
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgeListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.KnowledgePage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.QuestionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SearchResultPage;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
/**
* {@code exploreKnowledge} / {@code exploreQuestions} / {@code searchPublicResources} 의 응답 조립.
*
* <p>{@code fromValue} 는 계약 밖 값을 만나면 예외를 던진다. 그대로 둔다 — 여기서 조용히 null 을 넣으면 required 필드가 빈 채로 나가 소비자
* 쪽에서 더 늦게, 더 알기 어려운 모양으로 깨진다. 공개 projection 이 계약 밖 상태값을 담고 있다면 그건 데이터 결함이고 500 으로 드러나야 한다({@code
* INTERNAL_ERROR} 는 계약이 열거한 코드다).
*/
public final class ExploreResponseMapper {
private ExploreResponseMapper() {}
public static KnowledgePage knowledge(KnowledgePageView view) {
KnowledgePage dto = new KnowledgePage();
dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::knowledgeItem));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static KnowledgeListItem knowledgeItem(KnowledgeListItemView view) {
KnowledgeListItem dto = new KnowledgeListItem();
dto.setType(KnowledgeListItem.TypeEnum.fromValue(view.type()));
dto.setTitle(view.title());
dto.setPath(view.path());
dto.setPrimarySummary(view.primarySummary());
dto.setSecondarySummary(view.secondarySummary());
dto.setPrimaryTopic(PublicResponseMapper.topic(view.primaryTopic()));
dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject()));
dto.setPublishedAt(PublicResponseMapper.at(view.publishedAt()));
dto.setLastVerifiedAt(PublicResponseMapper.at(view.lastVerifiedAt()));
dto.setFreshnessStatus(view.freshnessStatus());
return dto;
}
public static QuestionPage questions(QuestionPageView view) {
QuestionPage dto = new QuestionPage();
dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::questionItem));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static QuestionListItem questionItem(QuestionListItemView view) {
QuestionListItem dto = new QuestionListItem();
dto.setQuestion(view.question());
dto.setPath(view.path());
dto.setStatus(QuestionListItem.StatusEnum.fromValue(view.status()));
dto.setSummary(view.summary());
dto.setCurrentUnderstanding(view.currentUnderstanding());
dto.setNextVerification(view.nextVerification());
dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject()));
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
public static SearchResultPage search(SearchResultPageView view) {
SearchResultPage dto = new SearchResultPage();
dto.setQuery(view.query());
dto.setItems(PublicResponseMapper.map(view.items(), ExploreResponseMapper::searchItem));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static SearchResultItem searchItem(SearchResultItemView view) {
SearchResultItem dto = new SearchResultItem();
dto.setContentType(view.contentType());
dto.setTitle(view.title());
dto.setPath(view.path());
dto.setSnippet(view.snippet());
dto.setMatchedFields(view.matchedFields());
dto.setPrimaryTopic(PublicResponseMapper.topic(view.primaryTopic()));
dto.setPrimaryProject(PublicResponseMapper.project(view.primaryProject()));
dto.setPublishedAt(PublicResponseMapper.at(view.publishedAt()));
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
}
@@ -0,0 +1,116 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectActivityPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDecisionPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectDetailResponseProject;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectListResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectRecordPage;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import java.util.List;
/** {@code listPublicProjects} 와 프로젝트 상세·하위 목록 세 개의 응답 조립. */
public final class ProjectResponseMapper {
private ProjectResponseMapper() {}
public static ProjectListResponse list(List<ProjectListItemView> views) {
ProjectListResponse dto = new ProjectListResponse();
dto.setItems(PublicResponseMapper.map(views, ProjectResponseMapper::listItem));
return dto;
}
private static ProjectListItem listItem(ProjectListItemView view) {
ProjectListItem dto = new ProjectListItem();
dto.setName(view.name());
dto.setSlug(view.slug());
dto.setPath(view.path());
dto.setOneLinePurpose(view.oneLinePurpose());
dto.setPhase(view.phase());
dto.setCurrentObjective(view.currentObjective());
dto.setNextStep(view.nextStep());
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
public static ProjectDetailResponse detail(ProjectDetailView view) {
PublishedProjectView p = view.project();
ProjectDetailResponseProject body = new ProjectDetailResponseProject();
body.setName(p.name());
body.setSlug(p.slug());
body.setOneLinePurpose(p.oneLinePurpose());
body.setPurpose(p.purpose());
body.setBoundary(p.boundary());
body.setPhase(p.phase());
body.setCurrentObjective(p.currentObjective());
body.setNextStep(p.nextStep());
body.setSystemOverviewMarkdown(p.systemOverviewMarkdown());
body.setTechnologies(p.technologies());
body.setTopics(
p.topics().stream().map(topic -> new TopicSummary(topic.name(), topic.slug())).toList());
body.setUpdatedAt(PublicResponseMapper.at(p.updatedAt()));
ProjectDetailResponse dto = new ProjectDetailResponse();
dto.setCanonicalPath(view.canonicalPath());
dto.setIndexable(view.indexable());
dto.setProject(body);
dto.setFeaturedDecision(PublicResponseMapper.related(view.featuredDecision()));
dto.setActiveQuestion(PublicResponseMapper.related(view.activeQuestion()));
dto.setSelectedRecords(PublicResponseMapper.relatedList(view.selectedRecords()));
return dto;
}
public static ProjectDecisionPage decisions(ProjectDecisionPageView view) {
ProjectDecisionPage dto = new ProjectDecisionPage();
dto.setItems(PublicResponseMapper.map(view.items(), ProjectResponseMapper::decision));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static ProjectDecisionItem decision(ProjectDecisionItemView view) {
ProjectDecisionItem dto = new ProjectDecisionItem();
dto.setId(view.id());
dto.setStatement(view.statement());
dto.setStatus(view.status());
dto.setRationaleSummary(view.rationaleSummary());
dto.setDecidedAt(PublicResponseMapper.at(view.decidedAt()));
dto.setSourceQuestion(PublicResponseMapper.related(view.sourceQuestion()));
dto.setSourceCase(PublicResponseMapper.related(view.sourceCase()));
return dto;
}
public static ProjectRecordPage records(ProjectRecordPageView view) {
ProjectRecordPage dto = new ProjectRecordPage();
dto.setItems(PublicResponseMapper.relatedList(view.items()));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
public static ProjectActivityPage activities(ProjectActivityPageView view) {
ProjectActivityPage dto = new ProjectActivityPage();
dto.setItems(PublicResponseMapper.map(view.items(), ProjectResponseMapper::activity));
dto.setPage(PublicResponseMapper.page(view.page()));
return dto;
}
private static ProjectActivityItem activity(ProjectActivityItemView view) {
ProjectActivityItem dto = new ProjectActivityItem();
dto.setType(view.type());
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setOccurredAt(PublicResponseMapper.at(view.occurredAt()));
dto.setRelatedPath(view.relatedPath());
return dto;
}
}
@@ -0,0 +1,158 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.AssetReference;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.BodyAsset;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ContactLink;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.LatestEntry;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.PageMetadata;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProjectSummary;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RelatedEntry;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TagSummary;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicSummary;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.BodyAssetView;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import java.net.URI;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.function.Function;
/** 여러 응답이 함께 쓰는 조각의 매핑. */
public final class PublicResponseMapper {
private PublicResponseMapper() {}
public static OffsetDateTime at(Instant instant) {
return instant == null ? null : instant.atOffset(ZoneOffset.UTC);
}
public static TopicSummary topic(TopicSummaryView view) {
if (view == null) {
return null;
}
TopicSummary dto = new TopicSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
return dto;
}
public static TagSummary tag(TagSummaryView view) {
TagSummary dto = new TagSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
return dto;
}
public static ProjectSummary project(ProjectSummaryView view) {
if (view == null) {
return null;
}
ProjectSummary dto = new ProjectSummary();
dto.setName(view.name());
dto.setSlug(view.slug());
dto.setPath(view.path());
return dto;
}
public static RelatedEntry related(RelatedEntryView view) {
if (view == null) {
return null;
}
RelatedEntry dto = new RelatedEntry();
dto.setType(RelatedEntry.TypeEnum.fromValue(view.type()));
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setPath(view.path());
return dto;
}
public static AssetReference asset(AssetReferenceView view) {
if (view == null) {
return null;
}
AssetReference dto = new AssetReference();
dto.setAssetId(view.assetId());
dto.setUrl(view.url());
dto.setAltText(view.altText());
dto.setWidth(view.width());
dto.setHeight(view.height());
dto.setContentType(view.contentType());
return dto;
}
/** 본문이 {@code :::evidence key="..."} 로 가리키는 Asset. key 에서 주소를 만들 수 없어 함께 내려보낸다. */
public static BodyAsset bodyAsset(BodyAssetView view) {
BodyAsset dto = new BodyAsset();
dto.setAssetKey(view.assetKey());
dto.setAssetId(view.assetId());
dto.setUrl(view.url());
dto.setContentType(view.contentType());
dto.setAltText(view.altText());
dto.setWidth(view.width());
dto.setHeight(view.height());
dto.setDecorative(view.decorative());
return dto;
}
public static ContactLink contact(ContactLinkView view) {
ContactLink dto = new ContactLink();
dto.setType(view.type());
dto.setLabel(view.label());
dto.setUrl(uri(view.url()));
return dto;
}
public static LatestEntry latest(LatestEntryView view) {
LatestEntry dto = new LatestEntry();
dto.setEntryType(LatestEntry.EntryTypeEnum.fromValue(view.entryType()));
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setPath(view.path());
dto.setPrimaryTopic(topic(view.primaryTopic()));
dto.setPrimaryProject(project(view.primaryProject()));
dto.setPublishedAt(at(view.publishedAt()));
return dto;
}
public static PageMetadata page(PageMetadataView view) {
PageMetadata dto = new PageMetadata();
dto.setNumber(view.number());
dto.setSize(view.size());
dto.setTotalElements(view.totalElements());
dto.setTotalPages(view.totalPages());
dto.setHasPrevious(view.hasPrevious());
dto.setHasNext(view.hasNext());
return dto;
}
/**
* 계약이 {@code format: uri} 로 선언한 자리. 저장된 값이 URI 로 파싱되지 않으면 그 링크를 내보내지 않는다 — 깨진 주소를 넣는 것보다 없는 편이
* 낫고, 소비자는 이 필드가 optional 임을 안다.
*/
static URI uri(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return URI.create(value);
} catch (IllegalArgumentException e) {
return null;
}
}
public static <S, T> List<T> map(List<S> source, Function<S, T> mapper) {
return source == null ? List.of() : source.stream().map(mapper).toList();
}
public static List<RelatedEntry> relatedList(List<RelatedEntryView> views) {
return map(views, PublicResponseMapper::related);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ReleaseListResponse;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import java.util.List;
/** {@code listPublicReleases} / {@code getPublicRelease} 의 응답 조립. */
public final class ReleaseResponseMapper {
private ReleaseResponseMapper() {}
public static ReleaseListResponse list(List<ReleaseListItemView> views) {
ReleaseListResponse dto = new ReleaseListResponse();
dto.setItems(PublicResponseMapper.map(views, ReleaseResponseMapper::item));
return dto;
}
private static ReleaseListItem item(ReleaseListItemView view) {
ReleaseListItem dto = new ReleaseListItem();
dto.setVersion(view.version());
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setReleasedOn(view.releasedOn());
dto.setChangeTypes(view.changeTypes());
dto.setPath(view.path());
return dto;
}
public static ReleaseDetailResponse detail(ReleaseDetailView view) {
ReleaseDetailResponse dto = new ReleaseDetailResponse();
dto.setVersion(view.version());
dto.setTitle(view.title());
dto.setSummary(view.summary());
dto.setReleasedOn(view.releasedOn());
dto.setChangeTypes(view.changeTypes());
dto.setReasonMarkdown(view.reasonMarkdown());
dto.setChangesMarkdown(view.changesMarkdown());
dto.setUserImpactMarkdown(view.userImpactMarkdown());
dto.setImplementationImpactMarkdown(view.implementationImpactMarkdown());
dto.setVerificationMarkdown(view.verificationMarkdown());
dto.setKnownLimitationsMarkdown(view.knownLimitationsMarkdown());
dto.setRelatedRecords(PublicResponseMapper.relatedList(view.relatedRecords()));
return dto;
}
}
@@ -0,0 +1,145 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.CurrentWorkFocus;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.HomeResponseFocus;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.OpenQuestionFocus;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponsePosition;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseTerritoriesInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseTrajectoryInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.ProfileResponseWorkingModelInner;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.RecentDecisionFocus;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponseBrand;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.SiteResponseOperator;
import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
/** {@code getPublicSite} / {@code getPublicHome} / {@code getPublicProfile} 의 응답 조립. */
public final class SiteResponseMapper {
private SiteResponseMapper() {}
public static SiteResponse site(SiteView view) {
SiteResponseBrand brand = new SiteResponseBrand();
brand.setTitle(view.brandTitle());
brand.setIdentityStatement(view.identityStatement());
SiteResponseOperator operator = new SiteResponseOperator();
operator.setDisplayName(view.operatorDisplayName());
operator.setShortIdentity(view.operatorShortIdentity());
operator.setAvatar(PublicResponseMapper.asset(view.operatorAvatar()));
operator.setProfilePath(view.operatorProfilePath());
SiteResponse dto = new SiteResponse();
dto.setBrand(brand);
dto.setOperator(operator);
dto.setContacts(PublicResponseMapper.map(view.contacts(), PublicResponseMapper::contact));
return dto;
}
public static HomeResponse home(HomeView view) {
HomeResponse dto = new HomeResponse();
dto.setFocus(focus(view.focus()));
dto.setLatestEntries(
PublicResponseMapper.map(view.latestEntries(), PublicResponseMapper::latest));
return dto;
}
private static HomeResponseFocus focus(HomeFocusView view) {
HomeResponseFocus dto = new HomeResponseFocus();
dto.setDefaultType(HomeResponseFocus.DefaultTypeEnum.fromValue(view.defaultType()));
dto.setCurrentWork(currentWork(view.currentWork()));
dto.setOpenQuestion(openQuestion(view.openQuestion()));
dto.setRecentDecision(recentDecision(view.recentDecision()));
return dto;
}
private static CurrentWorkFocus currentWork(HomeFocusView.CurrentWork view) {
if (view == null) {
return null;
}
CurrentWorkFocus dto = new CurrentWorkFocus();
dto.setProjectName(view.projectName());
dto.setProjectPath(view.projectPath());
dto.setPurpose(view.purpose());
dto.setPhase(CurrentWorkFocus.PhaseEnum.fromValue(view.phase()));
dto.setCurrentObjective(view.currentObjective());
dto.setNextStep(view.nextStep());
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
private static OpenQuestionFocus openQuestion(HomeFocusView.OpenQuestion view) {
if (view == null) {
return null;
}
OpenQuestionFocus dto = new OpenQuestionFocus();
dto.setQuestion(view.question());
dto.setQuestionPath(view.questionPath());
dto.setSummary(view.summary());
dto.setKnownFacts(view.knownFacts());
dto.setUnresolvedPoints(view.unresolvedPoints());
dto.setNextVerification(view.nextVerification());
dto.setUpdatedAt(PublicResponseMapper.at(view.updatedAt()));
return dto;
}
private static RecentDecisionFocus recentDecision(HomeFocusView.RecentDecision view) {
if (view == null) {
return null;
}
RecentDecisionFocus dto = new RecentDecisionFocus();
dto.setStatement(view.statement());
dto.setDecisionPath(view.decisionPath());
dto.setRationale(view.rationale());
dto.setConsequences(view.consequences());
dto.setDecidedAt(PublicResponseMapper.at(view.decidedAt()));
return dto;
}
public static ProfileResponse profile(ProfileView view) {
ProfileResponsePosition position = new ProfileResponsePosition();
position.setHeadline(view.headline());
position.setDescription(view.description());
ProfileResponse dto = new ProfileResponse();
dto.setPosition(position);
dto.setWorkingModel(
PublicResponseMapper.map(view.workingModel(), SiteResponseMapper::workingModel));
dto.setTerritories(PublicResponseMapper.map(view.territories(), SiteResponseMapper::territory));
dto.setSelectedEvidence(PublicResponseMapper.relatedList(view.selectedEvidence()));
dto.setTrajectory(PublicResponseMapper.map(view.trajectory(), SiteResponseMapper::trajectory));
dto.setContacts(PublicResponseMapper.map(view.contacts(), PublicResponseMapper::contact));
return dto;
}
private static ProfileResponseWorkingModelInner workingModel(ProfileView.NamedDescription view) {
ProfileResponseWorkingModelInner dto = new ProfileResponseWorkingModelInner();
dto.setName(view.name());
dto.setDescription(view.description());
return dto;
}
private static ProfileResponseTerritoriesInner territory(ProfileView.Territory view) {
ProfileResponseTerritoriesInner dto = new ProfileResponseTerritoriesInner();
dto.setName(view.name());
dto.setCurrentQuestion(view.currentQuestion());
dto.setTopicPath(view.topicPath());
return dto;
}
/**
* {@code trajectory} 의 계약 필드는 {@code title} 인데 view 는 {@code workingModel} 과 같은 {@code
* NamedDescription} 을 재사용한다 — 두 목록이 도메인적으로 같은 모양이라 record 를 나누지 않았고, 이름 차이는 여기서 흡수한다.
*/
private static ProfileResponseTrajectoryInner trajectory(ProfileView.NamedDescription view) {
ProfileResponseTrajectoryInner dto = new ProfileResponseTrajectoryInner();
dto.setTitle(view.name());
dto.setDescription(view.description());
return dto;
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.techlog.publicapi.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponse;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicDetailResponseTopic;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListItem;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model.TopicListResponse;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import java.util.List;
/** {@code listPublicTopics} / {@code getPublicTopic} 의 응답 조립. */
public final class TopicResponseMapper {
private TopicResponseMapper() {}
public static TopicListResponse list(List<TopicListItemView> views) {
TopicListResponse dto = new TopicListResponse();
dto.setItems(PublicResponseMapper.map(views, TopicResponseMapper::item));
return dto;
}
private static TopicListItem item(TopicListItemView view) {
TopicListItem dto = new TopicListItem();
dto.setName(view.name());
dto.setSlug(view.slug());
dto.setDescription(view.description());
dto.setRecordCount(view.recordCount());
return dto;
}
public static TopicDetailResponse detail(TopicDetailView view) {
TopicDetailResponseTopic topic = new TopicDetailResponseTopic();
topic.setName(view.name());
topic.setSlug(view.slug());
topic.setDescription(view.description());
topic.setScope(view.scope());
TopicDetailResponse dto = new TopicDetailResponse();
dto.setTopic(topic);
dto.setFeaturedReference(PublicResponseMapper.related(view.featuredReference()));
dto.setFeaturedCases(PublicResponseMapper.relatedList(view.featuredCases()));
dto.setActiveQuestions(PublicResponseMapper.relatedList(view.activeQuestions()));
dto.setRelatedProjects(PublicResponseMapper.relatedList(view.relatedProjects()));
dto.setLatestRecords(
PublicResponseMapper.map(view.latestRecords(), PublicResponseMapper::latest));
return dto;
}
}
@@ -0,0 +1,220 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UpdateAssetCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.DeleteAssetCommand;
import dev.caskeleton.application.techlog.studio.command.UploadAssetCommand;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetAssetQuery;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import dev.caskeleton.application.techlog.studio.service.DeleteStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioAssetsUseCase;
import dev.caskeleton.application.techlog.studio.service.UpdateStudioAssetUseCase;
import dev.caskeleton.application.techlog.studio.service.UploadStudioAssetUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.io.IOException;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 계약 {@code studio-v1.yaml}의 Assets 다섯 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioAssetController {
private final ListStudioAssetsUseCase listAssets;
private final UploadStudioAssetUseCase uploadAsset;
private final GetStudioAssetUseCase getAsset;
private final UpdateStudioAssetUseCase updateAsset;
private final DeleteStudioAssetUseCase deleteAsset;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioAssetController(
ListStudioAssetsUseCase listAssets,
UploadStudioAssetUseCase uploadAsset,
GetStudioAssetUseCase getAsset,
UpdateStudioAssetUseCase updateAsset,
DeleteStudioAssetUseCase deleteAsset,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.listAssets = listAssets;
this.uploadAsset = uploadAsset;
this.getAsset = getAsset;
this.updateAsset = updateAsset;
this.deleteAsset = deleteAsset;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/assets")
public AssetPage listStudioAssets(
@RequestParam(value = "q", required = false) String query,
@RequestParam(value = "kind", required = false) AssetKind kind,
@RequestParam(value = "managementStatus", required = false) AssetManagementStatus status,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
String fingerprint =
StudioCursors.fingerprint(
query,
kind == null ? null : kind.getValue(),
status == null ? null : status.getValue());
DocumentCursorPosition position =
cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false);
var page =
listAssets.handle(
new ListAssetsQuery(
query,
kind == null ? null : AssetKindView.valueOf(kind.getValue()),
status == null ? null : AssetManagementStatusView.valueOf(status.getValue()),
position == null ? null : position.updatedAt(),
position == null ? null : position.id(),
limit));
AssetPage body = detailMapper.toApi(page);
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping(value = "/v1/studio/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Asset> uploadStudioAsset(
@RequestParam("file") MultipartFile file,
@RequestParam("kind") AssetKind kind,
@RequestParam(value = "altText", required = false) String altText,
@RequestParam(value = "decorative", defaultValue = "false") boolean decorative,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
byte[] content;
try {
content = file.getBytes();
} catch (IOException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "the uploaded file could not be read");
}
StudioIdempotency.Outcome<Asset> outcome =
idempotency.run(
request,
"uploadStudioAsset",
// 바이트 전체가 아니라 파일명·크기·종류로 지문을 만든다 — 20MB 를 다시 직렬화해 해시하면
// 업로드마다 그만큼을 한 번 더 읽고 쓰는 셈이 된다.
java.util.List.of(
String.valueOf(file.getOriginalFilename()),
String.valueOf(file.getSize()),
kind.getValue()),
Asset.class,
() ->
detailMapper.toApi(
uploadAsset.handle(
new UploadAssetCommand(
file.getOriginalFilename(),
file.getContentType(),
content,
AssetKindView.valueOf(kind.getValue()),
altText,
decorative,
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/assets/{assetId}")
public AssetDetail getStudioAsset(@PathVariable("assetId") UUID assetId) {
return detailMapper.toApi(getAsset.handle(new GetAssetQuery(assetId)));
}
@PutMapping("/v1/studio/assets/{assetId}")
public ResponseEntity<Asset> updateStudioAsset(
@PathVariable("assetId") UUID assetId,
@Valid @RequestBody UpdateAssetCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<Asset> outcome =
idempotency.run(
request,
"updateStudioAsset",
command,
Asset.class,
() ->
detailMapper.toApi(
updateAsset.handle(
new dev.caskeleton.application.techlog.studio.command.UpdateAssetCommand(
assetId,
command.getExpectedVersion() == null
? 0L
: command.getExpectedVersion(),
command.getKind() == null
? null
: AssetKindView.valueOf(command.getKind().getValue()),
command.getAltText(),
command.getAltText() != null,
command.getDecorative(),
command.getManagementStatus() == null
? null
: AssetManagementStatusView.valueOf(
command.getManagementStatus().getValue()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@DeleteMapping("/v1/studio/assets/{assetId}")
public ResponseEntity<Void> deleteStudioAsset(
@PathVariable("assetId") UUID assetId,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
idempotency.run(
request,
"deleteStudioAsset",
assetId.toString(),
Void.class,
() -> {
deleteAsset.handle(new DeleteAssetCommand(assetId, StudioPrincipals.require(principal)));
return null;
});
// 계약은 204 다. 본문이 없으므로 EnvelopeBodyAdvice 도 감쌀 것이 없다.
return ResponseEntity.noContent().build();
}
}
@@ -29,7 +29,13 @@ public class StudioCatalogController {
this.listCatalog = listCatalog;
}
@GetMapping("/api/v1/studio/catalog")
/**
* 경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code
* ca-skeleton.presentation.api-base-path}("/api") 를 모든 컨트롤러 매핑에 붙인다. 이 저장소의 다른 컨트롤러들(healthcheck,
* /v1/uploads, /v1/files)과 같은 규칙이며, 여기에 {@code /api} 를 다시 쓰면 실제 경로가 {@code /api/api/v1/...} 로 밀려
* 계약(studio-v1.yaml, {@code servers: "/"})이 선언한 주소에서 사라진다.
*/
@GetMapping("/v1/studio/catalog")
public CatalogPage listStudioCatalog(
@RequestParam("type") CatalogEntryType type,
@RequestParam(value = "q", required = false) String q,
@@ -0,0 +1,226 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.SaveDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioRequestMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.command.CreateDocumentCommand;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetDocumentQuery;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import dev.caskeleton.application.techlog.studio.service.CreateStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioDocumentsUseCase;
import dev.caskeleton.application.techlog.studio.service.SaveOutcome;
import dev.caskeleton.application.techlog.studio.service.SaveStudioDocumentUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Documents 네 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 springdoc이 게시한 이름과 계약을 대조한다.
* 이름을 바꾸면 그 게이트가 빨간불이 된다.
*
* <p>반환값을 봉투로 감싸지 않는다 — {@code EnvelopeBodyAdvice}가 감싼다(ADR-006).
*
* <p>경로에 {@code /api}를 쓰지 않는다 — {@code PresentationWebConfig}가 {@code
* ca-skeleton.presentation.api-base-path}를 모든 매핑에 붙인다.
*/
@RestController
public class StudioDocumentController {
private final ListStudioDocumentsUseCase listDocuments;
private final CreateStudioDocumentUseCase createDocument;
private final GetStudioDocumentUseCase getDocument;
private final SaveStudioDocumentUseCase saveDocument;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioDocumentController(
ListStudioDocumentsUseCase listDocuments,
CreateStudioDocumentUseCase createDocument,
GetStudioDocumentUseCase getDocument,
SaveStudioDocumentUseCase saveDocument,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.listDocuments = listDocuments;
this.createDocument = createDocument;
this.getDocument = getDocument;
this.saveDocument = saveDocument;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/documents")
public DocumentPage listStudioDocuments(
@RequestParam(value = "q", required = false) String query,
@RequestParam(value = "kind", required = false) RecordKind kind,
@RequestParam(value = "publicationStatus", required = false)
PublicationStatus publicationStatus,
@RequestParam(value = "nextAction", required = false) NextAction nextAction,
@RequestParam(value = "projectId", required = false) UUID projectId,
@RequestParam(value = "sort", defaultValue = "UPDATED_DESC") String sort,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
DocumentSort documentSort = documentSort(sort);
// 지문에 정렬까지 넣는다 — 정렬만 바꾸고 커서를 재사용하면 커서가 가리키는 키의 의미가 달라진다.
String fingerprint =
StudioCursors.fingerprint(
query,
kind == null ? null : kind.getValue(),
publicationStatus == null ? null : publicationStatus.getValue(),
nextAction == null ? null : nextAction.getValue(),
projectId == null ? null : projectId.toString(),
documentSort.name());
DocumentCursorPosition position =
cursor == null || cursor.isBlank()
? null
: cursors.decode(cursor, fingerprint, documentSort == DocumentSort.TITLE_ASC);
DocumentPageView page =
listDocuments.handle(
new ListDocumentsQuery(
query,
kind == null
? null
: dev.caskeleton.application.techlog.studio.model.RecordKind.valueOf(
kind.getValue()),
publicationStatus == null
? null
: PublicationStatusView.valueOf(publicationStatus.getValue()),
nextAction == null
? null
: dev.caskeleton.application.techlog.studio.model.NextAction.valueOf(
nextAction.getValue()),
projectId,
documentSort,
position,
limit));
DocumentPage body = StudioResponseMapper.toApi(page);
// 어댑터가 준 것은 다음 쪽의 시작 위치일 뿐이다. 클라이언트에 나가는 것은 서명된 opaque 값이어야 한다.
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping("/v1/studio/documents")
public ResponseEntity<WorkingCopy> createStudioDocument(
@Valid @RequestBody WorkingCopyInput document,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<WorkingCopy> outcome =
idempotency.run(
request,
"createStudioDocument",
document,
WorkingCopy.class,
() -> {
WorkingCopyView created =
createDocument.handle(
new CreateDocumentCommand(
StudioRequestMapper.toApplication(document),
StudioPrincipals.require(principal)));
return StudioResponseMapper.toApi(created);
});
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/documents/{documentId}")
public WorkingCopyDetail getStudioDocument(@PathVariable("documentId") UUID documentId) {
return detailMapper.toApi(getDocument.handle(new GetDocumentQuery(documentId)));
}
@PutMapping("/v1/studio/documents/{documentId}")
public ResponseEntity<WorkingCopyDetail> saveStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody SaveDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<WorkingCopyDetail> outcome =
idempotency.run(
request,
"saveStudioDocument",
command,
WorkingCopyDetail.class,
() -> {
SaveOutcome saved =
saveDocument.handle(
new dev.caskeleton.application.techlog.studio.command.SaveDocumentCommand(
documentId,
command.getExpectedVersion() == null ? 0 : command.getExpectedVersion(),
StudioRequestMapper.toApplication(command.getDocument()),
StudioPrincipals.require(principal)));
return switch (saved) {
case SaveOutcome.Saved value -> detailMapper.toApi(value.detail());
case SaveOutcome.VersionConflict value -> throw versionConflict(value.latest());
};
});
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
/**
* 계약의 {@code VersionConflictDetails}는 {@code latestDocument}로 현재 상태 전체를 함께 준다 — 클라이언트가 다시 조회하지
* 않고도 충돌 화면을 그릴 수 있어야 한다.
*/
private StudioException versionConflict(WorkingCopyDetailView latest) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("latestDocument", detailMapper.toApi(latest));
return StudioException.withDetails(
StudioError.VERSION_CONFLICT, "expectedVersion does not match the stored version", details);
}
private static DocumentSort documentSort(String sort) {
try {
return DocumentSort.valueOf(sort);
} catch (IllegalArgumentException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"sort must be one of UPDATED_DESC, UPDATED_ASC, TITLE_ASC");
}
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CreatePreviewCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidateDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.studio.query.GetPreviewQuery;
import dev.caskeleton.application.techlog.studio.service.CreateStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.GetCurrentStudioPreviewUseCase;
import dev.caskeleton.application.techlog.studio.service.ValidateStudioDocumentUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Validation / Preview 세 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioPreviewController {
private final ValidateStudioDocumentUseCase validateDocument;
private final CreateStudioPreviewUseCase createPreview;
private final GetCurrentStudioPreviewUseCase getPreview;
private final StudioDetailMapper detailMapper;
private final StudioIdempotency idempotency;
public StudioPreviewController(
ValidateStudioDocumentUseCase validateDocument,
CreateStudioPreviewUseCase createPreview,
GetCurrentStudioPreviewUseCase getPreview,
StudioDetailMapper detailMapper,
StudioIdempotency idempotency) {
this.validateDocument = validateDocument;
this.createPreview = createPreview;
this.getPreview = getPreview;
this.detailMapper = detailMapper;
this.idempotency = idempotency;
}
@PostMapping("/v1/studio/documents/{documentId}/validate")
public ResponseEntity<ValidationReport> validateStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody ValidateDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<ValidationReport> outcome =
idempotency.run(
request,
"validateStudioDocument",
command,
ValidationReport.class,
() ->
detailMapper.toApi(
validateDocument.handle(
new dev.caskeleton.application.techlog.studio.command
.ValidateDocumentCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/documents/{documentId}/preview")
public PreviewDetail getCurrentStudioPreview(@PathVariable("documentId") UUID documentId) {
return detailMapper.toApi(getPreview.handle(new GetPreviewQuery(documentId)));
}
@PostMapping("/v1/studio/documents/{documentId}/preview")
public ResponseEntity<PublicPreview> createStudioPreview(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody CreatePreviewCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<PublicPreview> outcome =
idempotency.run(
request,
"createStudioPreview",
command,
PublicPreview.class,
() ->
detailMapper.toApi(
createPreview.handle(
new dev.caskeleton.application.techlog.studio.command.CreatePreviewCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
command.getValidationId(),
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
/**
* 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다 — 0 은 어떤 저장된 버전과도 일치하지 않아
* VERSION_CONFLICT 로 나간다.
*/
private static long expectedVersion(Integer value) {
return value == null ? 0L : value;
}
}
@@ -0,0 +1,183 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.http.ApiHeaders;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishDocumentCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnpublishCommand;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioDetailMapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioCursors;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioIdempotency;
import dev.caskeleton.adapter.inbound.web.techlog.studio.support.StudioPrincipals;
import dev.caskeleton.application.techlog.studio.command.UnpublishPublicationCommand;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.GetDashboardQuery;
import dev.caskeleton.application.techlog.studio.query.GetSnapshotQuery;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import dev.caskeleton.application.techlog.studio.service.GetStudioDashboardUseCase;
import dev.caskeleton.application.techlog.studio.service.GetStudioPublicationSnapshotUseCase;
import dev.caskeleton.application.techlog.studio.service.ListStudioPublicationsUseCase;
import dev.caskeleton.application.techlog.studio.service.PublishStudioDocumentUseCase;
import dev.caskeleton.application.techlog.studio.service.UnpublishStudioPublicationUseCase;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 계약 {@code studio-v1.yaml}의 Publication / Dashboard 다섯 operation.
*
* <p>메서드 이름이 곧 {@code operationId}다 — {@code StudioContractDriftTest}가 대조한다.
*/
@RestController
public class StudioPublicationController {
private final PublishStudioDocumentUseCase publishDocument;
private final UnpublishStudioPublicationUseCase unpublishPublication;
private final ListStudioPublicationsUseCase listPublications;
private final GetStudioPublicationSnapshotUseCase getSnapshot;
private final GetStudioDashboardUseCase getDashboard;
private final StudioDetailMapper detailMapper;
private final StudioCursors cursors;
private final StudioIdempotency idempotency;
public StudioPublicationController(
PublishStudioDocumentUseCase publishDocument,
UnpublishStudioPublicationUseCase unpublishPublication,
ListStudioPublicationsUseCase listPublications,
GetStudioPublicationSnapshotUseCase getSnapshot,
GetStudioDashboardUseCase getDashboard,
StudioDetailMapper detailMapper,
StudioCursors cursors,
StudioIdempotency idempotency) {
this.publishDocument = publishDocument;
this.unpublishPublication = unpublishPublication;
this.listPublications = listPublications;
this.getSnapshot = getSnapshot;
this.getDashboard = getDashboard;
this.detailMapper = detailMapper;
this.cursors = cursors;
this.idempotency = idempotency;
}
@GetMapping("/v1/studio/dashboard")
public StudioDashboard getStudioDashboard() {
return detailMapper.toApi(getDashboard.handle(new GetDashboardQuery()));
}
@PostMapping("/v1/studio/documents/{documentId}/publish")
public ResponseEntity<PublishResult> publishStudioDocument(
@PathVariable("documentId") UUID documentId,
@Valid @RequestBody PublishDocumentCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
// 게시 Event 에 최초 요청의 key 를 남긴다 — 재시도가 중복 Event 를 만들지 않았음을 이력에서
// 되짚을 수 있어야 한다(V7 publication_event.idempotency_key 주석).
String idempotencyKey = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY);
StudioIdempotency.Outcome<PublishResult> outcome =
idempotency.run(
request,
"publishStudioDocument",
command,
PublishResult.class,
() ->
detailMapper.toApi(
publishDocument.handle(
new dev.caskeleton.application.techlog.studio.command
.PublishDocumentCommand(
documentId,
expectedVersion(command.getExpectedVersion()),
command.getValidationId(),
command.getPreviewId(),
List.copyOf(command.getAcknowledgedWarningCodes()),
idempotencyKey,
StudioPrincipals.require(principal)))));
// 계약이 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());
}
@GetMapping("/v1/studio/publications")
public PublicationPage listStudioPublications(
@RequestParam(value = "type", required = false) PublicationEventType type,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "limit", defaultValue = "20") int limit) {
String fingerprint = StudioCursors.fingerprint(type == null ? null : type.getValue());
DocumentCursorPosition position =
cursor == null || cursor.isBlank() ? null : cursors.decode(cursor, fingerprint, false);
var page =
listPublications.handle(
new ListPublicationsQuery(
type == null ? null : PublicationEventTypeView.valueOf(type.getValue()),
position == null ? null : position.updatedAt(),
position == null ? null : position.id(),
limit));
PublicationPage body = detailMapper.toApi(page);
body.setNextCursor(
page.nextCursor() == null ? null : cursors.encode(page.nextCursor(), fingerprint));
return body;
}
@PostMapping("/v1/studio/publications/{publicationId}/unpublish")
public ResponseEntity<PublishResult> unpublishStudioPublication(
@PathVariable("publicationId") UUID publicationId,
@Valid @RequestBody UnpublishCommand command,
@AuthenticationPrincipal AuthenticatedPrincipal principal,
HttpServletRequest request) {
StudioIdempotency.Outcome<PublishResult> outcome =
idempotency.run(
request,
"unpublishStudioPublication",
command,
PublishResult.class,
() ->
detailMapper.toApi(
unpublishPublication.handle(
new UnpublishPublicationCommand(
publicationId,
expectedVersion(command.getExpectedPublicationRevision()),
StudioPrincipals.require(principal)))));
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@GetMapping("/v1/studio/publications/{publicationEventId}/preview")
public PublicationSnapshot getStudioPublicationSnapshot(
@PathVariable("publicationEventId") UUID publicationEventId) {
return detailMapper.toApi(getSnapshot.handle(new GetSnapshotQuery(publicationEventId)));
}
/** 계약상 required 지만 null 을 실어 보내는 클라이언트를 500 으로 떨어뜨리지 않는다. */
private static long expectedVersion(Integer value) {
return value == null ? 0L : value;
}
}
@@ -55,7 +55,13 @@ public class StudioSessionController {
this.csrfHeaderName = configured;
}
@GetMapping("/api/v1/studio/session")
/**
* 경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code
* ca-skeleton.presentation.api-base-path}("/api") 를 모든 컨트롤러 매핑에 붙인다. 이 저장소의 다른 컨트롤러들(healthcheck,
* /v1/uploads, /v1/files)과 같은 규칙이며, 여기에 {@code /api} 를 다시 쓰면 실제 경로가 {@code /api/api/v1/...} 로 밀려
* 계약(studio-v1.yaml, {@code servers: "/"})이 선언한 주소에서 사라진다.
*/
@GetMapping("/v1/studio/session")
public StudioSession getStudioSession(
@AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) {
if (csrfToken == null) {
@@ -0,0 +1,262 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Asset;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetManagementStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.AssetUsage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DashboardTotals;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PreviewDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicPreview;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationAggregate;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEvent;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationEventType;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationListItem;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationSnapshot;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublishResult;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.StudioDashboard;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationIssue;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ValidationReport;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.DashboardView;
import dev.caskeleton.application.techlog.studio.model.PreviewDetailView;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
import dev.caskeleton.shared.error.MappingException;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* {@code WorkingCopyDetail} 조립. {@code getStudioDocument}, {@code saveStudioDocument}, 그리고 낙관적 잠금
* 충돌의 {@code VersionConflictDetails.latestDocument}가 모두 이 결과를 쓴다.
*
* <p>Spring 컴포넌트인 이유는 하나뿐이다 — 미리보기의 {@code renderModel}이 DB에 문자열로 저장되어 있어 계약 DTO로 되살리려면 매퍼가 필요하다.
*/
@Component
public class StudioDetailMapper {
private final ObjectMapper objectMapper;
public StudioDetailMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public WorkingCopyDetail toApi(WorkingCopyDetailView view) {
WorkingCopyDetail detail = new WorkingCopyDetail();
detail.setDocument(StudioResponseMapper.toApi(view.document()));
detail.setCurrentValidation(toApi(view.currentValidation()));
detail.setLatestPreview(toApi(view.latestPreview()));
detail.setCurrentPublication(toApi(view.currentPublication()));
detail.setDependencyRevision(view.dependencyRevision());
detail.setNextAction(NextAction.fromValue(view.nextAction().name()));
return detail;
}
public ValidationReport toApi(ValidationReportView view) {
if (view == null) {
return null;
}
ValidationReport report = new ValidationReport();
report.setValidationId(view.validationId());
report.setDocumentId(view.documentId());
report.setValidatedVersion(Math.toIntExact(view.validatedVersion()));
report.setStatus(ValidationReport.StatusEnum.fromValue(view.status().name()));
report.setIssues(
view.issues().stream()
.map(
issue -> {
ValidationIssue dto = new ValidationIssue();
dto.setCode(issue.code());
dto.setSeverity(ValidationIssue.SeverityEnum.fromValue(issue.severity().name()));
dto.setPath(issue.path());
dto.setMessage(issue.message());
return dto;
})
.toList());
report.setValidatedAt(StudioResponseMapper.offsetDateTime(view.validatedAt()));
report.setValidUntil(StudioResponseMapper.offsetDateTime(view.validUntil()));
report.setDependencyRevision(view.dependencyRevision());
return report;
}
public PublicPreview toApi(PublicPreviewView view) {
if (view == null) {
return null;
}
PublicPreview preview = new PublicPreview();
preview.setPreviewId(view.previewId());
preview.setDocumentId(view.documentId());
preview.setPreviewVersion(Math.toIntExact(view.previewVersion()));
preview.setValidationId(view.validationId());
preview.setDependencyRevision(view.dependencyRevision());
preview.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt()));
preview.setExpiresAt(StudioResponseMapper.offsetDateTime(view.expiresAt()));
preview.setRenderModel(renderModel(view.renderModelJson()));
return preview;
}
public PublicationAggregate toApi(PublicationAggregateView view) {
if (view == null) {
return null;
}
PublicationAggregate aggregate = new PublicationAggregate();
aggregate.setPublicationId(view.publicationId());
aggregate.setDocumentId(view.documentId());
aggregate.setStatus(PublicationAggregate.StatusEnum.fromValue(view.status().name()));
aggregate.setPublishedVersion(Math.toIntExact(view.publishedVersion()));
aggregate.setPublicationRevision(Math.toIntExact(view.publicationRevision()));
aggregate.setLatestEventId(view.latestEventId());
aggregate.setPublicPath(view.publicPath());
aggregate.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt()));
return aggregate;
}
public PreviewDetail toApi(PreviewDetailView view) {
PreviewDetail detail = new PreviewDetail();
detail.setPreview(toApi(view.preview()));
detail.setState(PreviewDetail.StateEnum.fromValue(view.state().name()));
detail.setCurrentDocumentVersion(Math.toIntExact(view.currentDocumentVersion()));
detail.setCurrentValidationId(view.currentValidationId());
return detail;
}
public PublicationEvent toApi(PublicationEventView view) {
PublicationEvent event = new PublicationEvent();
event.setPublicationEventId(view.publicationEventId());
event.setPublicationId(view.publicationId());
event.setDocumentId(view.documentId());
event.setType(PublicationEventType.fromValue(view.type().name()));
event.setOccurredAt(StudioResponseMapper.offsetDateTime(view.occurredAt()));
event.setPublishedVersion(Math.toIntExact(view.publishedVersion()));
event.setSourcePublishedEventId(view.sourcePublishedEventId());
event.setSnapshotAvailable(view.snapshotAvailable());
return event;
}
public PublishResult toApi(PublishResultView view) {
PublishResult result = new PublishResult();
result.setPublication(toApi(view.publication()));
result.setEvent(toApi(view.event()));
return result;
}
public PublicationListItem toApi(PublicationListItemView view) {
PublicationListItem item = new PublicationListItem();
item.setEvent(toApi(view.event()));
item.setPublication(toApi(view.publication()));
item.setDocument(view.document() == null ? null : StudioResponseMapper.toApi(view.document()));
item.setAvailableActions(
view.availableActions().stream()
.map(action -> PublicationAction.fromValue(action.name()))
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)));
return item;
}
public PublicationPage toApi(PublicationPageView view) {
PublicationPage page = new PublicationPage();
page.setItems(view.items().stream().map(this::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public PublicationSnapshot toApi(PublicationSnapshotView view) {
PublicationSnapshot snapshot = new PublicationSnapshot();
snapshot.setEvent(toApi(view.event()));
snapshot.setRenderModel(renderModel(view.renderModelJson()));
snapshot.setContentFormatVersion(view.contentFormatVersion());
snapshot.setRendererContractVersion(view.rendererContractVersion());
return snapshot;
}
public StudioDashboard toApi(DashboardView view) {
StudioDashboard dashboard = new StudioDashboard();
dashboard.setContinueWriting(
view.continueWriting().stream().map(StudioResponseMapper::toApi).toList());
dashboard.setReadyToPublish(
view.readyToPublish().stream().map(StudioResponseMapper::toApi).toList());
dashboard.setRecentPublications(view.recentPublications().stream().map(this::toApi).toList());
DashboardTotals totals = new DashboardTotals();
totals.setDocuments(view.totals().documents());
totals.setNeedsValidation(view.totals().needsValidation());
totals.setReadyToPublish(view.totals().readyToPublish());
totals.setPublications(view.totals().publications());
dashboard.setTotals(totals);
return dashboard;
}
public Asset toApi(AssetView view) {
Asset asset = new Asset();
asset.setId(view.id());
asset.setAssetKey(view.assetKey());
asset.setKind(AssetKind.fromValue(view.kind().name()));
asset.setMediaType(view.mediaType());
asset.setOriginalFilename(view.originalFilename());
asset.setByteSize(Math.toIntExact(view.byteSize()));
asset.setWidth(view.width());
asset.setHeight(view.height());
asset.setAltText(view.altText());
asset.setDecorative(view.decorative());
asset.setManagementStatus(AssetManagementStatus.fromValue(view.managementStatus().name()));
asset.setPublicPath(view.publicPath());
asset.setUsageCount(view.usageCount());
asset.setVersion(Math.toIntExact(view.version()));
asset.setCreatedAt(StudioResponseMapper.offsetDateTime(view.createdAt()));
asset.setUpdatedAt(StudioResponseMapper.offsetDateTime(view.updatedAt()));
return asset;
}
public AssetPage toApi(AssetPageView view) {
AssetPage page = new AssetPage();
page.setItems(view.items().stream().map(this::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public AssetDetail toApi(AssetDetailView view) {
AssetDetail detail = new AssetDetail();
detail.setAsset(toApi(view.asset()));
detail.setUsages(
view.usages().stream()
.map(
usage -> {
AssetUsage dto = new AssetUsage();
dto.setDocumentId(usage.documentId());
dto.setDocumentKind(RecordKind.fromValue(usage.documentKind().name()));
dto.setTitle(usage.title());
dto.setPublished(usage.published());
return dto;
})
.toList());
detail.setHasPublicationHistory(view.hasPublicationHistory());
return detail;
}
/** 저장된 렌더 모델 JSON을 계약 union 타입으로 되살린다. */
private PublicRenderModel renderModel(String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
return objectMapper.readValue(json, PublicRenderModel.class);
} catch (JacksonException e) {
throw new MappingException("a stored preview render model no longer matches the contract", e);
}
}
}
@@ -0,0 +1,203 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RelationInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import java.util.List;
import java.util.UUID;
/**
* 계약 DTO({@code WorkingCopyInput} union) → application 입력 모델.
*
* <p>계약 union 의 네 분기를 {@code switch} 로 남김없이 다룬다 — 계약에 다섯 번째 유형이 생기면 생성 DTO 가 늘어나고 여기서 {@code
* default} 가 없는 채로 컴파일이 깨져 알려준다.
*/
public final class StudioRequestMapper {
private StudioRequestMapper() {}
public static WorkingCopyInputView toApplication(WorkingCopyInput input) {
if (input == null) {
throw StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "document is required");
}
return switch (input) {
case CaseInput value ->
new WorkingCopyInputView.CaseInputView(
base(
RecordKind.CASE,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
value.getProblem(),
value.getConclusion(),
value.getEnvironment(),
value.getReproduction(),
value.getLastVerifiedOn(),
value.getBodyMarkdown());
case ReferenceInput value ->
new WorkingCopyInputView.ReferenceInputView(
base(
RecordKind.REFERENCE,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
value.getPurpose(),
rules(value.getRules()),
orderedText(value.getApplyWhen()),
orderedText(value.getExceptions()),
orderedText(value.getExamples()),
value.getVerifiedOn());
case QuestionInput value ->
new WorkingCopyInputView.QuestionInputView(
base(
RecordKind.QUESTION,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
questionStatus(value.getQuestionStatus()),
orderedText(value.getFacts()),
orderedText(value.getAssumptions()),
orderedText(value.getUnknowns()),
orderedText(value.getConstraints()),
options(value.getOptions()),
value.getNextValidation(),
resolution(value.getResolution()));
case ProjectDecisionInput value ->
new WorkingCopyInputView.ProjectDecisionInputView(
base(
RecordKind.PROJECT_DECISION,
value.getTitle(),
value.getSlug(),
value.getSummary(),
value.getTopicId(),
value.getProjectId(),
value.getRelations()),
decisionStatus(value.getDecisionStatus()),
value.getDecidedOn(),
value.getStatement(),
value.getRationale(),
orderedText(value.getConsequences()));
default ->
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"unsupported document kind: " + input.getClass().getSimpleName());
};
}
private static WorkingCopyBaseInput base(
RecordKind kind,
String title,
String slug,
String summary,
UUID topicId,
UUID projectId,
List<RelationInput> relations) {
return new WorkingCopyBaseInput(
kind, title, slug, summary, topicId, projectId, relations(relations));
}
private static List<RelationView> relations(List<RelationInput> relations) {
return relations == null
? List.of()
: relations.stream()
.map(
relation ->
new RelationView(
relation.getId(),
relation.getTargetId(),
relation.getReason(),
order(relation.getOrder())))
.toList();
}
private static List<OrderedTextView> orderedText(List<OrderedText> items) {
return items == null
? List.of()
: items.stream()
.map(item -> new OrderedTextView(item.getId(), item.getText(), order(item.getOrder())))
.toList();
}
private static List<ReferenceRuleView> rules(List<ReferenceRule> items) {
return items == null
? List.of()
: items.stream()
.map(
item ->
new ReferenceRuleView(
item.getId(), item.getTitle(), item.getBody(), order(item.getOrder())))
.toList();
}
private static List<QuestionOptionView> options(List<QuestionOption> items) {
return items == null
? List.of()
: items.stream()
.map(
item ->
new QuestionOptionView(
item.getId(),
item.getTitle(),
item.getDescription(),
order(item.getOrder())))
.toList();
}
private static QuestionResolutionView resolution(QuestionResolution resolution) {
return resolution == null
? null
: new QuestionResolutionView(
resolution.getSummary(), resolution.getEvidenceTargetId(), resolution.getLinkLabel());
}
private static QuestionStatusView questionStatus(QuestionInput.QuestionStatusEnum status) {
if (status == null) {
return null;
}
return status == QuestionInput.QuestionStatusEnum.RESOLVED
? QuestionStatusView.RESOLVED
: QuestionStatusView.OPEN;
}
private static DecisionStatusView decisionStatus(ProjectDecisionInput.DecisionStatusEnum status) {
if (status == null) {
return null;
}
return status == ProjectDecisionInput.DecisionStatusEnum.ADOPTED
? DecisionStatusView.ADOPTED
: DecisionStatusView.PROPOSED;
}
/** 계약상 {@code order}는 required지만 null 을 실어 보내는 클라이언트를 500으로 떨어뜨리지 않는다. */
private static int order(Integer order) {
return order == null ? 0 : order;
}
}
@@ -0,0 +1,258 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentPage;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DocumentSummary;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicationStatus;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionOption;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RecordKind;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceRule;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferenceWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Relation;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.function.Function;
/** application 모델 → 계약 DTO. */
public final class StudioResponseMapper {
private StudioResponseMapper() {}
public static WorkingCopy toApi(WorkingCopyView view) {
return switch (view) {
case WorkingCopyView.CaseWorkingCopyView value -> {
CaseWorkingCopy dto = new CaseWorkingCopy();
dto.setKind(CaseWorkingCopy.KindEnum.CASE);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setProblem(value.problem());
dto.setConclusion(value.conclusion());
dto.setEnvironment(value.environment());
dto.setReproduction(value.reproduction());
dto.setLastVerifiedOn(value.lastVerifiedOn());
dto.setBodyMarkdown(value.bodyMarkdown());
yield dto;
}
case WorkingCopyView.ReferenceWorkingCopyView value -> {
ReferenceWorkingCopy dto = new ReferenceWorkingCopy();
dto.setKind(ReferenceWorkingCopy.KindEnum.REFERENCE);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setPurpose(value.purpose());
dto.setRules(rulesToApi(value.rules()));
dto.setApplyWhen(orderedTextToApi(value.applyWhen()));
dto.setExceptions(orderedTextToApi(value.exceptions()));
dto.setExamples(orderedTextToApi(value.examples()));
dto.setVerifiedOn(value.verifiedOn());
yield dto;
}
case WorkingCopyView.QuestionWorkingCopyView value -> {
QuestionWorkingCopy dto = new QuestionWorkingCopy();
dto.setKind(QuestionWorkingCopy.KindEnum.QUESTION);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setQuestionStatus(questionStatus(value.questionStatus()));
dto.setFacts(orderedTextToApi(value.facts()));
dto.setAssumptions(orderedTextToApi(value.assumptions()));
dto.setUnknowns(orderedTextToApi(value.unknowns()));
dto.setConstraints(orderedTextToApi(value.constraints()));
dto.setOptions(optionsToApi(value.options()));
dto.setNextValidation(value.nextValidation());
dto.setResolution(resolution(value.resolution()));
yield dto;
}
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
ProjectDecisionWorkingCopy dto = new ProjectDecisionWorkingCopy();
dto.setKind(ProjectDecisionWorkingCopy.KindEnum.PROJECT_DECISION);
applyBase(value.base(), dto::setTitle, dto::setSlug, dto::setSummary);
dto.setTopicId(value.base().topicId());
dto.setProjectId(value.base().projectId());
dto.setRelations(relations(value.base().relations()));
dto.setId(value.id());
dto.setVersion(version(value.version()));
dto.setUpdatedAt(offsetDateTime(value.updatedAt()));
dto.setDecisionStatus(decisionStatus(value.decisionStatus()));
dto.setDecidedOn(value.decidedOn());
dto.setStatement(value.statement());
dto.setRationale(value.rationale());
dto.setConsequences(orderedTextToApi(value.consequences()));
yield dto;
}
};
}
public static DocumentPage toApi(DocumentPageView view) {
DocumentPage page = new DocumentPage();
page.setItems(view.items().stream().map(StudioResponseMapper::toApi).toList());
page.setNextCursor(view.nextCursor());
return page;
}
public static DocumentSummary toApi(DocumentSummaryView view) {
DocumentSummary summary = new DocumentSummary();
summary.setId(view.id());
summary.setTitle(view.title());
summary.setKind(RecordKind.fromValue(view.kind().name()));
summary.setProject(displayTarget(view.project()));
summary.setUpdatedAt(offsetDateTime(view.updatedAt()));
summary.setPublicationStatus(PublicationStatus.fromValue(view.publicationStatus().name()));
summary.setPublishedVersion(
view.publishedVersion() == null ? null : Math.toIntExact(view.publishedVersion()));
summary.setHasUnpublishedChanges(view.hasUnpublishedChanges());
summary.setNextAction(NextAction.fromValue(view.nextAction().name()));
return summary;
}
public static DisplayTarget displayTarget(DisplayTargetView view) {
if (view == null) {
return null;
}
DisplayTarget target = new DisplayTarget();
target.setId(view.id());
target.setLabel(view.label());
target.setPublicPath(view.publicPath());
return target;
}
public static OffsetDateTime offsetDateTime(Instant instant) {
return instant == null ? null : instant.atOffset(ZoneOffset.UTC);
}
/**
* 계약의 {@code version}은 {@code integer}이고 컬럼은 {@code bigint}다. 넘치는 값을 조용히 잘라내면 클라이언트가 보내는 {@code
* expectedVersion}이 영영 맞지 않게 되므로 예외로 드러낸다.
*/
private static Integer version(long version) {
return Math.toIntExact(version);
}
private static void applyBase(
WorkingCopyBaseInput base,
java.util.function.Consumer<String> title,
java.util.function.Consumer<String> slug,
java.util.function.Consumer<String> summary) {
title.accept(base.title());
slug.accept(base.slug());
summary.accept(base.summary());
}
private static List<Relation> relations(List<RelationView> views) {
return map(
views,
view -> {
Relation relation = new Relation();
relation.setId(view.id());
relation.setTargetId(view.targetId());
relation.setReason(view.reason());
relation.setOrder(view.order());
return relation;
});
}
public static List<OrderedText> orderedTextToApi(List<OrderedTextView> views) {
return map(
views,
view -> {
OrderedText text = new OrderedText();
text.setId(view.id());
text.setText(view.text());
text.setOrder(view.order());
return text;
});
}
public static List<ReferenceRule> rulesToApi(List<ReferenceRuleView> views) {
return map(
views,
view -> {
ReferenceRule rule = new ReferenceRule();
rule.setId(view.id());
rule.setTitle(view.title());
rule.setBody(view.body());
rule.setOrder(view.order());
return rule;
});
}
public static List<QuestionOption> optionsToApi(List<QuestionOptionView> views) {
return map(
views,
view -> {
QuestionOption option = new QuestionOption();
option.setId(view.id());
option.setTitle(view.title());
option.setDescription(view.description());
option.setOrder(view.order());
return option;
});
}
private static QuestionResolution resolution(QuestionResolutionView view) {
if (view == null) {
return null;
}
QuestionResolution resolution = new QuestionResolution();
resolution.setSummary(view.summary());
resolution.setEvidenceTargetId(view.evidenceTargetId());
resolution.setLinkLabel(view.linkLabel());
return resolution;
}
private static QuestionWorkingCopy.QuestionStatusEnum questionStatus(QuestionStatusView status) {
if (status == null) {
return null;
}
return status == QuestionStatusView.RESOLVED
? QuestionWorkingCopy.QuestionStatusEnum.RESOLVED
: QuestionWorkingCopy.QuestionStatusEnum.OPEN;
}
private static ProjectDecisionWorkingCopy.DecisionStatusEnum decisionStatus(
dev.caskeleton.application.techlog.studio.model.DecisionStatusView status) {
if (status == null) {
return null;
}
return status == dev.caskeleton.application.techlog.studio.model.DecisionStatusView.ADOPTED
? ProjectDecisionWorkingCopy.DecisionStatusEnum.ADOPTED
: ProjectDecisionWorkingCopy.DecisionStatusEnum.PROPOSED;
}
private static <S, T> List<T> map(List<S> source, Function<S, T> mapper) {
return source == null ? List.of() : source.stream().map(mapper).toList();
}
}
@@ -0,0 +1,283 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.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;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TableBody;
import org.commonmark.ext.gfm.tables.TableCell;
import org.commonmark.ext.gfm.tables.TableHead;
import org.commonmark.ext.gfm.tables.TableRow;
import org.commonmark.node.BlockQuote;
import org.commonmark.node.BulletList;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.Heading;
import org.commonmark.node.Image;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Node;
import org.commonmark.node.OrderedList;
import org.commonmark.node.Paragraph;
import org.commonmark.node.ThematicBreak;
/**
* commonmark 블록 노드를 계약의 {@code CaseRenderBlock} union 으로 옮긴다.
*
* <p>계약이 표현할 수 없는 것은 조용히 다른 것으로 바꾸지 않고 경고로 남긴다(설계 05장 §12) — 렌더러가 지원하지 않는 문법을 그럴듯하게 잘못 해석하면 작성자는 게시
* 결과를 신뢰할 수 없다.
*/
final class BlockRenderer {
/**
* 계약 {@code HeadingBlock.level} 은 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 = 6;
private final HeadingIds headingIds;
private final List<String> warnings;
private int tableSequence;
private int listItemSequence;
BlockRenderer(HeadingIds headingIds, List<String> warnings) {
this.headingIds = headingIds;
this.warnings = warnings;
}
List<CaseRenderBlock> render(Node document) {
List<CaseRenderBlock> blocks = new ArrayList<>();
for (Node node = document.getFirstChild(); node != null; node = node.getNext()) {
CaseRenderBlock block = renderBlock(node);
if (block != null) {
blocks.add(block);
}
}
return blocks;
}
private CaseRenderBlock renderBlock(Node node) {
return switch (node) {
case Heading value -> heading(value);
case Paragraph value -> 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 -> {
ThematicBreakBlock block = new ThematicBreakBlock();
block.setType(ThematicBreakBlock.TypeEnum.THEMATIC_BREAK);
yield block;
}
default -> {
List<Inline> content = InlineRenderer.render(node);
yield content.isEmpty() ? null : paragraph(content);
}
};
}
/**
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
*
* <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);
block.setId(headingIds.nextFor(InlineRenderer.plainText(value)));
block.setLevel(Math.clamp(value.getLevel(), MIN_HEADING_LEVEL, MAX_HEADING_LEVEL));
block.setContent(InlineRenderer.render(value));
return block;
}
private static CaseRenderBlock paragraph(List<Inline> content) {
ParagraphBlock block = new ParagraphBlock();
block.setType(ParagraphBlock.TypeEnum.PARAGRAPH);
block.setContent(content);
return block;
}
/** 계약의 {@code BlockquoteBlock.content} 는 블록이 아니라 inline 배열이라 안쪽 문단을 이어 붙인다. */
private CaseRenderBlock blockquote(BlockQuote value) {
BlockquoteBlock block = new BlockquoteBlock();
block.setType(BlockquoteBlock.TypeEnum.BLOCKQUOTE);
List<Inline> content = new ArrayList<>();
for (Node child = value.getFirstChild(); child != null; child = child.getNext()) {
List<Inline> rendered = InlineRenderer.render(child);
if (rendered.isEmpty()) {
continue;
}
if (!content.isEmpty()) {
// 인용 안의 문단 경계. 이어 붙이기만 하면 앞 문단의 마지막 낱말과 다음 문단의 첫 낱말이
// 한 낱말로 붙어 읽힌다.
content.add(InlineRenderer.spacer());
}
content.addAll(rendered);
}
block.setContent(content);
return block;
}
private CaseRenderBlock bulletList(BulletList value) {
UnorderedListBlock block = new UnorderedListBlock();
block.setType(UnorderedListBlock.TypeEnum.UNORDERED_LIST);
block.setItems(listItems(value));
return block;
}
private CaseRenderBlock orderedList(OrderedList value) {
OrderedListBlock block = new OrderedListBlock();
block.setType(OrderedListBlock.TypeEnum.ORDERED_LIST);
block.setItems(listItems(value));
return block;
}
private List<ListItem> listItems(Node list) {
List<ListItem> items = new ArrayList<>();
for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
ListItem item = new ListItem();
listItemSequence++;
item.setId("li-" + listItemSequence);
List<Inline> content = new ArrayList<>();
for (Node paragraph = child.getFirstChild();
paragraph != null;
paragraph = paragraph.getNext()) {
content.addAll(InlineRenderer.render(paragraph));
}
item.setContent(content);
items.add(item);
}
return items;
}
private static CaseRenderBlock code(String literal, String info) {
CodeBlock block = new CodeBlock();
block.setType(CodeBlock.TypeEnum.CODE_BLOCK);
block.setCode(literal == null ? "" : literal);
block.setLanguage(info == null || info.isBlank() ? null : info.strip());
block.setLabel(null);
return block;
}
private CaseRenderBlock table(TableBlock value) {
DataTableBlock block = new DataTableBlock();
block.setType(DataTableBlock.TypeEnum.DATA_TABLE);
tableSequence++;
block.setId("table-" + tableSequence);
block.setCaption("");
// 계약은 rowHeaderColumn 을 1-based 로 정의한다. GFM 표에는 행 머리글 개념이 없으므로 비운다.
block.setRowHeaderColumn(null);
List<DataTableColumn> columns = new ArrayList<>();
List<DataTableRow> rows = new ArrayList<>();
int rowSequence = 0;
for (Node section = value.getFirstChild(); section != null; section = section.getNext()) {
for (Node row = section.getFirstChild(); row != null; row = row.getNext()) {
if (!(row instanceof TableRow tableRow)) {
continue;
}
if (section instanceof TableHead) {
int index = 0;
for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) {
DataTableColumn column = new DataTableColumn();
index++;
column.setId("col-" + index);
String label = InlineRenderer.plainText(cell);
// 계약의 label 은 minLength 1 이다. 빈 머리글 칸은 열 번호로 대신한다.
column.setLabel(label.isBlank() ? "col-" + index : label);
column.setAlignment(alignment(cell));
columns.add(column);
}
} else if (section instanceof TableBody) {
DataTableRow dataRow = new DataTableRow();
rowSequence++;
dataRow.setId("row-" + rowSequence);
List<DataTableCell> cells = new ArrayList<>();
int index = 0;
for (Node cell = tableRow.getFirstChild(); cell != null; cell = cell.getNext()) {
DataTableCell dataCell = new DataTableCell();
index++;
dataCell.setColumnId("col-" + index);
dataCell.setContent(InlineRenderer.render(cell));
cells.add(dataCell);
}
dataRow.setCells(cells);
rows.add(dataRow);
}
}
}
if (columns.isEmpty()) {
// 계약의 columns 는 minItems 1 이다. 머리글 없는 표는 계약상 표현할 수 없다.
warnings.add("DATA_TABLE_WITHOUT_HEADER_NOT_RENDERABLE");
return null;
}
block.setColumns(columns);
block.setRows(rows);
return block;
}
private static DataTableColumn.AlignmentEnum alignment(Node cell) {
if (!(cell instanceof TableCell tableCell) || tableCell.getAlignment() == null) {
return DataTableColumn.AlignmentEnum.LEFT;
}
return switch (tableCell.getAlignment()) {
case CENTER -> DataTableColumn.AlignmentEnum.CENTER;
case RIGHT -> DataTableColumn.AlignmentEnum.RIGHT;
case LEFT -> DataTableColumn.AlignmentEnum.LEFT;
};
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
/**
* 설계 05장 §6의 heading ID 알고리즘. TOC와 anchor가 Public과 Studio에서 같아야 하므로 구현은 한 곳에만 둔다.
*
* <pre>{@code
* "Authorization Code Flow" -> authorization-code-flow
* "JPA N+1 문제" -> jpa-n-1-문제
* "결론" -> 결론
* "결론" (두 번째) -> 결론-2
* }</pre>
*/
final class HeadingIds {
private static final String FALLBACK = "section";
private final Map<String, Integer> used = new HashMap<>();
/** 같은 문서 안에서 중복되면 {@code -2}, {@code -3} 순으로 suffix 를 붙인다. */
String nextFor(String headingPlainText) {
String base = slugify(headingPlainText);
int seen = used.merge(base, 1, Integer::sum);
return seen == 1 ? base : base + "-" + seen;
}
private static String slugify(String text) {
String normalized =
Normalizer.normalize(text == null ? "" : text, Normalizer.Form.NFKC)
.trim()
.toLowerCase(Locale.ROOT);
StringBuilder out = new StringBuilder(normalized.length());
boolean pendingSeparator = false;
for (int i = 0; i < normalized.length(); i++) {
char ch = normalized.charAt(i);
if (isKept(ch)) {
// 구분자는 실제로 유지 문자가 뒤따를 때만 쓴다 — 그래야 끝에 하이픈이 남지 않는다.
if (pendingSeparator && !out.isEmpty()) {
out.append('-');
}
pendingSeparator = false;
out.append(ch);
} else {
pendingSeparator = true;
}
}
return out.isEmpty() ? FALLBACK : out.toString();
}
/** 한글·영문·숫자·하이픈만 남긴다(설계 05장 §6 6단계). */
private static boolean isKept(char ch) {
if (ch == '-') {
return true;
}
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
return true;
}
return isHangul(ch);
}
private static boolean isHangul(char ch) {
return (ch >= 0xAC00 && ch <= 0xD7A3) // 완성형 음절
|| (ch >= 0x1100 && ch <= 0x11FF) // 초·중·종성 자모
|| (ch >= 0x3130 && ch <= 0x318F); // 호환용 자모
}
}
@@ -0,0 +1,117 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineEmphasis;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineLink;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import java.util.ArrayList;
import java.util.List;
import org.commonmark.node.Code;
import org.commonmark.node.Emphasis;
import org.commonmark.node.HardLineBreak;
import org.commonmark.node.Image;
import org.commonmark.node.Link;
import org.commonmark.node.Node;
import org.commonmark.node.SoftLineBreak;
import org.commonmark.node.StrongEmphasis;
import org.commonmark.node.Text;
/** commonmark inline 노드를 계약의 {@code Inline} union 으로 옮긴다. */
final class InlineRenderer {
private InlineRenderer() {}
static List<Inline> render(Node parent) {
List<Inline> out = new ArrayList<>();
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
Inline rendered = renderNode(child);
if (rendered != null) {
out.add(rendered);
}
}
return out;
}
/** heading id 계산과 alt 추출에 쓰는 평문. */
static String plainText(Node parent) {
StringBuilder text = new StringBuilder();
appendPlainText(parent, text);
return text.toString();
}
private static void appendPlainText(Node parent, StringBuilder text) {
for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
switch (child) {
case Text value -> text.append(value.getLiteral());
case Code value -> text.append(value.getLiteral());
case SoftLineBreak ignored -> text.append(' ');
case HardLineBreak ignored -> text.append(' ');
default -> appendPlainText(child, text);
}
}
}
private static Inline renderNode(Node node) {
return switch (node) {
case Text value -> text(value.getLiteral());
case Code value -> {
// 계약의 InlineCode.code 는 minLength 1 이다. 빈 백틱은 보낼 값이 없으므로 버린다.
if (value.getLiteral().isEmpty()) {
yield null;
}
InlineCode code = new InlineCode();
code.setType(InlineCode.TypeEnum.INLINE_CODE);
code.setCode(value.getLiteral());
yield code;
}
case Emphasis value -> {
InlineEmphasis emphasis = new InlineEmphasis();
emphasis.setType(InlineEmphasis.TypeEnum.EMPHASIS);
emphasis.setChildren(render(value));
yield emphasis;
}
case StrongEmphasis value -> {
InlineStrong strong = new InlineStrong();
strong.setType(InlineStrong.TypeEnum.STRONG);
strong.setChildren(render(value));
yield strong;
}
case Link value -> {
InlineLink link = new InlineLink();
link.setType(InlineLink.TypeEnum.LINK);
String label = plainText(value);
// 계약의 label 은 minLength 1 이다. 라벨 없는 링크는 주소 자체를 라벨로 쓴다 —
// 버리면 사용자가 쓴 링크가 통째로 사라진다.
link.setLabel(label.isBlank() ? value.getDestination() : label);
link.setHref(java.net.URI.create(value.getDestination()));
yield link;
}
// 이미지는 EvidenceFigure 로만 다룬다(설계 05장 §3). 인라인 이미지는 계약의 Inline union 에
// 대응 타입이 없으므로 alt 를 글자로 남긴다 — 조용히 사라지게 두지 않는다.
case Image value -> text(plainText(value));
case SoftLineBreak ignored -> text(" ");
case HardLineBreak ignored -> text(" ");
default -> {
String plain = plainText(node);
yield plain.isEmpty() ? null : text(plain);
}
};
}
/** 블록 경계를 한 칸 띄우는 조각. 계약의 Inline union 에 줄바꿈 타입이 없어 공백으로 표현한다. */
static Inline spacer() {
return text(" ");
}
private static Inline text(String literal) {
if (literal == null || literal.isEmpty()) {
return null;
}
InlineText inlineText = new InlineText();
inlineText.setType(InlineText.TypeEnum.TEXT);
inlineText.setText(literal);
return inlineText;
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.util.ArrayList;
import java.util.List;
/**
* 본문을 "평범한 Markdown" 조각과 "directive" 조각으로 순서대로 자른다.
*
* <p>fenced code block 안의 {@code :::} 는 directive 가 아니다 — 코드 예시로 directive 문법 자체를 적는 문서가 그 자리에서 잘리면
* 안 된다. 그래서 코드 펜스 안에 있는 동안은 directive 를 찾지 않는다.
*/
final class MarkdownSegments {
private MarkdownSegments() {}
/** 조각 하나. {@code directive} 가 null 이면 평범한 Markdown 이다. */
record Segment(String markdown, StudioDirective directive, String directiveBody) {}
static List<Segment> split(String source) {
List<Segment> segments = new ArrayList<>();
if (source == null || source.isBlank()) {
return segments;
}
String[] lines = source.split("\n", -1);
StringBuilder markdown = new StringBuilder();
String codeFence = null;
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
String trimmed = line.strip();
if (codeFence != null) {
markdown.append(line).append('\n');
if (trimmed.startsWith(codeFence)) {
codeFence = null;
}
continue;
}
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
codeFence = trimmed.startsWith("```") ? "```" : "~~~";
markdown.append(line).append('\n');
continue;
}
StudioDirective directive = StudioDirective.parse(line);
if (directive == null || directive.name().isEmpty()) {
markdown.append(line).append('\n');
continue;
}
flush(segments, markdown);
// 컨테이너형(:::name ... 내용 ... :::)인지 leaf형(:::evidence ...)인지는 <b>닫는 줄이
// 실제로 있는지</b>로 정한다. 이름으로 정하면 새 directive 를 더할 때마다 목록을 고쳐야 하고,
// "닫는 줄이 없으면 문서 끝까지 본문"으로 정하면 닫기를 빠뜨린 directive 하나가 뒤 내용을
// 통째로 삼킨다. 닫는 줄은 다음 directive 가 열리기 전까지만 찾는다.
int closing = -1;
for (int j = i + 1; j < lines.length; j++) {
String candidate = lines[j].strip();
if (":::".equals(candidate)) {
closing = j;
break;
}
if (StudioDirective.parse(lines[j]) != null) {
break;
}
}
if (closing < 0) {
segments.add(new Segment(null, directive, ""));
} else {
StringBuilder body = new StringBuilder();
for (int j = i + 1; j < closing; j++) {
body.append(lines[j]).append('\n');
}
segments.add(new Segment(null, directive, body.toString()));
i = closing;
}
}
flush(segments, markdown);
return segments;
}
private static void flush(List<Segment> segments, StringBuilder markdown) {
if (!markdown.isEmpty()) {
String text = markdown.toString();
if (!text.isBlank()) {
segments.add(new Segment(text, null, null));
}
markdown.setLength(0);
}
}
}
@@ -0,0 +1,166 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DisplayTarget;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ProjectDecisionPublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionPublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ReferencePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.RenderContext;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedQuestionResolution;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedRelation;
import dev.caskeleton.adapter.inbound.web.techlog.studio.mapper.StudioResponseMapper;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RenderInput;
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import org.springframework.stereotype.Component;
/**
* 편집본 + 해석된 의존 상태 → 계약 {@code PublicRenderModel}.
*
* <p>본문 Markdown 을 블록으로 바꾸는 것은 {@code CASE} 뿐이다 — 나머지 세 유형의 공개 모델은 구조화된 필드로만 이루어져 있다(계약 {@code
* *PublicRenderModel}).
*/
@Component
public class PublicRenderModelFactory {
private final StudioContentRenderer contentRenderer;
public PublicRenderModelFactory(StudioContentRenderer contentRenderer) {
this.contentRenderer = contentRenderer;
}
/**
* 렌더 결과와 그 과정에서 생긴 경고.
*
* @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들(설계 05장 §12)
*/
public record Rendered(PublicRenderModel model, List<String> warnings) {}
public Rendered create(RenderInput input) {
RenderContext context = new RenderContext();
context.setGeneratedAt(StudioResponseMapper.offsetDateTime(input.generatedAt()));
context.setDependencyRevision(input.dependencyRevision());
List<ResolvedRelation> relations =
input.relations().stream().map(PublicRenderModelFactory::relation).toList();
DisplayTarget topic = StudioResponseMapper.displayTarget(input.topic());
DisplayTarget project = StudioResponseMapper.displayTarget(input.project());
return switch (input.document()) {
case WorkingCopyView.CaseWorkingCopyView value -> {
StudioContentRenderer.RenderedContent body =
contentRenderer.render(value.bodyMarkdown(), input.assetsByKey());
CasePublicRenderModel model = new CasePublicRenderModel();
model.setKind(CasePublicRenderModel.KindEnum.CASE);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setProblem(value.problem());
model.setConclusion(value.conclusion());
model.setEnvironment(value.environment());
model.setReproduction(value.reproduction());
model.setLastVerifiedOn(value.lastVerifiedOn());
model.setBodyBlocks(body.blocks());
yield new Rendered(model, body.warnings());
}
case WorkingCopyView.ReferenceWorkingCopyView value -> {
ReferencePublicRenderModel model = new ReferencePublicRenderModel();
model.setKind(ReferencePublicRenderModel.KindEnum.REFERENCE);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setPurpose(value.purpose());
model.setRules(StudioResponseMapper.rulesToApi(value.rules()));
model.setApplyWhen(StudioResponseMapper.orderedTextToApi(value.applyWhen()));
model.setExceptions(StudioResponseMapper.orderedTextToApi(value.exceptions()));
model.setExamples(StudioResponseMapper.orderedTextToApi(value.examples()));
model.setVerifiedOn(value.verifiedOn());
yield new Rendered(model, List.of());
}
case WorkingCopyView.QuestionWorkingCopyView value -> {
QuestionPublicRenderModel model = new QuestionPublicRenderModel();
model.setKind(QuestionPublicRenderModel.KindEnum.QUESTION);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setStatus(
value.questionStatus() == QuestionStatusView.RESOLVED
? QuestionPublicRenderModel.StatusEnum.RESOLVED
: QuestionPublicRenderModel.StatusEnum.OPEN);
model.setFacts(StudioResponseMapper.orderedTextToApi(value.facts()));
model.setAssumptions(StudioResponseMapper.orderedTextToApi(value.assumptions()));
model.setUnknowns(StudioResponseMapper.orderedTextToApi(value.unknowns()));
model.setConstraints(StudioResponseMapper.orderedTextToApi(value.constraints()));
model.setOptions(StudioResponseMapper.optionsToApi(value.options()));
model.setNextValidation(value.nextValidation());
model.setResolution(resolution(value, input));
yield new Rendered(model, List.of());
}
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
ProjectDecisionPublicRenderModel model = new ProjectDecisionPublicRenderModel();
model.setKind(ProjectDecisionPublicRenderModel.KindEnum.PROJECT_DECISION);
model.setSlug(value.base().slug());
model.setTitle(value.base().title());
model.setSummary(value.base().summary());
model.setPublicPath(input.publicPath());
model.setTopic(topic);
model.setProject(project);
model.setRelations(relations);
model.setRenderContext(context);
model.setStatus(
value.decisionStatus() == DecisionStatusView.ADOPTED
? ProjectDecisionPublicRenderModel.StatusEnum.ADOPTED
: ProjectDecisionPublicRenderModel.StatusEnum.PROPOSED);
model.setDecidedOn(value.decidedOn());
model.setStatement(value.statement());
model.setRationale(value.rationale());
model.setConsequences(StudioResponseMapper.orderedTextToApi(value.consequences()));
yield new Rendered(model, List.of());
}
};
}
private static ResolvedRelation relation(ResolvedRelationView view) {
ResolvedRelation relation = new ResolvedRelation();
relation.setId(view.id());
relation.setTargetId(view.targetId());
relation.setTargetKind(ResolvedRelation.TargetKindEnum.fromValue(view.targetKind()));
relation.setTitle(view.title());
relation.setPublicPath(view.publicPath());
relation.setReason(view.reason());
relation.setOrder(view.order());
return relation;
}
private static ResolvedQuestionResolution resolution(
WorkingCopyView.QuestionWorkingCopyView value, RenderInput input) {
if (value.resolution() == null) {
return null;
}
ResolvedQuestionResolution resolution = new ResolvedQuestionResolution();
resolution.setSummary(value.resolution().summary());
resolution.setEvidenceTarget(
StudioResponseMapper.displayTarget(input.resolutionEvidenceTarget()));
resolution.setLinkLabel(value.resolution().linkLabel());
return resolution;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.application.techlog.studio.model.RenderInput;
import dev.caskeleton.application.techlog.studio.port.out.RenderModelPort;
import dev.caskeleton.shared.error.MappingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* 렌더 결과를 {@code studio_preview.render_model} 에 그대로 들어갈 JSON 으로 만든다.
*
* <p>렌더 경고는 여기서 버리지 않고 로그로 남긴다 — 계약의 {@code PublicPreview} 에 경고를 실을 자리가 없지만, 경고가 생겼다는 사실 자체가 "작성자가
* 쓴 문법 일부가 계약으로 표현되지 못했다"는 신호라 흔적 없이 사라지면 안 된다. 게시를 막아야 하는 종류(미해결 Asset 등)는 검증이 별도로 잡는다.
*/
@Component
public class RenderModelJsonAdapter implements RenderModelPort {
private static final Logger log = LoggerFactory.getLogger(RenderModelJsonAdapter.class);
private final PublicRenderModelFactory factory;
private final ObjectMapper objectMapper;
public RenderModelJsonAdapter(PublicRenderModelFactory factory, ObjectMapper objectMapper) {
this.factory = factory;
this.objectMapper = objectMapper;
}
@Override
public String renderToJson(RenderInput input) {
PublicRenderModelFactory.Rendered rendered = factory.create(input);
if (!rendered.warnings().isEmpty()) {
log.warn(
"studio render produced {} warning(s) for document {}: {}",
rendered.warnings().size(),
input.document().id(),
rendered.warnings());
}
try {
return objectMapper.writeValueAsString(rendered.model());
} catch (JacksonException e) {
throw new MappingException("failed to serialise a Studio render model", e);
}
}
}
@@ -0,0 +1,181 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ResolvedAsset;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.commonmark.ext.autolink.AutolinkExtension;
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.parser.Parser;
import org.springframework.stereotype.Component;
/**
* 본문 Markdown → 계약의 {@code CaseRenderBlock} 목록. Preview·공개·Snapshot 세 화면이 이 한 구현을 공유한다(ADR-005) —
* 화면마다 다른 경로를 두면 작성자가 확인한 것과 공개된 것이 달라진다.
*
* <p>Asset 해석은 렌더러가 직접 조회하지 않고 {@code assetsByKey} 로 주입받는다. Snapshot 만 게시 시점에 고정된 manifest 를 넣고
* 나머지는 현재 상태를 넣으며, 그것이 ADR-002 가 요구하는 유일하게 허용된 차이다.
*/
@Component
public class StudioContentRenderer implements ContentAnalyzerPort {
/** 설계 05장 §4가 허용한 callout 종류. 그 밖의 이름은 경고를 만들고 일반 인용으로 처리한다. */
private static final Set<String> INFO_CALLOUTS = Set.of("note", "tip");
private static final Set<String> WARNING_CALLOUTS = Set.of("warning", "danger");
private static final String EVIDENCE = "evidence";
private final Parser parser =
Parser.builder()
.extensions(
List.of(
TablesExtension.create(),
StrikethroughExtension.create(),
AutolinkExtension.create()))
.build();
/**
* 렌더 결과.
*
* @param blocks 계약 모양의 본문 블록
* @param warnings 계약으로 표현할 수 없어 버리거나 낮춰 처리한 것들의 코드
*/
public record RenderedContent(List<CaseRenderBlock> blocks, List<String> warnings) {}
public RenderedContent render(String bodyMarkdown, Map<String, ResolvedAssetView> assetsByKey) {
List<CaseRenderBlock> blocks = new ArrayList<>();
List<String> warnings = new ArrayList<>();
HeadingIds headingIds = new HeadingIds();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
if (segment.directive() == null) {
blocks.addAll(
new BlockRenderer(headingIds, warnings).render(parser.parse(segment.markdown())));
continue;
}
CaseRenderBlock block = renderDirective(segment, assetsByKey, headingIds, warnings);
if (block != null) {
blocks.add(block);
}
}
return new RenderedContent(blocks, warnings);
}
@Override
public ContentAnalysis analyze(String bodyMarkdown) {
List<AssetUsage> usages = new ArrayList<>();
Set<String> unsupported = new LinkedHashSet<>();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
StudioDirective directive = segment.directive();
if (directive == null) {
continue;
}
if (EVIDENCE.equals(directive.name())) {
usages.add(new AssetUsage(directive.attribute("key", ""), directive.attribute("alt", "")));
} else if (!INFO_CALLOUTS.contains(directive.name())
&& !WARNING_CALLOUTS.contains(directive.name())) {
unsupported.add(directive.name());
}
}
return new ContentAnalysis(usages, List.copyOf(unsupported), plainText(bodyMarkdown));
}
/** 검색 색인용 평문. 렌더러가 이미 파싱한 것을 다시 쓴다 — 정규식으로 마크업을 지우는 별도 구현을 두면 두 해석이 갈라져 색인이 본문과 어긋난다. */
private String plainText(String bodyMarkdown) {
StringBuilder text = new StringBuilder();
for (MarkdownSegments.Segment segment : MarkdownSegments.split(bodyMarkdown)) {
String source = segment.directive() == null ? segment.markdown() : segment.directiveBody();
if (source == null || source.isBlank()) {
continue;
}
String plain = InlineRenderer.plainText(parser.parse(source));
if (!plain.isBlank()) {
if (!text.isEmpty()) {
text.append(' ');
}
text.append(plain.strip());
}
}
return text.toString();
}
private CaseRenderBlock renderDirective(
MarkdownSegments.Segment segment,
Map<String, ResolvedAssetView> assetsByKey,
HeadingIds headingIds,
List<String> warnings) {
StudioDirective directive = segment.directive();
if (EVIDENCE.equals(directive.name())) {
return evidence(directive, assetsByKey, warnings);
}
if (INFO_CALLOUTS.contains(directive.name()) || WARNING_CALLOUTS.contains(directive.name())) {
CalloutBlock callout = new CalloutBlock();
callout.setType(CalloutBlock.TypeEnum.CALLOUT);
callout.setTone(
WARNING_CALLOUTS.contains(directive.name())
? CalloutBlock.ToneEnum.WARNING
: CalloutBlock.ToneEnum.INFO);
callout.setLabel(directive.argument());
callout.setContent(
InlineRenderer.render(
parser.parse(segment.directiveBody()).getFirstChild() == null
? parser.parse("")
: parser.parse(segment.directiveBody()).getFirstChild()));
return callout;
}
// 설계 05장 §4: 알 수 없는 종류는 경고를 만들고 일반 blockquote 로 안전하게 처리한다.
warnings.add("UNSUPPORTED_DIRECTIVE:" + directive.name());
List<CaseRenderBlock> fallback =
new BlockRenderer(headingIds, warnings)
.render(parser.parse("> " + segment.directiveBody().replace("\n", "\n> ")));
return fallback.isEmpty() ? null : fallback.getFirst();
}
private static CaseRenderBlock evidence(
StudioDirective directive,
Map<String, ResolvedAssetView> assetsByKey,
List<String> warnings) {
String key = directive.attribute("key", "");
if (key.isBlank()) {
warnings.add("EVIDENCE_WITHOUT_KEY");
return null;
}
ResolvedAssetView resolved = assetsByKey.get(key);
if (resolved == null) {
// 미해결 key 를 임의 경로로 채워 넣지 않는다 — 렌더 결과가 존재하지 않는 파일을 가리키게 된다.
// 게시는 검증이 막고, 미리보기에서는 이 경고가 사용자에게 무엇이 빠졌는지 알려준다.
warnings.add("UNRESOLVED_ASSET_KEY:" + key);
return null;
}
EvidenceFigureBlock block = new EvidenceFigureBlock();
block.setType(EvidenceFigureBlock.TypeEnum.EVIDENCE_FIGURE);
block.setKey(key);
block.setAlt(directive.attribute("alt", ""));
block.setCaption(directive.attribute("caption", ""));
block.setZoom(directive.booleanAttribute("zoom"));
ResolvedAsset asset = new ResolvedAsset();
asset.setAssetId(resolved.assetId());
asset.setAssetKey(resolved.assetKey());
asset.setMediaType(resolved.mediaType());
asset.setPublicPath(resolved.publicPath());
asset.setWidth(resolved.width());
asset.setHeight(resolved.height());
asset.setDecorative(resolved.decorative());
block.setAsset(asset);
return block;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* {@code :::name key="value" ...} 한 줄을 이름과 속성으로 나눈다.
*
* <p>directive 를 commonmark 확장이 아니라 줄 단위 스캔으로 다루는 이유: v1 문법에서 directive 는 중첩이 없고 줄 맨 앞에서만 열린다(설계
* 05장 §3.2, §4). 줄 스캔이면 동작이 눈으로 확인되고, 지원하지 않는 directive 를 "조용히 다른 것으로 해석"하는 일이 구조적으로 생기지 않는다.
*/
record StudioDirective(String name, String argument, Map<String, String> attributes) {
private static final Pattern OPENING = Pattern.compile("^:::([A-Za-z][A-Za-z0-9_-]*)\\s*(.*)$");
private static final Pattern ATTRIBUTE = Pattern.compile("([A-Za-z][A-Za-z0-9_-]*)=\"([^\"]*)\"");
static StudioDirective parse(String line) {
Matcher opening = OPENING.matcher(line.strip());
if (!opening.matches()) {
return null;
}
String rest = opening.group(2).strip();
Map<String, String> attributes = new LinkedHashMap<>();
Matcher attribute = ATTRIBUTE.matcher(rest);
int firstAttributeStart = rest.length();
while (attribute.find()) {
if (attribute.start() < firstAttributeStart) {
firstAttributeStart = attribute.start();
}
attributes.put(attribute.group(1), attribute.group(2));
}
String argument = rest.substring(0, firstAttributeStart).strip();
return new StudioDirective(opening.group(1), argument, attributes);
}
String attribute(String name, String fallback) {
String value = attributes.get(name);
return value == null ? fallback : value;
}
boolean booleanAttribute(String name) {
return "true".equalsIgnoreCase(attributes.get(name));
}
}
@@ -0,0 +1,122 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.cursor.CursorCodec;
import dev.caskeleton.adapter.inbound.web.cursor.CursorException;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
import org.springframework.stereotype.Component;
/**
* 계약이 "opaque 하고 정규화된 필터·정렬에 결합된 커서"라고 정한 것을 실제로 그렇게 만든다.
*
* <p>필터 지문을 커서 안에 함께 서명한다 — 그래야 필터를 바꾼 뒤 옛 커서를 재사용하는 요청을 거절할 수 있다. 거절하지 않으면 정렬 키의 의미가 달라진 채로 페이지가
* 이어져 사용자에게는 항목이 조용히 사라지거나 중복돼 보인다.
*/
@Component
public class StudioCursors {
private static final String SEPARATOR = "~";
/**
* 지문 입력의 필드 구분자(ASCII unit separator). 사용자 입력에 나타나지 않는 제어문자라 인접한 필드가 서로 섞여 같은 지문을 만드는 일이 없다 — 예를
* 들어 구분자가 없으면 (kind="A", q="B")와 (kind="AB", q="")가 같은 값이 된다.
*/
private static final char FIELD_SEPARATOR = (char) 0x1f;
private final CursorCodec codec;
private final Clock clock;
public StudioCursors(StudioSettings settings, Clock clock) {
this.codec =
new CursorCodec(
settings.cursorSigningKey().getBytes(StandardCharsets.UTF_8), CursorCodec.DEFAULT_TTL);
this.clock = clock;
}
/** 이 페이지 요청의 필터·정렬을 대표하는 값. 커서에 함께 실린다. */
public static String fingerprint(String... normalizedFilterParts) {
StringBuilder joined = new StringBuilder();
for (String part : normalizedFilterParts) {
joined.append(part == null ? "" : part).append(FIELD_SEPARATOR);
}
try {
byte[] digest =
MessageDigest.getInstance("SHA-256")
.digest(joined.toString().getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
public String encode(String payload, String fingerprint) {
String body =
fingerprint
+ SEPARATOR
+ Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(payload.getBytes(StandardCharsets.UTF_8));
return codec.encode(body, clock.instant());
}
/**
* 커서를 풀어 페이지 위치로 돌려준다.
*
* @throws StudioException 서명·만료·필터 지문 중 하나라도 맞지 않으면 {@code REQUEST_VALIDATION_FAILED}
*/
public DocumentCursorPosition decode(String cursor, String fingerprint, boolean titleSort) {
String body;
try {
body = codec.decode(cursor, clock.instant());
} catch (CursorException e) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "cursor is not usable: " + e.getMessage());
}
int separator = body.indexOf(SEPARATOR);
if (separator <= 0) {
throw malformed();
}
if (!fingerprint.equals(body.substring(0, separator))) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"cursor was issued for a different filter or sort; start from the first page");
}
String payload =
new String(
Base64.getUrlDecoder().decode(body.substring(separator + SEPARATOR.length())),
StandardCharsets.UTF_8);
int pipe = payload.lastIndexOf('|');
if (pipe <= 0) {
throw malformed();
}
String head = payload.substring(0, pipe);
UUID id;
try {
id = UUID.fromString(payload.substring(pipe + 1));
} catch (IllegalArgumentException e) {
throw malformed();
}
if (titleSort) {
return new DocumentCursorPosition(null, head, id);
}
try {
return new DocumentCursorPosition(Instant.parse(head), null, id);
} catch (DateTimeParseException e) {
throw malformed();
}
}
private static StudioException malformed() {
return StudioException.of(StudioError.REQUEST_VALIDATION_FAILED, "cursor is malformed");
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyKeySupport;
import dev.caskeleton.application.idempotency.IdempotencyContext;
import dev.caskeleton.application.idempotency.IdempotencyExecutor;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
/**
* 계약이 모든 mutation 에 요구하는 {@code Idempotency-Key} 처리(spec §8.2).
*
* <p>재생 여부를 스스로 판단하지 않고 <b>동작이 실제로 실행됐는지</b>로 안다 — 저장소를 미리 들여다보고 판정하면 그 사이에 다른 요청이 끼어들 수 있어 헤더가
* 거짓말을 하게 된다. 실행되지 않았다면 결과는 재생된 것이다.
*
* <p>{@code IdempotencyExecutor} 는 provider 가 {@code disabled} 인 배포에는 빈이 없다. 그때는 키의 존재만 계약대로 강제하고
* 실행은 그대로 통과시킨다 — 여기서 빈을 필수로 요구하면 그런 배포는 Studio 컨트롤러 때문에 부팅 자체가 실패한다.
*/
@Component
public class StudioIdempotency {
/**
* 계약 {@code components.headers.IdempotencyReplayed}. {@code ApiHeaders}에 두지 않는 이유는 그 파일이 템플릿
* SSOT({@code wiki/projects/ca-tmpl/registries/headers.yaml}) 소유라 이 기능이 손대면 다음 동기화에서 충돌하기 때문이다.
*/
public static final String IDEMPOTENCY_REPLAYED = "Idempotency-Replayed";
/** 계약 {@code components.parameters.IdempotencyKey.schema.maxLength}. */
private static final int MAX_KEY_LENGTH = 200;
private final ObjectProvider<IdempotencyExecutor> executors;
private final IdempotencyKeySupport keys;
public StudioIdempotency(
ObjectProvider<IdempotencyExecutor> executors, IdempotencyKeySupport keys) {
this.executors = executors;
this.keys = keys;
}
/**
* 결과와 그 결과가 재생된 것인지 여부.
*
* @param <R> 동작의 결과 타입
*/
public record Outcome<R>(R result, boolean replayed) {}
public <R> Outcome<R> run(
HttpServletRequest request,
String operationId,
Object requestPayload,
Class<R> responseType,
Supplier<R> action) {
String key = requireKey(request);
IdempotencyExecutor executor = executors.getIfAvailable();
if (executor == null) {
return new Outcome<>(action.get(), false);
}
AtomicBoolean executed = new AtomicBoolean(false);
R result =
executor.execute(
IdempotencyContext.of(keys.scope(key, operationId), keys.fingerprint(requestPayload)),
() -> {
executed.set(true);
return action.get();
},
keys.codec(responseType));
return new Outcome<>(result, !executed.get());
}
private String requireKey(HttpServletRequest request) {
Optional<String> key = keys.idempotencyKey(request);
if (key.isEmpty()) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED, "the Idempotency-Key header is required");
}
if (key.get().length() > MAX_KEY_LENGTH) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"the Idempotency-Key header must be at most " + MAX_KEY_LENGTH + " characters");
}
return key.get();
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
/**
* 감사 컬럼({@code created_by}/{@code updated_by})에 남길 주체를 뽑는다.
*
* <p>{@code idpUserId} 를 쓴다 — 이메일이나 표시 이름은 사용자가 바꿀 수 있어 과거 기록의 주체를 되짚을 수 없게 된다.
*/
public final class StudioPrincipals {
private StudioPrincipals() {}
public static String require(AuthenticatedPrincipal principal) {
if (principal == null || principal.idpUserId() == null || principal.idpUserId().isBlank()) {
throw StudioException.of(
StudioError.AUTHENTICATION_REQUIRED, "the request has no usable authenticated principal");
}
return principal.idpUserId();
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.support;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* {@code ca-skeleton.techlog.studio.*}. Studio 고유 설정을 템플릿 소유 파일({@code PresentationSettings} 등)에 섞지
* 않고 여기 모은다 — 그 파일들은 template sync 대상이라 이 기능이 손대면 다음 동기화에서 충돌한다.
*
* @param cursorSigningKey 목록 커서 서명 키. 비어 있으면 개발용 값으로 대체하고 경고한다 — 커서에는 권한이 실리지 않으므로 부팅을 막을 사유는 아니지만,
* 인스턴스마다 값이 다르면 한 인스턴스가 발급한 커서를 다른 인스턴스가 거부한다.
* @param validationTtl 검증 결과가 유효한 기간({@code studio_validation.valid_until})
* @param previewTtl 미리보기가 유효한 기간({@code studio_preview.expires_at})
*/
@ConfigurationProperties(prefix = "ca-skeleton.techlog.studio")
public record StudioSettings(String cursorSigningKey, Duration validationTtl, Duration previewTtl) {
private static final Logger log = LoggerFactory.getLogger(StudioSettings.class);
private static final String DEV_CURSOR_KEY = "__LOCAL_DEV_techlog_studio_cursor_signing_key";
private static final int MIN_KEY_BYTES = 16;
private static final Duration DEFAULT_VALIDATION_TTL = Duration.ofHours(1);
private static final Duration DEFAULT_PREVIEW_TTL = Duration.ofHours(24);
public StudioSettings {
if (cursorSigningKey == null
|| cursorSigningKey.getBytes(StandardCharsets.UTF_8).length < MIN_KEY_BYTES) {
log.warn(
"APP_STUDIO_CURSOR_SIGNING_KEY is missing or shorter than {} bytes; using a development"
+ " key. Cursors issued by one instance verify on another only while every instance"
+ " falls back to the same value.",
MIN_KEY_BYTES);
cursorSigningKey = DEV_CURSOR_KEY;
}
if (validationTtl == null || validationTtl.isZero() || validationTtl.isNegative()) {
validationTtl = DEFAULT_VALIDATION_TTL;
}
if (previewTtl == null || previewTtl.isZero() || previewTtl.isNegative()) {
previewTtl = DEFAULT_PREVIEW_TTL;
}
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.contract;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CasePublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseWorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.NextAction;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.PublicRenderModel;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.QuestionInput;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopy;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyDetail;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.WorkingCopyInput;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.ObjectMapper;
/**
* 계약의 discriminator union이 Jackson 양방향으로 계약대로 동작하는지 고정한다.
*
* <p>이 게이트가 필요한 이유는 <b>컴파일이 이걸 못 잡기 때문</b>이다. Plan 01에서 {@code useOneOfInterfaces=false}로 생성한
* union은 컴파일 오류 0개였지만 런타임에는 양방향 모두 계약을 위반했다 — 역직렬화는 {@code InvalidTypeIdException}("CaseInput not
* subtype of WorkingCopyInput"), 직렬화는 판별 필드에 {@code kind} 값 대신 클래스 simple name. 지금은 {@code
* prepareStudioCodegenSpec}이 계약에서 {@code x-implements}와 union interface를 파생시켜 고쳤고, 이 테스트가 그 파생 배선이
* 살아 있는지를 지킨다. 파생이 깨지면 여기서 빨간불이 난다.
*
* <p>{@code new ObjectMapper()}는 이 모듈의 다른 테스트와 같은 관용구다 — Jackson 3({@code tools.jackson})이며 앱의 HTTP
* 변환기와 같은 계열이다. Jackson 2 ({@code com.fasterxml.jackson.databind})로 검증하면 프로덕션에서 실제로 쓰이지 않는 경로를 재는
* 셈이라 의미가 없다.
*/
class StudioContractUnionJacksonTest {
private final ObjectMapper mapper = new ObjectMapper();
private static CaseWorkingCopy caseWorkingCopy() {
CaseWorkingCopy document = new CaseWorkingCopy();
document.setKind(CaseWorkingCopy.KindEnum.CASE);
document.setId(UUID.fromString("00000000-0000-4000-8000-000000000001"));
document.setVersion(1);
document.setTitle("제목");
document.setSlug("some-slug");
document.setSummary("요약");
document.setProblem("문제");
document.setConclusion("결론");
document.setEnvironment("환경");
document.setReproduction("재현");
document.setBodyMarkdown("본문");
return document;
}
@Test
void workingCopyUnionSerializesTheContractDiscriminatorAndRoundTrips() {
WorkingCopyDetail detail = new WorkingCopyDetail();
detail.setDocument(caseWorkingCopy());
detail.setDependencyRevision("rev-1");
detail.setNextAction(NextAction.VALIDATE);
String json = mapper.writeValueAsString(detail);
// 판별 필드는 계약이 정한 값이어야 한다. 클래스 이름("CaseWorkingCopy")이 나가면 프론트가 깨진다.
assertThat(json).contains("\"kind\":\"CASE\"");
assertThat(json).doesNotContain("CaseWorkingCopy");
// 판별 필드가 두 번 나가면 안 된다 — union interface가 As.EXISTING_PROPERTY인 이유다.
assertThat(json.split("\"kind\":", -1)).hasSize(2);
// slug는 문자열이다. 계약의 문자열 oneOf를 접지 않으면 여기서 {} 가 나간다.
assertThat(json).contains("\"slug\":\"some-slug\"");
WorkingCopyDetail back = mapper.readValue(json, WorkingCopyDetail.class);
assertThat(back.getDocument()).isInstanceOf(CaseWorkingCopy.class);
assertThat(((CaseWorkingCopy) back.getDocument()).getProblem()).isEqualTo("문제");
}
@Test
void workingCopyInputUnionRoundTripsThroughTheDeclaredUnionType() {
QuestionInput input = new QuestionInput();
input.setKind(QuestionInput.KindEnum.QUESTION);
input.setTitle("질문");
input.setSlug("");
input.setSummary("요약");
input.setNextValidation("다음 검증");
WorkingCopyInput declared = input;
String json = mapper.writeValueAsString(declared);
assertThat(json).contains("\"kind\":\"QUESTION\"");
WorkingCopyInput back = mapper.readValue(json, WorkingCopyInput.class);
assertThat(back).isInstanceOf(QuestionInput.class);
assertThat(((QuestionInput) back).getNextValidation()).isEqualTo("다음 검증");
}
@Test
void renderBlockAndInlineUnionsRoundTripInsideCollections() {
InlineText text = new InlineText();
text.setType(InlineText.TypeEnum.TEXT);
text.setText("본문 조각");
HeadingBlock heading = new HeadingBlock();
heading.setType(HeadingBlock.TypeEnum.HEADING);
heading.setId("h-1");
heading.setLevel(2);
heading.setContent(List.of(text));
CasePublicRenderModel model = new CasePublicRenderModel();
model.setKind(CasePublicRenderModel.KindEnum.CASE);
model.setSlug("some-slug");
model.setTitle("제목");
model.setSummary("요약");
model.setPublicPath("/case/some-slug");
model.setBodyBlocks(List.of(heading));
PublicRenderModel declared = model;
String json = mapper.writeValueAsString(declared);
assertThat(json).contains("\"kind\":\"CASE\"");
assertThat(json).contains("\"type\":\"HEADING\"");
assertThat(json).contains("\"type\":\"TEXT\"");
PublicRenderModel back = mapper.readValue(json, PublicRenderModel.class);
assertThat(back).isInstanceOf(CasePublicRenderModel.class);
List<CaseRenderBlock> blocks = ((CasePublicRenderModel) back).getBodyBlocks();
assertThat(blocks).hasSize(1).first().isInstanceOf(HeadingBlock.class);
List<Inline> content = ((HeadingBlock) blocks.get(0)).getContent();
assertThat(content).hasSize(1).first().isInstanceOf(InlineText.class);
assertThat(((InlineText) content.get(0)).getText()).isEqualTo("본문 조각");
}
@Test
void unionDeserializationRejectsAnUnknownDiscriminatorInsteadOfSilentlyDroppingIt() {
String json = "{\"kind\":\"NOT_A_KIND\",\"title\":\"제목\"}";
assertThat(
org.assertj.core.api.Assertions.catchThrowable(
() -> mapper.readValue(json, WorkingCopy.class)))
.isNotNull();
}
}
@@ -5,8 +5,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
@@ -58,6 +60,7 @@ import org.springframework.test.web.servlet.MockMvc;
excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({
PresentationWebConfig.class,
StudioCatalogController.class,
StudioExceptionHandler.class,
GlobalExceptionHandler.class,
@@ -98,6 +101,16 @@ class StudioCatalogBindingErrorEnvelopeTest {
static class TestBeans {
/**
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
*/
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
@Bean
ListCatalogUseCase listCatalogUseCase() {
CatalogQueryPort neverInvoked =
@@ -6,9 +6,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import java.util.List;
@@ -46,6 +48,7 @@ import org.springframework.test.web.servlet.MockMvc;
*/
@WebMvcTest(controllers = StudioSessionController.class)
@Import({
PresentationWebConfig.class,
StudioSessionController.class,
EnvelopeBodyAdvice.class,
StudioExceptionHandler.class,
@@ -89,6 +92,16 @@ class StudioSessionCsrfDisabledTest {
@EnableWebSecurity
static class SecurityTestConfig {
/**
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
*/
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
@Bean
SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
@@ -7,8 +7,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;
import java.util.List;
import java.util.Set;
@@ -68,6 +70,7 @@ import org.springframework.test.web.servlet.MockMvc;
*/
@WebMvcTest(controllers = StudioSessionController.class)
@Import({
PresentationWebConfig.class,
StudioSessionController.class,
EnvelopeBodyAdvice.class,
StudioSessionEnvelopeTest.SecurityTestConfig.class
@@ -111,6 +114,16 @@ class StudioSessionEnvelopeTest {
@EnableWebSecurity
static class SecurityTestConfig {
/**
* PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은
* 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시
* 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다.
*/
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
@Bean
SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
@@ -0,0 +1,224 @@
package dev.caskeleton.adapter.inbound.web.techlog.studio.render;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.BlockquoteBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CalloutBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CaseRenderBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.CodeBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.EvidenceFigureBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineCode;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineStrong;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.InlineText;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.port.out.ContentAnalyzerPort;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
/**
* 렌더러가 설계 05장이 정한 문법을 계약 블록으로 옮기는지 고정한다.
*
* <p>여기서 지키는 것은 "그럴듯하게 렌더링된다"가 아니라 <b>계약 제약을 어기지 않는다</b>이다 — heading level 범위, 표의 최소 열 수, 미해결 Asset
* 을 지어내지 않는 것. 이것들이 깨지면 미리보기는 화면에 나오지만 게시된 문서가 계약을 위반한다.
*/
class StudioContentRendererTest {
private final StudioContentRenderer renderer = new StudioContentRenderer();
private static ResolvedAssetView asset(String key, boolean decorative) {
return new ResolvedAssetView(
UUID.fromString("00000000-0000-4000-8000-000000000009"),
key,
"image/png",
"/media/00000000-0000-4000-8000-000000000009",
800,
600,
decorative);
}
@Test
void headingsGetContractLevelsAndStableIds() {
var rendered =
renderer.render(
"""
# Authorization Code Flow
###### 아주 깊은 제목
## 결론
## 결론
""",
Map.of());
List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
// 계약의 HeadingBlock.level 은 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("결론");
// 같은 제목이 두 번이면 두 번째부터 suffix 가 붙는다(설계 05장 §6 8단계).
assertThat(((HeadingBlock) blocks.get(3)).getId()).isEqualTo("결론-2");
}
@Test
void headingIdFollowsTheDesignedNormalisation() {
var rendered = renderer.render("## JPA N+1 문제\n", Map.of());
assertThat(((HeadingBlock) rendered.blocks().getFirst()).getId()).isEqualTo("jpa-n-1-문제");
}
@Test
void inlineMarkupBecomesTheContractInlineUnion() {
var rendered = renderer.render("본문 **강조** 와 `code` 조각\n", Map.of());
ParagraphBlock paragraph = (ParagraphBlock) rendered.blocks().getFirst();
assertThat(paragraph.getContent()).hasSize(5);
assertThat(paragraph.getContent().get(0)).isInstanceOf(InlineText.class);
assertThat(paragraph.getContent().get(1)).isInstanceOf(InlineStrong.class);
assertThat(paragraph.getContent().get(3)).isInstanceOf(InlineCode.class);
assertThat(((InlineCode) paragraph.getContent().get(3)).getCode()).isEqualTo("code");
}
@Test
void listsCodeAndTablesBecomeTheirContractBlocks() {
var rendered =
renderer.render(
"""
- 첫째
- 둘째
```java
int x = 1;
```
| 이름 | 값 |
| --- | ---: |
| a | 1 |
""",
Map.of());
List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks.get(0)).isInstanceOf(UnorderedListBlock.class);
assertThat(((UnorderedListBlock) blocks.get(0)).getItems()).hasSize(2);
CodeBlock code = (CodeBlock) blocks.get(1);
assertThat(code.getLanguage()).isEqualTo("java");
assertThat(code.getCode()).contains("int x = 1;");
DataTableBlock table = (DataTableBlock) blocks.get(2);
assertThat(table.getColumns()).hasSize(2);
assertThat(table.getColumns().get(1).getAlignment())
.isEqualTo(
dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn
.AlignmentEnum.RIGHT);
assertThat(table.getRows()).hasSize(1);
assertThat(table.getRows().getFirst().getCells().getFirst().getColumnId()).isEqualTo("col-1");
}
@Test
void calloutDirectivesBecomeCalloutBlocksWithTheContractTone() {
var rendered =
renderer.render(
"""
:::warning 주의
Presigned URL 을 본문에 저장하지 않습니다.
:::
:::note
확인했습니다.
:::
""",
Map.of());
CalloutBlock warning = (CalloutBlock) rendered.blocks().get(0);
assertThat(warning.getTone()).isEqualTo(CalloutBlock.ToneEnum.WARNING);
assertThat(warning.getLabel()).isEqualTo("주의");
CalloutBlock note = (CalloutBlock) rendered.blocks().get(1);
// 설계는 note/tip/warning/danger 네 가지를 허용하지만 계약의 tone 은 두 가지다.
assertThat(note.getTone()).isEqualTo(CalloutBlock.ToneEnum.INFO);
}
@Test
void anUnknownDirectiveDegradesToAQuoteAndWarnsInsteadOfBeingSilentlyReinterpreted() {
var rendered =
renderer.render(
"""
:::mermaid
graph TD;
:::
""",
Map.of());
assertThat(rendered.blocks().getFirst()).isInstanceOf(BlockquoteBlock.class);
assertThat(rendered.warnings()).contains("UNSUPPORTED_DIRECTIVE:mermaid");
}
@Test
void evidenceDirectiveResolvesThroughTheInjectedAssetManifest() {
var rendered =
renderer.render(
":::evidence key=\"flow\" alt=\"요청 흐름\" caption=\"흐름도\" zoom=\"true\"\n",
Map.of("flow", asset("flow", false)));
EvidenceFigureBlock figure = (EvidenceFigureBlock) rendered.blocks().getFirst();
assertThat(figure.getKey()).isEqualTo("flow");
assertThat(figure.getAlt()).isEqualTo("요청 흐름");
assertThat(figure.getCaption()).isEqualTo("흐름도");
assertThat(figure.getZoom()).isTrue();
assertThat(figure.getAsset().getPublicPath())
.isEqualTo("/media/00000000-0000-4000-8000-000000000009");
}
@Test
void anUnresolvedAssetKeyIsDroppedWithAWarningRatherThanPointedAtNothing() {
var rendered = renderer.render(":::evidence key=\"missing\" alt=\"x\"\n", Map.of());
// 임의 경로를 지어내면 렌더 결과가 존재하지 않는 파일을 가리킨다.
assertThat(rendered.blocks()).isEmpty();
assertThat(rendered.warnings()).contains("UNRESOLVED_ASSET_KEY:missing");
}
@Test
void directiveSyntaxInsideACodeFenceIsNotADirective() {
var rendered =
renderer.render(
"""
```markdown
:::evidence key="example"
```
""",
Map.of());
assertThat(rendered.blocks().getFirst()).isInstanceOf(CodeBlock.class);
assertThat(rendered.warnings()).isEmpty();
}
@Test
void analysisReportsEveryUsageSiteSeparatelyBecauseAltIsPerUsage() {
ContentAnalyzerPort.ContentAnalysis analysis =
renderer.analyze(
"""
:::evidence key="flow" alt="첫 번째"
:::evidence key="flow" alt=""
:::mermaid
:::
""");
assertThat(analysis.assetUsages())
.extracting(ContentAnalyzerPort.AssetUsage::assetKey)
.containsExactly("flow", "flow");
assertThat(analysis.assetUsages().get(0).alt()).isEqualTo("첫 번째");
assertThat(analysis.assetUsages().get(1).alt()).isEmpty();
assertThat(analysis.unsupportedDirectives()).containsExactly("mermaid");
}
}
@@ -26,6 +26,12 @@ dependencies {
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Studio 편집본의 jsonb 컬럼(reference_detail.rules/examples, open_question.options,
// project_decision.consequences, applies_to/excluded_scope)을 읽고 쓰려면 이 모듈에 JSON 매퍼가
// 필요하다. Jackson 2 databind 가 data-jpa 경유로 이미 classpath 에 딸려오지만 그건 선언하지 않은
// 우연한 가용성이고, 이 저장소는 dependency locking 을 쓴다 — 앱의 다른 계층과 같은
// Jackson 3(tools.jackson)을 명시적으로 선언한다.
implementation 'org.springframework.boot:spring-boot-starter-jackson'
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
implementation 'org.springframework.integration:spring-integration-jdbc'
@@ -117,6 +123,29 @@ def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTe
'postgresqlTechLogCatalogQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
// 슬라이스 2~5: Studio 영속 경로 전체(편집본 4종 왕복, 낙관적 잠금, union 목록, 의존 해석,
// validation/preview artifact, 게시 20단계, 게시 취소, Asset)를 실제 PostgreSQL 위에서 돌린다.
// 표준 check 는 Testcontainers 를 돌리지 않으므로, 이 태스크가 없으면 그 SQL 은 한 번도 실행되지
// 않은 채로 빌드가 통과한다.
def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogStudioPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종,
// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를
// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다.
def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogPublicPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest')
// studio-management-v1: 작업본 삭제 SQL 을 실제 PostgreSQL 위에서 돌린다. 이 태스크는 사고
// 하나에서 나왔다 — 참조 검사가 없는 열(public_resource_projection.document_id)을 읽었고,
// 컴파일과 단위 테스트를 모두 통과한 뒤 작성자가 삭제를 누른 순간 500 이 됐다. 위 둘과 같은
// 이유이며, 삭제 경로만 그 밖에 있었다.
def postgresqlTechLogManagementPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogManagementPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.management.ManagementPersistenceIntegrationTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
@@ -152,7 +152,7 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlInt
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -163,7 +163,7 @@ org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegr
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -205,7 +205,7 @@ org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspat
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -12,9 +12,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
*
* <p>Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the
* two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming
* {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that
* caused it.
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming {@code
* OutboxClaimRepository} — a symptom several layers away from the misspelled value that caused it.
*/
@ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX)
public record PersistenceVendorSettings(Vendor vendor) {
@@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable;
* H2 atomic scope claim.
*
* <p>H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL
* statement does not port. The standard {@code MERGE ... USING} does, and carries the same
* meaning in one statement:
* statement does not port. The standard {@code MERGE ... USING} does, and carries the same meaning
* in one statement:
*
* <ul>
* <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row);
@@ -15,12 +15,12 @@ import org.springframework.jdbc.core.JdbcOperations;
* <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)}
* — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives
* the transaction on a pooled connection. It is not left stale in practice because the
* transaction port applies these before every transaction, so each one overwrites the last;
* a connection borrowed outside that path keeps the previous transaction's guard.
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to
* {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the
* database here. It is left to the caller-side deadline the transaction port already
* enforces, rather than silently reported as applied.
* transaction port applies these before every transaction, so each one overwrites the last; a
* connection borrowed outside that path keeps the previous transaction's guard.
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to {@code
* idle_in_transaction_session_timeout}, so that budget cannot be pushed into the database
* here. It is left to the caller-side deadline the transaction port already enforces, rather
* than silently reported as applied.
* </ul>
*
* <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They
@@ -15,13 +15,13 @@ import org.springframework.jdbc.core.JdbcOperations;
/**
* H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers,
* implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the
* {@code local} profile sets.
* implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the {@code
* local} profile sets.
*
* <p><b>No Flyway location customizer, deliberately.</b> The PostgreSQL vendor points Flyway at
* {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local
* profile turns Flyway off and lets Hibernate derive the schema from the entities. Two
* consequences worth stating out loud:
* profile turns Flyway off and lets Hibernate derive the schema from the entities. Two consequences
* worth stating out loud:
*
* <ul>
* <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery
@@ -30,12 +30,12 @@ import org.springframework.jdbc.core.JdbcOperations;
* there will fail on a missing table rather than silently misbehave.
* <li>A fork that enables Flyway while this vendor is selected gets no location override, so
* Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own
* {@code FlywayConfigurationCustomizer} naming an H2 location.
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own {@code
* FlywayConfigurationCustomizer} naming an H2 location.
* </ul>
*
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency
* fidelity stay with the real-PostgreSQL integration suites.
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and
* vendor-concurrency fidelity stay with the real-PostgreSQL integration suites.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
@@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope}
@@ -162,7 +164,14 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort {
row.getExpiresAt()));
}
/**
* {@code deleteByScope} 는 {@code @Modifying} 벌크 delete 이므로 활성 트랜잭션을 요구한다. 이 메서드는 {@code
* IdempotencyExecutor} 의 실패 경로에서 호출되는데 그 지점에는 트랜잭션이 없다 — 예약 레코드를 지우려다 {@code
* TransactionRequiredException} 을 던져 원래 실패를 덮고 있었다(403 이 500 으로 바뀌고 로그에 원인이 남지 않았다). REQUIRES_NEW
* 인 이유: 정리는 실패한 작업의 롤백에 휩쓸리면 안 된다.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void discard(IdempotencyScope scope) {
repository.deleteByScope(
IdempotencyRecordEntityMapper.tenantColumn(scope),
@@ -0,0 +1,91 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code studio_preview} 접근.
*
* <p>{@code render_model}은 렌더러가 만든 계약 모양 그대로를 문자열로 저장하고 그대로 돌려준다 — 중간에서 파싱했다 다시 직렬화하면 사용자가 확인한 화면과
* 저장된 화면이 미묘하게 달라질 수 있고, 게시 시점에 그대로 snapshot 으로 옮겨야 하는 값이라 그 차이가 공개 결과까지 간다.
*/
@Repository
public class JdbcPreviewArtifactAdapter implements PreviewArtifactPort {
private final JdbcClient jdbcClient;
public JdbcPreviewArtifactAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Optional<PublicPreviewView> latestFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
selectColumns()
+ " WHERE source_kind = :kind AND source_id = :id"
+ " ORDER BY created_at DESC LIMIT 1")
.param("kind", kind.name())
.param("id", documentId)
.query(JdbcPreviewArtifactAdapter::mapRow)
.optional();
}
@Override
public Optional<PublicPreviewView> findById(UUID previewId) {
return jdbcClient
.sql(selectColumns() + " WHERE preview_id = :id")
.param("id", previewId)
.query(JdbcPreviewArtifactAdapter::mapRow)
.optional();
}
@Override
public PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal) {
jdbcClient
.sql(
"INSERT INTO studio_preview (preview_id, source_kind, source_id, source_version,"
+ " validation_id, dependency_revision, render_model, created_at, expires_at,"
+ " created_by)"
+ " VALUES (:previewId, :kind, :sourceId, :sourceVersion, :validationId,"
+ " :dependencyRevision, CAST(:renderModel AS jsonb), :createdAt, :expiresAt,"
+ " :principal)")
.param("previewId", preview.previewId())
.param("kind", kind.name())
.param("sourceId", preview.documentId())
.param("sourceVersion", preview.previewVersion())
.param("validationId", preview.validationId())
.param("dependencyRevision", preview.dependencyRevision())
.param("renderModel", preview.renderModelJson())
.param("createdAt", Timestamp.from(preview.createdAt()))
.param("expiresAt", Timestamp.from(preview.expiresAt()))
.param("principal", principal)
.update();
return preview;
}
private static String selectColumns() {
return "SELECT preview_id, source_id, source_version, validation_id, dependency_revision,"
+ " render_model, created_at, expires_at FROM studio_preview";
}
private static PublicPreviewView mapRow(ResultSet rs, int rowNum) throws SQLException {
return new PublicPreviewView(
rs.getObject("preview_id", UUID.class),
rs.getObject("source_id", UUID.class),
rs.getLong("source_version"),
rs.getObject("validation_id", UUID.class),
rs.getString("dependency_revision"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("expires_at").toInstant(),
rs.getString("render_model"));
}
}
@@ -0,0 +1,143 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ValidationIssueView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationSeverity;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.shared.error.MappingException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* {@code studio_validation} 접근. 검증 결과는 일급 artifact이며 실행 후 버리지 않는다(spec §7.3).
*
* <p>{@code studio_validation}에는 UPDATE가 없다. 재검증은 새 행이며, 이전 결과는 "그때 이 버전은 이런 상태였다"는 사실로 남는다 — 덮어쓰면
* 게시 시점에 어떤 근거로 통과했는지 되짚을 수 없다.
*/
@Repository
public class JdbcValidationArtifactAdapter implements ValidationArtifactPort {
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
public JdbcValidationArtifactAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
}
@Override
public Optional<ValidationReportView> latestFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
selectColumns()
+ " WHERE source_kind = :kind AND source_id = :id"
+ " ORDER BY validated_at DESC LIMIT 1")
.param("kind", kind.name())
.param("id", documentId)
.query(this::mapRow)
.optional();
}
@Override
public Optional<ValidationReportView> findById(UUID validationId) {
return jdbcClient
.sql(selectColumns() + " WHERE validation_id = :id")
.param("id", validationId)
.query(this::mapRow)
.optional();
}
@Override
public ValidationReportView save(RecordKind kind, ValidationReportView report, String principal) {
jdbcClient
.sql(
"INSERT INTO studio_validation (validation_id, source_kind, source_id,"
+ " validated_version, status, issues, dependency_revision, validated_at,"
+ " valid_until, created_by)"
+ " VALUES (:validationId, :kind, :sourceId, :validatedVersion, :status,"
+ " CAST(:issues AS jsonb), :dependencyRevision, :validatedAt, :validUntil,"
+ " :principal)")
.param("validationId", report.validationId())
.param("kind", kind.name())
.param("sourceId", report.documentId())
.param("validatedVersion", report.validatedVersion())
.param("status", report.status().name())
.param("issues", issuesToJson(report.issues()))
.param("dependencyRevision", report.dependencyRevision())
.param("validatedAt", Timestamp.from(report.validatedAt()))
.param("validUntil", Timestamp.from(report.validUntil()))
.param("principal", principal)
.update();
return report;
}
private static String selectColumns() {
return "SELECT validation_id, source_id, validated_version, status, issues,"
+ " dependency_revision, validated_at, valid_until FROM studio_validation";
}
private ValidationReportView mapRow(ResultSet rs, int rowNum) throws SQLException {
return new ValidationReportView(
rs.getObject("validation_id", UUID.class),
rs.getObject("source_id", UUID.class),
rs.getLong("validated_version"),
ValidationStatus.valueOf(rs.getString("status")),
issuesFromJson(rs.getString("issues")),
rs.getTimestamp("validated_at").toInstant(),
rs.getTimestamp("valid_until").toInstant(),
rs.getString("dependency_revision"));
}
private String issuesToJson(List<ValidationIssueView> issues) {
ArrayNode array = objectMapper.createArrayNode();
for (ValidationIssueView issue : issues) {
ObjectNode node = array.addObject();
node.put("code", issue.code());
node.put("severity", issue.severity().name());
node.put("path", issue.path());
node.put("message", issue.message());
}
try {
return objectMapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise validation issues", e);
}
}
private List<ValidationIssueView> issuesFromJson(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode array = objectMapper.readTree(json);
if (!array.isArray()) {
return List.of();
}
return array
.valueStream()
.map(
node ->
new ValidationIssueView(
node.path("code").asString(""),
ValidationSeverity.valueOf(node.path("severity").asString("ERROR")),
node.path("path").asString(""),
node.path("message").asString("")))
.toList();
} catch (JacksonException e) {
throw new MappingException("failed to read validation issues", e);
}
}
}
@@ -0,0 +1,244 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 작업본 삭제.
*
* <p>작업본 자신의 것(상세, 태그, 나가는 관계)은 {@code ON DELETE CASCADE} 로 따라 지워진다. 하지만 <em>다른</em> 기록이 이쪽을 가리키는
* 참조는 그렇지 않다 — 다른 문서의 관계 대상, 질문의 링크, 프로젝트 소속, 주제의 추천 목록, 결정의 근거 Case 다섯 곳이 CASCADE 없이 걸려 있다. 그대로
* 지우면 외래키 위반이고, 작성자에게는 500 으로 도착한다. 그래서 먼저 확인하고 거절한다 — 남의 기록을 조용히 고쳐 주는 것보다 낫다.
*/
@Repository
public class JdbcDocumentDeletionAdapter implements DocumentDeletionPort {
private final JdbcClient jdbcClient;
public JdbcDocumentDeletionAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
private static DeletableDocument mapDocument(ResultSet rs, int rowNum) throws SQLException {
return new DeletableDocument(
rs.getObject("id", UUID.class),
rs.getString("document_type"),
rs.getString("workflow_status"),
rs.getLong("version"));
}
private static DeletableQuestion mapQuestion(ResultSet rs, int rowNum) throws SQLException {
return new DeletableQuestion(rs.getObject("id", UUID.class), rs.getLong("version"));
}
@Override
public Optional<DeletableDocument> findDocument(UUID id, String documentType) {
return jdbcClient
.sql(
"SELECT id, document_type, workflow_status, version FROM document"
+ " WHERE id = :id AND document_type = :type")
.param("id", id)
.param("type", documentType)
.query(JdbcDocumentDeletionAdapter::mapDocument)
.optional();
}
@Override
public int deleteDocument(UUID id, long expectedVersion) {
return jdbcClient
.sql("DELETE FROM document WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.update();
}
@Override
public boolean documentReferenced(UUID id) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS ("
+ " SELECT 1 FROM document_relation WHERE target_document_id = :id"
+ " UNION ALL SELECT 1 FROM question_document_link WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM project_document_link WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM topic_featured_document WHERE document_id = :id"
+ " UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id"
+ ")")
.param("id", id)
.query(Boolean.class)
.single());
}
/**
* 공개 투영은 {@code document_id} 가 아니라 {@code (resource_type, resource_id)} 로 기록을 가리킨다 — 한 테이블이
* Case·Question·Project·Release 를 모두 담기 때문이다. 외래키도 없다.
*/
@Override
public boolean publiclyProjected(String resourceType, UUID id) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM public_resource_projection"
+ " WHERE resource_type = :type AND resource_id = :id"
+ " AND publication_state = 'ACTIVE')")
.param("type", resourceType)
.param("id", id)
.query(Boolean.class)
.single());
}
@Override
public int deleteProjection(String resourceType, UUID id) {
return jdbcClient
.sql(
"DELETE FROM public_resource_projection"
+ " WHERE resource_type = :type AND resource_id = :id")
.param("type", resourceType)
.param("id", id)
.update();
}
@Override
public boolean questionReferenced(UUID id) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS ("
+ " SELECT 1 FROM project_question_link WHERE question_id = :id"
+ " UNION ALL SELECT 1 FROM home_focus_config WHERE open_question_id = :id"
+ ")")
.param("id", id)
.query(Boolean.class)
.single());
}
private static DeletableDecision mapDecision(ResultSet rs, int rowNum) throws SQLException {
return new DeletableDecision(
rs.getObject("id", UUID.class), rs.getString("decision_status"), rs.getLong("version"));
}
@Override
public Optional<DeletableDecision> findDecision(UUID projectId, UUID decisionId) {
return jdbcClient
.sql(
"SELECT id, decision_status, version FROM project_decision"
+ " WHERE id = :id AND project_id = :projectId")
.param("id", decisionId)
.param("projectId", projectId)
.query(JdbcDocumentDeletionAdapter::mapDecision)
.optional();
}
@Override
public int deleteDecision(UUID decisionId, long expectedVersion) {
return jdbcClient
.sql("DELETE FROM project_decision WHERE id = :id AND version = :expected")
.param("id", decisionId)
.param("expected", expectedVersion)
.update();
}
/**
* {@code superseded_by_id} 는 자기 테이블을 가리키고 {@code home_focus_config} 도 CASCADE 가 아니다. 둘 다 그대로 지우면
* 외래키 위반이 된다.
*/
@Override
public boolean decisionReferenced(UUID decisionId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS ("
+ " SELECT 1 FROM project_decision WHERE superseded_by_id = :id"
+ " UNION ALL SELECT 1 FROM home_focus_config WHERE recent_decision_id = :id"
+ ")")
.param("id", decisionId)
.query(Boolean.class)
.single());
}
@Override
public boolean isCurrentlyPublished(String sourceKind, UUID id) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM publication"
+ " WHERE source_kind = :kind AND source_id = :id AND status = 'PUBLISHED')")
.param("kind", sourceKind)
.param("id", id)
.query(Boolean.class)
.single());
}
/**
* 이벤트와 게시를 한 문장으로 지운다.
*
* <p>둘은 서로를 가리키고, 한쪽 방향만 지연 검사다 — 어떤 순서로 나눠 지워도 중간 상태에서 한쪽 제약이 깨진다. 한 문장 안에서는 외래키 검사가 문장 끝에 한 번
* 도는 덕에 그 중간 상태가 존재하지 않는다. 순서를 맞추는 대신 순서가 필요 없게 만든다.
*/
@Override
public int deletePublicationHistory(String sourceKind, UUID id) {
int snapshots =
jdbcClient
.sql(
"DELETE FROM publication_snapshot WHERE publication_event_id IN"
+ " (SELECT publication_event_id FROM publication_event"
+ " WHERE source_kind = :kind AND source_id = :id)")
.param("kind", sourceKind)
.param("id", id)
.update();
int removed =
jdbcClient
.sql(
"WITH gone_events AS ("
+ " DELETE FROM publication_event"
+ " WHERE source_kind = :kind AND source_id = :id"
+ " RETURNING publication_event_id"
+ ")"
+ " DELETE FROM publication WHERE source_kind = :kind AND source_id = :id")
.param("kind", sourceKind)
.param("id", id)
.update();
return snapshots + removed;
}
/** 미리보기가 검증을 참조하므로 미리보기를 먼저 지운다. */
@Override
public int deleteWorkArtifacts(String sourceKind, UUID id) {
int previews =
jdbcClient
.sql("DELETE FROM studio_preview WHERE source_kind = :kind AND source_id = :id")
.param("kind", sourceKind)
.param("id", id)
.update();
int validations =
jdbcClient
.sql("DELETE FROM studio_validation WHERE source_kind = :kind AND source_id = :id")
.param("kind", sourceKind)
.param("id", id)
.update();
return previews + validations;
}
@Override
public Optional<DeletableQuestion> findQuestion(UUID id) {
return jdbcClient
.sql("SELECT id, version FROM open_question WHERE id = :id")
.param("id", id)
.query(JdbcDocumentDeletionAdapter::mapQuestion)
.optional();
}
@Override
public int deleteQuestion(UUID id, long expectedVersion) {
return jdbcClient
.sql("DELETE FROM open_question WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.update();
}
}
@@ -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();
}
}
@@ -0,0 +1,413 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Project 편집 저장소.
*
* <p>{@code technology_labels} 는 jsonb 다. 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON
* 문자열로 직렬화해 {@code ::jsonb} 로 캐스팅한다.
*/
@Repository
public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
private static final String EDIT_COLUMNS =
"id, version, name, slug, one_line_purpose, purpose_markdown, boundary_markdown,"
+ " system_overview_markdown, phase, current_objective, next_step, technology_labels,"
+ " workflow_status, target_visibility, featured_order, first_published_at,"
+ " last_published_at, updated_at";
private static final String INDEX_COLUMNS =
"id, name, phase, workflow_status, target_visibility, current_objective, next_step,"
+ " updated_at, version, first_published_at, last_published_at";
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
public JdbcProjectRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
}
private static Instant instant(ResultSet rs, String column) throws SQLException {
Timestamp t = rs.getTimestamp(column);
return t == null ? null : t.toInstant();
}
private List<String> labels(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode node = objectMapper.readTree(json);
if (!node.isArray()) {
return List.of();
}
List<String> out = new ArrayList<>();
for (JsonNode item : node) {
out.add(item.asString(""));
}
return List.copyOf(out);
} catch (RuntimeException malformed) {
// 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
// 편집 화면 전체를 막는 대신 빈 목록으로 두고 나머지 필드를 보여준다.
return List.of();
}
}
private String labelsJson(List<String> values) {
return objectMapper.writeValueAsString(values == null ? List.of() : values);
}
private ProjectEditView mapEdit(ResultSet rs, int rowNum) throws SQLException {
return new ProjectEditView(
rs.getObject("id", UUID.class),
rs.getLong("version"),
rs.getString("name"),
rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("purpose_markdown"),
rs.getString("boundary_markdown"),
rs.getString("system_overview_markdown"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
labels(rs.getString("technology_labels")),
topicIdsOf(rs.getObject("id", UUID.class)),
rs.getString("workflow_status"),
rs.getString("target_visibility"),
(Integer) rs.getObject("featured_order"),
instant(rs, "first_published_at"),
instant(rs, "last_published_at"),
instant(rs, "updated_at"));
}
private static ProjectIndexItemView mapIndex(ResultSet rs, int rowNum) throws SQLException {
return new ProjectIndexItemView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("phase"),
rs.getString("workflow_status"),
rs.getString("target_visibility"),
rs.getString("current_objective"),
rs.getString("next_step"),
instant(rs, "updated_at"),
rs.getLong("version"),
instant(rs, "first_published_at"),
instant(rs, "last_published_at"));
}
@Override
public List<ProjectIndexItemView> listAll(int limit, int offset) {
return jdbcClient
.sql(
"SELECT "
+ INDEX_COLUMNS
+ " FROM project ORDER BY updated_at DESC, id"
+ " LIMIT :limit OFFSET :offset")
.param("limit", limit)
.param("offset", offset)
.query(JdbcProjectRepositoryAdapter::mapIndex)
.list();
}
@Override
public int countAll() {
return Optional.ofNullable(
jdbcClient.sql("SELECT COUNT(*) FROM project").query(Integer.class).single())
.orElse(0);
}
@Override
public Optional<ProjectEditView> find(UUID id) {
return jdbcClient
.sql("SELECT " + EDIT_COLUMNS + " FROM project WHERE id = :id")
.param("id", id)
.query(this::mapEdit)
.optional();
}
@Override
public ProjectEditView create(CreateProjectCommand command) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project (id, name, created_by, updated_by) "
+ "VALUES (:id, :name, :actor, :actor)")
.param("id", id)
.param("name", command.title().trim())
.param("actor", command.actor())
.update();
return find(id).orElseThrow();
}
@Override
public Optional<ProjectEditView> update(UpdateProjectCommand command) {
String slug = command.slug() == null || command.slug().isBlank() ? null : command.slug().trim();
int updated =
jdbcClient
.sql(
"UPDATE project SET name = :name, slug = :slug, one_line_purpose = :purpose,"
+ " purpose_markdown = :purposeMd, boundary_markdown = :boundaryMd,"
+ " system_overview_markdown = :overviewMd, phase = :phase,"
+ " current_objective = :objective, next_step = :nextStep,"
+ " technology_labels = CAST(:labels AS jsonb),"
+ " target_visibility = :visibility, featured_order = :featured,"
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", command.id())
.param("expected", command.expectedVersion())
.param("name", command.name().trim())
.param("slug", slug)
.param("purpose", nullToEmpty(command.oneLinePurpose()))
.param("purposeMd", nullToEmpty(command.purposeMarkdown()))
.param("boundaryMd", nullToEmpty(command.boundaryMarkdown()))
.param("overviewMd", nullToEmpty(command.systemOverviewMarkdown()))
.param("phase", command.phase())
.param("objective", command.currentObjective())
.param("nextStep", command.nextStep())
.param("labels", labelsJson(command.technologyLabels()))
.param("visibility", command.targetVisibility())
.param("featured", command.featuredOrder())
.param("actor", command.actor())
.update();
if (updated == 0) {
return Optional.empty();
}
replaceTopics(command.id(), command.topicIds());
return find(command.id());
}
/** 편집기가 고른 주제. 순서가 곧 화면 순서이므로 목록의 자리를 {@code display_order} 에 그대로 적는다. */
private List<UUID> topicIdsOf(UUID projectId) {
return jdbcClient
.sql(
"SELECT topic_id FROM project_topic WHERE project_id = :projectId"
+ " ORDER BY display_order")
.param("projectId", projectId)
.query(UUID.class)
.list();
}
/*
* 통째로 교체한다. 부분 수정으로 두면 "주제를 전부 뗀다" 를 표현할 방법이 없고, 화면도 목록 하나를
* 한 번에 저장하므로 그쪽과도 맞는다.
*/
private void replaceTopics(UUID projectId, List<UUID> topicIds) {
jdbcClient
.sql("DELETE FROM project_topic WHERE project_id = :projectId")
.param("projectId", projectId)
.update();
int order = 0;
for (UUID topicId : topicIds) {
jdbcClient
.sql(
"INSERT INTO project_topic (project_id, topic_id, display_order)"
+ " VALUES (:projectId, :topicId, :order)")
.param("projectId", projectId)
.param("topicId", topicId)
.param("order", order++)
.update();
}
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
@Override
public int delete(UUID id, long expectedVersion) {
return jdbcClient
.sql("DELETE FROM project WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.update();
}
@Override
public boolean isReferenced(UUID id) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS ("
+ " SELECT 1 FROM project_document_link WHERE project_id = :id"
+ " UNION ALL SELECT 1 FROM project_question_link WHERE project_id = :id"
+ " UNION ALL SELECT 1 FROM project_decision WHERE project_id = :id"
+ " UNION ALL SELECT 1 FROM public_resource_project_link WHERE project_id = :id"
+ ")")
.param("id", id)
.query(Boolean.class)
.single());
}
@Override
public boolean slugTaken(String slug, UUID exceptId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM project WHERE slug = :slug"
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
.param("slug", slug)
.param("except", exceptId)
.query(Boolean.class)
.single());
}
/**
* 게시는 세 곳을 한 트랜잭션 안에서 함께 세운다 — 원본의 상태, 공개 투영, canonical route. 어느 하나가 빠지면 증상이 제각각이다: 투영이 없으면 화면이
* 조용히 비고, route 가 없으면 주소만 404 가 되며, 원본 상태가 안 바뀌면 Studio 가 계속 "초안" 이라고 말한다.
*
* <p>낙관적 잠금이 먼저다. 버전이 어긋나면 아래 두 문장은 아예 실행하지 않는다.
*/
@Override
public Optional<ProjectEditView> publish(
UUID id, long expectedVersion, String visibility, String actor) {
int updated =
jdbcClient
.sql(
"UPDATE project SET workflow_status = 'PUBLISHED', target_visibility = :visibility,"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now(), version = version + 1,"
+ " updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.param("visibility", visibility)
.param("actor", actor)
.update();
if (updated == 0) {
return Optional.empty();
}
ProjectEditView project =
find(id)
.orElseThrow(() -> new IllegalStateException("project vanished mid-publish: " + id));
upsertProjection(project, visibility);
replaceRoute(project);
return Optional.of(project);
}
@Override
public Optional<ProjectEditView> unpublish(UUID id, long expectedVersion, String actor) {
int updated =
jdbcClient
.sql(
"UPDATE project SET workflow_status = 'DRAFT', target_visibility = 'PRIVATE',"
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.param("actor", actor)
.update();
if (updated == 0) {
return Optional.empty();
}
jdbcClient
.sql(
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
+ " updated_at = now() WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", id)
.update();
return find(id);
}
/**
* 공개 조회들은 실제 내용을 {@code project} 테이블에서 직접 읽고 이 행은 가시성 관문·주소·정렬 시각으로만 쓴다. 그래서 {@code payload} 는 빈
* 객체로 둔다 — 여기에 사본을 두면 원본이 바뀔 때마다 두 곳이 어긋난다.
*
* <p>{@code search_text} 만은 예외다. 검색은 이 테이블 하나만 훑으므로 여기에 없는 낱말은 영영 찾을 수 없다.
*/
private void upsertProjection(ProjectEditView project, String visibility) {
String searchText =
String.join(
" ",
nullToEmpty(project.name()),
nullToEmpty(project.oneLinePurpose()),
nullToEmpty(project.purposeMarkdown()),
nullToEmpty(project.currentObjective()),
nullToEmpty(project.nextStep()));
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, state_code,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, navigation_path)"
+ " VALUES ('PROJECT', :id, :version, 'ACTIVE', :visibility, :title, :summary,"
+ " :stateCode, 1, '{}'::jsonb, :bodyPlainText, :searchText, :contentHash,"
+ " now(), now(), :navigationPath)"
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
+ " source_version = EXCLUDED.source_version,"
+ " publication_state = 'ACTIVE',"
+ " visibility = EXCLUDED.visibility,"
+ " title = EXCLUDED.title,"
+ " summary = EXCLUDED.summary,"
+ " state_code = EXCLUDED.state_code,"
+ " body_plain_text = EXCLUDED.body_plain_text,"
+ " search_text = EXCLUDED.search_text,"
+ " content_hash = EXCLUDED.content_hash,"
+ " updated_at = now(),"
+ " navigation_path = EXCLUDED.navigation_path")
.param("id", project.id())
.param("version", project.version())
.param("visibility", visibility)
.param("title", project.name())
.param("summary", project.oneLinePurpose())
.param("stateCode", project.phase())
.param("bodyPlainText", nullToEmpty(project.purposeMarkdown()))
.param("searchText", searchText)
.param("contentHash", sha256(searchText))
.param("navigationPath", "/projects/" + project.slug())
.update();
}
/** 이전 slug 의 route 는 alias 로 남긴다 — 지우면 이미 공개된 링크가 끊긴다. */
private void replaceRoute(ProjectEditView project) {
jdbcClient
.sql(
"UPDATE public_route SET route_role = 'ALIAS'"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id AND slug <> :slug")
.param("id", project.id())
.param("slug", project.slug())
.update();
jdbcClient
.sql(
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
+ " VALUES ('PROJECT', :slug, :id, 'CANONICAL')"
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
.param("id", project.id())
.param("slug", project.slug())
.update();
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256")
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
}
@@ -0,0 +1,258 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import dev.caskeleton.application.techlog.management.command.CreateReleaseCommand;
import dev.caskeleton.application.techlog.management.command.UpdateReleaseCommand;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
import dev.caskeleton.application.techlog.management.model.ReleaseIndexItemView;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.management.service.ReleaseDrafts;
import java.sql.Date;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Release 편집 저장소.
*
* <p>{@code change_types} 는 jsonb 다 — 문자열 배열을 그대로 넘기면 드라이버가 Postgres 배열로 보내 타입이 어긋나므로, JSON 으로 직렬화해
* {@code ::jsonb} 로 캐스팅한다.
*/
@Repository
public class JdbcReleaseRepositoryAdapter implements ReleaseRepositoryPort {
private static final String EDIT_COLUMNS =
"id, version, version_label, title, summary, released_on, change_types, reason_markdown,"
+ " changes_markdown, user_impact_markdown, implementation_impact_markdown,"
+ " verification_markdown, known_limitations_markdown, workflow_status,"
+ " first_published_at, last_published_at, updated_at";
private static final String INDEX_COLUMNS =
"id, version_label, title, released_on, workflow_status, updated_at, version,"
+ " first_published_at, last_published_at";
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
public JdbcReleaseRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
}
private static Instant instant(ResultSet rs, String column) throws SQLException {
Timestamp t = rs.getTimestamp(column);
return t == null ? null : t.toInstant();
}
private static LocalDate date(ResultSet rs, String column) throws SQLException {
Date d = rs.getDate(column);
return d == null ? null : d.toLocalDate();
}
private List<String> changeTypes(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode node = objectMapper.readTree(json);
if (!node.isArray()) {
return List.of();
}
List<String> out = new ArrayList<>();
for (JsonNode item : node) {
out.add(item.asString(""));
}
return List.copyOf(out);
} catch (RuntimeException malformed) {
// 열이 jsonb 배열로 제약돼 있으므로 여기 오면 데이터가 아니라 스키마가 어긋난 것이다.
// 편집 화면 전체를 막는 대신 빈 목록으로 두고 나머지 필드를 보여준다.
return List.of();
}
}
private String changeTypesJson(List<String> values) {
return objectMapper.writeValueAsString(values == null ? List.of() : values);
}
private ReleaseEditView mapEdit(ResultSet rs, int rowNum) throws SQLException {
return new ReleaseEditView(
rs.getObject("id", UUID.class),
rs.getLong("version"),
rs.getString("version_label"),
rs.getString("title"),
rs.getString("summary"),
date(rs, "released_on"),
changeTypes(rs.getString("change_types")),
rs.getString("reason_markdown"),
rs.getString("changes_markdown"),
rs.getString("user_impact_markdown"),
rs.getString("implementation_impact_markdown"),
rs.getString("verification_markdown"),
rs.getString("known_limitations_markdown"),
rs.getString("workflow_status"),
instant(rs, "first_published_at"),
instant(rs, "last_published_at"),
instant(rs, "updated_at"));
}
private static ReleaseIndexItemView mapIndex(ResultSet rs, int rowNum) throws SQLException {
return new ReleaseIndexItemView(
rs.getObject("id", UUID.class),
rs.getString("version_label"),
rs.getString("title"),
date(rs, "released_on"),
rs.getString("workflow_status"),
instant(rs, "updated_at"),
rs.getLong("version"),
instant(rs, "first_published_at"),
instant(rs, "last_published_at"));
}
@Override
public List<ReleaseIndexItemView> listAll(int limit, int offset) {
// 발행된 것이 위로, 그다음 최근 수정 순. 변경 기록은 버전 순서가 의미를 갖지만
// version_label 은 자유 문자열이라 정렬 키로 쓸 수 없다.
return jdbcClient
.sql(
"SELECT "
+ INDEX_COLUMNS
+ " FROM release"
+ " ORDER BY released_on DESC NULLS LAST, updated_at DESC, id"
+ " LIMIT :limit OFFSET :offset")
.param("limit", limit)
.param("offset", offset)
.query(JdbcReleaseRepositoryAdapter::mapIndex)
.list();
}
@Override
public int countAll() {
return Optional.ofNullable(
jdbcClient.sql("SELECT COUNT(*) FROM release").query(Integer.class).single())
.orElse(0);
}
@Override
public Optional<ReleaseEditView> find(UUID id) {
return jdbcClient
.sql("SELECT " + EDIT_COLUMNS + " FROM release WHERE id = :id")
.param("id", id)
.query(this::mapEdit)
.optional();
}
@Override
public ReleaseEditView create(CreateReleaseCommand command) {
UUID id = UUID.randomUUID();
// version_label 은 NOT NULL UNIQUE 이고 초안에는 아직 버전이 없다. id 에서 파생한 자리표시자를
// 넣어 유일성을 만족시키고, 발행이 그것이 실제 버전으로 바뀌었는지 확인한다.
String placeholder = ReleaseDrafts.PLACEHOLDER_PREFIX + id.toString().substring(0, 8);
jdbcClient
.sql(
"INSERT INTO release (id, version_label, title, created_by, updated_by)"
+ " VALUES (:id, :label, :title, :actor, :actor)")
.param("id", id)
.param("label", placeholder)
.param("title", command.title().trim())
.param("actor", command.actor())
.update();
return find(id).orElseThrow();
}
@Override
public Optional<ReleaseEditView> update(UpdateReleaseCommand command) {
int updated =
jdbcClient
.sql(
"UPDATE release SET version_label = :label, title = :title, summary = :summary,"
+ " released_on = :releasedOn,"
+ " change_types = CAST(:changeTypes AS jsonb),"
+ " reason_markdown = :reasonMd, changes_markdown = :changesMd,"
+ " user_impact_markdown = :userImpactMd,"
+ " implementation_impact_markdown = :implImpactMd,"
+ " verification_markdown = :verificationMd,"
+ " known_limitations_markdown = :limitationsMd,"
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", command.id())
.param("expected", command.expectedVersion())
.param("label", command.versionLabel().trim())
.param("title", command.title().trim())
.param("summary", nullToEmpty(command.summary()))
.param(
"releasedOn",
command.releasedOn() == null ? null : Date.valueOf(command.releasedOn()))
.param("changeTypes", changeTypesJson(command.changeTypes()))
.param("reasonMd", nullToEmpty(command.reasonMarkdown()))
.param("changesMd", nullToEmpty(command.changesMarkdown()))
.param("userImpactMd", nullToEmpty(command.userImpactMarkdown()))
.param("implImpactMd", nullToEmpty(command.implementationImpactMarkdown()))
.param("verificationMd", nullToEmpty(command.verificationMarkdown()))
.param("limitationsMd", nullToEmpty(command.knownLimitationsMarkdown()))
.param("actor", command.actor())
.update();
return updated == 0 ? Optional.empty() : find(command.id());
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
@Override
public int delete(UUID id, long expectedVersion) {
return jdbcClient
.sql("DELETE FROM release WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.update();
}
@Override
public boolean versionLabelTaken(String versionLabel, UUID exceptId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM release WHERE version_label = :label"
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
.param("label", versionLabel)
.param("except", exceptId)
.query(Boolean.class)
.single());
}
@Override
public Optional<ReleaseEditView> transition(
UUID id,
long expectedVersion,
String workflowStatus,
boolean stampPublication,
String actor) {
// COALESCE 가 최초 발행 시각을 지킨다 — 재발행은 last_published_at 만 옮긴다.
String publicationClause =
stampPublication
? " first_published_at = COALESCE(first_published_at, now()), last_published_at = now(),"
: "";
int updated =
jdbcClient
.sql(
"UPDATE release SET workflow_status = :status,"
+ publicationClause
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.param("status", workflowStatus)
.param("actor", actor)
.update();
return updated == 0 ? Optional.empty() : find(id);
}
}
@@ -0,0 +1,160 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import dev.caskeleton.application.techlog.management.command.SaveTopicCommand;
import dev.caskeleton.application.techlog.management.model.TopicEditView;
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* Topic 편집 저장소.
*
* <p>정규화된 이름은 애플리케이션이 아니라 여기서 계산해 컬럼에 넣는다. {@code uq_topic_normalized_name} 이 그 컬럼 위에 있으므로, 계산이 한
* 곳에만 있어야 사전 확인과 제약이 같은 값을 본다.
*/
@Repository
public class JdbcTopicRepositoryAdapter implements TopicRepositoryPort {
private static final String COLUMNS = "id, name, slug, description, scope, status, version";
private final JdbcClient jdbcClient;
public JdbcTopicRepositoryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
private static String normalize(String name) {
return name == null ? "" : name.trim().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT);
}
private static TopicEditView map(ResultSet rs, int rowNum) throws SQLException {
return new TopicEditView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("slug"),
rs.getString("description"),
rs.getString("scope"),
rs.getString("status"),
rs.getLong("version"),
null,
List.of());
}
@Override
public List<TopicEditView> listAll() {
return jdbcClient
.sql("SELECT " + COLUMNS + " FROM topic ORDER BY name")
.query(JdbcTopicRepositoryAdapter::map)
.list();
}
@Override
public Optional<TopicEditView> find(UUID id) {
return jdbcClient
.sql("SELECT " + COLUMNS + " FROM topic WHERE id = :id")
.param("id", id)
.query(JdbcTopicRepositoryAdapter::map)
.optional();
}
@Override
public TopicEditView create(SaveTopicCommand command) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, description, scope, status,"
+ " version, created_by, updated_by)"
+ " VALUES (:id, :name, :normalized, :slug, :description, :scope,"
+ " COALESCE(:status, 'ACTIVE'), 0, :actor, :actor)")
.param("id", id)
.param("name", command.name().trim())
.param("normalized", normalize(command.name()))
.param("slug", command.slug().trim())
.param("description", command.description())
.param("scope", command.scope())
.param("status", command.status())
.param("actor", command.actor())
.update();
return find(id).orElseThrow();
}
@Override
public Optional<TopicEditView> update(SaveTopicCommand command) {
int updated =
jdbcClient
.sql(
"UPDATE topic SET name = :name, normalized_name = :normalized, slug = :slug,"
+ " description = :description, scope = :scope,"
+ " status = COALESCE(:status, status), version = version + 1,"
+ " updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", command.id())
.param("expected", command.expectedVersion())
.param("name", command.name().trim())
.param("normalized", normalize(command.name()))
.param("slug", command.slug().trim())
.param("description", command.description())
.param("scope", command.scope())
.param("status", command.status())
.param("actor", command.actor())
.update();
return updated == 0 ? Optional.empty() : find(command.id());
}
@Override
public int delete(UUID id, long expectedVersion) {
return jdbcClient
.sql("DELETE FROM topic WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.update();
}
/** 참조 확인. 주제를 가리키는 곳이 늘어나면 여기도 늘어야 한다 — 빠뜨리면 외래키가 대신 막고 500 이 나간다. */
@Override
public boolean isReferenced(UUID id) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS ("
+ " SELECT 1 FROM document WHERE primary_topic_id = :id"
+ " UNION ALL SELECT 1 FROM open_question WHERE primary_topic_id = :id"
+ " UNION ALL SELECT 1 FROM public_resource_projection WHERE primary_topic_id = :id"
+ ")")
.param("id", id)
.query(Boolean.class)
.single());
}
@Override
public boolean nameTaken(String normalizedName, UUID exceptId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM topic WHERE normalized_name = :name"
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
.param("name", normalizedName)
.param("except", exceptId)
.query(Boolean.class)
.single());
}
@Override
public boolean slugTaken(String slug, UUID exceptId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM topic WHERE slug = :slug"
+ " AND (CAST(:except AS uuid) IS NULL OR id <> CAST(:except AS uuid)))")
.param("slug", slug)
.param("except", exceptId)
.query(Boolean.class)
.single());
}
}
@@ -0,0 +1,336 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.BodyAssetView;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.CaseRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedDocumentView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedQuestionView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPointGroupView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionUpdateView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRelationsView;
import dev.caskeleton.application.techlog.publicsite.model.TagSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 공개된 Case / Reference / Question 상세.
*
* <p>본문은 {@code public_resource_projection.payload}(Studio 렌더 모델)가 아니라 원본 테이블에서 읽는다 — 공개 계약은 블록 배열이
* 아니라 Markdown 원문과 {@code contentFormat} 을 준다. projection 은 "공개됐는가"와 게시 시각을 정하는 데만 쓴다.
*/
@Repository
public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
private final PublicRelationLookup relations;
public JdbcPublicDocumentQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
this.relations = new PublicRelationLookup(jdbcClient);
}
@Override
public Optional<CaseDetailView> findCase(String slug) {
return document("CASE", slug)
.map(
row ->
new CaseDetailView(
row.view().canonicalPath(),
true,
row.view(),
new CaseRelationsView(
relations.firstTargetOfType("CASE", row.id(), "QUESTION"),
relations.targetsOfType("CASE", row.id(), "PROJECT_DECISION"),
// 이 Case 에서 파생된 Reference 는 역방향이다 — Reference 쪽이 Case 를 가리킨다.
relations.sourcesOfType(row.id(), "REFERENCE"),
relations.targetsOfType("CASE", row.id(), "CASE"))));
}
@Override
public Optional<ReferenceDetailView> findReference(String slug) {
return document("REFERENCE", slug)
.map(
row ->
new ReferenceDetailView(
row.view().canonicalPath(),
true,
row.view(),
new ReferenceRelationsView(
relations.targetsOfType("REFERENCE", row.id(), "CASE"),
relations.targetsOfType("REFERENCE", row.id(), "PROJECT_DECISION"),
relations.targetsOfType("REFERENCE", row.id(), "REFERENCE"))));
}
/** 관계 조회에 문서 id 가 필요한데 계약의 응답에는 id 가 없다. 뷰 밖으로 id 를 새로 노출하지 않고 이 안에서만 함께 나른다. */
private record DocumentRow(UUID id, PublishedDocumentView view) {}
private Optional<DocumentRow> document(String type, String slug) {
return jdbcClient
.sql(
"SELECT d.id, d.title, d.body_markdown, d.content_format,"
+ " d.content_format_version, d.cover_asset_id,"
// `environment_items` 는 V7 이 만든 jsonb 배열이고, Studio 가 쓰는 것은 V8 이
// 따로 만든 `environment`/`reproduction`
// 문자열이다(V8__techlog_studio_working_copy.sql:23-26).
// 공개 조회가 배열 쪽을 읽는 동안 그 컬럼을 채우는 코드는 어디에도 없었고 —
// 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 두 칸은 늘 비어 있었다.
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
+ " r.scope_summary, r.applies_to, r.excluded_scope, r.freshness_status,"
// 검증일은 원본 `document.last_verified_at` 에서 읽는다. 투영 테이블의 같은 이름
// 컬럼(`public_resource_projection.last_verified_at`)은 `upsertProjection` 이
// 채우지 않아 언제나 null 이었다.
+ " p.navigation_path, p.published_at, p.updated_at, d.last_verified_at,"
+ " d.summary,"
+ " r.rules, r.examples,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug,"
+ " a.content_type AS cover_content_type, a.alt_text AS cover_alt,"
+ " a.width AS cover_width, a.height AS cover_height"
+ " FROM document d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
+ " LEFT JOIN topic t ON t.id = d.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " LEFT JOIN asset a ON a.id = d.cover_asset_id"
+ " WHERE d.document_type = :type AND d.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("type", type)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID id = rs.getObject("id", UUID.class);
boolean isCase = "CASE".equals(type);
return new DocumentRow(
id,
new PublishedDocumentView(
type,
rs.getString("navigation_path"),
rs.getString("title"),
rs.getString("summary"),
// Case 는 문제/결론, Reference 는 범위/적용이 각각 앞뒤 요약 자리에 온다.
isCase ? rs.getString("problem_summary") : rs.getString("scope_summary"),
isCase ? rs.getString("conclusion_summary") : null,
isCase ? environment(rs) : List.of(),
isCase ? List.of() : json.strings(rs.getString("applies_to")),
isCase ? List.of() : json.strings(rs.getString("excluded_scope")),
// Reference 의 본문은 body_markdown 이 아니라 규칙과 예시에 있다.
isCase ? List.of() : json.referenceRules(rs.getString("rules")),
isCase ? List.of() : json.strings(rs.getString("examples")),
isCase ? null : rs.getString("freshness_status"),
rs.getString("body_markdown"),
rs.getString("content_format"),
rs.getInt("content_format_version"),
topic(rs),
tags(id),
project(rs),
cover(rs),
// 본문에 evidence 를 담는 것은 CASE 뿐이다.
isCase ? bodyAssets(id) : List.of(),
instant(rs, "published_at"),
instant(rs, "updated_at"),
instant(rs, "last_verified_at")));
})
.optional();
}
@Override
public Optional<QuestionDetailView> findQuestion(String slug) {
return jdbcClient
.sql(
"SELECT q.id, q.question, q.slug, q.summary, q.context_markdown,"
+ " q.importance_markdown, q.question_status, q.next_verification,"
+ " q.resolution_type, q.resolution_summary, q.resolved_at, q.opened_at,"
+ " p.navigation_path, p.updated_at"
+ " FROM open_question q"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
+ " WHERE q.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID id = rs.getObject("id", UUID.class);
PublishedQuestionView question =
new PublishedQuestionView(
rs.getString("question"),
rs.getString("summary"),
rs.getString("context_markdown"),
rs.getString("importance_markdown"),
rs.getString("question_status"),
rs.getString("next_verification"),
new QuestionPointGroupView(
points(id, "FACT"),
points(id, "ASSUMPTION"),
points(id, "UNKNOWN"),
points(id, "CONSTRAINT")),
updates(id),
rs.getString("resolution_type"),
rs.getString("resolution_summary"),
instant(rs, "resolved_at"),
instant(rs, "opened_at"),
instant(rs, "updated_at"));
return new QuestionDetailView(
rs.getString("navigation_path"),
true,
question,
new QuestionRelationsView(
relations.primaryProject("project_question_link", "question_id", id),
relations.firstTargetOfType("QUESTION", id, "CASE"),
relations.firstTargetOfType("QUESTION", id, "PROJECT_DECISION"),
relations.targetsOfType("QUESTION", id, "REFERENCE")));
})
.optional();
}
private List<String> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind"
+ " ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(String.class)
.list();
}
/** 공개된 조사 기록만 보여준다 — {@code PRIVATE} 기록은 Studio 안에만 있다. */
private List<QuestionUpdateView> updates(UUID questionId) {
return jdbcClient
.sql(
"SELECT update_type, title, body_markdown, occurred_at FROM question_update"
+ " WHERE question_id = :id AND update_visibility = 'PUBLIC'"
+ " ORDER BY sequence_no")
.param("id", questionId)
.query(
(rs, rowNum) ->
new QuestionUpdateView(
rs.getString("update_type"),
rs.getString("title"),
rs.getString("body_markdown"),
instant(rs, "occurred_at")))
.list();
}
/**
* 본문이 {@code :::evidence key="..."} 로 가리키는 Asset.
*
* <p>{@code PUBLISHED} scope 의 참조만 읽는다 — 게시 이후 작업본이 Asset 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 하기
* 때문이다. {@code WORKING} scope 는 Studio 의 것이다.
*
* <p>{@code READY} 가 아닌 Asset 은 뺀다. {@code /media/{assetId}} 가 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를
* 가리키게 된다.
*/
private List<BodyAssetView> bodyAssets(UUID documentId) {
return jdbcClient
.sql(
"SELECT a.id, a.asset_key, a.content_type, a.alt_text, a.width, a.height, a.decorative"
+ " FROM asset_reference r"
+ " JOIN asset a ON a.id = r.asset_id"
+ " WHERE r.owner_type = 'DOCUMENT' AND r.owner_id = :id"
+ " AND r.reference_scope = 'PUBLISHED' AND r.reference_role = 'BODY'"
+ " AND a.management_status = 'READY'"
+ " ORDER BY a.asset_key")
.param("id", documentId)
.query(
(rs, rowNum) -> {
UUID assetId = rs.getObject("id", UUID.class);
return new BodyAssetView(
rs.getString("asset_key"),
assetId,
"/api/v1/public/media/" + assetId,
rs.getString("content_type"),
rs.getString("alt_text"),
integer(rs, "width"),
integer(rs, "height"),
rs.getBoolean("decorative"));
})
.list();
}
static Integer integer(ResultSet rs, String column) throws SQLException {
int value = rs.getInt(column);
return rs.wasNull() ? null : value;
}
static Instant instant(ResultSet rs, String column) throws SQLException {
var value = rs.getTimestamp(column);
return value == null ? null : value.toInstant();
}
/**
* 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다.
*
* <p>계약은 `environmentSummary` 를 문자열 배열로 두는데 Studio 가 채우는 것은 두 개의 문자열 (`environment`,
* `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 — 줄 단위로 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
*/
static List<String> environment(ResultSet rs) throws SQLException {
List<String> items = new ArrayList<>(2);
for (String column : List.of("environment", "reproduction")) {
String value = rs.getString(column);
if (value != null && !value.isBlank()) {
items.add(value);
}
}
return List.copyOf(items);
}
static TopicSummaryView topic(ResultSet rs) throws SQLException {
return rs.getString("topic_slug") == null
? null
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug"));
}
static ProjectSummaryView project(ResultSet rs) throws SQLException {
return rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug"));
}
static AssetReferenceView cover(ResultSet rs) throws SQLException {
UUID assetId = rs.getObject("cover_asset_id", UUID.class);
return assetId == null
? null
: new AssetReferenceView(
assetId,
"/api/v1/public/media/" + assetId,
rs.getString("cover_alt"),
(Integer) rs.getObject("cover_width"),
(Integer) rs.getObject("cover_height"),
rs.getString("cover_content_type"));
}
List<TagSummaryView> tags(UUID documentId) {
return jdbcClient
.sql(
"SELECT g.name, g.slug FROM document_tag dt JOIN tag g ON g.id = dt.tag_id"
+ " WHERE dt.document_id = :id ORDER BY dt.display_order")
.param("id", documentId)
.query((rs, rowNum) -> new TagSummaryView(rs.getString("name"), rs.getString("slug")))
.list();
}
}
@@ -0,0 +1,226 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgeListItemView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionListItemView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 탐색 목록.
*
* <p>필터와 정렬을 SQL 로 처리하고 페이지 총계를 같은 조건으로 센다 — 목록과 총계가 다른 조건을 쓰면 마지막 페이지가 비어 보이거나 있지도 않은 페이지 번호가 생긴다.
*/
@Repository
public class JdbcPublicExploreQueryAdapter implements PublicExploreQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicExploreQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public KnowledgePageView knowledge(ExploreKnowledgeQuery query) {
StringBuilder where =
new StringBuilder(
" WHERE " + PublicSql.ACTIVE + " AND p.resource_type IN ('CASE', 'REFERENCE')");
Map<String, Object> params = new HashMap<>();
if (query.type() != null) {
where.append(" AND p.resource_type = :type");
params.put("type", query.type());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
if (query.projectSlug() != null) {
where.append(" AND pr.slug = :projectSlug");
params.put("projectSlug", query.projectSlug());
}
if (query.tagSlug() != null) {
where.append(
" AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id"
+ " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id"
+ " AND g.slug = :tagSlug)");
params.put("tagSlug", query.tagSlug());
}
if (query.year() != null) {
where.append(" AND date_part('year', p.published_at) = :year");
params.put("year", query.year());
}
String joins =
" FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
long total = count(joins + where, params);
List<KnowledgeListItemView> items =
page(
"SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.state_code,"
+ " p.published_at, p.last_verified_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ knowledgeOrder(query.sort()),
params,
query.page().size(),
query.page().offset(),
JdbcPublicExploreQueryAdapter::readKnowledge);
return new KnowledgePageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
/** 계약의 정렬 세 값. 같은 시각이 여럿일 때 페이지 경계가 흔들리지 않도록 id 를 tie-breaker 로 둔다. */
private static String knowledgeOrder(String sort) {
String key =
switch (sort == null ? "PUBLISHED_DESC" : sort) {
case "UPDATED_DESC" -> "p.updated_at DESC";
case "VERIFIED_DESC" -> "p.last_verified_at DESC NULLS LAST";
default -> "p.published_at DESC";
};
return " ORDER BY " + key + ", p.resource_id DESC";
}
/**
* 계약 {@code exploreQuestions.sort} 의 세 값. {@code RESOLVED_DESC} 는 미해결 질문에 값이 없으므로 NULLS LAST 로 밀어
* 낸다 — 그러지 않으면 PostgreSQL 의 DESC 기본값 NULLS FIRST 때문에 미해결 질문이 "가장 최근에 해결된 것" 자리에 올라온다.
*/
private static String questionOrder(String sort) {
String key =
switch (sort == null ? "UPDATED_DESC" : sort) {
case "OPENED_DESC" -> "q.opened_at DESC NULLS LAST";
case "RESOLVED_DESC" -> "q.resolved_at DESC NULLS LAST";
default -> "p.updated_at DESC";
};
return " ORDER BY " + key + ", p.resource_id DESC";
}
private static KnowledgeListItemView readKnowledge(ResultSet rs, int rowNum) throws SQLException {
return new KnowledgeListItemView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("navigation_path"),
rs.getString("summary"),
null,
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant(),
rs.getTimestamp("last_verified_at") == null
? null
: rs.getTimestamp("last_verified_at").toInstant(),
rs.getString("state_code"));
}
@Override
public QuestionPageView questions(ExploreQuestionsQuery query) {
StringBuilder where =
new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND p.resource_type = 'QUESTION'");
Map<String, Object> params = new HashMap<>();
if (query.status() != null) {
where.append(" AND p.state_code = :status");
params.put("status", query.status());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
if (query.projectSlug() != null) {
where.append(" AND pr.slug = :projectSlug");
params.put("projectSlug", query.projectSlug());
}
if (query.tagSlug() != null) {
where.append(
" AND EXISTS (SELECT 1 FROM public_resource_tag rt JOIN tag g ON g.id = rt.tag_id"
+ " WHERE rt.resource_type = p.resource_type AND rt.resource_id = p.resource_id"
+ " AND g.slug = :tagSlug)");
params.put("tagSlug", query.tagSlug());
}
String joins =
" FROM public_resource_projection p"
+ " JOIN open_question q ON q.id = p.resource_id"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
long total = count(joins + where, params);
List<QuestionListItemView> items =
page(
"SELECT q.question, p.navigation_path, q.question_status, p.summary,"
+ " q.next_verification, p.updated_at,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ questionOrder(query.sort()),
params,
query.page().size(),
query.page().offset(),
(rs, rowNum) ->
new QuestionListItemView(
rs.getString("question"),
rs.getString("navigation_path"),
rs.getString("question_status"),
rs.getString("summary"),
null,
rs.getString("next_verification"),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("updated_at").toInstant()));
return new QuestionPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
private long count(String fromAndWhere, Map<String, Object> params) {
var spec = jdbcClient.sql("SELECT count(*)" + fromAndWhere);
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
return spec.query(Long.class).single();
}
private <T> List<T> page(
String sql,
Map<String, Object> params,
int size,
int offset,
org.springframework.jdbc.core.RowMapper<T> mapper) {
var spec = jdbcClient.sql(sql + " LIMIT :size OFFSET :offset");
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
return spec.param("size", size).param("offset", offset).query(mapper).list();
}
}
@@ -0,0 +1,350 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.PublishedProjectView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/** 프로젝트 목록·상세와 그 하위 목록. */
@Repository
public class JdbcPublicProjectQueryAdapter implements PublicProjectQueryPort {
private static final int SECTION_LIMIT = 10;
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicProjectQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public List<ProjectListItemView> list() {
return jdbcClient
.sql(
"SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective,"
+ " pr.next_step, p.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE "
+ PublicSql.ACTIVE
+ " ORDER BY pr.featured_order NULLS LAST, p.updated_at DESC")
.query(
(rs, rowNum) ->
new ProjectListItemView(
rs.getString("name"),
rs.getString("slug"),
"/projects/" + rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at")))
.list();
}
@Override
public Optional<ProjectDetailView> findBySlug(String slug) {
return jdbcClient
.sql(
"SELECT pr.id, pr.name, pr.slug, pr.one_line_purpose, pr.purpose_markdown,"
+ " pr.boundary_markdown, pr.phase, pr.current_objective, pr.next_step,"
+ " pr.system_overview_markdown, pr.technology_labels,"
+ " p.navigation_path, p.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID projectId = rs.getObject("id", UUID.class);
PublishedProjectView project =
new PublishedProjectView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("purpose_markdown"),
rs.getString("boundary_markdown"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
rs.getString("system_overview_markdown"),
json.strings(rs.getString("technology_labels")),
topicsOf(projectId),
JdbcPublicDocumentQueryAdapter.instant(rs, "updated_at"));
return new ProjectDetailView(
rs.getString("navigation_path"),
true,
project,
featuredDecision(projectId),
activeQuestion(projectId),
selectedRecords(projectId));
})
.optional();
}
private RelatedEntryView featuredDecision(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_decision d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ " ORDER BY d.is_featured DESC, d.decided_at DESC NULLS LAST LIMIT 1")
.param("projectId", projectId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
private RelatedEntryView activeQuestion(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_question_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = l.question_id"
+ " WHERE l.project_id = :projectId AND p.state_code <> 'RESOLVED'"
+ " AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.updated_at DESC LIMIT 1")
.param("projectId", projectId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
private List<RelatedEntryView> selectedRecords(UUID projectId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_project_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id"
+ " WHERE l.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ " ORDER BY l.featured_order NULLS LAST, p.published_at DESC LIMIT :limit")
.param("projectId", projectId)
.param("limit", SECTION_LIMIT)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
@Override
public Optional<ProjectDecisionPageView> decisions(ProjectDecisionPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
// 계약의 status 필터. 총계와 목록이 반드시 같은 조건을 써야 마지막 페이지가 비어 보이지 않는다.
String from =
" FROM project_decision d"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.project_id = :projectId AND "
+ PublicSql.ACTIVE
+ (query.status() == null ? "" : " AND d.decision_status = :status");
long total =
bind(jdbcClient.sql("SELECT count(*)" + from), projectId, query.status())
.query(Long.class)
.single();
List<ProjectDecisionItemView> items =
bind(
jdbcClient.sql(
"SELECT d.id, d.statement, d.decision_status, d.rationale_markdown,"
+ " d.decided_at, d.source_question_id, d.source_case_id"
+ from
+ " ORDER BY d.decided_at DESC NULLS LAST, d.id DESC"
+ " LIMIT :size OFFSET :offset"),
projectId,
query.status())
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) ->
new ProjectDecisionItemView(
rs.getObject("id", UUID.class),
rs.getString("statement"),
rs.getString("decision_status"),
rs.getString("rationale_markdown"),
JdbcPublicDocumentQueryAdapter.instant(rs, "decided_at"),
publishedEntry(rs.getObject("source_question_id", UUID.class)),
publishedEntry(rs.getObject("source_case_id", UUID.class))))
.list();
return new ProjectDecisionPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
/** 지목된 원천이 비공개면 링크를 만들지 않는다 — 404 로 이어지는 링크를 내보내지 않는다. */
private RelatedEntryView publishedEntry(UUID resourceId) {
if (resourceId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id = :id AND "
+ PublicSql.ACTIVE)
.param("id", resourceId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
@Override
public Optional<ProjectRecordPageView> records(ProjectRecordPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
// 계약이 세는 record CASE/REFERENCE/QUESTION 종류다. type 없으면 센다.
String from =
" FROM public_resource_project_link l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = l.resource_type AND p.resource_id = l.resource_id"
+ " WHERE l.project_id = :projectId"
+ " AND p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION')"
+ " AND "
+ PublicSql.ACTIVE
+ (query.type() == null ? "" : " AND p.resource_type = :type")
+ (query.relation() == null ? "" : " AND l.relation_type = :relation");
long total =
bindRecord(jdbcClient.sql("SELECT count(*)" + from), projectId, query)
.query(Long.class)
.single();
List<RelatedEntryView> items =
bindRecord(
jdbcClient.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ from
+ " ORDER BY p.published_at DESC, p.resource_id DESC"
+ " LIMIT :size OFFSET :offset"),
projectId,
query)
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
return new ProjectRecordPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
@Override
public Optional<ProjectActivityPageView> activities(ProjectPageQuery query) {
return projectId(query.projectSlug())
.map(
projectId -> {
String from =
" FROM project_activity a"
+ " WHERE a.project_id = :projectId AND a.visibility = 'PUBLIC'";
long total =
jdbcClient
.sql("SELECT count(*)" + from)
.param("projectId", projectId)
.query(Long.class)
.single();
List<ProjectActivityItemView> items =
jdbcClient
.sql(
"SELECT a.activity_type, a.title, a.summary, a.occurred_at,"
+ " a.related_resource_id"
+ from
+ " ORDER BY a.occurred_at DESC, a.id DESC"
+ " LIMIT :size OFFSET :offset")
.param("projectId", projectId)
.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) -> {
RelatedEntryView related =
publishedEntry(rs.getObject("related_resource_id", UUID.class));
return new ProjectActivityItemView(
rs.getString("activity_type"),
rs.getString("title"),
rs.getString("summary"),
JdbcPublicDocumentQueryAdapter.instant(rs, "occurred_at"),
related == null ? null : related.path());
})
.list();
return new ProjectActivityPageView(
items, PageMetadataView.of(query.page().page(), query.page().size(), total));
});
}
/**
* optional 필터는 SQL 조각과 파라미터 바인딩을 함께 켜고 꺼야 한다. 조각만 빼고 바인딩을 남기면 JdbcClient "쓰이지 않은 파라미터" 실패하고,
* 반대면 파라미터 미해결로 실패한다 총계와 목록 쿼리에서 같은 실수를 하지 않도록 곳에 모은다.
*/
private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bind(
org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec,
UUID projectId,
String status) {
spec = spec.param("projectId", projectId);
return status == null ? spec : spec.param("status", status);
}
private static org.springframework.jdbc.core.simple.JdbcClient.StatementSpec bindRecord(
org.springframework.jdbc.core.simple.JdbcClient.StatementSpec spec,
UUID projectId,
ProjectRecordPageQuery query) {
spec = spec.param("projectId", projectId);
if (query.type() != null) {
spec = spec.param("type", query.type());
}
return query.relation() == null ? spec : spec.param("relation", query.relation());
}
/** 공개된 프로젝트만 하위 목록을 연다 — 비공개 프로젝트의 결정 목록이 새어 나가면 안 된다. */
private Optional<UUID> projectId(String slug) {
return jdbcClient
.sql(
"SELECT pr.id FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.slug = :slug AND "
+ PublicSql.ACTIVE)
.param("slug", slug)
.query(UUID.class)
.optional();
}
/**
* 프로젝트가 다루는 주제. 화면의 "주요 주제" 목록을 그린다.
*
* <p>{@code ACTIVE} 주제만 내보낸다 보관된 주제를 링크로 내보내면 따라간 곳이 비어 있다. 순서는 편집기가 정한 {@code display_order}
* 그대로 따른다.
*/
private List<TopicSummaryView> topicsOf(UUID projectId) {
return jdbcClient
.sql(
"SELECT t.name, t.slug FROM project_topic pt"
+ " JOIN topic t ON t.id = pt.topic_id"
+ " WHERE pt.project_id = :projectId AND t.status = 'ACTIVE'"
+ " ORDER BY pt.display_order, t.name")
.param("projectId", projectId)
.query((rs, rowNum) -> new TopicSummaryView(rs.getString("name"), rs.getString("slug")))
.list();
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 릴리스 목록·상세.
*
* <p>릴리스는 {@code public_resource_projection} 거치지 않는다 설계상 Publication 파이프라인의 대상이 아니라 자체 {@code
* workflow_status} 공개 여부를 정하는 기록이다.
*/
@Repository
public class JdbcPublicReleaseQueryAdapter implements PublicReleaseQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicReleaseQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public List<ReleaseListItemView> list() {
return jdbcClient
.sql(
"SELECT version_label, title, summary, released_on, change_types FROM release"
+ " WHERE workflow_status = 'PUBLISHED'"
+ " ORDER BY released_on DESC NULLS LAST, version_label DESC")
.query(
(rs, rowNum) ->
new ReleaseListItemView(
rs.getString("version_label"),
rs.getString("title"),
rs.getString("summary"),
rs.getDate("released_on") == null
? null
: rs.getDate("released_on").toLocalDate(),
json.strings(rs.getString("change_types")),
"/releases/" + rs.getString("version_label")))
.list();
}
@Override
public Optional<ReleaseDetailView> findByVersion(String version) {
return jdbcClient
.sql(
"SELECT version_label, title, summary, released_on, change_types, reason_markdown,"
+ " changes_markdown, user_impact_markdown, implementation_impact_markdown,"
+ " verification_markdown, known_limitations_markdown, related_resources"
+ " FROM release WHERE version_label = :version AND workflow_status = 'PUBLISHED'")
.param("version", version)
.query(
(rs, rowNum) ->
new ReleaseDetailView(
rs.getString("version_label"),
rs.getString("title"),
rs.getString("summary"),
rs.getDate("released_on") == null
? null
: rs.getDate("released_on").toLocalDate(),
json.strings(rs.getString("change_types")),
rs.getString("reason_markdown"),
rs.getString("changes_markdown"),
rs.getString("user_impact_markdown"),
rs.getString("implementation_impact_markdown"),
rs.getString("verification_markdown"),
rs.getString("known_limitations_markdown"),
relatedRecords(rs.getString("related_resources"))))
.optional();
}
/**
* {@code related_resources} resource id 배열이다. 그중 <b>공개된 것만</b> 되살린다 릴리스가 지목한 기록이 비공개로 바뀌었을
* 있고, 링크를 그대로 내보내면 404 이어진다.
*/
private List<dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView> relatedRecords(
String relatedResourcesJson) {
List<String> ids = json.strings(relatedResourcesJson);
if (ids.isEmpty()) {
return List.of();
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id::text IN (:ids) AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("ids", ids)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
}
@@ -0,0 +1,146 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultItemView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 공개 검색.
*
* <p>게시 만들어 {@code search_text}(제목 + 요약 + 본문 평문) 본다. 검색 본문을 다시 훑지 않는 이유는 평문이 게시 시점에 확정된
* 값이기 때문이다 나중에 초안이 바뀌어도 공개 검색 결과는 공개된 내용을 따라야 한다.
*/
@Repository
public class JdbcPublicSearchQueryAdapter implements PublicSearchQueryPort {
/** 스니펫 길이. 너무 길면 목록이 읽히지 않고, 너무 짧으면 왜 걸렸는지 알 수 없다. */
private static final int SNIPPET_LENGTH = 200;
private final JdbcClient jdbcClient;
public JdbcPublicSearchQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public SearchResultPageView search(SearchQuery query) {
String pattern = "%" + query.query().toLowerCase(Locale.ROOT) + "%";
StringBuilder where =
new StringBuilder(" WHERE " + PublicSql.ACTIVE + " AND lower(p.search_text) LIKE :pattern");
Map<String, Object> params = new HashMap<>();
params.put("pattern", pattern);
if (query.type() != null) {
where.append(" AND p.resource_type = :type");
params.put("type", query.type());
}
if (query.topicSlug() != null) {
where.append(" AND t.slug = :topicSlug");
params.put("topicSlug", query.topicSlug());
}
String joins =
" FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id";
var countSpec = jdbcClient.sql("SELECT count(*)" + joins + where);
for (Map.Entry<String, Object> e : params.entrySet()) {
countSpec = countSpec.param(e.getKey(), e.getValue());
}
long total = countSpec.query(Long.class).single();
var spec =
jdbcClient.sql(
"SELECT p.resource_type, p.title, p.navigation_path, p.summary, p.body_plain_text,"
+ " p.published_at, p.updated_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ joins
+ where
+ " ORDER BY p.published_at DESC, p.resource_id DESC"
+ " LIMIT :size OFFSET :offset");
for (Map.Entry<String, Object> e : params.entrySet()) {
spec = spec.param(e.getKey(), e.getValue());
}
List<SearchResultItemView> items =
spec.param("size", query.page().size())
.param("offset", query.page().offset())
.query(
(rs, rowNum) ->
new SearchResultItemView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("navigation_path"),
snippet(
rs.getString("body_plain_text"),
rs.getString("summary"),
query.query()),
matchedFields(
query.query(),
rs.getString("title"),
rs.getString("summary"),
rs.getString("body_plain_text")),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant(),
rs.getTimestamp("updated_at").toInstant()))
.list();
return new SearchResultPageView(
query.query(), items, PageMetadataView.of(query.page().page(), query.page().size(), total));
}
/** 검색어가 나온 자리를 중심으로 잘라 준다. 없으면 요약을 쓴다. */
private static String snippet(String body, String summary, String term) {
String source = (body == null || body.isBlank()) ? summary : body;
if (source == null || source.isBlank()) {
return "";
}
int at = source.toLowerCase(Locale.ROOT).indexOf(term.toLowerCase(Locale.ROOT));
if (at < 0) {
return source.length() <= SNIPPET_LENGTH ? source : source.substring(0, SNIPPET_LENGTH);
}
int from = Math.max(0, at - SNIPPET_LENGTH / 2);
int to = Math.min(source.length(), from + SNIPPET_LENGTH);
return source.substring(from, to);
}
/** 어느 필드에서 걸렸는지. 사용자가 왜 이 결과가 나왔는지 알 수 있어야 한다. */
private static List<String> matchedFields(
String term, String title, String summary, String body) {
String needle = term.toLowerCase(Locale.ROOT);
List<String> fields = new ArrayList<>();
if (title != null && title.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("title");
}
if (summary != null && summary.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("summary");
}
if (body != null && body.toLowerCase(Locale.ROOT).contains(needle)) {
fields.add("content");
}
return fields;
}
}
@@ -0,0 +1,271 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.AssetReferenceView;
import dev.caskeleton.application.techlog.publicsite.model.HomeFocusView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/** 사이트 · 홈 · 프로필. 셋 다 단일 행 테이블이 원천이다. */
@Repository
public class JdbcPublicSiteQueryAdapter implements PublicSiteQueryPort {
private final JdbcClient jdbcClient;
private final PublicJson json;
public JdbcPublicSiteQueryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.json = new PublicJson(objectMapper);
}
@Override
public Optional<SiteView> site() {
return jdbcClient
.sql(
"SELECT s.brand_title, s.identity_statement, s.operator_display_name,"
+ " s.short_identity, s.contacts, s.avatar_asset_id,"
+ " a.content_type, a.alt_text, a.width, a.height"
+ " FROM site_config s LEFT JOIN asset a ON a.id = s.avatar_asset_id")
.query(
(rs, rowNum) ->
new SiteView(
rs.getString("brand_title"),
rs.getString("identity_statement"),
rs.getString("operator_display_name"),
rs.getString("short_identity"),
avatar(rs),
"/profile",
json.contacts(rs.getString("contacts"))))
.optional();
}
private static AssetReferenceView avatar(java.sql.ResultSet rs) throws java.sql.SQLException {
UUID assetId = rs.getObject("avatar_asset_id", UUID.class);
if (assetId == null) {
return null;
}
return new AssetReferenceView(
assetId,
// 본문과 마찬가지로 저장소 경로가 아니라 안정적인 전송 경로를 노출한다(설계 05장 §3.1).
"/api/v1/public/media/" + assetId,
rs.getString("alt_text"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getString("content_type"));
}
@Override
public HomeView home(int latestEntryLimit) {
HomeFocusView focus =
jdbcClient
.sql(
"SELECT default_focus_type, current_project_id, open_question_id,"
+ " recent_decision_id FROM home_focus_config")
.query(
(rs, rowNum) ->
HomeFocusView.resolve(
rs.getString("default_focus_type"),
currentWork(rs.getObject("current_project_id", UUID.class)),
openQuestion(rs.getObject("open_question_id", UUID.class)),
recentDecision(rs.getObject("recent_decision_id", UUID.class))))
.optional()
.orElseGet(() -> HomeFocusView.resolve(null, null, null, null));
return new HomeView(focus, latestEntries(latestEntryLimit));
}
/** 지목한 프로젝트가 지워졌거나 비공개면 focus 는 비운다 — 없는 것을 억지로 채우지 않는다. */
private HomeFocusView.CurrentWork currentWork(UUID projectId) {
if (projectId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT pr.name, pr.slug, pr.one_line_purpose, pr.phase, pr.current_objective,"
+ " pr.next_step, pr.updated_at FROM project pr"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pr.id"
+ " WHERE pr.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", projectId)
.query(
(rs, rowNum) ->
new HomeFocusView.CurrentWork(
rs.getString("name"),
"/projects/" + rs.getString("slug"),
rs.getString("one_line_purpose"),
rs.getString("phase"),
rs.getString("current_objective"),
rs.getString("next_step"),
rs.getTimestamp("updated_at").toInstant()))
.optional()
.orElse(null);
}
private HomeFocusView.OpenQuestion openQuestion(UUID questionId) {
if (questionId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT q.id, q.question, q.slug, q.summary, q.next_verification, q.updated_at"
+ " FROM open_question q"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
+ " WHERE q.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", questionId)
.query(
(rs, rowNum) ->
new HomeFocusView.OpenQuestion(
rs.getString("question"),
"/questions/" + rs.getString("slug"),
rs.getString("summary"),
points(questionId, "FACT"),
points(questionId, "UNKNOWN"),
rs.getString("next_verification"),
rs.getTimestamp("updated_at").toInstant()))
.optional()
.orElse(null);
}
private List<String> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT content FROM question_point WHERE question_id = :id AND point_kind = :kind"
+ " ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(String.class)
.list();
}
private HomeFocusView.RecentDecision recentDecision(UUID decisionId) {
if (decisionId == null) {
return null;
}
return jdbcClient
.sql(
"SELECT d.statement, d.slug, d.rationale_markdown, d.consequences, d.decided_at,"
+ " pr.slug AS project_slug FROM project_decision d"
+ " LEFT JOIN project pr ON pr.id = d.project_id"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = d.id"
+ " WHERE d.id = :id AND "
+ PublicSql.ACTIVE)
.param("id", decisionId)
.query(
(rs, rowNum) ->
new HomeFocusView.RecentDecision(
rs.getString("statement"),
PublicSql.pathOf(
"PROJECT_DECISION", rs.getString("slug"), rs.getString("project_slug")),
rs.getString("rationale_markdown"),
json.strings(rs.getString("consequences")),
rs.getTimestamp("decided_at") == null
? null
: rs.getTimestamp("decided_at").toInstant()))
.optional()
.orElse(null);
}
/**
* 계약 {@code LatestEntry.entryType} {@code CASE / REFERENCE / QUESTION / PROJECT_ACTIVITY /
* RELEASE} 다섯 값을 허용한다. projection 에는 {@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE}
* 들어 있으므로 여기서 걸러야 한다 거르지 않으면 응답 매퍼가 계약 값을 만나 500 되고, 500 화면 전체를 쓰게 만든다.
*
* <p>{@code RELEASE} 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
* workflow_status} 공개되므로 projection 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
* 계약은 값을 <b>허용</b> 매번 포함하라고 요구하지 않는다.
*/
private List<LatestEntryView> latestEntries(int limit) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ " FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " WHERE "
+ PublicSql.ACTIVE
+ " AND "
+ PublicSql.LATEST_ENTRY_TYPES
+ " ORDER BY p.published_at DESC LIMIT :limit")
.param("limit", limit)
.query(
(rs, rowNum) ->
new LatestEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant()))
.list();
}
@Override
public Optional<ProfileView> profile() {
return jdbcClient
.sql(
"SELECT headline, introduction_markdown, working_model, territories,"
+ " selected_evidence, trajectory, contacts FROM profile_page"
+ " WHERE target_visibility = 'PUBLIC'")
.query(
(rs, rowNum) ->
new ProfileView(
rs.getString("headline"),
rs.getString("introduction_markdown"),
json.namedDescriptions(rs.getString("working_model")),
json.territories(rs.getString("territories")),
selectedEvidence(rs.getString("selected_evidence")),
json.namedDescriptions(rs.getString("trajectory")),
json.contacts(rs.getString("contacts"))))
.optional();
}
/**
* {@code selected_evidence} resource id 배열이다. 그중 <b>공개된 것만</b> 되살린다 프로필이 지목한 기록이 비공개로 바뀌었을
* 있고, 링크를 그대로 내보내면 404 이어진다({@code JdbcPublicReleaseQueryAdapter} {@code related_resources}
* 같은 규칙).
*/
private List<RelatedEntryView> selectedEvidence(String selectedEvidenceJson) {
List<String> ids = json.strings(selectedEvidenceJson);
if (ids.isEmpty()) {
return List.of();
}
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_id::text IN (:ids) AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("ids", ids)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
}
@@ -0,0 +1,178 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.LatestEntryView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectSummaryView;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import dev.caskeleton.application.techlog.publicsite.model.TopicSummaryView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 주제 목록·상세. 개수와 목록 모두 공개된 것만 센다. */
@Repository
public class JdbcPublicTopicQueryAdapter implements PublicTopicQueryPort {
/** 상세 화면이 한 화면에 담는 개수. */
private static final int SECTION_LIMIT = 10;
private final JdbcClient jdbcClient;
public JdbcPublicTopicQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public List<TopicListItemView> list() {
return jdbcClient
.sql(
"SELECT t.name, t.slug, t.description,"
+ " (SELECT count(*) FROM public_resource_projection p"
+ " WHERE p.primary_topic_id = t.id AND "
+ PublicSql.ACTIVE
+ ") AS record_count"
+ " FROM topic t WHERE t.status = 'ACTIVE' ORDER BY t.name")
.query(
(rs, rowNum) ->
new TopicListItemView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("description"),
rs.getInt("record_count")))
.list();
}
@Override
public Optional<TopicDetailView> findBySlug(String slug) {
return jdbcClient
.sql(
"SELECT id, name, slug, description, scope FROM topic WHERE slug = :slug AND status = 'ACTIVE'")
.param("slug", slug)
.query(
(rs, rowNum) -> {
UUID topicId = rs.getObject("id", UUID.class);
return new TopicDetailView(
rs.getString("name"),
rs.getString("slug"),
rs.getString("description"),
rs.getString("scope"),
featured(topicId, "START_HERE").stream().findFirst().orElse(null),
featured(topicId, "FEATURED_CASE"),
activeQuestions(topicId),
relatedProjects(topicId),
latestRecords(topicId));
})
.optional();
}
/**
* {@code topic_featured_document} 지목한 문서 <b>공개된 것만</b> 보여준다 지목은 Studio 편집 행위이고 공개 여부와
* 별개다.
*/
private List<RelatedEntryView> featured(UUID topicId, String role) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM topic_featured_document f"
+ " JOIN public_resource_projection p ON p.resource_id = f.document_id"
+ " WHERE f.topic_id = :topicId AND f.feature_role = :role AND "
+ PublicSql.ACTIVE
+ " ORDER BY f.display_order")
.param("topicId", topicId)
.param("role", role)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
private List<RelatedEntryView> activeQuestions(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM public_resource_projection p"
+ " WHERE p.resource_type = 'QUESTION' AND p.primary_topic_id = :topicId"
+ " AND p.state_code <> 'RESOLVED' AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.updated_at DESC LIMIT :limit")
.param("topicId", topicId)
.param("limit", SECTION_LIMIT)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
private List<RelatedEntryView> relatedProjects(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM project_topic pt"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = pt.project_id"
+ " WHERE pt.topic_id = :topicId AND "
+ PublicSql.ACTIVE
+ " ORDER BY pt.display_order")
.param("topicId", topicId)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/**
* 계약 {@code LatestEntry.entryType} {@code CASE / REFERENCE / PROJECT_ACTIVITY / RELEASE} 값만
* 허용한다. projection 에는 {@code QUESTION}·{@code PROJECT}·{@code PROJECT_DECISION}·{@code PROFILE}
* 들어 있으므로 여기서 걸러야 한다 거르지 않으면 응답 매퍼가 계약 값을 만나 500 되고, 500 화면 전체를 쓰게 만든다.
*
* <p>{@code RELEASE} 결과에 없는 것은 누락이 아니다. 릴리스는 Publication 파이프라인을 거치지 않고 자체 {@code
* workflow_status} 공개되므로 projection 아예 행이 없다({@code JdbcPublicReleaseQueryAdapter} 클래스 주석).
* 계약은 값을 <b>허용</b> 매번 포함하라고 요구하지 않는다.
*/
private List<LatestEntryView> latestRecords(UUID topicId) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path, p.published_at,"
+ " t.name AS topic_name, t.slug AS topic_slug,"
+ " pr.name AS project_name, pr.slug AS project_slug"
+ " FROM public_resource_projection p"
+ " LEFT JOIN topic t ON t.id = p.primary_topic_id"
+ " LEFT JOIN public_resource_project_link l"
+ " ON l.resource_type = p.resource_type AND l.resource_id = p.resource_id"
+ " AND l.relation_type = 'PRIMARY'"
+ " LEFT JOIN project pr ON pr.id = l.project_id"
+ " WHERE p.primary_topic_id = :topicId AND "
+ PublicSql.ACTIVE
+ " AND "
+ PublicSql.LATEST_ENTRY_TYPES
+ " ORDER BY p.published_at DESC LIMIT :limit")
.param("topicId", topicId)
.param("limit", SECTION_LIMIT)
.query(
(rs, rowNum) ->
new LatestEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"),
rs.getString("topic_slug") == null
? null
: new TopicSummaryView(
rs.getString("topic_name"), rs.getString("topic_slug")),
rs.getString("project_slug") == null
? null
: new ProjectSummaryView(
rs.getString("project_name"),
rs.getString("project_slug"),
"/projects/" + rs.getString("project_slug")),
rs.getTimestamp("published_at").toInstant()))
.list();
}
static RelatedEntryView relatedEntry(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return new RelatedEntryView(
rs.getString("resource_type"),
rs.getString("title"),
rs.getString("summary"),
rs.getString("navigation_path"));
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.ContactLinkView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceRuleView;
import dev.caskeleton.shared.error.MappingException;
import java.util.ArrayList;
import java.util.List;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* 공개 조회가 읽는 jsonb 컬럼을 푼다.
*
* <p>Jackson POJO 바인딩을 쓰지 않고 key 명시적으로 읽는다 값들은 DB 영속된 모양이라 application record 필드 이름이
* 바뀌면 이미 저장된 행을 읽게 된다.
*/
final class PublicJson {
private final ObjectMapper mapper;
PublicJson(ObjectMapper mapper) {
this.mapper = mapper;
}
List<String> strings(String json) {
List<String> out = new ArrayList<>();
for (JsonNode node : array(json)) {
// 설계의 배열 컬럼은 문자열이거나 {text: ...} 모양일 있다. 받는다.
out.add(node.isString() ? node.asString("") : node.path("text").asString(node.toString()));
}
return out;
}
/** Reference 의 판단 기준. 설계의 컬럼은 {@code {title, body, order}} 배열이다. */
List<ReferenceRuleView> referenceRules(String json) {
List<ReferenceRuleView> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ReferenceRuleView(node.path("title").asString(""), node.path("body").asString("")));
}
return out;
}
List<ContactLinkView> contacts(String json) {
List<ContactLinkView> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ContactLinkView(
node.path("type").asString(""),
node.path("label").asString(""),
node.path("url").asString("")));
}
return out;
}
List<ProfileView.NamedDescription> namedDescriptions(String json) {
List<ProfileView.NamedDescription> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ProfileView.NamedDescription(
node.path("name").asString(node.path("title").asString("")),
node.path("description").asString("")));
}
return out;
}
List<ProfileView.Territory> territories(String json) {
List<ProfileView.Territory> out = new ArrayList<>();
for (JsonNode node : array(json)) {
out.add(
new ProfileView.Territory(
node.path("name").asString(""),
node.path("currentQuestion").asString(null),
node.path("topicPath").asString(null)));
}
return out;
}
private Iterable<JsonNode> array(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode node = mapper.readTree(json);
return node.isArray() ? node : List.of();
} catch (JacksonException e) {
throw new MappingException("failed to read a public jsonb column", e);
}
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import dev.caskeleton.application.techlog.publicsite.model.RelatedEntryView;
import java.util.List;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 공개 상세가 보여주는 관계.
*
* <p><b>어디서 읽는지가 중요하다.</b> 설계 스키마에는 유형별 링크 테이블({@code document_relation}, {@code
* question_document_link}) 있지만 <b> 테이블들에 쓰는 경로가 없다</b> Studio 편집기가 만드는 관계는 전부 {@code
* studio_relation} 들어간다(계약의 relations[] 유형 공통이라 그렇게 설계했다). 그래서 공개도 같은 곳에서 읽는다. 링크 테이블을 읽으면
* 관계가 항상 비어 보인다.
*
* <p>관계의 종류는 저장돼 있지 않으므로 <b>대상의 유형</b>으로 나눈다 계약이 관계를 유형별 묶음 (relatedCases / derivedReferences /
* projectDecisions / originQuestion)으로 요구하기 때문이다. 공개되지 않은 대상은 제외한다.
*/
final class PublicRelationLookup {
private final JdbcClient jdbcClient;
PublicRelationLookup(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/** {@code sourceKind} 문서가 가리키는 관계 중 대상이 {@code targetType} 이고 공개된 것들. */
List<RelatedEntryView> targetsOfType(String sourceKind, UUID sourceId, String targetType) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM studio_relation r"
+ " JOIN public_resource_projection p ON p.resource_id = r.target_id"
+ " WHERE r.source_kind = :sourceKind AND r.source_id = :sourceId"
+ " AND p.resource_type = :targetType AND "
+ PublicSql.ACTIVE
+ " ORDER BY r.display_order")
.param("sourceKind", sourceKind)
.param("sourceId", sourceId)
.param("targetType", targetType)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/** 같은 조회의 단수형. 계약이 하나만 받는 자리(originQuestion 등)에 쓴다. */
RelatedEntryView firstTargetOfType(String sourceKind, UUID sourceId, String targetType) {
return targetsOfType(sourceKind, sourceId, targetType).stream().findFirst().orElse(null);
}
/** 이 기록을 가리키는 <b>역방향</b> 관계. "이 Reference 를 적용한 Case" 같은 자리에 쓴다. */
List<RelatedEntryView> sourcesOfType(UUID targetId, String sourceType) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM studio_relation r"
+ " JOIN public_resource_projection p ON p.resource_id = r.source_id"
+ " WHERE r.target_id = :targetId AND p.resource_type = :sourceType"
+ " AND "
+ PublicSql.ACTIVE
+ " ORDER BY p.published_at DESC")
.param("targetId", targetId)
.param("sourceType", sourceType)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.list();
}
/** 이 기록이 속한 프로젝트. {@code project_*_link} 의 PRIMARY 를 따른다. */
RelatedEntryView primaryProject(String linkTable, String idColumn, UUID id) {
return jdbcClient
.sql(
"SELECT p.resource_type, p.title, p.summary, p.navigation_path"
+ " FROM "
+ linkTable
+ " l"
+ " JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT' AND p.resource_id = l.project_id"
+ " WHERE l."
+ idColumn
+ " = :id AND l.relation_type = 'PRIMARY'"
+ " AND "
+ PublicSql.ACTIVE)
.param("id", id)
.query(JdbcPublicTopicQueryAdapter::relatedEntry)
.optional()
.orElse(null);
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
/**
* 공개 조회가 공유하는 SQL 조각.
*
* <p>"무엇이 공개인가" 정의를 곳에 둔다. 쿼리가 조건을 따로 쓰면 어느 하나가 {@code publication_state} 빠뜨려도 드러나지 않고,
* 결과는 게시 취소한 문서가 계속 보이는 사고다.
*/
final class PublicSql {
/** 공개 노출 조건. 게시 취소({@code WITHDRAWN})와 비공개({@code UNLISTED})를 함께 배제한다. */
static final String ACTIVE = " p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
/**
* 계약 {@code LatestEntry.entryType} 허용하는 projection 실제로 담기는 것들. 홈과 주제 상세가 같은 목록 의미를 쓰므로
* 조건도 곳에서 정의한다.
*/
static final String LATEST_ENTRY_TYPES =
" p.resource_type IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_ACTIVITY') ";
private PublicSql() {}
/** 유형별 공개 경로. 게시 시 {@code navigation_path} 에 저장된 값을 그대로 쓴다. */
static String pathOf(String resourceType, String slug, String projectSlug) {
return switch (resourceType) {
case "CASE" -> "/cases/" + slug;
case "REFERENCE" -> "/references/" + slug;
case "QUESTION" -> "/questions/" + slug;
case "PROJECT" -> "/projects/" + slug;
case "PROJECT_DECISION" ->
projectSlug == null ? null : "/projects/" + projectSlug + "/decisions/" + slug;
case "RELEASE" -> "/releases/" + slug;
default -> null;
};
}
}
@@ -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());
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 의존 상태 해시를 {@link StudioDocumentSql} 정의로 계산한다.
*
* <p>목록이 쓰는 SQL과 <b>같은 </b> 쓴다. 여기서 다른 식을 쓰면 상세 화면이 계산한 값과 목록이 계산한 값이 달라져, 검증을 통과한 문서가 목록에서는
* "다시 검증하라" 보인다.
*/
@Repository
public class JdbcDependencyRevisionAdapter implements DependencyRevisionPort {
private final JdbcClient jdbcClient;
public JdbcDependencyRevisionAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public String revisionFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT dependency_revision FROM studio_document WHERE id = :id")
.param("id", documentId)
.query(String.class)
.optional()
.orElseThrow(
() ->
StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"cannot compute a dependency revision for unknown document " + documentId));
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 현재 게시 상태 조회. {@code publication}은 (source_kind, source_id)당 한 행이다. */
@Repository
public class JdbcPublicationQueryAdapter implements PublicationQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicationQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Optional<PublicationAggregateView> currentFor(UUID documentId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at"
+ " FROM publication WHERE source_id = :id")
.param("id", documentId)
.query(
(rs, rowNum) ->
new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("published_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("updated_at").toInstant()))
.optional();
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DashboardTotalsView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 대시보드 집계. 목록과 <b>같은</b> {@code studio_document} 정의를 쓴다 대시보드가 "게시 준비됨"이라고 문서와 목록에서 필터로 나오는
* 문서가 달라지면 숫자를 믿을 없다.
*/
@Repository
public class JdbcStudioDashboardQueryAdapter implements StudioDashboardQueryPort {
private final JdbcClient jdbcClient;
public JdbcStudioDashboardQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public List<DocumentSummaryView> topByNextAction(List<NextAction> actions, int limit) {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT * FROM studio_document WHERE next_action IN (:actions)"
+ " ORDER BY updated_at DESC, id DESC LIMIT :limit")
.param("actions", actions.stream().map(Enum::name).toList())
.param("limit", limit)
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
.list();
}
@Override
public DashboardTotalsView totals() {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT count(*) AS documents,"
+ " count(*) FILTER (WHERE next_action IN ('VALIDATE', 'FIX_VALIDATION'))"
+ " AS needs_validation,"
+ " count(*) FILTER (WHERE next_action = 'PUBLISH') AS ready_to_publish,"
+ " (SELECT count(*) FROM publication WHERE status = 'PUBLISHED') AS publications"
+ " FROM studio_document")
.query(
(rs, rowNum) ->
new DashboardTotalsView(
rs.getInt("documents"),
rs.getInt("needs_validation"),
rs.getInt("ready_to_publish"),
rs.getInt("publications")))
.single();
}
}
@@ -0,0 +1,225 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.PublicPaths;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 편집본이 의존하는 바깥 상태를 한 번에 읽는다. 검증과 렌더링이 같은 결과를 공유하도록 조회는 이 한 곳에서만 한다. */
@Repository
public class JdbcStudioDependencyResolverAdapter implements StudioDependencyResolverPort {
private final JdbcClient jdbcClient;
public JdbcStudioDependencyResolverAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Resolved resolve(WorkingCopyView document, Set<String> referencedAssetKeys) {
UUID topicId = document.base().topicId();
UUID projectId = document.base().projectId();
DisplayTargetView topic = topicId == null ? null : findTopic(topicId);
DisplayTargetView project = projectId == null ? null : findProject(projectId);
List<ResolvedRelationView> relations = new ArrayList<>();
List<UUID> missingTargets = new ArrayList<>();
resolveRelations(document, relations, missingTargets);
Map<String, ResolvedAssetView> assetsByKey = new LinkedHashMap<>();
Map<String, String> assetStatusByKey = new LinkedHashMap<>();
resolveAssets(referencedAssetKeys, assetsByKey, assetStatusByKey);
String projectSlug = projectId == null ? null : findProjectSlug(projectId);
String publicPath = PublicPaths.forKind(document.kind(), document.base().slug(), projectSlug);
return new Resolved(
topic,
topicId != null && topic == null,
project,
projectId != null && project == null,
relations,
missingTargets,
assetsByKey,
assetStatusByKey,
resolveEvidenceTarget(document),
publicPath,
findSlugOwner(document));
}
private DisplayTargetView findTopic(UUID topicId) {
return jdbcClient
.sql("SELECT id, name, slug FROM topic WHERE id = :id")
.param("id", topicId)
.query(
(rs, rowNum) ->
new DisplayTargetView(
rs.getObject("id", UUID.class),
rs.getString("name"),
"/topics/" + rs.getString("slug")))
.optional()
.orElse(null);
}
private DisplayTargetView findProject(UUID projectId) {
return jdbcClient
.sql("SELECT id, name, slug FROM project WHERE id = :id")
.param("id", projectId)
.query(
(rs, rowNum) ->
new DisplayTargetView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("slug") == null ? null : "/projects/" + rs.getString("slug")))
.optional()
.orElse(null);
}
private String findProjectSlug(UUID projectId) {
return jdbcClient
.sql("SELECT slug FROM project WHERE id = :id")
.param("id", projectId)
.query(String.class)
.optional()
.orElse(null);
}
/**
* 관계 대상은 유형 어디에도 있을 있고 프로젝트일 수도 있다(계약 {@code ResolvedRelation.targetKind} {@code
* RecordKind} 보다 하나 넓다). UNION 으로 번에 찾는다.
*/
private void resolveRelations(
WorkingCopyView document, List<ResolvedRelationView> resolved, List<UUID> missing) {
for (RelationView relation : document.base().relations()) {
if (relation.targetId() == null) {
continue;
}
TargetRow row = findTarget(relation.targetId());
if (row == null) {
missing.add(relation.targetId());
continue;
}
resolved.add(
new ResolvedRelationView(
relation.id(),
relation.targetId(),
row.kind(),
row.title(),
row.publicPath(),
relation.reason() == null ? "" : relation.reason(),
relation.order()));
}
}
private record TargetRow(String kind, String title, String publicPath) {}
private TargetRow findTarget(UUID targetId) {
return jdbcClient
.sql(
"SELECT document_type AS kind, title, slug, NULL::text AS project_slug FROM document"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'QUESTION', question, slug, NULL::text FROM open_question"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT', name, slug, NULL::text FROM project"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT_DECISION', pd.title, pd.slug, p.slug"
+ " FROM project_decision pd LEFT JOIN project p ON p.id = pd.project_id"
+ " WHERE pd.id = :id")
.param("id", targetId)
.query(
(rs, rowNum) -> {
String kind = rs.getString("kind");
String slug = rs.getString("slug");
String publicPath =
"PROJECT".equals(kind)
? (slug == null ? null : "/projects/" + slug)
: PublicPaths.forKind(
RecordKind.valueOf(kind), slug, rs.getString("project_slug"));
return new TargetRow(kind, rs.getString("title"), publicPath);
})
.optional()
.orElse(null);
}
private void resolveAssets(
Set<String> keys, Map<String, ResolvedAssetView> assets, Map<String, String> statuses) {
if (keys.isEmpty()) {
return;
}
jdbcClient
.sql(
"SELECT id, asset_key, content_type, object_key, width, height, decorative,"
+ " management_status FROM asset WHERE asset_key IN (:keys)")
.param("keys", keys)
.query(
(rs, rowNum) -> {
String key = rs.getString("asset_key");
statuses.put(key, rs.getString("management_status"));
assets.put(
key,
new ResolvedAssetView(
rs.getObject("id", UUID.class),
key,
rs.getString("content_type"),
// 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다
// (설계 05장 §3.1).
"/api/v1/public/media/" + rs.getString("id"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getBoolean("decorative")));
return key;
})
.list();
}
private DisplayTargetView resolveEvidenceTarget(WorkingCopyView document) {
if (!(document instanceof WorkingCopyView.QuestionWorkingCopyView value)
|| value.resolution() == null
|| value.resolution().evidenceTargetId() == null) {
return null;
}
TargetRow row = findTarget(value.resolution().evidenceTargetId());
return row == null
? null
: new DisplayTargetView(
value.resolution().evidenceTargetId(), row.title(), row.publicPath());
}
/**
* 같은 공개 경로 이름공간(= 같은 유형)에서 slug 이미 쓰는 다른 기록. 유형이 다르면 경로 접두사가 달라 충돌하지 않는다({@code /cases/x}
* {@code /questions/x} 다른 주소다).
*/
private UUID findSlugOwner(WorkingCopyView document) {
String slug = document.base().slug();
if (slug == null || slug.isBlank()) {
return null;
}
String sql =
switch (document.kind()) {
case CASE, REFERENCE ->
"SELECT id FROM document WHERE slug = :slug AND document_type = :kind AND id <> :id";
case QUESTION -> "SELECT id FROM open_question WHERE slug = :slug AND id <> :id";
case PROJECT_DECISION ->
"SELECT id FROM project_decision WHERE slug = :slug AND id <> :id";
};
var spec = jdbcClient.sql(sql).param("slug", slug).param("id", document.id());
if (document.kind() == RecordKind.CASE || document.kind() == RecordKind.REFERENCE) {
spec = spec.param("kind", document.kind().name());
}
return spec.query(UUID.class).optional().orElse(null);
}
}
@@ -0,0 +1,141 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code listStudioDocuments}. 유형이 서로 다른 테이블에 살기 때문에 공통 repository 대신 union projection을 돌린다(계약
* 설명, spec §8.3).
*
* <p>정렬 키에 항상 {@code id} 붙인다. {@code updated_at}만으로 자르면 같은 시각의 행들이 페이지 경계에서 중복되거나 누락된다 대량 저장 직후에
* 실제로 일어나는 일이다.
*/
@Repository
public class JdbcStudioDocumentQueryAdapter implements StudioDocumentQueryPort {
private final JdbcClient jdbcClient;
public JdbcStudioDocumentQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public DocumentPageView list(ListDocumentsQuery query) {
StringBuilder sql = new StringBuilder(StudioDocumentSql.documentProjectionCte());
sql.append(" SELECT * FROM studio_document WHERE 1 = 1");
Map<String, Object> params = new java.util.HashMap<>();
if (query.kind() != null) {
sql.append(" AND kind = :kind");
params.put("kind", query.kind().name());
}
if (query.projectId() != null) {
sql.append(" AND project_id = :projectId");
params.put("projectId", query.projectId());
}
if (query.nextAction() != null) {
sql.append(" AND next_action = :nextAction");
params.put("nextAction", query.nextAction().name());
}
if (query.publicationStatus() != null) {
sql.append(publicationStatusPredicate());
params.put("publicationStatus", query.publicationStatus().name());
}
if (query.query() != null && !query.query().isBlank()) {
sql.append(" AND lower(title) LIKE :titlePattern");
params.put("titlePattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
}
appendCursorPredicate(sql, params, query);
sql.append(orderBy(query.sort()));
// limit + 1 읽어 "다음 쪽이 있는가" 별도 count 없이 판정한다.
sql.append(" LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
boolean hasMore = rows.size() > query.limit();
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
List<DocumentSummaryView> items = new ArrayList<>(page.size());
for (Row row : page) {
items.add(row.summary());
}
return new DocumentPageView(
items, hasMore ? cursorPayload(page.getLast(), query.sort()) : null);
}
/**
* 게시 이력이 없는 문서는 {@code publication} 자체가 없다 {@code NEVER_PUBLISHED} "행이 없음"이지 특정 status 값이
* 아니다.
*/
private static String publicationStatusPredicate() {
return " AND ((:publicationStatus = 'NEVER_PUBLISHED' AND publication_status IS NULL)"
+ " OR publication_status = :publicationStatus)";
}
private static String orderBy(DocumentSort sort) {
return switch (sort) {
case UPDATED_DESC -> " ORDER BY updated_at DESC, id DESC";
case UPDATED_ASC -> " ORDER BY updated_at ASC, id ASC";
case TITLE_ASC -> " ORDER BY title ASC, id ASC";
};
}
private static void appendCursorPredicate(
StringBuilder sql, Map<String, Object> params, ListDocumentsQuery query) {
DocumentCursorPosition position = query.position();
if (position == null) {
return;
}
// switch 문이 아니라 식이다 열거 전부를 다루면 default 필요 없고, 정렬이 늘면 컴파일러가
// 여기서 막아 준다(문이면 커서 조건 없이 조용히 페이지를 다시 준다).
String predicate =
switch (query.sort()) {
case UPDATED_DESC -> {
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
yield " AND (updated_at, id) < (:cursorUpdatedAt, :cursorId)";
}
case UPDATED_ASC -> {
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
yield " AND (updated_at, id) > (:cursorUpdatedAt, :cursorId)";
}
case TITLE_ASC -> {
params.put("cursorTitle", position.title());
yield " AND (title, id) > (:cursorTitle, :cursorId)";
}
};
params.put("cursorId", position.id());
sql.append(predicate);
}
/** 다음 쪽의 시작 위치. web 계층이 이 값을 서명해 opaque cursor 로 만든다. */
private static String cursorPayload(Row last, DocumentSort sort) {
return switch (sort) {
case UPDATED_DESC, UPDATED_ASC -> last.updatedAt().toInstant() + "|" + last.summary().id();
case TITLE_ASC -> last.title() + "|" + last.summary().id();
};
}
/** 커서 계산에 필요한 정렬 키만 요약과 함께 들고 다닌다. */
private record Row(DocumentSummaryView summary, java.sql.Timestamp updatedAt, String title) {}
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new Row(
StudioDocumentRowMapper.read(rs), rs.getTimestamp("updated_at"), rs.getString("title"));
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
/**
* {@code studio_document} 계약 {@code DocumentSummary}.
*
* <p>목록·대시보드·게시 이력이 모두 매퍼를 쓴다. 화면마다 따로 만들면 같은 문서가 화면마다 다른 {@code nextAction} 이나 {@code
* hasUnpublishedChanges} 보인다.
*/
public final class StudioDocumentRowMapper {
private StudioDocumentRowMapper() {}
public static DocumentSummaryView read(ResultSet rs) throws SQLException {
UUID projectId = rs.getObject("project_id", UUID.class);
String projectSlug = rs.getString("project_slug");
Long publishedVersion = (Long) rs.getObject("published_version");
long version = rs.getLong("version");
String publicationStatus = rs.getString("publication_status");
return new DocumentSummaryView(
rs.getObject("id", UUID.class),
rs.getString("title"),
RecordKind.valueOf(rs.getString("kind")),
projectId == null
? null
: new DisplayTargetView(
projectId,
rs.getString("project_name"),
projectSlug == null ? null : "/projects/" + projectSlug),
rs.getTimestamp("updated_at").toInstant(),
publicationStatus == null
? PublicationStatusView.NEVER_PUBLISHED
: PublicationStatusView.valueOf(publicationStatus),
publishedVersion,
// 계약: "게시 취소 상태에서도 과거 publishedVersion 과 비교한다."
publishedVersion != null && publishedVersion != version,
NextAction.valueOf(rs.getString("next_action")));
}
}
@@ -0,0 +1,130 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
/**
* Studio 문서 union projection의 SQL 정의. 목록·대시보드·단건 조회가 <b>같은</b> 정의를 쓴다.
*
* <p>{@code dependencyRevision} {@code nextAction} 저장하지 않고 계산하는 값이다(spec §6.3, §7.3). 계산식이 SQL
* 곳과 Java 곳에 따로 있으면 목록의 {@code nextAction} 상세의 {@code nextAction} 조용히 갈라진다 사용자에게는 "목록에서는
* 게시하라더니 열어 보니 검증하라"는 모순으로 보인다. 그래서 계산은 여기 SQL 한 벌만 둔다.
*/
public final class StudioDocumentSql {
/**
* 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 올리는 순간 기존 validation/preview가 전부 stale이 되어 다시 검증·미리보기를 거치게 된다.
*/
public static final String RENDERER_CONTRACT_VERSION = "1";
private StudioDocumentSql() {}
/**
* 유형을 하나의 모양으로 모으고, 위에 의존 상태 해시와 최신 artifact를 붙인 CTE 묶음.
*
* <p>마지막 CTE {@code studio_document} 최종 결과이며 컬럼은 다음과 같다.
*
* <pre>{@code
* id kind title version updated_at topic_id project_id project_name project_slug
* dependency_revision
* validation_version validation_status validation_valid_until validation_revision
* preview_version preview_expires_at preview_revision
* publication_status published_version
* next_action
* }</pre>
*/
public static String documentProjectionCte() {
return """
WITH studio_source AS (
SELECT d.id,
d.document_type AS kind,
d.title,
d.version,
d.updated_at,
d.primary_topic_id AS topic_id,
(SELECT l.project_id FROM project_document_link l
WHERE l.document_id = d.id AND l.relation_type = 'PRIMARY') AS project_id
FROM document d
UNION ALL
SELECT q.id, 'QUESTION', q.question, q.version, q.updated_at, q.primary_topic_id,
(SELECT l.project_id FROM project_question_link l
WHERE l.question_id = q.id AND l.relation_type = 'PRIMARY')
FROM open_question q
UNION ALL
SELECT pd.id, 'PROJECT_DECISION', pd.title, pd.version, pd.updated_at,
pd.primary_topic_id, pd.project_id
FROM project_decision pd
),
studio_dependency AS (
SELECT s.id,
md5(concat_ws('|',
coalesce(t.id::text || ':' || t.version::text, '-'),
coalesce(p.id::text || ':' || p.version::text, '-'),
coalesce((
SELECT md5(string_agg(rel.sig, ',' ORDER BY rel.sig))
FROM (
SELECT r.target_id::text || ':'
|| coalesce(rd.version, rq.version, rp.version, 0)::text AS sig
FROM studio_relation r
LEFT JOIN document rd ON rd.id = r.target_id
LEFT JOIN open_question rq ON rq.id = r.target_id
LEFT JOIN project_decision rp ON rp.id = r.target_id
WHERE r.source_kind = s.kind AND r.source_id = s.id
) rel
), '-'),
'renderer:%s'
)) AS dependency_revision
FROM studio_source s
LEFT JOIN topic t ON t.id = s.topic_id
LEFT JOIN project p ON p.id = s.project_id
),
studio_latest_validation AS (
SELECT DISTINCT ON (v.source_id)
v.source_id, v.validation_id, v.validated_version, v.status,
v.valid_until, v.dependency_revision
FROM studio_validation v
ORDER BY v.source_id, v.validated_at DESC
),
studio_latest_preview AS (
SELECT DISTINCT ON (pv.source_id)
pv.source_id, pv.preview_id, pv.source_version, pv.expires_at,
pv.dependency_revision
FROM studio_preview pv
ORDER BY pv.source_id, pv.created_at DESC
),
studio_document AS (
SELECT s.id, s.kind, s.title, s.version, s.updated_at, s.topic_id, s.project_id,
pr.name AS project_name, pr.slug AS project_slug,
dep.dependency_revision,
val.validated_version AS validation_version,
val.status AS validation_status,
val.valid_until AS validation_valid_until,
val.dependency_revision AS validation_revision,
prev.source_version AS preview_version,
prev.expires_at AS preview_expires_at,
prev.dependency_revision AS preview_revision,
pub.status AS publication_status,
pub.published_version,
CASE
WHEN s.title IS NULL OR btrim(s.title) = '' THEN 'CONTINUE_EDITING'
WHEN val.validated_version IS NULL
OR val.validated_version <> s.version
OR val.dependency_revision IS DISTINCT FROM dep.dependency_revision
OR val.valid_until <= now() THEN 'VALIDATE'
WHEN val.status = 'INVALID' THEN 'FIX_VALIDATION'
WHEN prev.source_version IS NULL
OR prev.source_version <> s.version
OR prev.dependency_revision IS DISTINCT FROM dep.dependency_revision
OR prev.expires_at <= now() THEN 'CREATE_PREVIEW'
WHEN pub.status IS DISTINCT FROM 'PUBLISHED'
OR pub.published_version <> s.version THEN 'PUBLISH'
ELSE 'NONE'
END AS next_action
FROM studio_source s
JOIN studio_dependency dep ON dep.id = s.id
LEFT JOIN project pr ON pr.id = s.project_id
LEFT JOIN studio_latest_validation val ON val.source_id = s.id
LEFT JOIN studio_latest_preview prev ON prev.source_id = s.id
LEFT JOIN publication pub ON pub.source_id = s.id AND pub.source_kind = s.kind
)
"""
.formatted(RENDERER_CONTRACT_VERSION);
}
}
@@ -0,0 +1,244 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.model.AssetUsageView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code asset} 메타데이터 접근.
*
* <p>{@code usageCount} {@code asset_reference} 에서 센다 Asset 행에 캐시해 두면 참조가 바뀔 때마다 곳을 맞춰야 하고,
* 어긋나면 "쓰이고 있는데 삭제 가능"으로 보인다.
*/
@Repository
public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort {
private final JdbcClient jdbcClient;
public JdbcAssetRepositoryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public AssetPageView list(ListAssetsQuery query) {
StringBuilder sql = new StringBuilder(selectColumns() + " WHERE 1 = 1");
Map<String, Object> params = new HashMap<>();
if (query.kind() != null) {
sql.append(" AND a.asset_kind = :kind");
params.put("kind", query.kind().name());
}
if (query.managementStatus() != null) {
sql.append(" AND a.management_status = :status");
params.put("status", query.managementStatus().name());
}
if (query.query() != null && !query.query().isBlank()) {
sql.append(" AND (lower(a.asset_key) LIKE :pattern OR lower(a.original_name) LIKE :pattern)");
params.put("pattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
}
if (query.beforeCreatedAt() != null && query.beforeId() != null) {
sql.append(" AND (a.created_at, a.id) < (:before, :beforeId)");
params.put("before", Timestamp.from(query.beforeCreatedAt()));
params.put("beforeId", query.beforeId());
}
sql.append(" ORDER BY a.created_at DESC, a.id DESC LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<AssetView> rows = spec.query(JdbcAssetRepositoryAdapter::mapAsset).list();
boolean hasMore = rows.size() > query.limit();
List<AssetView> page = hasMore ? rows.subList(0, query.limit()) : rows;
String nextCursor = hasMore ? page.getLast().createdAt() + "|" + page.getLast().id() : null;
return new AssetPageView(page, nextCursor);
}
@Override
public Optional<AssetView> find(UUID assetId) {
return jdbcClient
.sql(selectColumns() + " WHERE a.id = :id")
.param("id", assetId)
.query(JdbcAssetRepositoryAdapter::mapAsset)
.optional();
}
@Override
public Optional<AssetDetailView> findDetail(UUID assetId) {
return find(assetId)
.map(
asset -> new AssetDetailView(asset, usagesOf(assetId), hasPublicationHistory(assetId)));
}
@Override
public AssetView create(NewAsset asset, String principal) {
jdbcClient
.sql(
"INSERT INTO asset (id, asset_key, asset_kind, management_status, object_key,"
+ " original_name, display_name, content_type, size_bytes, width, height,"
+ " checksum_sha256, alt_text, decorative, version, created_by, updated_by)"
+ " VALUES (:id, :assetKey, :kind, :status, :objectKey, :originalName,"
+ " :originalName, :contentType, :size, :width, :height, :checksum, :altText,"
// 계약의 Asset.version minimum 1 이다. 컬럼 기본값 0 그대로 두면 생성 직후
// 응답이 계약을 위반하고, 클라이언트가 보내는 expectedVersion 맞출 없다.
+ " :decorative, 1, :principal, :principal)")
.param("id", asset.id())
.param("assetKey", asset.assetKey())
.param("kind", asset.kind().name())
.param("status", asset.managementStatus().name())
.param("objectKey", asset.objectKey())
.param("originalName", asset.originalFilename())
.param("contentType", asset.mediaType())
.param("size", asset.byteSize())
.param("width", asset.width())
.param("height", asset.height())
.param("checksum", asset.checksumSha256())
.param("altText", asset.altText())
.param("decorative", asset.decorative())
.param("principal", principal)
.update();
return find(asset.id()).orElseThrow();
}
@Override
public Optional<AssetView> update(
UUID assetId,
long expectedVersion,
AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
AssetManagementStatusView managementStatus,
String principal) {
int updated =
jdbcClient
.sql(
"UPDATE asset SET"
// 보내지 않은 필드는 그대로 둔다 PUT 이지만 계약의 UpdateAssetCommand
// expectedVersion 전부 optional 이라 부분 갱신 의미다.
+ " asset_kind = COALESCE(:kind, asset_kind),"
+ " alt_text = CASE WHEN :altTextProvided THEN :altText ELSE alt_text END,"
+ " decorative = COALESCE(:decorative, decorative),"
+ " management_status = COALESCE(:status, management_status),"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("kind", kind == null ? null : kind.name())
.param("altTextProvided", altTextProvided)
.param("altText", altText)
.param("decorative", decorative)
.param("status", managementStatus == null ? null : managementStatus.name())
.param("principal", principal)
.param("id", assetId)
.param("expectedVersion", expectedVersion)
.update();
return updated == 0 ? Optional.empty() : find(assetId);
}
@Override
public Optional<String> findObjectKey(UUID assetId) {
return jdbcClient
.sql("SELECT object_key FROM asset WHERE id = :id")
.param("id", assetId)
.query(String.class)
.optional();
}
@Override
public void delete(UUID assetId) {
jdbcClient.sql("DELETE FROM asset WHERE id = :id").param("id", assetId).update();
}
private static String selectColumns() {
return "SELECT a.id, a.asset_key, a.asset_kind, a.management_status, a.original_name,"
+ " a.content_type, a.size_bytes, a.width, a.height, a.alt_text, a.decorative,"
+ " a.version, a.created_at, a.updated_at, a.first_published_at,"
+ " (SELECT count(*) FROM asset_reference r WHERE r.asset_id = a.id) AS usage_count"
+ " FROM asset a";
}
private static AssetView mapAsset(ResultSet rs, int rowNum) throws SQLException {
return new AssetView(
rs.getObject("id", UUID.class),
rs.getString("asset_key"),
AssetKindView.valueOf(rs.getString("asset_kind")),
rs.getString("content_type"),
rs.getString("original_name"),
rs.getLong("size_bytes"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getString("alt_text"),
rs.getBoolean("decorative"),
AssetManagementStatusView.valueOf(rs.getString("management_status")),
// 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다.
"/api/v1/public/media/" + rs.getString("id"),
rs.getInt("usage_count"),
rs.getLong("version"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("updated_at").toInstant());
}
private List<AssetUsageView> usagesOf(UUID assetId) {
return jdbcClient
.sql(
"SELECT r.owner_id, r.owner_type, r.reference_scope,"
+ " COALESCE(d.title, q.question, pd.title) AS title,"
+ " COALESCE(d.document_type, 'QUESTION') AS document_kind"
+ " FROM asset_reference r"
+ " LEFT JOIN document d ON d.id = r.owner_id"
+ " LEFT JOIN open_question q ON q.id = r.owner_id"
+ " LEFT JOIN project_decision pd ON pd.id = r.owner_id"
+ " WHERE r.asset_id = :id")
.param("id", assetId)
.query(
(rs, rowNum) ->
new AssetUsageView(
rs.getObject("owner_id", UUID.class),
kindOf(rs.getString("owner_type"), rs.getString("document_kind")),
rs.getString("title") == null ? "(제목 없음)" : rs.getString("title"),
"PUBLISHED".equals(rs.getString("reference_scope"))))
.list();
}
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
private static RecordKind kindOf(String ownerType, String documentKind) {
return switch (ownerType) {
case "DOCUMENT" -> RecordKind.valueOf(documentKind);
case "DECISION" -> RecordKind.PROJECT_DECISION;
default -> RecordKind.QUESTION;
};
}
/** 한 번이라도 공개된 적이 있으면 hard delete 를 금지한다(계약 {@code AssetDetail} 설명). */
private boolean hasPublicationHistory(UUID assetId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT (first_published_at IS NOT NULL"
+ " OR EXISTS (SELECT 1 FROM asset_reference r"
+ " WHERE r.asset_id = :id AND r.reference_scope = 'PUBLISHED'))"
+ " FROM asset WHERE id = :id")
.param("id", assetId)
.query(Boolean.class)
.optional()
.orElse(Boolean.FALSE));
}
}
@@ -0,0 +1,192 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentRowMapper;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentSql;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.PublicationActionView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 게시 이력 조회. 이력은 항상 최신순이며 정렬 선택지가 없다 계약에도 정렬 파라미터가 없다.
*
* <p>목록의 {@code document} 요약은 {@link StudioDocumentSql} 같은 정의에서 가져온다. 여기서 따로 만들면 목록 화면과 이력 화면의
* {@code nextAction} 갈라진다.
*/
@Repository
public class JdbcPublicationHistoryQueryAdapter implements PublicationHistoryQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicationHistoryQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public PublicationPageView list(ListPublicationsQuery query) {
StringBuilder sql =
new StringBuilder(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available,"
+ " p.status, p.publication_revision, p.latest_event_id, p.public_path,"
+ " p.updated_at AS publication_updated_at, p.published_version AS current_version"
+ " FROM publication_event e"
+ " JOIN publication p ON p.publication_id = e.publication_id"
+ " LEFT JOIN publication_snapshot s"
+ " ON s.publication_event_id = e.publication_event_id"
+ " WHERE 1 = 1");
Map<String, Object> params = new HashMap<>();
if (query.type() != null) {
sql.append(" AND e.event_type = :type");
params.put("type", query.type().name());
}
if (query.beforeOccurredAt() != null && query.beforeEventId() != null) {
sql.append(" AND (e.occurred_at, e.publication_event_id) < (:before, :beforeId)");
params.put("before", Timestamp.from(query.beforeOccurredAt()));
params.put("beforeId", query.beforeEventId());
}
sql.append(" ORDER BY e.occurred_at DESC, e.publication_event_id DESC LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
boolean hasMore = rows.size() > query.limit();
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
Map<UUID, DocumentSummaryView> summaries = summariesFor(page);
List<PublicationListItemView> items = new ArrayList<>(page.size());
for (Row row : page) {
items.add(
new PublicationListItemView(
row.event(),
row.publication(),
summaries.get(row.event().documentId()),
actionsFor(row)));
}
String nextCursor =
hasMore
? page.getLast().event().occurredAt()
+ "|"
+ page.getLast().event().publicationEventId()
: null;
return new PublicationPageView(items, nextCursor);
}
@Override
public Optional<PublicationSnapshotView> findSnapshot(UUID publicationEventId) {
return jdbcClient
.sql(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " true AS snapshot_available,"
+ " s.render_model, s.content_format_version, s.renderer_contract_version"
+ " FROM publication_snapshot s"
+ " JOIN publication_event e"
+ " ON e.publication_event_id = s.publication_event_id"
+ " WHERE s.publication_event_id = :id")
.param("id", publicationEventId)
.query(
(rs, rowNum) ->
new PublicationSnapshotView(
PublicationRowMappers.mapEvent(rs, rowNum),
rs.getString("render_model"),
rs.getString("content_format_version"),
rs.getString("renderer_contract_version")))
.optional();
}
@Override
public Optional<PublicationAggregateView> findById(UUID publicationId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at FROM publication"
+ " WHERE publication_id = :id")
.param("id", publicationId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.optional();
}
private Map<UUID, DocumentSummaryView> summariesFor(List<Row> rows) {
if (rows.isEmpty()) {
return Map.of();
}
List<UUID> ids = rows.stream().map(row -> row.event().documentId()).distinct().toList();
Map<UUID, DocumentSummaryView> summaries = new HashMap<>();
jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT * FROM studio_document WHERE id IN (:ids)")
.param("ids", ids)
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
.list()
.forEach(summary -> summaries.put(summary.id(), summary));
return summaries;
}
/**
* 계약 {@code PublicationListItem.availableActions}. 지금 상태에서 실제로 있는 것만 담는다 화면이 눌러도 실패할 버튼을
* 그리지 않게 하려는 값이다.
*/
private static List<PublicationActionView> actionsFor(Row row) {
List<PublicationActionView> actions = new ArrayList<>();
if (row.event().snapshotAvailable()) {
actions.add(PublicationActionView.VIEW_SNAPSHOT);
}
if (row.event().sourcePublishedEventId() != null) {
actions.add(PublicationActionView.VIEW_SOURCE_SNAPSHOT);
}
if (row.publication().status() == PublicationAggregateStatus.PUBLISHED
&& row.publication().latestEventId().equals(row.event().publicationEventId())) {
actions.add(PublicationActionView.UNPUBLISH);
}
return actions;
}
private record Row(PublicationEventView event, PublicationAggregateView publication) {}
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
PublicationEventView event =
new PublicationEventView(
rs.getObject("publication_event_id", UUID.class),
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationEventTypeView.valueOf(rs.getString("event_type")),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("published_version"),
rs.getObject("source_published_event_id", UUID.class),
rs.getBoolean("snapshot_available"));
PublicationAggregateView publication =
new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("current_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("publication_updated_at").toInstant());
return new Row(event, publication);
}
}
@@ -0,0 +1,561 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.shared.error.MappingException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* spec §7.5 게시 트랜잭션 10~19단계.
*
* <p>{@code publication.latest_event_id} {@code publication_event.publication_id} 서로를 가리킨다.
* 게시는 publication INSERT event INSERT publication UPDATE 순서로 트랜잭션 안에서 끝나며, 순환을 허용하는 것이 V7
* {@code DEFERRABLE INITIALLY DEFERRED} . 즉시 검사로 바꾸면 게시가 구조적으로 불가능해진다.
*/
@Repository
public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
/** 공개 projection payload 의 스키마 버전. 모양이 바뀌면 올린다. */
private static final short PAYLOAD_SCHEMA_VERSION = 1;
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
private final Supplier<UUID> idGenerator;
/**
* 생성자가 둘이라 Spring 어느 쪽을 쓸지 스스로 정하지 못한다 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
* 실패했다). 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
*/
@Autowired
public JdbcPublicationWriterAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this(jdbcClient, objectMapper, UUID::randomUUID);
}
JdbcPublicationWriterAdapter(
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
this.idGenerator = idGenerator;
}
@Override
public Optional<PublicationAggregateView> lockCurrentPublication(
RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at FROM publication"
+ " WHERE source_kind = :kind AND source_id = :id FOR UPDATE")
.param("kind", kind.name())
.param("id", documentId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.optional();
}
@Override
public PublishResultView publish(PublishRequest request) {
requireTransaction("publish");
Optional<PublicationAggregateView> existing =
lockCurrentPublication(request.kind(), request.documentId());
UUID publicationId =
existing.map(PublicationAggregateView::publicationId).orElseGet(idGenerator);
UUID eventId = idGenerator.get();
PublicationEventTypeView eventType =
existing.isEmpty()
? PublicationEventTypeView.PUBLISHED
: PublicationEventTypeView.REPUBLISHED;
if (existing.isEmpty()) {
// 10 이전: aggregate 먼저 만든다. latest_event_id 아직 없는 event 가리키지만
// 지연 검사라 커밋 시점에만 확인된다.
jdbcClient
.sql(
"INSERT INTO publication (publication_id, source_kind, source_id, status,"
+ " published_version, publication_revision, latest_event_id, public_path)"
+ " VALUES (:publicationId, :kind, :id, 'PUBLISHED', :version, 1, :eventId,"
+ " :publicPath)")
.param("publicationId", publicationId)
.param("kind", request.kind().name())
.param("id", request.documentId())
.param("version", request.version())
.param("eventId", eventId)
.param("publicPath", request.publicPath())
.update();
}
// 10. Event
jdbcClient
.sql(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, occurred_at, idempotency_key,"
+ " created_by)"
+ " VALUES (:eventId, :publicationId, :kind, :id, :type, :version, now(),"
+ " :idempotencyKey, :principal)")
.param("eventId", eventId)
.param("publicationId", publicationId)
.param("kind", request.kind().name())
.param("id", request.documentId())
.param("type", eventType.name())
.param("version", request.version())
.param("idempotencyKey", request.idempotencyKey())
.param("principal", request.principal())
.update();
// 11. Snapshot 게시 시점의 렌더 모델을 그대로 고정한다. 다시 렌더링하지 않는다.
jdbcClient
.sql(
"INSERT INTO publication_snapshot (publication_event_id, render_model,"
+ " content_format_version, renderer_contract_version, asset_manifest)"
+ " VALUES (:eventId, CAST(:renderModel AS jsonb), :contentFormatVersion,"
+ " :rendererContractVersion, CAST(:assetManifest AS jsonb))")
.param("eventId", eventId)
.param("renderModel", request.renderModelJson())
.param("contentFormatVersion", request.contentFormatVersion())
.param("rendererContractVersion", request.rendererContractVersion())
.param("assetManifest", manifestJson(request.assetManifest()))
.update();
upsertProjection(request);
replaceRoute(request);
replaceProjectLink(request);
replacePublishedAssetReferences(request);
markAssetsFirstPublished(request);
// 17. aggregate 갱신
jdbcClient
.sql(
"UPDATE publication SET status = 'PUBLISHED', published_version = :version,"
+ " publication_revision = publication_revision + :bump,"
+ " latest_event_id = :eventId, public_path = :publicPath, updated_at = now()"
+ " WHERE publication_id = :publicationId")
.param("version", request.version())
// 게시는 INSERT 이미 revision 1 넣었다. 여기서 올리면 클라이언트가 받은 값과
// 다음 unpublish 요구하는 값이 어긋난다.
.param("bump", existing.isEmpty() ? 0 : 1)
.param("eventId", eventId)
.param("publicPath", request.publicPath())
.param("publicationId", publicationId)
.update();
// 18. Document publish metadata
markSourcePublished(request);
// 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");
UUID lastPublishedEventId =
jdbcClient
.sql(
"SELECT publication_event_id FROM publication_event"
+ " WHERE publication_id = :publicationId"
+ " AND event_type IN ('PUBLISHED', 'REPUBLISHED')"
+ " ORDER BY occurred_at DESC LIMIT 1")
.param("publicationId", request.publicationId())
.query(UUID.class)
.optional()
.orElseThrow(
() ->
StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"this publication has no published event to withdraw"));
UUID eventId = idGenerator.get();
// UNPUBLISHED Event 자체 snapshot 만들지 않고 마지막 공개 Snapshot 참조한다(V7 주석).
jdbcClient
.sql(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, source_published_event_id,"
+ " occurred_at, created_by)"
+ " SELECT :eventId, p.publication_id, p.source_kind, p.source_id, 'UNPUBLISHED',"
+ " p.published_version, :sourceEventId, now(), :principal"
+ " FROM publication p WHERE p.publication_id = :publicationId")
.param("eventId", eventId)
.param("sourceEventId", lastPublishedEventId)
.param("principal", request.principal())
.param("publicationId", request.publicationId())
.update();
int updated =
jdbcClient
.sql(
"UPDATE publication SET status = 'UNPUBLISHED',"
+ " publication_revision = publication_revision + 1,"
+ " latest_event_id = :eventId, updated_at = now()"
+ " WHERE publication_id = :publicationId"
+ " AND publication_revision = :expectedRevision")
.param("eventId", eventId)
.param("publicationId", request.publicationId())
.param("expectedRevision", request.expectedRevision())
.update();
if (updated == 0) {
throw StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"the publication revision changed while withdrawing it");
}
// Projection ACTIVE -> WITHDRAWN. route 유지한다 주소가 사라지면 링크가 끊긴다.
jdbcClient
.sql(
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
+ " updated_at = now() WHERE resource_type = :type AND resource_id = :id")
.param("type", request.kind().name())
.param("id", request.documentId())
.update();
// Working copy 다시 초안으로 돌아간다.
if (request.kind() == RecordKind.CASE || request.kind() == RecordKind.REFERENCE) {
jdbcClient
.sql("UPDATE document SET workflow_status = 'DRAFT' WHERE id = :id")
.param("id", request.documentId())
.update();
}
return result(request.publicationId(), eventId);
}
/** 12. 공개 projection upsert. */
private void upsertProjection(PublishRequest request) {
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, state_code, primary_topic_id,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, navigation_path)"
+ " VALUES (:type, :id, :version, 'ACTIVE', 'PUBLIC', :title, :summary,"
+ " :stateCode, :topicId, :schemaVersion, CAST(:payload AS jsonb), :bodyPlainText,"
+ " :searchText, :contentHash, now(), now(), :navigationPath)"
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
+ " source_version = EXCLUDED.source_version,"
+ " publication_state = 'ACTIVE',"
+ " visibility = EXCLUDED.visibility,"
+ " title = EXCLUDED.title,"
+ " summary = EXCLUDED.summary,"
+ " state_code = EXCLUDED.state_code,"
+ " primary_topic_id = EXCLUDED.primary_topic_id,"
+ " payload = EXCLUDED.payload,"
+ " body_plain_text = EXCLUDED.body_plain_text,"
+ " search_text = EXCLUDED.search_text,"
+ " content_hash = EXCLUDED.content_hash,"
+ " updated_at = now(),"
+ " navigation_path = EXCLUDED.navigation_path")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("version", request.version())
.param("title", request.title())
.param("summary", request.summary())
.param("stateCode", request.stateCode())
.param("topicId", request.topicId())
.param("schemaVersion", PAYLOAD_SCHEMA_VERSION)
.param("payload", request.renderModelJson())
.param("bodyPlainText", request.bodyPlainText())
.param(
"searchText",
String.join(
" ",
nullToEmpty(request.title()),
nullToEmpty(request.summary()),
nullToEmpty(request.bodyPlainText())))
.param("contentHash", sha256(request.renderModelJson()))
.param("navigationPath", request.publicPath())
.update();
}
/** 13. canonical route. 이전 slug 의 route 는 alias 로 남긴다 — 지우면 공개된 링크가 끊긴다. */
private void replaceRoute(PublishRequest request) {
jdbcClient
.sql(
"UPDATE public_route SET route_role = 'ALIAS'"
+ " WHERE resource_type = :type AND resource_id = :id AND slug <> :slug")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("slug", slugOf(request.publicPath()))
.update();
jdbcClient
.sql(
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
+ " VALUES (:type, :slug, :id, 'CANONICAL')"
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
.param("type", request.kind().name())
.param("slug", slugOf(request.publicPath()))
.param("id", request.documentId())
.update();
}
/** 14. 공개 projection 의 프로젝트 링크. Studio 는 PRIMARY 하나만 소유한다. */
private void replaceProjectLink(PublishRequest request) {
jdbcClient
.sql(
"DELETE FROM public_resource_project_link"
+ " WHERE resource_type = :type AND resource_id = :id AND relation_type = 'PRIMARY'")
.param("type", request.kind().name())
.param("id", request.documentId())
.update();
if (request.projectId() == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
+ " relation_type) VALUES (:type, :id, :projectId, 'PRIMARY')"
+ " ON CONFLICT (resource_type, resource_id, project_id)"
+ " DO UPDATE SET relation_type = 'PRIMARY'")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("projectId", request.projectId())
.update();
}
/** 15. PUBLISHED scope 의 asset_reference 교체. WORKING scope 는 건드리지 않는다. */
private void replacePublishedAssetReferences(PublishRequest request) {
String ownerType = ownerTypeOf(request.kind());
jdbcClient
.sql(
"DELETE FROM asset_reference WHERE owner_type = :ownerType AND owner_id = :id"
+ " AND reference_scope = 'PUBLISHED'")
.param("ownerType", ownerType)
.param("id", request.documentId())
.update();
for (AssetManifestEntry entry : request.assetManifest()) {
jdbcClient
.sql(
"INSERT INTO asset_reference (asset_id, owner_type, owner_id, reference_scope,"
+ " reference_role) VALUES (:assetId, :ownerType, :id, 'PUBLISHED', 'BODY')"
+ " ON CONFLICT DO NOTHING")
.param("assetId", entry.assetId())
.param("ownerType", ownerType)
.param("id", request.documentId())
.update();
}
}
/** 16. 최초 공개 시각. 이미 값이 있으면 덮지 않는다 — "처음"은 한 번뿐이다. */
private void markAssetsFirstPublished(PublishRequest request) {
for (AssetManifestEntry entry : request.assetManifest()) {
jdbcClient
.sql(
"UPDATE asset SET first_published_at = now()"
+ " WHERE id = :assetId AND first_published_at IS NULL")
.param("assetId", entry.assetId())
.update();
}
}
/** 18. source 쪽 게시 메타데이터. */
private void markSourcePublished(PublishRequest request) {
// switch 문이 아니라 식이다 열거 전부를 다루면 default 필요 없고, 유형이 늘면 컴파일러가
// 여기서 막아 준다(문이면 조용히 아무것도 하고 지나간다).
int updated =
switch (request.kind()) {
case CASE, REFERENCE ->
jdbcClient
.sql(
"UPDATE document SET workflow_status = 'PUBLISHED',"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now() WHERE id = :id")
.param("id", request.documentId())
.update();
case QUESTION ->
jdbcClient
.sql(
"UPDATE open_question SET"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now() WHERE id = :id")
.param("id", request.documentId())
.update();
// project_decision 에는 게시 시각 컬럼이 없다. 게시 사실은 publication 소유하므로
// 여기서 억지로 컬럼을 만들지 않는다.
case PROJECT_DECISION -> 0;
};
if (updated == 0 && request.kind() != RecordKind.PROJECT_DECISION) {
throw StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"the source record disappeared while publishing " + request.documentId());
}
}
private PublishResultView result(UUID publicationId, UUID eventId) {
PublicationAggregateView aggregate =
jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version,"
+ " publication_revision, latest_event_id, public_path, updated_at"
+ " FROM publication WHERE publication_id = :id")
.param("id", publicationId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.single();
PublicationEventView event =
jdbcClient
.sql(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available"
+ " FROM publication_event e"
+ " LEFT JOIN publication_snapshot s"
+ " ON s.publication_event_id = e.publication_event_id"
+ " WHERE e.publication_event_id = :id")
.param("id", eventId)
.query(PublicationRowMappers::mapEvent)
.single();
return new PublishResultView(aggregate, event);
}
/**
* 어댑터는 <b>열린 트랜잭션 안에서만</b> 올바르게 동작한다.
*
* <p>{@code publication.latest_event_id} {@code publication_event.publication_id} 서로를 가리키고,
* 순환은 {@code fk_publication_latest_event} {@code DEFERRABLE INITIALLY DEFERRED} 로만 성립한다. 지연 검사는
* <b>트랜잭션 </b> 일어나므로, autocommit 이면 구문이 트랜잭션이라 INSERT 에서 바로 위반이 된다.
*
* <p> 사실을 주석으로만 남기면 트랜잭션 없이 호출한 코드가 "외래 키 위반"이라는, 원인과 한참 떨어진 오류를 만난다. 통합 테스트를 처음 돌렸을 실제로 그렇게
* 실패했다. 그래서 전제를 여기서 확인하고 무엇이 잘못됐는지 그대로 말한다.
*/
private static void requireTransaction(String operation) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"publication "
+ operation
+ " must run inside an active transaction: publication and publication_event"
+ " reference each other, and that cycle only resolves at commit through"
+ " fk_publication_latest_event's deferred check");
}
}
static PublicationAggregateView mapAggregate(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("published_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("updated_at").toInstant());
}
private String manifestJson(List<AssetManifestEntry> manifest) {
ArrayNode array = objectMapper.createArrayNode();
for (AssetManifestEntry entry : manifest) {
ObjectNode node = array.addObject();
node.put("assetId", entry.assetId() == null ? null : entry.assetId().toString());
node.put("assetKey", entry.assetKey());
node.put("mediaType", entry.mediaType());
node.put("publicPath", entry.publicPath());
node.put("width", entry.width());
node.put("height", entry.height());
node.put("decorative", entry.decorative());
}
try {
return objectMapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise a publication asset manifest", e);
}
}
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
private static String ownerTypeOf(RecordKind kind) {
return switch (kind) {
case CASE, REFERENCE -> "DOCUMENT";
case QUESTION -> "QUESTION";
case PROJECT_DECISION -> "DECISION";
};
}
/** {@code public_route.slug} 는 경로가 아니라 마지막 조각이다. */
private static String slugOf(String publicPath) {
if (publicPath == null || publicPath.isBlank()) {
throw StudioException.of(
StudioError.DOCUMENT_VALIDATION_FAILED, "a published record needs a public path");
}
return publicPath.substring(publicPath.lastIndexOf('/') + 1);
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256")
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
/** {@code publication_event} 행 매핑. writer 와 조회 어댑터가 같은 모양을 쓰도록 한 곳에 둔다. */
final class PublicationRowMappers {
private PublicationRowMappers() {}
static PublicationEventView mapEvent(ResultSet rs, int rowNum) throws SQLException {
return new PublicationEventView(
rs.getObject("publication_event_id", UUID.class),
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationEventTypeView.valueOf(rs.getString("event_type")),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("published_version"),
rs.getObject("source_published_event_id", UUID.class),
rs.getBoolean("snapshot_available"));
}
}
@@ -0,0 +1,255 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code CASE} / {@code REFERENCE} 편집본. {@code document} 루트 + 유형별 detail 테이블이다 (ADR-003, 설계
* 07장).
*/
final class DocumentWorkingCopyStore {
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
private final ProjectLinkStore projectLinks;
DocumentWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator,
ProjectLinkStore projectLinks) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
this.projectLinks = projectLinks;
}
Optional<WorkingCopyView> find(RecordKind kind, UUID id) {
return jdbcClient
.sql(
"SELECT d.id, d.version, d.updated_at, d.title, d.slug, d.summary, d.primary_topic_id,"
+ " d.body_markdown, d.last_verified_at,"
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
+ " r.scope_summary, r.rules, r.applies_to, r.excluded_scope, r.examples"
+ " FROM document d"
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
+ " WHERE d.id = :id AND d.document_type = :type")
.param("id", id)
.param("type", kind.name())
.query(
(rs, rowNum) -> {
UUID documentId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
kind,
rs.getString("title"),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
projectLinks.findPrimaryProjectForDocument(documentId).orElse(null),
relations.findBySource(kind, documentId));
long version = rs.getLong("version");
var updatedAt = instant(rs, "updated_at");
if (kind == RecordKind.CASE) {
return (WorkingCopyView)
new WorkingCopyView.CaseWorkingCopyView(
documentId,
version,
updatedAt,
base,
orEmpty(rs.getString("problem_summary")),
orEmpty(rs.getString("conclusion_summary")),
orEmpty(rs.getString("environment")),
orEmpty(rs.getString("reproduction")),
dateFromTimestamp(rs, "last_verified_at"),
orEmpty(rs.getString("body_markdown")));
}
return (WorkingCopyView)
new WorkingCopyView.ReferenceWorkingCopyView(
documentId,
version,
updatedAt,
base,
orEmpty(rs.getString("scope_summary")),
json.rulesFromJson(rs.getString("rules")),
json.orderedTextFromJson(rs.getString("applies_to")),
json.orderedTextFromJson(rs.getString("excluded_scope")),
json.orderedTextFromJson(rs.getString("examples")),
dateFromTimestamp(rs, "last_verified_at"));
})
.optional();
}
UUID create(WorkingCopyInputView input, String principal) {
RecordKind kind = input.kind();
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
jdbcClient
.sql(
"INSERT INTO document (id, document_type, slug, title, summary, body_markdown,"
+ " primary_topic_id, last_verified_at, version, created_by, updated_by)"
+ " VALUES (:id, :type, :slug, :title, :summary, :body, :topicId, :verifiedAt,"
// 계약의 WorkingCopyBase.version minimum 1 이다. 컬럼 기본값 0 그대로 두면
// 생성 직후 응답이 계약을 위반한다.
+ " 1, :principal, :principal)")
.param("id", id)
.param("type", kind.name())
.param("slug", slugToColumn(base.slug()))
.param("title", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("body", bodyMarkdownOf(input))
.param("topicId", base.topicId())
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
.param("principal", principal)
.update();
insertDetail(id, input);
relations.replace(kind, id, base.relations());
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
return id;
}
/**
* 낙관적 잠금 저장.
*
* @return 갱신된 행이 없으면(= {@code expectedVersion} 불일치) {@code false}
*/
boolean save(UUID id, long expectedVersion, WorkingCopyInputView input, String principal) {
RecordKind kind = input.kind();
WorkingCopyBaseInput base = input.base();
int updated =
jdbcClient
.sql(
"UPDATE document SET slug = :slug, title = :title, summary = :summary,"
+ " body_markdown = :body, primary_topic_id = :topicId,"
+ " last_verified_at = :verifiedAt, version = version + 1,"
+ " updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND document_type = :type AND version = :expectedVersion")
.param("slug", slugToColumn(base.slug()))
.param("title", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("body", bodyMarkdownOf(input))
.param("topicId", base.topicId())
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
.param("principal", principal)
.param("id", id)
.param("type", kind.name())
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
updateDetail(id, input);
relations.replace(kind, id, base.relations());
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
return true;
}
private void insertDetail(UUID id, WorkingCopyInputView input) {
switch (input) {
case WorkingCopyInputView.CaseInputView caseInput ->
jdbcClient
.sql(
"INSERT INTO case_detail (document_id, document_type, problem_summary,"
+ " conclusion_summary, environment, reproduction)"
+ " VALUES (:id, 'CASE', :problem, :conclusion, :environment, :reproduction)")
.param("id", id)
.param("problem", orEmpty(caseInput.problem()))
.param("conclusion", orEmpty(caseInput.conclusion()))
.param("environment", orEmpty(caseInput.environment()))
.param("reproduction", orEmpty(caseInput.reproduction()))
.update();
case WorkingCopyInputView.ReferenceInputView reference ->
jdbcClient
.sql(
"INSERT INTO reference_detail (document_id, document_type, scope_summary,"
+ " rules, applies_to, excluded_scope, examples)"
+ " VALUES (:id, 'REFERENCE', :purpose, CAST(:rules AS jsonb),"
+ " CAST(:applyWhen AS jsonb), CAST(:exceptions AS jsonb),"
+ " CAST(:examples AS jsonb))")
.param("id", id)
.param("purpose", orEmpty(reference.purpose()))
.param("rules", json.rulesToJson(reference.rules()))
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
.param("examples", json.orderedTextToJson(reference.examples()))
.update();
default ->
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
}
}
private void updateDetail(UUID id, WorkingCopyInputView input) {
switch (input) {
case WorkingCopyInputView.CaseInputView caseInput ->
jdbcClient
.sql(
"UPDATE case_detail SET problem_summary = :problem,"
+ " conclusion_summary = :conclusion, environment = :environment,"
+ " reproduction = :reproduction WHERE document_id = :id")
.param("id", id)
.param("problem", orEmpty(caseInput.problem()))
.param("conclusion", orEmpty(caseInput.conclusion()))
.param("environment", orEmpty(caseInput.environment()))
.param("reproduction", orEmpty(caseInput.reproduction()))
.update();
case WorkingCopyInputView.ReferenceInputView reference ->
jdbcClient
.sql(
"UPDATE reference_detail SET scope_summary = :purpose,"
+ " rules = CAST(:rules AS jsonb), applies_to = CAST(:applyWhen AS jsonb),"
+ " excluded_scope = CAST(:exceptions AS jsonb),"
+ " examples = CAST(:examples AS jsonb) WHERE document_id = :id")
.param("id", id)
.param("purpose", orEmpty(reference.purpose()))
.param("rules", json.rulesToJson(reference.rules()))
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
.param("examples", json.orderedTextToJson(reference.examples()))
.update();
default ->
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
}
}
/** {@code REFERENCE}는 계약에 본문이 없다 — 컬럼이 NOT NULL이므로 빈 문자열을 유지한다. */
private static String bodyMarkdownOf(WorkingCopyInputView input) {
return input instanceof WorkingCopyInputView.CaseInputView caseInput
? orEmpty(caseInput.bodyMarkdown())
: "";
}
/** 계약의 {@code lastVerifiedOn}(CASE) / {@code verifiedOn}(REFERENCE)은 같은 컬럼에 담긴다. */
private static java.time.LocalDate verifiedOnOf(WorkingCopyInputView input) {
return switch (input) {
case WorkingCopyInputView.CaseInputView caseInput -> caseInput.lastVerifiedOn();
case WorkingCopyInputView.ReferenceInputView reference -> reference.verifiedOn();
default -> null;
};
}
List<RelationView> relationsOf(RecordKind kind, UUID id) {
return relations.findBySource(kind, id);
}
}

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