diff --git a/README.md b/README.md
index 833dfe36..f0bae2c1 100644
--- a/README.md
+++ b/README.md
@@ -9,20 +9,20 @@ Docsa는 문서의 변경 이력과 다양한 버전을 효율적으로 관리
이 프로젝트의 목표는 Git을 모르는 사용자도 손쉽게 버전 관리를 활용하여 문서를 편집하고 운용할 수 있도록 돕는 것입니다.
사용자는 그래프로 구현된 시각적인 UI를 통해 문서를 직접 편집하고, 다양한 버전 흐름을 한눈에 확인하며 관리할 수 있습니다.
-Docsa는 문서의 변경 사항을 기록(commit) 단위로 추적하고, 버전(branch) 를 분기하거나, 서로 다른 버전을 병합(merge) 할 수 있는 강력한 기능을 제공합니다.
+Docsa는 문서의 변경 사항을 기록(commit) 단위로 추적하고, 브랜치(branch)를 생성하거나, 서로 다른 브랜치를 병합(merge) 할 수 있는 강력한 기능을 제공합니다.
이러한 기능은 editor.js 기반의 블록 단위 저장 방식을 통해 구현되며, 변경된 블록만을 감지하여 데이터베이스에 저장하고 이를 조합하여 기록함으로써 저장 효율성과 자원 활용도를 극대화합니다.
## 📌 주요 기능
>*Git의 개념과 용어를 모르는 일반인 유저를 위해, Docsa에서는 일반적인 Git의 개념과 대응되는 용어를 다음과 같이 재정의합니다.
->(기록: commit, 버전: branch, 병합하기: merge)
+>(기록: commit, 브랜치: branch, 병합하기: merge)
-### 문서 버전 관리
+### 문서 브랜치 관리
-- 문서는 기록,저장과 버전을 가진 최상위 단위입니다. 문서마다 모든 변경 사항을 기록 단위로 저장하여, 원하는 시점의 기록을 조회 (checkout) 할 수 있으며, 메인화면에서 생성과 삭제가 가능합니다.
+- 문서는 기록, 저장과 브랜치를 가진 최상위 단위입니다. 문서마다 모든 변경 사항을 기록 단위로 저장하여, 원하는 시점의 기록을 조회(checkout) 할 수 있으며, 메인화면에서 생성과 삭제가 가능합니다.
-### 버전(branch) 분기 및 병합
+### 브랜치(branch) 생성 및 병합
-- 하나의 문서에서 여러 버전을 생성하여 자유롭게 분기해나갈 수 있으며, 브랜치 간 병합을 통해 작업 내용을 통합할 수 있습니다. main버전 이외 버전 단위의 삭제도 가능합니다.
+- 하나의 문서에서 여러 브랜치를 생성하여 자유롭게 작업을 분기할 수 있으며, 브랜치 간 병합을 통해 작업 내용을 통합할 수 있습니다. main 브랜치 이외 브랜치 단위의 삭제도 가능합니다.
### 저장(Save) 및 기록(Commit) 시스템
@@ -32,9 +32,9 @@ Docsa는 문서의 변경 사항을 기록(commit) 단위로 추적하고, 버
- ‘비교하기’를 통해 현재 보고있는 기록과 다른 기록을 문단 단위로 비교할 수 있습니다.
-### 시각화된 버전 그래프 UI
+### 시각화된 브랜치 그래프 UI
-- 기록과 저장,브랜치 간의 관계를 트리 구조 그래프로 시각화하여, 문서의 버전 흐름을 직관적으로 확인할 수 있습니다.
+- 기록과 저장, 브랜치 간의 관계를 트리 구조 그래프로 시각화하여, 문서의 브랜치 흐름을 직관적으로 확인할 수 있습니다.
### 실시간 문서 편집
@@ -156,4 +156,3 @@ Docsa는 문서의 변경 사항을 기록(commit) 단위로 추적하고, 버
-
diff --git a/build.gradle b/build.gradle
index a5e9281f..f9ce7be2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -45,24 +45,22 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
- implementation 'org.springframework.retry:spring-retry'
- implementation 'org.springframework:spring-aspects'
-
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
compileOnly 'org.projectlombok:lombok'
- developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
- testImplementation 'org.awaitility:awaitility:4.2.0'
implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.12.1'
+ implementation 'org.flywaydb:flyway-core'
+ implementation 'org.flywaydb:flyway-mysql'
+
implementation platform('software.amazon.awssdk:bom:2.42.35')
implementation 'software.amazon.awssdk:s3'
diff --git a/infra/.env.example b/infra/.env.example
index fb66b2f1..dd100af7 100644
--- a/infra/.env.example
+++ b/infra/.env.example
@@ -30,3 +30,16 @@ MYSQL_ROOT_PASSWORD=
# ------------ GRAFANA ------------
GRAFANA_ADMIN=
GRAFANA_PASSWORD=
+
+# ------------ AWS ------------
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+
+AWS_BUCKET=
+AWS_PRESIGNED_EXPIRE_MIN=
+AWS_REGION=
+CDN_URL=
+
+# ------------ FLYWAY ------------
+FLYWAY_BASELINE_ON_MIGRATE=
+FLYWAY_BASELINE_VERSION=
\ No newline at end of file
diff --git a/infra/docker-compose.stg.yml b/infra/docker-compose.stg.yml
index 66cff9bd..5c27d46b 100644
--- a/infra/docker-compose.stg.yml
+++ b/infra/docker-compose.stg.yml
@@ -136,6 +136,12 @@ services:
image: prom/prometheus:latest
container_name: prometheus-stg
restart: unless-stopped
+ command:
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ - "--web.enable-remote-write-receiver"
+ ports:
+ - "${PROMETHEUS_BIND_ADDR:-127.0.0.1}:9090:9090"
depends_on:
- cadvisor
volumes:
diff --git a/perf/delete/README.md b/perf/delete/README.md
index fc0c9ef7..bcd8945c 100644
--- a/perf/delete/README.md
+++ b/perf/delete/README.md
@@ -8,8 +8,8 @@
## Files
-- `perf/delete/seed_dataset.js`
- - 유저별 문서/커밋/브랜치 데이터 생성
+- `perf/seed/seed_dataset.js`
+ - 유저별 문서/커밋/브랜치 데이터 생성. delete/read/thumbnail E2E에서 공용으로 사용
- `perf/delete/delete_only_benchmark.js`
- 생성된 문서를 찾아 삭제만 수행
- `perf/delete/compare_delete_summary.mjs`
@@ -69,7 +69,7 @@ USER_PREFIX=perfuser USER_DOMAIN=test.com USER_PASSWORD=Testtest1 \
USER_COUNT=100 DOCS_PER_USER=3 \
MAIN_COMMITS=6 FEATURE_COMMITS=4 BLOCKS_PER_COMMIT=20 \
SEED_VUS=20 \
-k6 run perf/delete/seed_dataset.js
+k6 run perf/seed/seed_dataset.js
```
생성 규칙:
@@ -103,7 +103,7 @@ k6 run perf/delete/delete_only_benchmark.js \
1. 브랜치 checkout
2. 서버 실행
3. 유저 준비(`PERF_SEED_USER_COUNT`, `PERF_SEED_DOCS_PER_USER=0` 적용된 상태)
-4. 데이터 생성 (`seed_dataset.js`)
+4. 데이터 생성 (`perf/seed/seed_dataset.js`)
5. 삭제 벤치 실행 (`delete_only_benchmark.js`)
6. 결과 파일 저장
diff --git a/perf/read/README.md b/perf/read/README.md
index 794e751b..e635a21b 100644
--- a/perf/read/README.md
+++ b/perf/read/README.md
@@ -8,7 +8,7 @@
## Files
-- `perf/delete/seed_dataset.js`
+- `perf/seed/seed_dataset.js`
- 목록 테스트에 사용할 문서/브랜치/커밋 데이터를 생성
- `perf/read/doc_list_benchmark.js`
- `sidebar`, `full list`, `search` 읽기 성능 측정
@@ -35,7 +35,7 @@ PERF_SEED_DOCS_PER_USER=0
## 2) Seed dataset
-삭제 벤치에서 쓰던 seed 스크립트를 그대로 재사용합니다.
+공용 seed 스크립트를 사용합니다.
중요한 건 목록 조회 시 문서별 branch/commit이 충분히 많아지도록 데이터를 만드는 것입니다.
```bash
@@ -45,7 +45,7 @@ USER_PREFIX=perfuser USER_DOMAIN=test.com USER_PASSWORD=Testtest1 \
USER_COUNT=50 DOCS_PER_USER=5 \
MAIN_COMMITS=8 FEATURE_COMMITS=5 BLOCKS_PER_COMMIT=100 \
SEED_VUS=20 \
-k6 run perf/delete/seed_dataset.js
+k6 run perf/seed/seed_dataset.js
```
애플리케이션 initializer로 DB에 직접 주입할 수도 있습니다. 이 방식은 API 호출 비용 없이
@@ -89,7 +89,7 @@ SEARCH_PREFIX=PERF
결과 파일:
-- `perf/read/results/doc_list_benchmark_.json`
+- `perf/read/results//.../*.json`
핵심 지표:
diff --git a/perf/read/doc_list_benchmark.js b/perf/read/doc_list_benchmark.js
index bb40bd7d..952eaeb8 100644
--- a/perf/read/doc_list_benchmark.js
+++ b/perf/read/doc_list_benchmark.js
@@ -11,6 +11,7 @@ const USER_COUNT = Number(__ENV.USER_COUNT || 50);
const DOCS_PER_USER = Number(__ENV.DOCS_PER_USER || 3);
const PAGE_SIZE = Number(__ENV.PAGE_SIZE || 10);
const RUN_ID = __ENV.RUN_ID;
+const DATASET_RUN_ID = __ENV.DATASET_RUN_ID || RUN_ID;
const RESULT_DIR = __ENV.RESULT_DIR || 'perf/read/results';
const SEARCH_PREFIX = __ENV.SEARCH_PREFIX || 'PDEL';
@@ -19,6 +20,7 @@ if (!RUN_ID) {
}
export const options = {
+ summaryTrendStats: ['min', 'med', 'avg', 'p(90)', 'p(95)', 'p(99)', 'max'],
scenarios: {
sidebar_list: {
executor: 'constant-vus',
@@ -70,9 +72,9 @@ function userEmail(userNo) {
function keywordForUser(userNo) {
if (SEARCH_PREFIX === 'PERF') {
- return `PERF-${RUN_ID}-u${pad3(userNo)}`;
+ return `PERF-${DATASET_RUN_ID}-u${pad3(userNo)}`;
}
- return `${SEARCH_PREFIX}-${RUN_ID}u${pad3(userNo)}`;
+ return `${SEARCH_PREFIX}-${DATASET_RUN_ID}u${pad3(userNo)}`;
}
function headers(cookie) {
diff --git a/perf/delete/seed_dataset.js b/perf/seed/seed_dataset.js
similarity index 97%
rename from perf/delete/seed_dataset.js
rename to perf/seed/seed_dataset.js
index ec61336a..d59b3b6b 100644
--- a/perf/delete/seed_dataset.js
+++ b/perf/seed/seed_dataset.js
@@ -13,6 +13,7 @@ const MAIN_COMMITS = Number(__ENV.MAIN_COMMITS || 6);
const FEATURE_COMMITS = Number(__ENV.FEATURE_COMMITS || 4);
const BLOCKS_PER_COMMIT = Number(__ENV.BLOCKS_PER_COMMIT || 20);
const RUN_ID = __ENV.RUN_ID || Math.floor(Date.now() / 1000).toString(36);
+const RESULT_DIR = __ENV.RESULT_DIR || '';
const TOTAL_DOCS = USER_COUNT * DOCS_PER_USER;
@@ -220,8 +221,13 @@ export default function () {
}
export function handleSummary(data) {
- return {
+ const summary = {
stdout: `\n[seed-dataset] run_id=${RUN_ID}, users=${USER_COUNT}, docs_per_user=${DOCS_PER_USER}, total_docs=${TOTAL_DOCS}\n`,
- 'perf/delete/results/seed_dataset_summary.json': JSON.stringify(data, null, 2),
};
+
+ if (RESULT_DIR) {
+ summary[`${RESULT_DIR}/seed_dataset_summary.json`] = JSON.stringify(data, null, 2);
+ }
+
+ return summary;
}
diff --git a/perf/thumbnail/preview_e2e_benchmark.js b/perf/thumbnail/preview_e2e_benchmark.js
new file mode 100644
index 00000000..6d70e127
--- /dev/null
+++ b/perf/thumbnail/preview_e2e_benchmark.js
@@ -0,0 +1,194 @@
+import http from 'k6/http';
+import exec from 'k6/execution';
+import { check } from 'k6';
+import { Rate, Trend } from 'k6/metrics';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const USER_PREFIX = __ENV.USER_PREFIX || 'perfe2e';
+const USER_DOMAIN = __ENV.USER_DOMAIN || 'test.com';
+const USER_PASSWORD = __ENV.USER_PASSWORD || 'Testtest1';
+const USER_COUNT = Number(__ENV.USER_COUNT || 1);
+const DOCS_PER_USER = Number(__ENV.DOCS_PER_USER || 20);
+const RUN_ID = __ENV.RUN_ID || 'e2e_u1d20';
+const E2E_VUS = Number(__ENV.E2E_VUS || 1);
+const E2E_DURATION = __ENV.E2E_DURATION || '30s';
+const RESULT_DIR = __ENV.RESULT_DIR || 'perf/thumbnail/results';
+
+export const options = {
+ summaryTrendStats: ['min', 'med', 'avg', 'p(90)', 'p(95)', 'p(99)', 'max'],
+ scenarios: {
+ preview_e2e: {
+ executor: 'constant-vus',
+ vus: E2E_VUS,
+ duration: E2E_DURATION,
+ exec: 'runPreviewE2E',
+ },
+ },
+ thresholds: {
+ http_req_failed: ['rate<0.02'],
+ preview_e2e_failed: ['rate<0.02'],
+ },
+};
+
+const e2eFailed = new Rate('preview_e2e_failed');
+const tLogin = new Trend('preview_login_ms');
+const tSetupDocList = new Trend('preview_setup_doc_list_ms');
+const tSetupGraph = new Trend('preview_setup_graph_ms');
+const tSaveUpdate = new Trend('op_preview_save_update_ms');
+const tDocList = new Trend('op_preview_doc_list_ms');
+const tTotal = new Trend('op_preview_e2e_total_ms');
+
+function pad3(n) {
+ return String(n).padStart(3, '0');
+}
+
+function userEmail(userNo) {
+ return `${USER_PREFIX}_u${pad3(userNo)}@${USER_DOMAIN}`;
+}
+
+function headers(cookie) {
+ return {
+ headers: {
+ Cookie: cookie,
+ 'Content-Type': 'application/json',
+ },
+ };
+}
+
+function ensureStatus(res, statuses, op) {
+ const ok = check(res, {
+ [`${op} status`]: (r) => statuses.includes(r.status),
+ });
+ e2eFailed.add(!ok, { op });
+ if (!ok) {
+ const snippet = typeof res.body === 'string' ? res.body.slice(0, 300) : '';
+ throw new Error(`${op} failed status=${res.status}, body=${snippet}`);
+ }
+}
+
+function parseJson(res, op) {
+ try {
+ return res.json();
+ } catch (_e) {
+ throw new Error(`${op} invalid JSON`);
+ }
+}
+
+function login(email) {
+ const loginJar = new http.CookieJar();
+ const res = http.post(
+ `${BASE_URL}/api/user/login`,
+ JSON.stringify({ email, password: USER_PASSWORD }),
+ {
+ headers: { 'Content-Type': 'application/json' },
+ jar: loginJar,
+ tags: { op: 'preview_login', name: 'POST /api/user/login' },
+ },
+ );
+ tLogin.add(res.timings.duration);
+ ensureStatus(res, [200], 'preview_login');
+
+ const jsession = res.cookies.JSESSIONID && res.cookies.JSESSIONID[0];
+ if (!jsession || !jsession.value) {
+ throw new Error('preview_login missing JSESSIONID');
+ }
+ return `JSESSIONID=${jsession.value}`;
+}
+
+function fetchDocs(cookie) {
+ const res = http.get(
+ `${BASE_URL}/api/document?page=0&size=${DOCS_PER_USER}&sort=updatedAt&order=desc`,
+ { headers: { Cookie: cookie }, tags: { op: 'preview_setup_doc_list', name: 'GET /api/document' } },
+ );
+ tSetupDocList.add(res.timings.duration);
+ ensureStatus(res, [200], 'preview_setup_doc_list');
+ const body = parseJson(res, 'preview_setup_doc_list');
+ if (!body || !Array.isArray(body.content) || body.content.length === 0) {
+ throw new Error('preview_setup_doc_list empty content');
+ }
+ return body.content.map((doc) => ({ docId: doc.id }));
+}
+
+function attachSaveIds(cookie, docs) {
+ return docs.map((doc) => {
+ const res = http.get(
+ `${BASE_URL}/api/document/${doc.docId}/graph`,
+ { headers: { Cookie: cookie }, tags: { op: 'preview_setup_graph', name: 'GET /api/document/{docId}/graph' } },
+ );
+ tSetupGraph.add(res.timings.duration);
+ ensureStatus(res, [200], 'preview_setup_graph');
+ const graph = parseJson(res, 'preview_setup_graph');
+ const branch = graph.branches && graph.branches.find((item) => item.saveId);
+ if (!branch) {
+ throw new Error(`preview_setup_graph missing saveId docId=${doc.docId}`);
+ }
+ return { docId: doc.docId, saveId: branch.saveId };
+ });
+}
+
+function buildSaveContent(docId, iteration) {
+ return [
+ {
+ id: `preview-e2e-${docId}-${iteration}-1`,
+ type: 'paragraph',
+ props: {},
+ content: [
+ {
+ type: 'text',
+ text: `preview e2e ${RUN_ID} doc ${docId} iteration ${iteration}`,
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ];
+}
+
+function requestSaveUpdate(cookie, target, iteration) {
+ const res = http.put(
+ `${BASE_URL}/api/document/${target.docId}/save/${target.saveId}`,
+ JSON.stringify({ content: buildSaveContent(target.docId, iteration) }),
+ { ...headers(cookie), tags: { op: 'preview_save_update', name: 'PUT /api/document/{docId}/save/{saveId}' } },
+ );
+ tSaveUpdate.add(res.timings.duration);
+ ensureStatus(res, [200], 'preview_save_update');
+}
+
+function requestDocList(cookie) {
+ const res = http.get(
+ `${BASE_URL}/api/document?page=0&size=${DOCS_PER_USER}&sort=updatedAt&order=desc`,
+ { headers: { Cookie: cookie }, tags: { op: 'preview_doc_list', name: 'GET /api/document' } },
+ );
+ tDocList.add(res.timings.duration);
+ ensureStatus(res, [200], 'preview_doc_list');
+}
+
+export function setup() {
+ const users = [];
+ for (let userNo = 1; userNo <= USER_COUNT; userNo += 1) {
+ const cookie = login(userEmail(userNo));
+ const docs = attachSaveIds(cookie, fetchDocs(cookie));
+ users.push({ userNo, cookie, docs });
+ }
+ return { users };
+}
+
+export function runPreviewE2E(data) {
+ const userIndex = (exec.vu.idInTest - 1) % data.users.length;
+ const user = data.users[userIndex];
+ const iteration = exec.scenario.iterationInTest;
+ const target = user.docs[(iteration + exec.vu.idInTest - 1) % user.docs.length];
+ const started = Date.now();
+
+ requestSaveUpdate(user.cookie, target, iteration);
+ requestDocList(user.cookie);
+
+ tTotal.add(Date.now() - started);
+}
+
+export function handleSummary(data) {
+ return {
+ stdout: `\n[preview-e2e] run_id=${RUN_ID}, users=${USER_COUNT}, docs_per_user=${DOCS_PER_USER}, vus=${E2E_VUS}, duration=${E2E_DURATION}\n`,
+ [`${RESULT_DIR}/preview_e2e_${RUN_ID}.json`]: JSON.stringify(data, null, 2),
+ };
+}
diff --git a/perf/thumbnail/thumbnail_workflow_benchmark.js b/perf/thumbnail/thumbnail_workflow_benchmark.js
new file mode 100644
index 00000000..534d39ce
--- /dev/null
+++ b/perf/thumbnail/thumbnail_workflow_benchmark.js
@@ -0,0 +1,300 @@
+import http from 'k6/http';
+import exec from 'k6/execution';
+import { check } from 'k6';
+import { Rate, Trend } from 'k6/metrics';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const USER_PREFIX = __ENV.USER_PREFIX || 'perfthumb';
+const USER_DOMAIN = __ENV.USER_DOMAIN || 'test.com';
+const USER_PASSWORD = __ENV.USER_PASSWORD || 'Testtest1';
+const USER_COUNT = Number(__ENV.USER_COUNT || 5);
+const DOCS_PER_USER = Number(__ENV.DOCS_PER_USER || 5);
+const RUN_ID = __ENV.RUN_ID || 'thumbwf';
+const WORKFLOW_VUS = Number(__ENV.WORKFLOW_VUS || USER_COUNT);
+const WORKFLOW_DURATION = __ENV.WORKFLOW_DURATION || '30s';
+const RESULT_DIR = __ENV.RESULT_DIR || 'perf/thumbnail/results';
+const THUMBNAIL_CONTENT_TYPE = __ENV.THUMBNAIL_CONTENT_TYPE || 'image/webp';
+const THUMBNAIL_SIZE_BYTES = Number(__ENV.THUMBNAIL_SIZE_BYTES || 1024);
+const THUMBNAIL_BODY = 'x'.repeat(THUMBNAIL_SIZE_BYTES);
+
+if (WORKFLOW_VUS > USER_COUNT) {
+ throw new Error('WORKFLOW_VUS must be <= USER_COUNT to avoid same-user thumbnail token contention');
+}
+
+export const options = {
+ summaryTrendStats: ['min', 'med', 'avg', 'p(90)', 'p(95)', 'p(99)', 'max'],
+ scenarios: {
+ thumbnail_workflow: {
+ executor: 'constant-vus',
+ vus: WORKFLOW_VUS,
+ duration: WORKFLOW_DURATION,
+ exec: 'runThumbnailWorkflow',
+ },
+ },
+ thresholds: {
+ http_req_failed: ['rate<0.02'],
+ thumbnail_workflow_failed: ['rate<0.02'],
+ },
+};
+
+const workflowFailed = new Rate('thumbnail_workflow_failed');
+const tLogin = new Trend('thumbnail_login_ms');
+const tDocListSetup = new Trend('thumbnail_setup_doc_list_ms');
+const tGraphSetup = new Trend('thumbnail_setup_graph_ms');
+const tSaveUpdate = new Trend('op_save_update_ms');
+const tUploadUrl = new Trend('op_image_upload_url_ms');
+const tS3PutThumbnail = new Trend('op_s3_put_thumbnail_ms');
+const tImageComplete = new Trend('op_image_complete_ms');
+const tThumbnailFinalize = new Trend('op_thumbnail_finalize_ms');
+const tDocListAfterThumbnail = new Trend('op_doc_list_after_thumbnail_ms');
+const tWorkflowTotal = new Trend('op_thumbnail_workflow_total_ms');
+
+function pad3(n) {
+ return String(n).padStart(3, '0');
+}
+
+function userEmail(userNo) {
+ return `${USER_PREFIX}_u${pad3(userNo)}@${USER_DOMAIN}`;
+}
+
+function headers(cookie) {
+ return {
+ headers: {
+ Cookie: cookie,
+ 'Content-Type': 'application/json',
+ },
+ };
+}
+
+function ensureStatus(res, statuses, op) {
+ const ok = check(res, {
+ [`${op} status`]: (r) => statuses.includes(r.status),
+ });
+ workflowFailed.add(!ok, { op });
+ if (!ok) {
+ const snippet = typeof res.body === 'string' ? res.body.slice(0, 300) : '';
+ throw new Error(`${op} failed status=${res.status}, body=${snippet}`);
+ }
+}
+
+function parseJson(res, op) {
+ try {
+ return res.json();
+ } catch (_e) {
+ throw new Error(`${op} invalid JSON`);
+ }
+}
+
+function login(email) {
+ const loginJar = new http.CookieJar();
+ const res = http.post(
+ `${BASE_URL}/api/user/login`,
+ JSON.stringify({ email, password: USER_PASSWORD }),
+ {
+ headers: { 'Content-Type': 'application/json' },
+ jar: loginJar,
+ tags: { op: 'thumbnail_login', name: 'POST /api/user/login' },
+ },
+ );
+ tLogin.add(res.timings.duration);
+ ensureStatus(res, [200], 'thumbnail_login');
+
+ const jsession = res.cookies.JSESSIONID && res.cookies.JSESSIONID[0];
+ if (!jsession || !jsession.value) {
+ throw new Error('thumbnail_login missing JSESSIONID');
+ }
+ return `JSESSIONID=${jsession.value}`;
+}
+
+function fetchDocs(cookie) {
+ const res = http.get(
+ `${BASE_URL}/api/document?page=0&size=${DOCS_PER_USER}&sort=updatedAt&order=desc`,
+ { headers: { Cookie: cookie }, tags: { op: 'thumbnail_setup_doc_list', name: 'GET /api/document' } },
+ );
+ tDocListSetup.add(res.timings.duration);
+ ensureStatus(res, [200], 'thumbnail_setup_doc_list');
+ const body = parseJson(res, 'thumbnail_setup_doc_list');
+ if (!body || !Array.isArray(body.content) || body.content.length === 0) {
+ throw new Error('thumbnail_setup_doc_list empty content');
+ }
+ return body.content.map((doc) => ({ docId: doc.id }));
+}
+
+function attachSaveIds(cookie, docs) {
+ return docs.map((doc) => {
+ const res = http.get(
+ `${BASE_URL}/api/document/${doc.docId}/graph`,
+ { headers: { Cookie: cookie }, tags: { op: 'thumbnail_setup_graph', name: 'GET /api/document/{docId}/graph' } },
+ );
+ tGraphSetup.add(res.timings.duration);
+ ensureStatus(res, [200], 'thumbnail_setup_graph');
+ const graph = parseJson(res, 'thumbnail_setup_graph');
+ const branch = graph.branches && graph.branches.find((item) => item.saveId);
+ if (!branch) {
+ throw new Error(`thumbnail_setup_graph missing saveId docId=${doc.docId}`);
+ }
+ return { docId: doc.docId, saveId: branch.saveId };
+ });
+}
+
+function buildSaveContent(docId, iteration) {
+ return [
+ {
+ id: `thumbwf-${docId}-${iteration}-1`,
+ type: 'paragraph',
+ props: {},
+ content: [
+ {
+ type: 'text',
+ text: `thumbnail workflow ${RUN_ID} doc ${docId} iteration ${iteration}`,
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ];
+}
+
+function requestSaveUpdate(cookie, target, iteration) {
+ const res = http.put(
+ `${BASE_URL}/api/document/${target.docId}/save/${target.saveId}`,
+ JSON.stringify({ content: buildSaveContent(target.docId, iteration) }),
+ { ...headers(cookie), tags: { op: 'save_update', name: 'PUT /api/document/{docId}/save/{saveId}' } },
+ );
+ tSaveUpdate.add(res.timings.duration);
+ ensureStatus(res, [200], 'save_update');
+ const body = parseJson(res, 'save_update');
+ if (!body.thumbnail || !body.thumbnail.requestToken) {
+ throw new Error('save_update missing thumbnail.requestToken');
+ }
+ return body.thumbnail.requestToken;
+}
+
+function requestUploadUrl(cookie, docId, iteration) {
+ const res = http.post(
+ `${BASE_URL}/api/images/upload-url`,
+ JSON.stringify({
+ docId,
+ originalFileName: `thumbnail-${RUN_ID}-${docId}-${iteration}.${extensionOf(THUMBNAIL_CONTENT_TYPE)}`,
+ contentType: THUMBNAIL_CONTENT_TYPE,
+ size: THUMBNAIL_SIZE_BYTES,
+ purpose: 'DOC_THUMBNAIL',
+ }),
+ { ...headers(cookie), tags: { op: 'image_upload_url', name: 'POST /api/images/upload-url' } },
+ );
+ tUploadUrl.add(res.timings.duration);
+ ensureStatus(res, [200], 'image_upload_url');
+ const body = parseJson(res, 'image_upload_url');
+ if (!body.imageId || !body.uploadUrl) {
+ throw new Error('image_upload_url missing imageId or uploadUrl');
+ }
+ return {
+ imageId: body.imageId,
+ uploadUrl: body.uploadUrl,
+ method: body.method || 'PUT',
+ };
+}
+
+function extensionOf(contentType) {
+ switch (contentType) {
+ case 'image/jpeg':
+ return 'jpg';
+ case 'image/png':
+ return 'png';
+ case 'image/webp':
+ return 'webp';
+ case 'image/gif':
+ return 'gif';
+ default:
+ return 'bin';
+ }
+}
+
+function requestS3PutThumbnail(upload) {
+ if (upload.method !== 'PUT') {
+ throw new Error(`s3_put_thumbnail unsupported upload method=${upload.method}`);
+ }
+
+ const res = http.put(
+ upload.uploadUrl,
+ THUMBNAIL_BODY,
+ {
+ headers: {
+ 'Content-Type': THUMBNAIL_CONTENT_TYPE,
+ },
+ tags: {
+ op: 's3_put_thumbnail',
+ name: 's3_put_thumbnail',
+ },
+ },
+ );
+ tS3PutThumbnail.add(res.timings.duration);
+ ensureStatus(res, [200, 201, 204], 's3_put_thumbnail');
+}
+
+function requestImageComplete(cookie, imageId) {
+ const res = http.post(
+ `${BASE_URL}/api/images/${imageId}/complete`,
+ null,
+ { headers: { Cookie: cookie }, tags: { op: 'image_complete', name: 'POST /api/images/{imageId}/complete' } },
+ );
+ tImageComplete.add(res.timings.duration);
+ ensureStatus(res, [200], 'image_complete');
+}
+
+function requestThumbnailFinalize(cookie, target, imageId, requestToken, iteration) {
+ const res = http.put(
+ `${BASE_URL}/api/document/${target.docId}/thumbnail`,
+ JSON.stringify({
+ imageId,
+ requestToken,
+ signature: `sig-${RUN_ID}-${target.docId}-${iteration}`,
+ }),
+ { ...headers(cookie), tags: { op: 'thumbnail_finalize', name: 'PUT /api/document/{docId}/thumbnail' } },
+ );
+ tThumbnailFinalize.add(res.timings.duration);
+ ensureStatus(res, [200], 'thumbnail_finalize');
+}
+
+function requestDocList(cookie) {
+ const res = http.get(
+ `${BASE_URL}/api/document?page=0&size=${DOCS_PER_USER}&sort=updatedAt&order=desc`,
+ { headers: { Cookie: cookie }, tags: { op: 'doc_list_after_thumbnail', name: 'GET /api/document' } },
+ );
+ tDocListAfterThumbnail.add(res.timings.duration);
+ ensureStatus(res, [200], 'doc_list_after_thumbnail');
+}
+
+export function setup() {
+ const users = [];
+ for (let userNo = 1; userNo <= USER_COUNT; userNo += 1) {
+ const cookie = login(userEmail(userNo));
+ const docs = attachSaveIds(cookie, fetchDocs(cookie));
+ users.push({ userNo, cookie, docs });
+ }
+ return { users };
+}
+
+export function runThumbnailWorkflow(data) {
+ const userIndex = (exec.vu.idInTest - 1) % data.users.length;
+ const user = data.users[userIndex];
+ const iteration = exec.scenario.iterationInTest;
+ const target = user.docs[iteration % user.docs.length];
+ const started = Date.now();
+
+ const requestToken = requestSaveUpdate(user.cookie, target, iteration);
+ const upload = requestUploadUrl(user.cookie, target.docId, iteration);
+ requestS3PutThumbnail(upload);
+ requestImageComplete(user.cookie, upload.imageId);
+ requestThumbnailFinalize(user.cookie, target, upload.imageId, requestToken, iteration);
+ requestDocList(user.cookie);
+
+ tWorkflowTotal.add(Date.now() - started);
+}
+
+export function handleSummary(data) {
+ return {
+ stdout: `\n[thumbnail-workflow] run_id=${RUN_ID}, users=${USER_COUNT}, docs_per_user=${DOCS_PER_USER}, vus=${WORKFLOW_VUS}, duration=${WORKFLOW_DURATION}, s3_put=included, thumbnail_size=${THUMBNAIL_SIZE_BYTES}\n`,
+ [`${RESULT_DIR}/thumbnail_workflow_${RUN_ID}.json`]: JSON.stringify(data, null, 2),
+ };
+}
diff --git a/src/main/java/io/ejangs/docsa/DocsaApplication.java b/src/main/java/io/ejangs/docsa/DocsaApplication.java
index 8f723cb6..30409793 100644
--- a/src/main/java/io/ejangs/docsa/DocsaApplication.java
+++ b/src/main/java/io/ejangs/docsa/DocsaApplication.java
@@ -4,7 +4,6 @@
import io.swagger.v3.oas.annotations.info.Info;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -16,7 +15,6 @@
)
)
@EnableAsync
-@EnableRetry
@EnableScheduling
@SpringBootApplication
public class DocsaApplication {
diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java
index 75751f1a..d419599d 100644
--- a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java
+++ b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java
@@ -16,12 +16,12 @@
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode;
import io.ejangs.docsa.global.exception.errorcode.DocErrorCode;
-import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.DomainType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.OriginType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.TriggerType;
-import io.ejangs.docsa.global.mongo.outbox.app.MongoDeleteOutboxFactory;
-import io.ejangs.docsa.global.mongo.outbox.util.MongoDeleteMapper;
+import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
+import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.outbox.mongo.util.MongoDeleteMapper;
import io.ejangs.docsa.global.util.RenewUpdatedAtHelper;
import java.util.*;
diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java
index 55f9693d..3ebfe126 100644
--- a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java
+++ b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java
@@ -4,11 +4,11 @@
import io.ejangs.docsa.domain.branch.dto.response.BranchCreateResponse;
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode;
-import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.DomainType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.OriginType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.TriggerType;
-import io.ejangs.docsa.global.mongo.outbox.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
+import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java
index f94086d7..232dfe32 100644
--- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java
+++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java
@@ -7,11 +7,11 @@
import io.ejangs.docsa.domain.branch.merge.dto.response.MergeResponse;
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode;
-import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.DomainType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.OriginType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.TriggerType;
-import io.ejangs.docsa.global.mongo.outbox.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
+import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/swagger/MergeDocs.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/swagger/MergeDocs.java
index 723a581b..89aa5e0e 100644
--- a/src/main/java/io/ejangs/docsa/domain/branch/merge/swagger/MergeDocs.java
+++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/swagger/MergeDocs.java
@@ -140,7 +140,7 @@
value = """
{
"status": 400,
- "message": "새로운 분기의 이름은 다른 버전의 이름과 중복될 수 없습니다.",
+ "message": "새로운 브랜치의 이름은 다른 브랜치의 이름과 중복될 수 없습니다.",
"error": "BRANCH_NAME_DUPLICATED"
}
"""
diff --git a/src/main/java/io/ejangs/docsa/domain/branch/swagger/CreateBranchDocs.java b/src/main/java/io/ejangs/docsa/domain/branch/swagger/CreateBranchDocs.java
index f5bd80fe..e530c2ad 100644
--- a/src/main/java/io/ejangs/docsa/domain/branch/swagger/CreateBranchDocs.java
+++ b/src/main/java/io/ejangs/docsa/domain/branch/swagger/CreateBranchDocs.java
@@ -98,7 +98,7 @@
value = """
{
"status": 400,
- "message": "새로운 버전의 이름은 다른 버전의 이름과 중복될 수 없습니다.",
+ "message": "새로운 브랜치의 이름은 다른 브랜치의 이름과 중복될 수 없습니다.",
"error": "BRANCH_NAME_DUPLICATED"
}
"""
diff --git a/src/main/java/io/ejangs/docsa/domain/branch/swagger/DeleteBranchDocs.java b/src/main/java/io/ejangs/docsa/domain/branch/swagger/DeleteBranchDocs.java
index 3350a9ae..40e5738c 100644
--- a/src/main/java/io/ejangs/docsa/domain/branch/swagger/DeleteBranchDocs.java
+++ b/src/main/java/io/ejangs/docsa/domain/branch/swagger/DeleteBranchDocs.java
@@ -18,12 +18,12 @@
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Operation(
- summary = "버전 삭제",
+ summary = "브랜치 삭제",
description = """
- 버전을 삭제합니다. 삭제 조건은 아래와 같습니다:
+ 브랜치를 삭제합니다. 삭제 조건은 아래와 같습니다:
- - 메인 버전(`fromCommit == null`)는 삭제 불가
- - 다른 버전이 이 버전을 기반(fromCommit)으로 만들어졌다면 삭제 불가
+ - 메인 브랜치(`fromCommit == null`)는 삭제 불가
+ - 다른 브랜치가 이 브랜치를 기반(fromCommit)으로 만들어졌다면 삭제 불가
- 블록, 시퀀스, 저장(MongoDB)도 함께 삭제됨
""",
parameters = {
@@ -36,7 +36,7 @@
),
@Parameter(
name = "branchId",
- description = "삭제할 버전 ID",
+ description = "삭제할 브랜치 ID",
example = "5",
required = true,
in = ParameterIn.PATH
@@ -45,31 +45,31 @@
responses = {
@ApiResponse(
responseCode = "204",
- description = "버전 삭제 성공"
+ description = "브랜치 삭제 성공"
),
@ApiResponse(
responseCode = "400",
- description = "버전 삭제 실패 - 삭제 불가능한 버전",
+ description = "브랜치 삭제 실패 - 삭제 불가능한 브랜치",
content = @Content(
schema = @Schema(implementation = ErrorResponse.class),
mediaType = MediaType.APPLICATION_JSON_VALUE,
examples = {
@ExampleObject(
- name = "메인 버전 삭제 시도",
+ name = "메인 브랜치 삭제 시도",
value = """
{
"status": 400,
- "message": "기본 버전은 삭제 또는 수정할 수 없습니다.",
+ "message": "메인 브랜치는 삭제 또는 수정할 수 없습니다.",
"error": "MAIN_BRANCH_FIX_UNAVAILABLE"
}
"""
),
@ExampleObject(
- name = "파생된 버전이 존재",
+ name = "파생된 브랜치가 존재",
value = """
{
"status": 400,
- "message": "서브 버전이 있어 해당 버전을 삭제할 수 없습니다.",
+ "message": "서브 브랜치가 있어 해당 브랜치를 삭제할 수 없습니다.",
"error": "SUB_BRANCH_DELETE_UNAVAILABLE"
}
"""
@@ -96,7 +96,7 @@
),
@ApiResponse(
responseCode = "404",
- description = "버전 삭제 실패 - 문서 또는 버전이 존재하지 않음",
+ description = "브랜치 삭제 실패 - 문서 또는 브랜치가 존재하지 않음",
content = @Content(
schema = @Schema(implementation = ErrorResponse.class),
mediaType = MediaType.APPLICATION_JSON_VALUE,
@@ -104,7 +104,7 @@
value = """
{
"status": 404,
- "message": "해당 버전을 찾을 수 없습니다.",
+ "message": "해당 브랜치를 찾을 수 없습니다.",
"error": "BRANCH_NOT_FOUND"
}
"""
@@ -113,7 +113,7 @@
),
@ApiResponse(
responseCode = "500",
- description = "버전 삭제 실패 - MySQL 또는 MongoDB 저장 실패",
+ description = "브랜치 삭제 실패 - MySQL 또는 MongoDB 저장 실패",
content = @Content(
schema = @Schema(implementation = ErrorResponse.class),
mediaType = MediaType.APPLICATION_JSON_VALUE,
diff --git a/src/main/java/io/ejangs/docsa/domain/branch/swagger/RenameBranchDocs.java b/src/main/java/io/ejangs/docsa/domain/branch/swagger/RenameBranchDocs.java
index 19a16d6a..da8f0c38 100644
--- a/src/main/java/io/ejangs/docsa/domain/branch/swagger/RenameBranchDocs.java
+++ b/src/main/java/io/ejangs/docsa/domain/branch/swagger/RenameBranchDocs.java
@@ -20,19 +20,19 @@
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Operation(
- summary = "버전 이름 변경",
- description = "버전의 이름을 수정합니다. 메인버전의 이름은 수정할 수 없습니다.",
+ summary = "브랜치 이름 변경",
+ description = "브랜치의 이름을 수정합니다. 메인 브랜치의 이름은 수정할 수 없습니다.",
parameters = {
@Parameter(
name = "docId",
- description = "수정하려는 버전이 속한 문서 id",
+ description = "수정하려는 브랜치가 속한 문서 id",
example = "1",
required = true,
in = ParameterIn.PATH
),
@Parameter(
name = "branchId",
- description = "수정하려는 버전의 id",
+ description = "수정하려는 브랜치의 id",
example = "1",
required = true,
in = ParameterIn.PATH
@@ -43,9 +43,9 @@
schema = @Schema(implementation = BranchRenameRequest.class),
mediaType = MediaType.APPLICATION_JSON_VALUE,
examples = @ExampleObject(
- value = """
+ value = """
{
- "newName" : "수정한 버전 이름"
+ "newName" : "수정한 브랜치 이름"
}
"""
)
@@ -54,7 +54,7 @@
responses = {
@ApiResponse(
responseCode = "200",
- description = "버전 이름 수정 성공",
+ description = "브랜치 이름 수정 성공",
content = @Content(
schema = @Schema(implementation = BranchRenameResponse.class),
mediaType = MediaType.APPLICATION_JSON_VALUE,
@@ -62,7 +62,7 @@
value = """
{
"id" : 1,
- "name": "수정한 버전 이름"
+ "name": "수정한 브랜치 이름"
}
"""
)
@@ -70,7 +70,7 @@
),
@ApiResponse(
responseCode = "400",
- description = "버전 이름 수정 실패 - 해당 문서에 속한 버전이 아님",
+ description = "브랜치 이름 수정 실패 - 해당 문서에 속한 브랜치가 아님",
content = @Content(
schema = @Schema(implementation = ErrorResponse.class),
mediaType = MediaType.APPLICATION_JSON_VALUE,
@@ -79,7 +79,7 @@
value = """
{
"status": 400,
- "message": "해당 버전을 찾을 수 없습니다.",
+ "message": "해당 브랜치를 찾을 수 없습니다.",
"error": "BRANCH_NOT_FOUND_OR_FORBIDDEN"
}
"""
@@ -106,7 +106,7 @@
),
@ApiResponse(
responseCode = "404",
- description = "버전 이름 수정 실패 - 해당 id를 가진 버전 없음",
+ description = "브랜치 이름 수정 실패 - 해당 id를 가진 브랜치 없음",
content = @Content(
schema = @Schema(implementation = ErrorResponse.class),
mediaType = MediaType.APPLICATION_JSON_VALUE,
@@ -115,7 +115,7 @@
value = """
{
"status": 404,
- "message": "해당 버전을 찾을 수 없습니다.",
+ "message": "해당 브랜치를 찾을 수 없습니다.",
"error": "BRANCH_NOT_FOUND"
}
"""
@@ -124,7 +124,7 @@
),
@ApiResponse(
responseCode = "500",
- description = "버전 이름 수정 실패 - MySQL 또는 MongoDB 저장 실패",
+ description = "브랜치 이름 수정 실패 - MySQL 또는 MongoDB 저장 실패",
content = @Content(
schema = @Schema(implementation = ErrorResponse.class),
mediaType = MediaType.APPLICATION_JSON_VALUE,
diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java
index 26ab2b01..46f98244 100644
--- a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java
+++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java
@@ -13,12 +13,12 @@
import io.ejangs.docsa.domain.doc.entity.Doc;
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode;
-import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.DomainType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.OriginType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.TriggerType;
-import io.ejangs.docsa.global.mongo.outbox.app.MongoDeleteOutboxFactory;
-import io.ejangs.docsa.global.mongo.outbox.util.MongoIdsCollector;
+import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
+import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector;
import io.ejangs.docsa.global.util.RenewUpdatedAtHelper;
import java.util.List;
import java.util.Map;
diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java
index 516d2ae4..25af5748 100644
--- a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java
+++ b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java
@@ -6,11 +6,11 @@
import io.ejangs.docsa.domain.doc.entity.Doc;
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode;
-import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.DomainType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.OriginType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.TriggerType;
-import io.ejangs.docsa.global.mongo.outbox.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
+import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMongoTxService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMongoTxService.java
index 0d7d67c8..5bcb3574 100644
--- a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMongoTxService.java
+++ b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMongoTxService.java
@@ -8,7 +8,7 @@
import io.ejangs.docsa.domain.commit.util.CommitBlockSequenceMapper;
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.BlockSequenceErrorCode;
-import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto;
+import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java
index 3afed8fe..08b4aae4 100644
--- a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java
+++ b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java
@@ -10,7 +10,7 @@
import io.ejangs.docsa.domain.edge.entity.Edge;
import io.ejangs.docsa.domain.edge.util.EdgeMapper;
import io.ejangs.docsa.domain.save.app.SaveQueryService;
-import io.ejangs.docsa.global.mongo.outbox.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
import io.ejangs.docsa.global.util.RenewUpdatedAtHelper;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/io/ejangs/docsa/domain/commit/swagger/CreateCommitDocs.java b/src/main/java/io/ejangs/docsa/domain/commit/swagger/CreateCommitDocs.java
index cb602680..e050d553 100644
--- a/src/main/java/io/ejangs/docsa/domain/commit/swagger/CreateCommitDocs.java
+++ b/src/main/java/io/ejangs/docsa/domain/commit/swagger/CreateCommitDocs.java
@@ -171,7 +171,7 @@
value = """
{
"status": 404,
- "message": "해당 버전을 찾을 수 없습니다.",
+ "message": "해당 브랜치를 찾을 수 없습니다.",
"error": "BRANCH_NOT_FOUND_OR_FORBIDDEN"
}
"""
@@ -191,7 +191,7 @@
value = """
{
"status": 404,
- "message": "해당 버전을 찾을 수 없습니다.",
+ "message": "해당 브랜치를 찾을 수 없습니다.",
"error": "BRANCH_NOT_FOUND"
}
"""
diff --git a/src/main/java/io/ejangs/docsa/domain/commit/swagger/DeleteCommitDocs.java b/src/main/java/io/ejangs/docsa/domain/commit/swagger/DeleteCommitDocs.java
index d1325003..5e2df56e 100644
--- a/src/main/java/io/ejangs/docsa/domain/commit/swagger/DeleteCommitDocs.java
+++ b/src/main/java/io/ejangs/docsa/domain/commit/swagger/DeleteCommitDocs.java
@@ -25,9 +25,9 @@
삭제 조건은 아래와 같습니다:
- - 각 버전의 LeafCommit만 삭제 가능
- - 어느 버전의 FromCommit이면 삭제 불가
- - 버전의 RootCommit은 삭제 불가(RootCommit까지 삭제하고 싶은 경우 버전 삭제를 권장합니다)
+ - 각 브랜치의 LeafCommit만 삭제 가능
+ - 어느 브랜치의 FromCommit이면 삭제 불가
+ - 브랜치의 RootCommit은 삭제 불가(RootCommit까지 삭제하고 싶은 경우 브랜치 삭제를 권장합니다)
""",
parameters = {
@Parameter(
@@ -62,7 +62,7 @@
value = """
{
"status": 400,
- "message": "버전의 마지막 기록이 아닙니다.",
+ "message": "브랜치의 마지막 기록이 아닙니다.",
"error": "IS_NOT_LEAF_COMMIT"
}
"""
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java
index e8947f81..0b739562 100644
--- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java
+++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java
@@ -24,12 +24,12 @@
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode;
import io.ejangs.docsa.global.exception.errorcode.DocErrorCode;
-import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.DomainType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.OriginType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.TriggerType;
-import io.ejangs.docsa.global.mongo.outbox.app.MongoDeleteOutboxFactory;
-import io.ejangs.docsa.global.mongo.outbox.util.MongoIdsCollector;
+import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
+import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java
index f25ab6b0..906cb674 100644
--- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java
+++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java
@@ -4,6 +4,8 @@
import io.ejangs.docsa.domain.branch.entity.Branch;
import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse;
import io.ejangs.docsa.domain.doc.entity.Doc;
+import io.ejangs.docsa.domain.doc.thumbnail.dao.ThumbnailRepository;
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail;
import io.ejangs.docsa.domain.doc.util.DocMapper;
import io.ejangs.docsa.domain.save.app.SaveQueryService;
import io.ejangs.docsa.domain.save.entity.Save;
@@ -20,6 +22,7 @@ public class DocCreateMySqlTxService {
private final DocQueryService docQueryService;
private final BranchQueryService branchQueryService;
private final SaveQueryService saveQueryService;
+ private final ThumbnailRepository thumbnailRepository;
@Value("${default.branch}")
private String defaultBranchName;
@@ -31,6 +34,10 @@ public DocCreateResponse createMySqlPart(String title, User user,
Doc doc = docQueryService.create(user, title);
Branch defaultBranch = branchQueryService.createBranch(doc, defaultBranchName);
Save defaultSave = saveQueryService.createSave(defaultBranch, saveContentId);
+ thumbnailRepository.save(Thumbnail.builder()
+ .doc(doc)
+ .build());
+
return DocMapper.toCreateResponse(doc, defaultSave);
}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java
index 21cf7882..b5bc52c9 100644
--- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java
+++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java
@@ -6,11 +6,11 @@
import io.ejangs.docsa.domain.user.entity.User;
import io.ejangs.docsa.global.exception.CustomException;
import io.ejangs.docsa.global.exception.errorcode.DocErrorCode;
-import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.DomainType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.OriginType;
-import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.TriggerType;
-import io.ejangs.docsa.global.mongo.outbox.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType;
+import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType;
+import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocPageResponse.java b/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocPageResponse.java
index a5be7626..41ea9a4d 100644
--- a/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocPageResponse.java
+++ b/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocPageResponse.java
@@ -1,6 +1,7 @@
package io.ejangs.docsa.domain.doc.dto.response;
import io.ejangs.docsa.domain.doc.dto.RecentActivityDto;
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus;
import java.time.LocalDateTime;
public record DocPageResponse(
@@ -8,17 +9,13 @@ public record DocPageResponse(
String title,
LocalDateTime createdAt,
LocalDateTime updatedAt,
- String preview,
+ String thumbnailUrl,
+ ThumbnailStatus thumbnailStatus,
RecentActivityDto recent
) {
- public DocPageResponse(Long id, String title, LocalDateTime createdAt,
- LocalDateTime updatedAt, String preview, RecentActivityDto recent) {
- this.id = id;
- this.title = title;
- this.createdAt = createdAt.plusHours(9L);
- this.updatedAt = updatedAt.plusHours(9L);
- this.preview = preview;
- this.recent = recent;
+ public DocPageResponse {
+ createdAt = createdAt.plusHours(9L);
+ updatedAt = updatedAt.plusHours(9L);
}
}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/entity/Doc.java b/src/main/java/io/ejangs/docsa/domain/doc/entity/Doc.java
index 2d48ecb1..35527fdd 100644
--- a/src/main/java/io/ejangs/docsa/domain/doc/entity/Doc.java
+++ b/src/main/java/io/ejangs/docsa/domain/doc/entity/Doc.java
@@ -2,6 +2,7 @@
import io.ejangs.docsa.domain.branch.entity.Branch;
import io.ejangs.docsa.domain.edge.entity.Edge;
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail;
import io.ejangs.docsa.domain.user.entity.User;
import io.ejangs.docsa.global.common.BaseEntity;
import jakarta.persistence.CascadeType;
@@ -14,6 +15,7 @@
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
+import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.util.ArrayList;
@@ -49,6 +51,9 @@ public class Doc extends BaseEntity {
@OneToMany(mappedBy = "doc", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List edges;
+ @OneToOne(mappedBy = "doc", cascade = CascadeType.ALL, orphanRemoval = true)
+ private Thumbnail thumbnail;
+
@Builder
private Doc(String title, User user) {
this.title = title;
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocGraphDocs.java b/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocGraphDocs.java
index e146288e..e7cca649 100644
--- a/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocGraphDocs.java
+++ b/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocGraphDocs.java
@@ -160,11 +160,11 @@
value = """
{
"status": 404,
- "message": "해당 버전을 찾을 수 없습니다.",
+ "message": "해당 브랜치를 찾을 수 없습니다.",
"error": "BRANCH_NOT_FOUND"
}
""",
- name = "버전을 찾을 수 없음."
+ name = "브랜치를 찾을 수 없음."
),
}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/api/ThumbnailController.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/api/ThumbnailController.java
new file mode 100644
index 00000000..bcc773cb
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/api/ThumbnailController.java
@@ -0,0 +1,44 @@
+package io.ejangs.docsa.domain.doc.thumbnail.api;
+
+import io.ejangs.docsa.domain.doc.thumbnail.app.ThumbnailService;
+import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailFinalizeRequest;
+import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailResponse;
+import io.ejangs.docsa.domain.doc.thumbnail.swagger.FinalizeThumbnailDocs;
+import io.ejangs.docsa.domain.user.security.CustomUserDetails;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/document/{docId}/thumbnail")
+@Tag(name = "Thumbnail API", description = "문서 대표 썸네일 API")
+public class ThumbnailController {
+
+ private final ThumbnailService thumbnailService;
+
+ @PutMapping
+ @FinalizeThumbnailDocs
+ public ResponseEntity finalizeThumbnail(
+ @AuthenticationPrincipal CustomUserDetails userDetails,
+ @PathVariable Long docId,
+ @Valid @RequestBody ThumbnailFinalizeRequest request
+ ) {
+ return ResponseEntity.ok(thumbnailService.finalizeThumbnail(
+ userDetails.getId(),
+ docId,
+ request.imageId(),
+ request.requestToken(),
+ request.signature()
+ ));
+ }
+
+
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailQueryService.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailQueryService.java
new file mode 100644
index 00000000..25dca073
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailQueryService.java
@@ -0,0 +1,29 @@
+package io.ejangs.docsa.domain.doc.thumbnail.app;
+
+import io.ejangs.docsa.domain.doc.entity.Doc;
+import io.ejangs.docsa.domain.doc.thumbnail.dao.ThumbnailRepository;
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail;
+import io.ejangs.docsa.global.exception.CustomException;
+import io.ejangs.docsa.global.exception.errorcode.ThumbnailErrorCode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class ThumbnailQueryService {
+
+ private final ThumbnailRepository thumbnailRepository;
+
+ public Thumbnail getByDocIdForUpdate(Long docId) {
+ return thumbnailRepository.findByDocIdForUpdate(docId)
+ .orElseThrow(() -> new CustomException(ThumbnailErrorCode.THUMBNAIL_NOT_FOUND));
+ }
+
+ public Thumbnail getOrCreateByDocForUpdate(Doc doc) {
+ return thumbnailRepository.findByDocIdForUpdate(doc.getId())
+ .orElseGet(() -> thumbnailRepository.save(Thumbnail.builder()
+ .doc(doc)
+ .build()));
+ }
+
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java
new file mode 100644
index 00000000..15c79843
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java
@@ -0,0 +1,99 @@
+package io.ejangs.docsa.domain.doc.thumbnail.app;
+
+import io.ejangs.docsa.domain.doc.app.create.DocQueryService;
+import io.ejangs.docsa.domain.doc.entity.Doc;
+import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailResponse;
+import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse;
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail;
+import io.ejangs.docsa.domain.image.app.ImageQueryService;
+import io.ejangs.docsa.domain.image.entity.Image;
+import io.ejangs.docsa.global.outbox.s3.app.S3DeleteOutboxFactory;
+import io.ejangs.docsa.global.exception.CustomException;
+import io.ejangs.docsa.global.exception.errorcode.ImageErrorCode;
+import io.ejangs.docsa.global.exception.errorcode.ThumbnailErrorCode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class ThumbnailService {
+
+ private final ThumbnailQueryService thumbnailQueryService;
+ private final DocQueryService docQueryService;
+ private final ImageQueryService imageQueryService;
+ private final S3DeleteOutboxFactory s3DeleteOutboxFactory;
+
+ @Value("${cloud.aws.s3.public-base-url}")
+ private String cdnUrl;
+
+ @Transactional
+ public ThumbnailSyncResponse requestUpdate(Long userId, Long docId) {
+ Doc doc = docQueryService.getByIdAndUserId(docId, userId);
+
+ Thumbnail thumbnail = thumbnailQueryService.getOrCreateByDocForUpdate(doc);
+
+ Long requestToken = thumbnail.requestUpdate();
+
+ return new ThumbnailSyncResponse(
+ requestToken,
+ thumbnail.getSignature(),
+ thumbnail.getStatus()
+ );
+ }
+
+ @Transactional
+ public ThumbnailResponse finalizeThumbnail(
+ Long userId,
+ Long docId,
+ Long imageId,
+ Long requestToken,
+ String signature
+ ) {
+ docQueryService.checkByIdAndUserId(docId, userId);
+
+ Thumbnail thumbnail = thumbnailQueryService.getByDocIdForUpdate(docId);
+
+ if (!thumbnail.isCurrentToken(requestToken)) {
+ throw new CustomException(ThumbnailErrorCode.STALE_THUMBNAIL_REQUEST);
+ }
+
+ Image image = imageQueryService.getByIdAndUserId(imageId, userId);
+
+ validateThumbnailImage(docId, image);
+
+ Image previousImage = thumbnail.getCurrentImage();
+ thumbnail.complete(image, signature);
+ enqueuePreviousThumbnailDeletion(previousImage, image);
+
+ return new ThumbnailResponse(
+ image.getId(),
+ "%s/%s".formatted(cdnUrl, image.getObjectKey()),
+ thumbnail.getStatus(),
+ thumbnail.getSignature()
+ );
+ }
+
+ private void validateThumbnailImage(Long docId, Image image) {
+ if (!image.getDocId().equals(docId)) {
+ throw new CustomException(ThumbnailErrorCode.THUMBNAIL_NOT_FOUND);
+ }
+
+ if (image.getStatus() != Image.ImageStatus.ACTIVE) {
+ throw new CustomException(ImageErrorCode.IMAGE_UPLOAD_NOT_COMPLETED);
+ }
+
+ if (image.getPurpose() != Image.Purpose.DOC_THUMBNAIL) {
+ throw new CustomException(ThumbnailErrorCode.INVALID_THUMBNAIL_PURPOSE);
+ }
+ }
+
+ private void enqueuePreviousThumbnailDeletion(Image previousImage, Image currentImage) {
+ if (previousImage == null || previousImage.getId().equals(currentImage.getId())) {
+ return;
+ }
+
+ s3DeleteOutboxFactory.enqueueImageDeletion(previousImage);
+ }
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java
new file mode 100644
index 00000000..a24865e3
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java
@@ -0,0 +1,33 @@
+package io.ejangs.docsa.domain.doc.thumbnail.dao;
+
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail;
+import jakarta.persistence.LockModeType;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface ThumbnailRepository extends JpaRepository {
+
+ Optional findByDocId(Long docId);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("""
+ select t
+ from Thumbnail t
+ where t.doc.id = :docId
+ """)
+ Optional findByDocIdForUpdate(@Param("docId") Long docId);
+
+
+ @Query("""
+ select t
+ from Thumbnail t
+ left join fetch t.currentImage
+ where t.doc.id in :docIds
+ """)
+ List findAllByDocIdInWithCurrentImage(@Param("docIds") Collection docIds);
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailFinalizeRequest.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailFinalizeRequest.java
new file mode 100644
index 00000000..ad71dfe6
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailFinalizeRequest.java
@@ -0,0 +1,22 @@
+package io.ejangs.docsa.domain.doc.thumbnail.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+@Schema(description = "문서 대표 썸네일 확정 요청")
+public record ThumbnailFinalizeRequest(
+ @Schema(description = "대표 썸네일로 확정할 이미지 id", example = "10")
+ @NotNull
+ Long imageId,
+
+ @Schema(description = "저장 API 응답으로 받은 최신 썸네일 요청 토큰", example = "12")
+ @NotNull
+ Long requestToken,
+
+ @Schema(description = "프론트가 썸네일 캡처 대상 영역 기준으로 계산한 signature", example = "9f0c1dd5d7e8b8f2a1f4c3d2e6a7b8c9")
+ @NotBlank
+ String signature
+) {
+
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailResponse.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailResponse.java
new file mode 100644
index 00000000..7a69ff1f
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailResponse.java
@@ -0,0 +1,18 @@
+package io.ejangs.docsa.domain.doc.thumbnail.dto;
+
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "문서 대표 썸네일 응답")
+public record ThumbnailResponse(
+ @Schema(description = "대표 썸네일 이미지 id", example = "10")
+ Long imageId,
+ @Schema(description = "대표 썸네일 CDN URL", example = "https://cdn.example.com/users/1/docs/1/images/550e8400-e29b-41d4-a716-446655440000.webp")
+ String thumbnailUrl,
+ @Schema(description = "썸네일 상태(EMPTY/PENDING/READY/FAILED)", example = "READY")
+ ThumbnailStatus status,
+ @Schema(description = "대표 썸네일이 반영한 프론트 상단 영역 signature", example = "9f0c1dd5d7e8b8f2a1f4c3d2e6a7b8c9")
+ String signature
+) {
+
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailSyncResponse.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailSyncResponse.java
new file mode 100644
index 00000000..2e9cd44c
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailSyncResponse.java
@@ -0,0 +1,16 @@
+package io.ejangs.docsa.domain.doc.thumbnail.dto;
+
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "저장 후 썸네일 동기화 정보")
+public record ThumbnailSyncResponse(
+ @Schema(description = "최신 썸네일 요청 토큰. finalize 시 그대로 전달해야 합니다.", example = "12")
+ Long requestToken,
+ @Schema(description = "현재 대표 썸네일이 반영한 signature. 프론트가 계산한 signature와 같으면 업로드를 생략합니다.", example = "9f0c1dd5d7e8b8f2a1f4c3d2e6a7b8c9")
+ String signature,
+ @Schema(description = "썸네일 상태(EMPTY/PENDING/READY/FAILED)", example = "PENDING")
+ ThumbnailStatus status
+) {
+
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/entity/Thumbnail.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/entity/Thumbnail.java
new file mode 100644
index 00000000..b8081ff3
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/entity/Thumbnail.java
@@ -0,0 +1,103 @@
+package io.ejangs.docsa.domain.doc.thumbnail.entity;
+
+import io.ejangs.docsa.domain.doc.entity.Doc;
+import io.ejangs.docsa.domain.image.entity.Image;
+import io.ejangs.docsa.global.common.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import jakarta.persistence.Version;
+
+@Entity
+@Getter
+@Table(name = "doc_thumbnails")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Thumbnail extends BaseEntity {
+
+ public enum ThumbnailStatus {
+ EMPTY,
+ PENDING,
+ READY,
+ FAILED
+ }
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @OneToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "doc_id", nullable = false, unique = true)
+ private Doc doc;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "current_image_id", unique = true)
+ private Image currentImage;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private ThumbnailStatus status;
+
+ @Column(nullable = false)
+ private Long requestToken;
+
+ @Column(length = 255)
+ private String signature;
+
+ private LocalDateTime requestedAt;
+
+ private LocalDateTime generatedAt;
+
+ @Column(length = 500)
+ private String lastError;
+
+ @Version
+ private Long version;
+
+ @Builder
+ private Thumbnail(Doc doc) {
+ this.doc = doc;
+ this.status = ThumbnailStatus.EMPTY;
+ this.requestToken = 0L;
+ }
+
+ public Long requestUpdate() {
+ this.requestToken++;
+ if (this.currentImage == null) {
+ this.status = ThumbnailStatus.PENDING;
+ }
+ this.requestedAt = LocalDateTime.now();
+ this.lastError = null;
+
+ return this.requestToken;
+ }
+
+ public boolean isCurrentToken(Long requestToken) {
+ return this.requestToken.equals(requestToken);
+ }
+
+ public void complete(Image image, String signature) {
+ this.currentImage = image;
+ this.signature = signature;
+ this.status = ThumbnailStatus.READY;
+ this.generatedAt = LocalDateTime.now();
+ this.lastError = null;
+ }
+
+ public void fail(String lastError) {
+ this.status = ThumbnailStatus.FAILED;
+ this.lastError = lastError;
+ }
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/swagger/FinalizeThumbnailDocs.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/swagger/FinalizeThumbnailDocs.java
new file mode 100644
index 00000000..e2b2363c
--- /dev/null
+++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/swagger/FinalizeThumbnailDocs.java
@@ -0,0 +1,173 @@
+package io.ejangs.docsa.domain.doc.thumbnail.swagger;
+
+import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailFinalizeRequest;
+import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailResponse;
+import io.ejangs.docsa.global.exception.ErrorResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.springframework.http.MediaType;
+
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Operation(
+ summary = "문서 대표 썸네일 확정",
+ description = """
+ 프론트가 생성한 썸네일 이미지를 문서의 대표 썸네일로 확정합니다.
+ 저장 API 응답의 `thumbnail.requestToken`과 프론트가 계산한 `signature`를 함께 전송해야 합니다.
+ 서버는 requestToken을 검증해 오래된 업로드 결과가 최신 썸네일을 덮어쓰지 못하게 막습니다.
+ 확정하려는 이미지는 같은 문서에 연결된 `DOC_THUMBNAIL` 목적의 `ACTIVE` 이미지여야 합니다.
+ 🔐 이 API는 세션 로그인 상태에서 호출되어야 하며,
+ 클라이언트는 쿠키(`JSESSIONID`)를 통해 인증 정보를 전송해야 합니다.
+ """,
+ parameters = {
+ @Parameter(
+ name = "docId",
+ description = "썸네일을 확정할 문서 id",
+ example = "1",
+ required = true,
+ in = ParameterIn.PATH
+ )
+ },
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON_VALUE,
+ schema = @Schema(implementation = ThumbnailFinalizeRequest.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "imageId": 10,
+ "requestToken": 12,
+ "signature": "9f0c1dd5d7e8b8f2a1f4c3d2e6a7b8c9"
+ }
+ """
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ responseCode = "200",
+ description = "썸네일 확정 성공",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON_VALUE,
+ schema = @Schema(implementation = ThumbnailResponse.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "imageId": 10,
+ "thumbnailUrl": "https://cdn.example.com/users/1/docs/1/images/550e8400-e29b-41d4-a716-446655440000.webp",
+ "status": "READY",
+ "signature": "9f0c1dd5d7e8b8f2a1f4c3d2e6a7b8c9"
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "400",
+ description = "썸네일 용도가 아닌 이미지",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON_VALUE,
+ schema = @Schema(implementation = ErrorResponse.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "status": 400,
+ "message": "썸네일 용도의 이미지가 아닙니다.",
+ "error": "INVALID_THUMBNAIL_PURPOSE"
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "401",
+ description = "인증 실패 - 로그인 세션이 없음",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON_VALUE,
+ schema = @Schema(implementation = ErrorResponse.class),
+ examples = @ExampleObject(
+ value = """
+ {
+ "status": 401,
+ "message": "로그인이 필요합니다.",
+ "error": "LOGIN_REQUIRED"
+ }
+ """
+ )
+ )
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "문서, 이미지 또는 썸네일 정보를 찾을 수 없음",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON_VALUE,
+ schema = @Schema(implementation = ErrorResponse.class),
+ examples = {
+ @ExampleObject(
+ name = "썸네일 정보 없음",
+ value = """
+ {
+ "status": 404,
+ "message": "썸네일 정보를 찾을 수 없습니다.",
+ "error": "THUMBNAIL_NOT_FOUND"
+ }
+ """
+ ),
+ @ExampleObject(
+ name = "이미지 없음",
+ value = """
+ {
+ "status": 404,
+ "message": "이미지를 찾을 수 없습니다.",
+ "error": "IMAGE_NOT_FOUND"
+ }
+ """
+ )
+ }
+ )
+ ),
+ @ApiResponse(
+ responseCode = "409",
+ description = "오래된 requestToken 또는 S3 업로드 미완료",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON_VALUE,
+ schema = @Schema(implementation = ErrorResponse.class),
+ examples = {
+ @ExampleObject(
+ name = "오래된 썸네일 요청",
+ value = """
+ {
+ "status": 409,
+ "message": "최신 썸네일 요청이 아닙니다.",
+ "error": "STALE_THUMBNAIL_REQUEST"
+ }
+ """
+ ),
+ @ExampleObject(
+ name = "이미지 업로드 미완료",
+ value = """
+ {
+ "status": 409,
+ "message": "이미지 업로드가 아직 완료되지 않았습니다.",
+ "error": "IMAGE_UPLOAD_NOT_COMPLETED"
+ }
+ """
+ )
+ }
+ )
+ )
+ }
+)
+public @interface FinalizeThumbnailDocs {
+}
diff --git a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java
index 87ab479f..c201da98 100644
--- a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java
+++ b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java
@@ -1,21 +1,19 @@
package io.ejangs.docsa.domain.doc.util;
import io.ejangs.docsa.domain.branch.entity.Branch;
-import io.ejangs.docsa.domain.commit.app.CommitContentAssembler;
-import io.ejangs.docsa.domain.commit.entity.Commit;
import io.ejangs.docsa.domain.doc.dto.RecentActivityDto;
import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse;
import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse;
import io.ejangs.docsa.domain.doc.entity.Doc;
-import io.ejangs.docsa.domain.save.dao.mongodb.SaveContentRepository;
-import io.ejangs.docsa.domain.save.document.SaveContent;
-import io.ejangs.docsa.domain.save.entity.Save;
-import io.ejangs.docsa.global.exception.CustomException;
-import io.ejangs.docsa.global.exception.errorcode.SaveErrorCode;
+import io.ejangs.docsa.domain.doc.thumbnail.dao.ThumbnailRepository;
+import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Component;
@@ -23,65 +21,63 @@
@RequiredArgsConstructor
public class DocListAssembler {
- private final CommitContentAssembler commitContentAssembler;
- private final SaveContentRepository saveContentRepository;
+ private final ThumbnailRepository thumbnailRepository;
- private static final String DEFAULT_PREVIEW = "미리보기 없음";
+ @Value("${cloud.aws.s3.public-base-url}")
+ private String cdnUrl;
public Page assembleDocList(Page docs) {
- return docs
- .map(doc -> {
- Branch recentBranch = getMostRecentBranch(doc);
- RecentActivityDto recent = getRecentActivity(recentBranch);
- String preview = extractPreviewSafe(recentBranch, recent);
- return DocMapper.toListResponse(doc, preview, recent);
- });
+ List docIds = docs.getContent().stream()
+ .map(Doc::getId)
+ .toList();
+
+ Map thumbnailByDocId = thumbnailRepository
+ .findAllByDocIdInWithCurrentImage(docIds)
+ .stream()
+ .collect(Collectors.toMap(
+ thumbnail -> thumbnail.getDoc().getId(),
+ Function.identity()
+ ));
+
+ return docs.map(doc -> {
+ Branch recentBranch = getMostRecentBranch(doc);
+ RecentActivityDto recent = getRecentActivity(recentBranch);
+ Thumbnail thumbnail = thumbnailByDocId.get(doc.getId());
+
+ return DocMapper.toListResponse(
+ doc,
+ buildThumbnailUrl(thumbnail),
+ thumbnailStatusOf(thumbnail),
+ recent
+ );
+ });
}
- public Page assembleDocListSimple(Page docs) {
- return docs
- .map(doc -> {
- Branch recentBranch = getMostRecentBranch(doc);
- RecentActivityDto recent = getRecentActivity(recentBranch);
- return DocMapper.toListSimpleResponse(doc, recent);
- });
- }
-
- private String extractPreviewSafe(Branch branch, RecentActivityDto recent) {
- if (branch == null || recent == null) {
- return DEFAULT_PREVIEW;
+ private String buildThumbnailUrl(Thumbnail thumbnail) {
+ if (thumbnail == null || thumbnail.getCurrentImage() == null) {
+ return null;
}
- return switch (recent.recentType()) {
- case COMMIT -> extractPreviewFromCommit(branch.getLeafCommit());
- case SAVE -> extractPreviewFromSave(branch.getSave());
- default -> DEFAULT_PREVIEW;
- };
+ return "%s/%s".formatted(cdnUrl, thumbnail.getCurrentImage().getObjectKey());
}
- private String extractPreviewFromCommit(Commit commit) {
- if (commit == null) {
- return DEFAULT_PREVIEW;
+ private Thumbnail.ThumbnailStatus thumbnailStatusOf(Thumbnail thumbnail) {
+ if (thumbnail == null) {
+ return Thumbnail.ThumbnailStatus.EMPTY;
}
-
- List