31 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
246 changed files with 24661 additions and 192 deletions
+16
View File
@@ -112,6 +112,22 @@ env_keys:
compatibility_impact: behavior-change
required_test: env-contract:server-shutdown-timeout-aligned
- name: APP_SESSION_TIMEOUT
# source: Studio 작성 세션. Spring 기본 30분 유휴 만료는 한 기록을 여러 번 저장하며
# 오래 머무는 작성 리듬보다 짧아, 저장하지 않은 편집을 잃는 원인이 되었다.
# 늘릴수록 훔친 session cookie 가 유효한 창도 같이 늘어나므로 Keycloak realm 의
# SSO idle 과 함께 맞춘다.
type: duration
default: 8h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-env-driven-runtime-configuration
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: env-contract:session-timeout
- name: APP_SERVER_FORWARD_HEADERS_STRATEGY
# source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification
# (trust X-Forwarded-* when behind LB/proxy)
+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
+366 -22
View File
@@ -27,6 +27,16 @@ sourceSets {
// 이 인터페이스가 compileGeneratedOpenapiJava의 컴파일 클래스패스에 있어야 한다.
// main sourceSet에 두면 main -> generatedOpenapi 단방향 배선(아래 참고) 때문에 보이지 않는다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-unions/src/main/java'))
// public-v1 도 같은 방식으로 model 만 생성한다. 별도 sourceSet 을 만들지 않는 이유는
// 두 계약의 생성물이 같은 성질(생성 코드, 품질 게이트 제외 대상, jar/test 클래스패스에
// 얹어야 함)을 갖기 때문이다 — sourceSet 을 늘리면 그 배선을 한 벌 더 복제하게 된다.
java.srcDir(layout.buildDirectory.dir('generated/openapi-public/src/main/java'))
java.srcDir(layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java'))
// 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을
@@ -164,18 +174,10 @@ ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codege
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
tasks.register('prepareStudioCodegenSpec') {
description = '계약에서 discriminator union 배선을 파생시켜 생성기 입력을 만든다.'
def specSource = file("${rootDir}/config/openapi/studio-v1.yaml")
def specOut = studioCodegenSpecFile
def ignoreOut = studioCodegenIgnoreFile
def unionDir = studioUnionSrcDir
def modelPackage = studioModelPackage
inputs.file(specSource)
outputs.file(specOut)
outputs.file(ignoreOut)
outputs.dir(unionDir)
doLast {
// 이 파생은 계약 두 벌(studio-v1, public-v1)에 똑같이 적용된다. 두 벌을 각자 복사해 두면
// 한쪽만 고쳐지는 날이 오므로 클로저 하나로 두고 태스크가 인자만 바꿔 호출한다.
ext.prepareTechLogCodegenSpec = { String label, File specSource, File specTarget,
File ignoreTarget, File unionDir, String modelPackage ->
def doc = new org.yaml.snakeyaml.Yaml().load(specSource.getText('UTF-8'))
def schemas = doc.components.schemas
@@ -209,6 +211,32 @@ tasks.register('prepareStudioCodegenSpec') {
}
collapseStringOneOf(doc)
// boolean 프로퍼티의 `const` 를 코드젠 사본에서만 걷어낸다.
//
// 봉투의 success 는 계약상 `{type: boolean, const: true}` 다. 생성기는 이 문서를 검증
// 경로 없이 읽으면 그 const 를 단일값 enum 으로 취급해 `enum SuccessEnum { TRUE("true") }`
// 를 만드는데, 그 enum 의 필드 타입은 Boolean 이고 생성자에는 String 을 넘겨 컴파일이
// 깨진다(실측). 검증 경로를 타는 studio 쪽에서는 같은 계약이 평범한 Boolean 으로 나온다 —
// 즉 계약이 아니라 생성기의 경로 차이가 원인이다.
//
// 값이 하나로 고정된다는 사실은 소비자에게 의미가 있으므로 정본 계약에는 그대로 두고,
// 여기서만 뗀다. 서버가 이 값을 잘못 넣을 위험은 없다 — 봉투는 EnvelopeBodyAdvice 가
// 만들고 컨트롤러가 손대지 않는다.
int[] consts = [0]
def dropBooleanConst
dropBooleanConst = { Object node ->
if (node instanceof Map) {
if (node.get('type') == 'boolean' && node.containsKey('const')) {
node.remove('const')
consts[0]++
}
new ArrayList(node.values()).each { dropBooleanConst(it) }
} else if (node instanceof List) {
node.each { dropBooleanConst(it) }
}
}
dropBooleanConst(doc)
// (4) `oneOf: [X, {type: null}]` 는 OpenAPI 3.1 이 nullable 을 적는 방식이다. 그대로 두면
// 생성기가 분기들을 병합한 <부모><필드> 래퍼 클래스를 새로 만들고(예: DocumentSummary.project 가
// DisplayTarget 이 아니라 PublicRenderModelBaseProject 가 된다), 같은 모양의 타입이 여러 벌
@@ -243,6 +271,47 @@ tasks.register('prepareStudioCodegenSpec') {
}
collapseNullableOneOf(doc)
// (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다.
//
// 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가
// 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수
// 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required
// 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/
// decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이
// 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing").
//
// 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠
// 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은
// 그대로 남는다.
int[] relaxed = [0]
def relaxNullableRequired
relaxNullableRequired = { Object node ->
if (node instanceof Map) {
def props = node.get('properties')
def required = node.get('required')
if (props instanceof Map && required instanceof List) {
def drop = []
props.each { Object name, Object schema ->
if (!(schema instanceof Map)) return
def type = schema.get('type')
if (type instanceof List && type.contains('null') && required.contains(name)) {
drop << name
}
}
if (!drop.isEmpty()) {
required.removeAll(drop)
relaxed[0] += drop.size()
if (required.isEmpty()) node.remove('required')
}
}
new ArrayList(node.values()).each { relaxNullableRequired(it) }
} else if (node instanceof List) {
node.each { relaxNullableRequired(it) }
}
}
relaxNullableRequired(doc)
logger.lifecycle("${label}: nullable required 해제 ${relaxed[0]}건")
// (1) x-implements 주입 + union 목록 수집
def unions = [:]
schemas.each { String name, Object schema ->
@@ -285,29 +354,68 @@ tasks.register('prepareStudioCodegenSpec') {
}
unions.put(name, [property: property, variants: variants])
}
if (unions.isEmpty()) {
throw new GradleException('계약에서 discriminator union 을 하나도 찾지 못했다 — 파생 규칙이 깨졌다.')
// 이 가드의 목적은 "union 이 있어야 한다"가 아니라 "계약에 있는 union 을 하나도 빠뜨리지
// 않았다"이다. public-v1 처럼 union 이 애초에 없는 계약도 있으므로 개수를 계약에서 세어
// 대조한다. 원래 studio 전용으로 "0개면 실패"로 썼다가 public-v1 에서 걸렸다.
int declaredUnions = schemas.count { String name, Object schema ->
schema instanceof Map && schema.get('oneOf') instanceof List &&
schema.get('discriminator') instanceof Map
}
if (unions.size() != declaredUnions) {
throw new GradleException(
"계약의 discriminator union ${declaredUnions}개 중 ${unions.size()}개만 파생했다 — " +
"파생 규칙이 계약을 따라가지 못한다.")
}
// 파생 계약 쓰기
//
// deep copy 가 반드시 선행한다. 위 변환들이 같은 Map/List 인스턴스를 여러 위치에
// 재사용하면 snakeyaml 이 그 지점을 YAML anchor/alias(&id001 / *id001)로 덤프한다.
// swagger-parser 는 alias 노드를 해석하지 못해 그 스키마를
// "is not of type `object`" 로 거부하고, validateSpec 을 끄면 generator 가 해당
// property 를 **조용히 누락한 채** 모델을 만든다(publishedAt, matchedFields 등이
// 실제로 사라졌다). 노드 identity 를 전부 끊어 alias 자체를 원천 차단한다.
def deepCopy
deepCopy = { Object node ->
if (node instanceof Map) {
def copy = new LinkedHashMap<String, Object>()
node.each { k, v -> copy.put(k, deepCopy(v)) }
return copy
}
if (node instanceof List) {
return node.collect { deepCopy(it) }
}
return node
}
def dumperOptions = new org.yaml.snakeyaml.DumperOptions()
dumperOptions.defaultFlowStyle = org.yaml.snakeyaml.DumperOptions.FlowStyle.BLOCK
dumperOptions.width = 8192
def specFile = specOut.get().asFile
def specFile = specTarget
specFile.parentFile.mkdirs()
specFile.setText(new org.yaml.snakeyaml.Yaml(dumperOptions).dump(doc), 'UTF-8')
def rendered = new org.yaml.snakeyaml.Yaml(dumperOptions).dump(deepCopy(doc))
// fail-closed: alias 가 하나라도 남으면 생성물이 조용히 불완전해진다.
def aliasLines = rendered.readLines().findAll { it =~ /(?:&|\*)id\d{3}\b/ }
if (!aliasLines.isEmpty()) {
throw new GradleException(
"${label}: 파생 계약에 YAML alias 가 남았다 — swagger-parser 가 해당 스키마를 " +
"거부하고 property 가 조용히 누락된다. 위반 ${aliasLines.size()}줄, 예: " +
aliasLines.take(3).join(' | '))
}
specFile.setText(rendered, 'UTF-8')
// union 클래스 생성 억제
def ignoreFile = ignoreOut.get().asFile
def ignoreFile = ignoreTarget
ignoreFile.setText(
(['# prepareStudioCodegenSpec 가 생성한다 — 손으로 고치지 않는다.',
(["# ${label} 가 생성한다 — 손으로 고치지 않는다.",
'# 이 파일들은 같은 package 의 Java interface 로 대체된다.']
+ unions.keySet().collect { "**/${it}.java" }).join('\n') + '\n',
'UTF-8')
// union interface 쓰기
def packageDir = new File(unionDir.get().asFile, modelPackage.replace('.', '/'))
project.delete(unionDir.get().asFile)
def packageDir = new File(unionDir, modelPackage.replace('.', '/'))
project.delete(unionDir)
packageDir.mkdirs()
unions.each { String name, Object spec ->
def subtypes = spec.variants.collect { String typeId, String variant ->
@@ -319,7 +427,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* {@code ${name}} — 계약의 discriminator union. prepareStudioCodegenSpec 가 계약의
* {@code ${name}} — 계약의 discriminator union. ${label} 가 계약의
* {@code oneOf} + {@code discriminator.mapping} 에서 파생한다. 손으로 고치지 않는다.
*
* <p>{@code As.EXISTING_PROPERTY} 다 — 하위 타입이 {@code ${spec.property}} 를 자기 필드로
@@ -339,11 +447,247 @@ public interface ${name} {}
}
logger.lifecycle(
"prepareStudioCodegenSpec: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), " +
"${label}: union ${unions.size()}개 파생(${unions.keySet().join(', ')}), boolean const ${consts[0]}건 제거, " +
"string oneOf ${collapsed[0]}건 · nullable oneOf ${nullable[0]}건 접음")
}
ext.studioModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.studio.api.model'
ext.studioCodegenSpecFile = layout.buildDirectory.file('openapi/studio-v1-codegen.yaml')
ext.studioCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore')
ext.studioUnionSrcDir = layout.buildDirectory.dir('generated/openapi-unions/src/main/java')
// `public` 은 Java 예약어라 패키지 조각으로 쓸 수 없다 — publicapi 로 둔다.
ext.publicModelPackage = 'dev.caskeleton.adapter.inbound.web.techlog.publicapi.api.model'
ext.publicCodegenSpecFile = layout.buildDirectory.file('openapi/public-v1-codegen.yaml')
ext.publicCodegenIgnoreFile = layout.buildDirectory.file('openapi/.openapi-generator-ignore-public')
ext.publicUnionSrcDir = layout.buildDirectory.dir('generated/openapi-public-unions/src/main/java')
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') {
@@ -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;
}
}
@@ -28,7 +28,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
@@ -109,7 +108,14 @@ public class StudioPublicationController {
idempotencyKey,
StudioPrincipals.require(principal)))));
return ResponseEntity.status(HttpStatus.CREATED)
// 계약이 200 으로 선언한 응답이다 (studio-v1.yaml `publishStudioDocument` responses).
// 여기서 201 을 돌려주는 동안 클라이언트는 게시가 끝난 요청을 계약 위반으로 거절했다 —
// 서버는 Publication 을 만들고 공개 경로까지 내줬는데 화면에는 실패로 보였다.
//
// 계약 드리프트 테스트가 이것을 잡지 못한 이유는 `ResponseEntity.status(...)` 가 런타임
// 값이라 springdoc 이 읽는 published 문서에는 나타나지 않기 때문이다. 상태를 계약과 함께
// 두려면 애노테이션이나 반환 타입으로 드러나야 한다.
return ResponseEntity.ok()
.header(StudioIdempotency.IDEMPOTENCY_REPLAYED, Boolean.toString(outcome.replayed()))
.body(outcome.result());
}
@@ -8,10 +8,12 @@ import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ImageBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ThematicBreakBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
import java.util.ArrayList;
import java.util.List;
@@ -24,6 +26,7 @@ import org.commonmark.node.BlockQuote;
import org.commonmark.node.BulletList;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.Heading;
import org.commonmark.node.Image;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Node;
import org.commonmark.node.OrderedList;
@@ -38,10 +41,13 @@ import org.commonmark.node.ThematicBreak;
*/
final class BlockRenderer {
/** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */
private static final int MIN_HEADING_LEVEL = 2;
/**
* 계약 {@code HeadingBlock.level} 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 였고 h1/h5/h6 를 이 범위로 접었는데,
* 그러면 작성자가 쓴 위계가 화면에서 달라진다. 지금은 그대로 담는다.
*/
private static final int MIN_HEADING_LEVEL = 1;
private static final int MAX_HEADING_LEVEL = 4;
private static final int MAX_HEADING_LEVEL = 6;
private final HeadingIds headingIds;
private final List<String> warnings;
@@ -67,18 +73,18 @@ final class BlockRenderer {
private CaseRenderBlock renderBlock(Node node) {
return switch (node) {
case Heading value -> heading(value);
case Paragraph value -> paragraph(InlineRenderer.render(value));
case Paragraph value -> paragraphOrImage(value);
case BlockQuote value -> blockquote(value);
case BulletList value -> bulletList(value);
case OrderedList value -> orderedList(value);
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
case IndentedCodeBlock value -> code(value.getLiteral(), null);
case TableBlock value -> table(value);
// 계약에 수평선 타입이 생겼다(studio-v1 3.1.0). 예전에는 담을 곳이 없어 버리고 경고했다.
case ThematicBreak ignored -> {
// 계약의 CaseRenderBlock 에 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던
// 구조가 생기므로 버리고 경고한다.
warnings.add("THEMATIC_BREAK_NOT_RENDERABLE");
yield null;
ThematicBreakBlock block = new ThematicBreakBlock();
block.setType(ThematicBreakBlock.TypeEnum.THEMATIC_BREAK);
yield block;
}
default -> {
List<Inline> content = InlineRenderer.render(node);
@@ -87,6 +93,47 @@ final class BlockRenderer {
};
}
/**
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
*
* <p>Markdown 에서 {@code ![alt](/media/...)} 는 문단 안의 inline 인데 계약의 Inline union 에는 그림이 없다. 예전에는
* 그래서 대체 텍스트만 남기고 그림을 버렸다. 계약에 블록이 생겼으므로 (studio-v1 3.1.0) 문단이 그림 하나로만 이루어진 경우를 블록으로 올린다.
*
* <p>주소는 상대 경로이거나 http(s) 여야 한다 — {@code javascript:} 같은 스킴이 화면에 그대로 실리면 안 된다.
*/
private CaseRenderBlock paragraphOrImage(Paragraph value) {
Node only = value.getFirstChild();
if (only instanceof Image image && only == value.getLastChild()) {
String source = image.getDestination();
if (isRenderableImageSource(source)) {
ImageBlock block = new ImageBlock();
block.setType(ImageBlock.TypeEnum.IMAGE);
block.setSrc(source);
block.setAlt(InlineRenderer.plainText(image));
block.setTitle(image.getTitle());
return block;
}
warnings.add("IMAGE_SOURCE_NOT_RENDERABLE");
}
return paragraph(InlineRenderer.render(value));
}
private static boolean isRenderableImageSource(String source) {
if (source == null || source.isBlank()) {
return false;
}
// 프로토콜 상대 주소(`//host/…`)는 앱 경로가 아니다. 아래의 단일 슬래시 검사보다 먼저 걸러야
// 한다 — 순서를 바꾸면 외부 호스트를 내부 경로로 오인한다.
if (source.startsWith("//")) {
return false;
}
if (source.startsWith("/")) {
return true;
}
String lower = source.toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("http://") || lower.startsWith("https://");
}
private CaseRenderBlock heading(Heading value) {
HeadingBlock block = new HeadingBlock();
block.setType(HeadingBlock.TypeEnum.HEADING);
@@ -57,9 +57,10 @@ class StudioContentRendererTest {
List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
// 계약의 HeadingBlock.level 은 2..4 다. h1h6 를 그대로 내보내면 계약 위반이다.
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2);
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4);
// 계약의 HeadingBlock.level 은 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 h1/h6 를 접었고,
// 그래서 작성자가 쓴 위계가 화면에서 달라졌다. 지금은 그대로 내보낸다.
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(1);
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(6);
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
@@ -131,6 +131,21 @@ def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadin
'postgresqlTechLogStudioPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
// public-v1: 공개 조회 영속 경로(사이트/홈/프로필, 탐색 2종, 주제, 문서 3종, 프로젝트 4종, 릴리스 2종,
// 검색)와 V9 스키마를 실제 PostgreSQL 위에서 돌린다. 같은 이유다 — 표준 check 는 Testcontainers 를
// 돌리지 않으므로 이 태스크가 없으면 그 SQL 은 한 번도 실행되지 않은 채로 빌드가 통과한다.
def postgresqlTechLogPublicPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogPublicPersistenceIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.publicsite.PublicSitePersistenceIntegrationTest')
// 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.'
@@ -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,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());
}
}
@@ -177,7 +177,7 @@ public class JdbcStudioDependencyResolverAdapter implements StudioDependencyReso
rs.getString("content_type"),
// 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다
// (설계 05장 §3.1).
"/media/" + rs.getString("id"),
"/api/v1/public/media/" + rs.getString("id"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getBoolean("decorative")));
@@ -189,7 +189,7 @@ public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort {
rs.getBoolean("decorative"),
AssetManagementStatusView.valueOf(rs.getString("management_status")),
// 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다.
"/media/" + rs.getString("id"),
"/api/v1/public/media/" + rs.getString("id"),
rs.getInt("usage_count"),
rs.getLong("version"),
rs.getTimestamp("created_at").toInstant(),
@@ -164,9 +164,56 @@ public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
// 18. Document publish metadata
markSourcePublished(request);
// 19. 프로젝트 활동 로그
recordProjectActivity(request);
return result(publicationId, eventId);
}
/**
* 프로젝트 활동은 손으로 적는 것이 아니라 게시가 남기는 로그다.
*
* <p>한동안 이 줄을 Studio 에서 직접 써야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다.
* 게시가 곧 사건이므로 게시가 기록한다.
*
* <p>{@code operation_key} 로 문서마다 한 줄만 남긴다. 재게시는 새로 올린 것이 아니라 같은 글을 고친 것이므로 타임라인에 다시 나타나지 않아야 한다
* — {@code uq_project_activity_operation_key} 가 그것을 보장하고, 여기서는 충돌을 무시한다.
*
* <p>프로젝트에 매달리지 않은 기록은 남길 자리가 없다. 그때는 아무것도 하지 않는다.
*/
private void recordProjectActivity(PublishRequest request) {
if (request.projectId() == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
+ " operation_key, created_by, updated_by)"
+ " VALUES (:id, :projectId, :type, :title, '', 'PUBLIC', 'AUTO',"
+ " :resourceType, :resourceId, now(), :operationKey, :actor, :actor)"
+ " ON CONFLICT (project_id, operation_key) DO NOTHING")
.param("id", idGenerator.get())
.param("projectId", request.projectId())
.param("type", activityTypeOf(request.kind()))
.param("title", request.title())
.param("resourceType", request.kind().name())
.param("resourceId", request.documentId())
.param("operationKey", "publication:" + request.documentId())
.param("actor", request.principal())
.update();
}
/** {@code project_activity_activity_type_check} 가 허용하는 값으로 옮긴다. */
private static String activityTypeOf(RecordKind kind) {
return switch (kind) {
case CASE -> "CASE_PUBLISHED";
case REFERENCE -> "REFERENCE_PUBLISHED";
case QUESTION -> "QUESTION_OPENED";
case PROJECT_DECISION -> "DECISION_ACCEPTED";
};
}
@Override
public PublishResultView unpublish(UnpublishRequest request) {
requireTransaction("unpublish");
@@ -0,0 +1,53 @@
-- 프로젝트 활동을 게시 로그로 되돌린다.
--
-- 이 표는 원래 손으로 적는 자리였다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린 사실과 따로
-- 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 이제 게시가 이 줄을 남긴다
-- (JdbcPublicationWriterAdapter 19단계).
--
-- 이 마이그레이션은 그 규칙을 이미 게시된 것들에 소급 적용한다. 게시는 있었는데 로그가 없는
-- 상태를 남겨 두면, 이 변경 이전에 올린 글은 타임라인에서 영영 빠진다.
--
-- occurred_at 은 최초 PUBLISHED 사건의 시각이다 -- now() 를 쓰면 옛 게시가 전부 오늘 올린 것처럼
-- 보인다. operation_key 는 애플리케이션이 쓰는 것과 같은 규칙이라, 나중에 같은 문서를 재게시해도
-- 줄이 늘지 않는다.
INSERT INTO project_activity (
id, project_id, activity_type, title, summary, visibility, origin,
related_resource_type, related_resource_id, occurred_at,
operation_key, created_by, updated_by
)
SELECT
gen_random_uuid(),
link.project_id,
CASE publication.source_kind
WHEN 'CASE' THEN 'CASE_PUBLISHED'
WHEN 'REFERENCE' THEN 'REFERENCE_PUBLISHED'
WHEN 'QUESTION' THEN 'QUESTION_OPENED'
ELSE 'DECISION_ACCEPTED'
END,
projection.title,
'',
'PUBLIC',
'AUTO',
publication.source_kind,
publication.source_id,
first_published.occurred_at,
'publication:' || publication.source_id,
'system:migration',
'system:migration'
FROM publication
JOIN public_resource_project_link link
ON link.resource_type = publication.source_kind
AND link.resource_id = publication.source_id
AND link.relation_type = 'PRIMARY'
JOIN public_resource_projection projection
ON projection.resource_type = publication.source_kind
AND projection.resource_id = publication.source_id
JOIN LATERAL (
SELECT min(event.occurred_at) AS occurred_at
FROM publication_event event
WHERE event.publication_id = publication.publication_id
AND event.event_type = 'PUBLISHED'
) first_published ON true
WHERE publication.status = 'PUBLISHED'
AND first_published.occurred_at IS NOT NULL
ON CONFLICT (project_id, operation_key) DO NOTHING;
@@ -0,0 +1,170 @@
-- public-v1 계약이 요구하는 나머지 테이블.
--
-- 원본: tech-log-design-package/database/V1__init.sql (설계 패키지 커밋 55a9599 기준)
--
-- V7 이 이 여섯을 제외하며 남긴 이유는 "이번 범위 밖(spec §2.2)" 이었다. 그 §2.2 가
-- 미룬 것이 바로 public-v1 이고, 여섯 테이블은 전부 public-v1 전용이다.
--
-- release -> listPublicReleases / getPublicRelease
-- site_config -> getPublicSite
-- profile_page -> getPublicProfile
-- home_focus_config -> getPublicHome (focus)
-- project_topic -> getPublicTopic (relatedProjects)
-- topic_featured_document -> getPublicTopic (featuredReference / featuredCases)
--
-- 원본 DDL 을 그대로 옮긴다. V7 이 tech_log 전용 스키마를 쓰지 않고 public 스키마에
-- 만들기로 한 결정만 이어받는다(원본의 CREATE SCHEMA / SET search_path 는 V7 이 이미 제외했다).
--
-- 시딩 INSERT 3건도 원본 그대로 가져온다. site_config / profile_page /
-- home_focus_config 는 단일 행 테이블이고(PK 가 고정 UUID 로 CHECK 되어 있다) 그 행이
-- 없으면 getPublicSite / getPublicProfile / getPublicHome 이 줄 것이 없다. 이 세 값을
-- 편집하는 API 는 studio-management-v1 이 소유하며 아직 구현 범위 밖이라, 지금은 이
-- 시딩이 유일한 공급원이다.
CREATE TABLE release (
id uuid PRIMARY KEY,
version_label varchar(32) NOT NULL,
title varchar(180) NOT NULL,
summary varchar(600) NOT NULL DEFAULT '',
released_on date,
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
change_types jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(change_types) = 'array'),
reason_markdown text NOT NULL DEFAULT '',
changes_markdown text NOT NULL DEFAULT '',
user_impact_markdown text NOT NULL DEFAULT '',
implementation_impact_markdown text NOT NULL DEFAULT '',
verification_markdown text NOT NULL DEFAULT '',
known_limitations_markdown text NOT NULL DEFAULT '',
related_resources jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(related_resources) = 'array'),
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_release_version_label UNIQUE (version_label)
);
CREATE TABLE site_config (
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000001'::uuid),
brand_title varchar(80) NOT NULL DEFAULT 'Tech Log',
identity_statement varchar(600) NOT NULL DEFAULT '',
operator_display_name varchar(80) NOT NULL DEFAULT '',
short_identity varchar(120),
avatar_asset_id uuid REFERENCES asset(id),
contacts jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(contacts) = 'array'),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL
);
CREATE TABLE profile_page (
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000002'::uuid),
headline varchar(300) NOT NULL DEFAULT '',
introduction_markdown text NOT NULL DEFAULT '',
working_model jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(working_model) = 'array'),
territories jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(territories) = 'array'),
selected_evidence jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(selected_evidence) = 'array'),
trajectory jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(trajectory) = 'array'),
contacts jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(contacts) = 'array'),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')),
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL
);
CREATE TABLE home_focus_config (
id uuid PRIMARY KEY CHECK (id = '00000000-0000-0000-0000-000000000003'::uuid),
default_focus_type varchar(30)
CHECK (default_focus_type IS NULL OR default_focus_type IN (
'CURRENT_WORK', 'OPEN_QUESTION', 'RECENT_DECISION'
)),
current_project_id uuid REFERENCES project(id),
open_question_id uuid REFERENCES open_question(id),
recent_decision_id uuid REFERENCES project_decision(id),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL
);
CREATE TABLE project_topic (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
topic_id uuid NOT NULL REFERENCES topic(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (project_id, topic_id),
CONSTRAINT uq_project_topic_order UNIQUE (project_id, display_order)
);
CREATE TABLE topic_featured_document (
topic_id uuid NOT NULL REFERENCES topic(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
feature_role varchar(30) NOT NULL
CHECK (feature_role IN ('START_HERE', 'FEATURED_CASE')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (topic_id, document_id, feature_role)
);
-- 한 Topic 의 START_HERE 는 하나뿐이다.
CREATE UNIQUE INDEX uq_topic_start_here
ON topic_featured_document(topic_id)
WHERE feature_role = 'START_HERE';
-- 단일 행 시딩. 이미 있으면 건드리지 않는다.
INSERT INTO site_config (
id,
brand_title,
identity_statement,
operator_display_name,
short_identity,
created_by,
updated_by
) VALUES (
'00000000-0000-0000-0000-000000000001'::uuid,
'Tech Log',
'문제를 재현하고 검증하여 운영 가능한 시스템 설계로 연결합니다.',
'동현',
'Backend · Platform',
'system:migration',
'system:migration'
) ON CONFLICT (id) DO NOTHING;
INSERT INTO profile_page (
id,
created_by,
updated_by
) VALUES (
'00000000-0000-0000-0000-000000000002'::uuid,
'system:migration',
'system:migration'
) ON CONFLICT (id) DO NOTHING;
INSERT INTO home_focus_config (
id,
created_by,
updated_by
) VALUES (
'00000000-0000-0000-0000-000000000003'::uuid,
'system:migration',
'system:migration'
) ON CONFLICT (id) DO NOTHING;
@@ -43,8 +43,16 @@ class PostgreSqlMigrationIntegrationTest {
.load()
.migrate();
/*
TechLog 가 들어오면서 8·9·10 이 붙었는데 이 목록은 7 에서 멈춰 있었다. 마이그레이션을 더한
사람이 여기를 같이 고치지 않으면 이 테스트만 빨개지고, 그 빨간색은 "스키마가 잘못됐다" 가
아니라 "목록을 안 고쳤다" 를 뜻한다 — 정확히 그 상태로 두 번 지나갔다.
목록을 고정해 두는 이유는 남아 있다: 마이그레이션이 순서대로, 빠짐없이 적용되는지 확인한다.
그래서 개수를 세는 것이 아니라 버전을 그대로 적는다.
*/
assertThat(appliedVersions(postgres, "flyway_schema_history"))
.containsExactly("1", "3", "4", "5", "6", "7");
.containsExactly("1", "3", "4", "5", "6", "7", "8", "9", "10");
Flyway coreStream =
Flyway.configure()
@@ -0,0 +1,818 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapter;
import dev.caskeleton.application.techlog.management.command.CreateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectActivityCommand;
import dev.caskeleton.application.techlog.management.command.UpdateProjectCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.model.ProjectActivityView;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import java.time.Instant;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
import tools.jackson.databind.ObjectMapper;
/**
* 작업본 삭제 SQL 을 실제 PostgreSQL 위에서 돌린다.
*
* <p>이 파일은 사고 하나에서 나왔다. {@code documentReferenced} 가 {@code public_resource_projection.document_id}
* 를 읽었는데 그 열은 없다 — 그 테이블은 {@code (resource_type, resource_id)} 로 기록을 가리킨다. 컴파일도 단위 테스트도 통과했고, 작성자가
* 삭제를 누른 순간 500 이 됐다. 옆 패키지의 통합 테스트가 그 위험을 정확히 예고하고 있었는데 (<i>"컴파일도 단위 테스트도 컬럼 이름 오타를 검증하지 못한다"</i>)
* 삭제 경로만 그 밖에 있었다.
*
* <p>그래서 여기서 겨냥하는 것은 결과값이 아니라 <b>SQL 이 실행되는가</b> 다. 열 이름, 조인 조건, 그리고 CASCADE 가 실제로 어디까지 따라오는지.
*/
class ManagementPersistenceIntegrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcDocumentDeletionAdapter deletion;
private static JdbcProjectRepositoryAdapter projects;
private static JdbcHomeFocusConfigAdapter homeFocus;
private static JdbcCatalogQueryAdapter catalog;
private static JdbcProjectActivityRepositoryAdapter activities;
@BeforeAll
static void migrate() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the management persistence integration test;"
+ " skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
deletion = new JdbcDocumentDeletionAdapter(jdbcClient);
projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper());
homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient);
catalog = new JdbcCatalogQueryAdapter(jdbcClient);
activities = new JdbcProjectActivityRepositoryAdapter(jdbcClient);
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
private static UUID insertCase(String title) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO document (id, document_type, title, created_by, updated_by)"
+ " VALUES (:id, 'CASE', :title, 'test', 'test')")
.param("id", id)
.param("title", title)
.update();
jdbcClient.sql("INSERT INTO case_detail (document_id) VALUES (:id)").param("id", id).update();
return id;
}
private static UUID insertQuestion(String question) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO open_question (id, question, created_by, updated_by)"
+ " VALUES (:id, :question, 'test', 'test')")
.param("id", id)
.param("question", question)
.update();
return id;
}
/** 이 테이블은 NOT NULL 이 많다 — 참조 검사에 필요한 열만으로는 행을 만들 수 없다. */
private static void insertProjection(String resourceType, UUID id, String state, String title) {
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, payload_schema_version, payload,"
+ " content_hash, published_at, updated_at, navigation_path)"
+ " VALUES (:type, :id, 0, :state, 'PUBLIC', :title, 1, '{}'::jsonb,"
+ " :hash, now(), now(), :path)")
.param("type", resourceType)
.param("id", id)
.param("state", state)
.param("title", title)
.param("hash", "0".repeat(64))
.param("path", "/test/" + id)
.update();
}
/**
* {@code publication.latest_event_id} 와 {@code publication_event.publication_id} 는 서로를 가리키고, 앞의
* 것만 {@code DEFERRABLE INITIALLY DEFERRED} 다. 그래서 두 삽입이 한 transaction 안에 있어야 한다 — 문장마다 커밋하면 지연
* 검사가 그 자리에서 터진다.
*/
private static void insertPublication(UUID sourceId) {
UUID publicationId = UUID.randomUUID();
UUID eventId = UUID.randomUUID();
try (java.sql.Connection connection = dataSource.getConnection()) {
connection.setAutoCommit(false);
try (java.sql.PreparedStatement publication =
connection.prepareStatement(
"INSERT INTO publication (publication_id, source_kind, source_id, status,"
+ " published_version, latest_event_id, public_path)"
+ " VALUES (?, 'CASE', ?, 'PUBLISHED', 0, ?, ?)")) {
publication.setObject(1, publicationId);
publication.setObject(2, sourceId);
publication.setObject(3, eventId);
publication.setString(4, "/cases/published-" + sourceId);
publication.executeUpdate();
}
try (java.sql.PreparedStatement event =
connection.prepareStatement(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, created_by)"
+ " VALUES (?, ?, 'CASE', ?, 'PUBLISHED', 0, 'test')")) {
event.setObject(1, eventId);
event.setObject(2, publicationId);
event.setObject(3, sourceId);
event.executeUpdate();
}
connection.commit();
} catch (java.sql.SQLException failure) {
throw new IllegalStateException("failed to seed publication history", failure);
}
}
private static int countIn(String table, String column, UUID id) {
return jdbcClient
.sql("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = :id")
.param("id", id)
.query(Integer.class)
.single();
}
@Test
void findsADocumentOnlyUnderItsOwnType() {
UUID id = insertCase("타입이 맞아야 찾힌다");
assertThat(deletion.findDocument(id, "CASE")).isPresent();
// Case 주소로 Reference 를 지울 수 없어야 한다 — 한 테이블을 나눠 쓰기 때문에 이것이 유일한 방어다.
assertThat(deletion.findDocument(id, "REFERENCE")).isEmpty();
}
@Test
void reportsNoReferencesForAFreshDraft() {
// 작성자가 방금 연 초안. 이 경로가 막히면 기능 자체가 무의미해진다.
assertThat(deletion.documentReferenced(insertCase("갓 만든 초안"))).isFalse();
}
@Test
void reportsEveryNonCascadingReferrer() {
UUID target = insertCase("가리켜지는 쪽");
UUID source = insertCase("가리키는 쪽");
jdbcClient
.sql(
"INSERT INTO document_relation (source_document_id, target_document_id, relation_type)"
+ " VALUES (:source, :target, 'RELATED')")
.param("source", source)
.param("target", target)
.update();
assertThat(deletion.documentReferenced(target)).isTrue();
// 나가는 관계는 자기 것이라 CASCADE 로 따라간다 — 가리키는 쪽은 자유롭게 지울 수 있어야 한다.
assertThat(deletion.documentReferenced(source)).isFalse();
}
@Test
void readsThePublicProjectionByResourceTypeAndId() {
// 이 테스트가 존재하는 이유. 이 쿼리가 열 이름을 틀렸고 아무도 잡지 못했다.
UUID id = insertCase("공개된 기록");
assertThat(deletion.publiclyProjected("CASE", id)).isFalse();
insertProjection("CASE", id, "ACTIVE", "공개된 기록");
assertThat(deletion.publiclyProjected("CASE", id)).isTrue();
assertThat(deletion.publiclyProjected("QUESTION", id)).isFalse();
}
@Test
void deletingCarriesTheDocumentsOwnRowsAndItsWithdrawnProjection() {
UUID id = insertCase("지워질 기록");
insertProjection("CASE", id, "WITHDRAWN", "지워질 기록");
jdbcClient
.sql(
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
+ " VALUES ('CASE', :slug, :id, 'CANONICAL')")
.param("slug", "deleted-" + id)
.param("id", id)
.update();
assertThat(deletion.deleteProjection("CASE", id)).isEqualTo(1);
// 공개 경로는 투영을 CASCADE 로 따라간다 — 손으로 지우지 않는 것이 옳은지 여기서 확인한다.
assertThat(countIn("public_route", "resource_id", id)).isZero();
assertThat(deletion.deleteDocument(id, 0L)).isEqualTo(1);
assertThat(countIn("case_detail", "document_id", id)).isZero();
assertThat(deletion.findDocument(id, "CASE")).isEmpty();
}
@Test
void refusesToDeleteWhenTheExpectedVersionHasMoved() {
UUID id = insertCase("먼저 수정된 기록");
jdbcClient.sql("UPDATE document SET version = 3 WHERE id = :id").param("id", id).update();
assertThat(deletion.deleteDocument(id, 0L)).isZero();
assertThat(deletion.findDocument(id, "CASE")).isPresent();
}
@Test
void readsQuestionReferencesFromItsOwnTables() {
UUID id = insertQuestion("질문은 다른 테이블이다");
assertThat(deletion.findQuestion(id)).isPresent();
assertThat(deletion.questionReferenced(id)).isFalse();
// 이 테이블은 CHECK 로 id 가 고정된 단일 행이다 — 테스트마다 새로 넣을 수 없다.
jdbcClient
.sql(
"INSERT INTO home_focus_config (id, open_question_id, created_by, updated_by)"
+ " VALUES (:configId, :id, 'test', 'test')"
+ " ON CONFLICT (id) DO UPDATE SET open_question_id = EXCLUDED.open_question_id")
.param("configId", UUID.fromString("00000000-0000-0000-0000-000000000003"))
.param("id", id)
.update();
assertThat(deletion.questionReferenced(id)).isTrue();
}
@Test
void deletesAQuestionAndItsOwnChildren() {
UUID id = insertQuestion("지워질 질문");
jdbcClient
.sql(
"INSERT INTO question_point (id, question_id, point_kind, content, display_order)"
+ " VALUES (:pointId, :id, 'ASSUMPTION', '가설', 0)")
.param("pointId", UUID.randomUUID())
.param("id", id)
.update();
assertThat(deletion.deleteQuestion(id, 0L)).isEqualTo(1);
assertThat(countIn("question_point", "question_id", id)).isZero();
assertThat(deletion.findQuestion(id)).isEmpty();
}
@Test
void seesOnlyAPublicationThatIsStillLive() {
// 막아야 하는 것은 "게시한 적이 있다" 가 아니라 "지금 읽히고 있다" 다 — 게시를 취소한 기록을
// 영영 지울 수 없게 하면, 작성자가 공개를 취소하는 이유 자체가 막힌다.
UUID id = insertCase("게시된 적 있는 기록");
assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse();
insertPublication(id);
assertThat(deletion.isCurrentlyPublished("CASE", id)).isTrue();
assertThat(deletion.isCurrentlyPublished("QUESTION", id)).isFalse();
jdbcClient
.sql("UPDATE publication SET status = 'UNPUBLISHED' WHERE source_id = :id")
.param("id", id)
.update();
assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse();
}
@Test
void clearsThePublicationHistoryInDependencyOrder() {
// 이력은 기록에 무슨 일이 있었는지 말하는 것이라, 기록이 사라지면 아무것도 가리키지 않는다.
// 외래키가 없어 DB 가 대신 지워 주지 않으므로, 순서까지 여기서 확인한다.
UUID id = insertCase("이력을 남긴 기록");
insertPublication(id);
assertThat(countIn("publication", "source_id", id)).isEqualTo(1);
assertThat(countIn("publication_event", "source_id", id)).isEqualTo(1);
// 반환값은 지운 publication 행 수다 — 이벤트는 같은 문장에서 함께 사라지므로 따로 세지 않는다.
assertThat(deletion.deletePublicationHistory("CASE", id)).isEqualTo(1);
assertThat(countIn("publication", "source_id", id)).isZero();
assertThat(countIn("publication_event", "source_id", id)).isZero();
}
@Test
void clearsTheValidationAndPreviewArtifactsInDependencyOrder() {
UUID id = insertCase("검증과 미리보기를 남긴 기록");
UUID validationId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO studio_validation (validation_id, source_kind, source_id,"
+ " validated_version, status, dependency_revision, valid_until, created_by)"
+ " VALUES (:vid, 'CASE', :id, 0, 'VALID', 'rev',"
+ " now() + interval '1 hour', 'test')")
.param("vid", validationId)
.param("id", id)
.update();
jdbcClient
.sql(
"INSERT INTO studio_preview (preview_id, validation_id, source_kind, source_id,"
+ " source_version, dependency_revision, render_model, expires_at, created_by)"
+ " VALUES (:pid, :vid, 'CASE', :id, 0, 'rev', '{}'::jsonb,"
+ " now() + interval '1 hour', 'test')")
.param("pid", UUID.randomUUID())
.param("vid", validationId)
.param("id", id)
.update();
// 미리보기가 검증을 참조하므로 순서가 틀리면 외래키 위반이다.
assertThat(deletion.deleteWorkArtifacts("CASE", id)).isEqualTo(2);
assertThat(countIn("studio_validation", "source_id", id)).isZero();
assertThat(countIn("studio_preview", "source_id", id)).isZero();
}
private static UUID insertDecision(String statement) {
UUID projectId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project (id, name, created_by, updated_by) VALUES (:id, :name, 'test', 'test')")
.param("id", projectId)
.param("name", "결정이 속한 프로젝트 " + projectId)
.update();
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project_decision (id, project_id, statement, created_by, updated_by)"
+ " VALUES (:id, :projectId, :statement, 'test', 'test')")
.param("id", id)
.param("projectId", projectId)
.param("statement", statement)
.update();
return id;
}
private static UUID projectOf(UUID decisionId) {
return jdbcClient
.sql("SELECT project_id FROM project_decision WHERE id = :id")
.param("id", decisionId)
.query(UUID.class)
.single();
}
@Test
void findsADecisionOnlyUnderItsOwnProject() {
UUID id = insertDecision("이 프로젝트의 결정");
assertThat(deletion.findDecision(projectOf(id), id)).isPresent();
// 경로가 프로젝트를 들고 있으므로, 다른 프로젝트 주소로는 닿지 않아야 한다.
assertThat(deletion.findDecision(UUID.randomUUID(), id)).isEmpty();
}
@Test
void reportsDecisionReferencesFromItsOwnTables() {
UUID id = insertDecision("가리켜지는 결정");
assertThat(deletion.decisionReferenced(id)).isFalse();
UUID successor = insertDecision("대체하는 결정");
jdbcClient
.sql("UPDATE project_decision SET superseded_by_id = :successor WHERE id = :id")
.param("successor", successor)
.param("id", id)
.update();
// superseded_by_id 는 자기 테이블을 CASCADE 없이 가리킨다 — 가리켜지는 쪽(successor)을
// 그대로 지우면 외래키 위반이 되므로 그쪽이 "쓰이고 있음" 이어야 한다.
assertThat(deletion.decisionReferenced(successor)).isTrue();
// 가리키는 쪽은 자유롭게 지울 수 있다.
assertThat(deletion.decisionReferenced(id)).isFalse();
}
@Test
void deletesADecisionAndRefusesAStaleVersion() {
UUID id = insertDecision("지워질 결정");
// 지운 뒤에는 프로젝트를 되물을 수 없으므로 먼저 붙잡아 둔다.
UUID projectId = projectOf(id);
assertThat(deletion.deleteDecision(id, 3L)).isZero();
assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1);
assertThat(deletion.findDecision(projectId, id)).isEmpty();
}
// ---------------------------------------------------------------- 프로젝트 게시
/**
* 프로젝트 게시는 원본 상태·공개 투영·canonical route 세 곳을 함께 세운다. 어느 하나가 빠지면 증상이 제각각이라 (투영이 없으면 화면이 조용히 비고,
* route 가 없으면 주소만 404) 세 곳을 한꺼번에 확인한다.
*/
@Test
void publishingAProjectRaisesTheProjection() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Publish Me", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'publish-me' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
ProjectEditView published =
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
assertThat(published.targetVisibility()).isEqualTo("PUBLIC");
assertThat(published.firstPublishedAt()).isNotNull();
assertThat(
jdbcClient
.sql(
"SELECT navigation_path FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id"
+ " AND publication_state = 'ACTIVE' AND visibility = 'PUBLIC'")
.param("id", loaded.id())
.query(String.class)
.optional())
.contains("/projects/publish-me");
assertThat(
jdbcClient
.sql(
"SELECT route_role FROM public_route"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", loaded.id())
.query(String.class)
.list())
.containsExactly("CANONICAL");
}
/** 게시 취소는 투영 행을 지우지 않고 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */
@Test
void unpublishingWithdrawsTheProjectionInsteadOfDeletingIt() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Withdraw Me", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'withdraw-me' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
ProjectEditView published =
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
assertThat(projects.unpublish(published.id(), published.version(), "test")).isPresent();
assertThat(
jdbcClient
.sql(
"SELECT publication_state FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", published.id())
.query(String.class)
.optional())
.contains("WITHDRAWN");
}
/** 낙관적 잠금. 어긋난 버전으로는 아무것도 세우지 않는다. */
@Test
void publishingWithAStaleVersionChangesNothing() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Stale", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'stale' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
assertThat(projects.publish(loaded.id(), loaded.version() + 99, "PUBLIC", "test")).isEmpty();
assertThat(
jdbcClient
.sql(
"SELECT count(*) FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", loaded.id())
.query(Integer.class)
.single())
.isZero();
}
// ---------------------------------------------------------------- 홈 focus
/** V9 가 빈 행 하나를 넣어 둔다. 조회가 실패하면 그 전제가 깨진 것이다. */
@Test
void homeFocusStartsAsASingleEmptyRow() {
HomeFocusConfigView current = homeFocus.load();
assertThat(current.currentProjectId()).isNull();
assertThat(current.openQuestionId()).isNull();
assertThat(current.recentDecisionId()).isNull();
}
@Test
void homeFocusRoundTripsAndRefusesAStaleVersion() {
ProjectEditView project = projects.create(new CreateProjectCommand("Focus Target", "test"));
HomeFocusConfigView before = homeFocus.load();
HomeFocusConfigView saved =
homeFocus
.update(
new UpdateHomeFocusCommand(
before.version(), "CURRENT_WORK", project.id(), null, null, "test"))
.orElseThrow();
assertThat(saved.currentProjectId()).isEqualTo(project.id());
assertThat(saved.defaultType()).isEqualTo("CURRENT_WORK");
assertThat(saved.version()).isEqualTo(before.version() + 1);
assertThat(homeFocus.projectExists(project.id())).isTrue();
assertThat(homeFocus.projectExists(UUID.randomUUID())).isFalse();
assertThat(
homeFocus.update(
new UpdateHomeFocusCommand(before.version(), null, null, null, null, "test")))
.isEmpty();
// null 은 "이 슬롯을 비운다" 다 — 부분 수정이 아니라 전체 교체.
HomeFocusConfigView cleared =
homeFocus
.update(new UpdateHomeFocusCommand(saved.version(), null, null, null, null, "test"))
.orElseThrow();
assertThat(cleared.currentProjectId()).isNull();
}
// ---------------------------------------------------------------- catalog
/** RELATION 은 작성 중에 고르는 것이므로 게시 여부와 무관하게 작업본까지 포함한다. */
@Test
void relationCatalogIncludesUnpublishedWorkingCopies() {
UUID caseId = insertCase("Relation Target Case");
UUID questionId = insertQuestion("Relation Target Question");
CatalogPageView page = catalog.search(CatalogEntryType.RELATION, null, null, 50);
assertThat(page.items()).extracting("id").contains(caseId, questionId);
assertThat(page.items())
.filteredOn(entry -> entry.id().equals(caseId))
.singleElement()
.satisfies(
entry -> {
assertThat(entry.kind()).isEqualTo("CASE");
assertThat(entry.publicPath()).isNull();
});
}
/** EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. */
@Test
void evidenceCatalogIncludesOnlyPubliclyVisibleRecords() {
UUID publishedId = insertCase("Evidence Published");
UUID draftId = insertCase("Evidence Draft");
UUID withdrawnId = insertCase("Evidence Withdrawn");
insertProjection("CASE", publishedId, "ACTIVE", "Evidence Published");
insertProjection("CASE", withdrawnId, "WITHDRAWN", "Evidence Withdrawn");
CatalogPageView page = catalog.search(CatalogEntryType.EVIDENCE, "evidence", null, 50);
assertThat(page.items()).extracting("id").contains(publishedId);
assertThat(page.items()).extracting("id").doesNotContain(draftId, withdrawnId);
}
/** 검색어는 대소문자를 가리지 않는다. */
@Test
void relationCatalogSearchIsCaseInsensitive() {
UUID caseId = insertCase("MixedCase Needle");
assertThat(catalog.search(CatalogEntryType.RELATION, "mixedcase", null, 50).items())
.extracting("id")
.contains(caseId);
}
// ---------------------------------------------------------------- 프로젝트 활동
private static ProjectActivityView addActivity(UUID projectId, String title, String visibility) {
return activities.create(
new CreateProjectActivityCommand(
projectId,
0L,
"MILESTONE_REACHED",
title,
"요약",
visibility,
null,
null,
Instant.parse("2026-08-23T00:00:00Z"),
"test"));
}
/**
* 공개 프로젝트 화면의 "활동" 은 이 테이블을 투영 없이 직접 읽는다. 그래서 여기서 만든 줄이 곧 그 화면이고, 편집기가 읽는 목록과 화면이 읽는 목록이 같은 정렬·같은
* 조건을 써야 한다.
*/
@Test
void createdActivitiesAreVisibleToTheEditorAndToThePublicQuery() {
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Host", "test"));
ProjectActivityView pub = addActivity(project.id(), "공개 활동", "PUBLIC");
addActivity(project.id(), "비공개 활동", "PRIVATE");
assertThat(pub.origin()).isEqualTo("MANUAL");
assertThat(activities.listByProject(project.id())).hasSize(2);
// 공개 조회가 쓰는 조건 그대로.
assertThat(
jdbcClient
.sql(
"SELECT title FROM project_activity"
+ " WHERE project_id = :id AND visibility = 'PUBLIC'"
+ " ORDER BY occurred_at DESC, id DESC")
.param("id", project.id())
.query(String.class)
.list())
.containsExactly("공개 활동");
}
@Test
void activityUpdatesRoundTripAndRefuseAStaleVersion() {
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Edit", "test"));
ProjectActivityView created = addActivity(project.id(), "처음 제목", "PRIVATE");
ProjectActivityView updated =
activities
.update(
new UpdateProjectActivityCommand(
project.id(),
created.id(),
created.version(),
"고친 제목",
"고친 요약",
"PUBLIC",
Instant.parse("2026-08-24T00:00:00Z"),
"test"))
.orElseThrow();
assertThat(updated.title()).isEqualTo("고친 제목");
assertThat(updated.visibility()).isEqualTo("PUBLIC");
assertThat(updated.version()).isEqualTo(created.version() + 1);
assertThat(
activities.update(
new UpdateProjectActivityCommand(
project.id(),
created.id(),
created.version(),
"다시",
null,
"PUBLIC",
Instant.parse("2026-08-24T00:00:00Z"),
"test")))
.isEmpty();
}
/** 경로가 프로젝트와 활동을 함께 요구하므로 저장소도 둘을 함께 본다 — 남의 프로젝트 줄을 고치거나 지울 수 없어야 한다. */
@Test
void activityWritesAreScopedToTheirOwnProject() {
ProjectEditView mine = projects.create(new CreateProjectCommand("Mine", "test"));
ProjectEditView theirs = projects.create(new CreateProjectCommand("Theirs", "test"));
ProjectActivityView activity = addActivity(theirs.id(), "남의 활동", "PUBLIC");
assertThat(activities.find(mine.id(), activity.id())).isEmpty();
assertThat(activities.delete(mine.id(), activity.id(), activity.version())).isZero();
assertThat(activities.find(theirs.id(), activity.id())).isPresent();
}
@Test
void deletingAnActivityHonoursTheExpectedVersion() {
ProjectEditView project = projects.create(new CreateProjectCommand("Activity Delete", "test"));
ProjectActivityView activity = addActivity(project.id(), "지울 활동", "PUBLIC");
assertThat(activities.delete(project.id(), activity.id(), activity.version() + 9)).isZero();
assertThat(activities.delete(project.id(), activity.id(), activity.version())).isEqualTo(1);
assertThat(activities.listByProject(project.id())).isEmpty();
}
// ---------------------------------------------------------------- 프로젝트 주제
private static UUID insertTopic(String name, String slug) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, :name, :normalized, :slug, 'test', 'test')")
.param("id", id)
.param("name", name)
.param("normalized", name.toLowerCase(java.util.Locale.ROOT))
.param("slug", slug)
.update();
return id;
}
private static UpdateProjectCommand updateWithTopics(
ProjectEditView project, java.util.List<UUID> topicIds) {
return new UpdateProjectCommand(
project.id(),
project.version(),
project.name(),
project.slug() == null ? "project-" + project.id() : project.slug(),
"목적 한 줄",
"",
"",
"",
project.phase(),
"지금 목표",
"다음 작업",
java.util.List.of("Keycloak"),
topicIds,
"PRIVATE",
null,
"test");
}
/**
* 프로젝트 화면의 "주요 주제" 는 이 링크 테이블을 읽는다. 화면은 처음부터 주제를 읽고 있었지만 저장할 경로가 없어 늘 비어 있었다 — 여기서 확인하는 것은 고른 순서가
* 그대로 남는가와, 통째로 교체되는가다.
*/
@Test
void projectTopicsRoundTripInTheChosenOrder() {
ProjectEditView project = projects.create(new CreateProjectCommand("Topic Host", "test"));
UUID first = insertTopic("주제 하나", "topic-one");
UUID second = insertTopic("주제 둘", "topic-two");
ProjectEditView saved =
projects.update(updateWithTopics(project, java.util.List.of(second, first))).orElseThrow();
assertThat(saved.topicIds()).containsExactly(second, first);
assertThat(saved.currentObjective()).isEqualTo("지금 목표");
assertThat(saved.nextStep()).isEqualTo("다음 작업");
// 공개 조회가 쓰는 조건 그대로 — ACTIVE 인 주제만, display_order 순서로.
assertThat(
jdbcClient
.sql(
"SELECT t.name FROM project_topic pt"
+ " JOIN topic t ON t.id = pt.topic_id"
+ " WHERE pt.project_id = :id AND t.status = 'ACTIVE'"
+ " ORDER BY pt.display_order, t.name")
.param("id", project.id())
.query(String.class)
.list())
.containsExactly("주제 둘", "주제 하나");
}
/** 부분 수정이 아니라 전체 교체다 — 빈 목록은 "주제를 전부 뗀다" 는 뜻이어야 한다. */
@Test
void savingAnEmptyTopicListDetachesEveryTopic() {
ProjectEditView project = projects.create(new CreateProjectCommand("Detach Host", "test"));
UUID topic = insertTopic("뗄 주제", "topic-detach");
ProjectEditView withTopic =
projects.update(updateWithTopics(project, java.util.List.of(topic))).orElseThrow();
assertThat(withTopic.topicIds()).containsExactly(topic);
ProjectEditView cleared =
projects.update(updateWithTopics(withTopic, java.util.List.of())).orElseThrow();
assertThat(cleared.topicIds()).isEmpty();
assertThat(
jdbcClient
.sql("SELECT count(*) FROM project_topic WHERE project_id = :id")
.param("id", project.id())
.query(Integer.class)
.single())
.isZero();
}
/**
* Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 편집기가 Decision 을 미리 그리려면 이 목록이 프로젝트의 공개 경로를
* 알고 있어야 한다 — 한동안 늘 null 이었고, 그동안 Decision 은 어떤 문서에서도 즉시 미리보기가 열리지 않았다.
*/
@Test
void projectCatalogCarriesThePublicPathOfPublishedProjectsOnly() {
ProjectEditView open = projects.create(new CreateProjectCommand("Catalog Published", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'catalog-published' WHERE id = :id")
.param("id", open.id())
.update();
ProjectEditView loaded = projects.find(open.id()).orElseThrow();
assertThat(projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test")).isPresent();
ProjectEditView hidden = projects.create(new CreateProjectCommand("Catalog Hidden", "test"));
var entries = catalog.search(CatalogEntryType.PROJECT, "catalog", null, 50).items();
assertThat(entries)
.filteredOn(entry -> entry.id().equals(open.id()))
.singleElement()
.satisfies(
entry -> assertThat(entry.publicPath()).isEqualTo("/projects/catalog-published"));
// 게시되지 않은 프로젝트는 공개 주소가 실제로 없다. 지어내면 미리보기가 보여 준 링크가 게시 뒤에 달라진다.
assertThat(entries)
.filteredOn(entry -> entry.id().equals(hidden.id()))
.singleElement()
.satisfies(entry -> assertThat(entry.publicPath()).isNull());
}
}
@@ -0,0 +1,980 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.publicsite;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.PublicPageRequest;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
import tools.jackson.databind.ObjectMapper;
/**
* 공개 조회 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
*
* <p>{@code StudioPersistenceIntegrationTest} 와 같은 이유로 존재한다 — 이 저장소의 표준 {@code check} 는
* Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이 테스트 없이는 <b>한 번도 실행되지 않은 채</b> 통과한다. 컴파일도 단위 테스트도
* 컬럼 이름 오타, jsonb 캐스팅, {@code EXISTS} 서브쿼리의 상관 조건을 검증하지 못한다.
*
* <p>특히 두 가지를 겨냥한다.
*
* <ol>
* <li><b>공개 조건</b>({@code PublicSql#ACTIVE}) 이 모든 경로에 걸려 있는가 — 게시 취소({@code WITHDRAWN})나
* 비공개({@code UNLISTED}) 자료가 어느 한 쿼리에서라도 새면 사고다. 그래서 모든 목록/상세 테스트에 "새면 안 되는 행"을 함께 심는다.
* <li><b>총계와 목록이 같은 조건을 쓰는가</b> — 페이지네이션이 있는 여섯 operation 은 count 쿼리와 목록 쿼리를 따로 만든다. 조건이 갈라지면 마지막
* 페이지가 비어 보이거나 없는 페이지 번호가 생긴다.
* </ol>
*/
class PublicSitePersistenceIntegrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static final UUID SITE_CONFIG_ID =
UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID PROFILE_PAGE_ID =
UUID.fromString("00000000-0000-0000-0000-000000000002");
private static final UUID HOME_FOCUS_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcPublicSiteQueryAdapter site;
private static JdbcPublicExploreQueryAdapter explore;
private static JdbcPublicTopicQueryAdapter topics;
private static JdbcPublicDocumentQueryAdapter documents;
private static JdbcPublicProjectQueryAdapter projects;
private static JdbcPublicReleaseQueryAdapter releases;
private static JdbcPublicSearchQueryAdapter search;
private static UUID topicId;
private static UUID projectId;
private static UUID tagId;
private static UUID caseId;
private static UUID referenceId;
private static UUID questionId;
private static UUID decisionId;
private static UUID hiddenCaseId;
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
@BeforeAll
static void migrateAndSeed() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the public-site persistence integration test;"
+ " skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
ObjectMapper objectMapper = new ObjectMapper();
site = new JdbcPublicSiteQueryAdapter(jdbcClient, objectMapper);
explore = new JdbcPublicExploreQueryAdapter(jdbcClient);
topics = new JdbcPublicTopicQueryAdapter(jdbcClient);
documents = new JdbcPublicDocumentQueryAdapter(jdbcClient, objectMapper);
projects = new JdbcPublicProjectQueryAdapter(jdbcClient, objectMapper);
releases = new JdbcPublicReleaseQueryAdapter(jdbcClient, objectMapper);
search = new JdbcPublicSearchQueryAdapter(jdbcClient);
seed();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
// ---------------------------------------------------------------- V9 스키마
@Test
void v9CreatesEveryTableThePublicContractReads() {
assertThat(tableExists("release")).isTrue();
assertThat(tableExists("site_config")).isTrue();
assertThat(tableExists("profile_page")).isTrue();
assertThat(tableExists("home_focus_config")).isTrue();
assertThat(tableExists("project_topic")).isTrue();
assertThat(tableExists("topic_featured_document")).isTrue();
}
/** 한 Topic 의 {@code START_HERE} 는 하나뿐이라는 부분 유니크 인덱스가 실제로 강제되는지. */
@Test
void aTopicCanOnlyHaveOneStartHereDocument() {
UUID scratchTopic = insertTopic("start-here-probe", "Start Here Probe");
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
.param("t", scratchTopic)
.param("d", referenceId)
.update();
org.assertj.core.api.Assertions.assertThatThrownBy(
() ->
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'START_HERE', 1)")
.param("t", scratchTopic)
.param("d", caseId)
.update())
.as("uq_topic_start_here 가 한 주제의 두 번째 START_HERE 를 막아야 한다")
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
jdbcClient
.sql("DELETE FROM topic_featured_document WHERE topic_id = :t")
.param("t", scratchTopic)
.update();
jdbcClient.sql("DELETE FROM topic WHERE id = :id").param("id", scratchTopic).update();
}
// ---------------------------------------------------------------- 사이트 · 홈 · 프로필
@Test
void siteReadsTheSingleRowConfigWithItsContacts() {
SiteView view = site.site().orElseThrow();
assertThat(view.brandTitle()).isEqualTo("Tech Log");
assertThat(view.operatorDisplayName()).isEqualTo("동현");
assertThat(view.operatorProfilePath()).isEqualTo("/profile");
assertThat(view.contacts()).hasSize(1);
assertThat(view.contacts().getFirst().type()).isEqualTo("GITHUB");
assertThat(view.contacts().getFirst().url()).isEqualTo("https://github.com/example");
}
@Test
void homeResolvesTheConfiguredFocusAndTheLatestEntries() {
HomeView view = site.home(10);
assertThat(view.focus().defaultType()).isEqualTo("CURRENT_WORK");
assertThat(view.focus().currentWork()).isNotNull();
assertThat(view.focus().currentWork().projectPath()).isEqualTo("/projects/tech-log");
assertThat(view.latestEntries()).isNotEmpty();
assertThat(view.latestEntries())
.as("게시 취소된 자료는 최신 목록에 없어야 한다")
.noneMatch(entry -> entry.title().contains("숨김"));
assertThat(view.latestEntries())
.as(
"계약 LatestEntry.entryType 이 허용하는 값만 나와야 한다 — projection 의 PROJECT/PROJECT_DECISION"
+ " 등이 섞이면 응답 매퍼가 계약 밖 값을 만나 500 이 되고, 그 500 은 홈 화면 전체를 못 쓰게 만든다")
.extracting("entryType")
.containsAnyOf("CASE", "REFERENCE", "PROJECT_ACTIVITY")
.allSatisfy(
type ->
assertThat(type)
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
assertThat(view.latestEntries())
.as("게시한 Open Question 도 최근 기록에 나와야 한다 — 목록에서 빠지면 게시한 사실이 어디에도 보이지 않는다")
.anySatisfy(
entry -> {
assertThat(entry.entryType()).isEqualTo("QUESTION");
assertThat(entry.path()).isEqualTo("/questions/reprocessing-latency");
});
}
/**
* 갓 마이그레이션한 상태에서 {@code default_focus_type} 은 NULL 이다. 계약은 이 필드를 required 로 선언하고 값 셋만 허용하므로, NULL
* 이 그대로 나가면 홈 화면 전체가 500 이 된다 — 실제 앱 기동 후 첫 요청에서 그렇게 깨졌다. 설정이 비어도 계약이 아는 값 하나로 정해져야 한다.
*/
@Test
void homeFocusFallsBackToAContractValueWhenNothingIsConfigured() {
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = NULL,"
+ " current_project_id = NULL, open_question_id = NULL,"
+ " recent_decision_id = NULL WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.update();
try {
HomeView view = site.home(10);
assertThat(view.focus().defaultType())
.isIn("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION");
assertThat(view.focus().currentWork()).isNull();
assertThat(view.focus().openQuestion()).isNull();
assertThat(view.focus().recentDecision()).isNull();
// 설정은 비어 있지만 내용이 있는 갈래가 있으면 그쪽을 고른다.
jdbcClient
.sql("UPDATE home_focus_config SET open_question_id = :q WHERE id = :id")
.param("q", questionId)
.param("id", HOME_FOCUS_ID)
.update();
assertThat(site.home(10).focus().defaultType()).isEqualTo("OPEN_QUESTION");
} finally {
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
+ " current_project_id = :project, open_question_id = :question,"
+ " recent_decision_id = :decision WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.param("project", projectId)
.param("question", questionId)
.param("decision", decisionId)
.update();
}
}
@Test
void profileReadsItsJsonbColumnsIntoTypedViews() {
ProfileView view = site.profile().orElseThrow();
assertThat(view.headline()).isEqualTo("문제를 재현해 검증한다");
assertThat(view.workingModel()).extracting(ProfileView.NamedDescription::name).contains("재현");
assertThat(view.territories()).extracting(ProfileView.Territory::name).contains("Kafka");
assertThat(view.contacts()).hasSize(1);
assertThat(view.selectedEvidence())
.as("selected_evidence 는 공개된 것만 되살린다")
.extracting("title")
.containsExactly("Kafka 재처리");
}
// ---------------------------------------------------------------- 탐색
@Test
void knowledgeListsOnlyPublishedCasesAndReferences() {
KnowledgePageView page =
explore.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, null, null, page(1, 20)));
assertThat(page.items()).extracting("title").contains("Kafka 재처리", "Kafka 운영 기준");
assertThat(page.items()).extracting("title").doesNotContain("숨김 Case");
assertThat(page.page().totalElements())
.as("총계와 목록이 같은 조건을 써야 한다")
.isEqualTo(page.items().size());
}
@Test
void knowledgeAppliesEveryContractFilter() {
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery("CASE", null, null, null, null, null, page(1, 20)))
.items())
.extracting("type")
.containsOnly("CASE");
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, "kafka", null, null, null, null, page(1, 20)))
.items())
.isNotEmpty();
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(
null, "no-such-topic", null, null, null, null, page(1, 20)))
.items())
.isEmpty();
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(
null, null, null, "reprocessing", null, null, page(1, 20)))
.items())
.as("tag 필터의 상관 EXISTS 서브쿼리")
.extracting("title")
.containsExactly("Kafka 재처리");
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, 1999, null, page(1, 20)))
.items())
.as("year 필터는 date_part 로 건다")
.isEmpty();
}
/** 계약의 정렬 세 값이 전부 유효한 SQL 이어야 한다 — 오타는 문법 오류로만 드러난다. */
@Test
void knowledgeAcceptsEveryContractSort() {
for (String sort : List.of("PUBLISHED_DESC", "UPDATED_DESC", "VERIFIED_DESC")) {
assertThat(
explore
.knowledge(
new ExploreKnowledgeQuery(null, null, null, null, null, sort, page(1, 20)))
.items())
.as("sort=%s", sort)
.isNotEmpty();
}
}
@Test
void questionsListAppliesStatusTagAndEverySort() {
QuestionPageView all =
explore.questions(new ExploreQuestionsQuery(null, null, null, null, null, page(1, 20)));
assertThat(all.items()).extracting("question").contains("재처리 지연을 어떻게 줄일까");
assertThat(
explore
.questions(
new ExploreQuestionsQuery("RESOLVED", null, null, null, null, page(1, 20)))
.items())
.isEmpty();
assertThat(
explore
.questions(
new ExploreQuestionsQuery(null, null, null, "reprocessing", null, page(1, 20)))
.items())
.as("질문에도 tag 필터가 걸려야 한다")
.isNotEmpty();
for (String sort : List.of("UPDATED_DESC", "OPENED_DESC", "RESOLVED_DESC")) {
assertThat(
explore
.questions(new ExploreQuestionsQuery(null, null, null, null, sort, page(1, 20)))
.items())
.as("sort=%s", sort)
.isNotEmpty();
}
}
// ---------------------------------------------------------------- 주제
@Test
void topicListCountsOnlyPublishedRecords() {
assertThat(topics.list()).extracting("slug").contains("kafka");
var kafka =
topics.list().stream().filter(t -> t.slug().equals("kafka")).findFirst().orElseThrow();
// Case · Reference · Question 셋만 이 주제를 primary 로 가지며, 게시 취소된 Case 는 세지 않는다.
assertThat(kafka.recordCount()).as("게시 취소된 자료는 세지 않는다").isEqualTo(3);
}
@Test
void topicDetailResolvesEverySection() {
TopicDetailView view = topics.findBySlug("kafka").orElseThrow();
assertThat(view.name()).isEqualTo("Kafka");
assertThat(view.featuredReference()).isNotNull();
assertThat(view.featuredReference().title()).isEqualTo("Kafka 운영 기준");
assertThat(view.activeQuestions()).isNotEmpty();
assertThat(view.relatedProjects()).extracting("title").contains("Tech Log");
assertThat(view.latestRecords()).isNotEmpty();
assertThat(view.latestRecords())
.as("주제 상세의 최신 기록도 계약의 entryType 을 벗어나면 안 된다")
.extracting("entryType")
.allSatisfy(
type ->
assertThat(type)
.isIn("CASE", "REFERENCE", "QUESTION", "PROJECT_ACTIVITY", "RELEASE"));
assertThat(view.latestRecords())
.as("주제와 홈은 같은 목록 의미를 쓴다 — 홈에 나오는 Open Question 이 여기서 빠지면 두 화면이 어긋난다")
.extracting("entryType")
.contains("QUESTION");
}
@Test
void topicDetailIsAbsentForAnUnknownSlug() {
assertThat(topics.findBySlug("no-such-topic")).isEmpty();
}
// ---------------------------------------------------------------- 문서 상세
@Test
void caseDetailReadsTheOriginalTableNotTheProjectionPayload() {
CaseDetailView view = documents.findCase("kafka-reprocessing").orElseThrow();
assertThat(view.canonicalPath()).isEqualTo("/cases/kafka-reprocessing");
assertThat(view.document().title()).isEqualTo("Kafka 재처리");
assertThat(view.document().primarySummary()).isEqualTo("재처리가 지연된다");
assertThat(view.document().secondarySummary()).isEqualTo("컨슈머 랙을 먼저 본다");
assertThat(view.document().content()).contains("# 재처리");
assertThat(view.document().contentFormat()).isEqualTo("MARKDOWN");
assertThat(view.document().environmentSummary()).containsExactly("Kafka 3.7");
assertThat(view.document().primaryTopic().slug()).isEqualTo("kafka");
assertThat(view.document().primaryProject().slug()).isEqualTo("tech-log");
assertThat(view.document().tags()).extracting("slug").containsExactly("reprocessing");
}
@Test
void referenceDetailReadsItsOwnScopeColumns() {
ReferenceDetailView view = documents.findReference("kafka-operations").orElseThrow();
assertThat(view.document().primarySummary()).isEqualTo("운영 기준을 정한다");
assertThat(view.document().appliesTo()).containsExactly("Kafka 3.x");
assertThat(view.document().excludedScope()).containsExactly("Kinesis");
assertThat(view.document().freshnessStatus()).isEqualTo("CURRENT");
}
@Test
void questionDetailReadsItsTimelineAndResolutionColumns() {
QuestionDetailView view = documents.findQuestion("reprocessing-latency").orElseThrow();
assertThat(view.question().question()).isEqualTo("재처리 지연을 어떻게 줄일까");
assertThat(view.question().status()).isEqualTo("OPEN");
assertThat(view.question().resolvedAt()).isNull();
assertThat(view.question().points()).isNotNull();
}
@Test
void aWithdrawnDocumentIsNotReadable() {
assertThat(documents.findCase("hidden-case")).as("게시 취소된 문서는 상세로도 열리면 안 된다").isEmpty();
}
// ---------------------------------------------------------------- 프로젝트
@Test
void projectListAndDetailReadEveryPublishedColumn() {
assertThat(projects.list()).extracting("slug").containsExactly("tech-log");
ProjectDetailView view = projects.findBySlug("tech-log").orElseThrow();
assertThat(view.project().name()).isEqualTo("Tech Log");
assertThat(view.project().technologies()).contains("Spring Boot");
assertThat(view.canonicalPath()).isEqualTo("/projects/tech-log");
assertThat(view.featuredDecision()).isNotNull();
assertThat(view.selectedRecords()).isNotEmpty();
}
@Test
void projectSubListsReturnEmptyOptionalForAnUnknownProject() {
assertThat(projects.decisions(new ProjectDecisionPageQuery("nope", null, page(1, 20))))
.isEmpty();
assertThat(projects.records(new ProjectRecordPageQuery("nope", null, null, page(1, 20))))
.isEmpty();
assertThat(projects.activities(new ProjectPageQuery("nope", page(1, 20)))).isEmpty();
}
@Test
void projectDecisionsApplyTheStatusFilterToBothCountAndPage() {
var all =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", null, page(1, 20)))
.orElseThrow();
assertThat(all.items()).hasSize(1);
assertThat(all.page().totalElements()).isEqualTo(1);
var accepted =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", "ACCEPTED", page(1, 20)))
.orElseThrow();
assertThat(accepted.items()).hasSize(1);
assertThat(accepted.page().totalElements()).isEqualTo(1);
var proposed =
projects
.decisions(new ProjectDecisionPageQuery("tech-log", "PROPOSED", page(1, 20)))
.orElseThrow();
assertThat(proposed.items()).isEmpty();
assertThat(proposed.page().totalElements()).as("필터가 목록에만 걸리고 총계에 안 걸리면 여기서 드러난다").isZero();
}
@Test
void projectRecordsApplyTypeAndRelationFilters() {
var all =
projects
.records(new ProjectRecordPageQuery("tech-log", null, null, page(1, 20)))
.orElseThrow();
assertThat(all.items()).isNotEmpty();
assertThat(all.page().totalElements()).isEqualTo(all.items().size());
var cases =
projects
.records(new ProjectRecordPageQuery("tech-log", "CASE", null, page(1, 20)))
.orElseThrow();
assertThat(cases.items()).extracting("type").containsOnly("CASE");
var related =
projects
.records(new ProjectRecordPageQuery("tech-log", null, "RELATED", page(1, 20)))
.orElseThrow();
assertThat(related.page().totalElements()).isEqualTo(related.items().size());
var none =
projects
.records(new ProjectRecordPageQuery("tech-log", "QUESTION", "RELATED", page(1, 20)))
.orElseThrow();
assertThat(none.page().totalElements()).isEqualTo(none.items().size());
}
@Test
void projectActivitiesListOnlyPublicOnes() {
var activities =
projects.activities(new ProjectPageQuery("tech-log", page(1, 20))).orElseThrow();
assertThat(activities.items()).extracting("title").containsExactly("첫 게시");
assertThat(activities.page().totalElements()).isEqualTo(1);
}
// ---------------------------------------------------------------- 릴리스
@Test
void releasesListOnlyPublishedOnesAndResolveRelatedRecords() {
assertThat(releases.list()).extracting("version").containsExactly("1.0.0");
ReleaseDetailView detail = releases.findByVersion("1.0.0").orElseThrow();
assertThat(detail.title()).isEqualTo("첫 공개");
assertThat(detail.changeTypes()).containsExactly("ADDED");
assertThat(detail.relatedRecords())
.as("related_resources 는 공개된 것만 되살린다")
.extracting("title")
.containsExactly("Kafka 재처리");
assertThat(releases.findByVersion("0.9.0")).as("DRAFT 릴리스는 열리면 안 된다").isEmpty();
}
// ---------------------------------------------------------------- 검색
@Test
void searchMatchesOnSearchTextAndAppliesFilters() {
SearchResultPageView hits = search.search(new SearchQuery("재처리", null, null, page(1, 20)));
assertThat(hits.query()).isEqualTo("재처리");
assertThat(hits.items()).isNotEmpty();
assertThat(hits.page().totalElements()).isEqualTo(hits.items().size());
assertThat(hits.items()).extracting("title").doesNotContain("숨김 Case");
assertThat(search.search(new SearchQuery("기준", "REFERENCE", null, page(1, 20))).items())
.extracting("contentType")
.containsOnly("REFERENCE");
assertThat(search.search(new SearchQuery("기준", "CASE", null, page(1, 20))).items())
.as("type 필터가 실제로 걸려야 한다")
.isEmpty();
assertThat(search.search(new SearchQuery("존재하지않는단어", null, null, page(1, 20))).items())
.isEmpty();
}
// ---------------------------------------------------------------- 시딩
/**
* 삽입 순서가 곧 제약이다. {@code public_resource_project_link}/{@code public_resource_tag} 는 {@code
* public_resource_projection} 을 복합 FK 로 참조하므로 원본 테이블 → projection → 링크/태그 순서를 지킨다.
*/
private static void seed() {
topicId = insertTopic("kafka", "Kafka");
projectId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project (id, slug, name, one_line_purpose, purpose_markdown,"
+ " boundary_markdown, system_overview_markdown, phase, current_objective,"
+ " next_step, technology_labels, workflow_status, target_visibility,"
+ " created_by, updated_by)"
+ " VALUES (:id, 'tech-log', 'Tech Log', '기록을 남긴다', '목적', '경계', '개요',"
+ " 'IMPLEMENTATION', '공개 API 완성', '통합 테스트',"
+ " '[\"Spring Boot\", \"PostgreSQL\"]'::jsonb, 'PUBLISHED', 'PUBLIC',"
+ " 'test', 'test')")
.param("id", projectId)
.update();
tagId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO tag (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, 'reprocessing', 'reprocessing', 'reprocessing', 'test', 'test')")
.param("id", tagId)
.update();
// --- Case (공개) ---
caseId = UUID.randomUUID();
insertDocument(caseId, "CASE", "kafka-reprocessing", "Kafka 재처리", topicId);
jdbcClient
.sql(
"INSERT INTO case_detail (document_id, problem_summary, conclusion_summary,"
+ " environment_items) VALUES (:id, '재처리가 지연된다', '컨슈머 랙을 먼저 본다',"
+ " '[\"Kafka 3.7\"]'::jsonb)")
.param("id", caseId)
.update();
publish(
"CASE",
caseId,
"Kafka 재처리",
"재처리가 지연된다",
"/cases/kafka-reprocessing",
"ACTIVE",
"PUBLIC",
topicId);
link(caseId, "CASE", "PRIMARY", 0);
tag(caseId, "CASE");
// 목록의 tag 필터는 projection(public_resource_tag)을, 상세는 원본(document_tag)을 읽는다.
jdbcClient
.sql(
"INSERT INTO document_tag (document_id, tag_id, display_order)" + " VALUES (:d, :t, 0)")
.param("d", caseId)
.param("t", tagId)
.update();
// --- Reference (공개) ---
referenceId = UUID.randomUUID();
insertDocument(referenceId, "REFERENCE", "kafka-operations", "Kafka 운영 기준", topicId);
jdbcClient
.sql(
"INSERT INTO reference_detail (document_id, scope_summary, applies_to,"
+ " excluded_scope, freshness_status) VALUES (:id, '운영 기준을 정한다',"
+ " '[\"Kafka 3.x\"]'::jsonb, '[\"Kinesis\"]'::jsonb, 'CURRENT')")
.param("id", referenceId)
.update();
publish(
"REFERENCE",
referenceId,
"Kafka 운영 기준",
"운영 기준을 정한다",
"/references/kafka-operations",
"ACTIVE",
"PUBLIC",
topicId);
link(referenceId, "REFERENCE", "RELATED", null);
// --- Case (게시 취소) — 어느 경로로도 새면 안 된다 ---
hiddenCaseId = UUID.randomUUID();
insertDocument(hiddenCaseId, "CASE", "hidden-case", "숨김 Case", topicId);
jdbcClient
.sql("INSERT INTO case_detail (document_id, problem_summary) VALUES (:id, '재처리 비밀')")
.param("id", hiddenCaseId)
.update();
publish(
"CASE",
hiddenCaseId,
"숨김 Case",
"재처리 비밀",
"/cases/hidden-case",
"WITHDRAWN",
"PUBLIC",
topicId);
// --- OpenQuestion (공개) ---
questionId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO open_question (id, slug, question, summary, context_markdown,"
+ " importance_markdown, next_verification, question_status, target_visibility,"
+ " primary_topic_id, opened_at, created_by, updated_by)"
+ " VALUES (:id, 'reprocessing-latency', '재처리 지연을 어떻게 줄일까',"
+ " '지연 원인을 좁힌다', '맥락', '중요도', '컨슈머 랙 측정', 'OPEN', 'PUBLIC', :topic,"
+ " :openedAt, 'test', 'test')")
.param("id", questionId)
.param("topic", topicId)
.param("openedAt", java.sql.Timestamp.from(NOW.minus(10, ChronoUnit.DAYS)))
.update();
publish(
"QUESTION",
questionId,
"재처리 지연을 어떻게 줄일까",
"지연 원인을 좁힌다",
"/questions/reprocessing-latency",
"ACTIVE",
"PUBLIC",
topicId);
// 질문 목록의 status 필터는 projection 의 state_code 를 본다.
jdbcClient
.sql(
"UPDATE public_resource_projection SET state_code = 'OPEN'"
+ " WHERE resource_type = 'QUESTION' AND resource_id = :id")
.param("id", questionId)
.update();
link(questionId, "QUESTION", "PRIMARY", 1);
tag(questionId, "QUESTION");
// --- ProjectDecision (공개) ---
decisionId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project_decision (id, project_id, statement, rationale_markdown,"
+ " consequences, decision_status, target_visibility, source_question_id,"
+ " source_case_id, is_featured, decided_at, created_by, updated_by)"
+ " VALUES (:id, :project, '재처리는 별도 토픽으로 분리한다', '격리해야 관측이 쉬워진다',"
+ " '[\"운영 토픽 증가\"]'::jsonb, 'ACCEPTED', 'PUBLIC', :question, :sourceCase,"
+ " true, :decidedAt, 'test', 'test')")
.param("id", decisionId)
.param("project", projectId)
.param("question", questionId)
.param("sourceCase", caseId)
.param("decidedAt", java.sql.Timestamp.from(NOW.minus(2, ChronoUnit.DAYS)))
.update();
publish(
"PROJECT_DECISION",
decisionId,
"재처리는 별도 토픽으로 분리한다",
"격리해야 관측이 쉬워진다",
"/projects/tech-log/decisions/" + decisionId,
"ACTIVE",
"PUBLIC",
null);
// --- Project (공개) ---
publish(
"PROJECT",
projectId,
"Tech Log",
"기록을 남긴다",
"/projects/tech-log",
"ACTIVE",
"PUBLIC",
null);
// --- 활동: 공개 하나 · 비공개 하나 ---
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, summary,"
+ " visibility, origin, related_resource_type, related_resource_id, occurred_at,"
+ " created_by, updated_by)"
+ " VALUES (gen_random_uuid(), :project, 'CASE_PUBLISHED', '첫 게시',"
+ " '첫 문서를 공개했다', 'PUBLIC', 'AUTO', 'CASE', :relatedCase, :at,"
+ " 'test', 'test')")
.param("project", projectId)
.param("relatedCase", caseId)
.param("at", java.sql.Timestamp.from(NOW.minus(1, ChronoUnit.DAYS)))
.update();
jdbcClient
.sql(
"INSERT INTO project_activity (id, project_id, activity_type, title, visibility,"
+ " origin, occurred_at, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), :project, 'MILESTONE_REACHED', '비공개 메모', 'PRIVATE',"
+ " 'MANUAL', :at, 'test', 'test')")
.param("project", projectId)
.param("at", java.sql.Timestamp.from(NOW))
.update();
// --- V9 연결 테이블 ---
jdbcClient
.sql("INSERT INTO project_topic (project_id, topic_id, display_order) VALUES (:p, :t, 0)")
.param("p", projectId)
.param("t", topicId)
.update();
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'START_HERE', 0)")
.param("t", topicId)
.param("d", referenceId)
.update();
jdbcClient
.sql(
"INSERT INTO topic_featured_document (topic_id, document_id, feature_role,"
+ " display_order) VALUES (:t, :d, 'FEATURED_CASE', 0)")
.param("t", topicId)
.param("d", caseId)
.update();
// --- 단일 행 설정 ---
jdbcClient
.sql("UPDATE site_config SET contacts = :contacts::jsonb WHERE id = :id")
.param("id", SITE_CONFIG_ID)
.param(
"contacts",
"[{\"type\":\"GITHUB\",\"label\":\"GitHub\","
+ "\"url\":\"https://github.com/example\"}]")
.update();
// V9 가 단일 행을 이미 시딩했으므로(INSERT ... ON CONFLICT DO NOTHING) 값 채우기는 UPDATE 다.
jdbcClient
.sql(
"UPDATE profile_page SET headline = '문제를 재현해 검증한다',"
+ " introduction_markdown = '소개', working_model = :workingModel::jsonb,"
+ " territories = :territories::jsonb, selected_evidence = :evidence::jsonb,"
+ " trajectory = :trajectory::jsonb, contacts = :contacts::jsonb,"
+ " target_visibility = 'PUBLIC' WHERE id = :id")
.param("id", PROFILE_PAGE_ID)
.param("workingModel", "[{\"name\":\"재현\",\"description\":\"먼저 재현한다\"}]")
.param(
"territories",
"[{\"name\":\"Kafka\",\"currentQuestion\":\"재처리 지연\","
+ "\"topicPath\":\"/topics/kafka\"}]")
// 게시 취소된 자료 id 를 함께 넣는다 — 공개된 것만 되살아나야 한다.
.param("evidence", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
.param("trajectory", "[{\"title\":\"2026\",\"description\":\"Tech Log 시작\"}]")
.param(
"contacts",
"[{\"type\":\"EMAIL\",\"label\":\"Email\"," + "\"url\":\"mailto:a@example.com\"}]")
.update();
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = 'CURRENT_WORK',"
+ " current_project_id = :project, open_question_id = :question,"
+ " recent_decision_id = :decision WHERE id = :id")
.param("id", HOME_FOCUS_ID)
.param("project", projectId)
.param("question", questionId)
.param("decision", decisionId)
.update();
// --- 릴리스: 공개 하나 · 초안 하나 ---
jdbcClient
.sql(
"INSERT INTO release (id, version_label, title, summary, released_on,"
+ " workflow_status, change_types, changes_markdown, verification_markdown,"
+ " related_resources, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), '1.0.0', '첫 공개', '공개 API 를 열었다',"
+ " DATE '2026-08-01', 'PUBLISHED', '[\"ADDED\"]'::jsonb, '변경', '검증',"
+ " :related::jsonb, 'test', 'test')")
// 게시 취소된 자료 id 를 일부러 함께 넣는다 — 공개된 것만 되살아나야 한다.
.param("related", "[\"" + caseId + "\",\"" + hiddenCaseId + "\"]")
.update();
jdbcClient
.sql(
"INSERT INTO release (id, version_label, title, summary, released_on,"
+ " workflow_status, created_by, updated_by)"
+ " VALUES (gen_random_uuid(), '0.9.0', '초안', '아직 공개 전', DATE '2026-07-01',"
+ " 'DRAFT', 'test', 'test')")
.update();
}
private static UUID insertTopic(String slug, String name) {
UUID id = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, description, scope,"
+ " status, created_by, updated_by)"
+ " VALUES (:id, :name, lower(:name), :slug, :name || ' 설명', '범위',"
+ " 'ACTIVE', 'test', 'test')")
.param("id", id)
.param("name", name)
.param("slug", slug)
.update();
return id;
}
private static void insertDocument(UUID id, String type, String slug, String title, UUID topic) {
jdbcClient
.sql(
"INSERT INTO document (id, document_type, slug, title, body_markdown,"
+ " content_format, content_format_version, workflow_status, target_visibility,"
+ " primary_topic_id, last_verified_at, created_by, updated_by)"
+ " VALUES (:id, :type, :slug, :title, '# 재처리\n본문', 'MARKDOWN', 1,"
+ " 'PUBLISHED', 'PUBLIC', :topic, :verifiedAt, 'test', 'test')")
.param("id", id)
.param("type", type)
.param("slug", slug)
.param("title", title)
.param("topic", topic)
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
.update();
}
private static void link(UUID resourceId, String type, String relation, Integer order) {
jdbcClient
.sql(
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
+ " relation_type, featured_order)"
+ " VALUES (:type, :id, :project, :relation, :order)")
.param("type", type)
.param("id", resourceId)
.param("project", projectId)
.param("relation", relation)
.param("order", order)
.update();
}
private static void tag(UUID resourceId, String type) {
jdbcClient
.sql(
"INSERT INTO public_resource_tag (resource_type, resource_id, tag_id, display_order)"
+ " VALUES (:type, :id, :tag, 0)")
.param("type", type)
.param("id", resourceId)
.param("tag", tagId)
.update();
}
/**
* projection 행을 만든다. {@code public_resource_project_link} 와 {@code public_resource_tag} 가 이 행을
* (resource_type, resource_id) 복합 FK 로 참조하므로 <b>반드시 링크·태그보다 먼저</b> 삽입해야 한다.
*
* <p>{@code topic} 을 인자로 받는 이유는 주제별 record 수를 세는 쿼리가 {@code primary_topic_id} 를 보기 때문이다. 모든
* projection 에 같은 주제를 박아 두면 Project 나 Decision 까지 그 주제의 기록으로 세어져, 실제 값과 다른 숫자에 테스트를 맞추게 된다.
*/
private static void publish(
String type,
UUID id,
String title,
String summary,
String path,
String state,
String visibility,
UUID topic) {
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, primary_topic_id,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, last_verified_at, navigation_path)"
+ " VALUES (:type, :id, 1, :state, :visibility, :title, :summary, :topic, 1,"
+ " '{}'::jsonb, :body, :search, repeat('a', 64), :publishedAt, :updatedAt,"
+ " :verifiedAt, :path)")
.param("type", type)
.param("id", id)
.param("state", state)
.param("visibility", visibility)
.param("title", title)
.param("summary", summary)
.param("topic", topic)
.param("body", title + " " + summary)
.param("search", title + " " + summary)
.param("publishedAt", java.sql.Timestamp.from(NOW.minus(5, ChronoUnit.DAYS)))
.param("updatedAt", java.sql.Timestamp.from(NOW.minus(4, ChronoUnit.DAYS)))
.param("verifiedAt", java.sql.Timestamp.from(NOW.minus(3, ChronoUnit.DAYS)))
.param("path", path)
.update();
}
private static PublicPageRequest page(int page, int size) {
return new PublicPageRequest(page, size);
}
private static boolean tableExists(String table) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables"
+ " WHERE table_schema = 'public' AND table_name = :t)")
.param("t", table)
.query(Boolean.class)
.single());
}
}
+25
View File
@@ -30,6 +30,16 @@ sourceSets {
}
}
// objectstorage 모듈은 AWS SDK BOM 을 자기 스코프로만 import 한다(그 모듈 주석 참고 —
// strict locking 의 영향 범위를 좁히려는 의도다). Spring 의 dependency-management 는
// 소비자에게 전파되지 않으므로, 런타임을 조립하는 이쪽에서 같은 SSOT 로 한 번 더 선언한다.
// 이것이 없으면 s3/netty-nio-client 가 버전 없이 남아 bootJar 가 해석에 실패한다.
dependencyManagement {
imports {
mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}"
}
}
configurations {
sampleOffTestImplementation.extendsFrom testImplementation
sampleOffTestCompileOnly.extendsFrom testCompileOnly
@@ -48,6 +58,10 @@ dependencies {
implementation project(':adapter:outbound:fileserver')
implementation project(':adapter:outbound:httpclient')
implementation project(':adapter:outbound:identifier')
// Studio Asset 바이너리의 실제 저장 구현. 이것이 없으면 ObjectStorageAssetBinaryAdapter 의
// ObjectProvider<ObjectStoragePort> 가 항상 비어서 업로드·삭제가 STUDIO_UNAVAILABLE 로
// 거절된다 — 컴파일은 통과하므로 빌드로는 드러나지 않고 런타임에만 나타난다.
implementation project(':adapter:outbound:objectstorage')
implementation project(':adapter:inbound:web')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
@@ -76,6 +90,17 @@ dependencies {
// Security types for ManagementSecurityConfig (not reachable via adapter-web's implementation dep). See README.
implementation 'org.springframework.boot:spring-boot-starter-security'
// Redis-backed HTTP session for auth-mode=redis-session (the BFF surface the Studio contract
// declares: sessionCookie TECHLOG_SESSION + X-CSRF-TOKEN). AuthenticationModeCompositionConfig
// requires the `redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair once
// that mode is active; StudioSessionInfrastructureConfig supplies the first, Spring Session's
// SpringHttpSessionConfiguration the second.
// OIDC Authorization Code 로그인 자동설정(ClientRegistrationRepository 등). 기존의
// spring-security-oauth2-client 는 라이브러리만 주고 Boot 자동설정은 스타터가 준다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// test-only: ArchUnit needs actuator types to verify the health-shape guardrail. See README.
testImplementation 'org.springframework.boot:spring-boot-starter-actuator'
// test-only: @WithMockUser for the actuator security authorization tests. See README.
+63 -20
View File
@@ -74,7 +74,7 @@ com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestR
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-codec:commons-codec:1.19.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
@@ -99,7 +99,7 @@ io.grpc:grpc-protobuf:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath
io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath
io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -108,27 +108,30 @@ io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTranspor
io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-classes-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-compression:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http2:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-http3:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-http:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-marshalling:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-native-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-protobuf:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec-socks:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-codec:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler-proxy:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns-native-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-classes-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-transport-native-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.17.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -147,7 +150,7 @@ io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClas
io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath
io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
@@ -185,8 +188,8 @@ org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.apache.httpcomponents:httpcore:4.4.16=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
@@ -268,7 +271,7 @@ org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -281,9 +284,10 @@ org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspa
org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -297,15 +301,18 @@ org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspat
org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-netty:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -314,6 +321,7 @@ org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspa
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-client:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -336,8 +344,10 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntim
org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath
org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-keyvalue:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath
org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -346,20 +356,23 @@ org.springframework.security:spring-security-core:7.0.0=compileClasspath,functio
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-data-redis:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context-support:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-oxm:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -374,7 +387,37 @@ org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffT
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:annotations:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:apache-client:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:arns:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:auth:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-query-protocol:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:aws-xml-protocol:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:checksums-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:checksums:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:crt-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:endpoints-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-aws:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:http-auth:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:http-client-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:identity-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:json-utils:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:metrics-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:netty-nio-client:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:profiles:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:protocol-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:regions:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:retries-spi:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:retries:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:s3:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:sdk-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:third-party-jackson-core:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.awssdk:utils:2.30.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
software.amazon.eventstream:eventstream:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -0,0 +1,624 @@
package dev.caskeleton.bootstrap.contract;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig;
import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.application.techlog.publicsite.model.CaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.HomeView;
import dev.caskeleton.application.techlog.publicsite.model.KnowledgePageView;
import dev.caskeleton.application.techlog.publicsite.model.PageMetadataView;
import dev.caskeleton.application.techlog.publicsite.model.ProfileView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectActivityPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDecisionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectListItemView;
import dev.caskeleton.application.techlog.publicsite.model.ProjectRecordPageView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionDetailView;
import dev.caskeleton.application.techlog.publicsite.model.QuestionPageView;
import dev.caskeleton.application.techlog.publicsite.model.ReferenceDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseDetailView;
import dev.caskeleton.application.techlog.publicsite.model.ReleaseListItemView;
import dev.caskeleton.application.techlog.publicsite.model.SearchResultPageView;
import dev.caskeleton.application.techlog.publicsite.model.SiteView;
import dev.caskeleton.application.techlog.publicsite.model.TopicDetailView;
import dev.caskeleton.application.techlog.publicsite.model.TopicListItemView;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import dev.caskeleton.application.techlog.publicsite.query.ExploreKnowledgeQuery;
import dev.caskeleton.application.techlog.publicsite.query.ExploreQuestionsQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectDecisionPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.ProjectRecordPageQuery;
import dev.caskeleton.application.techlog.publicsite.query.SearchQuery;
import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.yaml.snakeyaml.Yaml;
/**
* {@code PublicContractDriftTest} {@code StudioContractDriftTest} studio-v1 대해 하는 일을
* public-v1 대해 한다: springdoc 실제로 게시하는 표면과 vendored {@code src/config/openapi/public-v1.yaml}
* <b>양방향</b>으로 대조한다.
*
* <p>스캔 범위는 {@code dev.caskeleton.adapter.inbound.web.techlog.publicapi} . 아래에 컨트롤러를 두면 파일을
* 고치지 않아도 자동으로 감시 대상이 되고, 밖에 두면 게이트가 그것을 보지 못한다 성질과 함정은 형제 테스트의 클래스 javadoc 자세히 적혀 있다.
*
* <h2>계약의 {@code servers} 경로에 더해야 한다</h2>
*
* <p>studio-v1 {@code servers: "/"} 계약의 path 그대로 최종 주소지만, public-v1 {@code servers:
* "/api/v1/public"} 이고 path {@code /site} 처럼 짧다. 그래서 대조 전에 server prefix 붙인다 이걸 빠뜨리면
* published 계약이 건도 겹치지 않는데도 "published ⊆ 계약" 방향은 비교 대상이 0건이라 통과해 버린다. 아래 {@code compared} 비어있지
* 않음 단언이 상태를 실패로 만든다.
*/
class PublicContractDriftTest {
@Nested
@SpringBootTest(classes = ContractSurface.ContractSurfaceApp.class)
@AutoConfigureMockMvc(addFilters = false)
class ContractSurface {
@Autowired private MockMvc mvc;
@Test
void publishedPublicOperationsMatchTheContract() throws Exception {
JsonNode contract = readContract();
String prefix = serverPrefix(contract);
JsonNode published = readPublishedApiDocs();
List<String> problems = new ArrayList<>();
List<String> compared = new ArrayList<>();
JsonNode publishedPaths = published.path("paths");
for (Map.Entry<String, JsonNode> path : publishedPaths.properties()) {
if (!path.getKey().startsWith(prefix + "/")) {
continue;
}
compared.add(path.getKey());
String contractKey = path.getKey().substring(prefix.length());
JsonNode contractPath = contract.path("paths").path(contractKey);
if (contractPath.isMissingNode()) {
problems.add("계약에 없는 path: " + path.getKey());
continue;
}
for (Map.Entry<String, JsonNode> method : path.getValue().properties()) {
JsonNode contractOp = contractPath.path(method.getKey());
if (contractOp.isMissingNode()) {
problems.add("계약에 없는 method: " + method.getKey() + " " + path.getKey());
continue;
}
String publishedId = method.getValue().path("operationId").asText("");
String contractId = contractOp.path("operationId").asText("");
if (!publishedId.equals(contractId)) {
problems.add(
"operationId 불일치 "
+ method.getKey()
+ " "
+ path.getKey()
+ ": published="
+ publishedId
+ " contract="
+ contractId);
}
}
}
assertThat(problems).isEmpty();
assertThat(compared)
.as(
"published 표면에서 "
+ prefix
+ " 경로를 하나도 대조하지 못했다 —"
+ " PresentationWebConfig 의 api-base-path 배선이나 컨트롤러 매핑을 확인하라."
+ " published paths="
+ publishedPaths.properties().stream().map(Map.Entry::getKey).toList())
.isNotEmpty();
}
/** 반대 방향 — 계약의 19 operation 이 전부 published 표면에 있는가. */
@Test
void everyContractOperationIsPublished() throws Exception {
JsonNode contract = readContract();
String prefix = serverPrefix(contract);
JsonNode published = readPublishedApiDocs();
List<String> missing = new ArrayList<>();
int contractOperations = 0;
for (Map.Entry<String, JsonNode> path : contract.path("paths").properties()) {
for (Map.Entry<String, JsonNode> method : path.getValue().properties()) {
JsonNode operationId = method.getValue().path("operationId");
if (operationId.isMissingNode()) {
continue;
}
contractOperations++;
JsonNode publishedOperation =
published.path("paths").path(prefix + path.getKey()).path(method.getKey());
if (publishedOperation.isMissingNode()
|| !operationId.asText().equals(publishedOperation.path("operationId").asText(""))) {
missing.add(operationId.asText() + " (" + method.getKey() + " " + path.getKey() + ")");
}
}
}
assertThat(missing).as("계약이 약속했는데 서버가 제공하지 않는 operation").isEmpty();
// 계약이 통째로 비거나 잘못 읽혀도 단언은 통과한다 순회할 없으면 missing 비니까.
assertThat(contractOperations).as("public-v1 계약의 operation 수").isEqualTo(19);
}
private static String serverPrefix(JsonNode contract) {
String url = contract.path("servers").path(0).path("url").asText("");
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
private static JsonNode readContract() throws Exception {
Path contractFile =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/public-v1.yaml");
Map<String, Object> contractYaml;
try (InputStream in = Files.newInputStream(contractFile)) {
contractYaml = new Yaml().load(in);
}
return new ObjectMapper().valueToTree(contractYaml);
}
private JsonNode readPublishedApiDocs() throws Exception {
String body =
mvc.perform(get("/api/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
return new ObjectMapper().readTree(body);
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.publicapi")
@Import({PresentationWebConfig.class, PublicContractDriftTest.PublicPortStubs.class})
static class ContractSurfaceApp {
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
}
}
@Nested
@SpringBootTest(classes = EnvelopeWrapping.EnvelopeApp.class)
@AutoConfigureMockMvc(addFilters = false)
class EnvelopeWrapping {
@Autowired private MockMvc mvc;
/**
* ADR-006: 공개 조회 응답도 봉투로 나간다. {@code /topics} 고른 이유는 반환 타입이 평범한 POJO {@code
* EnvelopeBodyAdvice} 감싸기 전후로 같은 JSON 컨버터가 처리하기 때문이다(형제 테스트가 {@code byte[]} 반환 컨트롤러에서 겪은
* {@code ClassCastException} 피한다).
*/
@Test
void everyPublicResponseIsWrappedInTheEnvelope() throws Exception {
String body =
mvc.perform(get("/api/v1/public/topics"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
assertThat(body).contains("\"success\"").contains("\"data\"").contains("\"meta\"");
assertThat(body).doesNotContain("\"data\":{\"success\"");
}
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.publicapi")
@Import({
EnvelopeBodyAdvice.class,
PresentationWebConfig.class,
PublicContractDriftTest.PublicPortStubs.class
})
static class EnvelopeApp {
@Bean
PresentationSettings presentationSettings() {
return new PresentationSettings("/api");
}
}
}
/**
* 7개 outbound port stub 위에 올린 18개 use case. 번째 테스트는 springdoc 리플렉션이라 컨트롤러 메서드를 아예 호출하지
* 않고, 번째 테스트는 {@code /topics} 하나만 두드리며 감싸는 모양만 본다 실제 영속성 어댑터를 끌어오면 게이트가 말하려는 (계약 표면과 봉투)
* 무관한 DB 인프라가 딸려 온다.
*
* <p>production {@code TechLogPublicConfig} 그대로 {@code @Import} 하지 않는 이유는 클래스가
* app-bootstrap {@code main} 소스셋에 있고, functionalTest 소스셋은 {@code main} output 클래스패스에 두지 않기
* 때문이다. 억지로 넣으면 app-bootstrap {@code AutoConfiguration.imports}(fileserver / httpclient)까지 함께
* 활성화되어, 계약 표면만 보려는 최소 컨텍스트가 무관한 인프라를 요구하게 된다. 대신 같은 조립을 여기서 반복한다 production 배선 자체는 기동으로
* 확인한다.
*/
@Configuration(proxyBeanMethods = false)
static class PublicPortStubs {
@Bean
TransactionPort transactionPort() {
return new PassThroughTransactionPort();
}
@Bean
PublicSiteQueryPort publicSiteQueryPort() {
return new PublicSiteQueryPort() {
@Override
public Optional<SiteView> site() {
return Optional.empty();
}
@Override
public HomeView home(int latestEntryLimit) {
return new HomeView(null, List.of());
}
@Override
public Optional<ProfileView> profile() {
return Optional.empty();
}
};
}
@Bean
PublicExploreQueryPort publicExploreQueryPort() {
return new PublicExploreQueryPort() {
@Override
public KnowledgePageView knowledge(ExploreKnowledgeQuery query) {
return new KnowledgePageView(List.of(), PageMetadataView.of(1, 20, 0));
}
@Override
public QuestionPageView questions(ExploreQuestionsQuery query) {
return new QuestionPageView(List.of(), PageMetadataView.of(1, 20, 0));
}
};
}
@Bean
PublicTopicQueryPort publicTopicQueryPort() {
return new PublicTopicQueryPort() {
@Override
public List<TopicListItemView> list() {
return List.of();
}
@Override
public Optional<TopicDetailView> findBySlug(String slug) {
return Optional.empty();
}
};
}
@Bean
PublicDocumentQueryPort publicDocumentQueryPort() {
return new PublicDocumentQueryPort() {
@Override
public Optional<CaseDetailView> findCase(String slug) {
return Optional.empty();
}
@Override
public Optional<ReferenceDetailView> findReference(String slug) {
return Optional.empty();
}
@Override
public Optional<QuestionDetailView> findQuestion(String slug) {
return Optional.empty();
}
};
}
@Bean
PublicProjectQueryPort publicProjectQueryPort() {
return new PublicProjectQueryPort() {
@Override
public List<ProjectListItemView> list() {
return List.of();
}
@Override
public Optional<ProjectDetailView> findBySlug(String slug) {
return Optional.empty();
}
@Override
public Optional<ProjectDecisionPageView> decisions(ProjectDecisionPageQuery query) {
return Optional.empty();
}
@Override
public Optional<ProjectRecordPageView> records(ProjectRecordPageQuery query) {
return Optional.empty();
}
@Override
public Optional<ProjectActivityPageView> activities(ProjectPageQuery query) {
return Optional.empty();
}
};
}
@Bean
PublicReleaseQueryPort publicReleaseQueryPort() {
return new PublicReleaseQueryPort() {
@Override
public List<ReleaseListItemView> list() {
return List.of();
}
@Override
public Optional<ReleaseDetailView> findByVersion(String version) {
return Optional.empty();
}
};
}
@Bean
PublicSearchQueryPort publicSearchQueryPort() {
return new PublicSearchQueryPort() {
@Override
public SearchResultPageView search(SearchQuery query) {
return new SearchResultPageView(query.query(), List.of(), PageMetadataView.of(1, 20, 0));
}
};
}
/**
* 미디어 표면은 Studio Asset 저장소를 읽는다. 게이트는 계약 표면만 확인하므로 저장소 개를 비어 있는 것으로 세워 컨트롤러가 조립되게만 한다.
*/
@Bean
dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase
getPublicMediaUseCase(TransactionPort tx) {
return new dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase(
new StubAssetRepositoryPortForMedia(),
new dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort() {
@Override
public String store(String objectKey, byte[] content, String mediaType) {
return objectKey;
}
@Override
public java.util.Optional<byte[]> read(String objectKey) {
return java.util.Optional.empty();
}
@Override
public void delete(String objectKey) {
// 게이트는 바이너리를 다루지 않는다.
}
},
tx);
}
@Bean
GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) {
return new GetPublicSiteUseCase(port, tx);
}
@Bean
GetPublicHomeUseCase getPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort tx) {
return new GetPublicHomeUseCase(port, tx);
}
@Bean
GetPublicProfileUseCase getPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort tx) {
return new GetPublicProfileUseCase(port, tx);
}
@Bean
ExploreKnowledgeUseCase exploreKnowledgeUseCase(
PublicExploreQueryPort port, TransactionPort tx) {
return new ExploreKnowledgeUseCase(port, tx);
}
@Bean
ExploreQuestionsUseCase exploreQuestionsUseCase(
PublicExploreQueryPort port, TransactionPort tx) {
return new ExploreQuestionsUseCase(port, tx);
}
@Bean
ListPublicTopicsUseCase listPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort tx) {
return new ListPublicTopicsUseCase(port, tx);
}
@Bean
GetPublicTopicUseCase getPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort tx) {
return new GetPublicTopicUseCase(port, tx);
}
@Bean
GetPublicCaseUseCase getPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort tx) {
return new GetPublicCaseUseCase(port, tx);
}
@Bean
GetPublicReferenceUseCase getPublicReferenceUseCase(
PublicDocumentQueryPort port, TransactionPort tx) {
return new GetPublicReferenceUseCase(port, tx);
}
@Bean
GetPublicQuestionUseCase getPublicQuestionUseCase(
PublicDocumentQueryPort port, TransactionPort tx) {
return new GetPublicQuestionUseCase(port, tx);
}
@Bean
ListPublicProjectsUseCase listPublicProjectsUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new ListPublicProjectsUseCase(port, tx);
}
@Bean
GetPublicProjectUseCase getPublicProjectUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new GetPublicProjectUseCase(port, tx);
}
@Bean
ListPublicProjectDecisionsUseCase listPublicProjectDecisionsUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new ListPublicProjectDecisionsUseCase(port, tx);
}
@Bean
ListPublicProjectRecordsUseCase listPublicProjectRecordsUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new ListPublicProjectRecordsUseCase(port, tx);
}
@Bean
ListPublicProjectActivitiesUseCase listPublicProjectActivitiesUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new ListPublicProjectActivitiesUseCase(port, tx);
}
@Bean
ListPublicReleasesUseCase listPublicReleasesUseCase(
PublicReleaseQueryPort port, TransactionPort tx) {
return new ListPublicReleasesUseCase(port, tx);
}
@Bean
GetPublicReleaseUseCase getPublicReleaseUseCase(
PublicReleaseQueryPort port, TransactionPort tx) {
return new GetPublicReleaseUseCase(port, tx);
}
@Bean
SearchPublicResourcesUseCase searchPublicResourcesUseCase(
PublicSearchQueryPort port, TransactionPort tx) {
return new SearchPublicResourcesUseCase(port, tx);
}
}
private static final class PassThroughTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRootWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return action.get();
}
}
/**
* 미디어 컨트롤러를 조립하기 위한 최소 Asset 저장소. {@code StudioContractDriftTest} 것과 같은 모양이며, 게이트는 계약 표면만 보므로
* 어떤 Asset 돌려주지 않는다.
*/
private static final class StubAssetRepositoryPortForMedia
implements dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort {
@Override
public dev.caskeleton.application.techlog.studio.model.AssetPageView list(
dev.caskeleton.application.techlog.studio.query.ListAssetsQuery query) {
return new dev.caskeleton.application.techlog.studio.model.AssetPageView(List.of(), null);
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetView> find(
java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetDetailView>
findDetail(java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public dev.caskeleton.application.techlog.studio.model.AssetView create(
NewAsset asset, String principal) {
throw new UnsupportedOperationException("this contract-shape gate never stores an asset");
}
@Override
public java.util.Optional<dev.caskeleton.application.techlog.studio.model.AssetView> update(
java.util.UUID assetId,
long expectedVersion,
dev.caskeleton.application.techlog.studio.model.AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView managementStatus,
String principal) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<String> findObjectKey(java.util.UUID assetId) {
return java.util.Optional.empty();
}
@Override
public void delete(java.util.UUID assetId) {
// 게이트는 삭제하지 않는다.
}
}
}
@@ -46,18 +46,24 @@ import org.yaml.snakeyaml.Yaml;
* <em>implemented</em> operations are checked (direction is "published ⊆ contract", never the
* reverse), so this stays green as slices 2-5 add the other 17 operations <b>on one
* condition</b>: the new controllers must live somewhere under {@code
* dev.caskeleton.adapter.inbound.web.techlog}, the package {@link
* dev.caskeleton.adapter.inbound.web.techlog.studio}, the package {@link
* ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp}
* {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to
* this file. A controller placed <em>outside</em> that package tree is invisible to both minimal
* contexts springdoc never sees it, so this gate stays green even if its path/method/operationId
* contradicts the contract and the {@code @ComponentScan} base package below must be widened (or
* the new controller moved) before this gate can be trusted again. (An earlier draft of this class
* named the two controllers directly via {@code @Import} instead of scanning; that hardcoded list
* had exactly this blind spot confirmed by temporarily reintroducing it and observing a
* controller with an out-of-contract mapping pass silently, see task-10-report.md.) This test also
* fails the moment an in-scan controller's method name drifts from its {@code operationId} or ships
* an endpoint outside the contract.
* {@code @ComponentScan}. (The scan sat one level higher {@code ...web.techlog} until the
* public-v1 controllers arrived under {@code ...web.techlog.publicapi}: scanning those pulled a
* second contract's controllers into a Studio-only context, which then needs their use-case beans
* and has nothing to say about their contract. {@code PublicContractDriftTest} is this same gate
* for that tree, scanning {@code ...web.techlog.publicapi} against {@code public-v1.yaml}, so each
* contract keeps the automatic-pickup property inside its own package.) A controller placed there
* is picked up automatically, with no edit to this file. A controller placed <em>outside</em> that
* package tree is invisible to both minimal contexts springdoc never sees it, so this gate stays
* green even if its path/method/operationId contradicts the contract and the
* {@code @ComponentScan} base package below must be widened (or the new controller moved) before
* this gate can be trusted again. (An earlier draft of this class named the two controllers
* directly via {@code @Import} instead of scanning; that hardcoded list had exactly this blind spot
* confirmed by temporarily reintroducing it and observing a controller with an out-of-contract
* mapping pass silently, see task-10-report.md.) This test also fails the moment an in-scan
* controller's method name drifts from its {@code operationId} or ships an endpoint outside the
* contract.
*
* <h2>Why a hand-built minimal context rather than {@code CaSkeletonApplication}</h2>
*
@@ -277,7 +283,7 @@ class StudioContractDriftTest {
*/
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.studio")
@Import({PresentationWebConfig.class, StudioContractDriftTest.StudioDocumentTestBeans.class})
static class ContractSurfaceApp {
@@ -333,7 +339,7 @@ class StudioContractDriftTest {
*/
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog.studio")
@Import({
EnvelopeBodyAdvice.class,
PresentationWebConfig.class,
@@ -603,6 +609,12 @@ class StudioContractDriftTest {
return objectKey;
}
@Override
public java.util.Optional<byte[]> read(String objectKey) {
// 게이트는 바이너리를 다루지 않는다.
return java.util.Optional.empty();
}
@Override
public void delete(String objectKey) {
// 게이트는 바이너리를 다루지 않는다.
@@ -3,14 +3,16 @@ package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.util.Locale;
import java.util.Set;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.core.env.Environment;
/**
* Prevents Hibernate from becoming a production schema writer. Flyway owns the physical schema;
* production may only disable Hibernate DDL or validate the schema.
*/
public class JpaSchemaSafetyValidator implements SmartInitializingSingleton {
public class JpaSchemaSafetyValidator implements BeanFactoryPostProcessor {
static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto";
static final String DDL_AUTO_ENV_KEY = "APP_DATASOURCE_DDL_AUTO";
@@ -24,8 +26,18 @@ public class JpaSchemaSafetyValidator implements SmartInitializingSingleton {
this.environment = environment;
}
/**
* {@code BeanFactoryPostProcessor} 이지 {@code SmartInitializingSingleton} 아닌 이유: 후자는 모든 싱글턴이
* 만들어진 <em></em> 돈다. {@code entityManagerFactory} 싱글턴 하나이고, Hibernate 그것을 만들면서 {@code
* ddl-auto} 이미 적용한다 실측으로 확인했다: {@code ddl-auto=update} prod 띄우면 로그에 "Initialized JPA
* EntityManagerFactory" 가 먼저, 그 다음에 이 가드의 PROFILE_MISMATCH 가 찍히고, 스키마에는 그 사이에 만들어진 테이블이 남는다.
*
* <p> 가드가 트래픽은 막았지만 스키마 변조는 막고 있었다. 잘못 설정된 배포가 운영 DB 이미 바꿔 놓고 실패하는 셈이라, 검사를 인스턴스화 이전으로
* 옮긴다.
*/
@Override
public void afterSingletonsInstantiated() {
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
throws BeansException {
if (!isProdActive()) {
return;
}
@@ -29,8 +29,12 @@ public class RuntimeSafetyConfig {
return new OpenInViewSafetyValidator(environment);
}
/**
* {@code static} 이어야 한다 {@code BeanFactoryPostProcessor} 다른 빈보다 먼저 만들어지므로, 인스턴스 메서드로 두면 설정
* 클래스 전체가 too-early 초기화되어 경고가 뜬다.
*/
@Bean
JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
static JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
return new JpaSchemaSafetyValidator(environment);
}
@@ -33,6 +33,11 @@ final class ObjectStorageAssetBinaryAdapter implements AssetBinaryStoragePort {
return require().put(objectKey, content, mediaType).key();
}
@Override
public java.util.Optional<byte[]> read(String objectKey) {
return require().get(objectKey);
}
@Override
public void delete(String objectKey) {
require().delete(objectKey);
@@ -0,0 +1,56 @@
package dev.caskeleton.bootstrap.techlog;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
/**
* Studio IdP 역할을 {@code studio:read} / {@code studio:write} 잇는다.
*
* <p> YAML 아니라 여기인가: 프로파일 YAML 매핑을
*
* <pre>
* role-permissions:
* ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
* - studio:write
* </pre>
*
* 적고 있었다. Spring Boot {@code @ConfigurationProperties} <em></em>에서는 플레이스홀더를 풀지만 <em>Map
* </em>에서는 풀지 않는다 키는 리터럴 {@code "${APP_STUDIO_AUTHOR_ROLE:studio-author}"} 바인딩된다. 어떤 실제 역할과도
* 일치하지 않으므로 {@link dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry} 레지스트리가 되고,
* Studio 모든 쓰기가 403 된다. 실측으로 확인했다: {@code APP_STUDIO_AUTHOR_ROLE=studio-author} 명시해도 403, 리터럴
* 키로 바꾸면 즉시 201.
*
* <p>역할 이름은 배포마다 다르므로(계약도 {@code StudioSession.roles} 고정하지 않는다) 코드에 박을 없다. 스칼라 프로퍼티는 플레이스홀더가 정상
* 동작하므로, 여기서 이름을 해석한 리터럴 키로 매핑을 심는다.
*
* <p>{@code addLast} 넣으므로 운영자가 같은 키를 직접 주면 그쪽이 이긴다. {@code META-INF/spring.factories} 등록된다.
*/
public class StudioAuthzEnvironmentPostProcessor implements EnvironmentPostProcessor {
static final String ROLE_KEY = "app.studio.author-role";
static final String DEFAULT_ROLE = "studio-author";
private static final String PREFIX = "ca-skeleton.authz.role-permissions.";
@Override
public void postProcessEnvironment(
ConfigurableEnvironment environment, SpringApplication application) {
String role = resolveRole(environment);
Map<String, Object> mapping = new LinkedHashMap<>();
mapping.put(PREFIX + role + "[0]", "studio:read");
mapping.put(PREFIX + role + "[1]", "studio:write");
environment.getPropertySources().addLast(new MapPropertySource("studioAuthzMapping", mapping));
}
/**
* {@code APP_STUDIO_AUTHOR_ROLE} relaxed binding 으로 {@code app.studio.author-role} 닿는다. 값이 비어
* 있으면 기본값을 쓴다 문자열을 키로 심으면 역할 없는 호출자에게 권한이 붙는다.
*/
private static String resolveRole(ConfigurableEnvironment environment) {
String configured = environment.getProperty(ROLE_KEY);
return (configured == null || configured.isBlank()) ? DEFAULT_ROLE : configured.trim();
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.bootstrap.techlog;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.data.redis.RedisSessionRepository;
/**
* {@code auth-mode=redis-session} 세션 저장소.
*
* <p> 모드는 Studio 계약이 선언한 표면이다 {@code securitySchemes.sessionCookie} 세션 쿠키를, mutation 추가로
* {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF 구성이며, {@code SecurityConfig}
* {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지) {@code RedisSessionWebConfig}(서블릿 세션 필터 +
* host-only 쿠키) 이미 전제로 쓰여 있었다.
*
* <p>빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 모드에서 {@code
* redisVersionedSessionRepository} {@code springSessionRepositoryFilter} <em>이름으로</em> 요구하는데,
* 뒤의 것은 {@code RedisSessionWebConfig} {@code @EnableSpringHttpSession} 이미 등록하고 있었고 앞의 것이 어디에도
* 없었다. 그래서 {@code getStudioSession} 항상 503 이었다.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")
public class StudioSessionInfrastructureConfig {
/**
* 이름이 계약이다 {@code AuthenticationModeCompositionConfig#validate} 문자열을 찾는다. 이름을 바꾸면 부팅이
* "Redis Session repository/filter is incomplete" 실패한다.
*
* <p>{@code @EnableRedisHttpSession} 쓰지 않는 이유도 같다 애노테이션은 이름을 {@code sessionRepository}
* 고정한다.
*/
@Bean
public RedisSessionRepository redisVersionedSessionRepository(
RedisConnectionFactory connectionFactory) {
return new RedisSessionRepository(sessionRedisTemplate(connectionFactory));
}
/**
* 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code PrimitiveSessionSecurityContextRepository} 만든
* 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 직렬화 경계를 넘지 않는다) 직렬화기를 따로 좁힐 필요가 없다.
*/
private static RedisTemplate<String, Object> sessionRedisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
@@ -0,0 +1,188 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import dev.caskeleton.application.techlog.management.port.out.ProjectActivityRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
import dev.caskeleton.application.techlog.management.service.ArchiveReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.CreateProjectActivityUseCase;
import dev.caskeleton.application.techlog.management.service.CreateProjectUseCase;
import dev.caskeleton.application.techlog.management.service.CreateReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteDocumentDraftUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectActivityUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectDecisionUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase;
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectActivityUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 관리 표면(`studio-management-v1.yaml`) use case 배선. 지금은 topics 4개와 projects 5개만 있다 둘이 문서 작성을 막고
* 있던 선행 조건이기 때문이다.
*/
@Configuration(proxyBeanMethods = false)
public class TechLogManagementConfig {
@Bean
ListStudioTopicsUseCase listStudioTopicsUseCase(TopicRepositoryPort topics, TransactionPort tx) {
return new ListStudioTopicsUseCase(topics, tx);
}
@Bean
SaveTopicUseCase saveTopicUseCase(TopicRepositoryPort topics, TransactionPort tx) {
return new SaveTopicUseCase(topics, tx);
}
@Bean
DeleteTopicUseCase deleteTopicUseCase(TopicRepositoryPort topics, TransactionPort tx) {
return new DeleteTopicUseCase(topics, tx);
}
@Bean
ListStudioProjectsUseCase listStudioProjectsUseCase(
ProjectRepositoryPort projects, TransactionPort tx) {
return new ListStudioProjectsUseCase(projects, tx);
}
@Bean
GetProjectForEditUseCase getProjectForEditUseCase(
ProjectRepositoryPort projects, TransactionPort tx) {
return new GetProjectForEditUseCase(projects, tx);
}
@Bean
CreateProjectUseCase createProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
return new CreateProjectUseCase(projects, tx);
}
@Bean
UpdateProjectUseCase updateProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
return new UpdateProjectUseCase(projects, tx);
}
@Bean
DeleteProjectUseCase deleteProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
return new DeleteProjectUseCase(projects, tx);
}
@Bean
ListStudioReleasesUseCase listStudioReleasesUseCase(
ReleaseRepositoryPort releases, TransactionPort tx) {
return new ListStudioReleasesUseCase(releases, tx);
}
@Bean
GetReleaseForEditUseCase getReleaseForEditUseCase(
ReleaseRepositoryPort releases, TransactionPort tx) {
return new GetReleaseForEditUseCase(releases, tx);
}
@Bean
CreateReleaseUseCase createReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new CreateReleaseUseCase(releases, tx);
}
@Bean
UpdateReleaseUseCase updateReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new UpdateReleaseUseCase(releases, tx);
}
@Bean
DeleteReleaseUseCase deleteReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new DeleteReleaseUseCase(releases, tx);
}
@Bean
PublishReleaseUseCase publishReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new PublishReleaseUseCase(releases, tx);
}
@Bean
ArchiveReleaseUseCase archiveReleaseUseCase(ReleaseRepositoryPort releases, TransactionPort tx) {
return new ArchiveReleaseUseCase(releases, tx);
}
@Bean
DeleteDocumentDraftUseCase deleteDocumentDraftUseCase(
DocumentDeletionPort documents, TransactionPort tx) {
return new DeleteDocumentDraftUseCase(documents, tx);
}
@Bean
DeleteQuestionUseCase deleteQuestionUseCase(DocumentDeletionPort documents, TransactionPort tx) {
return new DeleteQuestionUseCase(documents, tx);
}
@Bean
DeleteProjectDecisionUseCase deleteProjectDecisionUseCase(
DocumentDeletionPort documents, TransactionPort tx) {
return new DeleteProjectDecisionUseCase(documents, tx);
}
@Bean
PublishProjectUseCase publishProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
return new PublishProjectUseCase(projects, tx);
}
@Bean
UnpublishProjectUseCase unpublishProjectUseCase(
ProjectRepositoryPort projects, TransactionPort tx) {
return new UnpublishProjectUseCase(projects, tx);
}
@Bean
GetHomeFocusUseCase getHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
return new GetHomeFocusUseCase(config, tx);
}
@Bean
UpdateHomeFocusUseCase updateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
return new UpdateHomeFocusUseCase(config, tx);
}
@Bean
ListStudioProjectActivitiesUseCase listStudioProjectActivitiesUseCase(
ProjectActivityRepositoryPort activities, TransactionPort tx) {
return new ListStudioProjectActivitiesUseCase(activities, tx);
}
@Bean
CreateProjectActivityUseCase createProjectActivityUseCase(
ProjectActivityRepositoryPort activities,
ProjectRepositoryPort projects,
TransactionPort tx) {
return new CreateProjectActivityUseCase(activities, projects, tx);
}
@Bean
UpdateProjectActivityUseCase updateProjectActivityUseCase(
ProjectActivityRepositoryPort activities, TransactionPort tx) {
return new UpdateProjectActivityUseCase(activities, tx);
}
@Bean
DeleteProjectActivityUseCase deleteProjectActivityUseCase(
ProjectActivityRepositoryPort activities, TransactionPort tx) {
return new DeleteProjectActivityUseCase(activities, tx);
}
}
@@ -0,0 +1,149 @@
package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicDocumentQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicExploreQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicProjectQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicReleaseQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSearchQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicSiteQueryPort;
import dev.caskeleton.application.techlog.publicsite.port.out.PublicTopicQueryPort;
import dev.caskeleton.application.techlog.publicsite.service.ExploreKnowledgeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ExploreQuestionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicCaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicHomeUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicMediaUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProfileUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicProjectUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicQuestionUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReferenceUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicReleaseUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicSiteUseCase;
import dev.caskeleton.application.techlog.publicsite.service.GetPublicTopicUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectActivitiesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectDecisionsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectRecordsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicProjectsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicReleasesUseCase;
import dev.caskeleton.application.techlog.publicsite.service.ListPublicTopicsUseCase;
import dev.caskeleton.application.techlog.publicsite.service.SearchPublicResourcesUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Tech Log 공개 조회(public-v1) 조립. application-core Spring 보지 않으므로 여기서 배선한다 {@link
* TechLogStudioConfig} 같은 이유·같은 모양이다.
*
* <p>계약의 18 operation 18 use case 1:1 이다. 배선을 파일에 모아 두면 operation 늘거나 어디를 고쳐야 하는지가
* 곳으로 정해진다.
*/
@Configuration
public class TechLogPublicConfig {
@Bean
GetPublicSiteUseCase getPublicSiteUseCase(PublicSiteQueryPort port, TransactionPort tx) {
return new GetPublicSiteUseCase(port, tx);
}
@Bean
GetPublicHomeUseCase getPublicHomeUseCase(PublicSiteQueryPort port, TransactionPort tx) {
return new GetPublicHomeUseCase(port, tx);
}
@Bean
GetPublicProfileUseCase getPublicProfileUseCase(PublicSiteQueryPort port, TransactionPort tx) {
return new GetPublicProfileUseCase(port, tx);
}
@Bean
ExploreKnowledgeUseCase exploreKnowledgeUseCase(PublicExploreQueryPort port, TransactionPort tx) {
return new ExploreKnowledgeUseCase(port, tx);
}
@Bean
ExploreQuestionsUseCase exploreQuestionsUseCase(PublicExploreQueryPort port, TransactionPort tx) {
return new ExploreQuestionsUseCase(port, tx);
}
@Bean
ListPublicTopicsUseCase listPublicTopicsUseCase(PublicTopicQueryPort port, TransactionPort tx) {
return new ListPublicTopicsUseCase(port, tx);
}
@Bean
GetPublicTopicUseCase getPublicTopicUseCase(PublicTopicQueryPort port, TransactionPort tx) {
return new GetPublicTopicUseCase(port, tx);
}
@Bean
GetPublicCaseUseCase getPublicCaseUseCase(PublicDocumentQueryPort port, TransactionPort tx) {
return new GetPublicCaseUseCase(port, tx);
}
@Bean
GetPublicReferenceUseCase getPublicReferenceUseCase(
PublicDocumentQueryPort port, TransactionPort tx) {
return new GetPublicReferenceUseCase(port, tx);
}
@Bean
GetPublicQuestionUseCase getPublicQuestionUseCase(
PublicDocumentQueryPort port, TransactionPort tx) {
return new GetPublicQuestionUseCase(port, tx);
}
@Bean
ListPublicProjectsUseCase listPublicProjectsUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new ListPublicProjectsUseCase(port, tx);
}
@Bean
GetPublicProjectUseCase getPublicProjectUseCase(PublicProjectQueryPort port, TransactionPort tx) {
return new GetPublicProjectUseCase(port, tx);
}
@Bean
ListPublicProjectDecisionsUseCase listPublicProjectDecisionsUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new ListPublicProjectDecisionsUseCase(port, tx);
}
@Bean
ListPublicProjectRecordsUseCase listPublicProjectRecordsUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new ListPublicProjectRecordsUseCase(port, tx);
}
@Bean
ListPublicProjectActivitiesUseCase listPublicProjectActivitiesUseCase(
PublicProjectQueryPort port, TransactionPort tx) {
return new ListPublicProjectActivitiesUseCase(port, tx);
}
@Bean
ListPublicReleasesUseCase listPublicReleasesUseCase(
PublicReleaseQueryPort port, TransactionPort tx) {
return new ListPublicReleasesUseCase(port, tx);
}
@Bean
GetPublicReleaseUseCase getPublicReleaseUseCase(PublicReleaseQueryPort port, TransactionPort tx) {
return new GetPublicReleaseUseCase(port, tx);
}
@Bean
SearchPublicResourcesUseCase searchPublicResourcesUseCase(
PublicSearchQueryPort port, TransactionPort tx) {
return new SearchPublicResourcesUseCase(port, tx);
}
/** 공개 미디어. Studio 의 Asset 저장소를 읽지만 표면은 공개 쪽이다 — 업로드는 인증이 필요하고 읽기는 누구나 해야 하기 때문이다. */
@Bean
GetPublicMediaUseCase getPublicMediaUseCase(
dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort assets,
dev.caskeleton.application.techlog.studio.port.out.AssetBinaryStoragePort binaries,
dev.caskeleton.application.transaction.TransactionPort transactions) {
return new GetPublicMediaUseCase(assets, binaries, transactions);
}
}
@@ -1,6 +1,7 @@
org.springframework.boot.EnvironmentPostProcessor=\
dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor,\
dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor
dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor,\
dev.caskeleton.bootstrap.techlog.StudioAuthzEnvironmentPostProcessor
org.springframework.boot.SpringBootExceptionReporter=\
dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter
@@ -22,16 +22,9 @@ spring:
ca-skeleton:
persistence:
vendor: postgresql
authz:
role-permissions:
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
#
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
# 흔들지 않고 프로파일에서 더한다.
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- studio:write
# authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
# YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
# 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
@@ -73,16 +73,9 @@ spring:
# 그대로 동작한다.
backend: filesystem
authz:
role-permissions:
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
#
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
# 흔들지 않고 프로파일에서 더한다.
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- studio:write
# authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
# YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
# 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
security:
oauth2:
@@ -161,7 +154,10 @@ ca-skeleton:
security:
issuer-uri: http://localhost:8081/realms/ca-skeleton
audience: ca-skeleton-api
public-paths: /api/healthcheck
# public-v1(공개 조회 계약)은 인증이 없다 — 계약의 security 가 비어 있고 서문이
# "인증이 필요하지 않다"고 명시한다. deny-by-default 기준선을 넓히는 변경이라
# docs/security/public-paths-snapshot.txt 가 함께 갱신되어야 통과한다.
public-paths: /api/healthcheck, /api/v1/public/**
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
# `const`. The template default is X-XSRF-TOKEN (application.yml:498, restated verbatim by
# src/.env:125, the profile src/.env:8 activates) — StudioSessionController's constructor
@@ -26,16 +26,9 @@ spring:
ca-skeleton:
persistence:
vendor: postgresql
authz:
role-permissions:
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
#
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
# 흔들지 않고 프로파일에서 더한다.
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- studio:write
# authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
# YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
# 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
@@ -173,6 +173,14 @@ spring:
# true | false (Java 21 virtual threads for Tomcat request handlers)
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
servlet:
session:
# Studio 작성자는 한 기록을 여러 번 저장하며 오래 머문다. Spring 기본 30분 유휴 만료는 그
# 리듬보다 짧아 작성 도중 로그인 화면으로 돌아가는 일이 잦았다 — 저장하지 않은 편집이
# 있으면 그 시점에 잃는다.
#
# 값은 배포가 정한다. 늘릴수록 훔친 session cookie가 유효한 창도 같이 늘어나므로,
# Keycloak realm 의 SSO idle 과 따로 놀지 않게 함께 맞춘다.
timeout: ${APP_SESSION_TIMEOUT:8h}
multipart:
# feature-api-contract-baseline D8: bound request body size so an oversized
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
@@ -2158,4 +2158,67 @@ class CleanArchitectureTest {
.filter(value -> value instanceof Number number && number.intValue() == SqlTypes.UUID)
.isPresent();
}
/**
* Spring 생성자를 고를 있어야 한다.
*
* <p> 규칙은 사고 하나에서 나왔다. 어댑터가 생성자를 갖고 있었고 하나는 운영용, 하나는 테스트가 id 생성기를 넣기 위한 어느 것에도
* {@code @Autowired} 없었다. 컴포넌트 스캔은 고르지 못하고 기본 생성자를 찾다가 실패한다. 컴파일도, 단위 테스트도, 실제 PostgreSQL 위에서
* 도는 통합 테스트도 전부 통과했다. 어느 것도 컨텍스트를 띄우지 않기 때문이다. 운영에서 기동만 했다.
*
* <p>그래서 여기서 막는다: 스캔되는 스프링 컴포넌트는 생성자가 하나이거나, 여럿이면 그중 하나에 {@code @Autowired} 붙어 있어야 한다.
*/
@ArchTest
static final ArchRule SPRING_COMPONENTS_HAVE_AN_UNAMBIGUOUS_CONSTRUCTOR =
classes()
.that()
.areAnnotatedWith("org.springframework.stereotype.Repository")
.or()
.areAnnotatedWith("org.springframework.stereotype.Service")
.or()
.areAnnotatedWith("org.springframework.stereotype.Component")
.or()
.areAnnotatedWith("org.springframework.web.bind.annotation.RestController")
.should(haveAConstructorSpringCanChoose())
.as(
"D20: a scanned Spring component must have exactly one constructor, or mark one"
+ " @Autowired — otherwise component scan falls back to a no-arg constructor that"
+ " does not exist and the application fails to start");
private static ArchCondition<JavaClass> haveAConstructorSpringCanChoose() {
return new ArchCondition<>("have a constructor Spring can choose") {
@Override
public void check(JavaClass item, ConditionEvents events) {
var constructors =
item.getConstructors().stream()
.filter(constructor -> !constructor.getModifiers().contains(JavaModifier.SYNTHETIC))
.toList();
if (constructors.size() <= 1) {
return;
}
boolean autowired =
constructors.stream()
.anyMatch(
constructor ->
constructor.getAnnotations().stream()
.anyMatch(
annotation ->
annotation
.getRawType()
.getName()
.equals(
"org.springframework.beans.factory.annotation.Autowired")));
if (!autowired) {
events.add(
SimpleConditionEvent.violated(
item,
item.getName()
+ " declares "
+ constructors.size()
+ " constructors and marks none @Autowired;"
+ " component scan cannot choose one"));
}
}
};
}
}
@@ -0,0 +1,110 @@
package dev.caskeleton.bootstrap.architecture;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementClientSafeMessages;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import dev.caskeleton.shared.error.OperationalError;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/**
* {@code PublicErrorRegistryTest} {@code PublicError} 대해 하는 일을 {@link ManagementError} 대해 한다
* row 존재 / 드리프트 / client-safe 문구, 그리고 계약 code 집합 대조.
*
* <p>계약은 12종을 열거하는데 enum 11종이다. 나머지 {@code INTERNAL_ERROR} 스켈레톤 공통 처리기가 소유하며({@link
* OperationalError#INTERNAL_ERROR}) 여기서 재선언하지 않는다 그쪽은 {@code retryable=true} 같은 code 곳에서
* 선언하면 레지스트리가 어느 값을 따라야 할지 없다.
*/
class ManagementErrorRegistryTest {
private static final String SKELETON_OWNED_CODE = OperationalError.INTERNAL_ERROR.code();
private static Map<String, Map<String, Object>> registryRowsByCode;
private static Set<String> contractCodes;
@BeforeAll
@SuppressWarnings("unchecked")
static void load() throws Exception {
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
Path registry = resources.requireTrackedFile("docs/registries/error-codes.yaml");
registryRowsByCode = new LinkedHashMap<>();
try (InputStream in = Files.newInputStream(registry)) {
Map<String, Object> root = new Yaml().load(in);
for (Map<String, Object> row : (List<Map<String, Object>>) root.get("errors")) {
registryRowsByCode.put((String) row.get("code"), row);
}
}
Path contract = resources.requireTrackedFile("src/config/openapi/studio-management-v1.yaml");
try (InputStream in = Files.newInputStream(contract)) {
Map<String, Object> doc = new Yaml().load(in);
Map<String, Object> components = (Map<String, Object>) doc.get("components");
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
Map<String, Object> apiError = (Map<String, Object>) schemas.get("ApiError");
Map<String, Object> properties = (Map<String, Object>) apiError.get("properties");
Map<String, Object> code = (Map<String, Object>) properties.get("code");
contractCodes = new TreeSet<>((List<String>) code.get("enum"));
}
}
@Test
void everyManagementErrorHasARegistryRow() {
Set<String> declared =
Arrays.stream(ManagementError.values())
.map(ManagementError::code)
.collect(Collectors.toSet());
assertThat(registryRowsByCode.keySet()).containsAll(declared);
}
@Test
void everyRowMatchesCategoryHttpStatusAndRetryable() {
for (ManagementError error : ManagementError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(row.get("category"))
.as("category of %s", error.code())
.isEqualTo(error.category().name());
assertThat(((Number) row.get("http_status")).intValue())
.as("http_status of %s", error.code())
.isEqualTo(error.httpStatus());
assertThat(row.get("retryable"))
.as("retryable of %s", error.code())
.isEqualTo(error.retryable());
}
}
/** 계약이 열거하는 code = enum 이 소유하는 code ∪ 스켈레톤 소유 code. 어느 쪽이 늘어도 여기서 걸린다. */
@Test
void contractCodeSetMatchesTheEnumPlusTheSkeletonOwnedCode() {
Set<String> owned =
Arrays.stream(ManagementError.values())
.map(ManagementError::code)
.collect(Collectors.toCollection(TreeSet::new));
owned.add(SKELETON_OWNED_CODE);
assertThat(contractCodes).isEqualTo(owned);
}
/** 모든 code 가 문구를 갖는다. switch 가 전부를 덮지 않으면 컴파일이 막지만, 빈 문구는 막지 못한다. */
@Test
void everyErrorHasANonBlankClientSafeMessage() {
for (ManagementError error : ManagementError.values()) {
assertThat(ManagementClientSafeMessages.forError(error))
.as("client-safe message for %s", error.code())
.isNotBlank();
}
}
}
@@ -0,0 +1,172 @@
package dev.caskeleton.bootstrap.architecture;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.techlog.publicapi.PublicClientSafeMessages;
import dev.caskeleton.application.techlog.publicsite.error.PublicError;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import dev.caskeleton.shared.error.OperationalError;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/**
* {@code StudioErrorRegistryTest} {@link dev.caskeleton.application.techlog.error.StudioError}
* 대해 하는 일을 {@link PublicError} 대해 한다 같은 (row 존재 / 드리프트 / client-safe 문구) 계약 code 집합 대조와
* vendored 계약 해시까지.
*
* <p>계약의 {@code ApiError.code} enum 값인데 {@link PublicError} 둘뿐이다. 나머지 하나 {@code
* INTERNAL_ERROR} 기능이 아니라 스켈레톤 공통 처리기가 내는 코드({@link OperationalError#INTERNAL_ERROR}) 이며, 같은
* code enum 각자 status 함께 선언하면 레지스트리가 어느 쪽을 따라야 할지 없어 일부러 재선언하지 않았다. 그래서 code 집합 대조는
* "정확히 일치" 아니라 "계약 = public 소유 {@code INTERNAL_ERROR}" 고정한다 어느 쪽에 code 생기든 테스트가 먼저
* 빨간불이 된다.
*/
class PublicErrorRegistryTest {
/** 계약이 열거하지만 이 기능이 소유하지 않는 code. 근거는 클래스 javadoc. */
private static final String SKELETON_OWNED_CODE = OperationalError.INTERNAL_ERROR.code();
private static Map<String, Map<String, Object>> registryRowsByCode;
@BeforeAll
@SuppressWarnings("unchecked")
static void loadRegistry() throws Exception {
Path registry =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("docs/registries/error-codes.yaml");
registryRowsByCode = new LinkedHashMap<>();
try (InputStream in = Files.newInputStream(registry)) {
Map<String, Object> root = new Yaml().load(in);
List<Map<String, Object>> errors = (List<Map<String, Object>>) root.get("errors");
for (Map<String, Object> row : errors) {
registryRowsByCode.put((String) row.get("code"), row);
}
}
}
@Test
void everyPublicErrorHasARegistryRow() {
Set<String> declared =
Arrays.stream(PublicError.values()).map(PublicError::code).collect(Collectors.toSet());
assertThat(registryRowsByCode.keySet()).containsAll(declared);
}
@Test
void everyPublicErrorRowMatchesCategoryHttpStatusAndRetryable() {
for (PublicError error : PublicError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(row.get("category"))
.as("category for %s", error.code())
.isEqualTo(error.category().name());
assertThat(((Number) row.get("http_status")).intValue())
.as("http_status for %s", error.code())
.isEqualTo(error.httpStatus());
assertThat(row.get("retryable"))
.as("retryable for %s", error.code())
.isEqualTo(error.retryable());
}
}
@Test
void everyPublicErrorClientSafeMessageMatchesRegistry() {
for (PublicError error : PublicError.values()) {
Map<String, Object> row = registryRowsByCode.get(error.code());
assertThat(row).as("registry row for %s", error.code()).isNotNull();
assertThat(PublicClientSafeMessages.forError(error))
.as("client_safe_message for %s", error.code())
.isEqualTo(row.get("client_safe_message"));
}
}
@Test
void enumPlusTheSkeletonOwnedCodeMatchesTheContractCodeSet() throws Exception {
Path contract =
RepositoryContractResources.fromSystemProperty()
.requireTrackedFile("src/config/openapi/public-v1.yaml");
Set<String> contractCodes = contractApiErrorCodes(contract);
Set<String> enumCodes =
Arrays.stream(PublicError.values()).map(PublicError::code).collect(Collectors.toSet());
assertThat(contractCodes)
.as("public-v1.yaml ApiError.code enum vs PublicError + %s", SKELETON_OWNED_CODE)
.containsExactlyInAnyOrderElementsOf(
java.util.stream.Stream.concat(
enumCodes.stream(), java.util.stream.Stream.of(SKELETON_OWNED_CODE))
.collect(Collectors.toSet()));
assertThat(enumCodes)
.as("PublicError 는 스켈레톤 소유 code 를 재선언하지 않는다")
.doesNotContain(SKELETON_OWNED_CODE);
}
@SuppressWarnings("unchecked")
private static Set<String> contractApiErrorCodes(Path contract) throws IOException {
try (InputStream in = Files.newInputStream(contract)) {
Map<String, Object> root = new Yaml().load(in);
Map<String, Object> components = (Map<String, Object>) root.get("components");
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
Map<String, Object> apiError = (Map<String, Object>) schemas.get("ApiError");
Map<String, Object> properties = (Map<String, Object>) apiError.get("properties");
Map<String, Object> code = (Map<String, Object>) properties.get("code");
List<String> enumValues = (List<String>) code.get("enum");
return Set.copyOf(enumValues);
}
}
/**
* {@code src/config/openapi/public-v1.yaml} 설계 패키지 계약의 vendored 사본이다({@code MANIFEST.sha256}
* {@code # source:} 줄이 출처를 기록한다). 단언이 없으면 vendor 사본을 손으로 고쳐도 아무도 알아채지 못한다 studio 쪽과 같은 이유의 같은
* 게이트다.
*/
@Test
void vendoredContractMatchesTheRecordedManifestHash() throws Exception {
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
Path contract = resources.requireTrackedFile("src/config/openapi/public-v1.yaml");
Path manifest = resources.requireTrackedFile("src/config/openapi/MANIFEST.sha256");
assertThat(sha256Hex(contract))
.as(
"src/config/openapi/public-v1.yaml sha256 must match the value MANIFEST.sha256 recorded"
+ " for it (local edit or vendoring drift)")
.isEqualTo(recordedSha256(manifest, "public-v1.yaml"));
}
private static String recordedSha256(Path manifest, String filename) throws IOException {
return Files.readAllLines(manifest).stream()
.map(String::strip)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.filter(line -> line.endsWith(filename))
.map(line -> line.substring(0, line.indexOf(' ')).strip())
.findFirst()
.orElseThrow(
() ->
new IllegalStateException(
"MANIFEST.sha256 has no hash row for " + filename + ": " + manifest));
}
private static String sha256Hex(Path file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(Files.readAllBytes(file));
StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
}
@@ -11,6 +11,8 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration;
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
@@ -117,13 +119,21 @@ class ActuatorSecurityHttpTest {
.andExpect(status().isForbidden());
}
/*
* Redis 함께 제외한다. 빼지 않으면 reactive Redis health indicator 슬라이스에 그대로
* 올라와 localhost:6379 붙으려 하고, 실패하면 /actuator/health 503 된다. 그러면
* 테스트는 "probe 가 permitAll 인가" 아니라 "이 기계에 Redis 가 떠 있는가" 재게 된다
* 보안 태세와 아무 상관 없는 이유로 빨개진다.
*/
@EnableAutoConfiguration(
exclude = {
DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
HibernateJpaAutoConfiguration.class,
DataJpaRepositoriesAutoConfiguration.class,
FlywayAutoConfiguration.class
FlywayAutoConfiguration.class,
DataRedisAutoConfiguration.class,
DataRedisReactiveAutoConfiguration.class
})
@Import(ManagementSecurityConfig.class)
static class MinimalActuatorApp {}
@@ -0,0 +1,88 @@
package dev.caskeleton.bootstrap.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.core.ResolvableType;
import org.springframework.core.env.MapPropertySource;
import org.springframework.mock.env.MockEnvironment;
/**
* 후처리기의 산출물은 프로퍼티가 아니라 <em>바인딩 결과</em>. 프로퍼티가 environment 들어갔는지만 보면 통과하면서도 실제 앱에서는 매핑이 죽는 경우가
* 있다 application.yml {@code role-permissions: {}} 같은 이름을 이미 선언하고 있고, 소스가 {@code addLast} 보다
* 우선순위가 높기 때문이다. 그래서 여기서는 {@code Map<String, List<String>>} 실제 바인딩해서 확인한다.
*/
class StudioAuthzEnvironmentPostProcessorTest {
private final StudioAuthzEnvironmentPostProcessor epp = new StudioAuthzEnvironmentPostProcessor();
private static final Bindable<Map<String, List<String>>> ROLE_PERMISSIONS =
Bindable.of(
ResolvableType.forClassWithGenerics(
Map.class,
ResolvableType.forClass(String.class),
ResolvableType.forClassWithGenerics(List.class, String.class)));
private static Map<String, List<String>> bind(MockEnvironment env) {
return Binder.get(env)
.bind("ca-skeleton.authz.role-permissions", ROLE_PERMISSIONS)
.orElse(Map.of());
}
/**
* 앱이 실제로 바인딩하는 대상은 {@code Map} 아니라 {@code RolePermissionPolicy} 레코드다(생성자 바인딩). 맵으로만 확인하면 레코드
* 경로에서만 나타나는 차이를 놓친다.
*/
@Test
void bindsThroughTheRecordTheApplicationActuallyUses() {
MockEnvironment env = new MockEnvironment();
env.setProperty("app.studio.author-role", "site-admin");
env.getPropertySources()
.addLast(
new MapPropertySource(
"applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", "")));
epp.postProcessEnvironment(env, new SpringApplication());
Map<String, List<String>> bound =
Binder.get(env)
.bind("ca-skeleton.authz", Bindable.of(RolePermissionPolicy.class))
.map(RolePermissionPolicy::rolePermissions)
.orElse(Map.of());
assertThat(bound).containsEntry("site-admin", List.of("studio:read", "studio:write"));
}
@Test
void grantsBothReadAndWriteToTheConfiguredRole() {
MockEnvironment env = new MockEnvironment();
env.setProperty("app.studio.author-role", "site-admin");
epp.postProcessEnvironment(env, new SpringApplication());
assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write"));
}
/**
* application.yml 선언하는 맵을 재현한다. 이것이 매핑을 가리면 Studio 모든 쓰기가 403 된다 읽기는 통과하는데 쓰기만 막히는,
* 진단하기 어려운 모양으로 나타난다.
*/
@Test
void survivesAnEmptyMapDeclaredByTheApplicationDefaults() {
MockEnvironment env = new MockEnvironment();
env.setProperty("app.studio.author-role", "site-admin");
env.getPropertySources()
.addLast(
new MapPropertySource(
"applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", "")));
epp.postProcessEnvironment(env, new SpringApplication());
assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write"));
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.bootstrap.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter;
import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy;
import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry;
import dev.caskeleton.application.security.AuthorizationPort;
import dev.caskeleton.shared.security.Permission;
import java.util.Set;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.jupiter.api.Test;
import org.springframework.aop.Advisor;
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
/**
* 후처리기가 심은 매핑이 <em>실행 중인 컨텍스트에서도</em> 살아 있는지 본다.
*
* <p>바인딩만 따로 확인하면 통과하지만 앱에서는 죽는 경우가 있어서다. {@code MethodSecurityConfig} advisor auto-proxy 보다 먼저
* 만들어져야 하는 인프라 빈인데 {@link AuthorizationPort} 생성자 파라미터로 받는다. 그래서 {@code AuthorizationAdapter
* RolePermissionRegistry RolePermissionPolicy} BeanPostProcessor 등록이 끝나기 전에 끌려 올라온다 운영 로그가
* 빈에 대해 "not eligible for getting processed by all BeanPostProcessors" 정확히 그렇게 찍고 있다.
*/
class StudioAuthzWiringTest {
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RolePermissionPolicy.class)
static class Wiring {
@Bean
RolePermissionRegistry rolePermissionRegistry(RolePermissionPolicy policy) {
return new RolePermissionRegistry(policy);
}
@Bean
AuthorizationPort authorizationAdapter(RolePermissionRegistry registry) {
return new AuthorizationAdapter(registry);
}
/** MethodSecurityConfig 와 같은 모양: 인프라 advisor 가 포트를 직접 받는다. */
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) {
AuthorizationManager<MethodInvocation> manager = (authentication, invocation) -> null;
return new AuthorizationManagerBeforeMethodInterceptor(
AnnotationMatchingPointcut.forMethodAnnotation(Deprecated.class), manager);
}
}
@Test
void theConfiguredRoleKeepsBothPermissionsInsideARunningContext() {
MockEnvironment env = new MockEnvironment();
env.setProperty("app.studio.author-role", "site-admin");
new StudioAuthzEnvironmentPostProcessor().postProcessEnvironment(env, new SpringApplication());
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.setEnvironment(env);
context.register(Wiring.class);
context.refresh();
RolePermissionRegistry registry = context.getBean(RolePermissionRegistry.class);
assertThat(registry.effectivePermissions(Set.of("site-admin")))
.contains(Permission.parse("studio:read"), Permission.parse("studio:write"));
}
}
}
@@ -105,7 +105,11 @@ public final class IdempotencyExecutor {
store.complete(scope, new StoredResponse(codec.serialize(result)));
return result;
} catch (RuntimeException e) {
store.discard(scope);
try {
store.discard(scope);
} catch (RuntimeException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
throw e;
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.application.techlog.error;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
/**
* 관리 계약(`studio-management-v1.yaml`) `ApiError.code` enum. 계약과 1:1이며 여기서 코드를 늘리거나 줄이면 계약과
* `docs/registries/error-codes.yaml` 함께 고쳐야 한다.
*
* <p>{@link StudioError} 합치지 않는다. 계약이 각자의 code 집합을 열거하고 있고, 한쪽에만 있는 코드를 다른 응답으로 있게 되면
* 순간 계약 모두 거짓이 된다.
*
* <p>계약의 {@code ApiError.code} 22종인데 여기는 21종이다. 나머지 하나 {@code INTERNAL_ERROR} 기능이 아니라 스켈레톤 공통
* 처리기가 내는 코드({@code OperationalError.INTERNAL_ERROR}) 이고, 같은 code enum 각자 status retryable
* 달고 선언하면 레지스트리가 어느 쪽을 따라야 할지 없다 실제로 그쪽은 {@code retryable=true} . {@code PublicError} 같은
* 이유로 같은 선택을 했다.
*/
public enum ManagementError implements ApiErrorCode {
AUTHENTICATION_REQUIRED(Category.AUTH, 401, false),
STUDIO_ACCESS_DENIED(Category.AUTHZ, 403, false),
REQUEST_VALIDATION_FAILED(Category.VALIDATION, 422, false),
VERSION_CONFLICT(Category.CONFLICT, 409, false),
TOPIC_NOT_FOUND(Category.NOT_FOUND, 404, false),
TOPIC_NAME_TAKEN(Category.CONFLICT, 409, false),
TOPIC_SLUG_TAKEN(Category.CONFLICT, 409, false),
TOPIC_IN_USE(Category.CONFLICT, 409, false),
PROJECT_NOT_FOUND(Category.NOT_FOUND, 404, false),
PROJECT_SLUG_TAKEN(Category.CONFLICT, 409, false),
PROJECT_IN_USE(Category.CONFLICT, 409, false),
RELEASE_NOT_FOUND(Category.NOT_FOUND, 404, false),
RELEASE_VERSION_TAKEN(Category.CONFLICT, 409, false),
RELEASE_NOT_PUBLISHABLE(Category.CONFLICT, 409, false),
DOCUMENT_NOT_FOUND(Category.NOT_FOUND, 404, false),
DOCUMENT_PUBLISHED(Category.CONFLICT, 409, false),
DOCUMENT_IN_USE(Category.CONFLICT, 409, false),
QUESTION_NOT_FOUND(Category.NOT_FOUND, 404, false),
QUESTION_IN_USE(Category.CONFLICT, 409, false),
DECISION_NOT_FOUND(Category.NOT_FOUND, 404, false),
DECISION_IN_USE(Category.CONFLICT, 409, false);
private final Category category;
private final int httpStatus;
private final boolean retryable;
ManagementError(Category category, int httpStatus, boolean retryable) {
this.category = category;
this.httpStatus = httpStatus;
this.retryable = retryable;
}
@Override
public String code() {
return name();
}
@Override
public Category category() {
return category;
}
@Override
public int httpStatus() {
return httpStatus;
}
@Override
public boolean retryable() {
return retryable;
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.application.techlog.error;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
/**
* 관리 use case 던지는 유일한 실패 표현. {@link StudioException} 같은 모양이되 code 집합만 다르다 전송 계층은 {@link
* ApiErrorCarrier} 보므로 예외를 따로 처리할 필요가 없다.
*/
public final class ManagementException extends RuntimeException implements ApiErrorCarrier {
private final transient ManagementError error;
private final transient Object details;
private ManagementException(ManagementError error, String message, Object details) {
super(message);
this.error = error;
this.details = details;
}
public static ManagementException of(ManagementError error, String message) {
return new ManagementException(error, message, null);
}
public static ManagementException withDetails(
ManagementError error, String message, Object details) {
return new ManagementException(error, message, details);
}
@Override
public ApiErrorCode errorCode() {
return error;
}
public ManagementError managementError() {
return error;
}
public Object details() {
return details;
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.techlog.management.command;
import java.time.Instant;
import java.util.UUID;
/**
* {@code createProjectActivity}. {@code origin} 명령이 정하지 않는다 경로로 들어온 것은 언제나 {@code MANUAL} 이다.
*/
public record CreateProjectActivityCommand(
UUID projectId,
long expectedProjectVersion,
String activityType,
String title,
String summary,
String visibility,
String relatedResourceType,
UUID relatedResourceId,
Instant occurredAt,
String actor) {}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.management.command;
/** 계약 {@code CreateDraftRequest} — 제목 하나로 초안을 연다. 나머지 필드는 열린 뒤 편집으로 채운다. */
public record CreateProjectCommand(String title, String actor) {}
@@ -0,0 +1,4 @@
package dev.caskeleton.application.techlog.management.command;
/** 계약 {@code CreateDraftRequest}. 제목 하나로 초안을 연다. */
public record CreateReleaseCommand(String title, String actor) {}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
/**
* 계약 {@code ExpectedVersionRequest}. Case / Reference / Question 삭제가 모두 같은 모양이라 하나로 둔다 대상 테이블만
* 다르고 요청은 "이 버전의 이 작업본을 지운다" 동일하다.
*/
public record DeleteDocumentCommand(UUID id, long expectedVersion, String actor) {}
@@ -0,0 +1,5 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
public record DeleteProjectCommand(UUID id, long expectedVersion, String actor) {}
@@ -0,0 +1,5 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
public record DeleteTopicCommand(UUID id, long expectedVersion, String actor) {}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
/**
* 계약 {@code ExpectedVersionRequest}. delete / publish / archive 같은 모양이라 하나로 둔다 "이 버전의 이
* 릴리즈에 상태 전이를 건다"는 같은 요청이고, 세 개의 동일한 record 를 두면 셋이 갈라졌을 때 어느 것이 옳은지 알 수 없다.
*/
public record ReleaseLifecycleCommand(UUID id, long expectedVersion, String actor) {}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
/**
* 생성과 수정이 같은 명령을 쓴다. 계약이 경우 모두 {@code TopicEdit} 본문으로 받기 때문이고, 구분은 {@code id} 유무다 {@code
* null} 이면 생성이다.
*
* <p>{@code expectedVersion} 수정에서만 의미가 있다. 생성에 값이 와도 무시하는 대신 거절하지 않는 이유는, 계약이 필드를 optional 두고
* 있어 클라이언트가 보내는 것이 위반이 아니기 때문이다.
*/
public record SaveTopicCommand(
UUID id,
String name,
String slug,
String description,
String scope,
String status,
Long expectedVersion,
String actor) {}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
/**
* {@code updateHomeFocus}. 슬롯은 전부 nullable 이고, null "이 슬롯을 비운다" 뜻이다 부분 수정이 아니라 전체 교체다.
*
* <p>부분 수정으로 두면 "비우기" 표현할 방법이 없어진다. 화면이 칸을 번에 보여 주고 번에 저장하므로 전체 교체가 화면과도 맞다.
*/
public record UpdateHomeFocusCommand(
long expectedVersion,
String defaultType,
UUID currentProjectId,
UUID openQuestionId,
UUID recentDecisionId,
String actor) {}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.techlog.management.command;
import java.time.Instant;
import java.util.UUID;
/**
* {@code updateProjectActivity}.
*
* <p>{@code activityType} 관련 자원은 고칠 없다 그것을 바꾸면 같은 줄이 다른 사건을 가리키게 된다. 바꿀 있는 것은 사람이 읽는
* 부분(제목·요약) 노출 여부, 그리고 언제 일어난 일인지다.
*/
public record UpdateProjectActivityCommand(
UUID projectId,
UUID activityId,
long expectedVersion,
String title,
String summary,
String visibility,
Instant occurredAt,
String actor) {}

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