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> content = commitContentAssembler.assemble( - commit.getCommitMongoId()); - return PreviewExtractor.doExtractPreview(content); + return thumbnail.getStatus(); } - private String extractPreviewFromSave(Save save) { - if (save == null) { - return DEFAULT_PREVIEW; - } - - SaveContent saveContent = saveContentRepository.findById(save.getSaveMongoId()) - .orElseThrow(() -> new CustomException(SaveErrorCode.SAVE_NOT_FOUND)); - List> content = saveContent.getContent(); - return PreviewExtractor.doExtractPreview(content); + public Page assembleDocListSimple(Page docs) { + return docs + .map(doc -> { + Branch recentBranch = getMostRecentBranch(doc); + RecentActivityDto recent = getRecentActivity(recentBranch); + return DocMapper.toListSimpleResponse(doc, recent); + }); } - private Branch getMostRecentBranch(Doc doc) { return doc.getBranches().stream() .max(Comparator.comparing(Branch::getUpdatedAt)) diff --git a/src/main/java/io/ejangs/docsa/domain/doc/util/DocMapper.java b/src/main/java/io/ejangs/docsa/domain/doc/util/DocMapper.java index a08151d4..8a9b0d83 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/util/DocMapper.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/util/DocMapper.java @@ -6,6 +6,7 @@ import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; import io.ejangs.docsa.domain.doc.dto.response.DocTitleUpdateResponse; import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.save.entity.Save; public class DocMapper { @@ -32,14 +33,16 @@ public static DocSimplePageResponse toListSimpleResponse(Doc doc, RecentActivity ); } - public static DocPageResponse toListResponse(Doc doc, String preview, + public static DocPageResponse toListResponse(Doc doc, String thumbnailUrl, + ThumbnailStatus thumbnailStatus, RecentActivityDto recent) { return new DocPageResponse( doc.getId(), doc.getTitle(), doc.getCreatedAt(), doc.getUpdatedAt(), - preview, + thumbnailUrl, + thumbnailStatus, recent ); } diff --git a/src/main/java/io/ejangs/docsa/domain/image/app/ImageQueryService.java b/src/main/java/io/ejangs/docsa/domain/image/app/ImageQueryService.java new file mode 100644 index 00000000..f93eee2b --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/image/app/ImageQueryService.java @@ -0,0 +1,21 @@ +package io.ejangs.docsa.domain.image.app; + +import io.ejangs.docsa.domain.image.dao.ImageRepository; +import io.ejangs.docsa.domain.image.entity.Image; +import io.ejangs.docsa.global.exception.CustomException; +import io.ejangs.docsa.global.exception.errorcode.ImageErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ImageQueryService { + + private final ImageRepository imageRepository; + + public Image getByIdAndUserId(Long imageId, Long userId) { + return imageRepository.findByIdAndUserId(imageId, userId) + .orElseThrow(() -> new CustomException(ImageErrorCode.IMAGE_NOT_FOUND)); + } + +} diff --git a/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java b/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java index 0e91ea9f..44030fc8 100644 --- a/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java +++ b/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java @@ -6,6 +6,7 @@ import io.ejangs.docsa.domain.image.dto.response.ImageUploadCompleteResponse; import io.ejangs.docsa.domain.image.dto.response.ImageUploadUrlResponse; import io.ejangs.docsa.domain.image.entity.Image; +import io.ejangs.docsa.domain.image.entity.Image.Purpose; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.ImageErrorCode; import java.time.Duration; @@ -31,6 +32,7 @@ public class ImageService { private static final long MAX_IMAGE_SIZE = 5 * 1024 * 1024; private final ImageRepository imageRepository; + private final ImageQueryService imageQueryService; private final DocQueryService docQueryService; private final S3Presigner s3Presigner; private final S3Client s3Client; @@ -54,7 +56,8 @@ public ImageUploadUrlResponse createUploadUrl(Long userId, ImageUploadUrlRequest validateImage(request.contentType(), request.size()); String extension = extensionOf(request.contentType()); - String objectKey = "users/%d/docs/%d/images/%s.%s" + + String objectKey = objectKeyOf(request.purpose()) .formatted(userId, request.docId(), UUID.randomUUID(), extension); Image image = imageRepository.save(Image.builder() @@ -64,6 +67,7 @@ public ImageUploadUrlResponse createUploadUrl(Long userId, ImageUploadUrlRequest .objectKey(objectKey) .contentType(request.contentType()) .size(request.size()) + .purpose(request.purpose()) .build()); PutObjectRequest putObjectRequest = PutObjectRequest.builder() @@ -88,14 +92,11 @@ public ImageUploadUrlResponse createUploadUrl(Long userId, ImageUploadUrlRequest "PUT", expireMinutes * 60 ); - - } @Transactional public ImageUploadCompleteResponse complete(Long userId, Long imageId) { - Image image = imageRepository.findByIdAndUserId(imageId, userId) - .orElseThrow(() -> new CustomException(ImageErrorCode.IMAGE_NOT_FOUND)); + Image image = imageQueryService.getByIdAndUserId(imageId, userId); String objectKey = image.getObjectKey(); @@ -138,6 +139,13 @@ private String extensionOf(String contentType) { }; } + private String objectKeyOf(Purpose purpose) { + return switch (purpose) { + case DOC_CONTENT -> "users/%d/docs/%d/images/%s.%s"; + case DOC_THUMBNAIL -> "users/%d/docs/%d/thumbnails/%s.%s"; + }; + } + private HeadObjectResponse getHeadObjectOrThrow(String objectKey) { try { return s3Client.headObject(HeadObjectRequest.builder() diff --git a/src/main/java/io/ejangs/docsa/domain/image/dto/request/ImageUploadUrlRequest.java b/src/main/java/io/ejangs/docsa/domain/image/dto/request/ImageUploadUrlRequest.java index 91aa28b3..bdb630a0 100644 --- a/src/main/java/io/ejangs/docsa/domain/image/dto/request/ImageUploadUrlRequest.java +++ b/src/main/java/io/ejangs/docsa/domain/image/dto/request/ImageUploadUrlRequest.java @@ -1,5 +1,6 @@ package io.ejangs.docsa.domain.image.dto.request; +import io.ejangs.docsa.domain.image.entity.Image.Purpose; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -22,7 +23,11 @@ public record ImageUploadUrlRequest( @Schema(description = "파일 크기(byte)", example = "102400") @NotNull(message = "Size Cannot be Null") @Positive(message = "Size Must be Positive Number") - Long size + Long size, + + @Schema(description = "이미지 업로드 목적(DOC_CONTENT/DOC_THUMBNAIL)", example = "DOC_CONTENT") + @NotNull(message = "Purpose Cannot be Null") + Purpose purpose ) { } diff --git a/src/main/java/io/ejangs/docsa/domain/image/entity/Image.java b/src/main/java/io/ejangs/docsa/domain/image/entity/Image.java index 027184c0..1a2294ca 100644 --- a/src/main/java/io/ejangs/docsa/domain/image/entity/Image.java +++ b/src/main/java/io/ejangs/docsa/domain/image/entity/Image.java @@ -23,9 +23,16 @@ public class Image extends BaseEntity { public enum ImageStatus { PENDING, ACTIVE, + DELETING, + DELETED, FAILED } + public enum Purpose { + DOC_CONTENT, + DOC_THUMBNAIL + } + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -52,14 +59,19 @@ public enum ImageStatus { @Column(nullable = false) private ImageStatus status; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Purpose purpose; + @Builder - private Image(Long userId, Long docId, String originalFileName, String objectKey, String contentType, Long size) { + private Image(Long userId, Long docId, String originalFileName, String objectKey, String contentType, Long size, Purpose purpose) { this.userId = userId; this.docId = docId; this.originalFileName = originalFileName; this.objectKey = objectKey; this.contentType = contentType; this.size = size; + this.purpose = purpose; this.status = ImageStatus.PENDING; } @@ -67,6 +79,14 @@ public void activate() { this.status = ImageStatus.ACTIVE; } + public void markDeleting() { + this.status = ImageStatus.DELETING; + } + + public void markDeleted() { + this.status = ImageStatus.DELETED; + } + public void fail() { this.status = ImageStatus.FAILED; } diff --git a/src/main/java/io/ejangs/docsa/domain/image/swagger/CreateImageUploadUrlDocs.java b/src/main/java/io/ejangs/docsa/domain/image/swagger/CreateImageUploadUrlDocs.java index 48173218..d1eeb598 100644 --- a/src/main/java/io/ejangs/docsa/domain/image/swagger/CreateImageUploadUrlDocs.java +++ b/src/main/java/io/ejangs/docsa/domain/image/swagger/CreateImageUploadUrlDocs.java @@ -37,7 +37,8 @@ "docId": 1, "originalFileName": "profile.png", "contentType": "image/png", - "size": 102400 + "size": 102400, + "purpose": "DOC_CONTENT" } """ ) diff --git a/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java b/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java index 64e109ab..c47a0afe 100644 --- a/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java +++ b/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java @@ -1,6 +1,8 @@ package io.ejangs.docsa.domain.save.app; import com.mongodb.DuplicateKeyException; +import io.ejangs.docsa.domain.doc.thumbnail.app.ThumbnailService; +import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse; import io.ejangs.docsa.domain.save.document.SaveContent; import io.ejangs.docsa.domain.save.dto.SaveIdentifierDto; import io.ejangs.docsa.domain.save.dto.request.SaveUpdateRequest; @@ -24,6 +26,7 @@ public class SaveService { private final SaveQueryService saveQueryService; + private final ThumbnailService thumbnailService; @Transactional(readOnly = true) public SaveGetResponse getSave(SaveIdentifierDto dto) { @@ -41,6 +44,11 @@ public SaveUpdateResponse updateSave(SaveIdentifierDto dto, SaveUpdateRequest re RenewUpdatedAtHelper.touch(findSave); saveQueryService.saveSave(findSave); + ThumbnailSyncResponse thumbnailSyncResponse = thumbnailService.requestUpdate( + dto.userId(), + dto.documentId() + ); + // MongoDB 저장 try { SaveContent saveContent = saveQueryService.getSaveContentById(findSave.getSaveMongoId()); @@ -55,14 +63,13 @@ public SaveUpdateResponse updateSave(SaveIdentifierDto dto, SaveUpdateRequest re throw new CustomException(SaveErrorCode.FAIL_TO_SAVE); } - return SaveMapper.toSaveUpdateResponse(findSave.getUpdatedAt()); + return SaveMapper.toSaveUpdateResponse(findSave.getUpdatedAt(), thumbnailSyncResponse); } private Save getValidSave(SaveIdentifierDto dto) { - // 존재하는 user, document 인지 검사 + Save findSave = saveQueryService.getSaveById(dto.saveId()); - // SAVE 소유자인지 검사 saveQueryService.checkSaveAndDocOwner(findSave, dto.userId(), dto.documentId()); return findSave; } diff --git a/src/main/java/io/ejangs/docsa/domain/save/dto/response/SaveUpdateResponse.java b/src/main/java/io/ejangs/docsa/domain/save/dto/response/SaveUpdateResponse.java index b17be346..b1c34d32 100644 --- a/src/main/java/io/ejangs/docsa/domain/save/dto/response/SaveUpdateResponse.java +++ b/src/main/java/io/ejangs/docsa/domain/save/dto/response/SaveUpdateResponse.java @@ -1,10 +1,19 @@ package io.ejangs.docsa.domain.save.dto.response; +import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; -public record SaveUpdateResponse(LocalDateTime updatedAt) { +@Schema(description = "저장 수정 응답") +public record SaveUpdateResponse( + @Schema(description = "저장 수정 시각", example = "2026-04-26T21:30:00") + LocalDateTime updatedAt, + @Schema(description = "프론트 썸네일 동기화 판단에 필요한 정보") + ThumbnailSyncResponse thumbnail +) { - public SaveUpdateResponse(LocalDateTime updatedAt) { + public SaveUpdateResponse(LocalDateTime updatedAt, ThumbnailSyncResponse thumbnail) { this.updatedAt = updatedAt.plusHours(9L); + this.thumbnail = thumbnail; } } diff --git a/src/main/java/io/ejangs/docsa/domain/save/swagger/UpdateSaveDocs.java b/src/main/java/io/ejangs/docsa/domain/save/swagger/UpdateSaveDocs.java index 60fdd294..6815717e 100644 --- a/src/main/java/io/ejangs/docsa/domain/save/swagger/UpdateSaveDocs.java +++ b/src/main/java/io/ejangs/docsa/domain/save/swagger/UpdateSaveDocs.java @@ -94,7 +94,12 @@ examples = @ExampleObject( value = """ { - "updatedAt": "2025-07-07T14:21:00" + "updatedAt": "2026-04-26T21:30:00", + "thumbnail": { + "requestToken": 12, + "signature": "9f0c1dd5d7e8b8f2a1f4c3d2e6a7b8c9", + "status": "PENDING" + } } """ ) diff --git a/src/main/java/io/ejangs/docsa/domain/save/util/SaveMapper.java b/src/main/java/io/ejangs/docsa/domain/save/util/SaveMapper.java index 94113f7b..3bf5c9c1 100644 --- a/src/main/java/io/ejangs/docsa/domain/save/util/SaveMapper.java +++ b/src/main/java/io/ejangs/docsa/domain/save/util/SaveMapper.java @@ -1,5 +1,6 @@ package io.ejangs.docsa.domain.save.util; +import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse; import io.ejangs.docsa.domain.save.dto.response.SaveGetResponse; import io.ejangs.docsa.domain.save.dto.response.SaveUpdateResponse; import java.time.LocalDateTime; @@ -8,8 +9,9 @@ public class SaveMapper { - public static SaveUpdateResponse toSaveUpdateResponse(LocalDateTime localDateTime) { - return new SaveUpdateResponse(localDateTime); + public static SaveUpdateResponse toSaveUpdateResponse(LocalDateTime localDateTime, + ThumbnailSyncResponse thumbnailSyncResponse) { + return new SaveUpdateResponse(localDateTime, thumbnailSyncResponse); } public static SaveGetResponse toSaveGetResponse(LocalDateTime localDateTime, diff --git a/src/main/java/io/ejangs/docsa/global/config/JpaConfig.java b/src/main/java/io/ejangs/docsa/global/config/JpaConfig.java index ccd8f25d..3b7e2b36 100644 --- a/src/main/java/io/ejangs/docsa/global/config/JpaConfig.java +++ b/src/main/java/io/ejangs/docsa/global/config/JpaConfig.java @@ -15,11 +15,13 @@ "io.ejangs.docsa.domain.branch.dao.mysql", "io.ejangs.docsa.domain.commit.dao.mysql", "io.ejangs.docsa.domain.doc.dao.mysql", + "io.ejangs.docsa.domain.doc.thumbnail.dao", "io.ejangs.docsa.domain.edge.dao.mysql", "io.ejangs.docsa.domain.save.dao.mysql", "io.ejangs.docsa.domain.user.dao.mysql", - "io.ejangs.docsa.global.mongo.outbox.dao.mysql", - "io.ejangs.docsa.domain.image.dao" + "io.ejangs.docsa.global.outbox.mongo.dao.mysql", + "io.ejangs.docsa.domain.image.dao", + "io.ejangs.docsa.global.outbox.s3.dao" }) public class JpaConfig { diff --git a/src/main/java/io/ejangs/docsa/global/exception/errorcode/BranchErrorCode.java b/src/main/java/io/ejangs/docsa/global/exception/errorcode/BranchErrorCode.java index 70d810cb..1436f186 100644 --- a/src/main/java/io/ejangs/docsa/global/exception/errorcode/BranchErrorCode.java +++ b/src/main/java/io/ejangs/docsa/global/exception/errorcode/BranchErrorCode.java @@ -8,14 +8,13 @@ @RequiredArgsConstructor public enum BranchErrorCode implements ErrorCode { - BRANCH_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 분기를 찾을 수 없습니다.", "BRANCH_NOT_FOUND"), - BRANCH_NOT_FOUND_OR_FORBIDDEN(HttpStatus.NOT_FOUND, "해당 분기를 찾을 수 없습니다.", "BRANCH_NOT_FOUND_OR_FORBIDDEN"), - MAIN_BRANCH_FIX_UNAVAILABLE(HttpStatus.BAD_REQUEST, "Main 분기는 삭제 또는 수정할 수 없습니다.", "MAIN_BRANCH_FIX_UNAVAILABLE"), - SUB_BRANCH_DELETE_UNAVAILABLE(HttpStatus.BAD_REQUEST, "서브 분기가 있어 해당 버전을 삭제할 수 없습니다.", "SUB_BRANCH_DELETE_UNAVAILABLE"), - BRANCH_DELETE_UNAVAILABLE(HttpStatus.BAD_REQUEST, "해당 분기를 삭제할 수 없습니다.", "BRANCH_DELETE_UNAVAILABLE"), - BRANCH_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "새로운 분기의 이름은 다른 버전의 이름과 중복될 수 없습니다.", + BRANCH_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 브랜치를 찾을 수 없습니다.", "BRANCH_NOT_FOUND"), + MAIN_BRANCH_FIX_UNAVAILABLE(HttpStatus.BAD_REQUEST, "메인 브랜치는 삭제 또는 수정할 수 없습니다.", "MAIN_BRANCH_FIX_UNAVAILABLE"), + SUB_BRANCH_DELETE_UNAVAILABLE(HttpStatus.BAD_REQUEST, "서브 브랜치가 있어 해당 브랜치를 삭제할 수 없습니다.", "SUB_BRANCH_DELETE_UNAVAILABLE"), + BRANCH_DELETE_UNAVAILABLE(HttpStatus.BAD_REQUEST, "해당 브랜치를 삭제할 수 없습니다.", "BRANCH_DELETE_UNAVAILABLE"), + BRANCH_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "새로운 브랜치의 이름은 다른 브랜치의 이름과 중복될 수 없습니다.", "BRANCH_NAME_DUPLICATED"), - FAIL_CREATE_BRANCH(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류로 인해 분기 생성에 실패했습니다.", + FAIL_CREATE_BRANCH(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류로 인해 브랜치 생성에 실패했습니다.", "FAIL_CREATE_BRANCH"); private final HttpStatus status; private final String message; diff --git a/src/main/java/io/ejangs/docsa/global/exception/errorcode/CommitErrorCode.java b/src/main/java/io/ejangs/docsa/global/exception/errorcode/CommitErrorCode.java index 4f78fc7d..86a75313 100644 --- a/src/main/java/io/ejangs/docsa/global/exception/errorcode/CommitErrorCode.java +++ b/src/main/java/io/ejangs/docsa/global/exception/errorcode/CommitErrorCode.java @@ -11,7 +11,7 @@ public enum CommitErrorCode implements ErrorCode { COMMIT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 기록을 찾을 수 없습니다.", "COMMIT_NOT_FOUND"), COMMIT_BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.", "COMMIT_BAD_REQUEST"), CAN_NOT_DELETE_COMMIT(HttpStatus.BAD_REQUEST, "이 기록은 삭제할 수 없습니다.", "CAN_NOT_DELETE_COMMIT"), - IS_NOT_LEAF_COMMIT(HttpStatus.BAD_REQUEST, "버전의 마지막 기록이 아닙니다.", "IS_NOT_LEAF_COMMIT"), + IS_NOT_LEAF_COMMIT(HttpStatus.BAD_REQUEST, "브랜치의 마지막 기록이 아닙니다.", "IS_NOT_LEAF_COMMIT"), FAIL_CREATE_COMMIT(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류로 인해 기록 생성에 실패했습니다.", "FAIL_CREATE_COMMIT"), FAIL_MERGE(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류로 인해 병합에 실패했습니다.", "FAIL_MERGE"), @@ -23,4 +23,3 @@ public enum CommitErrorCode implements ErrorCode { private final String error; } - diff --git a/src/main/java/io/ejangs/docsa/global/exception/errorcode/ThumbnailErrorCode.java b/src/main/java/io/ejangs/docsa/global/exception/errorcode/ThumbnailErrorCode.java new file mode 100644 index 00000000..1d785fc3 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/exception/errorcode/ThumbnailErrorCode.java @@ -0,0 +1,19 @@ +package io.ejangs.docsa.global.exception.errorcode; + +import javax.swing.text.html.HTML; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ThumbnailErrorCode implements ErrorCode{ + + THUMBNAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "썸네일 정보를 찾을 수 없습니다.", "THUMBNAIL_NOT_FOUND"), + STALE_THUMBNAIL_REQUEST(HttpStatus.CONFLICT, "최신 썸네일 요청이 아닙니다.", "STALE_THUMBNAIL_REQUEST"), + INVALID_THUMBNAIL_PURPOSE(HttpStatus.BAD_REQUEST, "썸네일 용도의 이미지가 아닙니다.", "INVALID_THUMBNAIL_PURPOSE"); + + private final HttpStatus status; + private final String message; + private final String error; +} diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/dao/mysql/MongoDeleteOutboxRepository.java b/src/main/java/io/ejangs/docsa/global/mongo/outbox/dao/mysql/MongoDeleteOutboxRepository.java deleted file mode 100644 index 49731edd..00000000 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/dao/mysql/MongoDeleteOutboxRepository.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.ejangs.docsa.global.mongo.outbox.dao.mysql; - -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; -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 java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface MongoDeleteOutboxRepository extends JpaRepository { - - List findTop100ByStatusOrderByCreatedAtAsc(MongoDeleteOutbox.OutboxStatus status); - - List findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( - MongoDeleteOutbox.OutboxStatus status, - LocalDateTime updatedAt - ); - - Optional findByIdAndStatus(Long id, MongoDeleteOutbox.OutboxStatus status); - - Optional findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId( - TriggerType triggerType, - DomainType domainType, - OriginType originType, - String originId - ); -} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/BaseOutboxEntity.java b/src/main/java/io/ejangs/docsa/global/outbox/BaseOutboxEntity.java new file mode 100644 index 00000000..21533fa6 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/BaseOutboxEntity.java @@ -0,0 +1,71 @@ +package io.ejangs.docsa.global.outbox; + +import io.ejangs.docsa.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Version; +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +@MappedSuperclass +public abstract class BaseOutboxEntity extends BaseEntity { + + @Column(nullable = false, length = 32) + @Enumerated(EnumType.STRING) + protected OutboxStatus status; + + @Column(nullable = false) + protected Integer retryCount; + + @Column(nullable = false) + protected Integer maxRetry; + + protected LocalDateTime doneAt; + + @Column(length = 2000) + protected String lastError; + + @Version + protected Long version; + + protected void initOutbox() { + this.status = OutboxStatus.OPEN; + this.retryCount = 0; + this.maxRetry = 10; + } + + public void markProcessing() { + this.status = OutboxStatus.PROCESSING; + updateTimestamp(); + } + + public void markDone() { + this.status = OutboxStatus.DONE; + this.doneAt = LocalDateTime.now(); + this.lastError = null; + updateTimestamp(); + } + + public void markRetry(String errorMessage) { + this.retryCount = this.retryCount + 1; + this.lastError = errorMessage; + + if (this.retryCount >= this.maxRetry) { + this.status = OutboxStatus.FAILED; + updateTimestamp(); + return; + } + + this.status = OutboxStatus.OPEN; + updateTimestamp(); + } + + public void recoverProcessingTimeout(String errorMessage) { + this.status = OutboxStatus.OPEN; + this.lastError = errorMessage; + updateTimestamp(); + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/OutboxStatus.java b/src/main/java/io/ejangs/docsa/global/outbox/OutboxStatus.java new file mode 100644 index 00000000..300eec75 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/OutboxStatus.java @@ -0,0 +1,8 @@ +package io.ejangs.docsa.global.outbox; + +public enum OutboxStatus { + OPEN, + PROCESSING, + DONE, + FAILED +} diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxCreateService.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxCreateService.java similarity index 72% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxCreateService.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxCreateService.java index 45debab2..a20e7de3 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxCreateService.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxCreateService.java @@ -1,7 +1,7 @@ -package io.ejangs.docsa.global.mongo.outbox.app; +package io.ejangs.docsa.global.outbox.mongo.app; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; +import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxFactory.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java similarity index 88% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxFactory.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java index 7d777820..90a7e42c 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxFactory.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java @@ -1,13 +1,13 @@ -package io.ejangs.docsa.global.mongo.outbox.app; +package io.ejangs.docsa.global.outbox.mongo.app; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.DatabaseErrorCode; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; -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.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; +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 java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxLifecycleService.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxLifecycleService.java similarity index 71% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxLifecycleService.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxLifecycleService.java index c92104b9..27aed7f8 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxLifecycleService.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxLifecycleService.java @@ -1,8 +1,9 @@ -package io.ejangs.docsa.global.mongo.outbox.app; +package io.ejangs.docsa.global.outbox.mongo.app; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; @@ -19,15 +20,18 @@ public class MongoDeleteOutboxLifecycleService { private final MongoDeleteOutboxRepository mongoDeleteOutboxRepository; public MongoIdsDto claimOpen(Long outboxId) { + int claimed = mongoDeleteOutboxRepository.claimOpenById(outboxId); + if (claimed == 0) { + return null; + } + MongoDeleteOutbox targetOutbox = mongoDeleteOutboxRepository - .findByIdAndStatus(outboxId, MongoDeleteOutbox.OutboxStatus.OPEN) + .findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) .orElse(null); if (targetOutbox == null) { return null; } - targetOutbox.markProcessing(); - mongoDeleteOutboxRepository.save(targetOutbox); return new MongoIdsDto( targetOutbox.getSaveContentIds(), targetOutbox.getCommitBlockSequenceIds(), @@ -37,7 +41,7 @@ public MongoIdsDto claimOpen(Long outboxId) { public void done(Long outboxId) { MongoDeleteOutbox targetOutbox = mongoDeleteOutboxRepository - .findByIdAndStatus(outboxId, MongoDeleteOutbox.OutboxStatus.PROCESSING) + .findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) .orElse(null); if (targetOutbox == null) { return; @@ -49,7 +53,7 @@ public void done(Long outboxId) { public void retry(Long outboxId, String errorMessage) { MongoDeleteOutbox targetOutbox = mongoDeleteOutboxRepository - .findByIdAndStatus(outboxId, MongoDeleteOutbox.OutboxStatus.PROCESSING) + .findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) .orElse(null); if (targetOutbox == null) { return; @@ -62,14 +66,14 @@ public void retry(Long outboxId, String errorMessage) { public int recoverTimedOutProcessing(LocalDateTime threshold) { List stuckOutboxes = mongoDeleteOutboxRepository .findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( - MongoDeleteOutbox.OutboxStatus.PROCESSING, + OutboxStatus.PROCESSING, threshold ); if (stuckOutboxes.isEmpty()) { return 0; } - stuckOutboxes.forEach(outbox -> outbox.markRetry(outbox.getLastError() + " (recovered)")); + stuckOutboxes.forEach(outbox -> outbox.recoverProcessingTimeout("PROCESSING timeout recovered")); mongoDeleteOutboxRepository.saveAll(stuckOutboxes); return stuckOutboxes.size(); } diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxWorker.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxWorker.java similarity index 86% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxWorker.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxWorker.java index c2e0c2ef..e07eadb5 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxWorker.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxWorker.java @@ -1,8 +1,9 @@ -package io.ejangs.docsa.global.mongo.outbox.app; +package io.ejangs.docsa.global.outbox.mongo.app; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -42,7 +43,7 @@ public void run() { } List outboxes = mongoDeleteOutboxRepository - .findTop100ByStatusOrderByCreatedAtAsc(MongoDeleteOutbox.OutboxStatus.OPEN); + .findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus.OPEN); if (outboxes.isEmpty()) { return; } diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteService.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteService.java similarity index 90% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteService.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteService.java index 0ec340ef..fe401b31 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteService.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteService.java @@ -1,9 +1,9 @@ -package io.ejangs.docsa.global.mongo.outbox.app; +package io.ejangs.docsa.global.outbox.mongo.app; import io.ejangs.docsa.domain.block.dao.mongodb.BlockRepository; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.save.dao.mongodb.SaveContentRepository; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/io/ejangs/docsa/global/outbox/mongo/dao/mysql/MongoDeleteOutboxRepository.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/dao/mysql/MongoDeleteOutboxRepository.java new file mode 100644 index 00000000..d81c1bd4 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/dao/mysql/MongoDeleteOutboxRepository.java @@ -0,0 +1,44 @@ +package io.ejangs.docsa.global.outbox.mongo.dao.mysql; + +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; +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 java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MongoDeleteOutboxRepository extends JpaRepository { + + List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status); + + List findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( + OutboxStatus status, + LocalDateTime updatedAt + ); + + Optional findByIdAndStatus(Long id, OutboxStatus status); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + update mongo_delete_outbox + set status = 'PROCESSING', + updated_at = current_timestamp, + version = version + 1 + where id = :outboxId + and status = 'OPEN' + """, nativeQuery = true) + int claimOpenById(@Param("outboxId") Long outboxId); + + Optional findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId( + TriggerType triggerType, + DomainType domainType, + OriginType originType, + String originId + ); +} diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/dto/CommitMongoIdsDto.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/dto/CommitMongoIdsDto.java similarity index 70% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/dto/CommitMongoIdsDto.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/dto/CommitMongoIdsDto.java index d0ef7d31..4e159a51 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/dto/CommitMongoIdsDto.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/dto/CommitMongoIdsDto.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.global.mongo.outbox.dto; +package io.ejangs.docsa.global.outbox.mongo.dto; import java.util.List; diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/dto/MongoIdsDto.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/dto/MongoIdsDto.java similarity index 94% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/dto/MongoIdsDto.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/dto/MongoIdsDto.java index 0d8d13c2..e309bea9 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/dto/MongoIdsDto.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/dto/MongoIdsDto.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.global.mongo.outbox.dto; +package io.ejangs.docsa.global.outbox.mongo.dto; import java.util.List; diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/entity/MongoDeleteOutbox.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/entity/MongoDeleteOutbox.java similarity index 68% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/entity/MongoDeleteOutbox.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/entity/MongoDeleteOutbox.java index ab92566d..fad8a65f 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/entity/MongoDeleteOutbox.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/entity/MongoDeleteOutbox.java @@ -1,6 +1,6 @@ -package io.ejangs.docsa.global.mongo.outbox.entity; +package io.ejangs.docsa.global.outbox.mongo.entity; -import io.ejangs.docsa.global.common.BaseEntity; +import io.ejangs.docsa.global.outbox.BaseOutboxEntity; import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; @@ -10,11 +10,10 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; -import jakarta.persistence.Version; -import java.time.LocalDateTime; import java.util.List; import lombok.AccessLevel; import lombok.Getter; @@ -28,18 +27,15 @@ name = "uk_mongo_delete_outbox_trigger_domain_origin", columnNames = {"trigger_type", "domain_type", "origin_type", "origin_id"} ) + }, + indexes = { + @Index(name = "idx_mongo_delete_outbox_status_created_at", columnList = "status, created_at"), + @Index(name = "idx_mongo_delete_outbox_status_updated_at", columnList = "status, updated_at") } ) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class MongoDeleteOutbox extends BaseEntity { - - public enum OutboxStatus { - OPEN, - PROCESSING, - DONE, - FAILED - } +public class MongoDeleteOutbox extends BaseOutboxEntity { public enum TriggerType { DELETE, @@ -97,24 +93,6 @@ public enum OriginType { @Column(name = "block_id") private List blockIds; - @Column(nullable = false, length = 32) - @Enumerated(EnumType.STRING) - private OutboxStatus status; - - @Column(nullable = false) - private Integer retryCount; - - @Column(nullable = false) - private Integer maxRetry; - - private LocalDateTime doneAt; - - @Column(length = 2000) - private String lastError; - - @Version - private Long version; - public static MongoDeleteOutbox open( TriggerType triggerType, DomainType domainType, @@ -133,36 +111,7 @@ public static MongoDeleteOutbox open( outbox.saveContentIds = saveIds; outbox.commitBlockSequenceIds = commitIds; outbox.blockIds = blockIds; - outbox.status = OutboxStatus.OPEN; - outbox.retryCount = 0; - outbox.maxRetry = 10; + outbox.initOutbox(); return outbox; } - - public void markProcessing() { - this.status = OutboxStatus.PROCESSING; - updateTimestamp(); - } - - public void markDone() { - this.status = OutboxStatus.DONE; - this.doneAt = LocalDateTime.now(); - this.lastError = null; - updateTimestamp(); - } - - public void markRetry(String errorMessage) { - this.retryCount = this.retryCount + 1; - this.lastError = errorMessage; - - if (this.retryCount >= this.maxRetry) { - this.status = OutboxStatus.FAILED; - updateTimestamp(); - return; - } - - this.status = OutboxStatus.OPEN; - updateTimestamp(); - } - } diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/util/MongoDeleteMapper.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/util/MongoDeleteMapper.java similarity index 86% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/util/MongoDeleteMapper.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/util/MongoDeleteMapper.java index df23448f..177f61e7 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/util/MongoDeleteMapper.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/util/MongoDeleteMapper.java @@ -1,8 +1,8 @@ -package io.ejangs.docsa.global.mongo.outbox.util; +package io.ejangs.docsa.global.outbox.mongo.util; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.save.entity.Save; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import java.util.List; import java.util.Objects; import java.util.Optional; diff --git a/src/main/java/io/ejangs/docsa/global/mongo/outbox/util/MongoIdsCollector.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/util/MongoIdsCollector.java similarity index 95% rename from src/main/java/io/ejangs/docsa/global/mongo/outbox/util/MongoIdsCollector.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/util/MongoIdsCollector.java index ca3c4bba..37d2e67d 100644 --- a/src/main/java/io/ejangs/docsa/global/mongo/outbox/util/MongoIdsCollector.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/util/MongoIdsCollector.java @@ -1,10 +1,10 @@ -package io.ejangs.docsa.global.mongo.outbox.util; +package io.ejangs.docsa.global.outbox.mongo.util; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.save.entity.Save; -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.Objects; diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxFactory.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxFactory.java new file mode 100644 index 00000000..ec11e2ce --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxFactory.java @@ -0,0 +1,24 @@ +package io.ejangs.docsa.global.outbox.s3.app; + +import io.ejangs.docsa.domain.image.entity.Image; +import io.ejangs.docsa.global.outbox.s3.dao.S3DeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.s3.entity.S3DeleteOutbox; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class S3DeleteOutboxFactory { + + private final S3DeleteOutboxRepository s3DeleteOutboxRepository; + + public void enqueueImageDeletion(Image image) { + Objects.requireNonNull(image, "image is required"); + Objects.requireNonNull(image.getId(), "imageId is required"); + Objects.requireNonNull(image.getObjectKey(), "objectKey is required"); + + image.markDeleting(); + s3DeleteOutboxRepository.insertOpenIfAbsent(image.getId(), image.getObjectKey()); + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxLifecycleService.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxLifecycleService.java new file mode 100644 index 00000000..78fb65e5 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxLifecycleService.java @@ -0,0 +1,81 @@ +package io.ejangs.docsa.global.outbox.s3.app; + +import io.ejangs.docsa.domain.image.dao.ImageRepository; +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.s3.dao.S3DeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.s3.dto.S3DeleteTarget; +import io.ejangs.docsa.global.outbox.s3.entity.S3DeleteOutbox; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(rollbackFor = Exception.class) +@RequiredArgsConstructor +public class S3DeleteOutboxLifecycleService { + + private final S3DeleteOutboxRepository s3DeleteOutboxRepository; + private final ImageRepository imageRepository; + + public S3DeleteTarget claimOpen(Long outboxId) { + int claimed = s3DeleteOutboxRepository.claimOpenById(outboxId); + if (claimed == 0) { + return null; + } + + S3DeleteOutbox targetOutbox = s3DeleteOutboxRepository + .findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) + .orElse(null); + if (targetOutbox == null) { + return null; + } + + return new S3DeleteTarget(targetOutbox.getImageId(), targetOutbox.getObjectKey()); + } + + public void done(Long outboxId) { + S3DeleteOutbox targetOutbox = s3DeleteOutboxRepository + .findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) + .orElse(null); + if (targetOutbox == null) { + return; + } + + targetOutbox.markDone(); + s3DeleteOutboxRepository.save(targetOutbox); + imageRepository.findById(targetOutbox.getImageId()) + .ifPresent(image -> { + image.markDeleted(); + imageRepository.save(image); + }); + } + + public void retry(Long outboxId, String errorMessage) { + S3DeleteOutbox targetOutbox = s3DeleteOutboxRepository + .findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) + .orElse(null); + if (targetOutbox == null) { + return; + } + + targetOutbox.markRetry(errorMessage); + s3DeleteOutboxRepository.save(targetOutbox); + } + + public int recoverTimedOutProcessing(LocalDateTime threshold) { + List stuckOutboxes = s3DeleteOutboxRepository + .findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( + OutboxStatus.PROCESSING, + threshold + ); + if (stuckOutboxes.isEmpty()) { + return 0; + } + + stuckOutboxes.forEach(outbox -> outbox.recoverProcessingTimeout("PROCESSING timeout recovered")); + s3DeleteOutboxRepository.saveAll(stuckOutboxes); + return stuckOutboxes.size(); + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxWorker.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxWorker.java new file mode 100644 index 00000000..006b9aaa --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxWorker.java @@ -0,0 +1,71 @@ +package io.ejangs.docsa.global.outbox.s3.app; + +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.s3.dao.S3DeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.s3.dto.S3DeleteTarget; +import io.ejangs.docsa.global.outbox.s3.entity.S3DeleteOutbox; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "s3.delete.outbox.worker", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class S3DeleteOutboxWorker { + + private static final Duration PROCESSING_TIMEOUT = Duration.ofMinutes(5); + + private final S3DeleteOutboxRepository s3DeleteOutboxRepository; + private final S3DeleteService s3DeleteService; + private final S3DeleteOutboxLifecycleService s3DeleteOutboxLifecycleService; + + @Scheduled( + fixedDelayString = "${s3.delete.outbox.worker.fixed-delay:PT1M}", + initialDelayString = "${s3.delete.outbox.worker.initial-delay:PT0S}" + ) + public void run() { + int recovered = s3DeleteOutboxLifecycleService.recoverTimedOutProcessing( + LocalDateTime.now().minus(PROCESSING_TIMEOUT) + ); + if (recovered > 0) { + log.warn("[S3 Outbox Worker] Recovered timed-out PROCESSING rows: {}", recovered); + } + + List outboxes = s3DeleteOutboxRepository + .findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus.OPEN); + if (outboxes.isEmpty()) { + return; + } + + deleteTargets(outboxes); + log.info("[S3 Outbox Worker] Complete target delete : {}", outboxes.size()); + } + + private void deleteTargets(List outboxes) { + for (S3DeleteOutbox outbox : outboxes) { + S3DeleteTarget target = s3DeleteOutboxLifecycleService.claimOpen(outbox.getId()); + if (target == null) { + continue; + } + + try { + s3DeleteService.deleteTarget(target); + s3DeleteOutboxLifecycleService.done(outbox.getId()); + } catch (Exception e) { + log.error("[S3 Outbox Worker] Error : {}", e.getMessage(), e); + s3DeleteOutboxLifecycleService.retry(outbox.getId(), e.getMessage()); + } + } + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteService.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteService.java new file mode 100644 index 00000000..f3f6f383 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteService.java @@ -0,0 +1,33 @@ +package io.ejangs.docsa.global.outbox.s3.app; + +import io.ejangs.docsa.global.outbox.s3.dto.S3DeleteTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Service +@RequiredArgsConstructor +public class S3DeleteService { + + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public void deleteTarget(S3DeleteTarget target) { + try { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(target.objectKey()) + .build()); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return; + } + throw e; + } + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/dao/S3DeleteOutboxRepository.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/dao/S3DeleteOutboxRepository.java new file mode 100644 index 00000000..cff592ae --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/dao/S3DeleteOutboxRepository.java @@ -0,0 +1,45 @@ +package io.ejangs.docsa.global.outbox.s3.dao; + +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.s3.entity.S3DeleteOutbox; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface S3DeleteOutboxRepository extends JpaRepository { + + List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status); + + List findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( + OutboxStatus status, + LocalDateTime updatedAt + ); + + Optional findByIdAndStatus(Long id, OutboxStatus status); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + update s3_delete_outbox + set status = 'PROCESSING', + updated_at = current_timestamp, + version = version + 1 + where id = :outboxId + and status = 'OPEN' + """, nativeQuery = true) + int claimOpenById(@Param("outboxId") Long outboxId); + + @Modifying + @Query(value = """ + insert into s3_delete_outbox + (created_at, updated_at, image_id, object_key, status, retry_count, max_retry, version) + values + (now(6), now(6), :imageId, :objectKey, 'OPEN', 0, 10, 0) + on duplicate key update + object_key = object_key + """, nativeQuery = true) + void insertOpenIfAbsent(@Param("imageId") Long imageId, @Param("objectKey") String objectKey); +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/dto/S3DeleteTarget.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/dto/S3DeleteTarget.java new file mode 100644 index 00000000..f65af433 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/dto/S3DeleteTarget.java @@ -0,0 +1,7 @@ +package io.ejangs.docsa.global.outbox.s3.dto; + +public record S3DeleteTarget( + Long imageId, + String objectKey +) { +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/entity/S3DeleteOutbox.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/entity/S3DeleteOutbox.java new file mode 100644 index 00000000..00efb477 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/entity/S3DeleteOutbox.java @@ -0,0 +1,48 @@ +package io.ejangs.docsa.global.outbox.s3.entity; + +import io.ejangs.docsa.global.outbox.BaseOutboxEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "s3_delete_outbox", + uniqueConstraints = { + @UniqueConstraint(name = "uk_s3_delete_outbox_object_key", columnNames = "object_key") + }, + indexes = { + @Index(name = "idx_s3_delete_outbox_status_created_at", columnList = "status, created_at"), + @Index(name = "idx_s3_delete_outbox_status_updated_at", columnList = "status, updated_at") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class S3DeleteOutbox extends BaseOutboxEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long imageId; + + @Column(name = "object_key", nullable = false, length = 500) + private String objectKey; + + public static S3DeleteOutbox open(Long imageId, String objectKey) { + S3DeleteOutbox outbox = new S3DeleteOutbox(); + outbox.imageId = imageId; + outbox.objectKey = objectKey; + outbox.initOutbox(); + return outbox; + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 6ff7c820..0b843e06 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -16,19 +16,24 @@ spring: starttls-enable: ${STARTTLS_ENABLE:false} starttls-required: ${STARTTLS_REQUIRED:false} + flyway: + enabled: ${FLYWAY_ENABLED:false} + baseline-on-migrate: ${FLYWAY_BASELINE_ON_MIGRATE:false} + baseline-version: ${FLYWAY_BASELINE_VERSION:1} + jpa: hibernate: - ddl-auto: create-drop + ddl-auto: ${DDL_AUTO:create-drop} properties: hibernate: show_sql: false - format_sql: true - highlight_sql: true + format_sql: false + highlight_sql: false decorator: datasource: datasource-proxy: - enabled: true + enabled: false logging: slf4j multiline: true format-sql: true @@ -43,7 +48,7 @@ decorator: logging: level: - net.ttddyy.dsproxy.listener: DEBUG + net.ttddyy.dsproxy.listener: OFF server: port: 8080 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5c239b98..91e8e597 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -5,6 +5,11 @@ spring: username: ${MYSQL_USERNAME} password: ${MYSQL_PASSWORD} + flyway: + enabled: ${FLYWAY_ENABLED:true} + baseline-on-migrate: ${FLYWAY_BASELINE_ON_MIGRATE:true} + baseline-version: ${FLYWAY_BASELINE_VERSION:1} + mail: host: smtp.gmail.com port: 587 diff --git a/src/main/resources/application-stg.yml b/src/main/resources/application-stg.yml index eb44e05b..18c0faf4 100644 --- a/src/main/resources/application-stg.yml +++ b/src/main/resources/application-stg.yml @@ -5,6 +5,11 @@ spring: username: ${MYSQL_USERNAME} password: ${MYSQL_PASSWORD} + flyway: + enabled: ${FLYWAY_ENABLED:true} + baseline-on-migrate: ${FLYWAY_BASELINE_ON_MIGRATE:true} + baseline-version: ${FLYWAY_BASELINE_VERSION:1} + mail: host: mailpit port: 1025 @@ -72,18 +77,13 @@ springdoc: decorator: datasource: datasource-proxy: - enabled: true - logging: slf4j - multiline: true - format-sql: true - count-query: true + enabled: false query: - enable-logging: true - log-level: debug + enable-logging: false slow-query: enable-logging: true log-level: warn - threshold: 100 + threshold: 300 logging: level: diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 00000000..f353118b --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,142 @@ +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE `branches` ( + `created_at` datetime(6) NOT NULL, + `document_id` bigint DEFAULT NULL, + `from_commit_id` bigint DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `leaf_commit_id` bigint DEFAULT NULL, + `merge_target_commit_id` bigint DEFAULT NULL, + `root_commit_id` bigint DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `name` varchar(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UKf6lkgdoc9cekvllr7juun2571` (`name`,`document_id`), + UNIQUE KEY `UK3xq7lqgyigivvjp137bq1vmqp` (`leaf_commit_id`), + UNIQUE KEY `UKhr8mfwhefxqiel77vtf8swma0` (`root_commit_id`), + KEY `FKeltapt1yxecp73acaguheypy` (`document_id`), + KEY `idx_branches_from_commit_id` (`from_commit_id`), + KEY `idx_branches_merge_target_commit_id` (`merge_target_commit_id`), + CONSTRAINT `FK7twh77wcak2w54m32nsy2363` FOREIGN KEY (`root_commit_id`) REFERENCES `commits` (`id`), + CONSTRAINT `FKa0eeai0ufc7if1gjjnegbx47o` FOREIGN KEY (`leaf_commit_id`) REFERENCES `commits` (`id`), + CONSTRAINT `FKeltapt1yxecp73acaguheypy` FOREIGN KEY (`document_id`) REFERENCES `docs` (`id`), + CONSTRAINT `FKmtx5jtry1cln0u3hdlb4m755t` FOREIGN KEY (`merge_target_commit_id`) REFERENCES `commits` (`id`), + CONSTRAINT `FKs3sjpir7sqlh1i56lo0qkeks9` FOREIGN KEY (`from_commit_id`) REFERENCES `commits` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `commits` ( + `branch_id` bigint DEFAULT NULL, + `created_at` datetime(6) NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) DEFAULT NULL, + `commit_mongo_id` varchar(255) DEFAULT NULL, + `description` varchar(255) DEFAULT NULL, + `title` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + KEY `FKnt8u1dar544lhedn95o9eerl7` (`branch_id`), + CONSTRAINT `FKnt8u1dar544lhedn95o9eerl7` FOREIGN KEY (`branch_id`) REFERENCES `branches` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `docs` ( + `created_at` datetime(6) NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) DEFAULT NULL, + `user_id` bigint NOT NULL, + `title` varchar(50) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_title` (`user_id`,`title`), + CONSTRAINT `FK9tkihf94m82526acn683ihkdc` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `edges` ( + `document_id` bigint NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `next_commit_id` bigint NOT NULL, + `prev_commit_id` bigint NOT NULL, + PRIMARY KEY (`id`), + KEY `FKgs0qaalf21ufuu813879c2xc0` (`document_id`), + KEY `FKnwxobo0keg8te2ynwp094kwqp` (`next_commit_id`), + KEY `FKq7cxggirphdswwud6pb6y3uki` (`prev_commit_id`), + CONSTRAINT `FKgs0qaalf21ufuu813879c2xc0` FOREIGN KEY (`document_id`) REFERENCES `docs` (`id`), + CONSTRAINT `FKnwxobo0keg8te2ynwp094kwqp` FOREIGN KEY (`next_commit_id`) REFERENCES `commits` (`id`), + CONSTRAINT `FKq7cxggirphdswwud6pb6y3uki` FOREIGN KEY (`prev_commit_id`) REFERENCES `commits` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `images` ( + `created_at` datetime(6) NOT NULL, + `doc_id` bigint NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `size` bigint NOT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `user_id` bigint NOT NULL, + `object_key` varchar(500) NOT NULL, + `content_type` varchar(255) NOT NULL, + `original_file_name` varchar(255) NOT NULL, + `status` enum('ACTIVE','FAILED','PENDING') NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_images_object_key` (`object_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `mongo_delete_outbox` ( + `max_retry` int NOT NULL, + `retry_count` int NOT NULL, + `created_at` datetime(6) NOT NULL, + `done_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) DEFAULT NULL, + `version` bigint DEFAULT NULL, + `last_error` varchar(2000) DEFAULT NULL, + `origin_id` varchar(255) NOT NULL, + `domain_type` enum('BRANCH','COMMIT','DOC','MERGE','SAVE') NOT NULL, + `origin_type` enum('BRANCH_ID','CBS_ID','COMMIT_ID','DOC_ID','SAVE_CONTENT_ID','SAVE_ID') NOT NULL, + `status` enum('DONE','FAILED','OPEN','PROCESSING') NOT NULL, + `trigger_type` enum('COMPENSATE','DELETE') NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_mongo_delete_outbox_trigger_domain_origin` (`trigger_type`,`domain_type`,`origin_type`,`origin_id`), + KEY `idx_mongo_delete_outbox_status_created_at` (`status`,`created_at`), + KEY `idx_mongo_delete_outbox_status_updated_at` (`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `mongo_outbox_block_ids` ( + `outbox_id` bigint NOT NULL, + `block_id` varchar(255) DEFAULT NULL, + KEY `fk_mongo_outbox_block_ids_outbox` (`outbox_id`), + CONSTRAINT `fk_mongo_outbox_block_ids_outbox` FOREIGN KEY (`outbox_id`) REFERENCES `mongo_delete_outbox` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `mongo_outbox_commit_ids` ( + `outbox_id` bigint NOT NULL, + `commit_id` varchar(255) DEFAULT NULL, + KEY `fk_mongo_outbox_commit_ids_outbox` (`outbox_id`), + CONSTRAINT `fk_mongo_outbox_commit_ids_outbox` FOREIGN KEY (`outbox_id`) REFERENCES `mongo_delete_outbox` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `mongo_outbox_save_ids` ( + `outbox_id` bigint NOT NULL, + `save_id` varchar(255) DEFAULT NULL, + KEY `fk_mongo_outbox_save_ids_outbox` (`outbox_id`), + CONSTRAINT `fk_mongo_outbox_save_ids_outbox` FOREIGN KEY (`outbox_id`) REFERENCES `mongo_delete_outbox` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `saves` ( + `branch_id` bigint NOT NULL, + `created_at` datetime(6) NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) DEFAULT NULL, + `save_mongo_id` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UKc6xd7j1suin26hn2oghr6tkvl` (`branch_id`), + CONSTRAINT `FK9s60khkapgaopi0la134u25a2` FOREIGN KEY (`branch_id`) REFERENCES `branches` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `users` ( + `created_at` datetime(6) NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) DEFAULT NULL, + `email` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `password` varchar(255) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/src/main/resources/db/migration/V2__add_thumbnail_and_s3_delete_outbox.sql b/src/main/resources/db/migration/V2__add_thumbnail_and_s3_delete_outbox.sql new file mode 100644 index 00000000..a6be1ba1 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_thumbnail_and_s3_delete_outbox.sql @@ -0,0 +1,80 @@ +ALTER TABLE images + ADD COLUMN purpose enum('DOC_CONTENT','DOC_THUMBNAIL') NOT NULL DEFAULT 'DOC_CONTENT' + AFTER original_file_name; + +ALTER TABLE images + MODIFY COLUMN status enum('ACTIVE','DELETED','DELETING','FAILED','PENDING') NOT NULL; + +SET @idx_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'mongo_delete_outbox' + AND index_name = 'idx_mongo_delete_outbox_status_created_at' +); +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX idx_mongo_delete_outbox_status_created_at ON mongo_delete_outbox (status, created_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'mongo_delete_outbox' + AND index_name = 'idx_mongo_delete_outbox_status_updated_at' +); +SET @sql = IF( + @idx_exists = 0, + 'CREATE INDEX idx_mongo_delete_outbox_status_updated_at ON mongo_delete_outbox (status, updated_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE doc_thumbnails ( + created_at datetime(6) NOT NULL, + current_image_id bigint DEFAULT NULL, + doc_id bigint NOT NULL, + generated_at datetime(6) DEFAULT NULL, + id bigint NOT NULL AUTO_INCREMENT, + request_token bigint NOT NULL, + requested_at datetime(6) DEFAULT NULL, + updated_at datetime(6) DEFAULT NULL, + version bigint DEFAULT NULL, + last_error varchar(500) DEFAULT NULL, + signature varchar(255) DEFAULT NULL, + status enum('EMPTY','FAILED','PENDING','READY') NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_doc_thumbnails_doc_id (doc_id), + UNIQUE KEY uk_doc_thumbnails_current_image_id (current_image_id), + CONSTRAINT fk_doc_thumbnails_current_image + FOREIGN KEY (current_image_id) REFERENCES images (id), + CONSTRAINT fk_doc_thumbnails_doc + FOREIGN KEY (doc_id) REFERENCES docs (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE s3_delete_outbox ( + max_retry int NOT NULL, + retry_count int NOT NULL, + created_at datetime(6) NOT NULL, + done_at datetime(6) DEFAULT NULL, + id bigint NOT NULL AUTO_INCREMENT, + image_id bigint NOT NULL, + updated_at datetime(6) DEFAULT NULL, + version bigint DEFAULT NULL, + object_key varchar(500) NOT NULL, + last_error varchar(2000) DEFAULT NULL, + status enum('DONE','FAILED','OPEN','PROCESSING') NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_s3_delete_outbox_object_key (object_key), + KEY idx_s3_delete_outbox_status_created_at (status, created_at), + KEY idx_s3_delete_outbox_status_updated_at (status, updated_at), + CONSTRAINT fk_s3_delete_outbox_image + FOREIGN KEY (image_id) REFERENCES images (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/test/java/io/ejangs/docsa/DocsaApplicationTests.java b/src/test/java/io/ejangs/docsa/DocsaApplicationTests.java index 5c66478d..a7131403 100644 --- a/src/test/java/io/ejangs/docsa/DocsaApplicationTests.java +++ b/src/test/java/io/ejangs/docsa/DocsaApplicationTests.java @@ -2,13 +2,11 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.test.context.ActiveProfiles; @EnableAsync -@EnableRetry @EnableScheduling @SpringBootTest @ActiveProfiles("test") diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java index c94e1da6..db23d3be 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java @@ -17,11 +17,11 @@ 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.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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateConsistencyIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateConsistencyIntegrationTest.java index 7a625aa5..7917438d 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateConsistencyIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateConsistencyIntegrationTest.java @@ -27,11 +27,11 @@ import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; -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.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; +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 java.util.HashSet; import java.util.List; import java.util.Map; diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java index de8aff36..092ba87a 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java @@ -15,11 +15,11 @@ import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; -import io.ejangs.docsa.global.mongo.outbox.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.outbox.mongo.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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java index dc0dda37..c0e3ca37 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java @@ -17,11 +17,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.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.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.dto.MongoIdsDto; import java.util.Collections; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java index 51a2c073..a8f4a926 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java @@ -25,7 +25,7 @@ import io.ejangs.docsa.domain.user.security.CustomUserDetails; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BlockSequenceErrorCode; -import io.ejangs.docsa.global.mongo.outbox.util.MongoIdsCollector; +import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; import java.util.Collections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java index 760a786c..1375ffa7 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java @@ -31,8 +31,9 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BlockSequenceErrorCode; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -240,7 +241,7 @@ void mysqlFail_compensateMongoDelete() { assertThat(outbox.getTriggerType()).isEqualTo(MongoDeleteOutbox.TriggerType.COMPENSATE); assertThat(outbox.getDomainType()).isEqualTo(MongoDeleteOutbox.DomainType.COMMIT); assertThat(outbox.getOriginType()).isEqualTo(MongoDeleteOutbox.OriginType.CBS_ID); - assertThat(outbox.getStatus()).isEqualTo(MongoDeleteOutbox.OutboxStatus.OPEN); + assertThat(outbox.getStatus()).isEqualTo(OutboxStatus.OPEN); assertThat(outbox.getOriginId()).isNotBlank(); }); } diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/DeleteCommitIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/DeleteCommitIntegrationTest.java index 2214ae3f..0d9678a2 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/DeleteCommitIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/DeleteCommitIntegrationTest.java @@ -21,8 +21,8 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; -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.util.MongoIdsCollector; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java index db559482..d0f6d550 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java @@ -23,6 +23,7 @@ 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.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.doc.util.DocTestUtils; import io.ejangs.docsa.domain.save.dao.mongodb.SaveContentRepository; import io.ejangs.docsa.domain.save.dao.mysql.SaveRepository; @@ -35,8 +36,9 @@ import io.ejangs.docsa.global.exception.errorcode.DatabaseErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.exception.errorcode.UserErrorCode; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -212,7 +214,7 @@ void mysqlFail_createCompensateOutbox() { assertThat(outbox.getTriggerType()).isEqualTo(MongoDeleteOutbox.TriggerType.COMPENSATE); assertThat(outbox.getDomainType()).isEqualTo(MongoDeleteOutbox.DomainType.DOC); assertThat(outbox.getOriginType()).isEqualTo(MongoDeleteOutbox.OriginType.SAVE_CONTENT_ID); - assertThat(outbox.getStatus()).isEqualTo(MongoDeleteOutbox.OutboxStatus.OPEN); + assertThat(outbox.getStatus()).isEqualTo(OutboxStatus.OPEN); } } @Nested @@ -250,7 +252,7 @@ void mysqlFail_storeOpenOutbox() { List outboxes = mongoDeleteOutboxRepository.findAll(); assertThat(outboxes).hasSize(1); - assertThat(outboxes.getFirst().getStatus()).isEqualTo(MongoDeleteOutbox.OutboxStatus.OPEN); + assertThat(outboxes.getFirst().getStatus()).isEqualTo(OutboxStatus.OPEN); assertThat(outboxes.getFirst().getRetryCount()).isEqualTo(0); } } @@ -339,11 +341,11 @@ void getDocListWithPreview() throws Exception { assertEquals("문서 1", second.title()); assertEquals(RecentType.COMMIT, second.recent().recentType()); - assertTrue(second.preview().startsWith("문단 5: 몰라어쩌구저꺼궁롱ㄹ라알이;ㅇㄹ")); // preview 포함 + assertEquals(ThumbnailStatus.EMPTY, second.thumbnailStatus()); assertEquals("문서 2", first.title()); assertEquals(RecentType.SAVE, first.recent().recentType()); - assertTrue(first.preview().startsWith("문단 3: 테스트 코드가 너무 싫어서 미치겠다는 문단")); // preview 포함 + assertEquals(ThumbnailStatus.EMPTY, first.thumbnailStatus()); } @Test diff --git a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java new file mode 100644 index 00000000..eda7b8a1 --- /dev/null +++ b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java @@ -0,0 +1,139 @@ +package io.ejangs.docsa.domain.doc.thumbnail.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.entity.Thumbnail; +import io.ejangs.docsa.domain.image.app.ImageQueryService; +import io.ejangs.docsa.domain.image.entity.Image; +import io.ejangs.docsa.domain.image.entity.Image.ImageStatus; +import io.ejangs.docsa.domain.image.entity.Image.Purpose; +import io.ejangs.docsa.global.outbox.s3.app.S3DeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.s3.dao.S3DeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.s3.entity.S3DeleteOutbox; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ThumbnailServiceUnitTest { + + @Mock + private ThumbnailQueryService thumbnailQueryService; + + @Mock + private DocQueryService docQueryService; + + @Mock + private ImageQueryService imageQueryService; + + @Mock + private S3DeleteOutboxRepository s3DeleteOutboxRepository; + + private ThumbnailService thumbnailService; + + @BeforeEach + void setUp() { + S3DeleteOutboxFactory s3DeleteOutboxFactory = + new S3DeleteOutboxFactory(s3DeleteOutboxRepository); + thumbnailService = new ThumbnailService( + thumbnailQueryService, + docQueryService, + imageQueryService, + s3DeleteOutboxFactory + ); + ReflectionTestUtils.setField(thumbnailService, "cdnUrl", "https://cdn.example.com"); + } + + @Test + @DisplayName("기존 썸네일을 새 이미지로 교체하면 이전 S3 객체 삭제 Outbox를 적재한다") + void finalizeThumbnail_enqueuePreviousThumbnailDeletion() { + Long userId = 1L; + Long docId = 2L; + Long requestToken = 1L; + Image oldImage = activeThumbnailImage(10L, userId, docId, "old.webp"); + Image newImage = activeThumbnailImage(11L, userId, docId, "new.webp"); + Thumbnail thumbnail = thumbnailWithCurrentImage(oldImage, requestToken); + + when(thumbnailQueryService.getByDocIdForUpdate(docId)).thenReturn(thumbnail); + when(imageQueryService.getByIdAndUserId(newImage.getId(), userId)).thenReturn(newImage); + + ThumbnailResponse response = thumbnailService.finalizeThumbnail( + userId, + docId, + newImage.getId(), + requestToken, + "new-signature" + ); + + assertThat(response.imageId()).isEqualTo(newImage.getId()); + assertThat(response.thumbnailUrl()).isEqualTo("https://cdn.example.com/" + newImage.getObjectKey()); + assertThat(thumbnail.getCurrentImage()).isEqualTo(newImage); + assertThat(oldImage.getStatus()).isEqualTo(ImageStatus.DELETING); + verify(s3DeleteOutboxRepository).insertOpenIfAbsent( + 10L, + "users/1/docs/2/images/old.webp" + ); + } + + @Test + @DisplayName("같은 이미지를 다시 확정하면 삭제 Outbox를 만들지 않는다") + void finalizeThumbnail_skipDeletionWhenImageIsSame() { + Long userId = 1L; + Long docId = 2L; + Long requestToken = 1L; + Image image = activeThumbnailImage(10L, userId, docId, "same.webp"); + Thumbnail thumbnail = thumbnailWithCurrentImage(image, requestToken); + + when(thumbnailQueryService.getByDocIdForUpdate(docId)).thenReturn(thumbnail); + when(imageQueryService.getByIdAndUserId(image.getId(), userId)).thenReturn(image); + + thumbnailService.finalizeThumbnail( + userId, + docId, + image.getId(), + requestToken, + "same-signature" + ); + + assertThat(image.getStatus()).isEqualTo(ImageStatus.ACTIVE); + verify(s3DeleteOutboxRepository, never()).save(any(S3DeleteOutbox.class)); + } + + private Thumbnail thumbnailWithCurrentImage(Image image, Long expectedRequestToken) { + Thumbnail thumbnail = Thumbnail.builder() + .doc(mock(Doc.class)) + .build(); + thumbnail.complete(image, "old-signature"); + Long requestToken = thumbnail.requestUpdate(); + assertThat(requestToken).isEqualTo(expectedRequestToken); + return thumbnail; + } + + private Image activeThumbnailImage(Long imageId, Long userId, Long docId, String fileName) { + Image image = Image.builder() + .userId(userId) + .docId(docId) + .originalFileName(fileName) + .objectKey("users/%d/docs/%d/images/%s".formatted(userId, docId, fileName)) + .contentType("image/webp") + .size(1024L) + .purpose(Purpose.DOC_THUMBNAIL) + .build(); + ReflectionTestUtils.setField(image, "id", imageId); + image.activate(); + return image; + } +} diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java index cbd2c347..46d107fa 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java @@ -18,10 +18,10 @@ 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.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.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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java index 53caca4e..b1d3312b 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java @@ -35,7 +35,7 @@ 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.util.MongoIdsCollector; +import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/util/DocTestUtils.java b/src/test/java/io/ejangs/docsa/domain/doc/util/DocTestUtils.java index 6cb0f100..f0d01669 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/util/DocTestUtils.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/util/DocTestUtils.java @@ -13,6 +13,7 @@ import io.ejangs.docsa.domain.doc.dto.RecentActivityDto.RecentType; import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.edge.entity.Edge; import io.ejangs.docsa.domain.save.dao.mongodb.SaveContentRepository; import io.ejangs.docsa.domain.save.document.SaveContent; @@ -365,7 +366,7 @@ public static Page convertToDocListResponsePage(List docs, String title = doc.getTitle(); LocalDateTime createdAt = doc.getCreatedAt(); LocalDateTime updatedAt = doc.getUpdatedAt(); - String preview = "미리보기 없음"; + String thumbnailUrl = null; // 최근 활동 (SAVE > COMMIT 우선) RecentActivityDto recent = doc.getBranches().stream() @@ -387,7 +388,8 @@ public static Page convertToDocListResponsePage(List docs, .findFirst() .orElse(null); - return new DocPageResponse(docId, title, createdAt, updatedAt, preview, recent); + return new DocPageResponse(docId, title, createdAt, updatedAt, thumbnailUrl, + ThumbnailStatus.EMPTY, recent); }) .toList(); diff --git a/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java index ee585284..fbc9f852 100644 --- a/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java @@ -16,11 +16,11 @@ import io.ejangs.docsa.domain.image.dto.response.ImageUploadUrlResponse; import io.ejangs.docsa.domain.image.entity.Image; import io.ejangs.docsa.domain.image.entity.Image.ImageStatus; +import io.ejangs.docsa.domain.image.entity.Image.Purpose; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.ImageErrorCode; import java.net.URI; import java.time.Duration; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -44,6 +44,9 @@ class ImageServiceUnitTest { @Mock private ImageRepository imageRepository; + @Mock + private ImageQueryService imageQueryService; + @Mock private DocQueryService docQueryService; @@ -60,7 +63,8 @@ class ImageServiceUnitTest { @BeforeEach void setUp() { - imageService = new ImageService(imageRepository, docQueryService, s3Presigner, s3Client); + imageService = new ImageService(imageRepository, imageQueryService, docQueryService, + s3Presigner, s3Client); ReflectionTestUtils.setField(imageService, "bucket", "docsa-image-bucket"); ReflectionTestUtils.setField(imageService, "expireMinutes", 5L); ReflectionTestUtils.setField(imageService, "cdnUrl", "https://cdn.example.com"); @@ -72,7 +76,7 @@ void createUploadUrl_success() throws Exception { Long userId = 1L; Long docId = 2L; ImageUploadUrlRequest request = - new ImageUploadUrlRequest(docId, "sample.png", "image/png", 1024L); + new ImageUploadUrlRequest(docId, "sample.png", "image/png", 1024L, Purpose.DOC_CONTENT); when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); when(imageRepository.save(any(Image.class))).thenAnswer(invocation -> { @@ -107,13 +111,47 @@ void createUploadUrl_success() throws Exception { assertThat(putObjectRequest.contentType()).isEqualTo("image/png"); } + @Test + @DisplayName("썸네일 업로드 URL 생성 시 thumbnails prefix로 S3 key를 만든다") + void createUploadUrl_success_whenPurposeIsThumbnail() throws Exception { + Long userId = 1L; + Long docId = 2L; + ImageUploadUrlRequest request = + new ImageUploadUrlRequest(docId, "thumbnail.webp", "image/webp", 1024L, Purpose.DOC_THUMBNAIL); + + when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); + when(imageRepository.save(any(Image.class))).thenAnswer(invocation -> { + Image image = invocation.getArgument(0); + ReflectionTestUtils.setField(image, "id", 10L); + return image; + }); + when(presignedPutObjectRequest.url()) + .thenReturn(URI.create("https://s3.example.com/upload").toURL()); + when(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))) + .thenReturn(presignedPutObjectRequest); + + ImageUploadUrlResponse response = imageService.createUploadUrl(userId, request); + + assertThat(response.objectKey()) + .startsWith("users/1/docs/2/thumbnails/") + .endsWith(".webp"); + + ArgumentCaptor presignCaptor = + ArgumentCaptor.forClass(PutObjectPresignRequest.class); + verify(s3Presigner).presignPutObject(presignCaptor.capture()); + + PutObjectRequest putObjectRequest = presignCaptor.getValue().putObjectRequest(); + assertThat(putObjectRequest.key()).isEqualTo(response.objectKey()); + assertThat(putObjectRequest.contentType()).isEqualTo("image/webp"); + } + @Test @DisplayName("지원하지 않는 이미지 형식이면 업로드 URL을 생성하지 않는다") void createUploadUrl_fail_whenContentTypeInvalid() { Long userId = 1L; Long docId = 2L; ImageUploadUrlRequest request = - new ImageUploadUrlRequest(docId, "sample.svg", "image/svg+xml", 1024L); + new ImageUploadUrlRequest(docId, "sample.svg", "image/svg+xml", 1024L, Purpose.DOC_CONTENT); when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); @@ -139,9 +177,10 @@ void complete_success() { .objectKey(objectKey) .contentType("image/png") .size(1024L) + .purpose(Purpose.DOC_CONTENT) .build(); - when(imageRepository.findByIdAndUserId(imageId, userId)).thenReturn(Optional.of(image)); + when(imageQueryService.getByIdAndUserId(imageId, userId)).thenReturn(image); when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn( HeadObjectResponse.builder() .contentType("image/png") @@ -177,9 +216,10 @@ void complete_fail_whenUploadNotCompleted() { .objectKey("users/1/docs/2/images/image.png") .contentType("image/png") .size(1024L) + .purpose(Purpose.DOC_CONTENT) .build(); - when(imageRepository.findByIdAndUserId(imageId, userId)).thenReturn(Optional.of(image)); + when(imageQueryService.getByIdAndUserId(imageId, userId)).thenReturn(image); when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow( S3Exception.builder() .statusCode(404) diff --git a/src/test/java/io/ejangs/docsa/domain/save/api/SaveControllerTest.java b/src/test/java/io/ejangs/docsa/domain/save/api/SaveControllerTest.java index da2666f7..13d61f07 100644 --- a/src/test/java/io/ejangs/docsa/domain/save/api/SaveControllerTest.java +++ b/src/test/java/io/ejangs/docsa/domain/save/api/SaveControllerTest.java @@ -9,6 +9,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.save.app.SaveService; import io.ejangs.docsa.domain.save.dto.SaveIdentifierDto; import io.ejangs.docsa.domain.save.dto.request.SaveUpdateRequest; @@ -66,7 +68,7 @@ void updateSave_success() throws Exception { Long saveId = 1L; when(saveService.updateSave(dto, request)).thenReturn(new SaveUpdateResponse( - LocalDateTime.now())); + LocalDateTime.now(), new ThumbnailSyncResponse(10L, "wow", ThumbnailStatus.READY))); mockMvc.perform( put("/api/document/{documentId}/save/{saveId}", documentId, saveId) diff --git a/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceIntegrationTest.java index 2eb815e8..89608576 100644 --- a/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceIntegrationTest.java @@ -5,7 +5,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import com.mongodb.DuplicateKeyException; import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.commit.dao.mysql.CommitRepository; @@ -38,6 +37,7 @@ import org.springframework.dao.RecoverableDataAccessException; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @SpringBootTest @@ -111,10 +111,13 @@ class SaveRollbackTest { @Test @DisplayName("Mongo 저장 실패 시 updateSave 롤백") - //@Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional(propagation = Propagation.NOT_SUPPORTED) void updateSave_fails_whenMongoSaveFails_thenMysqlDeleted() throws Exception { // given - LocalDateTime beforeUpdatedAt = save.getUpdatedAt(); + saveRepository.flush(); + em.clear(); + Save before = saveRepository.findById(save.getId()).orElseThrow(); + LocalDateTime beforeUpdatedAt = before.getUpdatedAt(); SaveIdentifierDto dto = new SaveIdentifierDto(doc.getId(), save.getId(), user.getId()); SaveUpdateRequest request = new SaveUpdateRequest(data); diff --git a/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java index b222bbd6..ae6c252f 100644 --- a/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java @@ -9,6 +9,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import io.ejangs.docsa.domain.doc.thumbnail.app.ThumbnailService; +import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.save.document.SaveContent; import io.ejangs.docsa.domain.save.dto.SaveIdentifierDto; import io.ejangs.docsa.domain.save.dto.request.SaveUpdateRequest; @@ -37,6 +40,8 @@ class SaveServiceUnitTest { @Mock private SaveQueryService saveQueryService; @Mock + private ThumbnailService thumbnailService; + @Mock private Save mockSave; @Mock private SaveContent mockSaveContent; @@ -108,7 +113,10 @@ void getSave_fail_invalidSave() { @Test @DisplayName("성공적인 updateSave") void updateSave_success() { - SaveUpdateResponse expectedResponse = new SaveUpdateResponse(LocalDateTime.now()); + ThumbnailSyncResponse thumbnailSyncResponse = new ThumbnailSyncResponse(10L, "wow", + ThumbnailStatus.READY); + SaveUpdateResponse expectedResponse = new SaveUpdateResponse(LocalDateTime.now(), + thumbnailSyncResponse); when(saveQueryService.getSaveById(idDto.saveId())).thenReturn(mockSave); doNothing().when(saveQueryService) @@ -116,9 +124,12 @@ void updateSave_success() { when(mockSave.getSaveMongoId()).thenReturn("mongo-1"); when(saveQueryService.getSaveContentById("mongo-1")).thenReturn(mockSaveContent); when(mockSave.getUpdatedAt()).thenReturn(LocalDateTime.now()); + when(thumbnailService.requestUpdate(idDto.userId(), idDto.documentId())) + .thenReturn(thumbnailSyncResponse); try (MockedStatic mockedMapper = mockStatic(SaveMapper.class)) { - mockedMapper.when(() -> SaveMapper.toSaveUpdateResponse(mockSave.getUpdatedAt())) + mockedMapper.when(() -> SaveMapper.toSaveUpdateResponse(mockSave.getUpdatedAt(), + thumbnailSyncResponse)) .thenReturn(expectedResponse); SaveUpdateResponse actualResponse = saveService.updateSave(idDto, request); diff --git a/src/test/java/io/ejangs/docsa/global/mongo/outbox/MongoTransactionTestService.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/MongoTransactionTestService.java similarity index 96% rename from src/test/java/io/ejangs/docsa/global/mongo/outbox/MongoTransactionTestService.java rename to src/test/java/io/ejangs/docsa/global/outbox/mongo/MongoTransactionTestService.java index 28eed5e9..2e292e0a 100644 --- a/src/test/java/io/ejangs/docsa/global/mongo/outbox/MongoTransactionTestService.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/MongoTransactionTestService.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.global.mongo.outbox; +package io.ejangs.docsa.global.outbox.mongo; import io.ejangs.docsa.domain.commit.dao.mysql.CommitRepository; import io.ejangs.docsa.domain.commit.entity.Commit; diff --git a/src/test/java/io/ejangs/docsa/global/mongo/outbox/MongoTransactionTests.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/MongoTransactionTests.java similarity index 97% rename from src/test/java/io/ejangs/docsa/global/mongo/outbox/MongoTransactionTests.java rename to src/test/java/io/ejangs/docsa/global/outbox/mongo/MongoTransactionTests.java index b819a209..f9482777 100644 --- a/src/test/java/io/ejangs/docsa/global/mongo/outbox/MongoTransactionTests.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/MongoTransactionTests.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.global.mongo.outbox; +package io.ejangs.docsa.global.outbox.mongo; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; diff --git a/src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxFactoryUnitTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java similarity index 87% rename from src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxFactoryUnitTest.java rename to src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java index 0e84e65f..b8c05f94 100644 --- a/src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxFactoryUnitTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.global.mongo.outbox.app; +package io.ejangs.docsa.global.outbox.mongo.app; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -7,12 +7,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; -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.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; +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 java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxIntegrationTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java similarity index 91% rename from src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxIntegrationTest.java rename to src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java index 8df520ec..a8bd4b0b 100644 --- a/src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java @@ -1,16 +1,16 @@ -package io.ejangs.docsa.global.mongo.outbox.app; +package io.ejangs.docsa.global.outbox.mongo.app; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; -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.OutboxStatus; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox.TriggerType; +import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; +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.OutboxStatus; import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -48,7 +48,8 @@ class MongoDeleteOutboxIntegrationTest { @BeforeEach void cleanOutbox() { - mongoDeleteOutboxRepository.deleteAll(); + mongoDeleteOutboxRepository.deleteAllInBatch(); + mongoDeleteOutboxRepository.flush(); } @Test @@ -120,7 +121,7 @@ void factoryDedupeWithSameKey() { } @Test - @DisplayName("PROCESSING timeout 건은 run() 시작 시 복구되어 재처리된다") + @DisplayName("PROCESSING timeout 건은 retryCount 증가 없이 복구되어 재처리된다") void workerRecoversTimedOutProcessingBeforeDelete() { MongoDeleteOutbox outbox = createOpenOutbox(); mongoDeleteOutboxLifecycleService.claimOpen(outbox.getId()); @@ -138,7 +139,7 @@ void workerRecoversTimedOutProcessingBeforeDelete() { MongoDeleteOutbox done = mongoDeleteOutboxRepository.findById(outbox.getId()).orElseThrow(); assertThat(done.getStatus()).isEqualTo(OutboxStatus.DONE); - assertThat(done.getRetryCount()).isEqualTo(1); + assertThat(done.getRetryCount()).isEqualTo(0); } @Test diff --git a/src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxRaceIntegrationTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java similarity index 90% rename from src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxRaceIntegrationTest.java rename to src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java index a2b8593c..828a5b41 100644 --- a/src/test/java/io/ejangs/docsa/global/mongo/outbox/app/MongoDeleteOutboxRaceIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java @@ -1,13 +1,13 @@ -package io.ejangs.docsa.global.mongo.outbox.app; +package io.ejangs.docsa.global.outbox.mongo.app; import static org.assertj.core.api.Assertions.assertThat; -import io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.mongo.outbox.dto.MongoIdsDto; -import io.ejangs.docsa.global.mongo.outbox.entity.MongoDeleteOutbox; -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.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; +import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; +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 java.util.List; import java.util.Optional; import java.util.UUID; @@ -119,7 +119,7 @@ static void clear() { RaceBarrierAspect.barrier = null; } - @Around("execution(* io.ejangs.docsa.global.mongo.outbox.dao.mysql.MongoDeleteOutboxRepository.findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId(..))") + @Around("execution(* io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository.findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId(..))") Object awaitAfterEmptyLookup(ProceedingJoinPoint joinPoint) throws Throwable { @SuppressWarnings("unchecked") Optional result = (Optional) joinPoint.proceed(); diff --git a/src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteServiceUnitTest.java b/src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteServiceUnitTest.java new file mode 100644 index 00000000..fb603666 --- /dev/null +++ b/src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteServiceUnitTest.java @@ -0,0 +1,74 @@ +package io.ejangs.docsa.global.outbox.s3.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.ejangs.docsa.global.outbox.s3.dto.S3DeleteTarget; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@ExtendWith(MockitoExtension.class) +class S3DeleteServiceUnitTest { + + @Mock + private S3Client s3Client; + + private S3DeleteService s3DeleteService; + + @BeforeEach + void setUp() { + s3DeleteService = new S3DeleteService(s3Client); + ReflectionTestUtils.setField(s3DeleteService, "bucket", "docsa-image-bucket"); + } + + @Test + @DisplayName("S3 삭제 대상 objectKey로 DeleteObject를 호출한다") + void deleteTarget_success() { + S3DeleteTarget target = new S3DeleteTarget(10L, "users/1/docs/2/images/old.webp"); + when(s3Client.deleteObject(any(DeleteObjectRequest.class))) + .thenReturn(DeleteObjectResponse.builder().build()); + + s3DeleteService.deleteTarget(target); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(DeleteObjectRequest.class); + verify(s3Client).deleteObject(captor.capture()); + DeleteObjectRequest request = captor.getValue(); + assertThat(request.bucket()).isEqualTo("docsa-image-bucket"); + assertThat(request.key()).isEqualTo(target.objectKey()); + } + + @Test + @DisplayName("이미 삭제된 S3 객체는 성공으로 처리한다") + void deleteTarget_success_whenObjectMissing() { + S3DeleteTarget target = new S3DeleteTarget(10L, "users/1/docs/2/images/missing.webp"); + when(s3Client.deleteObject(any(DeleteObjectRequest.class))) + .thenThrow(S3Exception.builder().statusCode(404).build()); + + s3DeleteService.deleteTarget(target); + } + + @Test + @DisplayName("S3 삭제 중 404가 아닌 예외는 재시도를 위해 전파한다") + void deleteTarget_fail_whenS3ErrorOccurs() { + S3DeleteTarget target = new S3DeleteTarget(10L, "users/1/docs/2/images/old.webp"); + RuntimeException s3Exception = S3Exception.builder().statusCode(500).build(); + when(s3Client.deleteObject(any(DeleteObjectRequest.class))).thenThrow(s3Exception); + + assertThatThrownBy(() -> s3DeleteService.deleteTarget(target)) + .isSameAs(s3Exception); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 1bf305dd..04c2b1c6 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -9,6 +9,9 @@ spring: mongodb: uri: ${MONGO_URI:mongodb://localhost:27017/docsa-test} + flyway: + enabled: false + jpa: hibernate: ddl-auto: create @@ -60,6 +63,14 @@ mongo: fixed-delay: PT1M initial-delay: PT24H +s3: + delete: + outbox: + worker: + fixed-delay: PT1M + initial-delay: PT24H + + cloud: aws: region: ap-northeast-2