diff --git a/README.md b/README.md
index f0bae2c1..833dfe36 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,3 +156,4 @@ Docsa는 문서의 변경 사항을 기록(commit) 단위로 추적하고, 브
+
diff --git a/build.gradle b/build.gradle
index f9ce7be2..a5e9281f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -45,22 +45,24 @@ 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 dd100af7..fb66b2f1 100644
--- a/infra/.env.example
+++ b/infra/.env.example
@@ -30,16 +30,3 @@ 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 5c27d46b..66cff9bd 100644
--- a/infra/docker-compose.stg.yml
+++ b/infra/docker-compose.stg.yml
@@ -136,12 +136,6 @@ 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 bcd8945c..fc0c9ef7 100644
--- a/perf/delete/README.md
+++ b/perf/delete/README.md
@@ -8,8 +8,8 @@
## Files
-- `perf/seed/seed_dataset.js`
- - 유저별 문서/커밋/브랜치 데이터 생성. delete/read/thumbnail E2E에서 공용으로 사용
+- `perf/delete/seed_dataset.js`
+ - 유저별 문서/커밋/브랜치 데이터 생성
- `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/seed/seed_dataset.js
+k6 run perf/delete/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. 데이터 생성 (`perf/seed/seed_dataset.js`)
+4. 데이터 생성 (`seed_dataset.js`)
5. 삭제 벤치 실행 (`delete_only_benchmark.js`)
6. 결과 파일 저장
diff --git a/perf/seed/seed_dataset.js b/perf/delete/seed_dataset.js
similarity index 97%
rename from perf/seed/seed_dataset.js
rename to perf/delete/seed_dataset.js
index d59b3b6b..ec61336a 100644
--- a/perf/seed/seed_dataset.js
+++ b/perf/delete/seed_dataset.js
@@ -13,7 +13,6 @@ 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;
@@ -221,13 +220,8 @@ export default function () {
}
export function handleSummary(data) {
- const summary = {
+ return {
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/read/README.md b/perf/read/README.md
index e635a21b..794e751b 100644
--- a/perf/read/README.md
+++ b/perf/read/README.md
@@ -8,7 +8,7 @@
## Files
-- `perf/seed/seed_dataset.js`
+- `perf/delete/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/seed/seed_dataset.js
+k6 run perf/delete/seed_dataset.js
```
애플리케이션 initializer로 DB에 직접 주입할 수도 있습니다. 이 방식은 API 호출 비용 없이
@@ -89,7 +89,7 @@ SEARCH_PREFIX=PERF
결과 파일:
-- `perf/read/results//.../*.json`
+- `perf/read/results/doc_list_benchmark_.json`
핵심 지표:
diff --git a/perf/read/doc_list_benchmark.js b/perf/read/doc_list_benchmark.js
index 952eaeb8..bb40bd7d 100644
--- a/perf/read/doc_list_benchmark.js
+++ b/perf/read/doc_list_benchmark.js
@@ -11,7 +11,6 @@ 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';
@@ -20,7 +19,6 @@ if (!RUN_ID) {
}
export const options = {
- summaryTrendStats: ['min', 'med', 'avg', 'p(90)', 'p(95)', 'p(99)', 'max'],
scenarios: {
sidebar_list: {
executor: 'constant-vus',
@@ -72,9 +70,9 @@ function userEmail(userNo) {
function keywordForUser(userNo) {
if (SEARCH_PREFIX === 'PERF') {
- return `PERF-${DATASET_RUN_ID}-u${pad3(userNo)}`;
+ return `PERF-${RUN_ID}-u${pad3(userNo)}`;
}
- return `${SEARCH_PREFIX}-${DATASET_RUN_ID}u${pad3(userNo)}`;
+ return `${SEARCH_PREFIX}-${RUN_ID}u${pad3(userNo)}`;
}
function headers(cookie) {
diff --git a/perf/thumbnail/preview_e2e_benchmark.js b/perf/thumbnail/preview_e2e_benchmark.js
deleted file mode 100644
index 6d70e127..00000000
--- a/perf/thumbnail/preview_e2e_benchmark.js
+++ /dev/null
@@ -1,194 +0,0 @@
-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
deleted file mode 100644
index 534d39ce..00000000
--- a/perf/thumbnail/thumbnail_workflow_benchmark.js
+++ /dev/null
@@ -1,300 +0,0 @@
-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 30409793..8f723cb6 100644
--- a/src/main/java/io/ejangs/docsa/DocsaApplication.java
+++ b/src/main/java/io/ejangs/docsa/DocsaApplication.java
@@ -4,6 +4,7 @@
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;
@@ -15,6 +16,7 @@
)
)
@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 d419599d..75751f1a 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.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.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.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 3ebfe126..55f9693d 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.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.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 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 232dfe32..f94086d7 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.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.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 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 89aa5e0e..723a581b 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 e530c2ad..f5bd80fe 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 40e5738c..3350a9ae 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 da8f0c38..19a16d6a 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 46f98244..26ab2b01 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.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.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.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 25af5748..516d2ae4 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.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.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 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 5bcb3574..0d7d67c8 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.outbox.mongo.dto.MongoIdsDto;
+import io.ejangs.docsa.global.mongo.outbox.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 08b4aae4..3afed8fe 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.outbox.mongo.app.MongoDeleteOutboxFactory;
+import io.ejangs.docsa.global.mongo.outbox.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 e050d553..cb602680 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 5e2df56e..d1325003 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 0b739562..e8947f81 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.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.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 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 906cb674..f25ab6b0 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,8 +4,6 @@
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;
@@ -22,7 +20,6 @@ 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;
@@ -34,10 +31,6 @@ 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 b5bc52c9..21cf7882 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.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.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 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 41ea9a4d..a5be7626 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,7 +1,6 @@
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(
@@ -9,13 +8,17 @@ public record DocPageResponse(
String title,
LocalDateTime createdAt,
LocalDateTime updatedAt,
- String thumbnailUrl,
- ThumbnailStatus thumbnailStatus,
+ String preview,
RecentActivityDto recent
) {
- public DocPageResponse {
- createdAt = createdAt.plusHours(9L);
- updatedAt = updatedAt.plusHours(9L);
+ 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;
}
}
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 35527fdd..2d48ecb1 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,7 +2,6 @@
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;
@@ -15,7 +14,6 @@
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;
@@ -51,9 +49,6 @@ 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 e7cca649..e146288e 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
deleted file mode 100644
index bcc773cb..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/api/ThumbnailController.java
+++ /dev/null
@@ -1,44 +0,0 @@
-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
deleted file mode 100644
index 25dca073..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailQueryService.java
+++ /dev/null
@@ -1,29 +0,0 @@
-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
deleted file mode 100644
index 15c79843..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java
+++ /dev/null
@@ -1,99 +0,0 @@
-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
deleted file mode 100644
index a24865e3..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index ad71dfe6..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailFinalizeRequest.java
+++ /dev/null
@@ -1,22 +0,0 @@
-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
deleted file mode 100644
index 7a69ff1f..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailResponse.java
+++ /dev/null
@@ -1,18 +0,0 @@
-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
deleted file mode 100644
index 2e9cd44c..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dto/ThumbnailSyncResponse.java
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index b8081ff3..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/entity/Thumbnail.java
+++ /dev/null
@@ -1,103 +0,0 @@
-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
deleted file mode 100644
index e2b2363c..00000000
--- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/swagger/FinalizeThumbnailDocs.java
+++ /dev/null
@@ -1,173 +0,0 @@
-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 c201da98..87ab479f 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,19 +1,21 @@
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.doc.thumbnail.dao.ThumbnailRepository;
-import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail;
+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 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;
@@ -21,63 +23,65 @@
@RequiredArgsConstructor
public class DocListAssembler {
- private final ThumbnailRepository thumbnailRepository;
+ private final CommitContentAssembler commitContentAssembler;
+ private final SaveContentRepository saveContentRepository;
- @Value("${cloud.aws.s3.public-base-url}")
- private String cdnUrl;
+ private static final String DEFAULT_PREVIEW = "미리보기 없음";
public Page assembleDocList(Page docs) {
- 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
- );
- });
+ return docs
+ .map(doc -> {
+ Branch recentBranch = getMostRecentBranch(doc);
+ RecentActivityDto recent = getRecentActivity(recentBranch);
+ String preview = extractPreviewSafe(recentBranch, recent);
+ return DocMapper.toListResponse(doc, preview, recent);
+ });
}
- private String buildThumbnailUrl(Thumbnail thumbnail) {
- if (thumbnail == null || thumbnail.getCurrentImage() == null) {
- return null;
+ 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;
}
- return "%s/%s".formatted(cdnUrl, thumbnail.getCurrentImage().getObjectKey());
+ return switch (recent.recentType()) {
+ case COMMIT -> extractPreviewFromCommit(branch.getLeafCommit());
+ case SAVE -> extractPreviewFromSave(branch.getSave());
+ default -> DEFAULT_PREVIEW;
+ };
}
- private Thumbnail.ThumbnailStatus thumbnailStatusOf(Thumbnail thumbnail) {
- if (thumbnail == null) {
- return Thumbnail.ThumbnailStatus.EMPTY;
+ private String extractPreviewFromCommit(Commit commit) {
+ if (commit == null) {
+ return DEFAULT_PREVIEW;
}
- return thumbnail.getStatus();
+
+ List