From 674fde44fc7e4536f39dc200de76b0a63a851480 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 24 Feb 2026 01:46:30 +0900 Subject: [PATCH 001/157] :technologist: slack alert --- .github/scripts/slack-dm-notify.mjs | 185 +++++++++++++++++++++++++ .github/workflows/github-dm-notify.yml | 33 +++++ 2 files changed, 218 insertions(+) create mode 100644 .github/scripts/slack-dm-notify.mjs create mode 100644 .github/workflows/github-dm-notify.yml diff --git a/.github/scripts/slack-dm-notify.mjs b/.github/scripts/slack-dm-notify.mjs new file mode 100644 index 00000000..c5e7dc71 --- /dev/null +++ b/.github/scripts/slack-dm-notify.mjs @@ -0,0 +1,185 @@ +import fs from "fs"; + +/* ===================== + 0. Guard: secrets 없음 → 조용히 종료 +===================== */ +const token = process.env.SLACK_BOT_TOKEN; +if (!token) { + console.log("No SLACK_BOT_TOKEN (likely fork PR). Skipping."); + process.exit(0); +} + +/* ===================== + 1. Load event + user map +===================== */ +const event = JSON.parse( + fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8") +); + +// user map: Secret 우선, 없으면 파일 fallback +let userMap = {}; +if (process.env.SLACK_USER_MAP) { + userMap = JSON.parse(process.env.SLACK_USER_MAP); +} else if (fs.existsSync(".github/slack-map.json")) { + userMap = JSON.parse( + fs.readFileSync(".github/slack-map.json", "utf8") + ); +} + +/* ===================== + 2. Utilities +===================== */ +const uniq = (arr) => [...new Set(arr.filter(Boolean))]; + +const extractMentions = (text) => { + if (!text) return []; + const re = /@([a-zA-Z0-9-]+)/g; + const out = []; + let m; + while ((m = re.exec(text)) !== null) out.push(m[1]); + return out; +}; + +const ghToSlack = (login) => userMap[login] || null; + +/* ===================== + 3. Slack API helpers +===================== */ +async function openDM(userId) { + const res = await fetch( + "https://slack.com/api/conversations.open", + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ users: userId }), + } + ); + const json = await res.json(); + if (!json.ok) throw new Error(json.error); + return json.channel.id; +} + +async function postMessage(channel, text) { + const res = await fetch( + "https://slack.com/api/chat.postMessage", + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ channel, text }), + } + ); + const json = await res.json(); + if (!json.ok) throw new Error(json.error); +} + +/* ===================== + 4. Recipient & message builder +===================== */ +function buildNotification(e) { + const recipients = []; + let title = ""; + let url = ""; + + const repo = process.env.GITHUB_REPOSITORY; + const action = e.action ? ` (${e.action})` : ""; + const actor = e.sender?.login ? `@${e.sender.login}` : "someone"; + + // ----- Pull Request ----- + if (e.pull_request) { + const pr = e.pull_request; + title = `PR #${pr.number}: ${pr.title}`; + url = pr.html_url; + + // 리뷰 요청: 단일 reviewer 우선 + if (e.action === "review_requested" && e.requested_reviewer) { + recipients.push(e.requested_reviewer.login); + } + + // assign + (pr.assignees || []).forEach((a) => + recipients.push(a.login) + ); + + // 본문 멘션 + recipients.push(...extractMentions(pr.body)); + } + + // ----- Issue (PR 제외) ----- + if (e.issue && !e.issue.pull_request) { + const is = e.issue; + title = `Issue #${is.number}: ${is.title}`; + url = is.html_url; + + (is.assignees || []).forEach((a) => + recipients.push(a.login) + ); + + recipients.push(...extractMentions(is.body)); + } + + // ----- Issue / PR Comment ----- + if (e.comment) { + url = e.comment.html_url; + recipients.push(...extractMentions(e.comment.body)); + + // issue_comment on a PR: 작성자 + assignee에게도 알림 + if (e.issue?.pull_request) { + recipients.push(e.issue.user?.login); + (e.issue.assignees || []).forEach((a) => recipients.push(a.login)); + } + } + + // ----- PR Review Submitted ----- + if (e.review && e.pull_request) { + const pr = e.pull_request; + title = `Review on PR #${pr.number}: ${pr.title} (${e.review.state})`; + url = e.review.html_url || pr.html_url; + + // PR 작성자에게 알림 + recipients.push(pr.user?.login); + recipients.push(...extractMentions(e.review.body)); + } + + const text = + `🔔 GitHub 알림${action}\n` + + `Repo: ${repo}\n` + + `By: ${actor}\n` + + `${title}\n` + + `🔗 ${url}`; + + return { recipients: uniq(recipients), text }; +} + +/* ===================== + 5. Main +===================== */ +async function main() { + const { recipients, text } = buildNotification(event); + + const slackUsers = uniq( + recipients.map(ghToSlack).filter(Boolean) + ); + + if (slackUsers.length === 0) { + console.log("No matching Slack users. Skipping."); + return; + } + + for (const uid of slackUsers) { + const channel = await openDM(uid); + await postMessage(channel, text); + } + + console.log(`Sent Slack DM to ${slackUsers.length} user(s).`); +} + +main().catch((err) => { + console.error("Slack notify failed:", err.message); + process.exit(1); +}); diff --git a/.github/workflows/github-dm-notify.yml b/.github/workflows/github-dm-notify.yml new file mode 100644 index 00000000..89419036 --- /dev/null +++ b/.github/workflows/github-dm-notify.yml @@ -0,0 +1,33 @@ +name: GitHub -> Slack DM Notify + +on: + pull_request: + types: [opened, reopened, ready_for_review, review_requested, assigned] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + issue_comment: + types: [created] + +permissions: + contents: read + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Send Slack DMs + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_USER_MAP: ${{ secrets.SLACK_USER_MAP }} + run: node .github/scripts/slack-dm-notify.mjs From 2f15fb7884ba8fa004f268b66bafbff1de01d01c Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 24 Feb 2026 01:54:37 +0900 Subject: [PATCH 002/157] :technologist: slack alert --- .github/scripts/slack-dm-notify.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/slack-dm-notify.mjs b/.github/scripts/slack-dm-notify.mjs index c5e7dc71..8fd93016 100644 --- a/.github/scripts/slack-dm-notify.mjs +++ b/.github/scripts/slack-dm-notify.mjs @@ -153,7 +153,8 @@ function buildNotification(e) { `${title}\n` + `🔗 ${url}`; - return { recipients: uniq(recipients), text }; + const sender = e.sender?.login; + return { recipients: uniq(recipients).filter((r) => r !== sender), text }; } /* ===================== From 2a1a2003ed73fa8bd8a75f29479c5cf5e0225b3b Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Fri, 27 Feb 2026 23:09:30 +0900 Subject: [PATCH 003/157] :technologist: refactored & improved slack notification --- .github/scripts/slack-dm-notify.mjs | 162 +++++++++++++++++++--------- 1 file changed, 112 insertions(+), 50 deletions(-) diff --git a/.github/scripts/slack-dm-notify.mjs b/.github/scripts/slack-dm-notify.mjs index 8fd93016..efdc5d22 100644 --- a/.github/scripts/slack-dm-notify.mjs +++ b/.github/scripts/slack-dm-notify.mjs @@ -79,78 +79,140 @@ async function postMessage(channel, text) { } /* ===================== - 4. Recipient & message builder + 4. Event handlers + 각 핸들러는 { title, body, url, recipients } 를 반환 ===================== */ -function buildNotification(e) { - const recipients = []; - let title = ""; - let url = ""; - const repo = process.env.GITHUB_REPOSITORY; - const action = e.action ? ` (${e.action})` : ""; - const actor = e.sender?.login ? `@${e.sender.login}` : "someone"; - - // ----- Pull Request ----- - if (e.pull_request) { - const pr = e.pull_request; - title = `PR #${pr.number}: ${pr.title}`; - url = pr.html_url; +// pull_request: opened, reopened, ready_for_review, review_requested, assigned +function handlePullRequest(e) { + const pr = e.pull_request; + const title = `PR #${pr.number}: ${pr.title}`; + const url = pr.html_url; + const recipients = []; - // 리뷰 요청: 단일 reviewer 우선 - if (e.action === "review_requested" && e.requested_reviewer) { - recipients.push(e.requested_reviewer.login); - } + // 리뷰 요청: 새로 요청된 reviewer에게만 + if (e.action === "review_requested" && e.requested_reviewer) { + recipients.push(e.requested_reviewer.login); + } - // assign - (pr.assignees || []).forEach((a) => - recipients.push(a.login) - ); + // assign: 새로 assign된 사람에게만 + if (e.action === "assigned") { + recipients.push(e.assignee?.login); + } - // 본문 멘션 + // 본문 멘션: 처음 열릴 때만 + if (e.action === "opened") { recipients.push(...extractMentions(pr.body)); } - // ----- Issue (PR 제외) ----- - if (e.issue && !e.issue.pull_request) { - const is = e.issue; - title = `Issue #${is.number}: ${is.title}`; - url = is.html_url; + return { title, body: pr.body, url, recipients }; +} + +// pull_request_review: submitted +function handlePullRequestReview(e) { + const pr = e.pull_request; + const title = `PR #${pr.number}: ${pr.title}의 새 리뷰 (${e.review.state})`; + const url = e.review.html_url || pr.html_url; + const recipients = [ + pr.user?.login, + ...extractMentions(e.review.body), + ]; + + return { title, body: e.review.body, url, recipients }; +} + +// pull_request_review_comment: created +function handlePullRequestReviewComment(e) { + const pr = e.pull_request; + const title = `PR #${pr.number} ${pr.title}의 새 코멘트`; + const url = e.comment.html_url; + const recipients = [ + pr.user?.login, + ...extractMentions(e.comment.body), + ]; + + return { title, body: e.comment.body, url, recipients }; +} - (is.assignees || []).forEach((a) => - recipients.push(a.login) - ); +// issue_comment on PR: created +function handleIssueCommentOnPR(e) { + const is = e.issue; + const title = `PR #${is.number}: ${is.title}의 새 코멘트`; + const url = e.comment.html_url; + const recipients = [ + is.user?.login, + ...extractMentions(e.comment.body), + ]; + + return { title, body: e.comment.body, url, recipients }; +} +// issue_comment on Issue: created +function handleIssueComment(e) { + const is = e.issue; + const title = `Issue #${is.number}: ${is.title}의 새 코멘트`; + const url = e.comment.html_url; + const recipients = [ + is.user?.login, + ...extractMentions(e.comment.body), + ]; + + return { title, body: e.comment.body, url, recipients }; +} + +// issues: opened, assigned +function handleIssues(e) { + const is = e.issue; + const title = `Issue #${is.number}: ${is.title}`; + const url = is.html_url; + const recipients = []; + + // assign: 새로 assign된 사람에게만 + if (e.action === "assigned") { + recipients.push(e.assignee?.login); + } + + // 본문 멘션: 처음 열릴 때만 + if (e.action === "opened") { recipients.push(...extractMentions(is.body)); } - // ----- Issue / PR Comment ----- - if (e.comment) { - url = e.comment.html_url; - recipients.push(...extractMentions(e.comment.body)); + return { title, body: is.body, url, recipients }; +} - // issue_comment on a PR: 작성자 + assignee에게도 알림 - if (e.issue?.pull_request) { - recipients.push(e.issue.user?.login); - (e.issue.assignees || []).forEach((a) => recipients.push(a.login)); - } - } +/* ===================== + 5. Notification builder (이벤트 디스패처) +===================== */ +function buildNotification(e) { + let result; - // ----- PR Review Submitted ----- if (e.review && e.pull_request) { - const pr = e.pull_request; - title = `Review on PR #${pr.number}: ${pr.title} (${e.review.state})`; - url = e.review.html_url || pr.html_url; - - // PR 작성자에게 알림 - recipients.push(pr.user?.login); - recipients.push(...extractMentions(e.review.body)); + result = handlePullRequestReview(e); + } else if (e.comment && e.pull_request) { + result = handlePullRequestReviewComment(e); + } else if (e.comment && e.issue?.pull_request) { + result = handleIssueCommentOnPR(e); + } else if (e.comment && e.issue) { + result = handleIssueComment(e); + } else if (e.pull_request) { + result = handlePullRequest(e); + } else if (e.issue) { + result = handleIssues(e); + } else { + return { recipients: [], text: "" }; } + const { title, body, url, recipients } = result; + const repo = process.env.GITHUB_REPOSITORY; + const action = e.action ? ` (${e.action})` : ""; + const actor = e.sender?.login ? `@${e.sender.login}` : "someone"; + const text = `🔔 GitHub 알림${action}\n` + `Repo: ${repo}\n` + `By: ${actor}\n` + `${title}\n` + + (body ? `${body}\n` : "") + `🔗 ${url}`; const sender = e.sender?.login; @@ -158,7 +220,7 @@ function buildNotification(e) { } /* ===================== - 5. Main + 6. Main ===================== */ async function main() { const { recipients, text } = buildNotification(event); From 5f78ac9b77968998eef8405ab1bfe08c86eac2b9 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 28 Feb 2026 00:06:49 +0900 Subject: [PATCH 004/157] =?UTF-8?q?:technologist:=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=20=EC=8A=AC=EB=9E=99=20=EC=95=8C=EB=A6=BC=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20=EB=B0=8F=20=EC=8A=AC=EB=9E=99=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/slack-dm-notify.mjs | 137 ++++++++++++++++++------- .github/workflows/github-dm-notify.yml | 2 + 2 files changed, 101 insertions(+), 38 deletions(-) diff --git a/.github/scripts/slack-dm-notify.mjs b/.github/scripts/slack-dm-notify.mjs index efdc5d22..685d29f7 100644 --- a/.github/scripts/slack-dm-notify.mjs +++ b/.github/scripts/slack-dm-notify.mjs @@ -45,41 +45,62 @@ const ghToSlack = (login) => userMap[login] || null; /* ===================== 3. Slack API helpers ===================== */ -async function openDM(userId) { - const res = await fetch( - "https://slack.com/api/conversations.open", - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ users: userId }), - } - ); +async function slackPost(endpoint, body) { + const res = await fetch(`https://slack.com/api/${endpoint}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (res.status === 429) { + const err = new Error("ratelimited"); + err.retryAfterMs = parseInt(res.headers.get("Retry-After") || "5", 10) * 1000; + throw err; + } const json = await res.json(); if (!json.ok) throw new Error(json.error); - return json.channel.id; + return json; } -async function postMessage(channel, text) { - const res = await fetch( - "https://slack.com/api/chat.postMessage", - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ channel, text }), - } +async function sendDM(userId, text) { + const { channel } = await slackPost("conversations.open", { users: userId }); + await slackPost("chat.postMessage", { channel: channel.id, text }); +} + +/* ===================== + 4. GitHub API helper +===================== */ + +// 대기 중인 reviewer(requested_reviewers) + 이미 리뷰 제출한 reviewer 모두 반환 +async function fetchPRReviewers(prNumber) { + const repo = process.env.GITHUB_REPOSITORY; + const headers = { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: "application/vnd.github.v3+json", + }; + + const [prRes, reviewsRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo}/pulls/${prNumber}`, { headers }), + fetch(`https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, { headers }), + ]); + + const pr = await prRes.json(); + const reviews = await reviewsRes.json(); + + const reviewers = new Set( + (pr.requested_reviewers || []).map((r) => r.login) ); - const json = await res.json(); - if (!json.ok) throw new Error(json.error); + (Array.isArray(reviews) ? reviews : []).forEach((r) => { + if (r.user?.login) reviewers.add(r.user.login); + }); + + return [...reviewers]; } /* ===================== - 4. Event handlers + 5. Event handlers 각 핸들러는 { title, body, url, recipients } 를 반환 ===================== */ @@ -122,12 +143,14 @@ function handlePullRequestReview(e) { } // pull_request_review_comment: created -function handlePullRequestReviewComment(e) { +async function handlePullRequestReviewComment(e) { const pr = e.pull_request; const title = `PR #${pr.number} ${pr.title}의 새 코멘트`; const url = e.comment.html_url; + const reviewers = await fetchPRReviewers(pr.number); const recipients = [ pr.user?.login, + ...reviewers, ...extractMentions(e.comment.body), ]; @@ -135,12 +158,14 @@ function handlePullRequestReviewComment(e) { } // issue_comment on PR: created -function handleIssueCommentOnPR(e) { +async function handleIssueCommentOnPR(e) { const is = e.issue; const title = `PR #${is.number}: ${is.title}의 새 코멘트`; const url = e.comment.html_url; + const reviewers = await fetchPRReviewers(is.number); const recipients = [ is.user?.login, + ...reviewers, ...extractMentions(e.comment.body), ]; @@ -181,17 +206,17 @@ function handleIssues(e) { } /* ===================== - 5. Notification builder (이벤트 디스패처) + 6. Notification builder (이벤트 디스패처) ===================== */ -function buildNotification(e) { +async function buildNotification(e) { let result; if (e.review && e.pull_request) { result = handlePullRequestReview(e); } else if (e.comment && e.pull_request) { - result = handlePullRequestReviewComment(e); + result = await handlePullRequestReviewComment(e); } else if (e.comment && e.issue?.pull_request) { - result = handleIssueCommentOnPR(e); + result = await handleIssueCommentOnPR(e); } else if (e.comment && e.issue) { result = handleIssueComment(e); } else if (e.pull_request) { @@ -220,10 +245,24 @@ function buildNotification(e) { } /* ===================== - 6. Main + 7. Main ===================== */ + +// 토큰/스코프/계정 문제 등 모든 요청에 영향을 주는 전역 오류 → 즉시 job 실패 +const FATAL_SLACK_ERRORS = new Set([ + "invalid_auth", + "not_authed", + "account_inactive", + "token_revoked", + "token_expired", + "no_permission", + "missing_scope", +]); + +const MAX_ATTEMPTS = 3; + async function main() { - const { recipients, text } = buildNotification(event); + const { recipients, text } = await buildNotification(event); const slackUsers = uniq( recipients.map(ghToSlack).filter(Boolean) @@ -234,12 +273,34 @@ async function main() { return; } + let successCount = 0; for (const uid of slackUsers) { - const channel = await openDM(uid); - await postMessage(channel, text); + let sent = false; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + await sendDM(uid, text); + sent = true; + break; + } catch (err) { + // 전역 오류: 재시도 없이 즉시 throw → job 실패 + if (FATAL_SLACK_ERRORS.has(err.message)) throw err; + + if (attempt === MAX_ATTEMPTS) { + console.error(`Failed to DM ${uid} after ${MAX_ATTEMPTS} attempts: ${err.message}`); + break; + } + const delay = err.retryAfterMs ?? 1000 * 2 ** (attempt - 1); + console.warn(`Retry ${attempt}/${MAX_ATTEMPTS} for ${uid} in ${delay}ms: ${err.message}`); + await new Promise((r) => setTimeout(r, delay)); + } + } + if (sent) successCount++; } - console.log(`Sent Slack DM to ${slackUsers.length} user(s).`); + if (successCount === 0) { + throw new Error(`All ${slackUsers.length} DM(s) failed — check bot token and user mappings.`); + } + console.log(`Sent Slack DM to ${successCount}/${slackUsers.length} user(s).`); } main().catch((err) => { diff --git a/.github/workflows/github-dm-notify.yml b/.github/workflows/github-dm-notify.yml index 89419036..2c3dde16 100644 --- a/.github/workflows/github-dm-notify.yml +++ b/.github/workflows/github-dm-notify.yml @@ -14,6 +14,7 @@ on: permissions: contents: read + pull-requests: read jobs: notify: @@ -30,4 +31,5 @@ jobs: env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} SLACK_USER_MAP: ${{ secrets.SLACK_USER_MAP }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node .github/scripts/slack-dm-notify.mjs From 51ebdd3f42d63488e000cdb52c5626bd7a59b5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=89=E1=85=A5=E1=86=BC?= =?UTF-8?q?=E1=84=8B=E1=85=AF=E1=86=AB?= Date: Mon, 2 Mar 2026 20:22:16 +0900 Subject: [PATCH 005/157] =?UTF-8?q?=ED=99=95=EC=A0=95=EC=9E=90=EA=B0=80=20?= =?UTF-8?q?=EC=9E=88=EC=9D=84=20=EC=8B=9C,=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=B7=A8=EC=86=8C=20=EB=AA=BB=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/exception/EventErrorCode.kt | 5 +++++ .../domain/event/exception/EventException.kt | 2 ++ .../domain/event/service/EventService.kt | 14 +++++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index a5a14793..9c322420 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -97,4 +97,9 @@ enum class EventErrorCode( title = "정원이 초과되었습니다.", message = "모임 정원이 가득 차\n더 이상 신청할 수 없습니다.", ), + EVENT_HAS_CONFIRMED_REGISTRATIONS( + httpStatusCode = HttpStatus.CONFLICT, + title = "확정된 참가자가 있습니다.", + message = "확정된 참가자가 있는 모임은 삭제할 수 없습니다.", + ), } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventException.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventException.kt index 28b6cdbf..365e87b2 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventException.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventException.kt @@ -19,6 +19,8 @@ class EventForbiddenException : EventException(error = EventErrorCode.NOT_EVENT_ class EventFullException : EventException(error = EventErrorCode.EVENT_FULL) +class EventHasConfirmedRegistrationsException : EventException(error = EventErrorCode.EVENT_HAS_CONFIRMED_REGISTRATIONS) + class EventValidationException( error: EventErrorCode, ) : EventException(error = error) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index c76a6237..a642052c 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -12,6 +12,7 @@ import com.wafflestudio.spring2025.domain.event.dto.response.ViewerInfo import com.wafflestudio.spring2025.domain.event.dto.response.ViewerStatus import com.wafflestudio.spring2025.domain.event.exception.EventErrorCode import com.wafflestudio.spring2025.domain.event.exception.EventForbiddenException +import com.wafflestudio.spring2025.domain.event.exception.EventHasConfirmedRegistrationsException import com.wafflestudio.spring2025.domain.event.exception.EventNotFoundException import com.wafflestudio.spring2025.domain.event.exception.EventValidationException import com.wafflestudio.spring2025.domain.event.model.Event @@ -343,7 +344,18 @@ class EventService( ) { val event = getEventByPublicId(publicId) requireCreator(event, requesterId) - eventRepository.deleteById(requireNotNull(event.id)) + val confirmedNumber = + registrationRepository + .countByEventIdAndStatus( + eventID = requireNotNull(event.id), + registrationStatus = RegistrationStatus.CONFIRMED, + ).toInt() + + if (confirmedNumber > 0) { + throw EventHasConfirmedRegistrationsException() + } else { + eventRepository.deleteById(requireNotNull(event.id)) + } } private fun getEventByPublicId(publicId: String): Event = From 1da2d6191c0e30a8641ed1f13ad78f6bfdca53bf Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 3 Mar 2026 00:29:22 +0900 Subject: [PATCH 006/157] =?UTF-8?q?:rocket:=20dev=20server=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-dev.yml | 118 +++++++++++++++++++++++++++++++++++ docker-compose.dev.yml | 81 ++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 .github/workflows/cd-dev.yml create mode 100644 docker-compose.dev.yml diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml new file mode 100644 index 00000000..8afbb3a7 --- /dev/null +++ b/.github/workflows/cd-dev.yml @@ -0,0 +1,118 @@ +name: CD Pipeline - Dev Deploy + +on: + push: + branches: + - dev + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: picokosmos + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + docker.io/picokosmos/spring-app:dev + docker.io/picokosmos/spring-app:dev-${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Deploy to Dev Server + uses: appleboy/ssh-action@v1.0.3 + env: + APP_DIR: ${{ secrets.APP_DIR }} + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + port: ${{ secrets.DEV_SSH_PORT }} + envs: APP_DIR + script: | + set -euo pipefail + if [ -n "${APP_DIR:-}" ]; then + cd "$APP_DIR" + else + cd ~/app + fi + + # Create/update .env file with secrets + cat > .env << 'EOF' + SPRING_MAIL_USERNAME=${{ secrets.SPRING_MAIL_USERNAME }} + SPRING_MAIL_PASSWORD=${{ secrets.SPRING_MAIL_PASSWORD }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }} + JWT_SECRET=${{ secrets.JWT_SECRET }} + SENDGRID_API_KEY=${{ secrets.SENDGRID_API_KEY }} + GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }} + GOOGLE_REDIRECT_URI=${{ secrets.DEV_GOOGLE_REDIRECT_URI }} + EMAIL_SERVICE_DOMAIN=${{ secrets.DEV_EMAIL_SERVICE_DOMAIN }} + EOF + + # Pull latest docker-compose file + curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/dev/docker-compose.dev.yml -o docker-compose.yml + + # Pull latest images and restart + docker compose pull + docker compose up -d + + # Clean up old images + docker image prune -f + + # Show status + echo "=== Container Status ===" + docker compose ps + + # Wait for app to be healthy + echo "=== Waiting for app to start ===" + sleep 30 + docker compose logs --tail=50 app + + - name: Health check + uses: appleboy/ssh-action@v1.0.3 + env: + APP_DIR: ${{ secrets.APP_DIR }} + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + port: ${{ secrets.DEV_SSH_PORT }} + envs: APP_DIR + script: | + set -euo pipefail + if [ -n "${APP_DIR:-}" ]; then + cd "$APP_DIR" + else + cd ~/app + fi + + echo "=== Waiting for app to be healthy ===" + for i in {1..30}; do + if curl -sf http://localhost:8080/actuator/health > /dev/null 2>&1; then + echo "App is healthy!" + exit 0 + fi + echo "Attempt $i/30: App not ready yet, waiting 30s..." + sleep 30 + done + + echo "App failed to start. Logs:" + docker compose logs --tail=100 + exit 1 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..99b4dc2e --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,81 @@ +version: "3.8" + +services: + app: + image: picokosmos/spring-app:dev + container_name: spring-app-dev + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI} + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/moisha?allowPublicKeyRetrieval=true + SPRING_DATASOURCE_USERNAME: user + SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + SPRING_FLYWAY_ENABLED: "true" + SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME} + SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + JWT_EXPIRATION_IN_MS: 86400000 + SENDGRID_API_KEY: ${SENDGRID_API_KEY} + EMAIL_FROM_ADDRESS: picokosmos@gmail.com + EMAIL_FROM_NAME: 모이밍 + EMAIL_SERVICE_DOMAIN: ${EMAIL_SERVICE_DOMAIN} + CORS_ALLOWED_ORIGINS: https://dev.moiming.app + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + memory: 512M + + mysql: + image: mysql:8.4 + container_name: mysql-db-dev + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: moisha + MYSQL_USER: user + MYSQL_PASSWORD: ${DB_PASSWORD} + volumes: + - mysql_data_dev:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + memory: 384M + + redis: + image: redis:7-alpine + container_name: redis-cache-dev + restart: unless-stopped + command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru + volumes: + - redis_data_dev:/data + deploy: + resources: + limits: + memory: 64M + +volumes: + mysql_data_dev: + redis_data_dev: From 95fb12df1aec327250ea317083885820d26a899f Mon Sep 17 00:00:00 2001 From: uykhc Date: Wed, 4 Mar 2026 01:04:02 +0900 Subject: [PATCH 007/157] =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EC=88=98=EC=A0=95=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/dto/response/UpdateEventResponse.kt | 5 + .../domain/event/exception/EventErrorCode.kt | 16 ++ .../domain/event/service/EventService.kt | 60 ++++++ .../spring2025/EventServiceTest.kt | 176 ++++++++++++++++++ .../spring2025/UpdateEventResponseTest.kt | 32 ++++ 5 files changed, 289 insertions(+) create mode 100644 src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt create mode 100644 src/test/kotlin/com/wafflestudio/spring2025/UpdateEventResponseTest.kt diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt index cad30758..d699e6af 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt @@ -13,8 +13,12 @@ data class UpdateEventResponse( val waitlistEnabled: Boolean, val registrationStartsAt: Instant?, val registrationEndsAt: Instant?, + val notice: String, ) { companion object { + const val PARTICIPANT_NOTICE: String = + "수정된 사항은 참여자들에게 자동 안내되지 않습니다. 필요 시 직접 안내해 주세요." + fun from(event: Event): UpdateEventResponse = UpdateEventResponse( title = event.title, @@ -26,6 +30,7 @@ data class UpdateEventResponse( waitlistEnabled = event.waitlistEnabled, registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, + notice = PARTICIPANT_NOTICE, ) } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index a5a14793..f840327d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -91,6 +91,22 @@ enum class EventErrorCode( message = "신청 마감 시간이 모임 시작 시간보다\n빨라야 합니다.", ), + REGISTRATION_START_CANNOT_DELAY_WITH_PARTICIPANTS( + httpStatusCode = HttpStatus.BAD_REQUEST, + title = "Registration start cannot be delayed.", + message = "When participants exist, registration start cannot be moved later than the current value.", + ), + REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS( + httpStatusCode = HttpStatus.BAD_REQUEST, + title = "Registration end cannot be advanced.", + message = "When participants exist, registration end cannot be moved earlier than the current value.", + ), + CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS( + httpStatusCode = HttpStatus.BAD_REQUEST, + title = "Capacity cannot be decreased.", + message = "When participants exist, capacity cannot be reduced below the current value.", + ), + // 23xx - Conflict EVENT_FULL( httpStatusCode = HttpStatus.CONFLICT, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index c76a6237..c6338049 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -15,6 +15,7 @@ import com.wafflestudio.spring2025.domain.event.exception.EventForbiddenExceptio import com.wafflestudio.spring2025.domain.event.exception.EventNotFoundException import com.wafflestudio.spring2025.domain.event.exception.EventValidationException import com.wafflestudio.spring2025.domain.event.model.Event +import com.wafflestudio.spring2025.domain.event.repository.EventLockRepository import com.wafflestudio.spring2025.domain.event.repository.EventRepository import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository @@ -22,12 +23,14 @@ import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.time.Instant import java.util.UUID @Service class EventService( private val eventRepository: EventRepository, + private val eventLockRepository: EventLockRepository, private val registrationRepository: RegistrationRepository, private val userRepository: UserRepository, private val imageService: ImageService, @@ -296,6 +299,7 @@ class EventService( ) } + @Transactional fun update( publicId: String, title: String?, @@ -311,6 +315,17 @@ class EventService( ): Event { val event = getEventByPublicId(publicId) requireCreator(event, requesterId) + val eventId = requireNotNull(event.id) { "Event id is null: publicId=$publicId" } + eventLockRepository.lockById(eventId) + + val activeParticipants = countActiveParticipants(eventId) + validateParticipantAwareUpdate( + event = event, + registrationStartsAt = registrationStartsAt, + registrationEndsAt = registrationEndsAt, + capacity = capacity, + activeParticipants = activeParticipants, + ) title?.let { if (it.isBlank()) throw EventValidationException(EventErrorCode.EVENT_TITLE_BLANK) @@ -419,6 +434,51 @@ class EventService( } } + private fun countActiveParticipants(eventId: Long): Int { + val confirmedCount = + registrationRepository + .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED) + .toInt() + + val waitlistedCount = + registrationRepository + .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.WAITLISTED) + .toInt() + + return confirmedCount + waitlistedCount + } + + private fun validateParticipantAwareUpdate( + event: Event, + registrationStartsAt: Instant?, + registrationEndsAt: Instant?, + capacity: Int?, + activeParticipants: Int, + ) { + if (activeParticipants <= 0) return + + if (registrationStartsAt != null && + event.registrationStartsAt != null && + registrationStartsAt.isAfter(event.registrationStartsAt) + ) { + throw EventValidationException(EventErrorCode.REGISTRATION_START_CANNOT_DELAY_WITH_PARTICIPANTS) + } + + if (registrationEndsAt != null && + event.registrationEndsAt != null && + registrationEndsAt.isBefore(event.registrationEndsAt) + ) { + throw EventValidationException(EventErrorCode.REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS) + } + + if (capacity != null && + event.capacity != null && + capacity < event.capacity!! + ) { + throw EventValidationException(EventErrorCode.CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS) + } + } + private fun buildCapabilities( viewerStatus: ViewerStatus, capacity: Int?, diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt new file mode 100644 index 00000000..de6ffa8e --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt @@ -0,0 +1,176 @@ +package com.wafflestudio.spring2025 + +import com.wafflestudio.spring2025.common.image.service.ImageService +import com.wafflestudio.spring2025.domain.event.exception.EventErrorCode +import com.wafflestudio.spring2025.domain.event.exception.EventValidationException +import com.wafflestudio.spring2025.domain.event.model.Event +import com.wafflestudio.spring2025.domain.event.repository.EventLockRepository +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.event.service.EventService +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import com.wafflestudio.spring2025.domain.user.repository.UserRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import java.time.Instant +import java.util.UUID + +class EventServiceTest { + private val eventRepository = mock(EventRepository::class.java) + private val eventLockRepository = mock(EventLockRepository::class.java) + private val registrationRepository = mock(RegistrationRepository::class.java) + private val userRepository = mock(UserRepository::class.java) + private val imageService = mock(ImageService::class.java) + + private val eventService = + EventService( + eventRepository = eventRepository, + eventLockRepository = eventLockRepository, + registrationRepository = registrationRepository, + userRepository = userRepository, + imageService = imageService, + ) + + @Test + fun `참여자가 있으면 신청 시작일을 기존보다 늦출 수 없다`() { + val event = makeEvent() + stubEventLookup(event) + stubParticipantCounts(eventId = event.id!!, confirmed = 1, waitlisted = 0) + + val exception = + assertThrows { + eventService.update( + publicId = event.publicId, + title = null, + description = null, + location = null, + startsAt = null, + endsAt = null, + capacity = null, + waitlistEnabled = null, + registrationStartsAt = event.registrationStartsAt!!.plusSeconds(300), + registrationEndsAt = null, + requesterId = event.createdBy, + ) + } + + assertThat(exception.code).isEqualTo(EventErrorCode.REGISTRATION_START_CANNOT_DELAY_WITH_PARTICIPANTS) + } + + @Test + fun `참여자가 있으면 신청 마감일을 기존보다 앞당길 수 없다`() { + val event = makeEvent() + stubEventLookup(event) + stubParticipantCounts(eventId = event.id!!, confirmed = 1, waitlisted = 0) + + val exception = + assertThrows { + eventService.update( + publicId = event.publicId, + title = null, + description = null, + location = null, + startsAt = null, + endsAt = null, + capacity = null, + waitlistEnabled = null, + registrationStartsAt = null, + registrationEndsAt = event.registrationEndsAt!!.minusSeconds(300), + requesterId = event.createdBy, + ) + } + + assertThat(exception.code).isEqualTo(EventErrorCode.REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS) + } + + @Test + fun `대기열만 있어도 정원을 기존보다 줄일 수 없다`() { + val event = makeEvent(capacity = 10) + stubEventLookup(event) + stubParticipantCounts(eventId = event.id!!, confirmed = 0, waitlisted = 1) + + val exception = + assertThrows { + eventService.update( + publicId = event.publicId, + title = null, + description = null, + location = null, + startsAt = null, + endsAt = null, + capacity = 5, + waitlistEnabled = null, + registrationStartsAt = null, + registrationEndsAt = null, + requesterId = event.createdBy, + ) + } + + assertThat(exception.code).isEqualTo(EventErrorCode.CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS) + } + + @Test + fun `참여자가 없으면 신청 시작일과 정원을 변경할 수 있다`() { + val event = makeEvent(capacity = 10) + stubEventLookup(event) + stubParticipantCounts(eventId = event.id!!, confirmed = 0, waitlisted = 0) + `when`(eventRepository.save(any(Event::class.java))).thenAnswer { it.arguments[0] as Event } + val expectedRegistrationStartsAt = event.registrationStartsAt!!.plusSeconds(300) + + val updated = + eventService.update( + publicId = event.publicId, + title = null, + description = null, + location = null, + startsAt = null, + endsAt = null, + capacity = 5, + waitlistEnabled = null, + registrationStartsAt = expectedRegistrationStartsAt, + registrationEndsAt = null, + requesterId = event.createdBy, + ) + + assertThat(updated.capacity).isEqualTo(5) + assertThat(updated.registrationStartsAt).isEqualTo(expectedRegistrationStartsAt) + } + + private fun stubEventLookup(event: Event) { + `when`(eventRepository.findByPublicId(event.publicId)).thenReturn(event) + `when`(eventRepository.save(any(Event::class.java))).thenAnswer { it.arguments[0] as Event } + } + + private fun stubParticipantCounts( + eventId: Long, + confirmed: Long, + waitlisted: Long, + ) { + `when`(registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.CONFIRMED)).thenReturn(confirmed) + `when`(registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.WAITLISTED)).thenReturn(waitlisted) + } + + private fun makeEvent(capacity: Int = 10): Event { + val now = Instant.now().plusSeconds(3600) + return Event( + id = 1L, + publicId = UUID.randomUUID().toString(), + title = "title", + description = "description", + location = "location", + startsAt = now.plusSeconds(3600), + endsAt = now.plusSeconds(7200), + capacity = capacity, + waitlistEnabled = true, + registrationStartsAt = now.plusSeconds(300), + registrationEndsAt = now.plusSeconds(1200), + createdBy = 100L, + createdAt = now, + updatedAt = now, + ) + } +} diff --git a/src/test/kotlin/com/wafflestudio/spring2025/UpdateEventResponseTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/UpdateEventResponseTest.kt new file mode 100644 index 00000000..85f96252 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/UpdateEventResponseTest.kt @@ -0,0 +1,32 @@ +package com.wafflestudio.spring2025 + +import com.wafflestudio.spring2025.domain.event.dto.response.UpdateEventResponse +import com.wafflestudio.spring2025.domain.event.model.Event +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.UUID + +class UpdateEventResponseTest { + @Test + fun `이벤트 수정 응답에 안내 문구가 포함된다`() { + val event = + Event( + publicId = UUID.randomUUID().toString(), + title = "title", + description = "description", + location = "location", + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + capacity = 10, + waitlistEnabled = true, + registrationStartsAt = Instant.now().plusSeconds(600), + registrationEndsAt = Instant.now().plusSeconds(1800), + createdBy = 1L, + ) + + val response = UpdateEventResponse.from(event) + + assertThat(response.notice).isEqualTo(UpdateEventResponse.PARTICIPANT_NOTICE) + } +} From 6c45a149b23fb4bae18c48a5215800a56524d62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Tue, 3 Mar 2026 17:46:59 +0900 Subject: [PATCH 008/157] :seedling: add issue templates (work, bug) --- .github/ISSUE_TEMPLATE/bug.yaml | 53 ++++++++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/work.yaml | 51 ++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yaml create mode 100644 .github/ISSUE_TEMPLATE/work.yaml diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 00000000..185bc240 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,53 @@ +name: 🐛 Bug +description: 버그 리포트 +title: "" +labels: ["bug"] + +body: + - type: textarea + id: reproduce + attributes: + label: 재현 방법 (필수) + description: 버그를 재현할 수 있는 최소 단계를 작성해주세요. + placeholder: | + (예시) + 1. POST /events/{id}/registrations 호출 + 2. 정원이 가득 찬 이벤트에 신청 + 3. 500 에러 발생 + validations: + required: true + + - type: textarea + id: expected + attributes: + label: 기대 동작 (필수) + description: 정상이라면 어떤 상태/응답이 나와야 하나요? + placeholder: | + (예시) + - 정원 초과 시 WAITLIST로 처리 + validations: + required: true + + - type: textarea + id: attachments + attributes: + label: 스크린샷 / 로그 (선택) + description: 가능하면 첨부해주세요. 이미지/파일은 이 입력칸에 드래그&드롭하면 업로드됩니다. + placeholder: | + (예시) + - Swagger 응답 캡처 첨부 + - 서버 로그 일부 붙여넣기 + validations: + required: false + + - type: textarea + id: suspected_cause + attributes: + label: 예상 원인 (선택) + description: 원인을 추측할 수 있다면 간단히 작성해주세요. (모르겠다면 비워도 됩니다) + placeholder: | + (예시) + - null 검증이 누락된 것으로 보입니다. + - 예외가 GlobalExceptionHandler에서 처리되지 않는 것 같습니다. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/work.yaml b/.github/ISSUE_TEMPLATE/work.yaml new file mode 100644 index 00000000..231b130a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/work.yaml @@ -0,0 +1,51 @@ +name: 🧩 Work +description: 기능 추가, 리팩터링, 인프라, 문서 등 작업 이슈 +title: "" +labels: ["work"] + +body: + - type: textarea + id: summary + attributes: + label: 작업 내용 (필수) + description: 무엇을 변경/추가하는지와 핵심 내용을 간단히 작성해주세요. + placeholder: | + (예시) + 회원 탈퇴 API를 구현합니다. + - 로그인된 사용자가 탈퇴 요청 시 계정을 삭제합니다. + validations: + required: true + + - type: textarea + id: background + attributes: + label: 배경 / 문제점 (선택) + description: 현재 상태와 이 작업이 필요한 이유를 간단히 작성해주세요. + placeholder: | + (예시) + - 탈퇴 로직의 부재로 새로운 계정 생성에 제약이 있어 테스트가 어렵습니다. + - 사용자가 탈퇴할 수 있는 기능이 필요합니다. + validations: + required: false + + - type: textarea + id: goal + attributes: + label: 목표 / 기대 결과 (선택) + description: 이 작업이 완료되면 무엇이 달라지나요? + placeholder: | + (예시) + - 로그인된 사용자가 탈퇴 요청 시 계정이 삭제됩니다. + - 탈퇴 후 동일 이메일로 재가입이 가능합니다. + validations: + required: false + + - type: checkboxes + id: impact + attributes: + label: 영향 범위 (해당 시 체크) + description: 해당 작업이 시스템에 미치는 영향이 있다면 체크해주세요. + options: + - label: DB 스키마 변경 + - label: API 스펙 변경 + - label: 환경 변수 추가/변경 From 98a1d8301d80178af81376e1f42805c0112df3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Tue, 3 Mar 2026 17:47:16 +0900 Subject: [PATCH 009/157] :seedling: add pr templates (main, dev) --- .github/PULL_REQUEST_TEMPLATE/dev.md | 40 +++++++++++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE/main.md | 15 ++++++++++ 2 files changed, 55 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE/dev.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/main.md diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md new file mode 100644 index 00000000..c8f632ce --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -0,0 +1,40 @@ +## ✨ Feature PR (to dev) + +### 🔀 Base branch 확인 +- [ ] 이 PR의 base branch를 `dev`로 설정했습니다. + +### 📌 작업 내용 + + +- + +### 🔎 영향 범위 + +- [ ] DB 스키마 변경 +- [ ] API 스펙 변경 +- [ ] 환경 변수 추가/변경 + +### 📡API 변경사항 + + +| Method | URL | 변경 내용 | +|-----------|-----|----------| +| (예시) POST | /events | capacity 필드 추가 | + +### 👀 집중 리뷰 요청사항 + + +- + +### 🔥 관련 이슈 +- closes # + +### 🧪 로컬 테스트 여부 +- [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. diff --git a/.github/PULL_REQUEST_TEMPLATE/main.md b/.github/PULL_REQUEST_TEMPLATE/main.md new file mode 100644 index 00000000..94ff65d8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/main.md @@ -0,0 +1,15 @@ +## 🚀 Production PR (to main) + +### 🔀 Base branch 확인 +- [ ] 이 PR의 base branch는 `main`입니다. `main`에는 `dev` 혹은 `hotfix` 브랜치만 머지할 수 있습니다. + +### 📝 업데이트 사항 요약 + + +- + +### 🧪 테스트 여부 +- [ ] dev 서버 배포 성공 +- [ ] CI 성공 +- [ ] 환경 변수 설정 확인 +- [ ] dev 서버에서 변경사항에 대해 의도대로 동작함을 확인 From d8ab8cee1e996e888b69b4caa06ab2a8c5c51ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Tue, 3 Mar 2026 17:47:38 +0900 Subject: [PATCH 010/157] :wrench: add ci action to dev branch --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58f21177..3faac186 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,10 @@ name: ci.yml on: pull_request: branches: - - main + - main, dev push: branches: - - main + - main, dev jobs: build: From cf70bc76a1a9c1281948091a818783114c351813 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 3 Mar 2026 20:15:50 +0900 Subject: [PATCH 011/157] =?UTF-8?q?:pencil2:=20=EB=88=84=EB=9D=BD=EB=90=9C?= =?UTF-8?q?=20=EA=B3=B5=EB=B0=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/dev.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index c8f632ce..18e60275 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -14,7 +14,7 @@ - [ ] API 스펙 변경 - [ ] 환경 변수 추가/변경 -### 📡API 변경사항 +### 📡 API 변경사항 + +### 🔀 Base branch 확인 +- [ ] 이 PR의 base branch를 `dev`로 설정했습니다. + +### 📌 작업 내용 + + +- + +### 🔎 영향 범위 + +- [ ] DB 스키마 변경 +- [ ] API 스펙 변경 +- [ ] 환경 변수 추가/변경 + +### 📡 API 변경사항 + + +| Method | URL | 변경 내용 | +|-----------|-----|----------| +| (예시) POST | /events | capacity 필드 추가 | + +### 👀 집중 리뷰 요청사항 + + +- + +### 🔥 관련 이슈 +- closes # + +### 🧪 로컬 테스트 여부 +- [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. From 0368cca507de84613648f8f0d2845bed591a02c3 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 3 Mar 2026 20:37:34 +0900 Subject: [PATCH 013/157] =?UTF-8?q?:seedling:=20pr=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9A=94=EC=86=8C=EB=93=A4=20=EC=B5=9C=EC=83=81=EB=8B=A8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/dev.md | 5 +++-- .github/PULL_REQUEST_TEMPLATE/main.md | 15 ++++++++++----- .github/pull_request_template.md | 5 +++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index 18e60275..bf25fec3 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -3,6 +3,9 @@ ### 🔀 Base branch 확인 - [ ] 이 PR의 base branch를 `dev`로 설정했습니다. +### 🧪 로컬 테스트 여부 +- [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. + ### 📌 작업 내용 @@ -36,5 +39,3 @@ API 변경이 없을 경우 이 섹션을 삭제해 주세요. ### 🔥 관련 이슈 - closes # -### 🧪 로컬 테스트 여부 -- [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. diff --git a/.github/PULL_REQUEST_TEMPLATE/main.md b/.github/PULL_REQUEST_TEMPLATE/main.md index 94ff65d8..08eb409a 100644 --- a/.github/PULL_REQUEST_TEMPLATE/main.md +++ b/.github/PULL_REQUEST_TEMPLATE/main.md @@ -3,13 +3,18 @@ ### 🔀 Base branch 확인 - [ ] 이 PR의 base branch는 `main`입니다. `main`에는 `dev` 혹은 `hotfix` 브랜치만 머지할 수 있습니다. -### 📝 업데이트 사항 요약 - - -- - ### 🧪 테스트 여부 - [ ] dev 서버 배포 성공 - [ ] CI 성공 - [ ] 환경 변수 설정 확인 - [ ] dev 서버에서 변경사항에 대해 의도대로 동작함을 확인 + +### 📝 업데이트 사항 요약 + + +- + +### 🔥 관련 이슈 + +- closes # + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9c873c4e..31634556 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,6 +9,9 @@ main에 머지하는 PR이라면 주소창에서 현재 url 맨 뒤에 다음을 ### 🔀 Base branch 확인 - [ ] 이 PR의 base branch를 `dev`로 설정했습니다. +### 🧪 로컬 테스트 여부 +- [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. + ### 📌 작업 내용 @@ -42,5 +45,3 @@ API 변경이 없을 경우 이 섹션을 삭제해 주세요. ### 🔥 관련 이슈 - closes # -### 🧪 로컬 테스트 여부 -- [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. From f561ed3ac64409a23518be9fc0395b9ddf4ccfb9 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 3 Mar 2026 20:38:01 +0900 Subject: [PATCH 014/157] =?UTF-8?q?:seedling:=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EA=B5=AC=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index 185bc240..4f37934c 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -7,12 +7,14 @@ body: - type: textarea id: reproduce attributes: - label: 재현 방법 (필수) - description: 버그를 재현할 수 있는 최소 단계를 작성해주세요. + label: 문제 상황 및 재현 방법 (필수) + description: 버그를 간략히 설명하고, 재현할 수 있는 최소 단계를 작성해주세요. placeholder: | (예시) - 1. POST /events/{id}/registrations 호출 - 2. 정원이 가득 찬 이벤트에 신청 + 정원이 가득 찬 일정에 참여신청 시 500 에러 발생 + + 1. 정원이 가득 찬 일정 선택 + 2. POST /events/{해당 일정 id}/registrations 호출 3. 500 에러 발생 validations: required: true From 7d074a78f4d97e159728bff0f87d55fa976e6223 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 3 Mar 2026 21:05:53 +0900 Subject: [PATCH 015/157] =?UTF-8?q?:seedling:=20pr=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=EC=97=90=EC=84=9C=20=EA=B0=80=EB=8F=85=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=ED=95=B4=EC=B9=98=EB=8A=94=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/dev.md | 22 +++++----------------- .github/PULL_REQUEST_TEMPLATE/main.md | 1 - .github/pull_request_template.md | 24 +++--------------------- 3 files changed, 8 insertions(+), 39 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index bf25fec3..62b63a46 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -6,36 +6,24 @@ ### 🧪 로컬 테스트 여부 - [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. -### 📌 작업 내용 - +### 📌 작업 내용(what & why) -- +- ### 🔎 영향 범위 - - [ ] DB 스키마 변경 - [ ] API 스펙 변경 - [ ] 환경 변수 추가/변경 -### 📡 API 변경사항 - +### 📡 API 변경사항 (있다면) | Method | URL | 변경 내용 | |-----------|-----|----------| | (예시) POST | /events | capacity 필드 추가 | -### 👀 집중 리뷰 요청사항 - +### 👀 집중 리뷰 요청사항 (있다면) -- +- ### 🔥 관련 이슈 - closes # - diff --git a/.github/PULL_REQUEST_TEMPLATE/main.md b/.github/PULL_REQUEST_TEMPLATE/main.md index 08eb409a..42b0afff 100644 --- a/.github/PULL_REQUEST_TEMPLATE/main.md +++ b/.github/PULL_REQUEST_TEMPLATE/main.md @@ -10,7 +10,6 @@ - [ ] dev 서버에서 변경사항에 대해 의도대로 동작함을 확인 ### 📝 업데이트 사항 요약 - - diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 31634556..62b63a46 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,47 +1,29 @@ ## ✨ Feature PR (to dev) - - ### 🔀 Base branch 확인 - [ ] 이 PR의 base branch를 `dev`로 설정했습니다. ### 🧪 로컬 테스트 여부 - [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. -### 📌 작업 내용 - +### 📌 작업 내용(what & why) - ### 🔎 영향 범위 - - [ ] DB 스키마 변경 - [ ] API 스펙 변경 - [ ] 환경 변수 추가/변경 -### 📡 API 변경사항 - +### 📡 API 변경사항 (있다면) | Method | URL | 변경 내용 | |-----------|-----|----------| | (예시) POST | /events | capacity 필드 추가 | -### 👀 집중 리뷰 요청사항 - +### 👀 집중 리뷰 요청사항 (있다면) - ### 🔥 관련 이슈 - closes # - From ba2d32d87562270e3348005932a3caa72ce90d38 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 3 Mar 2026 21:15:05 +0900 Subject: [PATCH 016/157] =?UTF-8?q?:seedling:=20pr=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=EC=97=90=EC=84=9C=20base=20branch=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=A6=AC=EB=A7=88=EC=9D=B8=EB=8D=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/dev.md | 3 --- .github/PULL_REQUEST_TEMPLATE/main.md | 3 --- .github/pull_request_template.md | 3 --- 3 files changed, 9 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index 62b63a46..05d3d9a6 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -1,8 +1,5 @@ ## ✨ Feature PR (to dev) -### 🔀 Base branch 확인 -- [ ] 이 PR의 base branch를 `dev`로 설정했습니다. - ### 🧪 로컬 테스트 여부 - [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. diff --git a/.github/PULL_REQUEST_TEMPLATE/main.md b/.github/PULL_REQUEST_TEMPLATE/main.md index 42b0afff..e46646af 100644 --- a/.github/PULL_REQUEST_TEMPLATE/main.md +++ b/.github/PULL_REQUEST_TEMPLATE/main.md @@ -1,8 +1,5 @@ ## 🚀 Production PR (to main) -### 🔀 Base branch 확인 -- [ ] 이 PR의 base branch는 `main`입니다. `main`에는 `dev` 혹은 `hotfix` 브랜치만 머지할 수 있습니다. - ### 🧪 테스트 여부 - [ ] dev 서버 배포 성공 - [ ] CI 성공 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 62b63a46..05d3d9a6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,5 @@ ## ✨ Feature PR (to dev) -### 🔀 Base branch 확인 -- [ ] 이 PR의 base branch를 `dev`로 설정했습니다. - ### 🧪 로컬 테스트 여부 - [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. From 452a6207a4c7cbecc0acd838a5c5ef44b3dc26cf Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 3 Mar 2026 21:27:51 +0900 Subject: [PATCH 017/157] =?UTF-8?q?:seedling:=20pr=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=EC=97=90=EC=84=9C=20task=EB=A1=9C=20=EC=98=A4?= =?UTF-8?q?=EC=9D=B8=EB=90=98=EB=8A=94=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20bullet=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/dev.md | 7 ++++--- .github/pull_request_template.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index 05d3d9a6..5acee232 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -8,9 +8,10 @@ - ### 🔎 영향 범위 -- [ ] DB 스키마 변경 -- [ ] API 스펙 변경 -- [ ] 환경 변수 추가/변경 + +- DB 스키마 변경 +- API 스펙 변경 +- 환경 변수 추가/변경 ### 📡 API 변경사항 (있다면) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 05d3d9a6..5acee232 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,9 +8,10 @@ - ### 🔎 영향 범위 -- [ ] DB 스키마 변경 -- [ ] API 스펙 변경 -- [ ] 환경 변수 추가/변경 + +- DB 스키마 변경 +- API 스펙 변경 +- 환경 변수 추가/변경 ### 📡 API 변경사항 (있다면) From 8b7532b1cced4a7061235065fde1703305731c70 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Wed, 4 Mar 2026 00:23:28 +0900 Subject: [PATCH 018/157] =?UTF-8?q?:bug:=20ci=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EA=B1=B0=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EA=B8=B0=EC=9E=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3faac186..16f3dde9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,12 @@ name: ci.yml on: pull_request: branches: - - main, dev + - main + - dev push: branches: - - main, dev + - main + - dev jobs: build: From 47e4f964a0f87828d61fa5e19b72504e62493129 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Wed, 4 Mar 2026 00:26:13 +0900 Subject: [PATCH 019/157] =?UTF-8?q?:bug:=20ISSUE=20TEMPLATE=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=97=88=EC=9A=A9=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=B9=88=20=EC=A0=9C=EB=AA=A9=20placeholder=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug.yaml | 2 +- .github/ISSUE_TEMPLATE/work.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index 4f37934c..37542fba 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -1,6 +1,6 @@ name: 🐛 Bug description: 버그 리포트 -title: "" +title: "제목" labels: ["bug"] body: diff --git a/.github/ISSUE_TEMPLATE/work.yaml b/.github/ISSUE_TEMPLATE/work.yaml index 231b130a..5bf4cf36 100644 --- a/.github/ISSUE_TEMPLATE/work.yaml +++ b/.github/ISSUE_TEMPLATE/work.yaml @@ -1,6 +1,6 @@ name: 🧩 Work description: 기능 추가, 리팩터링, 인프라, 문서 등 작업 이슈 -title: "" +title: "제목" labels: ["work"] body: From e636a7e3f1e9443769fa2629c428b76b838a81c7 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Wed, 4 Mar 2026 00:30:54 +0900 Subject: [PATCH 020/157] =?UTF-8?q?:seedling:=20issue=20=EB=B0=8F=20pr=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20'=EC=98=81=ED=96=A5=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84'=EC=97=90=20=EB=8F=99=EC=8B=9C=EC=84=B1/=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=ED=95=AD=EB=AA=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/work.yaml | 1 + .github/PULL_REQUEST_TEMPLATE/dev.md | 1 + .github/pull_request_template.md | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/work.yaml b/.github/ISSUE_TEMPLATE/work.yaml index 5bf4cf36..11096d1e 100644 --- a/.github/ISSUE_TEMPLATE/work.yaml +++ b/.github/ISSUE_TEMPLATE/work.yaml @@ -49,3 +49,4 @@ body: - label: DB 스키마 변경 - label: API 스펙 변경 - label: 환경 변수 추가/변경 + - label: 동시성 / 트랜잭션 처리 영향 diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index 5acee232..3cd9c34a 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -12,6 +12,7 @@ - DB 스키마 변경 - API 스펙 변경 - 환경 변수 추가/변경 +- 동시성 / 트랜잭션 처리 영향 ### 📡 API 변경사항 (있다면) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5acee232..3cd9c34a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,6 +12,7 @@ - DB 스키마 변경 - API 스펙 변경 - 환경 변수 추가/변경 +- 동시성 / 트랜잭션 처리 영향 ### 📡 API 변경사항 (있다면) From f427fae8ac31b7e61c083bd6b0568ad2749747b3 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Thu, 5 Mar 2026 20:18:31 +0900 Subject: [PATCH 021/157] =?UTF-8?q?:memo:=20PR=20template=EC=97=90=20docum?= =?UTF-8?q?entation=20=ED=99=95=EC=9D=B8=20=ED=95=AD=EB=AA=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/dev.md | 7 ++++++- .github/pull_request_template.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index 3cd9c34a..ad0b6ad0 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -1,8 +1,13 @@ ## ✨ Feature PR (to dev) -### 🧪 로컬 테스트 여부 +### 🧪 로컬 테스트 여부 (작업자 체크) - [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. +### 📄 documentation 최신화 여부 +> 변경사항과 관련된 API의 swagger documentation이 실제 동작과 일치하는지 확인합니다. +- [ ] (작업자) 확인하였습니다. +- [ ] (리뷰어) 확인하였습니다. + ### 📌 작업 내용(what & why) - diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3cd9c34a..ad0b6ad0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,13 @@ ## ✨ Feature PR (to dev) -### 🧪 로컬 테스트 여부 +### 🧪 로컬 테스트 여부 (작업자 체크) - [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. +### 📄 documentation 최신화 여부 +> 변경사항과 관련된 API의 swagger documentation이 실제 동작과 일치하는지 확인합니다. +- [ ] (작업자) 확인하였습니다. +- [ ] (리뷰어) 확인하였습니다. + ### 📌 작업 내용(what & why) - From 23d1f9a0239fc21b3b7312d895f7401542766a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Fri, 6 Mar 2026 09:48:34 +0900 Subject: [PATCH 022/157] =?UTF-8?q?:seedling:=20default=20pr=20template?= =?UTF-8?q?=EB=A5=BC=20=EB=A9=94=EC=9D=B8=20=ED=85=9C=ED=94=8C=EB=A6=BF/?= =?UTF-8?q?=EB=8D=B0=EB=B8=8C=20=ED=85=9C=ED=94=8C=EB=A6=BF=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EA=B2=B0=EB=90=98=EB=8A=94=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=ED=98=95=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 36 ++++---------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ad0b6ad0..aaff02e3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,33 +1,5 @@ -## ✨ Feature PR (to dev) +## PR 템플릿 선택 +>preview 클릭 후 원하는 템플릿으로 이동하세요. -### 🧪 로컬 테스트 여부 (작업자 체크) -- [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. - -### 📄 documentation 최신화 여부 -> 변경사항과 관련된 API의 swagger documentation이 실제 동작과 일치하는지 확인합니다. -- [ ] (작업자) 확인하였습니다. -- [ ] (리뷰어) 확인하였습니다. - -### 📌 작업 내용(what & why) - -- - -### 🔎 영향 범위 - -- DB 스키마 변경 -- API 스펙 변경 -- 환경 변수 추가/변경 -- 동시성 / 트랜잭션 처리 영향 - -### 📡 API 변경사항 (있다면) - -| Method | URL | 변경 내용 | -|-----------|-----|----------| -| (예시) POST | /events | capacity 필드 추가 | - -### 👀 집중 리뷰 요청사항 (있다면) - -- - -### 🔥 관련 이슈 -- closes # +- ✨ [dev PR 템플릿](?expand=1&template=feature_template.md) +- 🚀 [main PR 템플릿](?expand=1&template=feature_template.md) From 1e845ad883a00e3d9125b5e303a885994766ba30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Fri, 6 Mar 2026 09:49:09 +0900 Subject: [PATCH 023/157] =?UTF-8?q?:seedling:=20Issue=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=EC=97=90=EC=84=9C=20=EB=AA=A9=ED=91=9C/=EA=B8=B0?= =?UTF-8?q?=EB=8C=80=20=EA=B2=B0=EA=B3=BC=20=ED=95=AD=EB=AA=A9=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20'=ED=95=84=EC=88=98'=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/work.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/work.yaml b/.github/ISSUE_TEMPLATE/work.yaml index 11096d1e..12293f44 100644 --- a/.github/ISSUE_TEMPLATE/work.yaml +++ b/.github/ISSUE_TEMPLATE/work.yaml @@ -31,14 +31,14 @@ body: - type: textarea id: goal attributes: - label: 목표 / 기대 결과 (선택) + label: 목표 / 기대 결과 (필수) description: 이 작업이 완료되면 무엇이 달라지나요? placeholder: | (예시) - 로그인된 사용자가 탈퇴 요청 시 계정이 삭제됩니다. - 탈퇴 후 동일 이메일로 재가입이 가능합니다. validations: - required: false + required: true - type: checkboxes id: impact From 0688129771a6b283b1836dd839b00a875aeac3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Fri, 6 Mar 2026 10:13:48 +0900 Subject: [PATCH 024/157] =?UTF-8?q?:bug:=20pr=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EB=A7=81=ED=81=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index aaff02e3..5d6af1b7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ ## PR 템플릿 선택 >preview 클릭 후 원하는 템플릿으로 이동하세요. -- ✨ [dev PR 템플릿](?expand=1&template=feature_template.md) -- 🚀 [main PR 템플릿](?expand=1&template=feature_template.md) +- ✨ [dev PR 템플릿](?expand=1&template=dev.md) +- 🚀 [main PR 템플릿](?expand=1&template=main.md) From 9e8c6c49314fb4199c14ade014724f03becc1533 Mon Sep 17 00:00:00 2001 From: uykhc Date: Fri, 6 Mar 2026 15:44:46 +0900 Subject: [PATCH 025/157] update: update event-update-logic --- .../event/dto/response/UpdateEventResponse.kt | 5 - .../domain/event/exception/EventErrorCode.kt | 2 +- .../domain/event/service/EventService.kt | 44 ++++----- .../service/RegistrationService.kt | 4 +- .../service/WaitlistReconciliationService.kt | 5 + .../spring2025/EventServiceTest.kt | 94 +++++++++++++++++-- .../spring2025/UpdateEventResponseTest.kt | 32 ------- 7 files changed, 118 insertions(+), 68 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt delete mode 100644 src/test/kotlin/com/wafflestudio/spring2025/UpdateEventResponseTest.kt diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt index d699e6af..cad30758 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt @@ -13,12 +13,8 @@ data class UpdateEventResponse( val waitlistEnabled: Boolean, val registrationStartsAt: Instant?, val registrationEndsAt: Instant?, - val notice: String, ) { companion object { - const val PARTICIPANT_NOTICE: String = - "수정된 사항은 참여자들에게 자동 안내되지 않습니다. 필요 시 직접 안내해 주세요." - fun from(event: Event): UpdateEventResponse = UpdateEventResponse( title = event.title, @@ -30,7 +26,6 @@ data class UpdateEventResponse( waitlistEnabled = event.waitlistEnabled, registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, - notice = PARTICIPANT_NOTICE, ) } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index f840327d..30fbaf18 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -104,7 +104,7 @@ enum class EventErrorCode( CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS( httpStatusCode = HttpStatus.BAD_REQUEST, title = "Capacity cannot be decreased.", - message = "When participants exist, capacity cannot be reduced below the current value.", + message = "Capacity cannot be reduced below the current confirmed participant count.", ), // 23xx - Conflict diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index c6338049..5b1bb3c7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -19,6 +19,7 @@ import com.wafflestudio.spring2025.domain.event.repository.EventLockRepository import com.wafflestudio.spring2025.domain.event.repository.EventRepository import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import com.wafflestudio.spring2025.domain.registration.service.WaitlistReconciliationService import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort @@ -32,6 +33,7 @@ class EventService( private val eventRepository: EventRepository, private val eventLockRepository: EventLockRepository, private val registrationRepository: RegistrationRepository, + private val waitlistReconciliationService: WaitlistReconciliationService, private val userRepository: UserRepository, private val imageService: ImageService, ) { @@ -317,14 +319,15 @@ class EventService( requireCreator(event, requesterId) val eventId = requireNotNull(event.id) { "Event id is null: publicId=$publicId" } eventLockRepository.lockById(eventId) + val previousCapacity = event.capacity - val activeParticipants = countActiveParticipants(eventId) + val confirmedParticipants = countConfirmedParticipants(eventId) validateParticipantAwareUpdate( event = event, registrationStartsAt = registrationStartsAt, registrationEndsAt = registrationEndsAt, capacity = capacity, - activeParticipants = activeParticipants, + confirmedParticipants = confirmedParticipants, ) title?.let { @@ -349,7 +352,11 @@ class EventService( registrationEndsAt = event.registrationEndsAt, ) - return eventRepository.save(event) + val savedEvent = eventRepository.save(event) + if (isCapacityIncreased(previousCapacity, savedEvent.capacity)) { + waitlistReconciliationService.reconcileWaitlist(eventId) + } + return savedEvent } fun delete( @@ -434,28 +441,19 @@ class EventService( } } - private fun countActiveParticipants(eventId: Long): Int { - val confirmedCount = - registrationRepository - .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED) - .toInt() - - val waitlistedCount = - registrationRepository - .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.WAITLISTED) - .toInt() - - return confirmedCount + waitlistedCount - } + private fun countConfirmedParticipants(eventId: Long): Int = + registrationRepository + .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED) + .toInt() private fun validateParticipantAwareUpdate( event: Event, registrationStartsAt: Instant?, registrationEndsAt: Instant?, capacity: Int?, - activeParticipants: Int, + confirmedParticipants: Int, ) { - if (activeParticipants <= 0) return + if (confirmedParticipants <= 0) return if (registrationStartsAt != null && event.registrationStartsAt != null && @@ -471,14 +469,16 @@ class EventService( throw EventValidationException(EventErrorCode.REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS) } - if (capacity != null && - event.capacity != null && - capacity < event.capacity!! - ) { + if (capacity != null && capacity < confirmedParticipants) { throw EventValidationException(EventErrorCode.CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS) } } + private fun isCapacityIncreased( + previousCapacity: Int?, + newCapacity: Int?, + ): Boolean = previousCapacity != null && newCapacity != null && newCapacity > previousCapacity + private fun buildCapabilities( viewerStatus: ViewerStatus, capacity: Int?, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index eb02c22d..54084a91 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -47,7 +47,7 @@ RegistrationService( private val userRepository: UserRepository, private val emailService: EmailService, private val imageService: ImageService, -) { +) : WaitlistReconciliationService { private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") private val tokenValidity = Duration.ofHours(24) @@ -669,7 +669,7 @@ RegistrationService( } @Transactional - fun reconcileWaitlist(eventId: Long) { + override fun reconcileWaitlist(eventId: Long) { eventLockRepository.lockById(eventId) val event: Event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt new file mode 100644 index 00000000..c2193228 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt @@ -0,0 +1,5 @@ +package com.wafflestudio.spring2025.domain.registration.service + +interface WaitlistReconciliationService { + fun reconcileWaitlist(eventId: Long) +} diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt index de6ffa8e..91c186d1 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt @@ -9,12 +9,15 @@ import com.wafflestudio.spring2025.domain.event.repository.EventRepository import com.wafflestudio.spring2025.domain.event.service.EventService import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import com.wafflestudio.spring2025.domain.registration.service.WaitlistReconciliationService import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import java.time.Instant import java.util.UUID @@ -23,6 +26,7 @@ class EventServiceTest { private val eventRepository = mock(EventRepository::class.java) private val eventLockRepository = mock(EventLockRepository::class.java) private val registrationRepository = mock(RegistrationRepository::class.java) + private val waitlistReconciliationService = mock(WaitlistReconciliationService::class.java) private val userRepository = mock(UserRepository::class.java) private val imageService = mock(ImageService::class.java) @@ -31,12 +35,13 @@ class EventServiceTest { eventRepository = eventRepository, eventLockRepository = eventLockRepository, registrationRepository = registrationRepository, + waitlistReconciliationService = waitlistReconciliationService, userRepository = userRepository, imageService = imageService, ) @Test - fun `참여자가 있으면 신청 시작일을 기존보다 늦출 수 없다`() { + fun `registration start cannot be delayed when confirmed exists`() { val event = makeEvent() stubEventLookup(event) stubParticipantCounts(eventId = event.id!!, confirmed = 1, waitlisted = 0) @@ -62,7 +67,7 @@ class EventServiceTest { } @Test - fun `참여자가 있으면 신청 마감일을 기존보다 앞당길 수 없다`() { + fun `registration end cannot be advanced when confirmed exists`() { val event = makeEvent() stubEventLookup(event) stubParticipantCounts(eventId = event.id!!, confirmed = 1, waitlisted = 0) @@ -88,10 +93,37 @@ class EventServiceTest { } @Test - fun `대기열만 있어도 정원을 기존보다 줄일 수 없다`() { + fun `registration window can be changed when only waitlisted exists`() { val event = makeEvent(capacity = 10) stubEventLookup(event) stubParticipantCounts(eventId = event.id!!, confirmed = 0, waitlisted = 1) + val expectedStart = event.registrationStartsAt!!.plusSeconds(300) + val expectedEnd = event.registrationEndsAt!!.minusSeconds(300) + + val updated = + eventService.update( + publicId = event.publicId, + title = null, + description = null, + location = null, + startsAt = null, + endsAt = null, + capacity = null, + waitlistEnabled = null, + registrationStartsAt = expectedStart, + registrationEndsAt = expectedEnd, + requesterId = event.createdBy, + ) + + assertThat(updated.registrationStartsAt).isEqualTo(expectedStart) + assertThat(updated.registrationEndsAt).isEqualTo(expectedEnd) + } + + @Test + fun `capacity cannot be set below confirmed count`() { + val event = makeEvent(capacity = 10) + stubEventLookup(event) + stubParticipantCounts(eventId = event.id!!, confirmed = 3, waitlisted = 5) val exception = assertThrows { @@ -102,7 +134,7 @@ class EventServiceTest { location = null, startsAt = null, endsAt = null, - capacity = 5, + capacity = 2, waitlistEnabled = null, registrationStartsAt = null, registrationEndsAt = null, @@ -114,11 +146,60 @@ class EventServiceTest { } @Test - fun `참여자가 없으면 신청 시작일과 정원을 변경할 수 있다`() { + fun `capacity can be lowered to at least confirmed count`() { + val event = makeEvent(capacity = 10) + stubEventLookup(event) + stubParticipantCounts(eventId = event.id!!, confirmed = 3, waitlisted = 10) + + val updated = + eventService.update( + publicId = event.publicId, + title = null, + description = null, + location = null, + startsAt = null, + endsAt = null, + capacity = 3, + waitlistEnabled = null, + registrationStartsAt = null, + registrationEndsAt = null, + requesterId = event.createdBy, + ) + + assertThat(updated.capacity).isEqualTo(3) + verify(waitlistReconciliationService, never()).reconcileWaitlist(event.id!!) + } + + @Test + fun `increasing capacity delegates waitlist reconciliation`() { + val event = makeEvent(capacity = 1) + stubEventLookup(event) + stubParticipantCounts(eventId = event.id!!, confirmed = 1, waitlisted = 2) + + val updated = + eventService.update( + publicId = event.publicId, + title = null, + description = null, + location = null, + startsAt = null, + endsAt = null, + capacity = 2, + waitlistEnabled = null, + registrationStartsAt = null, + registrationEndsAt = null, + requesterId = event.createdBy, + ) + + assertThat(updated.capacity).isEqualTo(2) + verify(waitlistReconciliationService).reconcileWaitlist(event.id!!) + } + + @Test + fun `both registration start and capacity can be changed when no confirmed exists`() { val event = makeEvent(capacity = 10) stubEventLookup(event) stubParticipantCounts(eventId = event.id!!, confirmed = 0, waitlisted = 0) - `when`(eventRepository.save(any(Event::class.java))).thenAnswer { it.arguments[0] as Event } val expectedRegistrationStartsAt = event.registrationStartsAt!!.plusSeconds(300) val updated = @@ -138,6 +219,7 @@ class EventServiceTest { assertThat(updated.capacity).isEqualTo(5) assertThat(updated.registrationStartsAt).isEqualTo(expectedRegistrationStartsAt) + verify(waitlistReconciliationService, never()).reconcileWaitlist(event.id!!) } private fun stubEventLookup(event: Event) { diff --git a/src/test/kotlin/com/wafflestudio/spring2025/UpdateEventResponseTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/UpdateEventResponseTest.kt deleted file mode 100644 index 85f96252..00000000 --- a/src/test/kotlin/com/wafflestudio/spring2025/UpdateEventResponseTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.wafflestudio.spring2025 - -import com.wafflestudio.spring2025.domain.event.dto.response.UpdateEventResponse -import com.wafflestudio.spring2025.domain.event.model.Event -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant -import java.util.UUID - -class UpdateEventResponseTest { - @Test - fun `이벤트 수정 응답에 안내 문구가 포함된다`() { - val event = - Event( - publicId = UUID.randomUUID().toString(), - title = "title", - description = "description", - location = "location", - startsAt = Instant.now().plusSeconds(3600), - endsAt = Instant.now().plusSeconds(7200), - capacity = 10, - waitlistEnabled = true, - registrationStartsAt = Instant.now().plusSeconds(600), - registrationEndsAt = Instant.now().plusSeconds(1800), - createdBy = 1L, - ) - - val response = UpdateEventResponse.from(event) - - assertThat(response.notice).isEqualTo(UpdateEventResponse.PARTICIPANT_NOTICE) - } -} From b4ca6c9a4695eedcc50e93427d856338f31c984c Mon Sep 17 00:00:00 2001 From: uykhc Date: Fri, 6 Mar 2026 15:49:52 +0900 Subject: [PATCH 026/157] update: delete not useful test --- .../spring2025/EventServiceTest.kt | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt index 91c186d1..d34b312d 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt @@ -92,33 +92,6 @@ class EventServiceTest { assertThat(exception.code).isEqualTo(EventErrorCode.REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS) } - @Test - fun `registration window can be changed when only waitlisted exists`() { - val event = makeEvent(capacity = 10) - stubEventLookup(event) - stubParticipantCounts(eventId = event.id!!, confirmed = 0, waitlisted = 1) - val expectedStart = event.registrationStartsAt!!.plusSeconds(300) - val expectedEnd = event.registrationEndsAt!!.minusSeconds(300) - - val updated = - eventService.update( - publicId = event.publicId, - title = null, - description = null, - location = null, - startsAt = null, - endsAt = null, - capacity = null, - waitlistEnabled = null, - registrationStartsAt = expectedStart, - registrationEndsAt = expectedEnd, - requesterId = event.createdBy, - ) - - assertThat(updated.registrationStartsAt).isEqualTo(expectedStart) - assertThat(updated.registrationEndsAt).isEqualTo(expectedEnd) - } - @Test fun `capacity cannot be set below confirmed count`() { val event = makeEvent(capacity = 10) From c8e777ee55581524ba7035408c3a3624668bf28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=89=E1=85=A5=E1=86=BC?= =?UTF-8?q?=E1=84=8B=E1=85=AF=E1=86=AB?= Date: Fri, 6 Mar 2026 21:47:24 +0900 Subject: [PATCH 027/157] =?UTF-8?q?=EB=A9=94=EC=9D=BC=EB=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/email/service/EmailService.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index ee4e4b88..ebe46ada 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -138,7 +138,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = "모이밍 참여 신청 확정", + subject = emailName("참여 신청 확정", data.eventTitle), htmlContent = htmlContent, ) @@ -164,7 +164,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = "모이밍 참여 신청 대기", + subject = emailName("참여 신청 대기", data.eventTitle), htmlContent = htmlContent, ) @@ -186,7 +186,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = "모이밍 참여 신청 강제 취소", + subject = emailName("참여 신청 강제 취소", data.eventTitle), htmlContent = htmlContent, ) @@ -214,7 +214,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = "모이밍 참여 신청 취소", + subject = emailName("참여 신청 취소", data.eventTitle), htmlContent = htmlContent, ) @@ -255,7 +255,7 @@ class EmailService( sendHtmlEmail( to = toEmail, - subject = "모이밍 참여 신청 대기 후, 확정", + subject = emailName("참여 신청 대기 후 확정", eventTitle), htmlContent = htmlContent, ) @@ -274,6 +274,14 @@ class EmailService( location } + private fun emailName( + title: String, + eventTitle: String?, + ): String { + val safeEventTitle = if (eventTitle.isNullOrBlank()) "제목 미정 모임" else eventTitle + return "[모이밍] $title: $safeEventTitle" + } + private fun formatEventDateRange( start: Instant?, end: Instant?, From fa9131dfc07a57f1472975a3ee1bc66a27360e68 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 16:59:38 +0900 Subject: [PATCH 028/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20Auth/user?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/AuthIntegrationTest.kt | 596 +++++++++++++++--- .../wafflestudio/spring2025/DataGenerator.kt | 14 + .../spring2025/TestContainerConfig.kt | 16 + .../spring2025/UserIntegrationTest.kt | 290 +++++++++ 4 files changed, 822 insertions(+), 94 deletions(-) create mode 100644 src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt create mode 100644 src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt diff --git a/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt index 1d1c9a37..9677fc30 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt @@ -1,12 +1,21 @@ package com.wafflestudio.spring2025 import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.domain.auth.dto.LoginRequest import com.wafflestudio.spring2025.domain.auth.dto.SignupRequest +import com.wafflestudio.spring2025.domain.auth.model.SocialProvider +import com.wafflestudio.spring2025.domain.user.identity.model.UserIdentity +import com.wafflestudio.spring2025.domain.user.identity.repository.UserIdentityRepository +import com.wafflestudio.spring2025.domain.user.model.PendingUser +import com.wafflestudio.spring2025.domain.user.repository.PendingUserRepository import org.junit.jupiter.api.Test +import org.mindrot.jbcrypt.BCrypt import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.web.servlet.MockMvc @@ -15,139 +24,538 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant +import java.util.UUID @SpringBootTest @ActiveProfiles("test") @Testcontainers @AutoConfigureMockMvc +@Import(TestContainerConfig::class) class AuthIntegrationTest @Autowired constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, private val dataGenerator: DataGenerator, + private val pendingUserRepository: PendingUserRepository, + private val userIdentityRepository: UserIdentityRepository, ) { + // 실제 이메일 발송 방지: EmailService를 Mock으로 대체 + @MockitoBean + private lateinit var emailService: EmailService + + // ================================================================= + // POST /api/auth/signup — 이메일 회원가입 요청 (인증 이메일 발송 트리거) + // ================================================================= + @Test - fun `should signup successfully`() { - // 회원가입할 수 있다 - val email = "user1@example.com" - val name = "user1" - val password = "password123" + fun `유효한 데이터로 회원가입 요청 시 204를 반환한다`() { + val request = + SignupRequest( + email = "newuser-${UUID.randomUUID()}@example.com", + name = "새사용자", + password = "password123", + ) - val request = SignupRequest(email, name, password) - mvc - .perform( - post("/api/v1/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isCreated) + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) } @Test - fun `should return 400 error when email is blank during signup`() { - // 회원가입 시 이메일이 비어있으면 400 에러 - val request = SignupRequest("", "user", "password") - mvc - .perform( - post("/api/v1/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + fun `이메일이 빈 문자열이면 회원가입 요청 시 400을 반환한다`() { + val request = SignupRequest(email = "", name = "테스트유저", password = "password123") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_EMAIL")) } @Test - fun `should return 409 error when email already exists during signup`() { - // 회원가입 시 이메일이 이미 존재하면 409 에러 - val (user, token) = dataGenerator.generateUser() - val request = SignupRequest(user.email, user.name, "password", user.profileImage) - mvc - .perform( - post("/api/v1/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isConflict) + fun `이메일 형식이 올바르지 않으면 회원가입 요청 시 400을 반환한다`() { + val request = SignupRequest(email = "not-an-email", name = "테스트유저", password = "password123") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_EMAIL")) + } + + @Test + fun `이름이 공백이면 회원가입 요청 시 400을 반환한다`() { + val request = SignupRequest(email = "user@example.com", name = " ", password = "password123") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_NAME")) + } + + @Test + fun `비밀번호가 8자 미만이면 회원가입 요청 시 400을 반환한다`() { + val request = SignupRequest(email = "user@example.com", name = "테스트유저", password = "pass1") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `비밀번호에 숫자가 없으면 회원가입 요청 시 400을 반환한다`() { + val request = SignupRequest(email = "user@example.com", name = "테스트유저", password = "onlyletters") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `비밀번호에 문자가 없으면 회원가입 요청 시 400을 반환한다`() { + val request = SignupRequest(email = "user@example.com", name = "테스트유저", password = "12345678") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `인증 대기 중인 이메일로 회원가입 요청 시 409를 반환한다`() { + val email = "pending-${UUID.randomUUID()}@example.com" + pendingUserRepository.save( + PendingUser( + email = email, + name = "대기유저", + passwordHash = BCrypt.hashpw("password123", BCrypt.gensalt()), + verificationCode = UUID.randomUUID().toString(), + expiresAt = Instant.now().plusSeconds(3600), + ), + ) + + val request = SignupRequest(email = email, name = "테스트유저", password = "password123") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("EMAIL_VERIFICATION_PENDING")) + } + + @Test + fun `이미 이메일로 가입된 계정에 회원가입 요청 시 409를 반환한다`() { + val (user, _) = dataGenerator.generateUser() + val request = SignupRequest(email = user.email, name = "테스트유저", password = "password123") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("EMAIL_ACCOUNT_ALREADY_EXIST")) + } + + @Test + fun `구글 소셜로 가입된 이메일로 회원가입 요청 시 409를 반환한다`() { + val (user, _) = dataGenerator.generateUser() + userIdentityRepository.save( + UserIdentity( + userId = user.id!!, + provider = SocialProvider.GOOGLE.name, + providerUserId = "google-${UUID.randomUUID()}", + ), + ) + + val request = SignupRequest(email = user.email, name = "테스트유저", password = "password123") + + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("GOOGLE_ACCOUNT_ALREADY_EXIST")) + } + + // ================================================================= + // POST /api/auth/email-verification/{code} — 이메일 인증 완료 + // ================================================================= + + @Test + fun `유효한 인증 코드로 이메일 인증 성공 시 200을 반환한다`() { + val verificationCode = UUID.randomUUID().toString() + pendingUserRepository.save( + PendingUser( + email = "verify-${UUID.randomUUID()}@example.com", + name = "인증유저", + passwordHash = BCrypt.hashpw("password123", BCrypt.gensalt()), + verificationCode = verificationCode, + expiresAt = Instant.now().plusSeconds(3600), + ), + ) + + mvc.perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) } @Test - fun `should login with email successfully`() { - // 로그인할 수 있다 - val password = "myPassword" - val (user, token) = dataGenerator.generateUserWithPassword(password) + fun `존재하지 않는 인증 코드로 이메일 인증 시 400을 반환한다`() { + val invalidCode = UUID.randomUUID().toString() + + mvc.perform( + post("/api/auth/email-verification/$invalidCode"), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) + } + + @Test + fun `만료된 인증 코드로 이메일 인증 시 400을 반환한다`() { + val expiredCode = UUID.randomUUID().toString() + pendingUserRepository.save( + PendingUser( + email = "expired-${UUID.randomUUID()}@example.com", + name = "만료유저", + passwordHash = BCrypt.hashpw("password123", BCrypt.gensalt()), + verificationCode = expiredCode, + expiresAt = Instant.now().minusSeconds(3600), // 이미 만료된 코드 + ), + ) + + mvc.perform( + post("/api/auth/email-verification/$expiredCode"), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) + } + + // ================================================================= + // POST /api/auth/login — 이메일 로그인 + // ================================================================= + + @Test + fun `유효한 이메일과 비밀번호로 로그인 성공 시 200과 토큰을 반환한다`() { + val password = "myPassword1" + val (user, _) = dataGenerator.generateUserWithPassword(password) val request = LoginRequest(email = user.email, password = password) - mvc - .perform( - post("/api/v1/auth/login") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + + mvc.perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.token").isNotEmpty) } @Test - fun `should return 401 error when email is incorrect during login`() { - // 로그인 시 이메일이 틀렸다면 401 에러 - val request = LoginRequest(email = "wrong@example.com", password = "something") - mvc - .perform( - post("/api/v1/auth/login") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + fun `존재하지 않는 이메일로 로그인 시 401을 반환한다`() { + val request = LoginRequest(email = "nonexistent@example.com", password = "password123") + + mvc.perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) } @Test - fun `should retrieve own user information`() { - // 본인 정보를 조회할 수 있다 - val (user, token) = dataGenerator.generateUser() - mvc - .perform( - get("/api/v1/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.email").value(user.email)) + fun `잘못된 비밀번호로 로그인 시 401을 반환한다`() { + val (user, _) = dataGenerator.generateUserWithPassword("correctPassword1") + val request = LoginRequest(email = user.email, password = "wrongPassword9") + + mvc.perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) } @Test - fun `should return 401 when retrieving user information with invalid token`() { - // 유효하지 않은 토큰으로 본인 정보 조회 시 401 - val (user, token) = dataGenerator.generateUser() - mvc - .perform( - get("/api/v1/users/me") - .header("Authorization", "Bearer wrong") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + fun `비밀번호 없이 생성된 사용자(소셜 가입)로 이메일 로그인 시 401을 반환한다`() { + // DataGenerator.generateUser()는 passwordHash 없이 User 생성 + val (user, _) = dataGenerator.generateUser() + val request = LoginRequest(email = user.email, password = "anyPassword1") + + mvc.perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) + } + + // ================================================================= + // POST /api/auth/logout — 로그아웃 (JWT 블랙리스트 등록) + // ================================================================= + + @Test + fun `유효한 토큰으로 로그아웃 성공 시 204를 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc.perform( + post("/api/auth/logout") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + } + + @Test + fun `Authorization 헤더 없이 로그아웃 요청 시 204를 반환한다`() { + // /api/auth/logout은 @AuthRequired가 없으므로 인증 없이도 204 반환 + mvc.perform( + post("/api/auth/logout") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) } + // ================================================================= + // GET /api/users/me — 내 정보 조회 + // ================================================================= + @Test - fun `should blacklist jwt`() { + fun `유효한 토큰으로 내 정보 조회 시 200과 사용자 정보를 반환한다`() { val (user, token) = dataGenerator.generateUser() - mvc - .perform( - get("/api/v1/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + + mvc.perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(user.id)) .andExpect(jsonPath("$.email").value(user.email)) - mvc - .perform( - post("/api/v1/users/logout") - .queryParam("token", token) - .header("Authorization", "Bearer $token") + .andExpect(jsonPath("$.name").value(user.name)) + .andExpect(jsonPath("$.profileImage").value(null as Any?)) + } + + @Test + fun `Authorization 헤더 없이 내 정보 조회 시 401을 반환한다`() { + mvc.perform( + get("/api/users/me") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `유효하지 않은 토큰으로 내 정보 조회 시 401을 반환한다`() { + mvc.perform( + get("/api/users/me") + .header("Authorization", "Bearer invalid.token.value") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `로그아웃 후 블랙리스트된 토큰으로 내 정보 조회 시 401을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + // 로그아웃으로 토큰 블랙리스트 등록 + mvc.perform( + post("/api/auth/logout") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + + // 블랙리스트된 토큰으로 내 정보 조회 시 401 + mvc.perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + // ================================================================= + // End-to-End: 회원가입 → 이메일 인증 → 로그인 → 내 정보 조회 + // ================================================================= + + @Test + fun `회원가입 요청 후 이메일 인증을 완료하면 로그인 후 내 정보를 조회할 수 있다`() { + val email = "e2e-${UUID.randomUUID()}@example.com" + val name = "E2E유저" + val password = "password123" + + // 1. 회원가입 요청 → 204 No Content (인증 이메일 발송) + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(SignupRequest(email = email, name = name, password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + + // 2. DB에서 PendingUser의 인증 코드 획득 + val verificationCode = pendingUserRepository.findByEmail(email)!!.verificationCode + + // 3. 이메일 인증 완료 → 200 OK (User 생성) + mvc.perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) + + // 4. 로그인 → 200 OK + JWT 토큰 + val loginResult = + mvc.perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = email, password = password))) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) - mvc - .perform( - post("/api/v1/users/logout") - .queryParam("token", token) - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) - mvc - .perform( - get("/api/v1/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.token").isNotEmpty) + .andReturn() + + val token = + mapper + .readTree(loginResult.response.contentAsString) + .get("token") + .asText() + + // 5. 내 정보 조회 → 200 OK + 사용자 정보 + mvc.perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.email").value(email)) + .andExpect(jsonPath("$.name").value(name)) + } + + @Test + fun `이메일 인증 완료 후 동일한 인증 코드로 재인증 시도 시 400을 반환한다`() { + val verificationCode = UUID.randomUUID().toString() + pendingUserRepository.save( + PendingUser( + email = "onetime-${UUID.randomUUID()}@example.com", + name = "일회용인증", + passwordHash = BCrypt.hashpw("password123", BCrypt.gensalt()), + verificationCode = verificationCode, + expiresAt = Instant.now().plusSeconds(3600), + ), + ) + + // 첫 번째 인증 → 성공 + mvc.perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) + + // 두 번째 인증 (PendingUser 삭제됨) → 실패 + mvc.perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) + } + + // ================================================================= + // 이메일 인증 만료 후 재가입 요청 시나리오 + // EmailVerificationService.createPendingUser: + // 만료된 PendingUser가 존재하면 삭제 후 새로운 PendingUser 생성 + // ================================================================= + + @Test + fun `인증 코드가 만료된 이메일로 재가입 요청 시 기존 PendingUser를 교체하고 204를 반환한다`() { + val email = "reregister-${UUID.randomUUID()}@example.com" + val oldCode = UUID.randomUUID().toString() + + // 만료된 PendingUser 사전 생성 + pendingUserRepository.save( + PendingUser( + email = email, + name = "구사용자", + passwordHash = BCrypt.hashpw("oldPassword1", BCrypt.gensalt()), + verificationCode = oldCode, + expiresAt = Instant.now().minusSeconds(3600), // 이미 만료 + ), + ) + + // 동일 이메일로 재가입 요청 → 만료된 PendingUser 삭제 후 새로 생성 → 204 + val request = SignupRequest(email = email, name = "신사용자", password = "newPassword1") + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + + // 새로운 PendingUser가 생성됐는지 확인 (코드가 교체됨) + val newPendingUser = pendingUserRepository.findByEmail(email)!! + assert(newPendingUser.verificationCode != oldCode) { "인증 코드가 갱신되지 않았습니다" } + assert(newPendingUser.expiresAt.isAfter(Instant.now())) { "새로운 만료 시각이 현재보다 미래여야 합니다" } + } + + @Test + fun `만료된 인증 코드로 인증 시도 후 동일 이메일로 재가입하면 새 코드로 인증할 수 있다`() { + val email = "reauth-${UUID.randomUUID()}@example.com" + val password = "password123" + val expiredCode = UUID.randomUUID().toString() + + // 1. 만료된 PendingUser 사전 생성 + pendingUserRepository.save( + PendingUser( + email = email, + name = "재인증유저", + passwordHash = BCrypt.hashpw(password, BCrypt.gensalt()), + verificationCode = expiredCode, + expiresAt = Instant.now().minusSeconds(3600), + ), + ) + + // 2. 만료된 코드로 인증 시도 → 400 + mvc.perform( + post("/api/auth/email-verification/$expiredCode"), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) + + // 3. 동일 이메일로 재가입 요청 → 204 (만료된 PendingUser 삭제 후 신규 생성) + mvc.perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(SignupRequest(email = email, name = "재인증유저", password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + + // 4. 새로운 인증 코드로 이메일 인증 → 200 + val newCode = pendingUserRepository.findByEmail(email)!!.verificationCode + mvc.perform( + post("/api/auth/email-verification/$newCode"), + ).andExpect(status().isOk) + + // 5. 새로 가입된 계정으로 로그인 → 200 + mvc.perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = email, password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.token").isNotEmpty) } } + +// ============================================================================= +// [구체화 필요 사항] +// +// 1. 소셜 가입 사용자 이메일 로그인 버그 (AuthService.login): +// - passwordHash가 null인 User로 로그인 시 BCrypt.checkpw(password, null)에서 +// NullPointerException이 발생하여 500이 반환됨. +// - 올바른 동작: 401 LOGIN_FAILED 반환. +// - 수정 방향: `if (user.passwordHash == null || !BCrypt.checkpw(password, user.passwordHash))` +// 형태로 null guard 추가 필요. +// - 관련 테스트: "비밀번호 없이 생성된 사용자(소셜 가입)로 이메일 로그인 시 401을 반환한다" +// → 현재 코드에서는 실패(red test)이며, 버그 수정 후 통과 예상. +// +// 2. 로그아웃 정책: +// - 현재 /api/auth/logout은 @AuthRequired가 없어 블랙리스트된 토큰으로도 204 반환. +// - 비즈니스 정책상 이미 로그아웃된 토큰으로 재로그아웃 시도를 어떻게 처리할지 명확화 필요. +// - 현재 구현은 silent no-op(조용히 무시). 에러 반환 정책이 필요하면 @AuthRequired 추가 고려. +// ============================================================================= \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt b/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt index c55e17af..5e6313aa 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt @@ -40,4 +40,18 @@ class DataGenerator( val token = jwtTokenProvider.createToken(user.id!!) return user to token } + + fun generateUserWithProfileImage(profileImageKey: String): Pair { + val email = "user-${UUID.randomUUID()}@example.com" + val user = + userRepository.save( + User( + email = email, + name = "user", + profileImage = profileImageKey, + ), + ) + val token = jwtTokenProvider.createToken(user.id!!) + return user to token + } } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt b/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt new file mode 100644 index 00000000..6b19e885 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt @@ -0,0 +1,16 @@ +package com.wafflestudio.spring2025 + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.context.annotation.Bean +import org.testcontainers.containers.GenericContainer +import org.testcontainers.utility.DockerImageName + +@TestConfiguration +class TestContainerConfig { + @Bean + @ServiceConnection(name = "redis") + fun redisContainer(): GenericContainer<*> = + GenericContainer(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379) +} \ No newline at end of file diff --git a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt new file mode 100644 index 00000000..fc83aa09 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt @@ -0,0 +1,290 @@ +package com.wafflestudio.spring2025 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.image.service.ImageService +import com.wafflestudio.spring2025.domain.auth.dto.LoginRequest +import com.wafflestudio.spring2025.domain.user.dto.PatchMeRequest +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.BDDMockito.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.testcontainers.junit.jupiter.Testcontainers +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.HeadObjectRequest +import software.amazon.awssdk.services.s3.model.NoSuchKeyException + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +@AutoConfigureMockMvc +class UserIntegrationTest + @Autowired + constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, + private val dataGenerator: DataGenerator, + ) { + // UserService.validateProfileImage 에서 직접 호출하는 S3Client 모킹 + @MockitoBean + private lateinit var s3Client: S3Client + + // UserService.me 에서 presignedGetUrl 호출 시 실제 AWS 호출 방지 + @MockitoBean + private lateinit var imageService: ImageService + + // ================================================================= + // GET /api/users/me — 내 정보 조회 + // ================================================================= + + @Test + fun `유효한 토큰으로 내 정보 조회 시 200과 사용자 정보를 반환한다`() { + val (user, token) = dataGenerator.generateUser() + + mvc.perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(user.id)) + .andExpect(jsonPath("$.email").value(user.email)) + .andExpect(jsonPath("$.name").value(user.name)) + .andExpect(jsonPath("$.profileImage").value(null as Any?)) + } + + @Test + fun `Authorization 헤더 없이 내 정보 조회 시 401을 반환한다`() { + mvc.perform( + get("/api/users/me") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `유효하지 않은 토큰으로 내 정보 조회 시 401을 반환한다`() { + mvc.perform( + get("/api/users/me") + .header("Authorization", "Bearer invalid.token.value") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `프로필 이미지가 있는 사용자의 내 정보 조회 시 presigned URL을 반환한다`() { + val s3Key = "profile-images/1/photo.jpg" + val presignedUrl = "https://s3.example.com/presigned/photo.jpg" + + // profileImage가 설정된 User를 DB에 직접 생성 + val (user, token) = dataGenerator.generateUserWithProfileImage(s3Key) + + given(imageService.presignedGetUrl(s3Key)).willReturn(presignedUrl) + + mvc.perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(user.id)) + .andExpect(jsonPath("$.profileImage").value(presignedUrl)) + } + + // ================================================================= + // PATCH /api/users/me — 내 정보 수정 + // ================================================================= + + @Test + fun `이름을 변경하면 200과 변경된 이름을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val newName = "변경된이름" + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = newName, email = null, password = null, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value(newName)) + } + + @Test + fun `변경 필드 없이 PATCH 시 기존 정보 그대로 200을 반환한다`() { + val (user, token) = dataGenerator.generateUser() + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value(user.name)) + .andExpect(jsonPath("$.email").value(user.email)) + } + + @Test + fun `비밀번호를 변경하면 이전 비밀번호는 무효화되고 새 비밀번호로 로그인할 수 있다`() { + val oldPassword = "oldPassword1" + val newPassword = "newPassword2" + val (user, token) = dataGenerator.generateUserWithPassword(oldPassword) + + // 비밀번호 변경 + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = newPassword, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + // 이전 비밀번호로 로그인 → 실패 + mvc.perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = user.email, password = oldPassword))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + + // 새 비밀번호로 로그인 → 성공 + mvc.perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = user.email, password = newPassword))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.token").isNotEmpty) + } + + @Test + fun `S3에 존재하는 키로 프로필 이미지를 변경하면 200을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val validKey = "profile-images/test/valid.jpg" + val presignedUrl = "https://s3.example.com/presigned/valid.jpg" + + // headObject 는 기본적으로 예외 없이 반환 (mock 기본값) → 유효한 키로 간주 + given(imageService.presignedGetUrl(validKey)).willReturn(presignedUrl) + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = validKey))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.profileImage").value(presignedUrl)) + } + + @Test + fun `이름과 비밀번호를 동시에 변경하면 200과 변경된 정보를 반환한다`() { + val oldPassword = "oldPassword1" + val newPassword = "newPassword2" + val newName = "새이름" + val (user, token) = dataGenerator.generateUserWithPassword(oldPassword) + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = newName, email = null, password = newPassword, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value(newName)) + .andExpect(jsonPath("$.email").value(user.email)) + } + + @Test + fun `이메일 변경 시도 시 403을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = "newemail@example.com", password = null, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("EMAIL_CHANGE_FORBIDDEN")) + } + + @Test + fun `이름을 공백 문자열로 변경 시도 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = " ", email = null, password = null, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_NAME")) + } + + @Test + fun `비밀번호를 8자 미만으로 변경 시도 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = "abc1", profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `비밀번호에 숫자가 없으면 변경 시도 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = "onlyletters", profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `비밀번호에 문자가 없으면 변경 시도 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = "12345678", profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `S3에 존재하지 않는 키로 프로필 이미지 변경 시도 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + given(s3Client.headObject(any(HeadObjectRequest::class.java))) + .willThrow(NoSuchKeyException.builder().message("Not found").statusCode(404).build()) + + mvc.perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = "nonexistent/image.jpg"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("PROFILE_IMAGE_NOT_FOUND")) + } + + @Test + fun `토큰 없이 내 정보 수정 시도 시 401을 반환한다`() { + mvc.perform( + patch("/api/users/me") + .content(mapper.writeValueAsString(PatchMeRequest(name = "새이름", email = null, password = null, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + } \ No newline at end of file From 589f2194c3bf7f2610044810797a10e351e1234e Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 17:11:39 +0900 Subject: [PATCH 029/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20test=20propert?= =?UTF-8?q?ies=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.properties | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index f5effe00..fcec3309 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -8,3 +8,10 @@ spring.data.redis.port=6379 jwt.secret=test-secret-key-for-jwt-token-generation-minimum-32-chars jwt.expiration-in-ms=86400000 + +oauth.providers.google.client-id=test-google-client-id +oauth.providers.google.client-secret=test-google-client-secret +oauth.providers.google.redirect-uri=http://localhost:8080/auth/google/callback + +aws.s3.bucket=test-bucket +aws.s3.region=ap-northeast-2 From 237ec0c6a79552bbc1912ef4bf544ff0b82245da Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 17:21:37 +0900 Subject: [PATCH 030/157] :art: ktlint --- .../spring2025/AuthIntegrationTest.kt | 353 ++++++++++-------- .../spring2025/TestContainerConfig.kt | 2 +- .../spring2025/UserIntegrationTest.kt | 253 ++++++++----- 3 files changed, 345 insertions(+), 263 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt index 9677fc30..5c3862dc 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt @@ -15,9 +15,9 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Import -import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post @@ -58,22 +58,24 @@ class AuthIntegrationTest password = "password123", ) - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isNoContent) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) } @Test fun `이메일이 빈 문자열이면 회원가입 요청 시 400을 반환한다`() { val request = SignupRequest(email = "", name = "테스트유저", password = "password123") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_EMAIL")) } @@ -81,11 +83,12 @@ class AuthIntegrationTest fun `이메일 형식이 올바르지 않으면 회원가입 요청 시 400을 반환한다`() { val request = SignupRequest(email = "not-an-email", name = "테스트유저", password = "password123") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_EMAIL")) } @@ -93,11 +96,12 @@ class AuthIntegrationTest fun `이름이 공백이면 회원가입 요청 시 400을 반환한다`() { val request = SignupRequest(email = "user@example.com", name = " ", password = "password123") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_NAME")) } @@ -105,11 +109,12 @@ class AuthIntegrationTest fun `비밀번호가 8자 미만이면 회원가입 요청 시 400을 반환한다`() { val request = SignupRequest(email = "user@example.com", name = "테스트유저", password = "pass1") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) } @@ -117,11 +122,12 @@ class AuthIntegrationTest fun `비밀번호에 숫자가 없으면 회원가입 요청 시 400을 반환한다`() { val request = SignupRequest(email = "user@example.com", name = "테스트유저", password = "onlyletters") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) } @@ -129,11 +135,12 @@ class AuthIntegrationTest fun `비밀번호에 문자가 없으면 회원가입 요청 시 400을 반환한다`() { val request = SignupRequest(email = "user@example.com", name = "테스트유저", password = "12345678") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) } @@ -152,11 +159,12 @@ class AuthIntegrationTest val request = SignupRequest(email = email, name = "테스트유저", password = "password123") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isConflict) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) .andExpect(jsonPath("$.code").value("EMAIL_VERIFICATION_PENDING")) } @@ -165,11 +173,12 @@ class AuthIntegrationTest val (user, _) = dataGenerator.generateUser() val request = SignupRequest(email = user.email, name = "테스트유저", password = "password123") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isConflict) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) .andExpect(jsonPath("$.code").value("EMAIL_ACCOUNT_ALREADY_EXIST")) } @@ -186,11 +195,12 @@ class AuthIntegrationTest val request = SignupRequest(email = user.email, name = "테스트유저", password = "password123") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isConflict) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) .andExpect(jsonPath("$.code").value("GOOGLE_ACCOUNT_ALREADY_EXIST")) } @@ -211,18 +221,20 @@ class AuthIntegrationTest ), ) - mvc.perform( - post("/api/auth/email-verification/$verificationCode"), - ).andExpect(status().isOk) + mvc + .perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) } @Test fun `존재하지 않는 인증 코드로 이메일 인증 시 400을 반환한다`() { val invalidCode = UUID.randomUUID().toString() - mvc.perform( - post("/api/auth/email-verification/$invalidCode"), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/email-verification/$invalidCode"), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) } @@ -239,9 +251,10 @@ class AuthIntegrationTest ), ) - mvc.perform( - post("/api/auth/email-verification/$expiredCode"), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/email-verification/$expiredCode"), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) } @@ -255,11 +268,12 @@ class AuthIntegrationTest val (user, _) = dataGenerator.generateUserWithPassword(password) val request = LoginRequest(email = user.email, password = password) - mvc.perform( - post("/api/auth/login") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.token").isNotEmpty) } @@ -267,11 +281,12 @@ class AuthIntegrationTest fun `존재하지 않는 이메일로 로그인 시 401을 반환한다`() { val request = LoginRequest(email = "nonexistent@example.com", password = "password123") - mvc.perform( - post("/api/auth/login") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) } @@ -280,11 +295,12 @@ class AuthIntegrationTest val (user, _) = dataGenerator.generateUserWithPassword("correctPassword1") val request = LoginRequest(email = user.email, password = "wrongPassword9") - mvc.perform( - post("/api/auth/login") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) } @@ -294,11 +310,12 @@ class AuthIntegrationTest val (user, _) = dataGenerator.generateUser() val request = LoginRequest(email = user.email, password = "anyPassword1") - mvc.perform( - post("/api/auth/login") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) } @@ -310,20 +327,22 @@ class AuthIntegrationTest fun `유효한 토큰으로 로그아웃 성공 시 204를 반환한다`() { val (_, token) = dataGenerator.generateUser() - mvc.perform( - post("/api/auth/logout") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isNoContent) + mvc + .perform( + post("/api/auth/logout") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) } @Test fun `Authorization 헤더 없이 로그아웃 요청 시 204를 반환한다`() { // /api/auth/logout은 @AuthRequired가 없으므로 인증 없이도 204 반환 - mvc.perform( - post("/api/auth/logout") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isNoContent) + mvc + .perform( + post("/api/auth/logout") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) } // ================================================================= @@ -334,11 +353,12 @@ class AuthIntegrationTest fun `유효한 토큰으로 내 정보 조회 시 200과 사용자 정보를 반환한다`() { val (user, token) = dataGenerator.generateUser() - mvc.perform( - get("/api/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.id").value(user.id)) .andExpect(jsonPath("$.email").value(user.email)) .andExpect(jsonPath("$.name").value(user.name)) @@ -347,20 +367,22 @@ class AuthIntegrationTest @Test fun `Authorization 헤더 없이 내 정보 조회 시 401을 반환한다`() { - mvc.perform( - get("/api/users/me") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + get("/api/users/me") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) } @Test fun `유효하지 않은 토큰으로 내 정보 조회 시 401을 반환한다`() { - mvc.perform( - get("/api/users/me") - .header("Authorization", "Bearer invalid.token.value") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer invalid.token.value") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) } @@ -369,18 +391,20 @@ class AuthIntegrationTest val (_, token) = dataGenerator.generateUser() // 로그아웃으로 토큰 블랙리스트 등록 - mvc.perform( - post("/api/auth/logout") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isNoContent) + mvc + .perform( + post("/api/auth/logout") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) // 블랙리스트된 토큰으로 내 정보 조회 시 401 - mvc.perform( - get("/api/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) } @@ -395,27 +419,30 @@ class AuthIntegrationTest val password = "password123" // 1. 회원가입 요청 → 204 No Content (인증 이메일 발송) - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(SignupRequest(email = email, name = name, password = password))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isNoContent) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(SignupRequest(email = email, name = name, password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) // 2. DB에서 PendingUser의 인증 코드 획득 val verificationCode = pendingUserRepository.findByEmail(email)!!.verificationCode // 3. 이메일 인증 완료 → 200 OK (User 생성) - mvc.perform( - post("/api/auth/email-verification/$verificationCode"), - ).andExpect(status().isOk) + mvc + .perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) // 4. 로그인 → 200 OK + JWT 토큰 val loginResult = - mvc.perform( - post("/api/auth/login") - .content(mapper.writeValueAsString(LoginRequest(email = email, password = password))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = email, password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.token").isNotEmpty) .andReturn() @@ -426,11 +453,12 @@ class AuthIntegrationTest .asText() // 5. 내 정보 조회 → 200 OK + 사용자 정보 - mvc.perform( - get("/api/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.email").value(email)) .andExpect(jsonPath("$.name").value(name)) } @@ -449,14 +477,16 @@ class AuthIntegrationTest ) // 첫 번째 인증 → 성공 - mvc.perform( - post("/api/auth/email-verification/$verificationCode"), - ).andExpect(status().isOk) + mvc + .perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) // 두 번째 인증 (PendingUser 삭제됨) → 실패 - mvc.perform( - post("/api/auth/email-verification/$verificationCode"), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) } @@ -484,11 +514,12 @@ class AuthIntegrationTest // 동일 이메일로 재가입 요청 → 만료된 PendingUser 삭제 후 새로 생성 → 204 val request = SignupRequest(email = email, name = "신사용자", password = "newPassword1") - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isNoContent) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) // 새로운 PendingUser가 생성됐는지 확인 (코드가 교체됨) val newPendingUser = pendingUserRepository.findByEmail(email)!! @@ -514,30 +545,34 @@ class AuthIntegrationTest ) // 2. 만료된 코드로 인증 시도 → 400 - mvc.perform( - post("/api/auth/email-verification/$expiredCode"), - ).andExpect(status().isBadRequest) + mvc + .perform( + post("/api/auth/email-verification/$expiredCode"), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) // 3. 동일 이메일로 재가입 요청 → 204 (만료된 PendingUser 삭제 후 신규 생성) - mvc.perform( - post("/api/auth/signup") - .content(mapper.writeValueAsString(SignupRequest(email = email, name = "재인증유저", password = password))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isNoContent) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(SignupRequest(email = email, name = "재인증유저", password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) // 4. 새로운 인증 코드로 이메일 인증 → 200 val newCode = pendingUserRepository.findByEmail(email)!!.verificationCode - mvc.perform( - post("/api/auth/email-verification/$newCode"), - ).andExpect(status().isOk) + mvc + .perform( + post("/api/auth/email-verification/$newCode"), + ).andExpect(status().isOk) // 5. 새로 가입된 계정으로 로그인 → 200 - mvc.perform( - post("/api/auth/login") - .content(mapper.writeValueAsString(LoginRequest(email = email, password = password))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = email, password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.token").isNotEmpty) } } @@ -558,4 +593,4 @@ class AuthIntegrationTest // - 현재 /api/auth/logout은 @AuthRequired가 없어 블랙리스트된 토큰으로도 204 반환. // - 비즈니스 정책상 이미 로그아웃된 토큰으로 재로그아웃 시도를 어떻게 처리할지 명확화 필요. // - 현재 구현은 silent no-op(조용히 무시). 에러 반환 정책이 필요하면 @AuthRequired 추가 고려. -// ============================================================================= \ No newline at end of file +// ============================================================================= diff --git a/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt b/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt index 6b19e885..8b03c934 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt @@ -13,4 +13,4 @@ class TestContainerConfig { fun redisContainer(): GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7-alpine")) .withExposedPorts(6379) -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt index fc83aa09..9a537457 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt @@ -51,11 +51,12 @@ class UserIntegrationTest fun `유효한 토큰으로 내 정보 조회 시 200과 사용자 정보를 반환한다`() { val (user, token) = dataGenerator.generateUser() - mvc.perform( - get("/api/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.id").value(user.id)) .andExpect(jsonPath("$.email").value(user.email)) .andExpect(jsonPath("$.name").value(user.name)) @@ -64,20 +65,22 @@ class UserIntegrationTest @Test fun `Authorization 헤더 없이 내 정보 조회 시 401을 반환한다`() { - mvc.perform( - get("/api/users/me") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + get("/api/users/me") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) } @Test fun `유효하지 않은 토큰으로 내 정보 조회 시 401을 반환한다`() { - mvc.perform( - get("/api/users/me") - .header("Authorization", "Bearer invalid.token.value") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer invalid.token.value") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) } @@ -91,11 +94,12 @@ class UserIntegrationTest given(imageService.presignedGetUrl(s3Key)).willReturn(presignedUrl) - mvc.perform( - get("/api/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.id").value(user.id)) .andExpect(jsonPath("$.profileImage").value(presignedUrl)) } @@ -109,12 +113,14 @@ class UserIntegrationTest val (_, token) = dataGenerator.generateUser() val newName = "변경된이름" - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = newName, email = null, password = null, profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString(PatchMeRequest(name = newName, email = null, password = null, profileImage = null)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.name").value(newName)) } @@ -122,12 +128,13 @@ class UserIntegrationTest fun `변경 필드 없이 PATCH 시 기존 정보 그대로 200을 반환한다`() { val (user, token) = dataGenerator.generateUser() - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.name").value(user.name)) .andExpect(jsonPath("$.email").value(user.email)) } @@ -139,26 +146,32 @@ class UserIntegrationTest val (user, token) = dataGenerator.generateUserWithPassword(oldPassword) // 비밀번호 변경 - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = newPassword, profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = null, password = newPassword, profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) // 이전 비밀번호로 로그인 → 실패 - mvc.perform( - post("/api/auth/login") - .content(mapper.writeValueAsString(LoginRequest(email = user.email, password = oldPassword))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = user.email, password = oldPassword))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) // 새 비밀번호로 로그인 → 성공 - mvc.perform( - post("/api/auth/login") - .content(mapper.writeValueAsString(LoginRequest(email = user.email, password = newPassword))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = user.email, password = newPassword))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.token").isNotEmpty) } @@ -171,12 +184,14 @@ class UserIntegrationTest // headObject 는 기본적으로 예외 없이 반환 (mock 기본값) → 유효한 키로 간주 given(imageService.presignedGetUrl(validKey)).willReturn(presignedUrl) - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = validKey))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = validKey)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.profileImage").value(presignedUrl)) } @@ -187,12 +202,16 @@ class UserIntegrationTest val newName = "새이름" val (user, token) = dataGenerator.generateUserWithPassword(oldPassword) - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = newName, email = null, password = newPassword, profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = newName, email = null, password = newPassword, profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) .andExpect(jsonPath("$.name").value(newName)) .andExpect(jsonPath("$.email").value(user.email)) } @@ -201,12 +220,16 @@ class UserIntegrationTest fun `이메일 변경 시도 시 403을 반환한다`() { val (_, token) = dataGenerator.generateUser() - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = "newemail@example.com", password = null, profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isForbidden) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = "newemail@example.com", password = null, profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) .andExpect(jsonPath("$.code").value("EMAIL_CHANGE_FORBIDDEN")) } @@ -214,12 +237,14 @@ class UserIntegrationTest fun `이름을 공백 문자열로 변경 시도 시 400을 반환한다`() { val (_, token) = dataGenerator.generateUser() - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = " ", email = null, password = null, profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString(PatchMeRequest(name = " ", email = null, password = null, profileImage = null)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_NAME")) } @@ -227,12 +252,14 @@ class UserIntegrationTest fun `비밀번호를 8자 미만으로 변경 시도 시 400을 반환한다`() { val (_, token) = dataGenerator.generateUser() - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = "abc1", profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = "abc1", profileImage = null)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) } @@ -240,12 +267,16 @@ class UserIntegrationTest fun `비밀번호에 숫자가 없으면 변경 시도 시 400을 반환한다`() { val (_, token) = dataGenerator.generateUser() - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = "onlyletters", profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = null, password = "onlyletters", profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) } @@ -253,12 +284,16 @@ class UserIntegrationTest fun `비밀번호에 문자가 없으면 변경 시도 시 400을 반환한다`() { val (_, token) = dataGenerator.generateUser() - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = "12345678", profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = null, password = "12345678", profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) } @@ -267,24 +302,36 @@ class UserIntegrationTest val (_, token) = dataGenerator.generateUser() given(s3Client.headObject(any(HeadObjectRequest::class.java))) - .willThrow(NoSuchKeyException.builder().message("Not found").statusCode(404).build()) - - mvc.perform( - patch("/api/users/me") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = "nonexistent/image.jpg"))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) + .willThrow( + NoSuchKeyException + .builder() + .message("Not found") + .statusCode(404) + .build(), + ) + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = null, password = null, profileImage = "nonexistent/image.jpg"), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("PROFILE_IMAGE_NOT_FOUND")) } @Test fun `토큰 없이 내 정보 수정 시도 시 401을 반환한다`() { - mvc.perform( - patch("/api/users/me") - .content(mapper.writeValueAsString(PatchMeRequest(name = "새이름", email = null, password = null, profileImage = null))) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + mvc + .perform( + patch("/api/users/me") + .content( + mapper.writeValueAsString(PatchMeRequest(name = "새이름", email = null, password = null, profileImage = null)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) } - } \ No newline at end of file + } From 82b1a94efde22958c3f812e50b284b26d89622c6 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 23:09:28 +0900 Subject: [PATCH 031/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80=EB=A0=A8=20mockito?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20assert=EB=8C=80=EC=B2=B4=20=EB=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/AuthIntegrationTest.kt | 74 ++++--------------- 1 file changed, 14 insertions(+), 60 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt index 5c3862dc..d670c9f3 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt @@ -9,8 +9,13 @@ import com.wafflestudio.spring2025.domain.user.identity.model.UserIdentity import com.wafflestudio.spring2025.domain.user.identity.repository.UserIdentityRepository import com.wafflestudio.spring2025.domain.user.model.PendingUser import com.wafflestudio.spring2025.domain.user.repository.PendingUserRepository +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mindrot.jbcrypt.BCrypt +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -50,10 +55,11 @@ class AuthIntegrationTest // ================================================================= @Test - fun `유효한 데이터로 회원가입 요청 시 204를 반환한다`() { + fun `유효한 데이터로 회원가입 요청 시 204를 반환하고 인증 이메일을 발송한다`() { + val email = "newuser-${UUID.randomUUID()}@example.com" val request = SignupRequest( - email = "newuser-${UUID.randomUUID()}@example.com", + email = email, name = "새사용자", password = "password123", ) @@ -64,6 +70,8 @@ class AuthIntegrationTest .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isNoContent) + + verify(emailService).sendVerificationEmail(eq(email), any()) } @Test @@ -346,46 +354,10 @@ class AuthIntegrationTest } // ================================================================= - // GET /api/users/me — 내 정보 조회 + // GET /api/users/me — 로그아웃 블랙리스트 검증 (인증 흐름 관점) + // 기본 내 정보 조회 시나리오는 UserIntegrationTest에서 다룸 // ================================================================= - @Test - fun `유효한 토큰으로 내 정보 조회 시 200과 사용자 정보를 반환한다`() { - val (user, token) = dataGenerator.generateUser() - - mvc - .perform( - get("/api/users/me") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.id").value(user.id)) - .andExpect(jsonPath("$.email").value(user.email)) - .andExpect(jsonPath("$.name").value(user.name)) - .andExpect(jsonPath("$.profileImage").value(null as Any?)) - } - - @Test - fun `Authorization 헤더 없이 내 정보 조회 시 401을 반환한다`() { - mvc - .perform( - get("/api/users/me") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) - .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) - } - - @Test - fun `유효하지 않은 토큰으로 내 정보 조회 시 401을 반환한다`() { - mvc - .perform( - get("/api/users/me") - .header("Authorization", "Bearer invalid.token.value") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) - .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) - } - @Test fun `로그아웃 후 블랙리스트된 토큰으로 내 정보 조회 시 401을 반환한다`() { val (_, token) = dataGenerator.generateUser() @@ -523,8 +495,8 @@ class AuthIntegrationTest // 새로운 PendingUser가 생성됐는지 확인 (코드가 교체됨) val newPendingUser = pendingUserRepository.findByEmail(email)!! - assert(newPendingUser.verificationCode != oldCode) { "인증 코드가 갱신되지 않았습니다" } - assert(newPendingUser.expiresAt.isAfter(Instant.now())) { "새로운 만료 시각이 현재보다 미래여야 합니다" } + assertNotEquals(oldCode, newPendingUser.verificationCode) + assertTrue(newPendingUser.expiresAt.isAfter(Instant.now())) } @Test @@ -576,21 +548,3 @@ class AuthIntegrationTest .andExpect(jsonPath("$.token").isNotEmpty) } } - -// ============================================================================= -// [구체화 필요 사항] -// -// 1. 소셜 가입 사용자 이메일 로그인 버그 (AuthService.login): -// - passwordHash가 null인 User로 로그인 시 BCrypt.checkpw(password, null)에서 -// NullPointerException이 발생하여 500이 반환됨. -// - 올바른 동작: 401 LOGIN_FAILED 반환. -// - 수정 방향: `if (user.passwordHash == null || !BCrypt.checkpw(password, user.passwordHash))` -// 형태로 null guard 추가 필요. -// - 관련 테스트: "비밀번호 없이 생성된 사용자(소셜 가입)로 이메일 로그인 시 401을 반환한다" -// → 현재 코드에서는 실패(red test)이며, 버그 수정 후 통과 예상. -// -// 2. 로그아웃 정책: -// - 현재 /api/auth/logout은 @AuthRequired가 없어 블랙리스트된 토큰으로도 204 반환. -// - 비즈니스 정책상 이미 로그아웃된 토큰으로 재로그아웃 시도를 어떻게 처리할지 명확화 필요. -// - 현재 구현은 silent no-op(조용히 무시). 에러 반환 정책이 필요하면 @AuthRequired 추가 고려. -// ============================================================================= From 9ea4bda232d43ecef7518f672dd2bd1257e09040 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 23:09:49 +0900 Subject: [PATCH 032/157] =?UTF-8?q?:bug:=20passwordHash=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20null=20guard=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wafflestudio/spring2025/domain/auth/service/AuthService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/AuthService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/AuthService.kt index de4141b4..ceedbc89 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/AuthService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/AuthService.kt @@ -25,7 +25,7 @@ class AuthService( password: String, ): String { val user: User = userRepository.findByEmail(email) ?: throw LoginFailedException() - if (BCrypt.checkpw(password, user.passwordHash).not()) { + if (user.passwordHash == null || !BCrypt.checkpw(password, user.passwordHash)) { throw LoginFailedException() } val accessToken = jwtTokenProvider.createToken(user.id!!) From 8a1d388fbd67999f4647d8dd635b67e20921fb0f Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 23:10:21 +0900 Subject: [PATCH 033/157] =?UTF-8?q?:heavy=5Fplus=5Fsign:=20mockito-kotlin?= =?UTF-8?q?=20dependency=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index e669d039..a1316724 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:mysql") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") // profile_image 업로드 관련 의존성 추가 From 5ab923348440bccc3df2f67009f6f0eaba03d5c9 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 23:10:48 +0900 Subject: [PATCH 034/157] =?UTF-8?q?:bug:=20=EB=88=84=EB=9D=BD=EB=90=9C=20T?= =?UTF-8?q?estContainerConfig=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt index 9a537457..dbe38e39 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt @@ -10,6 +10,7 @@ import org.mockito.BDDMockito.given import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.bean.override.mockito.MockitoBean @@ -28,6 +29,7 @@ import software.amazon.awssdk.services.s3.model.NoSuchKeyException @ActiveProfiles("test") @Testcontainers @AutoConfigureMockMvc +@Import(TestContainerConfig::class) class UserIntegrationTest @Autowired constructor( From daf3e24c3b563743f3a9dfc9ab89edf63f8294f6 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 17:30:35 +0900 Subject: [PATCH 035/157] =?UTF-8?q?:test=5Ftube:=20event=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/EventIntegrationTest.kt | 928 ++++++++++++++++++ 1 file changed, 928 insertions(+) create mode 100644 src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt new file mode 100644 index 00000000..befee21d --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -0,0 +1,928 @@ +package com.wafflestudio.spring2025 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.domain.event.dto.request.CreateEventRequest +import com.wafflestudio.spring2025.domain.event.dto.request.UpdateEventRequest +import com.wafflestudio.spring2025.domain.event.model.Event +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.registration.model.Registration +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +@AutoConfigureMockMvc +class EventIntegrationTest + @Autowired + constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, + private val dataGenerator: DataGenerator, + private val eventRepository: EventRepository, + private val registrationRepository: RegistrationRepository, + ) { + // ================================================================= + // Helpers + // ================================================================= + + private fun createEventInDb( + createdBy: Long, + title: String = "테스트 이벤트", + capacity: Int = 10, + waitlistEnabled: Boolean = false, + startsAt: Instant = Instant.now().plusSeconds(3600), + endsAt: Instant = Instant.now().plusSeconds(7200), + registrationStartsAt: Instant? = null, + registrationEndsAt: Instant? = null, + ): Event = + eventRepository.save( + Event( + publicId = UUID.randomUUID().toString(), + title = title, + capacity = capacity, + waitlistEnabled = waitlistEnabled, + startsAt = startsAt, + endsAt = endsAt, + registrationStartsAt = registrationStartsAt, + registrationEndsAt = registrationEndsAt, + createdBy = createdBy, + ), + ) + + /** + * 유효한 CreateEventRequest 기본값. + * - startsAt=now+7200, endsAt=now+10800 (미래) + * - registrationStartsAt=now+3600 < startsAt(+7200) ✓ + * - registrationEndsAt=now+5400 < startsAt(+7200), > registrationStartsAt(+3600) ✓ + */ + private fun validCreateRequest( + title: String = "정기 모임", + capacity: Int = 30, + waitlistEnabled: Boolean = false, + ) = CreateEventRequest( + title = title, + description = "정기 모임입니다", + location = "강의실 101", + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(10800), + capacity = capacity, + waitlistEnabled = waitlistEnabled, + registrationStartsAt = Instant.now().plusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + // ================================================================= + // POST /api/events — 이벤트 생성 + // ================================================================= + + @Test + fun `유효한 데이터로 이벤트 생성 시 200과 publicId를 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(validCreateRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.publicId").isNotEmpty) + } + + @Test + fun `인증 없이 이벤트 생성 요청 시 401을 반환한다`() { + mvc + .perform( + post("/api/events") + .content(mapper.writeValueAsString(validCreateRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `제목이 공백인 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(validCreateRequest(title = " "))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_TITLE_BLANK")) + } + + @Test + fun `정원이 0인 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(validCreateRequest(capacity = 0))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_CAPACITY_INVALID")) + } + + @Test + fun `정원이 음수인 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(validCreateRequest(capacity = -5))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_CAPACITY_INVALID")) + } + + @Test + fun `모임 시작 시간이 과거이면 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + startsAt = Instant.now().minusSeconds(3600), + endsAt = Instant.now().plusSeconds(3600), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_STARTS_IN_PAST")) + } + + @Test + fun `모임 종료 시간이 과거이면 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + startsAt = null, + endsAt = Instant.now().minusSeconds(3600), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_ENDS_IN_PAST")) + } + + @Test + fun `모임 시작 시간이 종료 시간보다 늦으면 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(3600), // startsAt 보다 이전 + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_TIME_RANGE_INVALID")) + } + + @Test + fun `신청 시작 시간이 과거이면 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + registrationStartsAt = Instant.now().minusSeconds(3600), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_STARTS_IN_PAST")) + } + + @Test + fun `신청 마감 시간이 과거이면 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + registrationEndsAt = Instant.now().minusSeconds(3600), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_IN_PAST")) + } + + @Test + fun `신청 마감 시간이 신청 시작 시간보다 이르면 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + registrationStartsAt = Instant.now().plusSeconds(5400), + registrationEndsAt = Instant.now().plusSeconds(3600), // 시작보다 이전 + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_TIME_RANGE_INVALID")) + } + + @Test + fun `신청 시작 시간이 모임 시작 시간보다 늦으면 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + // startsAt=+3600, registrationStartsAt=+5400 → 신청 시작이 모임 시작보다 늦음 + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationStartsAt = Instant.now().plusSeconds(5400), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_STARTS_AFTER_EVENT_START")) + } + + @Test + fun `신청 마감 시간이 모임 시작 시간보다 늦으면 이벤트 생성 요청 시 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + // startsAt=+3600, registrationEndsAt=+5400 → 신청 마감이 모임 시작보다 늦음 + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_AFTER_EVENT_START")) + } + + // ================================================================= + // GET /api/events/{publicId} — 이벤트 상세 조회 + // ================================================================= + + @Test + fun `존재하지 않는 publicId로 이벤트 상세 조회 시 404를 반환한다`() { + mvc + .perform( + get("/api/events/${UUID.randomUUID()}"), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("EVENT_NOT_FOUND")) + } + + @Test + fun `비로그인 사용자가 이벤트 상세 조회 시 200과 올바른 응답 구조를 반환한다`() { + val (creator, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, title = "공개 이벤트", capacity = 10) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.event.publicId").value(event.publicId)) + .andExpect(jsonPath("$.event.title").value("공개 이벤트")) + .andExpect(jsonPath("$.event.capacity").value(10)) + .andExpect(jsonPath("$.event.totalApplicants").value(0)) + .andExpect(jsonPath("$.creator.name").value(creator.name)) + .andExpect(jsonPath("$.creator.email").value(creator.email)) + .andExpect(jsonPath("$.viewer.status").value("NONE")) + .andExpect(jsonPath("$.viewer.name").value(null as Any?)) + .andExpect(jsonPath("$.viewer.waitlistPosition").value(null as Any?)) + .andExpect(jsonPath("$.capabilities.shareLink").value(false)) + .andExpect(jsonPath("$.capabilities.apply").value(true)) // 정원 여유 있고 신청 기간 제한 없음 + .andExpect(jsonPath("$.capabilities.wait").value(false)) + .andExpect(jsonPath("$.capabilities.cancel").value(false)) + .andExpect(jsonPath("$.guestsPreview").isArray) + } + + @Test + fun `로그인한 이벤트 주최자가 상세 조회 시 viewerStatus가 HOST이고 shareLink가 true이다`() { + val (host, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("HOST")) + .andExpect(jsonPath("$.viewer.name").value(host.name)) + .andExpect(jsonPath("$.capabilities.shareLink").value(true)) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + .andExpect(jsonPath("$.capabilities.wait").value(false)) + .andExpect(jsonPath("$.capabilities.cancel").value(false)) + } + + @Test + fun `로그인했지만 참여하지 않은 사용자가 이벤트 조회 시 viewerStatus가 NONE이고 이름이 포함된다`() { + val (creator, _) = dataGenerator.generateUser() + val (viewer, viewerToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $viewerToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("NONE")) + .andExpect(jsonPath("$.viewer.name").value(viewer.name)) + } + + @Test + fun `정원이 찬 이벤트를 비로그인 사용자가 조회 시 apply는 false이다`() { + val (creator, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 1, waitlistEnabled = false) + + registrationRepository.save( + Registration( + userId = creator.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + .andExpect(jsonPath("$.capabilities.wait").value(false)) + } + + @Test + fun `정원이 찬 이벤트에서 waitlistEnabled이면 비로그인 사용자 조회 시 wait가 true이다`() { + val (creator, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 1, waitlistEnabled = true) + + registrationRepository.save( + Registration( + userId = creator.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + .andExpect(jsonPath("$.capabilities.wait").value(true)) + } + + @Test + fun `신청 기간이 지난 이벤트를 비로그인 사용자가 조회 시 apply와 wait 모두 false이다`() { + val (creator, _) = dataGenerator.generateUser() + // registrationEndsAt이 과거 → withinWindow = false + val event = + eventRepository.save( + Event( + publicId = UUID.randomUUID().toString(), + title = "마감된 이벤트", + capacity = 10, + waitlistEnabled = true, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationStartsAt = Instant.now().minusSeconds(7200), + registrationEndsAt = Instant.now().minusSeconds(3600), // 이미 마감 + createdBy = creator.id!!, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + .andExpect(jsonPath("$.capabilities.wait").value(false)) + } + + @Test + fun `CONFIRMED 참가자가 이벤트 조회 시 viewerStatus가 CONFIRMED이고 cancel이 true이다`() { + val (creator, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!) + + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $participantToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + .andExpect(jsonPath("$.capabilities.cancel").value(true)) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + } + + @Test + fun `WAITLISTED 참가자가 이벤트 조회 시 viewerStatus가 WAITLISTED이고 waitlistPosition이 포함된다`() { + val (creator, _) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 1, waitlistEnabled = true) + + // 정원 채우기 (1명 확정) + registrationRepository.save( + Registration( + userId = creator.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + // 대기 등록 + registrationRepository.save( + Registration( + userId = waiter.id!!, + eventId = event.id!!, + status = RegistrationStatus.WAITLISTED, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $waiterToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + .andExpect(jsonPath("$.viewer.waitlistPosition").value(1)) + .andExpect(jsonPath("$.capabilities.cancel").value(true)) + } + + @Test + fun `totalApplicants는 CONFIRMED + WAITLISTED 수의 합이다`() { + val (creator, _) = dataGenerator.generateUser() + val (user1, _) = dataGenerator.generateUser() + val (user2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 2, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = creator.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = user1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = user2.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.event.totalApplicants").value(3)) + } + + // ================================================================= + // GET /api/events/me — 내가 만든 이벤트 목록 조회 (무한 스크롤) + // ================================================================= + + @Test + fun `인증 없이 내 이벤트 목록 조회 요청 시 401을 반환한다`() { + mvc + .perform( + get("/api/events/me"), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `생성한 이벤트가 없을 때 내 이벤트 목록 조회 시 빈 목록을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events").isArray) + .andExpect(jsonPath("$.events.length()").value(0)) + .andExpect(jsonPath("$.hasNext").value(false)) + } + + @Test + fun `내 이벤트 목록 조회 시 내가 생성한 이벤트만 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val (otherUser, _) = dataGenerator.generateUser() + + val myEvent = createEventInDb(createdBy = user.id!!, title = "내 이벤트") + createEventInDb(createdBy = otherUser.id!!, title = "다른 사람 이벤트") + + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(1)) + .andExpect(jsonPath("$.events[0].publicId").value(myEvent.publicId)) + .andExpect(jsonPath("$.events[0].title").value("내 이벤트")) + } + + @Test + fun `내 이벤트 목록 조회 시 size 파라미터로 페이지 크기를 제한할 수 있다`() { + val (user, token) = dataGenerator.generateUser() + repeat(3) { i -> createEventInDb(createdBy = user.id!!, title = "이벤트 $i") } + + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token") + .param("size", "2"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(2)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.nextCursor").isNotEmpty) + } + + @Test + fun `내 이벤트 목록에 이벤트 응답 필드가 올바르게 포함된다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!, title = "필드 검증 이벤트", capacity = 20) + + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events[0].publicId").value(event.publicId)) + .andExpect(jsonPath("$.events[0].title").value("필드 검증 이벤트")) + .andExpect(jsonPath("$.events[0].capacity").value(20)) + .andExpect(jsonPath("$.events[0].totalApplicants").value(0)) + } + + @Test + fun `cursor 기반 페이징으로 다음 페이지를 조회할 수 있다`() { + val (user, token) = dataGenerator.generateUser() + repeat(3) { i -> + createEventInDb(createdBy = user.id!!, title = "이벤트 $i") + Thread.sleep(10) // createdAt 순서 보장 + } + + // 첫 페이지 (size=2) → 최신 2개 + val firstResult = + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token") + .param("size", "2"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(2)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andReturn() + + val nextCursor = mapper.readTree(firstResult.response.contentAsString).get("nextCursor").asText() + + // 두 번째 페이지 (cursor 사용) → 나머지 1개 + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token") + .param("size", "2") + .param("cursor", nextCursor), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(1)) + .andExpect(jsonPath("$.hasNext").value(false)) + } + + // ================================================================= + // PUT /api/events/{publicId} — 이벤트 수정 + // ================================================================= + + @Test + fun `주최자가 유효한 데이터로 이벤트 수정 시 200과 수정된 정보를 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "수정된 제목", location = "새 장소"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("수정된 제목")) + .andExpect(jsonPath("$.location").value("새 장소")) + } + + @Test + fun `인증 없이 이벤트 수정 요청 시 401을 반환한다`() { + val (user, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "수정"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `존재하지 않는 이벤트 수정 요청 시 404를 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + put("/api/events/${UUID.randomUUID()}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "수정"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("EVENT_NOT_FOUND")) + } + + @Test + fun `이벤트 주최자가 아닌 사용자가 수정 요청 시 403을 반환한다`() { + val (creator, _) = dataGenerator.generateUser() + val (_, otherToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $otherToken") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "수정"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("NOT_EVENT_ADMIN")) + } + + @Test + fun `이벤트 제목을 공백으로 수정 요청 시 400을 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(title = " "))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_TITLE_BLANK")) + } + + @Test + fun `정원을 0으로 수정 요청 시 400을 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 0))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_CAPACITY_INVALID")) + } + + @Test + fun `정원을 음수로 수정 요청 시 400을 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = -1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_CAPACITY_INVALID")) + } + + @Test + fun `모임 시작 시간을 과거로 수정 요청 시 400을 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(startsAt = Instant.now().minusSeconds(3600)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_STARTS_IN_PAST")) + } + + @Test + fun `수정 시 null 필드는 기존 값을 유지한다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!, title = "원래 제목") + + // title만 수정, description은 null → 기존 description(null) 유지 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "바뀐 제목"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("바뀐 제목")) + .andExpect(jsonPath("$.waitlistEnabled").value(false)) // 기존 값 유지 + } + + // ================================================================= + // DELETE /api/events/{publicId} — 이벤트 삭제 + // ================================================================= + + @Test + fun `확정된 참가자가 없는 이벤트를 주최자가 삭제 시 204를 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + } + + @Test + fun `이벤트 삭제 후 해당 이벤트 조회 시 404를 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("EVENT_NOT_FOUND")) + } + + @Test + fun `인증 없이 이벤트 삭제 요청 시 401을 반환한다`() { + val (user, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}"), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `존재하지 않는 이벤트 삭제 요청 시 404를 반환한다`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + delete("/api/events/${UUID.randomUUID()}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("EVENT_NOT_FOUND")) + } + + @Test + fun `이벤트 주최자가 아닌 사용자가 삭제 요청 시 403을 반환한다`() { + val (creator, _) = dataGenerator.generateUser() + val (_, otherToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $otherToken"), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("NOT_EVENT_ADMIN")) + } + + @Test + fun `확정된 참가자가 있는 이벤트 삭제 요청 시 409를 반환한다`() { + val (user, token) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("EVENT_HAS_CONFIRMED_REGISTRATIONS")) + } + } + +// ============================================================================= +// [구체화 필요 사항] +// +// 1. CreateEventRequest.capacity 필드 null 허용 여부: +// - DTO에서 capacity는 Int (non-nullable, 기본값 없음)로 선언되어 있어, +// JSON 역직렬화 시 누락되면 Jackson이 400 에러를 반환함. +// - 서비스의 validateCreateOrUpdate에는 EVENT_CAPACITY_REQUIRED(capacity==null) 검사가 있으나 +// CreateEventRequest에서는 실제로 null이 올 수 없으므로, 이 분기는 업데이트 시에만 의미 있음. +// - 정책 확인 필요: capacity 생략 시 명시적인 400 + EVENT_CAPACITY_REQUIRED 응답이 필요하다면 +// DTO를 Int?로 변경하거나, @JsonSetter(nulls = Nulls.FAIL) 등 추가 처리가 필요. +// +// 2. waitlistEnabled 필드 null 허용 여부: +// - CreateEventRequest.waitlistEnabled 역시 Boolean (non-nullable)로 선언되어, +// 생략 시 Jackson 역직렬화 단계에서 400이 발생함. +// - 이것이 의도된 동작인지, 기본값(false)을 사용할 수 있도록 허용할지 명확화 필요. +// +// 3. 이벤트 수정 시 시간 범위 교차 검증: +// - update는 필드를 개별적으로 덮어쓴 후 validateCreateOrUpdate를 호출함. +// - 기존 이벤트의 startsAt=미래, 수정 요청에 startsAt만 더 미래로 변경하면 +// 기존 registrationEndsAt이 새 startsAt보다 여전히 이전인지 재검증됨. +// - 의도한 동작인지, 개별 필드 변경의 부작용으로 기존에 유효했던 조합이 +// 무효화되는 케이스에 대한 정책 명확화 필요. +// +// 4. GET /api/events/{publicId} 에서 비인증 요청 시 viewer.name이 null인지 확인: +// - requesterId가 null이면 viewerName = null로 설정되므로 viewer.name은 null이어야 함. +// - UserArgumentResolver가 Authorization 헤더 없을 때 null을 반환하는지 확인 필요. +// ============================================================================= From d3ea644de321d64f7d752f4861284fa1c4a51474 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 17:31:59 +0900 Subject: [PATCH 036/157] :art: ktlint --- .../domain/registration/service/RegistrationService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 54084a91..0d363f47 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -379,7 +379,7 @@ RegistrationService( } val isHost = userId != null && userId == event.createdBy - val isRegistrant = userId != null && registration.userId == userId +// val isRegistrant = userId != null && registration.userId == userId if (status == RegistrationStatus.BANNED && !isHost) { throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_PATCH_UNAUTHORIZED) From a3c50c02c6caea739faa46ac2ab8ff8ce8763d3e Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 20:30:38 +0900 Subject: [PATCH 037/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20event=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20integration=20test=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EA=B8=B0=EC=A1=B4=20event=20service=20test?= =?UTF-8?q?=EB=8F=84=20=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/EventIntegrationTest.kt | 212 +++++++++++++++--- 1 file changed, 184 insertions(+), 28 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index befee21d..da387a97 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -373,6 +373,8 @@ class EventIntegrationTest .andExpect(jsonPath("$.capabilities.wait").value(false)) .andExpect(jsonPath("$.capabilities.cancel").value(false)) .andExpect(jsonPath("$.guestsPreview").isArray) + .andExpect(jsonPath("$.viewer.registrationPublicId").value(null as Any?)) + .andExpect(jsonPath("$.viewer.reservationEmail").value(null as Any?)) } @Test @@ -497,6 +499,8 @@ class EventIntegrationTest .header("Authorization", "Bearer $participantToken"), ).andExpect(status().isOk) .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + .andExpect(jsonPath("$.viewer.registrationPublicId").isNotEmpty) + .andExpect(jsonPath("$.viewer.reservationEmail").value(null as Any?)) .andExpect(jsonPath("$.capabilities.cancel").value(true)) .andExpect(jsonPath("$.capabilities.apply").value(false)) } @@ -531,6 +535,8 @@ class EventIntegrationTest ).andExpect(status().isOk) .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) .andExpect(jsonPath("$.viewer.waitlistPosition").value(1)) + .andExpect(jsonPath("$.viewer.registrationPublicId").isNotEmpty) + .andExpect(jsonPath("$.viewer.reservationEmail").value(null as Any?)) .andExpect(jsonPath("$.capabilities.cancel").value(true)) } @@ -787,6 +793,29 @@ class EventIntegrationTest .andExpect(jsonPath("$.code").value("EVENT_STARTS_IN_PAST")) } + @Test + fun `모임 시작 시간을 앞당겨 기존 신청 마감 시간과 역전되면 400을 반환한다`() { + val (user, token) = dataGenerator.generateUser() + // startsAt=+7200, registrationEndsAt=+5400 → 유효한 초기 상태 + val event = + createEventInDb( + createdBy = user.id!!, + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(10800), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + // startsAt=+3600 으로 앞당기면 registrationEndsAt(+5400) > startsAt(+3600) 이 됨 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(startsAt = Instant.now().plusSeconds(3600)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_AFTER_EVENT_START")) + } + @Test fun `수정 시 null 필드는 기존 값을 유지한다`() { val (user, token) = dataGenerator.generateUser() @@ -804,6 +833,161 @@ class EventIntegrationTest .andExpect(jsonPath("$.waitlistEnabled").value(false)) // 기존 값 유지 } + @Test + fun `확정 참가자 있을 때 신청 시작 시간을 늦추면 400을 반환한다`() { + val (host, token) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val event = + createEventInDb( + createdBy = host.id!!, + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(10800), + registrationStartsAt = Instant.now().plusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + registrationRepository.save( + Registration(userId = participant.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED), + ) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + // +3600 → +4500 으로 늦춤 (기존 값보다 이후, startsAt=+7200 이전) + .content(mapper.writeValueAsString(UpdateEventRequest(registrationStartsAt = Instant.now().plusSeconds(4500)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_START_CANNOT_DELAY_WITH_PARTICIPANTS")) + } + + @Test + fun `확정 참가자 있을 때 신청 마감 시간을 현재 시각 이전으로 변경하면 400을 반환한다`() { + val (host, token) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, registrationEndsAt = Instant.now().plusSeconds(3600)) + + registrationRepository.save( + Registration(userId = participant.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED), + ) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(registrationEndsAt = Instant.now().minusSeconds(60)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS")) + } + + @Test + fun `확정 참가자 수보다 정원을 줄이면 400을 반환한다`() { + val (host, token) = dataGenerator.generateUser() + val (p1, _) = dataGenerator.generateUser() + val (p2, _) = dataGenerator.generateUser() + val (p3, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10) + + registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = p3.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 2))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS")) + } + + @Test + fun `정원을 확정 참가자 수와 동일하게 줄일 수 있다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmed, _) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 5, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = confirmed.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = waiter.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + // capacity 5 → 1 (확정자 수와 동일) — 허용됨 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capacity").value(1)) + + // 대기자는 여전히 WAITLISTED — 정원이 늘지 않았으므로 전환 없음 + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $waiterToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원을 늘리면 대기자가 확정 참가자로 전환된다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 1, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = host.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = waiter.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 2))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $waiterToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + } + + @Test + fun `확정 참가자 없을 때 신청 시작 시간과 정원을 함께 변경할 수 있다`() { + val (host, token) = dataGenerator.generateUser() + val event = + createEventInDb( + createdBy = host.id!!, + capacity = 10, + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(10800), + registrationStartsAt = Instant.now().plusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + val newRegistrationStartsAt = Instant.now().plusSeconds(3900) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + UpdateEventRequest( + capacity = 5, + registrationStartsAt = newRegistrationStartsAt, + ), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capacity").value(5)) + } + // ================================================================= // DELETE /api/events/{publicId} — 이벤트 삭제 // ================================================================= @@ -898,31 +1082,3 @@ class EventIntegrationTest .andExpect(jsonPath("$.code").value("EVENT_HAS_CONFIRMED_REGISTRATIONS")) } } - -// ============================================================================= -// [구체화 필요 사항] -// -// 1. CreateEventRequest.capacity 필드 null 허용 여부: -// - DTO에서 capacity는 Int (non-nullable, 기본값 없음)로 선언되어 있어, -// JSON 역직렬화 시 누락되면 Jackson이 400 에러를 반환함. -// - 서비스의 validateCreateOrUpdate에는 EVENT_CAPACITY_REQUIRED(capacity==null) 검사가 있으나 -// CreateEventRequest에서는 실제로 null이 올 수 없으므로, 이 분기는 업데이트 시에만 의미 있음. -// - 정책 확인 필요: capacity 생략 시 명시적인 400 + EVENT_CAPACITY_REQUIRED 응답이 필요하다면 -// DTO를 Int?로 변경하거나, @JsonSetter(nulls = Nulls.FAIL) 등 추가 처리가 필요. -// -// 2. waitlistEnabled 필드 null 허용 여부: -// - CreateEventRequest.waitlistEnabled 역시 Boolean (non-nullable)로 선언되어, -// 생략 시 Jackson 역직렬화 단계에서 400이 발생함. -// - 이것이 의도된 동작인지, 기본값(false)을 사용할 수 있도록 허용할지 명확화 필요. -// -// 3. 이벤트 수정 시 시간 범위 교차 검증: -// - update는 필드를 개별적으로 덮어쓴 후 validateCreateOrUpdate를 호출함. -// - 기존 이벤트의 startsAt=미래, 수정 요청에 startsAt만 더 미래로 변경하면 -// 기존 registrationEndsAt이 새 startsAt보다 여전히 이전인지 재검증됨. -// - 의도한 동작인지, 개별 필드 변경의 부작용으로 기존에 유효했던 조합이 -// 무효화되는 케이스에 대한 정책 명확화 필요. -// -// 4. GET /api/events/{publicId} 에서 비인증 요청 시 viewer.name이 null인지 확인: -// - requesterId가 null이면 viewerName = null로 설정되므로 viewer.name은 null이어야 함. -// - UserArgumentResolver가 Authorization 헤더 없을 때 null을 반환하는지 확인 필요. -// ============================================================================= From 5387602ba52ad8aae7debda11fc238d112f1896b Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 20:31:05 +0900 Subject: [PATCH 038/157] =?UTF-8?q?:fire:=20EventServiceTest.kt=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(integration=20test=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=20=ED=86=B5=ED=95=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/EventServiceTest.kt | 231 ------------------ 1 file changed, 231 deletions(-) delete mode 100644 src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt deleted file mode 100644 index d34b312d..00000000 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventServiceTest.kt +++ /dev/null @@ -1,231 +0,0 @@ -package com.wafflestudio.spring2025 - -import com.wafflestudio.spring2025.common.image.service.ImageService -import com.wafflestudio.spring2025.domain.event.exception.EventErrorCode -import com.wafflestudio.spring2025.domain.event.exception.EventValidationException -import com.wafflestudio.spring2025.domain.event.model.Event -import com.wafflestudio.spring2025.domain.event.repository.EventLockRepository -import com.wafflestudio.spring2025.domain.event.repository.EventRepository -import com.wafflestudio.spring2025.domain.event.service.EventService -import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository -import com.wafflestudio.spring2025.domain.registration.service.WaitlistReconciliationService -import com.wafflestudio.spring2025.domain.user.repository.UserRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.mockito.ArgumentMatchers.any -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import java.time.Instant -import java.util.UUID - -class EventServiceTest { - private val eventRepository = mock(EventRepository::class.java) - private val eventLockRepository = mock(EventLockRepository::class.java) - private val registrationRepository = mock(RegistrationRepository::class.java) - private val waitlistReconciliationService = mock(WaitlistReconciliationService::class.java) - private val userRepository = mock(UserRepository::class.java) - private val imageService = mock(ImageService::class.java) - - private val eventService = - EventService( - eventRepository = eventRepository, - eventLockRepository = eventLockRepository, - registrationRepository = registrationRepository, - waitlistReconciliationService = waitlistReconciliationService, - userRepository = userRepository, - imageService = imageService, - ) - - @Test - fun `registration start cannot be delayed when confirmed exists`() { - val event = makeEvent() - stubEventLookup(event) - stubParticipantCounts(eventId = event.id!!, confirmed = 1, waitlisted = 0) - - val exception = - assertThrows { - eventService.update( - publicId = event.publicId, - title = null, - description = null, - location = null, - startsAt = null, - endsAt = null, - capacity = null, - waitlistEnabled = null, - registrationStartsAt = event.registrationStartsAt!!.plusSeconds(300), - registrationEndsAt = null, - requesterId = event.createdBy, - ) - } - - assertThat(exception.code).isEqualTo(EventErrorCode.REGISTRATION_START_CANNOT_DELAY_WITH_PARTICIPANTS) - } - - @Test - fun `registration end cannot be advanced when confirmed exists`() { - val event = makeEvent() - stubEventLookup(event) - stubParticipantCounts(eventId = event.id!!, confirmed = 1, waitlisted = 0) - - val exception = - assertThrows { - eventService.update( - publicId = event.publicId, - title = null, - description = null, - location = null, - startsAt = null, - endsAt = null, - capacity = null, - waitlistEnabled = null, - registrationStartsAt = null, - registrationEndsAt = event.registrationEndsAt!!.minusSeconds(300), - requesterId = event.createdBy, - ) - } - - assertThat(exception.code).isEqualTo(EventErrorCode.REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS) - } - - @Test - fun `capacity cannot be set below confirmed count`() { - val event = makeEvent(capacity = 10) - stubEventLookup(event) - stubParticipantCounts(eventId = event.id!!, confirmed = 3, waitlisted = 5) - - val exception = - assertThrows { - eventService.update( - publicId = event.publicId, - title = null, - description = null, - location = null, - startsAt = null, - endsAt = null, - capacity = 2, - waitlistEnabled = null, - registrationStartsAt = null, - registrationEndsAt = null, - requesterId = event.createdBy, - ) - } - - assertThat(exception.code).isEqualTo(EventErrorCode.CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS) - } - - @Test - fun `capacity can be lowered to at least confirmed count`() { - val event = makeEvent(capacity = 10) - stubEventLookup(event) - stubParticipantCounts(eventId = event.id!!, confirmed = 3, waitlisted = 10) - - val updated = - eventService.update( - publicId = event.publicId, - title = null, - description = null, - location = null, - startsAt = null, - endsAt = null, - capacity = 3, - waitlistEnabled = null, - registrationStartsAt = null, - registrationEndsAt = null, - requesterId = event.createdBy, - ) - - assertThat(updated.capacity).isEqualTo(3) - verify(waitlistReconciliationService, never()).reconcileWaitlist(event.id!!) - } - - @Test - fun `increasing capacity delegates waitlist reconciliation`() { - val event = makeEvent(capacity = 1) - stubEventLookup(event) - stubParticipantCounts(eventId = event.id!!, confirmed = 1, waitlisted = 2) - - val updated = - eventService.update( - publicId = event.publicId, - title = null, - description = null, - location = null, - startsAt = null, - endsAt = null, - capacity = 2, - waitlistEnabled = null, - registrationStartsAt = null, - registrationEndsAt = null, - requesterId = event.createdBy, - ) - - assertThat(updated.capacity).isEqualTo(2) - verify(waitlistReconciliationService).reconcileWaitlist(event.id!!) - } - - @Test - fun `both registration start and capacity can be changed when no confirmed exists`() { - val event = makeEvent(capacity = 10) - stubEventLookup(event) - stubParticipantCounts(eventId = event.id!!, confirmed = 0, waitlisted = 0) - val expectedRegistrationStartsAt = event.registrationStartsAt!!.plusSeconds(300) - - val updated = - eventService.update( - publicId = event.publicId, - title = null, - description = null, - location = null, - startsAt = null, - endsAt = null, - capacity = 5, - waitlistEnabled = null, - registrationStartsAt = expectedRegistrationStartsAt, - registrationEndsAt = null, - requesterId = event.createdBy, - ) - - assertThat(updated.capacity).isEqualTo(5) - assertThat(updated.registrationStartsAt).isEqualTo(expectedRegistrationStartsAt) - verify(waitlistReconciliationService, never()).reconcileWaitlist(event.id!!) - } - - private fun stubEventLookup(event: Event) { - `when`(eventRepository.findByPublicId(event.publicId)).thenReturn(event) - `when`(eventRepository.save(any(Event::class.java))).thenAnswer { it.arguments[0] as Event } - } - - private fun stubParticipantCounts( - eventId: Long, - confirmed: Long, - waitlisted: Long, - ) { - `when`(registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.CONFIRMED)).thenReturn(confirmed) - `when`(registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.WAITLISTED)).thenReturn(waitlisted) - } - - private fun makeEvent(capacity: Int = 10): Event { - val now = Instant.now().plusSeconds(3600) - return Event( - id = 1L, - publicId = UUID.randomUUID().toString(), - title = "title", - description = "description", - location = "location", - startsAt = now.plusSeconds(3600), - endsAt = now.plusSeconds(7200), - capacity = capacity, - waitlistEnabled = true, - registrationStartsAt = now.plusSeconds(300), - registrationEndsAt = now.plusSeconds(1200), - createdBy = 100L, - createdAt = now, - updatedAt = now, - ) - } -} From 8ad9cbef37cd75ea1f40fd915ebbb510511ba526 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 20:33:13 +0900 Subject: [PATCH 039/157] =?UTF-8?q?:adhesive=5Fbandage:=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=8B=A0=EC=B2=AD=EB=A7=88=EA=B0=90=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B6=88=EA=B0=80=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=9D=84=20'=EA=B8=B0=EC=A1=B4=20=EB=A7=88=EA=B0=90=EC=8B=9C?= =?UTF-8?q?=EA=B0=81'=20->=20'=ED=98=84=EC=9E=AC=20=EC=8B=9C=EA=B0=81'?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/service/EventService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 1d345dd3..ae5af1f9 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -464,6 +464,7 @@ class EventService( registrationEndsAt: Instant?, capacity: Int?, confirmedParticipants: Int, + now: Instant = Instant.now(), ) { if (confirmedParticipants <= 0) return @@ -475,8 +476,7 @@ class EventService( } if (registrationEndsAt != null && - event.registrationEndsAt != null && - registrationEndsAt.isBefore(event.registrationEndsAt) + registrationEndsAt.isBefore(now) ) { throw EventValidationException(EventErrorCode.REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS) } From e6259513a6e1096bc5d3e9bab93f867679a22f31 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 8 Mar 2026 23:25:42 +0900 Subject: [PATCH 040/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20email=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=20=EB=8C=80=ED=95=9C=20moc?= =?UTF-8?q?kito=20=EB=B9=88=20=EC=B6=94=EA=B0=80,=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/EventIntegrationTest.kt | 73 ++++++++++++++----- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index da387a97..eac9d87d 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -1,6 +1,7 @@ package com.wafflestudio.spring2025 import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.domain.event.dto.request.CreateEventRequest import com.wafflestudio.spring2025.domain.event.dto.request.UpdateEventRequest import com.wafflestudio.spring2025.domain.event.model.Event @@ -8,12 +9,14 @@ import com.wafflestudio.spring2025.domain.event.repository.EventRepository import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -38,6 +41,9 @@ class EventIntegrationTest private val eventRepository: EventRepository, private val registrationRepository: RegistrationRepository, ) { + @MockitoBean + private lateinit var emailService: EmailService + // ================================================================= // Helpers // ================================================================= @@ -638,12 +644,14 @@ class EventIntegrationTest @Test fun `cursor 기반 페이징으로 다음 페이지를 조회할 수 있다`() { val (user, token) = dataGenerator.generateUser() - repeat(3) { i -> - createEventInDb(createdBy = user.id!!, title = "이벤트 $i") - Thread.sleep(10) // createdAt 순서 보장 - } - - // 첫 페이지 (size=2) → 최신 2개 + // events[0]=이벤트 0(oldest), events[1]=이벤트 1, events[2]=이벤트 2(newest) + val events = + (0 until 3).map { i -> + createEventInDb(createdBy = user.id!!, title = "이벤트 $i") + .also { Thread.sleep(10) } // createdAt 순서 보장 + } + + // 첫 페이지 (size=2) → 최신 2개: 이벤트 2, 이벤트 1 val firstResult = mvc .perform( @@ -655,18 +663,49 @@ class EventIntegrationTest .andExpect(jsonPath("$.hasNext").value(true)) .andReturn() - val nextCursor = mapper.readTree(firstResult.response.contentAsString).get("nextCursor").asText() + val firstTree = mapper.readTree(firstResult.response.contentAsString) + val nextCursor = firstTree.get("nextCursor").asText() + val firstPageIds = + (0 until 2).map { + firstTree + .get("events") + .get(it) + .get("publicId") + .asText() + } + + // 1페이지는 최신순 (이벤트 2 → 이벤트 1) + assertThat(firstPageIds[0]).isEqualTo(events[2].publicId) + assertThat(firstPageIds[1]).isEqualTo(events[1].publicId) + + // 두 번째 페이지 (cursor 사용) → 나머지 1개: 이벤트 0 + val secondResult = + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token") + .param("size", "2") + .param("cursor", nextCursor), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(1)) + .andExpect(jsonPath("$.hasNext").value(false)) + .andReturn() - // 두 번째 페이지 (cursor 사용) → 나머지 1개 - mvc - .perform( - get("/api/events/me") - .header("Authorization", "Bearer $token") - .param("size", "2") - .param("cursor", nextCursor), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.events.length()").value(1)) - .andExpect(jsonPath("$.hasNext").value(false)) + val secondTree = mapper.readTree(secondResult.response.contentAsString) + val secondPageIds = + listOf( + secondTree + .get("events") + .get(0) + .get("publicId") + .asText(), + ) + + // 2페이지는 가장 오래된 이벤트 0 + assertThat(secondPageIds[0]).isEqualTo(events[0].publicId) + + // 1페이지와 2페이지 간 publicId 중복 없음 + assertThat(firstPageIds).doesNotContainAnyElementsOf(secondPageIds) } // ================================================================= From 3ed45442023cf7cfee37d14ee35144a9e2a5e4da Mon Sep 17 00:00:00 2001 From: uykhc Date: Mon, 9 Mar 2026 01:18:13 +0900 Subject: [PATCH 041/157] feat: implement Regstration Integration Test --- .../spring2025/RegistrationIntegrationTest.kt | 422 +++++++++++++++--- 1 file changed, 364 insertions(+), 58 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index 77734df8..bc5c3bcf 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -1,30 +1,36 @@ package com.wafflestudio.spring2025 +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper -import com.wafflestudio.spring2025.domain.auth.LoggedInUser +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.registration.dto.request.CreateRegistrationRequest +import com.wafflestudio.spring2025.domain.registration.dto.request.DeleteRegistrationRequest +import com.wafflestudio.spring2025.domain.registration.dto.request.UpdateRegistrationStatusRequest +import com.wafflestudio.spring2025.domain.registration.model.Registration +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository -import com.wafflestudio.spring2025.domain.user.repository.UserRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.TestConfiguration -import org.springframework.core.MethodParameter -import org.springframework.core.annotation.Order +import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.web.bind.support.WebDataBinderFactory -import org.springframework.web.context.request.NativeWebRequest -import org.springframework.web.method.support.HandlerMethodArgumentResolver -import org.springframework.web.method.support.ModelAndViewContainer -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.testcontainers.junit.jupiter.Testcontainers import java.time.Instant import java.util.UUID @@ -32,7 +38,8 @@ import java.util.UUID @SpringBootTest @ActiveProfiles("test") @Testcontainers -@AutoConfigureMockMvc(addFilters = false) +@AutoConfigureMockMvc +@Import(TestContainerConfig::class) class RegistrationIntegrationTest @Autowired constructor( @@ -42,41 +49,364 @@ class RegistrationIntegrationTest private val eventRepository: EventRepository, private val registrationRepository: RegistrationRepository, ) { + @MockitoBean + private lateinit var emailService: EmailService + + @Test + fun `로그인 사용자가 등록하면 CONFIRMED 상태로 저장된다`() { + val (host, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Backend Study", capacity = 10, waitlistEnabled = false) + + val result = + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrationPublicId").isNotEmpty) + .andReturn() + + val registrationId = extractRegistrationPublicId(result) + val saved = findRegistration(registrationId) + + assertEquals(participant.id, saved.userId) + assertNull(saved.guestName) + assertNull(saved.guestEmail) + assertEquals(RegistrationStatus.CONFIRMED, saved.status) + } + @Test - fun `내 신청 목록을 최신순과 페이징으로 조회한다`() { - val (user, _) = dataGenerator.generateUser() - val event1 = createEvent(createdBy = user.id!!, title = "Event 1") - val event2 = createEvent(createdBy = user.id!!, title = "Event 2") + fun `비로그인 사용자도 guestName과 guestEmail로 등록할 수 있다`() { + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Guest Registration") + + val registrationId = registerAsGuest(event.publicId, "Guest User", "guest-1@example.com") + val saved = findRegistration(registrationId) + + assertNull(saved.userId) + assertEquals("Guest User", saved.guestName) + assertEquals("guest-1@example.com", saved.guestEmail) + assertEquals(RegistrationStatus.CONFIRMED, saved.status) + } + + @Test + fun `주최자는 자신의 이벤트에 등록할 수 없다`() { + val (host, hostToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Host Event") mvc .perform( - post("/api/v1/events/${event1.id}/registrations") - .content("{}") - .header("X-Test-User-Id", user.id.toString()) + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("REGISTRATION_BLOCKED_HOST")) + } + + @Test + fun `정원이 가득 찼고 대기열이 비활성화면 등록을 할 수 없다`() { + val (host, _) = dataGenerator.generateUser() + val (_, firstRegistrantToken) = dataGenerator.generateUser() + val (_, secondRegistrantToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "No Waitlist", capacity = 1, waitlistEnabled = false) + + registerAsUser(event.publicId, firstRegistrantToken) + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $secondRegistrantToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("EVENT_FULL")) + } + + @Test + fun `정원이 가득 찼고 대기열이 활성화면 WAITLISTED로 등록된다`() { + val (host, _) = dataGenerator.generateUser() + val (_, confirmedUserToken) = dataGenerator.generateUser() + val (_, waitlistedUserToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Waitlist Enabled", capacity = 1, waitlistEnabled = true) + + val confirmedRegistrationId = registerAsUser(event.publicId, confirmedUserToken) + val waitlistedRegistrationId = registerAsUser(event.publicId, waitlistedUserToken) + + assertEquals(RegistrationStatus.CONFIRMED, findRegistration(confirmedRegistrationId).status) + assertEquals(RegistrationStatus.WAITLISTED, findRegistration(waitlistedRegistrationId).status) + } + + @Test + fun `이벤트 신청 시작 시간 이전에는 신청할 수 없다`() { + val (host, _) = dataGenerator.generateUser() + val (_, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Registration Not Open Yet", + registrationStartsAt = now.plusSeconds(1800), + registrationEndsAt = now.plusSeconds(7200), + ) + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `이벤트 신청 마감 시간 이후에는 신청할 수 없다`() { + val (host, _) = dataGenerator.generateUser() + val (_, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Registration Closed", + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `게스트 등록 시 이메일 형식이 잘못되면 400을 반환한다`() { + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Invalid Guest Email") + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .content( + mapper.writeValueAsString( + CreateRegistrationRequest( + guestName = "Guest User", + guestEmail = "invalid-email-format", + ), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_WRONG_EMAIL")) + } + + @Test + fun `같은 이벤트에 동일한 게스트 이메일로 중복 등록할 수 없다`() { + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Duplicate Guest Email") - Thread.sleep(5) + registerAsGuest(event.publicId, "Guest One", "guest-duplicate@example.com") mvc .perform( - post("/api/v1/events/${event2.id}/registrations") - .content("{}") - .header("X-Test-User-Id", user.id.toString()) + post("/api/events/${event.publicId}/registrations") + .content( + mapper.writeValueAsString( + CreateRegistrationRequest( + guestName = "Guest Two", + guestEmail = "guest-duplicate@example.com", + ), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("REGISTRATION_ALREADY_EXISTS")) + } + + @Test + fun `본인의 참가 신청은 본인만 취소할 수 있다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (_, participantToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Host Delete Permission") + val participantRegistrationId = registerAsUser(event.publicId, participantToken) + + mvc + .perform( + delete("/api/registrations/$participantRegistrationId") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(DeleteRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("REGISTRATION_DELETE_UNAUTHORIZED")) + + mvc + .perform( + delete("/api/registrations/$participantRegistrationId") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) + assertNull(registrationRepository.findByRegistrationPublicId(participantRegistrationId)) + } + + @Test + fun `CONFIRMED 등록을 삭제하면 대기자가 승격된다`() { + val (host, _) = dataGenerator.generateUser() + val (_, confirmedUserToken) = dataGenerator.generateUser() + val (_, waitlistedUserToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Delete Promotion", capacity = 1, waitlistEnabled = true) + + val confirmedRegistrationId = registerAsUser(event.publicId, confirmedUserToken) + val waitlistedRegistrationId = registerAsUser(event.publicId, waitlistedUserToken) + + assertEquals(RegistrationStatus.CONFIRMED, findRegistration(confirmedRegistrationId).status) + assertEquals(RegistrationStatus.WAITLISTED, findRegistration(waitlistedRegistrationId).status) + mvc .perform( - get("/api/v1/registrations/me") - .header("X-Test-User-Id", user.id.toString()) - .queryParam("page", "0") - .queryParam("size", "1") + delete("/api/registrations/$confirmedRegistrationId") + .header("Authorization", "Bearer $confirmedUserToken") + .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) - .andExpect(jsonPath("$.registrations.length()").value(1)) - .andExpect(jsonPath("$.registrations[0].title").value(event2.title)) - .andExpect(jsonPath("$.registrations[0].publicId").value(event2.publicId)) + + assertNull(registrationRepository.findByRegistrationPublicId(confirmedRegistrationId)) + assertEquals(RegistrationStatus.CONFIRMED, findRegistration(waitlistedRegistrationId).status) + } + + @Test + fun `주최자만 강퇴할 수 있고 CONFIRMED 강퇴 시 대기자가 승격된다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmedUser, confirmedUserToken) = dataGenerator.generateUser() + val (_, waitlistedUserToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Ban Scenario", capacity = 1, waitlistEnabled = true) + + val confirmedRegistrationId = registerAsUser(event.publicId, confirmedUserToken) + val waitlistedRegistrationId = registerAsUser(event.publicId, waitlistedUserToken) + + mvc + .perform( + patch("/api/registrations/$confirmedRegistrationId") + .header("Authorization", "Bearer $confirmedUserToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("REGISTRATION_PATCH_UNAUTHORIZED")) + + mvc + .perform( + patch("/api/registrations/$confirmedRegistrationId") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.patchEmail").value(confirmedUser.email)) + + assertEquals(RegistrationStatus.BANNED, findRegistration(confirmedRegistrationId).status) + assertEquals(RegistrationStatus.CONFIRMED, findRegistration(waitlistedRegistrationId).status) + } + + @Test + fun `주최자만 참여자 이메일을 볼 수 있고 참여자는 볼 수 없다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Email Visibility", capacity = 10, waitlistEnabled = false) + + registerAsUser(event.publicId, participantToken) + registerAsGuest(event.publicId, "Guest User", "guest@example.com") + + val nonHostResponse = + mvc + .perform( + get("/api/events/${event.publicId}/registrations") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andReturn() + + val nonHostParticipants = extractParticipants(nonHostResponse) + assertTrue(nonHostParticipants.all { it.path("email").isNull }) + + val hostResponse = + mvc + .perform( + get("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $hostToken") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andReturn() + + val hostParticipants = extractParticipants(hostResponse) + assertTrue(hostParticipants.any { it.path("email").asText() == participant.email }) + assertTrue(hostParticipants.any { it.path("email").asText() == "guest@example.com" }) + } + + private fun registerAsUser( + eventPublicId: String, + token: String, + ): String { + val result = + mvc + .perform( + post("/api/events/$eventPublicId/registrations") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrationPublicId").isNotEmpty) + .andReturn() + + return extractRegistrationPublicId(result) + } + + private fun registerAsGuest( + eventPublicId: String, + guestName: String, + guestEmail: String, + ): String { + val result = + mvc + .perform( + post("/api/events/$eventPublicId/registrations") + .content( + mapper.writeValueAsString( + CreateRegistrationRequest( + guestName = guestName, + guestEmail = guestEmail, + ), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrationPublicId").isNotEmpty) + .andReturn() + + return extractRegistrationPublicId(result) + } + + private fun extractRegistrationPublicId(result: MvcResult): String = + mapper.readTree(result.response.contentAsString).path("registrationPublicId").asText() + + private fun extractParticipants(result: MvcResult): List = + mapper + .readTree(result.response.contentAsString) + .path("participants") + .toList() + + private fun findRegistration(registrationPublicId: String): Registration { + val registration = registrationRepository.findByRegistrationPublicId(registrationPublicId) + assertNotNull(registration) + return registration!! } private fun createEvent( @@ -84,6 +414,8 @@ class RegistrationIntegrationTest title: String, capacity: Int = 10, waitlistEnabled: Boolean = false, + registrationStartsAt: Instant? = null, + registrationEndsAt: Instant? = null, ): Event = eventRepository.save( Event( @@ -95,35 +427,9 @@ class RegistrationIntegrationTest endsAt = Instant.now().plusSeconds(7200), capacity = capacity, waitlistEnabled = waitlistEnabled, - registrationEndsAt = null, + registrationStartsAt = registrationStartsAt, + registrationEndsAt = registrationEndsAt, createdBy = createdBy, ), ) - - @TestConfiguration - @Order(0) - class TestWebConfig( - private val userRepository: UserRepository, - ) : WebMvcConfigurer { - override fun addArgumentResolvers(resolvers: MutableList) { - resolvers.add(0, TestLoggedInUserArgumentResolver(userRepository)) - } - } - - private class TestLoggedInUserArgumentResolver( - private val userRepository: UserRepository, - ) : HandlerMethodArgumentResolver { - override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.hasParameterAnnotation(LoggedInUser::class.java) - - override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory?, - ): Any? { - val userIdHeader = webRequest.getHeader("X-Test-User-Id") ?: return null - val userId = userIdHeader.toLongOrNull() ?: return null - return userRepository.findById(userId).orElse(null) - } - } } From b0ca767d7ff7b288a18de68c39a09041b6582aca Mon Sep 17 00:00:00 2001 From: uykhc Date: Mon, 9 Mar 2026 23:06:42 +0900 Subject: [PATCH 042/157] ci: configure CI/CD workflows for main and dev branches --- .github/workflows/cd-dev.yml | 20 +++++++++++++++++--- .github/workflows/cd.yml | 20 +++++++++++++++++--- .github/workflows/ci.yml | 32 ++++++++++++++++++++++---------- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 8afbb3a7..2b387548 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -1,18 +1,32 @@ name: CD Pipeline - Dev Deploy on: - push: + workflow_run: + workflows: ["CI"] + types: [completed] branches: - dev workflow_dispatch: +permissions: + contents: read + +concurrency: + group: cd-dev + cancel-in-progress: true + jobs: deploy: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest + env: + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} steps: - name: Checkout code uses: actions/checkout@v4 + with: + ref: ${{ env.DEPLOY_SHA }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -30,7 +44,7 @@ jobs: push: true tags: | docker.io/picokosmos/spring-app:dev - docker.io/picokosmos/spring-app:dev-${{ github.sha }} + docker.io/picokosmos/spring-app:dev-${{ env.DEPLOY_SHA }} cache-from: type=gha cache-to: type=gha,mode=max @@ -67,7 +81,7 @@ jobs: EOF # Pull latest docker-compose file - curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/dev/docker-compose.dev.yml -o docker-compose.yml + curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/${{ env.DEPLOY_SHA }}/docker-compose.dev.yml -o docker-compose.yml # Pull latest images and restart docker compose pull diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index dd1523dd..6f9fdecf 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,18 +1,32 @@ name: CD Pipeline - EC2 Deploy on: - push: + workflow_run: + workflows: ["CI"] + types: [completed] branches: - main workflow_dispatch: +permissions: + contents: read + +concurrency: + group: cd-main + cancel-in-progress: true + jobs: deploy: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest + env: + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} steps: - name: Checkout code uses: actions/checkout@v4 + with: + ref: ${{ env.DEPLOY_SHA }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -30,7 +44,7 @@ jobs: push: true tags: | docker.io/picokosmos/spring-app:latest - docker.io/picokosmos/spring-app:${{ github.sha }} + docker.io/picokosmos/spring-app:${{ env.DEPLOY_SHA }} cache-from: type=gha cache-to: type=gha,mode=max @@ -66,7 +80,7 @@ jobs: EOF # Pull latest docker-compose file - curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/main/docker-compose.prod.yml -o docker-compose.yml + curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/${{ env.DEPLOY_SHA }}/docker-compose.prod.yml -o docker-compose.yml # Pull latest images and restart docker compose pull diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16f3dde9..39d553cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: ci.yml +name: CI on: pull_request: branches: @@ -9,14 +9,21 @@ on: - main - dev +permissions: + contents: read + jobs: - build: + ci: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v4 + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v3 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -26,13 +33,18 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - - name: Build with Gradle - run: ./gradlew build -x test - - name: Run ktlint - run: ./gradlew ktlintCheck - continue-on-error: true + run: ./gradlew ktlintCheck --no-daemon - - name: Run integration tests - run: ./gradlew test - continue-on-error: true + - name: Run tests (includes integration tests) + run: ./gradlew test --no-daemon + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-test-report-${{ github.run_id }} + path: | + build/reports/tests/test + build/test-results/test + if-no-files-found: warn From 012d24ab2628699651d32854633b7b73f24f8f45 Mon Sep 17 00:00:00 2001 From: uykhc Date: Mon, 9 Mar 2026 23:28:34 +0900 Subject: [PATCH 043/157] test: import redis test container config in event integration test --- .../kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index eac9d87d..bd0fcc84 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.bean.override.mockito.MockitoBean @@ -32,6 +33,7 @@ import java.util.UUID @ActiveProfiles("test") @Testcontainers @AutoConfigureMockMvc +@Import(TestContainerConfig::class) class EventIntegrationTest @Autowired constructor( From 4874b982e142ca360279823e50384a01b124f3cc Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 14 Mar 2026 20:35:47 +0900 Subject: [PATCH 044/157] =?UTF-8?q?:construction=5Fworker:=20prod=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=8B=9C=20cancel-in-progress=20=EB=AF=B8?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6f9fdecf..0125175b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -13,7 +13,7 @@ permissions: concurrency: group: cd-main - cancel-in-progress: true + cancel-in-progress: false jobs: deploy: From f3bb9d6197b6f57e8196684cffb6683b86bd1fa4 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 00:01:37 +0900 Subject: [PATCH 045/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20RegistrationIn?= =?UTF-8?q?tegrationTest.kt=EC=97=90=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/RegistrationIntegrationTest.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index bc5c3bcf..cc17fa1b 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -16,6 +16,9 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -352,6 +355,53 @@ class RegistrationIntegrationTest assertTrue(hostParticipants.any { it.path("email").asText() == "guest@example.com" }) } + @Test + fun `동시에 여러 사용자가 신청해도 정원을 초과하지 않는다`() { + val capacity = 4 + val totalUsers = 15 + + val (host, _) = dataGenerator.generateUser() + val event = + createEvent( + createdBy = host.id!!, + title = "동시성 테스트 이벤트", + capacity = capacity, + waitlistEnabled = true, + ) + + val tokens = (1..totalUsers).map { dataGenerator.generateUser().second } + + val executor = Executors.newFixedThreadPool(totalUsers) + val startLatch = CountDownLatch(1) + val doneLatch = CountDownLatch(totalUsers) + + tokens.forEach { token -> + executor.submit { + try { + startLatch.await() + mvc.perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ) + } finally { + doneLatch.countDown() + } + } + } + + startLatch.countDown() + doneLatch.await(10, TimeUnit.SECONDS) + executor.shutdown() + + val confirmed = registrationRepository.countByEventIdAndStatus(event.id!!, RegistrationStatus.CONFIRMED) + val waitlisted = registrationRepository.countByEventIdAndStatus(event.id!!, RegistrationStatus.WAITLISTED) + + assertEquals(capacity.toLong(), confirmed, "CONFIRMED 수가 정원과 일치해야 합니다") + assertEquals((totalUsers - capacity).toLong(), waitlisted, "WAITLISTED 수가 정확해야 합니다") + } + private fun registerAsUser( eventPublicId: String, token: String, From 25326c2d97309501444f05537bab25fb6ebaed00 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 00:03:47 +0900 Subject: [PATCH 046/157] =?UTF-8?q?:bug:=20registration=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(read=20with=20lock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/RegistrationRepository.kt | 13 +++++++++++++ .../registration/service/RegistrationService.kt | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 81ceeddf..c65c817c 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -46,6 +46,19 @@ interface RegistrationRepository : registrationStatus: RegistrationStatus, ): Long + @Query( + """ + SELECT COUNT(*) + FROM registrations + WHERE event_id = :eventId AND status = :status + FOR SHARE + """, + ) + fun countByEventIdAndStatusWithLock( + eventId: Long, + status: RegistrationStatus, + ): Long + fun findByEventIdAndStatusOrderByCreatedAtAsc( eventID: Long, registrationStatus: RegistrationStatus, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 0d363f47..e6b4b627 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -71,7 +71,7 @@ RegistrationService( val capacity = lockedEvent.capacity ?: throw IllegalStateException("이벤트의 capacity가 설정되어 있지 않습니다.") val currentConfirmed = registrationRepository - .countByEventIdAndStatus(eventPk, RegistrationStatus.CONFIRMED) + .countByEventIdAndStatusWithLock(eventPk, RegistrationStatus.CONFIRMED) .toInt() val status = @@ -677,7 +677,7 @@ RegistrationService( val capacity = event.capacity ?: throw IllegalStateException("이벤트의 capacity가 설정되어 있지 않습니다.") val confirmed = - registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.CONFIRMED).toInt() + registrationRepository.countByEventIdAndStatusWithLock(eventId, RegistrationStatus.CONFIRMED).toInt() val available = capacity - confirmed if (available <= 0) return From 78b1d704abeb89b9c56f8c5b1e88980b8e2cda57 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 00:05:34 +0900 Subject: [PATCH 047/157] :art: ktlint --- .../wafflestudio/spring2025/RegistrationIntegrationTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index cc17fa1b..f94eb031 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -16,9 +16,6 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -37,6 +34,9 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.testcontainers.junit.jupiter.Testcontainers import java.time.Instant import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit @SpringBootTest @ActiveProfiles("test") From b9522ce7dc8566b08a5908d6f8add3c75f933947 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 15:26:42 +0900 Subject: [PATCH 048/157] =?UTF-8?q?:label:=20=EB=A7=88=EA=B0=90=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20dto=20not=20null=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/dto/request/CreateEventRequest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt index dbd1f9d6..a68e005a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt @@ -34,5 +34,5 @@ data class CreateEventRequest( description = "신청 마감 시간 (ISO-8601)", example = "2026-02-02T17:30:00Z", ) - val registrationEndsAt: Instant? = null, + val registrationEndsAt: Instant, ) From 993b03adbf0e65832049e9e7ae71b7d20384f465 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 15:32:53 +0900 Subject: [PATCH 049/157] =?UTF-8?q?:label:=20=EB=A7=88=EA=B0=90=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20dto=20not=20null=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/service/EventService.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index ae5af1f9..e97daed2 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -50,15 +50,17 @@ class EventService( capacity: Int?, waitlistEnabled: Boolean, registrationStartsAt: Instant?, - registrationEndsAt: Instant?, + registrationEndsAt: Instant, createdBy: Long, ): String { + val actualRegistrationStartsAt = registrationStartsAt ?: Instant.now() + validateCreateOrUpdate( title = title, startsAt = startsAt, endsAt = endsAt, capacity = capacity, - registrationStartsAt = registrationStartsAt, + registrationStartsAt = actualRegistrationStartsAt, registrationEndsAt = registrationEndsAt, ) @@ -72,7 +74,7 @@ class EventService( endsAt = endsAt, capacity = capacity, waitlistEnabled = waitlistEnabled, - registrationStartsAt = registrationStartsAt, + registrationStartsAt = actualRegistrationStartsAt, registrationEndsAt = registrationEndsAt, createdBy = createdBy, ) From 397b1a0ebbcb5e6eba1cabfce869fc97817ed57c Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 15:33:23 +0900 Subject: [PATCH 050/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EB=A7=88?= =?UTF-8?q?=EA=B0=90=EA=B8=B0=ED=95=9C=20dto=20not=20null=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/wafflestudio/spring2025/EventIntegrationTest.kt | 7 ++++++- .../wafflestudio/spring2025/RegistrationIntegrationTest.kt | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index bd0fcc84..3bbd6c4c 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -58,7 +58,7 @@ class EventIntegrationTest startsAt: Instant = Instant.now().plusSeconds(3600), endsAt: Instant = Instant.now().plusSeconds(7200), registrationStartsAt: Instant? = null, - registrationEndsAt: Instant? = null, + registrationEndsAt: Instant = Instant.now().plusSeconds(5400), ): Event = eventRepository.save( Event( @@ -177,6 +177,7 @@ class EventIntegrationTest waitlistEnabled = false, startsAt = Instant.now().minusSeconds(3600), endsAt = Instant.now().plusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), ) mvc @@ -199,6 +200,7 @@ class EventIntegrationTest waitlistEnabled = false, startsAt = null, endsAt = Instant.now().minusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), ) mvc @@ -221,6 +223,7 @@ class EventIntegrationTest waitlistEnabled = false, startsAt = Instant.now().plusSeconds(7200), endsAt = Instant.now().plusSeconds(3600), // startsAt 보다 이전 + registrationEndsAt = Instant.now().plusSeconds(5400), ) mvc @@ -242,6 +245,7 @@ class EventIntegrationTest capacity = 10, waitlistEnabled = false, registrationStartsAt = Instant.now().minusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), ) mvc @@ -309,6 +313,7 @@ class EventIntegrationTest startsAt = Instant.now().plusSeconds(3600), endsAt = Instant.now().plusSeconds(7200), registrationStartsAt = Instant.now().plusSeconds(5400), + registrationEndsAt = Instant.now().plusSeconds(7200), ) mvc diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index bc5c3bcf..8b9fddb4 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -415,7 +415,7 @@ class RegistrationIntegrationTest capacity: Int = 10, waitlistEnabled: Boolean = false, registrationStartsAt: Instant? = null, - registrationEndsAt: Instant? = null, + registrationEndsAt: Instant = Instant.now().plusSeconds(5400), ): Event = eventRepository.save( Event( From 3baa93ab6d3ea24ace52651989f70be2608389c8 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 15:51:37 +0900 Subject: [PATCH 051/157] :card_file_box: registrationEndsAt not null --- .../db/migration/V14__make_registration_ends_at_not_null.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql diff --git a/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql new file mode 100644 index 00000000..037eb91e --- /dev/null +++ b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql @@ -0,0 +1 @@ +ALTER TABLE events MODIFY registration_ends_at DATETIME(6) NOT NULL; \ No newline at end of file From f4bc95e1cad6eeacf04328a81e3f81841ab47453 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 17:54:23 +0900 Subject: [PATCH 052/157] :label: registrationEndsAt not null --- .../wafflestudio/spring2025/domain/event/model/Event.kt | 2 +- .../domain/registration/service/RegistrationService.kt | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt index 683ef8b8..3f635d9d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt @@ -26,7 +26,7 @@ class Event( @Column("registration_starts_at") var registrationStartsAt: Instant? = null, @Column("registration_ends_at") - var registrationEndsAt: Instant? = null, + var registrationEndsAt: Instant, @Column("created_by") var createdBy: Long, @CreatedDate diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 0d363f47..ac88918a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -604,14 +604,9 @@ RegistrationService( } private fun isRegistrationEnabled(event: Event): Boolean { - val registrationStartsAt = event.registrationStartsAt - val registrationEndsAt = event.registrationEndsAt val now = Instant.now() - - // null 정책: 시작/끝이 null이면 제한 없는 것으로 취급 - val afterStart = registrationStartsAt?.let { !now.isBefore(it) } ?: true - val beforeEnd = registrationEndsAt?.let { !now.isAfter(it) } ?: true - + val afterStart = event.registrationStartsAt?.let { !now.isBefore(it) } ?: true + val beforeEnd = !now.isAfter(event.registrationEndsAt) return afterStart && beforeEnd } From 816bf9e1bda5fb4f6338f4bc0ccd890d197b4e3e Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 17:58:37 +0900 Subject: [PATCH 053/157] =?UTF-8?q?:sparkles:=20policy=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=20event=20create,=20update=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/service/EventService.kt | 128 ++++++++---------- 1 file changed, 53 insertions(+), 75 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index e97daed2..4a1276d6 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -55,11 +55,12 @@ class EventService( ): String { val actualRegistrationStartsAt = registrationStartsAt ?: Instant.now() - validateCreateOrUpdate( + validateCreateConstraints(registrationEndsAt) + validateInvariantConstraints( title = title, + capacity = capacity, startsAt = startsAt, endsAt = endsAt, - capacity = capacity, registrationStartsAt = actualRegistrationStartsAt, registrationEndsAt = registrationEndsAt, ) @@ -325,35 +326,36 @@ class EventService( val previousCapacity = event.capacity val confirmedParticipants = countConfirmedParticipants(eventId) - validateParticipantAwareUpdate( - event = event, - registrationStartsAt = registrationStartsAt, - registrationEndsAt = registrationEndsAt, + validateCapacityUpdate( capacity = capacity, confirmedParticipants = confirmedParticipants, ) - title?.let { - if (it.isBlank()) throw EventValidationException(EventErrorCode.EVENT_TITLE_BLANK) - event.title = it.trim() - } + val mergedTitle = title?.trim() ?: event.title + val mergedCapacity = capacity ?: event.capacity + val mergedStartsAt = startsAt ?: event.startsAt + val mergedEndsAt = endsAt ?: event.endsAt + val mergedRegistrationStartsAt = registrationStartsAt ?: event.registrationStartsAt + val mergedRegistrationEndsAt = registrationEndsAt ?: event.registrationEndsAt + + validateInvariantConstraints( + title = mergedTitle, + capacity = mergedCapacity, + startsAt = mergedStartsAt, + endsAt = mergedEndsAt, + registrationStartsAt = mergedRegistrationStartsAt, + registrationEndsAt = mergedRegistrationEndsAt, + ) + + event.title = mergedTitle description?.let { event.description = it } location?.let { event.location = it } - startsAt?.let { event.startsAt = it } - endsAt?.let { event.endsAt = it } - capacity?.let { event.capacity = it } + event.startsAt = mergedStartsAt + event.endsAt = mergedEndsAt + event.capacity = mergedCapacity waitlistEnabled?.let { event.waitlistEnabled = it } - registrationStartsAt?.let { event.registrationStartsAt = it } - registrationEndsAt?.let { event.registrationEndsAt = it } - - validateCreateOrUpdate( - title = event.title, - startsAt = event.startsAt, - endsAt = event.endsAt, - capacity = event.capacity, - registrationStartsAt = event.registrationStartsAt, - registrationEndsAt = event.registrationEndsAt, - ) + event.registrationStartsAt = mergedRegistrationStartsAt + event.registrationEndsAt = mergedRegistrationEndsAt val savedEvent = eventRepository.save(event) if (isCapacityIncreased(previousCapacity, savedEvent.capacity)) { @@ -395,14 +397,22 @@ class EventService( } } - private fun validateCreateOrUpdate( + private fun validateCreateConstraints( + registrationEndsAt: Instant, + now: Instant = Instant.now(), + ) { + if (registrationEndsAt.isBefore(now)) { + throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_IN_PAST) + } + } + + private fun validateInvariantConstraints( title: String, + capacity: Int?, startsAt: Instant?, endsAt: Instant?, - capacity: Int?, registrationStartsAt: Instant?, - registrationEndsAt: Instant?, - now: Instant = Instant.now(), + registrationEndsAt: Instant, ) { // 제목 검증 if (title.isBlank()) { @@ -417,40 +427,25 @@ class EventService( throw EventValidationException(EventErrorCode.EVENT_CAPACITY_INVALID) } - // 모임 시간 검증 - if (startsAt != null && startsAt.isBefore(now)) { - throw EventValidationException(EventErrorCode.EVENT_STARTS_IN_PAST) - } - if (endsAt != null && endsAt.isBefore(now)) { - throw EventValidationException(EventErrorCode.EVENT_ENDS_IN_PAST) - } - if (startsAt != null && endsAt != null && !startsAt.isBefore(endsAt)) { + // 모임 시간 검증: 일정 시작 ≤ 일정 끝 + if (startsAt != null && endsAt != null && startsAt.isAfter(endsAt)) { throw EventValidationException(EventErrorCode.EVENT_TIME_RANGE_INVALID) } - // 신청 기간 검증 - if (registrationStartsAt != null && registrationStartsAt.isBefore(now)) { - throw EventValidationException(EventErrorCode.REGISTRATION_STARTS_IN_PAST) - } - if (registrationEndsAt != null && registrationEndsAt.isBefore(now)) { - throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_IN_PAST) - } - if (registrationStartsAt != null && - registrationEndsAt != null && - registrationStartsAt.isAfter(registrationEndsAt) - ) { + // 신청 기간 검증: 모집 시작 < 모집 마감 (strict) + if (registrationStartsAt != null && !registrationStartsAt.isBefore(registrationEndsAt)) { throw EventValidationException(EventErrorCode.REGISTRATION_TIME_RANGE_INVALID) } - if (registrationStartsAt != null && - startsAt != null && - registrationStartsAt.isAfter(startsAt) - ) { + // 모집 시작 ≤ 일정 시작 + if (registrationStartsAt != null && startsAt != null && registrationStartsAt.isAfter(startsAt)) { throw EventValidationException(EventErrorCode.REGISTRATION_STARTS_AFTER_EVENT_START) } - if (registrationEndsAt != null && - startsAt != null && - registrationEndsAt.isAfter(startsAt) - ) { + // 모집 마감 ≤ 일정 시작 + if (startsAt != null && registrationEndsAt.isAfter(startsAt)) { + throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_AFTER_EVENT_START) + } + // 모집 마감 ≤ 일정 끝 (행사시작 없을 때도 직접 체크) + if (endsAt != null && registrationEndsAt.isAfter(endsAt)) { throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_AFTER_EVENT_START) } } @@ -460,29 +455,12 @@ class EventService( .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED) .toInt() - private fun validateParticipantAwareUpdate( - event: Event, - registrationStartsAt: Instant?, - registrationEndsAt: Instant?, + private fun validateCapacityUpdate( capacity: Int?, confirmedParticipants: Int, - now: Instant = Instant.now(), ) { if (confirmedParticipants <= 0) return - if (registrationStartsAt != null && - event.registrationStartsAt != null && - registrationStartsAt.isAfter(event.registrationStartsAt) - ) { - throw EventValidationException(EventErrorCode.REGISTRATION_START_CANNOT_DELAY_WITH_PARTICIPANTS) - } - - if (registrationEndsAt != null && - registrationEndsAt.isBefore(now) - ) { - throw EventValidationException(EventErrorCode.REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS) - } - if (capacity != null && capacity < confirmedParticipants) { throw EventValidationException(EventErrorCode.CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS) } @@ -499,12 +477,12 @@ class EventService( confirmedCount: Int, waitlistEnabled: Boolean, registrationStartsAt: Instant?, - registrationEndsAt: Instant?, + registrationEndsAt: Instant, now: Instant = Instant.now(), ): CapabilitiesInfo { val withinWindow = (registrationStartsAt?.let { !now.isBefore(it) } ?: true) && - (registrationEndsAt?.let { !now.isAfter(it) } ?: true) + !now.isAfter(registrationEndsAt) val isFull = capacity != null && confirmedCount >= capacity From 7467d92d3de65fc0455a38f1ea57311c5cb98b41 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 17:58:51 +0900 Subject: [PATCH 054/157] =?UTF-8?q?:fire:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=97=90=EB=9F=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/exception/EventErrorCode.kt | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index 23b6dbcf..63e5fe40 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -48,16 +48,6 @@ enum class EventErrorCode( ), // 모임 시간 검증 - EVENT_STARTS_IN_PAST( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "모임 시작 시간이 유효하지 않습니다.", - message = "모임 시작 시간은\n현재 시간 이후여야 합니다.", - ), - EVENT_ENDS_IN_PAST( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "모임 종료 시간이 유효하지 않습니다.", - message = "모임 종료 시간은\n현재 시간 이후여야 합니다.", - ), EVENT_TIME_RANGE_INVALID( httpStatusCode = HttpStatus.BAD_REQUEST, title = "모임 시간이 유효하지 않습니다.", @@ -65,11 +55,6 @@ enum class EventErrorCode( ), // 신청 기간 검증 - REGISTRATION_STARTS_IN_PAST( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "신청 시작 시간이 유효하지 않습니다.", - message = "신청 시작 시간은\n현재 시간 이후여야 합니다.", - ), REGISTRATION_ENDS_IN_PAST( httpStatusCode = HttpStatus.BAD_REQUEST, title = "신청 마감 시간이 유효하지 않습니다.", @@ -91,16 +76,6 @@ enum class EventErrorCode( message = "신청 마감 시간이 모임 시작 시간보다\n빨라야 합니다.", ), - REGISTRATION_START_CANNOT_DELAY_WITH_PARTICIPANTS( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "Registration start cannot be delayed.", - message = "When participants exist, registration start cannot be moved later than the current value.", - ), - REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "Registration end cannot be advanced.", - message = "When participants exist, registration end cannot be moved earlier than the current value.", - ), CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS( httpStatusCode = HttpStatus.BAD_REQUEST, title = "Capacity cannot be decreased.", From ea3af1d97edacf2ed73f9a4d3e3de16dd9c2af53 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 17:59:32 +0900 Subject: [PATCH 055/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EC=9A=B4=20policy=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/EventIntegrationTest.kt | 87 +++++++++++-------- 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index 3bbd6c4c..00cfe698 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -168,8 +168,9 @@ class EventIntegrationTest } @Test - fun `모임 시작 시간이 과거이면 이벤트 생성 요청 시 400을 반환한다`() { + fun `모임 시작 시간이 신청 마감 시간보다 이르면 이벤트 생성 요청 시 400을 반환한다`() { val (_, token) = dataGenerator.generateUser() + // startsAt=now-3600 이면 registrationEndsAt(+5400) > startsAt(-3600) 위반 val request = CreateEventRequest( title = "정기 모임", @@ -187,30 +188,7 @@ class EventIntegrationTest .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("EVENT_STARTS_IN_PAST")) - } - - @Test - fun `모임 종료 시간이 과거이면 이벤트 생성 요청 시 400을 반환한다`() { - val (_, token) = dataGenerator.generateUser() - val request = - CreateEventRequest( - title = "정기 모임", - capacity = 10, - waitlistEnabled = false, - startsAt = null, - endsAt = Instant.now().minusSeconds(3600), - registrationEndsAt = Instant.now().plusSeconds(5400), - ) - - mvc - .perform( - post("/api/events") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(request)) - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("EVENT_ENDS_IN_PAST")) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_AFTER_EVENT_START")) } @Test @@ -237,8 +215,9 @@ class EventIntegrationTest } @Test - fun `신청 시작 시간이 과거이면 이벤트 생성 요청 시 400을 반환한다`() { + fun `신청 시작 시간이 과거여도 이벤트를 생성할 수 있다`() { val (_, token) = dataGenerator.generateUser() + // 과거 registrationStartsAt = 이미 모집이 시작된 것으로 허용 val request = CreateEventRequest( title = "정기 모임", @@ -254,8 +233,7 @@ class EventIntegrationTest .header("Authorization", "Bearer $token") .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("REGISTRATION_STARTS_IN_PAST")) + ).andExpect(status().isOk) } @Test @@ -825,8 +803,9 @@ class EventIntegrationTest } @Test - fun `모임 시작 시간을 과거로 수정 요청 시 400을 반환한다`() { + fun `모임 시작 시간을 신청 마감 시간보다 이전으로 수정하면 400을 반환한다`() { val (user, token) = dataGenerator.generateUser() + // 기본 registrationEndsAt=now+5400 → startsAt=now-3600 으로 변경하면 역전 val event = createEventInDb(createdBy = user.id!!) mvc @@ -836,7 +815,7 @@ class EventIntegrationTest .content(mapper.writeValueAsString(UpdateEventRequest(startsAt = Instant.now().minusSeconds(3600)))) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("EVENT_STARTS_IN_PAST")) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_AFTER_EVENT_START")) } @Test @@ -880,7 +859,7 @@ class EventIntegrationTest } @Test - fun `확정 참가자 있을 때 신청 시작 시간을 늦추면 400을 반환한다`() { + fun `확정 참가자 있어도 신청 시작 시간을 늦출 수 있다`() { val (host, token) = dataGenerator.generateUser() val (participant, _) = dataGenerator.generateUser() val event = @@ -900,15 +879,14 @@ class EventIntegrationTest .perform( put("/api/events/${event.publicId}") .header("Authorization", "Bearer $token") - // +3600 → +4500 으로 늦춤 (기존 값보다 이후, startsAt=+7200 이전) + // +3600 → +4500 으로 늦춤 — 기존 신청자 영향 없이 모집 중단 .content(mapper.writeValueAsString(UpdateEventRequest(registrationStartsAt = Instant.now().plusSeconds(4500)))) .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("REGISTRATION_START_CANNOT_DELAY_WITH_PARTICIPANTS")) + ).andExpect(status().isOk) } @Test - fun `확정 참가자 있을 때 신청 마감 시간을 현재 시각 이전으로 변경하면 400을 반환한다`() { + fun `확정 참가자 있어도 신청 마감 시간을 과거로 앞당길 수 있다`() { val (host, token) = dataGenerator.generateUser() val (participant, _) = dataGenerator.generateUser() val event = createEventInDb(createdBy = host.id!!, registrationEndsAt = Instant.now().plusSeconds(3600)) @@ -921,10 +899,47 @@ class EventIntegrationTest .perform( put("/api/events/${event.publicId}") .header("Authorization", "Bearer $token") + // 모집 마감을 현재 이전으로 앞당김 — 모집 중단, 기존 신청자 영향 없음 .content(mapper.writeValueAsString(UpdateEventRequest(registrationEndsAt = Instant.now().minusSeconds(60)))) .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + } + + @Test + fun `신청 시작 시간과 신청 마감 시간이 같으면 400을 반환한다`() { + val (_, token) = dataGenerator.generateUser() + val sameTime = Instant.now().plusSeconds(3600) + val request = + CreateEventRequest( + title = "정기 모임", + capacity = 10, + waitlistEnabled = false, + registrationStartsAt = sameTime, + registrationEndsAt = sameTime, + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("REGISTRATION_END_CANNOT_ADVANCE_WITH_PARTICIPANTS")) + .andExpect(jsonPath("$.code").value("REGISTRATION_TIME_RANGE_INVALID")) + } + + @Test + fun `일정 수정 시 신청 마감 시간을 과거로 앞당길 수 있다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!, registrationEndsAt = Instant.now().plusSeconds(3600)) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(registrationEndsAt = Instant.now().minusSeconds(60)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) } @Test From ab3839f9770465d8c1905bdd5ceb25bc47fd2343 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 18:14:59 +0900 Subject: [PATCH 056/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EC=9A=B4=20policy=EC=99=80=20=EB=A7=9E=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wafflestudio/spring2025/EventIntegrationTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index 00cfe698..251832f7 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -55,8 +55,8 @@ class EventIntegrationTest title: String = "테스트 이벤트", capacity: Int = 10, waitlistEnabled: Boolean = false, - startsAt: Instant = Instant.now().plusSeconds(3600), - endsAt: Instant = Instant.now().plusSeconds(7200), + startsAt: Instant = Instant.now().plusSeconds(7200), + endsAt: Instant = Instant.now().plusSeconds(10800), registrationStartsAt: Instant? = null, registrationEndsAt: Instant = Instant.now().plusSeconds(5400), ): Event = @@ -170,14 +170,14 @@ class EventIntegrationTest @Test fun `모임 시작 시간이 신청 마감 시간보다 이르면 이벤트 생성 요청 시 400을 반환한다`() { val (_, token) = dataGenerator.generateUser() - // startsAt=now-3600 이면 registrationEndsAt(+5400) > startsAt(-3600) 위반 + // registrationEndsAt(+5400) > startsAt(+3600) 위반 val request = CreateEventRequest( title = "정기 모임", capacity = 10, waitlistEnabled = false, - startsAt = Instant.now().minusSeconds(3600), - endsAt = Instant.now().plusSeconds(3600), + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), registrationEndsAt = Instant.now().plusSeconds(5400), ) From c6fafce4949090a4ab9016fe091ee8b98ed5a370 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 18:18:13 +0900 Subject: [PATCH 057/157] =?UTF-8?q?:memo:=20registrationStartsAt=20null=20?= =?UTF-8?q?=EC=8B=9C=20behavior=20swagger=EC=97=90=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/dto/request/CreateEventRequest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt index a68e005a..db6254ca 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt @@ -26,7 +26,7 @@ data class CreateEventRequest( @Schema(description = "대기 명단 사용 여부", example = "true") val waitlistEnabled: Boolean, @Schema( - description = "신청 시작 시간 (ISO-8601)", + description = "신청 시작 시간 (ISO-8601). null인 경우 요청 시점으로 자동 설정됩니다.", example = "2026-02-02T17:00:00Z", ) val registrationStartsAt: Instant? = null, From b54885e5ce1d9ba31f6a34cdb95b9898888a1aff Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 18:24:57 +0900 Subject: [PATCH 058/157] =?UTF-8?q?:card=5Ffile=5Fbox:=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=EC=97=90=20registrationEndsAt=20null=EC=9D=B8=20?= =?UTF-8?q?=ED=96=89=EC=9D=B4=20=EC=9E=88=EC=97=88=EB=8B=A4=EB=A9=B4=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V14__make_registration_ends_at_not_null.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql index 037eb91e..965183f4 100644 --- a/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql +++ b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql @@ -1 +1,2 @@ +DELETE FROM events WHERE registration_ends_at IS NULL; ALTER TABLE events MODIFY registration_ends_at DATETIME(6) NOT NULL; \ No newline at end of file From f59c20c5e7f6cf59df4f83554caa37c8993ccd07 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 19:46:51 +0900 Subject: [PATCH 059/157] =?UTF-8?q?:sparkles:=20event=20repository=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/repository/RegistrationRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 81ceeddf..2c8fe42c 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -12,6 +12,8 @@ interface RegistrationRepository : PagingAndSortingRepository { fun findByEventId(eventId: Long): List + fun deleteByEventId(eventId: Long) + fun findByRegistrationPublicId(registrationPublicId: String): Registration? @Query( From 40db64045a69de6fdd0b99e60f98661f52a0d058 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 19:47:19 +0900 Subject: [PATCH 060/157] =?UTF-8?q?:sparkles:=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EB=B0=8F=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/email/service/EmailService.kt | 30 ++++++ .../email/template/event-cancelled.html | 102 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/main/resources/com/wafflestudio/spring2025/common/email/template/event-cancelled.html diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index ebe46ada..57801bd5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -118,6 +118,17 @@ class EmailService( val description: String?, ) + data class EventCancellationEmailData( + val toEmail: String, + val name: String, + val eventTitle: String?, + val startsAt: Instant?, + val endsAt: Instant?, + val location: String?, + val description: String?, + val hostEmail: String, + ) + fun sendRegistrationStatusEmail(data: RegistrationStatusEmailData) { when (data.status) { RegistrationStatus.CONFIRMED -> { @@ -221,6 +232,25 @@ class EmailService( logger.info("신청 삭제 정보가 ${data.toEmail} 로 전달되었습니다.") } + fun sendEventCancellationEmail(data: EventCancellationEmailData) { + val htmlContent = + loadTemplate("event-cancelled.html") + .replace("{name}", data.name) + .replace("{eventTitle}", formatEventTitle(data.eventTitle)) + .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) + .replace("{location}", formatLocation(data.location)) + .replace("{hostEmail}", data.hostEmail) + .replace("{description}", formatDescription(data.description)) + + sendHtmlEmail( + to = data.toEmail, + subject = emailName("일정 취소", data.eventTitle), + htmlContent = htmlContent, + ) + + logger.info("일정 취소 정보가 ${data.toEmail} 로 전달되었습니다.") + } + fun sendWaitlistPromotionEmail( toEmail: String, eventTitle: String?, diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/event-cancelled.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/event-cancelled.html new file mode 100644 index 00000000..4d6fd334 --- /dev/null +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/event-cancelled.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + +
+ + + + + +
+ 모이밍 +
+ + + + + + +
+
+ + + + +
+ x-mark +
+
+
+ 안녕하세요 {name}님
+ 일정이 취소되었습니다. +
+ +
+
{eventTitle} +
+ + + + + + + + + + +
+ calendar + + 일시 {eventDateRange} +
+ map-pin + + 장소 {location} +
+
+ + + + + +
+ + + + + +
+ user + + 문의: + 일정 주최자에게 연락 바랍니다 ({hostEmail}) +
+
+ +
+ {description}
+ +
+ + + From 395acd34db1491f9241df8b010516af2c1590ed3 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:02:04 +0900 Subject: [PATCH 061/157] =?UTF-8?q?:fire:=20'=EC=B0=B8=EC=97=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9E=88=EC=9D=84=20=EC=8B=9C=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=B6=88=EA=B0=80'=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/exception/EventErrorCode.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index 63e5fe40..56bea72b 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -88,9 +88,4 @@ enum class EventErrorCode( title = "정원이 초과되었습니다.", message = "모임 정원이 가득 차\n더 이상 신청할 수 없습니다.", ), - EVENT_HAS_CONFIRMED_REGISTRATIONS( - httpStatusCode = HttpStatus.CONFLICT, - title = "확정된 참가자가 있습니다.", - message = "확정된 참가자가 있는 모임은 삭제할 수 없습니다.", - ), } From 20fd13c4fc2f30bbb825fd8c043fe8e68df9327b Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:02:13 +0900 Subject: [PATCH 062/157] =?UTF-8?q?:fire:=20'=EC=B0=B8=EC=97=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9E=88=EC=9D=84=20=EC=8B=9C=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=B6=88=EA=B0=80'=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/exception/EventException.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventException.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventException.kt index 365e87b2..28b6cdbf 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventException.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventException.kt @@ -19,8 +19,6 @@ class EventForbiddenException : EventException(error = EventErrorCode.NOT_EVENT_ class EventFullException : EventException(error = EventErrorCode.EVENT_FULL) -class EventHasConfirmedRegistrationsException : EventException(error = EventErrorCode.EVENT_HAS_CONFIRMED_REGISTRATIONS) - class EventValidationException( error: EventErrorCode, ) : EventException(error = error) From 5aaac9eec7558ab909da49895733d3d5f36cce3d Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:02:45 +0900 Subject: [PATCH 063/157] =?UTF-8?q?:sparkles:=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=EC=9E=90=20=EC=9E=88=EB=8A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=ED=97=88=EC=9A=A9=20=EB=B0=8F=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=95=8C=EB=A6=BC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/service/EventService.kt | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 4a1276d6..6dcb98fa 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -1,5 +1,6 @@ package com.wafflestudio.spring2025.domain.event.service +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.event.dto.response.CapabilitiesInfo import com.wafflestudio.spring2025.domain.event.dto.response.CreatorInfo @@ -12,7 +13,6 @@ import com.wafflestudio.spring2025.domain.event.dto.response.ViewerInfo import com.wafflestudio.spring2025.domain.event.dto.response.ViewerStatus import com.wafflestudio.spring2025.domain.event.exception.EventErrorCode import com.wafflestudio.spring2025.domain.event.exception.EventForbiddenException -import com.wafflestudio.spring2025.domain.event.exception.EventHasConfirmedRegistrationsException import com.wafflestudio.spring2025.domain.event.exception.EventNotFoundException import com.wafflestudio.spring2025.domain.event.exception.EventValidationException import com.wafflestudio.spring2025.domain.event.model.Event @@ -26,6 +26,8 @@ import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.Instant import java.util.UUID @@ -37,6 +39,7 @@ class EventService( private val waitlistReconciliationService: WaitlistReconciliationService, private val userRepository: UserRepository, private val imageService: ImageService, + private val emailService: EmailService, ) { /** * 일정 생성 @@ -364,23 +367,50 @@ class EventService( return savedEvent } + @Transactional fun delete( publicId: String, requesterId: Long, ) { val event = getEventByPublicId(publicId) requireCreator(event, requesterId) - val confirmedNumber = - registrationRepository - .countByEventIdAndStatus( - eventID = requireNotNull(event.id), - registrationStatus = RegistrationStatus.CONFIRMED, - ).toInt() - - if (confirmedNumber > 0) { - throw EventHasConfirmedRegistrationsException() - } else { - eventRepository.deleteById(requireNotNull(event.id)) + val eventId = requireNotNull(event.id) + + // 알림 대상: CONFIRMED + WAITLISTED (BANNED 제외) + val registrationsToNotify = + registrationRepository.findByEventId(eventId) + .filter { it.status == RegistrationStatus.CONFIRMED || it.status == RegistrationStatus.WAITLISTED } + + // 이메일 데이터 구성 (삭제 전에 user 정보 조회) + val hostUser = userRepository.findById(event.createdBy).orElseThrow { EventNotFoundException() } + val userIds = registrationsToNotify.mapNotNull { it.userId }.distinct() + val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } + + val emailDataList = + registrationsToNotify.mapNotNull { reg -> + val user = reg.userId?.let { usersById[it] } + val toEmail = user?.email ?: reg.guestEmail + if (toEmail.isNullOrBlank()) return@mapNotNull null + EmailService.EventCancellationEmailData( + toEmail = toEmail, + name = user?.name ?: reg.guestName ?: "참여자", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + description = event.description, + hostEmail = hostUser.email, + ) + } + + // FK 제약으로 인해 event 삭제 전 registrations 먼저 삭제 + registrationRepository.deleteByEventId(eventId) + eventRepository.deleteById(eventId) + + afterCommit { + emailDataList.forEach { data -> + emailService.sendEventCancellationEmail(data) + } } } @@ -471,6 +501,20 @@ class EventService( newCapacity: Int?, ): Boolean = previousCapacity != null && newCapacity != null && newCapacity > previousCapacity + private fun afterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + action() + return + } + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + action() + } + }, + ) + } + private fun buildCapabilities( viewerStatus: ViewerStatus, capacity: Int?, From c06ae7ab38d54ab7089b47912ea3ef3fb9a9f137 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:13:51 +0900 Subject: [PATCH 064/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/EventIntegrationTest.kt | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index 251832f7..22352811 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -11,6 +11,10 @@ import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -1122,7 +1126,7 @@ class EventIntegrationTest } @Test - fun `확정된 참가자가 있는 이벤트 삭제 요청 시 409를 반환한다`() { + fun `확정된 참가자가 있는 이벤트도 주최자가 삭제 시 204를 반환한다`() { val (user, token) = dataGenerator.generateUser() val (participant, _) = dataGenerator.generateUser() val event = createEventInDb(createdBy = user.id!!) @@ -1139,7 +1143,60 @@ class EventIntegrationTest .perform( delete("/api/events/${event.publicId}") .header("Authorization", "Bearer $token"), - ).andExpect(status().isConflict) - .andExpect(jsonPath("$.code").value("EVENT_HAS_CONFIRMED_REGISTRATIONS")) + ).andExpect(status().isNoContent) + } + + @Test + fun `이벤트 삭제 시 CONFIRMED와 WAITLISTED 신청자에게 이메일이 발송되고 BANNED에게는 발송되지 않는다`() { + val (host, token) = dataGenerator.generateUser() + val (confirmed, _) = dataGenerator.generateUser() + val (waitlisted, _) = dataGenerator.generateUser() + val (banned, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 2) + + registrationRepository.save(Registration(userId = confirmed.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = waitlisted.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + registrationRepository.save(Registration(userId = banned.id!!, eventId = event.id!!, status = RegistrationStatus.BANNED)) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + verify(emailService, times(2)).sendEventCancellationEmail(any()) + } + + @Test + fun `이벤트 삭제 시 관련 registration도 모두 삭제된다`() { + val (host, token) = dataGenerator.generateUser() + val (p1, _) = dataGenerator.generateUser() + val (p2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 1) + + registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + assertThat(registrationRepository.findByEventId(event.id!!)).isEmpty() + } + + @Test + fun `신청자가 없는 이벤트 삭제 시 이메일이 발송되지 않는다`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + verify(emailService, never()).sendEventCancellationEmail(any()) } } From 50c996283ff54d7c0497e4cafce90e34cca320c1 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:15:21 +0900 Subject: [PATCH 065/157] =?UTF-8?q?:bug:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=EC=9D=98=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/service/EventService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 6dcb98fa..5e11ba1c 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -375,6 +375,7 @@ class EventService( val event = getEventByPublicId(publicId) requireCreator(event, requesterId) val eventId = requireNotNull(event.id) + eventLockRepository.lockById(eventId) // 알림 대상: CONFIRMED + WAITLISTED (BANNED 제외) val registrationsToNotify = @@ -382,7 +383,7 @@ class EventService( .filter { it.status == RegistrationStatus.CONFIRMED || it.status == RegistrationStatus.WAITLISTED } // 이메일 데이터 구성 (삭제 전에 user 정보 조회) - val hostUser = userRepository.findById(event.createdBy).orElseThrow { EventNotFoundException() } + val hostUser = userRepository.findById(event.createdBy).orElse(null) val userIds = registrationsToNotify.mapNotNull { it.userId }.distinct() val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } @@ -399,7 +400,7 @@ class EventService( endsAt = event.endsAt, location = event.location, description = event.description, - hostEmail = hostUser.email, + hostEmail = hostUser?.email ?: "", ) } From f70d577b291dc368e684339618dcecb76e405a1a Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:15:44 +0900 Subject: [PATCH 066/157] :art: ktlint --- .../spring2025/domain/event/service/EventService.kt | 3 ++- .../com/wafflestudio/spring2025/EventIntegrationTest.kt | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 5e11ba1c..f9c19448 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -379,7 +379,8 @@ class EventService( // 알림 대상: CONFIRMED + WAITLISTED (BANNED 제외) val registrationsToNotify = - registrationRepository.findByEventId(eventId) + registrationRepository + .findByEventId(eventId) .filter { it.status == RegistrationStatus.CONFIRMED || it.status == RegistrationStatus.WAITLISTED } // 이메일 데이터 구성 (삭제 전에 user 정보 조회) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index 22352811..f6b790d0 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -1155,7 +1155,9 @@ class EventIntegrationTest val event = createEventInDb(createdBy = host.id!!, capacity = 2) registrationRepository.save(Registration(userId = confirmed.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) - registrationRepository.save(Registration(userId = waitlisted.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + registrationRepository.save( + Registration(userId = waitlisted.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED), + ) registrationRepository.save(Registration(userId = banned.id!!, eventId = event.id!!, status = RegistrationStatus.BANNED)) mvc From ac6ba6e0c7214ae027f920a638a1a068906ba523 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:34:02 +0900 Subject: [PATCH 067/157] =?UTF-8?q?:bug:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/repository/EventLockRepository.kt | 7 ++++--- .../spring2025/domain/event/service/EventService.kt | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt index 00c251ab..ef61bf11 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt @@ -7,11 +7,12 @@ import org.springframework.stereotype.Repository class EventLockRepository( private val jdbcTemplate: JdbcTemplate, ) { - fun lockById(eventId: Long) { - jdbcTemplate.queryForObject( + fun lockById(eventId: Long): Boolean { + val result = jdbcTemplate.query( "SELECT id FROM events WHERE id = ? FOR UPDATE", - Long::class.java, + { rs, _ -> rs.getLong("id") }, eventId, ) + return result.isNotEmpty() } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index f9c19448..40af92ef 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -325,7 +325,7 @@ class EventService( val event = getEventByPublicId(publicId) requireCreator(event, requesterId) val eventId = requireNotNull(event.id) { "Event id is null: publicId=$publicId" } - eventLockRepository.lockById(eventId) + if (!eventLockRepository.lockById(eventId)) throw EventNotFoundException() val previousCapacity = event.capacity val confirmedParticipants = countConfirmedParticipants(eventId) @@ -375,7 +375,7 @@ class EventService( val event = getEventByPublicId(publicId) requireCreator(event, requesterId) val eventId = requireNotNull(event.id) - eventLockRepository.lockById(eventId) + if (!eventLockRepository.lockById(eventId)) throw EventNotFoundException() // 알림 대상: CONFIRMED + WAITLISTED (BANNED 제외) val registrationsToNotify = From 1c6b207c316a2b4e52092f1efea89b873602ed2e Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:34:18 +0900 Subject: [PATCH 068/157] =?UTF-8?q?:bug:=20fk=20violation=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V14__make_registration_ends_at_not_null.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql index 965183f4..ea711986 100644 --- a/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql +++ b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql @@ -1,2 +1,3 @@ +DELETE r FROM registrations r INNER JOIN events e ON r.event_id = e.id WHERE e.registration_ends_at IS NULL; DELETE FROM events WHERE registration_ends_at IS NULL; ALTER TABLE events MODIFY registration_ends_at DATETIME(6) NOT NULL; \ No newline at end of file From 16d1dd375a449b396e0808957c2a5f652eff4a85 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:36:04 +0900 Subject: [PATCH 069/157] :art: ktlint --- .../domain/event/repository/EventLockRepository.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt index ef61bf11..503604de 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt @@ -8,11 +8,12 @@ class EventLockRepository( private val jdbcTemplate: JdbcTemplate, ) { fun lockById(eventId: Long): Boolean { - val result = jdbcTemplate.query( - "SELECT id FROM events WHERE id = ? FOR UPDATE", - { rs, _ -> rs.getLong("id") }, - eventId, - ) + val result = + jdbcTemplate.query( + "SELECT id FROM events WHERE id = ? FOR UPDATE", + { rs, _ -> rs.getLong("id") }, + eventId, + ) return result.isNotEmpty() } } From 20cd50578ca4ba908078b5192ceb07a9cdc48453 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:42:51 +0900 Subject: [PATCH 070/157] =?UTF-8?q?:sparkles:=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EA=B0=84=20=EB=82=B4,=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C=EC=B7=A8=EC=86=8C=EB=8A=94=20=ED=95=AD=EC=83=81=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/service/RegistrationService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index ac88918a..b0d82054 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -252,6 +252,10 @@ RegistrationService( } } + if (!isRegistrationEnabled(event)) { + throw RegistrationValidationException(RegistrationErrorCode.NOT_WITHIN_REGISTRATION_WINDOW) + } + val wasConfirmed = registration.status == RegistrationStatus.CONFIRMED registrationRepository.deleteById(requireNotNull(registration.id)) @@ -374,10 +378,6 @@ RegistrationService( registrationRepository.lockByRegistrationPublicId(registrationId) ?: throw RegistrationNotFoundException() - if (!isRegistrationEnabled(event)) { - throw RegistrationValidationException(RegistrationErrorCode.NOT_WITHIN_REGISTRATION_WINDOW) - } - val isHost = userId != null && userId == event.createdBy // val isRegistrant = userId != null && registration.userId == userId From e631987a9a2d2b70ec47da3597be238e4e2ec29d Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:49:12 +0900 Subject: [PATCH 071/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20/=20=EA=B0=95=EC=A0=9C=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=EC=8B=9C=EC=A0=90=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EC=B5=9C=EC=8B=A0=ED=99=94=EB=90=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/RegistrationIntegrationTest.kt | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index 8b9fddb4..35a43ecf 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -318,6 +318,187 @@ class RegistrationIntegrationTest assertEquals(RegistrationStatus.CONFIRMED, findRegistration(waitlistedRegistrationId).status) } + @Test + fun `모집 시작 전에는 자발적 취소를 할 수 없다`() { + val (host, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Before Registration Opens - Cancel", + registrationStartsAt = now.plusSeconds(3600), + registrationEndsAt = now.plusSeconds(7200), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + delete("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(DeleteRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `모집 마감 후에는 자발적 취소를 할 수 없다`() { + val (host, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "After Registration Closed - Cancel", + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + delete("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(DeleteRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `모집 시작 전에도 주최자는 BANNED 처리를 할 수 있다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban Before Registration Opens", + registrationStartsAt = now.plusSeconds(3600), + registrationEndsAt = now.plusSeconds(7200), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + patch("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertEquals(RegistrationStatus.BANNED, findRegistration(registration.registrationPublicId).status) + } + + @Test + fun `모집 마감 후에도 주최자는 BANNED 처리를 할 수 있다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban After Registration Closed", + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + patch("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertEquals(RegistrationStatus.BANNED, findRegistration(registration.registrationPublicId).status) + } + + @Test + fun `모집 마감 후 BANNED 처리 시 대기자는 승격되지 않는다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmedUser, _) = dataGenerator.generateUser() + val (waitlistedUser, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban After Closed - No Promotion", + capacity = 1, + waitlistEnabled = true, + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val confirmedReg = + registrationRepository.save( + Registration( + userId = confirmedUser.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + val waitlistedReg = + registrationRepository.save( + Registration( + userId = waitlistedUser.id!!, + eventId = event.id!!, + status = RegistrationStatus.WAITLISTED, + ), + ) + + mvc + .perform( + patch("/api/registrations/${confirmedReg.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertEquals(RegistrationStatus.BANNED, findRegistration(confirmedReg.registrationPublicId).status) + assertEquals(RegistrationStatus.WAITLISTED, findRegistration(waitlistedReg.registrationPublicId).status) + } + + // ───────────────────────────────────────────────────────────────────────── + @Test fun `주최자만 참여자 이메일을 볼 수 있고 참여자는 볼 수 없다`() { val (host, hostToken) = dataGenerator.generateUser() From b6d059565f66ed24639a85a3d55239808350cbe8 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:27:11 +0900 Subject: [PATCH 072/157] =?UTF-8?q?:sparkles:=20=EC=A0=95=EC=9B=90=20?= =?UTF-8?q?=EC=B6=95=EC=86=8C=20=EC=8B=9C=20=EB=8C=80=EA=B8=B0=20=EA=B0=95?= =?UTF-8?q?=EB=93=B1=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/email/service/EmailService.kt | 49 ++++++++++++++-- .../domain/event/service/EventService.kt | 30 +++------- .../repository/RegistrationRepository.kt | 12 ++++ .../service/RegistrationService.kt | 58 +++++++++++++++++++ .../service/WaitlistReconciliationService.kt | 1 + 5 files changed, 124 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index 57801bd5..e5c6162a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -129,6 +129,22 @@ class EmailService( val hostEmail: String, ) + data class DemotionEmailData( + val toEmail: String, + val name: String, + val eventTitle: String?, + val startsAt: Instant?, + val endsAt: Instant?, + val location: String?, + val newCapacity: Int, + val registrationStartsAt: Instant?, + val registrationEndsAt: Instant, + val description: String?, + val publicId: String, + val registrationPublicId: String, + val waitingNum: Int?, + ) + fun sendRegistrationStatusEmail(data: RegistrationStatusEmailData) { when (data.status) { RegistrationStatus.CONFIRMED -> { @@ -149,7 +165,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = emailName("참여 신청 확정", data.eventTitle), + subject = emailName("참여 확정", data.eventTitle), htmlContent = htmlContent, ) @@ -175,7 +191,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = emailName("참여 신청 대기", data.eventTitle), + subject = emailName("대기 등록", data.eventTitle), htmlContent = htmlContent, ) @@ -197,7 +213,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = emailName("참여 신청 강제 취소", data.eventTitle), + subject = emailName("강제 취소", data.eventTitle), htmlContent = htmlContent, ) @@ -251,6 +267,31 @@ class EmailService( logger.info("일정 취소 정보가 ${data.toEmail} 로 전달되었습니다.") } + fun sendDemotionEmail(data: DemotionEmailData) { + val htmlContent = + loadTemplate("registration-demoted.html") + .replace("{name}", data.name) + .replace("{waitingNum}", data.waitingNum?.toString() ?: "-") + .replace("{newCapacity}", data.newCapacity.toString()) + .replace("{eventTitle}", formatEventTitle(data.eventTitle)) + .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) + .replace("{location}", formatLocation(data.location)) + .replace( + "{registrationDateRange}", + formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), + ).replace("{description}", formatDescription(data.description)) + .replace("{publicId}", data.publicId) + .replace("{registrationPublicId}", data.registrationPublicId) + + sendHtmlEmail( + to = data.toEmail, + subject = emailName("대기자로 전환", data.eventTitle), + htmlContent = htmlContent, + ) + + logger.info("정원 축소 대기 변경 알림이 ${data.toEmail} 로 전달되었습니다.") + } + fun sendWaitlistPromotionEmail( toEmail: String, eventTitle: String?, @@ -285,7 +326,7 @@ class EmailService( sendHtmlEmail( to = toEmail, - subject = emailName("참여 신청 대기 후 확정", eventTitle), + subject = emailName("참여 확정", eventTitle), htmlContent = htmlContent, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 40af92ef..b1c13695 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -328,12 +328,6 @@ class EventService( if (!eventLockRepository.lockById(eventId)) throw EventNotFoundException() val previousCapacity = event.capacity - val confirmedParticipants = countConfirmedParticipants(eventId) - validateCapacityUpdate( - capacity = capacity, - confirmedParticipants = confirmedParticipants, - ) - val mergedTitle = title?.trim() ?: event.title val mergedCapacity = capacity ?: event.capacity val mergedStartsAt = startsAt ?: event.startsAt @@ -364,6 +358,9 @@ class EventService( if (isCapacityIncreased(previousCapacity, savedEvent.capacity)) { waitlistReconciliationService.reconcileWaitlist(eventId) } + if (isCapacityDecreased(previousCapacity, savedEvent.capacity)) { + waitlistReconciliationService.demoteToWaitlist(eventId, savedEvent.capacity!!) + } return savedEvent } @@ -482,27 +479,16 @@ class EventService( } } - private fun countConfirmedParticipants(eventId: Long): Int = - registrationRepository - .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED) - .toInt() - - private fun validateCapacityUpdate( - capacity: Int?, - confirmedParticipants: Int, - ) { - if (confirmedParticipants <= 0) return - - if (capacity != null && capacity < confirmedParticipants) { - throw EventValidationException(EventErrorCode.CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS) - } - } - private fun isCapacityIncreased( previousCapacity: Int?, newCapacity: Int?, ): Boolean = previousCapacity != null && newCapacity != null && newCapacity > previousCapacity + private fun isCapacityDecreased( + previousCapacity: Int?, + newCapacity: Int?, + ): Boolean = previousCapacity != null && newCapacity != null && newCapacity < previousCapacity + private fun afterCommit(action: () -> Unit) { if (!TransactionSynchronizationManager.isActualTransactionActive()) { action() diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 2c8fe42c..53c143b8 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -53,6 +53,18 @@ interface RegistrationRepository : registrationStatus: RegistrationStatus, ): List + @Query( + """ + SELECT * FROM registrations + WHERE event_id = :eventId AND status = :status + ORDER BY created_at DESC, id DESC + """, + ) + fun findByEventIdAndStatusOrderByCreatedAtDescIdDesc( + eventId: Long, + status: RegistrationStatus, + ): List + @Query( """ SELECT event_id AS eventId, COUNT(*) AS totalCount diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index b0d82054..c35e9e75 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -663,6 +663,64 @@ RegistrationService( } } + @Transactional + override fun demoteToWaitlist(eventId: Long, newCapacity: Int) { + eventLockRepository.lockById(eventId) + + val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } + + val confirmedRegs = + registrationRepository.findByEventIdAndStatusOrderByCreatedAtDescIdDesc( + eventId, + RegistrationStatus.CONFIRMED, + ) + + val excessCount = confirmedRegs.size - newCapacity + if (excessCount <= 0) return + + val toDemote = confirmedRegs.take(excessCount) + toDemote.forEach { it.status = RegistrationStatus.WAITLISTED } + registrationRepository.saveAll(toDemote) + + val demotedPublicIds = toDemote.map { it.registrationPublicId } + val waitlistPositions = + registrationRepository + .findWaitlistPositionsByRegistrationPublicIds( + eventId = eventId, + status = RegistrationStatus.WAITLISTED, + registrationPublicIds = demotedPublicIds, + ).associate { it.registrationPublicId to it.waitlistNumber.toInt() } + + val userIds = toDemote.mapNotNull { it.userId }.distinct() + val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } + + val emailDataList = + toDemote.mapNotNull { reg -> + val user = reg.userId?.let { usersById[it] } + val toEmail = user?.email ?: reg.guestEmail + if (toEmail.isNullOrBlank()) return@mapNotNull null + EmailService.DemotionEmailData( + toEmail = toEmail, + name = user?.name ?: reg.guestName ?: "참여자", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + newCapacity = newCapacity, + registrationStartsAt = event.registrationStartsAt, + registrationEndsAt = event.registrationEndsAt, + description = event.description, + publicId = event.publicId, + registrationPublicId = reg.registrationPublicId, + waitingNum = waitlistPositions[reg.registrationPublicId], + ) + } + + afterCommit { + emailDataList.forEach { emailService.sendDemotionEmail(it) } + } + } + @Transactional override fun reconcileWaitlist(eventId: Long) { eventLockRepository.lockById(eventId) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt index c2193228..0478e0c7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt @@ -2,4 +2,5 @@ package com.wafflestudio.spring2025.domain.registration.service interface WaitlistReconciliationService { fun reconcileWaitlist(eventId: Long) + fun demoteToWaitlist(eventId: Long, newCapacity: Int) } From 2146219cf97516840766bbe6ef151f40ba3030a4 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:27:29 +0900 Subject: [PATCH 073/157] =?UTF-8?q?:fire:=20=EC=A0=95=EC=9B=90=20=EC=B6=95?= =?UTF-8?q?=EC=86=8C=20=EB=B6=88=ED=97=88=ED=95=98=EB=8A=94=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/exception/EventErrorCode.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index 56bea72b..19743e40 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -76,12 +76,6 @@ enum class EventErrorCode( message = "신청 마감 시간이 모임 시작 시간보다\n빨라야 합니다.", ), - CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "Capacity cannot be decreased.", - message = "Capacity cannot be reduced below the current confirmed participant count.", - ), - // 23xx - Conflict EVENT_FULL( httpStatusCode = HttpStatus.CONFLICT, From f0462fa73bb78a7ac7a73084609e44bca53f94c1 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:27:44 +0900 Subject: [PATCH 074/157] =?UTF-8?q?:sparkles:=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=EA=B0=95=EB=93=B1=20=EB=A9=94=EC=9D=BC=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../email/template/registration-demoted.html | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html new file mode 100644 index 00000000..de5ac2a6 --- /dev/null +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html @@ -0,0 +1,120 @@ + + + + + + + + + + + + +
+ + + + + +
+ 모이밍 +
+ + + + + + +
+
+ + + + +
+ loader-mark +
+
+
+ 안녕하세요 {name}님
+ {waitingNum}번째 대기로 변경되었습니다. +
+ +
+
{eventTitle} +
+ + + + + + + + + + + + + + +
+ calendar + + 일시 {eventDateRange} +
+ map-pin + + 장소 {location} +
+ user + + 정원 {newCapacity}명 +
+
+ ›
+
+ + + + + +
+ + + + + +
+ clock + + 신청 기간: + {registrationDateRange} +
+
+ +
+ {description}
+ + + 취소하기 + +
+ + + From 6907d4f14d71b96e96b0458b49c7b91544f17a0f Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:36:20 +0900 Subject: [PATCH 075/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EA=B0=95?= =?UTF-8?q?=EB=93=B1=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/EventIntegrationTest.kt | 114 ++++++++++++++++-- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index f6b790d0..832e3e0b 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -947,25 +947,121 @@ class EventIntegrationTest } @Test - fun `확정 참가자 수보다 정원을 줄이면 400을 반환한다`() { - val (host, token) = dataGenerator.generateUser() - val (p1, _) = dataGenerator.generateUser() - val (p2, _) = dataGenerator.generateUser() - val (p3, _) = dataGenerator.generateUser() - val event = createEventInDb(createdBy = host.id!!, capacity = 10) + fun `정원을 확정 참가자 수보다 줄이면 초과 참가자가 대기로 강등된다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (p1, p1Token) = dataGenerator.generateUser() + val (p2, p2Token) = dataGenerator.generateUser() + val (p3, p3Token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(10) registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(10) registrationRepository.save(Registration(userId = p3.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + // capacity 10 → 2: 확정자 3명 중 가장 최근 확정된 p3 1명이 강등 mvc .perform( put("/api/events/${event.publicId}") - .header("Authorization", "Bearer $token") + .header("Authorization", "Bearer $hostToken") .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 2))) .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS")) + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capacity").value(2)) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p1Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p2Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p3Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 축소 시 가장 최근 확정된 참가자부터 강등된다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (early, earlyToken) = dataGenerator.generateUser() + val (late, lateToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = early.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(20) + registrationRepository.save(Registration(userId = late.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + + // capacity 10 → 1: 더 늦게 확정된 late만 강등 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $earlyToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $lateToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 축소로 강등 시 기존 대기자는 영향 없다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmed, _) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 5, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = confirmed.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = waiter.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + // confirmed=1 == newCapacity=1 → 강등 없음, 기존 대기자도 그대로 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $waiterToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 축소로 강등된 참가자에게 이메일이 발송된다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (p1, _) = dataGenerator.generateUser() + val (p2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + + // capacity 10 → 1: 1명 강등 → sendDemotionEmail 1회 호출 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + verify(emailService, times(1)).sendDemotionEmail(any()) } @Test From 12caafa225287a7e7e93201fb25af19034019d53 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:36:30 +0900 Subject: [PATCH 076/157] :art: ktlint --- .../domain/registration/service/RegistrationService.kt | 5 ++++- .../registration/service/WaitlistReconciliationService.kt | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index c35e9e75..ebe1b326 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -664,7 +664,10 @@ RegistrationService( } @Transactional - override fun demoteToWaitlist(eventId: Long, newCapacity: Int) { + override fun demoteToWaitlist( + eventId: Long, + newCapacity: Int, + ) { eventLockRepository.lockById(eventId) val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt index 0478e0c7..07110e7c 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt @@ -2,5 +2,9 @@ package com.wafflestudio.spring2025.domain.registration.service interface WaitlistReconciliationService { fun reconcileWaitlist(eventId: Long) - fun demoteToWaitlist(eventId: Long, newCapacity: Int) + + fun demoteToWaitlist( + eventId: Long, + newCapacity: Int, + ) } From fe21da7784036e945f12a185a5f60eeed4b32e58 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:45:20 +0900 Subject: [PATCH 077/157] =?UTF-8?q?:bug:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/service/EventService.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index b1c13695..90636a67 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -322,10 +322,11 @@ class EventService( registrationEndsAt: Instant?, requesterId: Long, ): Event { - val event = getEventByPublicId(publicId) - requireCreator(event, requesterId) - val eventId = requireNotNull(event.id) { "Event id is null: publicId=$publicId" } + val eventForAuth = getEventByPublicId(publicId) + requireCreator(eventForAuth, requesterId) + val eventId = requireNotNull(eventForAuth.id) { "Event id is null: publicId=$publicId" } if (!eventLockRepository.lockById(eventId)) throw EventNotFoundException() + val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } val previousCapacity = event.capacity val mergedTitle = title?.trim() ?: event.title From f52e405471a4241fe29eb7d201dc3ddd5b4ff66c Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:55:02 +0900 Subject: [PATCH 078/157] =?UTF-8?q?:card=5Ffile=5Fbox:=20event=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=A0=84=EC=97=90=20registration=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20(fk)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V14__make_registration_ends_at_not_null.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql index 965183f4..9cdb2d8e 100644 --- a/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql +++ b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql @@ -1,2 +1,3 @@ +DELETE r FROM registrations r INNER JOIN events e ON r.event_id = e.id WHERE e.registration_ends_at IS NULL; DELETE FROM events WHERE registration_ends_at IS NULL; -ALTER TABLE events MODIFY registration_ends_at DATETIME(6) NOT NULL; \ No newline at end of file +ALTER TABLE events MODIFY registration_ends_at DATETIME(6) NOT NULL; From f8605f00de8b2b2a872b90d72a25002c2d31691e Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 17 Mar 2026 00:26:15 +0900 Subject: [PATCH 079/157] =?UTF-8?q?:bug:=20=EB=8D=B0=EB=93=9C=EB=9D=BD=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RegistrationService.kt | 12 ++++++++-- .../spring2025/RegistrationIntegrationTest.kt | 22 +++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index e6b4b627..1a784680 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -232,12 +232,20 @@ RegistrationService( guestEmail: String?, registrationPublicId: String, ) { - val registration = + // eventId를 얻기 위한 미리보기 조회 (락 없음) + val preview = registrationRepository.findByRegistrationPublicId(registrationPublicId) ?: throw RegistrationNotFoundException() + // create()와 동일한 락 순서(event → registration)로 통일해 데드락 방지 + eventLockRepository.lockById(preview.eventId) val event = - eventRepository.findById(registration.eventId).orElseThrow { EventNotFoundException() } + eventRepository.findById(preview.eventId).orElseThrow { EventNotFoundException() } + + // event 락 획득 후 registration 행 락 + val registration = + registrationRepository.lockByRegistrationPublicId(registrationPublicId) + ?: throw RegistrationNotFoundException() if (userId != null) { if (registration.userId != userId) { diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index f94eb031..5ff2ad26 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -34,6 +34,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.testcontainers.junit.jupiter.Testcontainers import java.time.Instant import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -374,17 +375,21 @@ class RegistrationIntegrationTest val executor = Executors.newFixedThreadPool(totalUsers) val startLatch = CountDownLatch(1) val doneLatch = CountDownLatch(totalUsers) + val statusCodes = CopyOnWriteArrayList() tokens.forEach { token -> executor.submit { try { startLatch.await() - mvc.perform( - post("/api/events/${event.publicId}/registrations") - .header("Authorization", "Bearer $token") - .content(mapper.writeValueAsString(CreateRegistrationRequest())) - .contentType(MediaType.APPLICATION_JSON), - ) + val result = + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andReturn() + statusCodes.add(result.response.status) } finally { doneLatch.countDown() } @@ -392,9 +397,12 @@ class RegistrationIntegrationTest } startLatch.countDown() - doneLatch.await(10, TimeUnit.SECONDS) + val completed = doneLatch.await(10, TimeUnit.SECONDS) executor.shutdown() + assertTrue(completed, "모든 요청이 타임아웃(10초) 내에 완료되어야 합니다") + assertTrue(statusCodes.all { it == 200 }, "모든 요청이 HTTP 200으로 성공해야 합니다: $statusCodes") + val confirmed = registrationRepository.countByEventIdAndStatus(event.id!!, RegistrationStatus.CONFIRMED) val waitlisted = registrationRepository.countByEventIdAndStatus(event.id!!, RegistrationStatus.WAITLISTED) From 8a77960045c1e5f5785ad00c2c76e8347b711c02 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 17 Mar 2026 00:34:38 +0900 Subject: [PATCH 080/157] =?UTF-8?q?:memo:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=AA=85=ED=99=95=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/exception/EventErrorCode.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index 63e5fe40..555a7eb6 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -75,6 +75,11 @@ enum class EventErrorCode( title = "신청 기간이 유효하지 않습니다.", message = "신청 마감 시간이 모임 시작 시간보다\n빨라야 합니다.", ), + REGISTRATION_ENDS_AFTER_EVENT_END( + httpStatusCode = HttpStatus.BAD_REQUEST, + title = "신청 기간이 유효하지 않습니다.", + message = "신청 마감 시간이 모임 종료 시간보다\n빨라야 합니다.", + ), CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS( httpStatusCode = HttpStatus.BAD_REQUEST, From 6217ad54113f8a323dcaa560decb1e82bf2ba88c Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 17 Mar 2026 00:35:06 +0900 Subject: [PATCH 081/157] :label: registrationEndsAt not null --- .../wafflestudio/spring2025/domain/event/dto/core/EventDto.kt | 4 ++-- .../domain/event/dto/response/EventDetailResponse.kt | 4 ++-- .../domain/event/dto/response/MyEventsInfiniteResponse.kt | 2 +- .../domain/event/dto/response/UpdateEventResponse.kt | 2 +- .../spring2025/domain/event/service/EventService.kt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt index 6b0259f8..2b4765a2 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt @@ -24,7 +24,7 @@ data class EventDto( @Schema(description = "신청 시작 시간 (epoch milliseconds)") val registrationStartsAt: Long?, @Schema(description = "신청 마감 시간 (epoch milliseconds)") - val registrationEndsAt: Long?, + val registrationEndsAt: Long, @Schema(description = "작성자 ID") val createdBy: Long, @Schema(description = "생성 일시 (epoch milliseconds)") @@ -42,7 +42,7 @@ data class EventDto( capacity = event.capacity, waitlistEnabled = event.waitlistEnabled, registrationStartsAt = event.registrationStartsAt?.toEpochMilli(), - registrationEndsAt = event.registrationEndsAt?.toEpochMilli(), + registrationEndsAt = event.registrationEndsAt.toEpochMilli(), createdBy = event.createdBy, createdAt = event.createdAt?.toEpochMilli(), updatedAt = event.updatedAt?.toEpochMilli(), diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt index d2850400..08d7cccd 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt @@ -37,8 +37,8 @@ data class EventInfo( val totalApplicants: Int, @Schema(description = "신청 시작 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z", nullable = true) val registrationStartsAt: Instant?, - @Schema(description = "신청 마감 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z", nullable = true) - val registrationEndsAt: Instant?, + @Schema(description = "신청 마감 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z") + val registrationEndsAt: Instant, ) @Schema(description = "생성자 정보 블록") diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt index 26d15f6c..a1b21d36 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt @@ -14,7 +14,7 @@ data class MyEventResponse( val startsAt: Instant?, val endsAt: Instant?, val registrationStartsAt: Instant?, - val registrationEndsAt: Instant?, + val registrationEndsAt: Instant, val capacity: Int?, val totalApplicants: Int, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt index cad30758..6b0a937d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt @@ -12,7 +12,7 @@ data class UpdateEventResponse( val capacity: Int?, val waitlistEnabled: Boolean, val registrationStartsAt: Instant?, - val registrationEndsAt: Instant?, + val registrationEndsAt: Instant, ) { companion object { fun from(event: Event): UpdateEventResponse = diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 4a1276d6..8d0b03b5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -446,7 +446,7 @@ class EventService( } // 모집 마감 ≤ 일정 끝 (행사시작 없을 때도 직접 체크) if (endsAt != null && registrationEndsAt.isAfter(endsAt)) { - throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_AFTER_EVENT_START) + throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_AFTER_EVENT_END) } } From a57f1687c63b73b21d2c2024998795052a9c79e3 Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 17 Mar 2026 02:25:32 +0900 Subject: [PATCH 082/157] =?UTF-8?q?EventService=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=EB=B6=80=ED=84=B0=20=EB=9D=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20getEventByPublicId=EB=A5=BC=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20publicId=20FOR=20UPDATE=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/repository/EventLockRepository.kt | 7 +++++++ .../spring2025/domain/event/service/EventService.kt | 5 ++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt index 503604de..14945fbc 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt @@ -7,6 +7,13 @@ import org.springframework.stereotype.Repository class EventLockRepository( private val jdbcTemplate: JdbcTemplate, ) { + fun lockIdByPublicId(publicId: String): Long? = + jdbcTemplate.query( + "SELECT id FROM events WHERE public_id = ? FOR UPDATE", + { rs, _ -> rs.getLong("id") }, + publicId, + ).firstOrNull() + fun lockById(eventId: Long): Boolean { val result = jdbcTemplate.query( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 40af92ef..f8c3c61e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -372,10 +372,9 @@ class EventService( publicId: String, requesterId: Long, ) { - val event = getEventByPublicId(publicId) + val eventId = eventLockRepository.lockIdByPublicId(publicId) ?: throw EventNotFoundException() + val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } requireCreator(event, requesterId) - val eventId = requireNotNull(event.id) - if (!eventLockRepository.lockById(eventId)) throw EventNotFoundException() // 알림 대상: CONFIRMED + WAITLISTED (BANNED 제외) val registrationsToNotify = From 271cffa1a51332fa1baf0c0e1c6f68380660a83a Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 17 Mar 2026 04:14:46 +0900 Subject: [PATCH 083/157] fix(registration): lock event before cancel-window check in deleteInternal --- .../registration/service/RegistrationService.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index b0d82054..b60ac40a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -232,12 +232,20 @@ RegistrationService( guestEmail: String?, registrationPublicId: String, ) { - val registration = + // eventId를 얻기 위한 미리보기 조회 (락 없음) + val preview = registrationRepository.findByRegistrationPublicId(registrationPublicId) ?: throw RegistrationNotFoundException() + // 이벤트 업데이트와 경합 시 취소 가능 시점 판정을 일관되게 하기 위해 선락 + eventLockRepository.lockById(preview.eventId) val event = - eventRepository.findById(registration.eventId).orElseThrow { EventNotFoundException() } + eventRepository.findById(preview.eventId).orElseThrow { EventNotFoundException() } + + // 이벤트 락 획득 후 registration 행 락 + val registration = + registrationRepository.lockByRegistrationPublicId(registrationPublicId) + ?: throw RegistrationNotFoundException() if (userId != null) { if (registration.userId != userId) { From 8507638b0616e59ab068c759deb5fc45a858b379 Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 17 Mar 2026 04:19:34 +0900 Subject: [PATCH 084/157] fix(registration): unify delete lock order to event->registration to avoid deadlocks --- .../registration/service/RegistrationService.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index ebe1b326..e685fc15 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -232,12 +232,20 @@ RegistrationService( guestEmail: String?, registrationPublicId: String, ) { - val registration = + // eventId lookup without lock + val preview = registrationRepository.findByRegistrationPublicId(registrationPublicId) ?: throw RegistrationNotFoundException() + // Lock event first to keep lock ordering consistent and avoid deadlocks. + eventLockRepository.lockById(preview.eventId) val event = - eventRepository.findById(registration.eventId).orElseThrow { EventNotFoundException() } + eventRepository.findById(preview.eventId).orElseThrow { EventNotFoundException() } + + // Lock registration row after event lock. + val registration = + registrationRepository.lockByRegistrationPublicId(registrationPublicId) + ?: throw RegistrationNotFoundException() if (userId != null) { if (registration.userId != userId) { From 0a6dfa80cd137ee35c33d51161007a4e756b6a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Tue, 17 Mar 2026 09:22:38 +0900 Subject: [PATCH 085/157] :art: ktlint --- .../domain/event/repository/EventLockRepository.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt index 14945fbc..e9ff2f04 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt @@ -8,11 +8,12 @@ class EventLockRepository( private val jdbcTemplate: JdbcTemplate, ) { fun lockIdByPublicId(publicId: String): Long? = - jdbcTemplate.query( - "SELECT id FROM events WHERE public_id = ? FOR UPDATE", - { rs, _ -> rs.getLong("id") }, - publicId, - ).firstOrNull() + jdbcTemplate + .query( + "SELECT id FROM events WHERE public_id = ? FOR UPDATE", + { rs, _ -> rs.getLong("id") }, + publicId, + ).firstOrNull() fun lockById(eventId: Long): Boolean { val result = From 8699cdd898e13144685511cce0ccb7e14b5bea33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Tue, 17 Mar 2026 09:27:41 +0900 Subject: [PATCH 086/157] =?UTF-8?q?:bug:=20=EB=8D=B0=EB=93=9C=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/service/RegistrationService.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index ac88918a..64f5734f 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -252,6 +252,8 @@ RegistrationService( } } + eventLockRepository.lockById(registration.eventId) + val wasConfirmed = registration.status == RegistrationStatus.CONFIRMED registrationRepository.deleteById(requireNotNull(registration.id)) From 8504814ea74a3723f488860c083486f105d0dcc1 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:42:51 +0900 Subject: [PATCH 087/157] =?UTF-8?q?:sparkles:=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EA=B0=84=20=EB=82=B4,=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C=EC=B7=A8=EC=86=8C=EB=8A=94=20=ED=95=AD=EC=83=81=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt --- .../domain/registration/service/RegistrationService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 64f5734f..e4207cde 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -254,6 +254,10 @@ RegistrationService( eventLockRepository.lockById(registration.eventId) + if (!isRegistrationEnabled(event)) { + throw RegistrationValidationException(RegistrationErrorCode.NOT_WITHIN_REGISTRATION_WINDOW) + } + val wasConfirmed = registration.status == RegistrationStatus.CONFIRMED registrationRepository.deleteById(requireNotNull(registration.id)) @@ -376,10 +380,6 @@ RegistrationService( registrationRepository.lockByRegistrationPublicId(registrationId) ?: throw RegistrationNotFoundException() - if (!isRegistrationEnabled(event)) { - throw RegistrationValidationException(RegistrationErrorCode.NOT_WITHIN_REGISTRATION_WINDOW) - } - val isHost = userId != null && userId == event.createdBy // val isRegistrant = userId != null && registration.userId == userId From fa609ed50a38fe0e9118e78d5a658ba696034f1a Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 20:49:12 +0900 Subject: [PATCH 088/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20/=20=EA=B0=95=EC=A0=9C=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=EC=8B=9C=EC=A0=90=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EC=B5=9C=EC=8B=A0=ED=99=94=EB=90=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/RegistrationIntegrationTest.kt | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index 8b9fddb4..35a43ecf 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -318,6 +318,187 @@ class RegistrationIntegrationTest assertEquals(RegistrationStatus.CONFIRMED, findRegistration(waitlistedRegistrationId).status) } + @Test + fun `모집 시작 전에는 자발적 취소를 할 수 없다`() { + val (host, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Before Registration Opens - Cancel", + registrationStartsAt = now.plusSeconds(3600), + registrationEndsAt = now.plusSeconds(7200), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + delete("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(DeleteRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `모집 마감 후에는 자발적 취소를 할 수 없다`() { + val (host, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "After Registration Closed - Cancel", + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + delete("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(DeleteRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `모집 시작 전에도 주최자는 BANNED 처리를 할 수 있다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban Before Registration Opens", + registrationStartsAt = now.plusSeconds(3600), + registrationEndsAt = now.plusSeconds(7200), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + patch("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertEquals(RegistrationStatus.BANNED, findRegistration(registration.registrationPublicId).status) + } + + @Test + fun `모집 마감 후에도 주최자는 BANNED 처리를 할 수 있다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban After Registration Closed", + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + patch("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertEquals(RegistrationStatus.BANNED, findRegistration(registration.registrationPublicId).status) + } + + @Test + fun `모집 마감 후 BANNED 처리 시 대기자는 승격되지 않는다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmedUser, _) = dataGenerator.generateUser() + val (waitlistedUser, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban After Closed - No Promotion", + capacity = 1, + waitlistEnabled = true, + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val confirmedReg = + registrationRepository.save( + Registration( + userId = confirmedUser.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + val waitlistedReg = + registrationRepository.save( + Registration( + userId = waitlistedUser.id!!, + eventId = event.id!!, + status = RegistrationStatus.WAITLISTED, + ), + ) + + mvc + .perform( + patch("/api/registrations/${confirmedReg.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertEquals(RegistrationStatus.BANNED, findRegistration(confirmedReg.registrationPublicId).status) + assertEquals(RegistrationStatus.WAITLISTED, findRegistration(waitlistedReg.registrationPublicId).status) + } + + // ───────────────────────────────────────────────────────────────────────── + @Test fun `주최자만 참여자 이메일을 볼 수 있고 참여자는 볼 수 없다`() { val (host, hostToken) = dataGenerator.generateUser() From 71374d63df49359582a8bbce5dab64d6c880b778 Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 17 Mar 2026 04:14:46 +0900 Subject: [PATCH 089/157] fix(registration): lock event before cancel-window check in deleteInternal --- .../registration/service/RegistrationService.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index e4207cde..ab77a5c1 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -232,12 +232,20 @@ RegistrationService( guestEmail: String?, registrationPublicId: String, ) { - val registration = + // eventId를 얻기 위한 미리보기 조회 (락 없음) + val preview = registrationRepository.findByRegistrationPublicId(registrationPublicId) ?: throw RegistrationNotFoundException() + // 이벤트 업데이트와 경합 시 취소 가능 시점 판정을 일관되게 하기 위해 선락 + eventLockRepository.lockById(preview.eventId) val event = - eventRepository.findById(registration.eventId).orElseThrow { EventNotFoundException() } + eventRepository.findById(preview.eventId).orElseThrow { EventNotFoundException() } + + // 이벤트 락 획득 후 registration 행 락 + val registration = + registrationRepository.lockByRegistrationPublicId(registrationPublicId) + ?: throw RegistrationNotFoundException() if (userId != null) { if (registration.userId != userId) { From e2c2172feffec9eb09dc73ee6318477b27cf4ba7 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:27:11 +0900 Subject: [PATCH 090/157] =?UTF-8?q?:sparkles:=20=EC=A0=95=EC=9B=90=20?= =?UTF-8?q?=EC=B6=95=EC=86=8C=20=EC=8B=9C=20=EB=8C=80=EA=B8=B0=20=EA=B0=95?= =?UTF-8?q?=EB=93=B1=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/email/service/EmailService.kt | 49 ++++++++++++++-- .../domain/event/service/EventService.kt | 30 +++------- .../repository/RegistrationRepository.kt | 12 ++++ .../service/RegistrationService.kt | 58 +++++++++++++++++++ .../service/WaitlistReconciliationService.kt | 1 + 5 files changed, 124 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index 57801bd5..e5c6162a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -129,6 +129,22 @@ class EmailService( val hostEmail: String, ) + data class DemotionEmailData( + val toEmail: String, + val name: String, + val eventTitle: String?, + val startsAt: Instant?, + val endsAt: Instant?, + val location: String?, + val newCapacity: Int, + val registrationStartsAt: Instant?, + val registrationEndsAt: Instant, + val description: String?, + val publicId: String, + val registrationPublicId: String, + val waitingNum: Int?, + ) + fun sendRegistrationStatusEmail(data: RegistrationStatusEmailData) { when (data.status) { RegistrationStatus.CONFIRMED -> { @@ -149,7 +165,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = emailName("참여 신청 확정", data.eventTitle), + subject = emailName("참여 확정", data.eventTitle), htmlContent = htmlContent, ) @@ -175,7 +191,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = emailName("참여 신청 대기", data.eventTitle), + subject = emailName("대기 등록", data.eventTitle), htmlContent = htmlContent, ) @@ -197,7 +213,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = emailName("참여 신청 강제 취소", data.eventTitle), + subject = emailName("강제 취소", data.eventTitle), htmlContent = htmlContent, ) @@ -251,6 +267,31 @@ class EmailService( logger.info("일정 취소 정보가 ${data.toEmail} 로 전달되었습니다.") } + fun sendDemotionEmail(data: DemotionEmailData) { + val htmlContent = + loadTemplate("registration-demoted.html") + .replace("{name}", data.name) + .replace("{waitingNum}", data.waitingNum?.toString() ?: "-") + .replace("{newCapacity}", data.newCapacity.toString()) + .replace("{eventTitle}", formatEventTitle(data.eventTitle)) + .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) + .replace("{location}", formatLocation(data.location)) + .replace( + "{registrationDateRange}", + formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), + ).replace("{description}", formatDescription(data.description)) + .replace("{publicId}", data.publicId) + .replace("{registrationPublicId}", data.registrationPublicId) + + sendHtmlEmail( + to = data.toEmail, + subject = emailName("대기자로 전환", data.eventTitle), + htmlContent = htmlContent, + ) + + logger.info("정원 축소 대기 변경 알림이 ${data.toEmail} 로 전달되었습니다.") + } + fun sendWaitlistPromotionEmail( toEmail: String, eventTitle: String?, @@ -285,7 +326,7 @@ class EmailService( sendHtmlEmail( to = toEmail, - subject = emailName("참여 신청 대기 후 확정", eventTitle), + subject = emailName("참여 확정", eventTitle), htmlContent = htmlContent, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index f8c3c61e..2df8b19b 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -328,12 +328,6 @@ class EventService( if (!eventLockRepository.lockById(eventId)) throw EventNotFoundException() val previousCapacity = event.capacity - val confirmedParticipants = countConfirmedParticipants(eventId) - validateCapacityUpdate( - capacity = capacity, - confirmedParticipants = confirmedParticipants, - ) - val mergedTitle = title?.trim() ?: event.title val mergedCapacity = capacity ?: event.capacity val mergedStartsAt = startsAt ?: event.startsAt @@ -364,6 +358,9 @@ class EventService( if (isCapacityIncreased(previousCapacity, savedEvent.capacity)) { waitlistReconciliationService.reconcileWaitlist(eventId) } + if (isCapacityDecreased(previousCapacity, savedEvent.capacity)) { + waitlistReconciliationService.demoteToWaitlist(eventId, savedEvent.capacity!!) + } return savedEvent } @@ -481,27 +478,16 @@ class EventService( } } - private fun countConfirmedParticipants(eventId: Long): Int = - registrationRepository - .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED) - .toInt() - - private fun validateCapacityUpdate( - capacity: Int?, - confirmedParticipants: Int, - ) { - if (confirmedParticipants <= 0) return - - if (capacity != null && capacity < confirmedParticipants) { - throw EventValidationException(EventErrorCode.CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS) - } - } - private fun isCapacityIncreased( previousCapacity: Int?, newCapacity: Int?, ): Boolean = previousCapacity != null && newCapacity != null && newCapacity > previousCapacity + private fun isCapacityDecreased( + previousCapacity: Int?, + newCapacity: Int?, + ): Boolean = previousCapacity != null && newCapacity != null && newCapacity < previousCapacity + private fun afterCommit(action: () -> Unit) { if (!TransactionSynchronizationManager.isActualTransactionActive()) { action() diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 2c8fe42c..53c143b8 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -53,6 +53,18 @@ interface RegistrationRepository : registrationStatus: RegistrationStatus, ): List + @Query( + """ + SELECT * FROM registrations + WHERE event_id = :eventId AND status = :status + ORDER BY created_at DESC, id DESC + """, + ) + fun findByEventIdAndStatusOrderByCreatedAtDescIdDesc( + eventId: Long, + status: RegistrationStatus, + ): List + @Query( """ SELECT event_id AS eventId, COUNT(*) AS totalCount diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index ab77a5c1..46c97cc6 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -673,6 +673,64 @@ RegistrationService( } } + @Transactional + override fun demoteToWaitlist(eventId: Long, newCapacity: Int) { + eventLockRepository.lockById(eventId) + + val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } + + val confirmedRegs = + registrationRepository.findByEventIdAndStatusOrderByCreatedAtDescIdDesc( + eventId, + RegistrationStatus.CONFIRMED, + ) + + val excessCount = confirmedRegs.size - newCapacity + if (excessCount <= 0) return + + val toDemote = confirmedRegs.take(excessCount) + toDemote.forEach { it.status = RegistrationStatus.WAITLISTED } + registrationRepository.saveAll(toDemote) + + val demotedPublicIds = toDemote.map { it.registrationPublicId } + val waitlistPositions = + registrationRepository + .findWaitlistPositionsByRegistrationPublicIds( + eventId = eventId, + status = RegistrationStatus.WAITLISTED, + registrationPublicIds = demotedPublicIds, + ).associate { it.registrationPublicId to it.waitlistNumber.toInt() } + + val userIds = toDemote.mapNotNull { it.userId }.distinct() + val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } + + val emailDataList = + toDemote.mapNotNull { reg -> + val user = reg.userId?.let { usersById[it] } + val toEmail = user?.email ?: reg.guestEmail + if (toEmail.isNullOrBlank()) return@mapNotNull null + EmailService.DemotionEmailData( + toEmail = toEmail, + name = user?.name ?: reg.guestName ?: "참여자", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + newCapacity = newCapacity, + registrationStartsAt = event.registrationStartsAt, + registrationEndsAt = event.registrationEndsAt, + description = event.description, + publicId = event.publicId, + registrationPublicId = reg.registrationPublicId, + waitingNum = waitlistPositions[reg.registrationPublicId], + ) + } + + afterCommit { + emailDataList.forEach { emailService.sendDemotionEmail(it) } + } + } + @Transactional override fun reconcileWaitlist(eventId: Long) { eventLockRepository.lockById(eventId) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt index c2193228..0478e0c7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt @@ -2,4 +2,5 @@ package com.wafflestudio.spring2025.domain.registration.service interface WaitlistReconciliationService { fun reconcileWaitlist(eventId: Long) + fun demoteToWaitlist(eventId: Long, newCapacity: Int) } From 7d99eef2ef5ec6e4e64ad3031282c0aaa3c2df48 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:27:29 +0900 Subject: [PATCH 091/157] =?UTF-8?q?:fire:=20=EC=A0=95=EC=9B=90=20=EC=B6=95?= =?UTF-8?q?=EC=86=8C=20=EB=B6=88=ED=97=88=ED=95=98=EB=8A=94=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/exception/EventErrorCode.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index 56bea72b..19743e40 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -76,12 +76,6 @@ enum class EventErrorCode( message = "신청 마감 시간이 모임 시작 시간보다\n빨라야 합니다.", ), - CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "Capacity cannot be decreased.", - message = "Capacity cannot be reduced below the current confirmed participant count.", - ), - // 23xx - Conflict EVENT_FULL( httpStatusCode = HttpStatus.CONFLICT, From d02bc4180804475ce0b27185bdbabf343e2cadec Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:27:44 +0900 Subject: [PATCH 092/157] =?UTF-8?q?:sparkles:=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=EA=B0=95=EB=93=B1=20=EB=A9=94=EC=9D=BC=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../email/template/registration-demoted.html | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html new file mode 100644 index 00000000..de5ac2a6 --- /dev/null +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html @@ -0,0 +1,120 @@ + + + + + + + + + + + + +
+ + + + + +
+ 모이밍 +
+ + + + + + +
+
+ + + + +
+ loader-mark +
+
+
+ 안녕하세요 {name}님
+ {waitingNum}번째 대기로 변경되었습니다. +
+ +
+
{eventTitle} +
+ + + + + + + + + + + + + + +
+ calendar + + 일시 {eventDateRange} +
+ map-pin + + 장소 {location} +
+ user + + 정원 {newCapacity}명 +
+
+ ›
+
+ + + + + +
+ + + + + +
+ clock + + 신청 기간: + {registrationDateRange} +
+
+ +
+ {description}
+ + + 취소하기 + +
+ + + From 0dcf7398050c0f56e17357bf41f7a32f836dc8a2 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:36:20 +0900 Subject: [PATCH 093/157] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EA=B0=95?= =?UTF-8?q?=EB=93=B1=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/EventIntegrationTest.kt | 114 ++++++++++++++++-- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index f6b790d0..832e3e0b 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -947,25 +947,121 @@ class EventIntegrationTest } @Test - fun `확정 참가자 수보다 정원을 줄이면 400을 반환한다`() { - val (host, token) = dataGenerator.generateUser() - val (p1, _) = dataGenerator.generateUser() - val (p2, _) = dataGenerator.generateUser() - val (p3, _) = dataGenerator.generateUser() - val event = createEventInDb(createdBy = host.id!!, capacity = 10) + fun `정원을 확정 참가자 수보다 줄이면 초과 참가자가 대기로 강등된다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (p1, p1Token) = dataGenerator.generateUser() + val (p2, p2Token) = dataGenerator.generateUser() + val (p3, p3Token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(10) registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(10) registrationRepository.save(Registration(userId = p3.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + // capacity 10 → 2: 확정자 3명 중 가장 최근 확정된 p3 1명이 강등 mvc .perform( put("/api/events/${event.publicId}") - .header("Authorization", "Bearer $token") + .header("Authorization", "Bearer $hostToken") .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 2))) .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.code").value("CAPACITY_CANNOT_DECREASE_WITH_PARTICIPANTS")) + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capacity").value(2)) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p1Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p2Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p3Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 축소 시 가장 최근 확정된 참가자부터 강등된다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (early, earlyToken) = dataGenerator.generateUser() + val (late, lateToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = early.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(20) + registrationRepository.save(Registration(userId = late.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + + // capacity 10 → 1: 더 늦게 확정된 late만 강등 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $earlyToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $lateToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 축소로 강등 시 기존 대기자는 영향 없다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmed, _) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 5, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = confirmed.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = waiter.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + // confirmed=1 == newCapacity=1 → 강등 없음, 기존 대기자도 그대로 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $waiterToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 축소로 강등된 참가자에게 이메일이 발송된다`() { + val (host, hostToken) = dataGenerator.generateUser() + val (p1, _) = dataGenerator.generateUser() + val (p2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + + // capacity 10 → 1: 1명 강등 → sendDemotionEmail 1회 호출 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + verify(emailService, times(1)).sendDemotionEmail(any()) } @Test From bac556afb1f3d15113b991570df057ec202ff4f5 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:36:30 +0900 Subject: [PATCH 094/157] :art: ktlint --- .../domain/registration/service/RegistrationService.kt | 5 ++++- .../registration/service/WaitlistReconciliationService.kt | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 46c97cc6..4a338582 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -674,7 +674,10 @@ RegistrationService( } @Transactional - override fun demoteToWaitlist(eventId: Long, newCapacity: Int) { + override fun demoteToWaitlist( + eventId: Long, + newCapacity: Int, + ) { eventLockRepository.lockById(eventId) val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt index 0478e0c7..07110e7c 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt @@ -2,5 +2,9 @@ package com.wafflestudio.spring2025.domain.registration.service interface WaitlistReconciliationService { fun reconcileWaitlist(eventId: Long) - fun demoteToWaitlist(eventId: Long, newCapacity: Int) + + fun demoteToWaitlist( + eventId: Long, + newCapacity: Int, + ) } From ce8ff9a228f36784ee6157db537cbdafdfd8d4a3 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 15 Mar 2026 21:45:20 +0900 Subject: [PATCH 095/157] =?UTF-8?q?:bug:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/service/EventService.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 2df8b19b..6be5b0df 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -322,10 +322,11 @@ class EventService( registrationEndsAt: Instant?, requesterId: Long, ): Event { - val event = getEventByPublicId(publicId) - requireCreator(event, requesterId) - val eventId = requireNotNull(event.id) { "Event id is null: publicId=$publicId" } + val eventForAuth = getEventByPublicId(publicId) + requireCreator(eventForAuth, requesterId) + val eventId = requireNotNull(eventForAuth.id) { "Event id is null: publicId=$publicId" } if (!eventLockRepository.lockById(eventId)) throw EventNotFoundException() + val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } val previousCapacity = event.capacity val mergedTitle = title?.trim() ?: event.title From b4cdffd75742002e90d94da9277553b9200f2b8d Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 21 Mar 2026 19:47:11 +0900 Subject: [PATCH 096/157] =?UTF-8?q?:bug:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/exception/EventErrorCode.kt | 5 +++++ .../spring2025/domain/event/service/EventService.kt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index 19743e40..72b25149 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -75,6 +75,11 @@ enum class EventErrorCode( title = "신청 기간이 유효하지 않습니다.", message = "신청 마감 시간이 모임 시작 시간보다\n빨라야 합니다.", ), + REGISTRATION_ENDS_AFTER_EVENT_END( + httpStatusCode = HttpStatus.BAD_REQUEST, + title = "신청 기간이 유효하지 않습니다.", + message = "신청 마감 시간이 모임 마감 시간보다\n빨라야 합니다.", + ), // 23xx - Conflict EVENT_FULL( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 6be5b0df..bbcf16ba 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -475,7 +475,7 @@ class EventService( } // 모집 마감 ≤ 일정 끝 (행사시작 없을 때도 직접 체크) if (endsAt != null && registrationEndsAt.isAfter(endsAt)) { - throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_AFTER_EVENT_START) + throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_AFTER_EVENT_END) } } From 0501bee100aeb999ce815829a3c302c0ba139924 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 21 Mar 2026 22:08:37 +0900 Subject: [PATCH 097/157] =?UTF-8?q?:pencil:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/exception/EventErrorCode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index 72b25149..9d77e7c4 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -78,7 +78,7 @@ enum class EventErrorCode( REGISTRATION_ENDS_AFTER_EVENT_END( httpStatusCode = HttpStatus.BAD_REQUEST, title = "신청 기간이 유효하지 않습니다.", - message = "신청 마감 시간이 모임 마감 시간보다\n빨라야 합니다.", + message = "신청 마감 시간이 모임 종료 시간보다\n빨라야 합니다.", ), // 23xx - Conflict From 8e71596f5caa683d29544be0364590b873ac2b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Tue, 24 Mar 2026 17:01:42 +0900 Subject: [PATCH 098/157] =?UTF-8?q?:bug:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20null=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/auth/service/SocialAuthService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/SocialAuthService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/SocialAuthService.kt index 2bca98f4..f119bc12 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/SocialAuthService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/SocialAuthService.kt @@ -32,7 +32,7 @@ class SocialAuthService( User( email = userInfo.email, name = userInfo.name, - profileImage = userInfo.profilePicture, + profileImage = null, passwordHash = null, ), ) From 92ff766035e856aa31be7c4fcd745033a11855cd Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:02:43 +0900 Subject: [PATCH 099/157] =?UTF-8?q?:rocket:=20OCI=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/_deploy.yml | 49 +++++++++++++++++++++++++++ .github/workflows/oci-deploy-dev.yml | 14 ++++++++ .github/workflows/oci-deploy-prod.yml | 29 ++++++++++++++++ Dockerfile | 3 ++ build.gradle.kts | 10 ++++++ src/main/resources/application.yaml | 20 +++++++++++ 6 files changed, 125 insertions(+) create mode 100644 .github/workflows/_deploy.yml create mode 100644 .github/workflows/oci-deploy-dev.yml create mode 100644 .github/workflows/oci-deploy-prod.yml diff --git a/.github/workflows/_deploy.yml b/.github/workflows/_deploy.yml new file mode 100644 index 00000000..89f2a34f --- /dev/null +++ b/.github/workflows/_deploy.yml @@ -0,0 +1,49 @@ +name: deploy-template + +on: + workflow_call: #직접실행x + inputs: + ocir_repository: + required: true + type: string + dockerfile: + required: true + type: string + description: "Dockerfile path (e.g., api/Dockerfile)" + secrets: + OCI_AUTH_TOKEN: + required: true + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-24.04-arm + + env: + IMAGE_TAG: ${{ github.run_number }} #github.* 변수들은 github 자체 내장 + BUILD_NUMBER: ${{ github.run_number }} + OCIR_REGISTRY: yny.ocir.io #동일 + OCIR_NAMESPACE: ax1dvc8vmenm # 같은 ocir내에서는 동일 + OCIR_REPOSITORY: ${{ inputs.ocir_repository }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to OCIR + run: echo "${{ secrets.OCI_AUTH_TOKEN }}" | docker login $OCIR_REGISTRY -u ax1dvc8vmenm/members/waffle-deployer --password-stdin + #알맞게 수정 + + - name: Docker build, tag, and push image to OCIR + id: build-push-image + run: | + echo "${{ github.token }}" > .github_token + docker build \ + --secret id=github_token,src=./.github_token \ + -f ${{ inputs.dockerfile }} \ + -t $OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG \ + . \ + --platform linux/arm64 + rm -f .github_token + docker push $OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG + echo "image=$OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT diff --git a/.github/workflows/oci-deploy-dev.yml b/.github/workflows/oci-deploy-dev.yml new file mode 100644 index 00000000..194d7171 --- /dev/null +++ b/.github/workflows/oci-deploy-dev.yml @@ -0,0 +1,14 @@ +name: Deploy-api-dev + +on: + push: + branches: [develop] # 원하는 브랜치로 설정 + +jobs: + deploy: + uses: ./.github/workflows/_deploy.yml + with: + ocir_repository: moiming-dev/moiming-server # ocir 레포에 맞게 설정 + dockerfile: Dockerfile #dockerfile 경로 설정 + secrets: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} # 와플 deployer 계정 토큰 필요 diff --git a/.github/workflows/oci-deploy-prod.yml b/.github/workflows/oci-deploy-prod.yml new file mode 100644 index 00000000..e803479b --- /dev/null +++ b/.github/workflows/oci-deploy-prod.yml @@ -0,0 +1,29 @@ +name: Deploy-api-prod + +on: + push: + branches: [main2] # 원하는 브랜치 설정 + +jobs: + deploy: + uses: ./.github/workflows/_deploy.yml + with: + ocir_repository: moiming-prod/moiming-server + dockerfile: Dockerfile + secrets: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + + notify: + needs: deploy + runs-on: ubuntu-latest + steps: + - name: Slack Notify + uses: rtCamp/action-slack-notify@v2.3.3 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_CHANNEL: team-moiming-github + SLACK_TITLE: NEW RELEASE + SLACK_USERNAME: moiming-server + SLACK_ICON: https://user-images.githubusercontent.com/35535636/103177470-2237cb00-48be-11eb-9211-3ffa567c8ac3.png + #원하는 아이콘으로 바꾸기 + SLACK_MESSAGE: Check for updated environment diff --git a/Dockerfile b/Dockerfile index be00e2a1..797cacec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,9 @@ COPY build.gradle.kts settings.gradle.kts ./ COPY gradle ./gradle COPY src ./src RUN gradle build --no-daemon -x test +#RUN --mount=type=secret,id=github_token GITHUB_TOKEN=$(cat /run/secrets/github_token) ./gradlew :api:bootJar +#github_token을 받아서 waffle-spring에서 oci vault 라이브러리를 받아옴. + FROM eclipse-temurin:17-jre WORKDIR /app diff --git a/build.gradle.kts b/build.gradle.kts index a1316724..4b904632 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,13 @@ buildscript { repositories { mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/wafflestudio/spring-waffle") + credentials { + username = "wafflestudio" + password = System.getenv("GITHUB_TOKEN") + } + } } dependencies { @@ -62,6 +69,9 @@ dependencies { // profile_image 업로드 관련 의존성 추가 implementation(platform("software.amazon.awssdk:bom:2.41.19")) implementation("software.amazon.awssdk:s3") + + //oci vault 의존성 + implementation("com.wafflestudio.spring:spring-boot-starter-waffle-oci-vault:2.1.0") } kotlin { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 29fc2f77..13db1496 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -93,3 +93,23 @@ aws: server: forward-headers-strategy: framework + +--- +spring: + config: + activate: + on-profile: devx +oci: + vault: + secret-ids: ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqaa3m45icdqcka43cvyzb4kb3ktuatfobwusnsdd4bvzkq + region: ap-chuncheon-1 + +--- +spring: + config: + activate: + on-profile: prodx +oci: + vault: + secret-ids: ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqameflpkt7abivvrauqwheybzrkqipf7msv3ol6ikhd3aq + region: ap-chuncheon-1 From 18359a10da490a3fc08c542c0090de78ada2a6d0 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 28 Mar 2026 17:19:18 +0900 Subject: [PATCH 100/157] =?UTF-8?q?:recycle:=20EmailClient=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../email/client/JavaMailEmailClient.kt | 25 +++++++------ .../common/email/client/NoopEmailClient.kt | 22 ++++++++++++ .../common/email/service/EmailService.kt | 35 +++++-------------- 3 files changed, 45 insertions(+), 37 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/spring2025/common/email/client/NoopEmailClient.kt diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt index 5dd82a1b..5468f780 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt @@ -1,8 +1,12 @@ package com.wafflestudio.spring2025.common.email.client +import com.wafflestudio.spring2025.common.email.exception.EmailErrorCode +import com.wafflestudio.spring2025.common.email.exception.EmailServiceUnavailableException +import jakarta.mail.MessagingException import jakarta.mail.internet.MimeMessage import org.slf4j.LoggerFactory import org.springframework.context.annotation.Primary +import org.springframework.mail.MailException import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.javamail.MimeMessageHelper import org.springframework.stereotype.Component @@ -21,26 +25,25 @@ class JavaMailEmailClient( fromEmail: String, fromName: String, ) { - logger.info("메일링 임시 중단으로 발송 생략: $to") - return + // 지금 AWS SDK를 쓰는 게 아니라 그냥 email config를 AWS SES의 SMTP 엔드포인트로 해놓은 상황 + // (SDK가 아닌 SMTP 방식으로 SES 사용) try { val message: MimeMessage = javaMailSender.createMimeMessage() - - val helper = MimeMessageHelper(message, true, "UTF-8") + val helper = MimeMessageHelper(message, "UTF-8") helper.setTo(to) helper.setSubject(subject) - helper.setText(htmlContent, true) - helper.setFrom("$fromName <$fromEmail>") + // AWS SES 샌드박스 모드에서는 발신/수신 주소 모두 사전 인증(Verified)이 필요합니다. javaMailSender.send(message) - - logger.info("Gmail을 통해 메일 발송 성공: $to") - } catch (e: Exception) { - logger.error("Gmail 메일 발송 실패: $to", e) - throw RuntimeException("Email sending failed via Gmail", e) + } catch (e: MessagingException) { + logger.error("메일 구성 실패: to={}, subject={}", to, subject, e) + throw EmailServiceUnavailableException(EmailErrorCode.EMAIL_SERVICE_UNAVAILABLE) + } catch (e: MailException) { + logger.error("메일 전송 실패: to={}, subject={}", to, subject, e) + throw EmailServiceUnavailableException(EmailErrorCode.EMAIL_SERVICE_UNAVAILABLE) } } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/NoopEmailClient.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/NoopEmailClient.kt new file mode 100644 index 00000000..fc357e1b --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/NoopEmailClient.kt @@ -0,0 +1,22 @@ +package com.wafflestudio.spring2025.common.email.client + +import org.slf4j.LoggerFactory +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.stereotype.Component + +@Component +class NoopEmailClient( + private val javaMailSender: JavaMailSender, +) : EmailClient { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun sendEmail( + to: String, + subject: String, + htmlContent: String, + fromEmail: String, + fromName: String, + ) { + logger.info("메일링 중단으로 발송 생략: $to") + } +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index e5c6162a..5ae525fb 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -1,16 +1,10 @@ package com.wafflestudio.spring2025.common.email.service -import com.wafflestudio.spring2025.common.email.exception.EmailErrorCode -import com.wafflestudio.spring2025.common.email.exception.EmailServiceUnavailableException +import com.wafflestudio.spring2025.common.email.client.EmailClient import com.wafflestudio.spring2025.config.EmailConfig import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import jakarta.mail.MessagingException -import jakarta.mail.internet.MimeMessage import org.slf4j.LoggerFactory import org.springframework.core.io.ClassPathResource -import org.springframework.mail.MailException -import org.springframework.mail.javamail.JavaMailSender -import org.springframework.mail.javamail.MimeMessageHelper import org.springframework.stereotype.Service import java.nio.charset.StandardCharsets import java.time.Instant @@ -19,8 +13,8 @@ import java.time.format.DateTimeFormatter @Service class EmailService( - private val javaMailSender: JavaMailSender, private val emailConfig: EmailConfig, + private val emailClient: EmailClient, ) { private val logger = LoggerFactory.getLogger(javaClass) @@ -52,24 +46,13 @@ class EmailService( subject: String, htmlContent: String, ) { - try { - val message: MimeMessage = javaMailSender.createMimeMessage() - val helper = MimeMessageHelper(message, "UTF-8") - - helper.setTo(to) - helper.setSubject(subject) - helper.setText(htmlContent, true) - helper.setFrom("${emailConfig.fromName} <${emailConfig.fromEmail}>") - - // AWS SES 샌드박스 모드에서는 발신/수신 주소 모두 사전 인증(Verified)이 필요합니다. - javaMailSender.send(message) - } catch (e: MessagingException) { - logger.error("메일 구성 실패: to={}, subject={}", to, subject, e) - throw EmailServiceUnavailableException(EmailErrorCode.EMAIL_SERVICE_UNAVAILABLE) - } catch (e: MailException) { - logger.error("메일 전송 실패: to={}, subject={}", to, subject, e) - throw EmailServiceUnavailableException(EmailErrorCode.EMAIL_SERVICE_UNAVAILABLE) - } + emailClient.sendEmail( + to = to, + subject = subject, + htmlContent = htmlContent, + fromEmail = emailConfig.fromEmail, + fromName = emailConfig.fromName, + ) } /** From 78dff7bbe31a4373c906ad23bedff352c531fd3f Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Sun, 29 Mar 2026 02:06:16 +0900 Subject: [PATCH 101/157] =?UTF-8?q?valkey=20=EB=B3=80=EA=B2=BD=ED=95=84?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 13db1496..39dc5a95 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -34,7 +34,7 @@ spring: enabled: true data: redis: - host: localhost + host: localhost # valkey 변경 필요 port: 6379 jwt: From 4b14cb336e28f374871951c93c50ab2838a0af26 Mon Sep 17 00:00:00 2001 From: uykhc Date: Mon, 30 Mar 2026 01:20:46 +0900 Subject: [PATCH 102/157] test: add /api/registrations/me pagination integration tests and fix JDBC alias mapping --- .../repository/RegistrationRepository.kt | 8 +- .../spring2025/RegistrationIntegrationTest.kt | 120 ++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 3511037c..5ed64233 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -80,7 +80,7 @@ interface RegistrationRepository : @Query( """ - SELECT event_id AS eventId, COUNT(*) AS totalCount + SELECT event_id, COUNT(*) AS total_count FROM registrations WHERE event_id IN (:eventIds) AND status IN (:statuses) GROUP BY event_id @@ -93,9 +93,9 @@ interface RegistrationRepository : @Query( """ - SELECT registration_public_id AS registrationPublicId, - event_id AS eventId, - ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY created_at ASC, id ASC) AS waitlistNumber + SELECT registration_public_id, + event_id, + ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY created_at ASC, id ASC) AS waitlist_number FROM registrations WHERE event_id IN (:eventIds) AND status = :status """, diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index ff410e93..8b2b2649 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -591,6 +591,120 @@ class RegistrationIntegrationTest assertEquals((totalUsers - capacity).toLong(), waitlisted, "WAITLISTED 수가 정확해야 합니다") } + @Test + fun `my registrations endpoint paginates without overlap`() { + val (_, participantToken) = dataGenerator.generateUser() + val registeredEventIds = + (1..25).map { index -> + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "my-registrations-$index") + registerAsUser(event.publicId, participantToken) + event.publicId + } + + val page0 = + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(10)) + .andReturn() + + val page1 = + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "1") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(10)) + .andReturn() + + val page2 = + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "2") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(5)) + .andReturn() + + val page3 = + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "3") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(0)) + .andReturn() + + val page0Ids = extractMyRegistrationEventPublicIds(page0) + val page1Ids = extractMyRegistrationEventPublicIds(page1) + val page2Ids = extractMyRegistrationEventPublicIds(page2) + val page3Ids = extractMyRegistrationEventPublicIds(page3) + + assertTrue(page0Ids.intersect(page1Ids.toSet()).isEmpty()) + assertTrue(page0Ids.intersect(page2Ids.toSet()).isEmpty()) + assertTrue(page1Ids.intersect(page2Ids.toSet()).isEmpty()) + assertTrue(page3Ids.isEmpty()) + + val merged = (page0Ids + page1Ids + page2Ids).toSet() + assertEquals(registeredEventIds.toSet(), merged) + } + + @Test + fun `my registrations endpoint respects size parameter`() { + val (_, participantToken) = dataGenerator.generateUser() + + repeat(7) { index -> + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "size-check-$index") + registerAsUser(event.publicId, participantToken) + } + + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "0") + .param("size", "3") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(3)) + + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "1") + .param("size", "3") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(3)) + + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "2") + .param("size", "3") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(1)) + } + private fun registerAsUser( eventPublicId: String, token: String, @@ -642,6 +756,12 @@ class RegistrationIntegrationTest .path("participants") .toList() + private fun extractMyRegistrationEventPublicIds(result: MvcResult): List = + mapper + .readTree(result.response.contentAsString) + .path("registrations") + .map { it.path("publicId").asText() } + private fun findRegistration(registrationPublicId: String): Registration { val registration = registrationRepository.findByRegistrationPublicId(registrationPublicId) assertNotNull(registration) From d5b7725dbd0a7533d1f592cd7e45262f2c57c1b0 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Mon, 30 Mar 2026 23:18:52 +0900 Subject: [PATCH 103/157] :construction: deployment settings --- .github/workflows/oci-deploy-dev.yml | 2 +- .github/workflows/oci-deploy-prod.yml | 2 +- Dockerfile | 4 +++- src/main/resources/application.yaml | 10 ++++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/oci-deploy-dev.yml b/.github/workflows/oci-deploy-dev.yml index 194d7171..24aee498 100644 --- a/.github/workflows/oci-deploy-dev.yml +++ b/.github/workflows/oci-deploy-dev.yml @@ -2,7 +2,7 @@ name: Deploy-api-dev on: push: - branches: [develop] # 원하는 브랜치로 설정 + branches: [dev] # 원하는 브랜치로 설정 jobs: deploy: diff --git a/.github/workflows/oci-deploy-prod.yml b/.github/workflows/oci-deploy-prod.yml index e803479b..17d17afa 100644 --- a/.github/workflows/oci-deploy-prod.yml +++ b/.github/workflows/oci-deploy-prod.yml @@ -2,7 +2,7 @@ name: Deploy-api-prod on: push: - branches: [main2] # 원하는 브랜치 설정 + branches: [main] # 원하는 브랜치 설정 jobs: deploy: diff --git a/Dockerfile b/Dockerfile index 797cacec..1007c9f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,9 @@ WORKDIR /app COPY build.gradle.kts settings.gradle.kts ./ COPY gradle ./gradle COPY src ./src -RUN gradle build --no-daemon -x test +RUN --mount=type=secret,id=github_token \ + GITHUB_TOKEN=$(cat /run/secrets/github_token) \ + gradle build --no-daemon -x test #RUN --mount=type=secret,id=github_token GITHUB_TOKEN=$(cat /run/secrets/github_token) ./gradlew :api:bootJar #github_token을 받아서 waffle-spring에서 oci vault 라이브러리를 받아옴. diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 39dc5a95..1acf46bf 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -99,6 +99,11 @@ spring: config: activate: on-profile: devx + datasource: + url: jdbc:mysql://10.0.30.217:3306/moiming_dev?allowPublicKeyRetrieval=true + username: moiming-dev +cors: + allowed-origins: https://dev.moiming.wafflestudio.com oci: vault: secret-ids: ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqaa3m45icdqcka43cvyzb4kb3ktuatfobwusnsdd4bvzkq @@ -109,6 +114,11 @@ spring: config: activate: on-profile: prodx + datasource: + url: jdbc:mysql://10.0.30.217:3306/moiming_prod?allowPublicKeyRetrieval=true + username: moiming-prod +cors: + allowed-origins: https://moiming.wafflestudio.com oci: vault: secret-ids: ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqameflpkt7abivvrauqwheybzrkqipf7msv3ol6ikhd3aq From c5dbd03f3f2f4cc1dcb2b4a8fd55436ceab12191 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 4 Apr 2026 11:41:32 +0900 Subject: [PATCH 104/157] =?UTF-8?q?:construction:=20redis=20host,=20spring?= =?UTF-8?q?=20profile=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1acf46bf..a3ac50d4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -34,7 +34,7 @@ spring: enabled: true data: redis: - host: localhost # valkey 변경 필요 + host: moiming-valkey port: 6379 jwt: @@ -98,7 +98,7 @@ server: spring: config: activate: - on-profile: devx + on-profile: dev datasource: url: jdbc:mysql://10.0.30.217:3306/moiming_dev?allowPublicKeyRetrieval=true username: moiming-dev @@ -113,7 +113,7 @@ oci: spring: config: activate: - on-profile: prodx + on-profile: prod datasource: url: jdbc:mysql://10.0.30.217:3306/moiming_prod?allowPublicKeyRetrieval=true username: moiming-prod From 90b18ba2e7e5aec2fb5052c307c0dba4ec63a8bd Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 4 Apr 2026 22:26:06 +0900 Subject: [PATCH 105/157] =?UTF-8?q?:fire:=20=EA=B8=B0=EC=A1=B4=20VM=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=A4=91?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-dev.yml | 5 ----- .github/workflows/cd.yml | 5 ----- 2 files changed, 10 deletions(-) diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 2b387548..3b20f82e 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -1,11 +1,6 @@ name: CD Pipeline - Dev Deploy on: - workflow_run: - workflows: ["CI"] - types: [completed] - branches: - - dev workflow_dispatch: permissions: diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0125175b..53f745b0 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,11 +1,6 @@ name: CD Pipeline - EC2 Deploy on: - workflow_run: - workflows: ["CI"] - types: [completed] - branches: - - main workflow_dispatch: permissions: From 7c2bd6d21296ee85f21459c8f7dc9167566385e0 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 4 Apr 2026 22:27:52 +0900 Subject: [PATCH 106/157] :seedling: email config update --- src/main/resources/application.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a3ac50d4..3d3c926f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -11,8 +11,8 @@ spring: mail: host: email-smtp.us-east-1.amazonaws.com port: 587 - username: ${AWS_SES_SMTP_USERNAME:YOUR_SES_SMTP_USERNAME} - password: ${AWS_SES_SMTP_PASSWORD:YOUR_SES_SMTP_PASSWORD} + username: ${OCI_EMAIL_SMTP_USERNAME:YOUR_OCI_SMTP_USERNAME} + password: ${OCI_EMAIL_SMTP_PASSWORD:YOUR_OCI_SMTP_PASSWORD} properties: mail: smtp: @@ -42,7 +42,7 @@ jwt: expiration-in-ms: 86400000 email: - from-email: no-reply@moiming.app + from-email: moiming@wafflestudio.com from-name: 모이밍 oauth: From fe598e584f6a4dcbdce96ffdb07f5dc38d25ce6b Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 4 Apr 2026 22:44:20 +0900 Subject: [PATCH 107/157] :art: ktlint --- build.gradle.kts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4b904632..a5f63d46 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,13 +30,13 @@ buildscript { repositories { mavenCentral() - maven { - url = uri("https://maven.pkg.github.com/wafflestudio/spring-waffle") - credentials { - username = "wafflestudio" - password = System.getenv("GITHUB_TOKEN") - } - } + maven { + url = uri("https://maven.pkg.github.com/wafflestudio/spring-waffle") + credentials { + username = "wafflestudio" + password = System.getenv("GITHUB_TOKEN") + } + } } dependencies { @@ -70,7 +70,7 @@ dependencies { implementation(platform("software.amazon.awssdk:bom:2.41.19")) implementation("software.amazon.awssdk:s3") - //oci vault 의존성 + // oci vault 의존성 implementation("com.wafflestudio.spring:spring-boot-starter-waffle-oci-vault:2.1.0") } From 992c6e3d1ff73be6f70184f5f97e82421e65d66e Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 4 Apr 2026 22:46:52 +0900 Subject: [PATCH 108/157] :seedling: domain update --- src/main/resources/application.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3d3c926f..71e54607 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -87,9 +87,10 @@ aws: allowed-origins: > http://localhost:3000, http://localhost:5173, - https://www.moiming.app, - https://moiming.app, - https://api.moiming.app + https://moiming.wafflestudio.com, + https://dev.moiming.wafflestudio.com, + https://api.moiming.wafflestudio.com + https://api.dev.moiming.wafflestudio.com server: forward-headers-strategy: framework From f8fae5437f58fd3c07f6c7dc02ee3f72fce89fdf Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 4 Apr 2026 23:57:56 +0900 Subject: [PATCH 109/157] :seedling: add github token dependency --- .github/workflows/ci.yml | 4 ++++ build.gradle.kts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39d553cb..7043f903 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,9 +35,13 @@ jobs: - name: Run ktlint run: ./gradlew ktlintCheck --no-daemon + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run tests (includes integration tests) run: ./gradlew test --no-daemon + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload test reports if: always() diff --git a/build.gradle.kts b/build.gradle.kts index a5f63d46..aa61a1ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -70,8 +70,10 @@ dependencies { implementation(platform("software.amazon.awssdk:bom:2.41.19")) implementation("software.amazon.awssdk:s3") - // oci vault 의존성 - implementation("com.wafflestudio.spring:spring-boot-starter-waffle-oci-vault:2.1.0") + // oci vault 의존성 (GITHUB_TOKEN 없는 로컬 환경에서는 제외) + if (System.getenv("GITHUB_TOKEN") != null) { + implementation("com.wafflestudio.spring:spring-boot-starter-waffle-oci-vault:2.1.0") + } } kotlin { From df7a9ae60f149fc3a81f521db9cbf08066c34ba1 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 5 Apr 2026 00:01:48 +0900 Subject: [PATCH 110/157] :seedling: remove gradle wrapper validation --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7043f903..dcca0862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v3 - - name: Set up JDK 17 uses: actions/setup-java@v4 with: From a1691d6d54baa8dae6c4603a9f41df0f26b1bd94 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 5 Apr 2026 00:08:54 +0900 Subject: [PATCH 111/157] :pushpin: upgrade java version to 21 --- .github/workflows/ci.yml | 4 ++-- Dockerfile | 4 ++-- build.gradle.kts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcca0862..d75114a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Setup Gradle diff --git a/Dockerfile b/Dockerfile index 1007c9f9..9f8c6055 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM gradle:8.5-jdk17 AS build +FROM gradle:8.5-jdk21 AS build WORKDIR /app COPY build.gradle.kts settings.gradle.kts ./ COPY gradle ./gradle @@ -10,7 +10,7 @@ RUN --mount=type=secret,id=github_token \ #github_token을 받아서 waffle-spring에서 oci vault 라이브러리를 받아옴. -FROM eclipse-temurin:17-jre +FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=build /app/build/libs/*.jar app.jar EXPOSE 8080 diff --git a/build.gradle.kts b/build.gradle.kts index aa61a1ce..91960e03 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ description = "seminar-2025-springboot-assignment" java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } From d6b660cf887135eff8c163d5312dea9c2803a3db Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 5 Apr 2026 00:23:09 +0900 Subject: [PATCH 112/157] :pushpin: version upgrade --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 91960e03..36c5e055 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,8 @@ import java.util.Properties plugins { - kotlin("jvm") version "1.9.25" - kotlin("plugin.spring") version "1.9.25" + kotlin("jvm") version "2.2.0" + kotlin("plugin.spring") version "2.2.0" id("org.springframework.boot") version "3.5.5" id("io.spring.dependency-management") version "1.1.7" id("org.flywaydb.flyway") version "9.22.3" From d850d49ce6626d36a1abd32e9ef305ad02b7bc99 Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:43:29 +0900 Subject: [PATCH 113/157] password override --- src/main/resources/application.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 71e54607..67bcfa7c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -103,6 +103,7 @@ spring: datasource: url: jdbc:mysql://10.0.30.217:3306/moiming_dev?allowPublicKeyRetrieval=true username: moiming-dev + password: cors: allowed-origins: https://dev.moiming.wafflestudio.com oci: From 5f1ccf4a0e33b4499b1d4b3ff5b388da9d9c3f30 Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:32:07 +0900 Subject: [PATCH 114/157] feat: debugging print added --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 36c5e055..1743ddf4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -72,7 +72,10 @@ dependencies { // oci vault 의존성 (GITHUB_TOKEN 없는 로컬 환경에서는 제외) if (System.getenv("GITHUB_TOKEN") != null) { + println("[DEBUG] VAULT_DEP_ADDED (GITHUB_TOKEN length=${System.getenv("GITHUB_TOKEN").length})") implementation("com.wafflestudio.spring:spring-boot-starter-waffle-oci-vault:2.1.0") + } else { + println("[DEBUG] VAULT_DEP_SKIPPED (GITHUB_TOKEN is null)") } } From d4ef8379ab62219d13916abbda6208417c6d10df Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:46:49 +0900 Subject: [PATCH 115/157] feat: add vault library check in Docker build process --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9f8c6055..08d090e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,9 @@ COPY gradle ./gradle COPY src ./src RUN --mount=type=secret,id=github_token \ GITHUB_TOKEN=$(cat /run/secrets/github_token) \ - gradle build --no-daemon -x test + gradle build --no-daemon -x test && \ + echo "=== Checking vault library in JAR ===" && \ + (jar tf build/libs/*.jar | grep -i oci-vault && echo "[DEBUG] VAULT_LIB_FOUND") || echo "[DEBUG] VAULT_LIB_NOT_FOUND" #RUN --mount=type=secret,id=github_token GITHUB_TOKEN=$(cat /run/secrets/github_token) ./gradlew :api:bootJar #github_token을 받아서 waffle-spring에서 oci vault 라이브러리를 받아옴. From da6f05ff129c70a7c2d8a51dea2f07f7d6e43520 Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:06:17 +0900 Subject: [PATCH 116/157] =?UTF-8?q?feat:=20vault=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=99=80=ED=94=8C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=9C=EA=B1=B0,=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/_deploy.yml | 3 + Dockerfile | 8 +- build.gradle.kts | 10 +-- .../config/OciVaultPostProcessorBridge.kt | 74 +++++++++++++++++++ src/main/resources/META-INF/spring.factories | 1 + 5 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt create mode 100644 src/main/resources/META-INF/spring.factories diff --git a/.github/workflows/_deploy.yml b/.github/workflows/_deploy.yml index 89f2a34f..3e26e43f 100644 --- a/.github/workflows/_deploy.yml +++ b/.github/workflows/_deploy.yml @@ -18,6 +18,9 @@ jobs: deploy: name: Deploy runs-on: ubuntu-24.04-arm + permissions: + packages: read + contents: read env: IMAGE_TAG: ${{ github.run_number }} #github.* 변수들은 github 자체 내장 diff --git a/Dockerfile b/Dockerfile index 08d090e8..61a5b703 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,11 +3,9 @@ WORKDIR /app COPY build.gradle.kts settings.gradle.kts ./ COPY gradle ./gradle COPY src ./src -RUN --mount=type=secret,id=github_token \ - GITHUB_TOKEN=$(cat /run/secrets/github_token) \ - gradle build --no-daemon -x test && \ - echo "=== Checking vault library in JAR ===" && \ - (jar tf build/libs/*.jar | grep -i oci-vault && echo "[DEBUG] VAULT_LIB_FOUND") || echo "[DEBUG] VAULT_LIB_NOT_FOUND" +RUN gradle build --no-daemon -x test && \ + BOOT_JAR=$(ls build/libs/*.jar | grep -v plain) && \ + jar tf "$BOOT_JAR" | grep -q "oci-java-sdk-secrets" || (echo "ERROR: OCI SDK missing from fat JAR" && exit 1) #RUN --mount=type=secret,id=github_token GITHUB_TOKEN=$(cat /run/secrets/github_token) ./gradlew :api:bootJar #github_token을 받아서 waffle-spring에서 oci vault 라이브러리를 받아옴. diff --git a/build.gradle.kts b/build.gradle.kts index 1743ddf4..cfebb3cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -70,13 +70,9 @@ dependencies { implementation(platform("software.amazon.awssdk:bom:2.41.19")) implementation("software.amazon.awssdk:s3") - // oci vault 의존성 (GITHUB_TOKEN 없는 로컬 환경에서는 제외) - if (System.getenv("GITHUB_TOKEN") != null) { - println("[DEBUG] VAULT_DEP_ADDED (GITHUB_TOKEN length=${System.getenv("GITHUB_TOKEN").length})") - implementation("com.wafflestudio.spring:spring-boot-starter-waffle-oci-vault:2.1.0") - } else { - println("[DEBUG] VAULT_DEP_SKIPPED (GITHUB_TOKEN is null)") - } + // OCI Vault - SDK 직접 사용 (waffle-oci-vault 2.1.0은 Spring Boot 3.5와 호환 안됨) + implementation("com.oracle.oci.sdk:oci-java-sdk-secrets:3.80.1") + implementation("com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.80.1") } kotlin { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt new file mode 100644 index 00000000..150f6895 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt @@ -0,0 +1,74 @@ +package com.wafflestudio.spring2025.config + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.oracle.bmc.Region +import com.oracle.bmc.auth.InstancePrincipalsAuthenticationDetailsProvider +import com.oracle.bmc.secrets.SecretsClient +import com.oracle.bmc.secrets.model.Base64SecretBundleContentDetails +import com.oracle.bmc.secrets.requests.GetSecretBundleRequest +import org.springframework.boot.SpringApplication +import org.springframework.boot.env.EnvironmentPostProcessor +import org.springframework.core.env.ConfigurableEnvironment +import org.springframework.core.env.MapPropertySource +import java.util.Base64 + +/** + * OCI Vault EnvironmentPostProcessor that replaces the broken waffle-oci-vault library. + * The library (2.1.0) implements org.springframework.boot.EnvironmentPostProcessor (wrong package) + * instead of org.springframework.boot.env.EnvironmentPostProcessor, making it incompatible + * with Spring Boot 3.x. + */ +class OciVaultPostProcessorBridge : EnvironmentPostProcessor { + private val objectMapper = jacksonObjectMapper() + + override fun postProcessEnvironment( + environment: ConfigurableEnvironment, + application: SpringApplication, + ) { + val secretIds = environment.getProperty("oci.vault.secret-ids") ?: return + val regionId = environment.getProperty("oci.vault.region", "ap-chuncheon-1") + val region = Region.fromRegionId(regionId) + + val authProvider = + try { + InstancePrincipalsAuthenticationDetailsProvider.builder().build() + } catch (e: Exception) { + System.err.println("[OciVault] Instance Principal auth failed: ${e.message}") + return + } + + val secrets = mutableMapOf() + + SecretsClient.builder().region(region).build(authProvider).use { client -> + for (secretId in secretIds.split(",").map { it.trim() }) { + try { + val request = + GetSecretBundleRequest + .builder() + .secretId(secretId) + .build() + val response = client.getSecretBundle(request) + val content = + response.secretBundle.secretBundleContent + as? Base64SecretBundleContentDetails ?: continue + val decoded = String(Base64.getDecoder().decode(content.content)) + val parsed: Map = objectMapper.readValue(decoded) + + for ((key, value) in parsed) { + val existing = environment.getProperty(key) + if (existing.isNullOrBlank()) { + secrets[key] = value + } + } + } catch (e: Exception) { + System.err.println("[OciVault] Failed to fetch secret $secretId: ${e.message}") + } + } + } + + if (secrets.isNotEmpty()) { + environment.propertySources.addFirst(MapPropertySource("oci-vault-secrets", secrets)) + } + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..f8cf5e6f --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.env.EnvironmentPostProcessor=com.wafflestudio.spring2025.config.OciVaultPostProcessorBridge From a389d30c2030baa7efde617c66e1f33119c5514f Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:24:44 +0900 Subject: [PATCH 117/157] =?UTF-8?q?fix:=20json=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/config/OciVaultPostProcessorBridge.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt index 150f6895..adc4e51b 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt @@ -1,5 +1,6 @@ package com.wafflestudio.spring2025.config +import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.oracle.bmc.Region @@ -20,7 +21,9 @@ import java.util.Base64 * with Spring Boot 3.x. */ class OciVaultPostProcessorBridge : EnvironmentPostProcessor { - private val objectMapper = jacksonObjectMapper() + private val objectMapper = + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) override fun postProcessEnvironment( environment: ConfigurableEnvironment, From 53a5aeaa1bd621ae2477a2edaa8d9d595b902473 Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:39:50 +0900 Subject: [PATCH 118/157] fix: handle potential exceptions when retrieving environment properties --- .../spring2025/config/OciVaultPostProcessorBridge.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt index adc4e51b..224e25dd 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt @@ -59,7 +59,12 @@ class OciVaultPostProcessorBridge : EnvironmentPostProcessor { val parsed: Map = objectMapper.readValue(decoded) for ((key, value) in parsed) { - val existing = environment.getProperty(key) + val existing = + try { + environment.getProperty(key) + } catch (_: Exception) { + null + } if (existing.isNullOrBlank()) { secrets[key] = value } From 9cf6fdc789166d436374cd05a4bb2b33873e2972 Mon Sep 17 00:00:00 2001 From: s3mng <104117184+s3mng@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:03:46 +0900 Subject: [PATCH 119/157] refactor: simplify secret retrieval logic in OciVaultPostProcessorBridge --- .../spring2025/config/OciVaultPostProcessorBridge.kt | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt index 224e25dd..aaac34e7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt @@ -58,17 +58,7 @@ class OciVaultPostProcessorBridge : EnvironmentPostProcessor { val decoded = String(Base64.getDecoder().decode(content.content)) val parsed: Map = objectMapper.readValue(decoded) - for ((key, value) in parsed) { - val existing = - try { - environment.getProperty(key) - } catch (_: Exception) { - null - } - if (existing.isNullOrBlank()) { - secrets[key] = value - } - } + secrets.putAll(parsed) } catch (e: Exception) { System.err.println("[OciVault] Failed to fetch secret $secretId: ${e.message}") } From 82b8dd85c331eabdee801e00be10d78c66978c0b Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 7 Apr 2026 22:22:19 +0900 Subject: [PATCH 120/157] =?UTF-8?q?:bug:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EB=B9=88=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 67bcfa7c..3d04db1c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -48,9 +48,9 @@ email: oauth: providers: google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + redirect-uri: ${GOOGLE_REDIRECT_URI:} logging: level: From bbbb976d70c92a285d24986e89ca5c9cc1e6ed03 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 7 Apr 2026 22:54:44 +0900 Subject: [PATCH 121/157] :bug: removed duplicate cors layer --- .../kotlin/com/wafflestudio/spring2025/config/WebConfig.kt | 2 ++ src/main/resources/application.yaml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/config/WebConfig.kt b/src/main/kotlin/com/wafflestudio/spring2025/config/WebConfig.kt index ead78181..9d44a658 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/config/WebConfig.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/config/WebConfig.kt @@ -21,6 +21,8 @@ class WebConfig( } override fun addCorsMappings(registry: CorsRegistry) { + val origins = corsProperties.getAllowedOriginsList() + if (origins.isEmpty()) return registry .addMapping("/**") .allowedOrigins(*corsProperties.getAllowedOriginsList().toTypedArray()) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3d04db1c..00626212 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -105,7 +105,7 @@ spring: username: moiming-dev password: cors: - allowed-origins: https://dev.moiming.wafflestudio.com + allowed-origins: "" oci: vault: secret-ids: ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqaa3m45icdqcka43cvyzb4kb3ktuatfobwusnsdd4bvzkq @@ -120,7 +120,7 @@ spring: url: jdbc:mysql://10.0.30.217:3306/moiming_prod?allowPublicKeyRetrieval=true username: moiming-prod cors: - allowed-origins: https://moiming.wafflestudio.com + allowed-origins: "" oci: vault: secret-ids: ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqameflpkt7abivvrauqwheybzrkqipf7msv3ol6ikhd3aq From 646d30af3ae45de05331a7d7d15eb28f0dfae108 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 12 Apr 2026 20:59:18 +0900 Subject: [PATCH 122/157] =?UTF-8?q?:seedling:=20email=20smtp=20host=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/common/email/client/JavaMailEmailClient.kt | 5 ++--- src/main/resources/application.yaml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt index 5468f780..a76969dc 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt @@ -25,8 +25,8 @@ class JavaMailEmailClient( fromEmail: String, fromName: String, ) { - // 지금 AWS SDK를 쓰는 게 아니라 그냥 email config를 AWS SES의 SMTP 엔드포인트로 해놓은 상황 - // (SDK가 아닌 SMTP 방식으로 SES 사용) + // OCI Email Delivery의 SMTP 엔드포인트를 사용 (SDK가 아닌 SMTP 방식) + // 발신 주소는 OCI 콘솔 Approved Senders에 등록되어 있어야 합니다. try { val message: MimeMessage = javaMailSender.createMimeMessage() val helper = MimeMessageHelper(message, "UTF-8") @@ -36,7 +36,6 @@ class JavaMailEmailClient( helper.setText(htmlContent, true) helper.setFrom("$fromName <$fromEmail>") - // AWS SES 샌드박스 모드에서는 발신/수신 주소 모두 사전 인증(Verified)이 필요합니다. javaMailSender.send(message) } catch (e: MessagingException) { logger.error("메일 구성 실패: to={}, subject={}", to, subject, e) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 00626212..7fefbb20 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,7 +9,7 @@ spring: password: somepassword driver-class-name: com.mysql.cj.jdbc.Driver mail: - host: email-smtp.us-east-1.amazonaws.com + host: smtp.email.ap-chuncheon-1.oci.oraclecloud.com port: 587 username: ${OCI_EMAIL_SMTP_USERNAME:YOUR_OCI_SMTP_USERNAME} password: ${OCI_EMAIL_SMTP_PASSWORD:YOUR_OCI_SMTP_PASSWORD} From ed1f9d61fe85b47b9345921555821004d31a9aec Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 12 Apr 2026 21:28:16 +0900 Subject: [PATCH 123/157] =?UTF-8?q?:label:=20event=20response=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0,=20=EC=B0=B8=EC=97=AC=EC=9E=90=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=EC=9E=90=20=ED=95=84=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/dto/response/EventDetailResponse.kt | 6 ++-- .../dto/response/MyEventsInfiniteResponse.kt | 3 +- .../domain/event/service/EventService.kt | 6 ++-- .../spring2025/EventIntegrationTest.kt | 28 ++++++++++++++++--- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt index 08d7cccd..84e7616a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt @@ -33,8 +33,10 @@ data class EventInfo( val endsAt: Instant?, @Schema(description = "정원", example = "10", nullable = true) val capacity: Int?, - @Schema(description = "총 신청자 수(확정+대기 등)", example = "8") - val totalApplicants: Int, + @Schema(description = "참여자 수(참여 확정 인원)", example = "5") + val confirmedCount: Int, + @Schema(description = "대기자 수", example = "5") + val waitlistCount: Int, @Schema(description = "신청 시작 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z", nullable = true) val registrationStartsAt: Instant?, @Schema(description = "신청 마감 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z") diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt index a1b21d36..1cac6c53 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt @@ -16,5 +16,6 @@ data class MyEventResponse( val registrationStartsAt: Instant?, val registrationEndsAt: Instant, val capacity: Int?, - val totalApplicants: Int, + val confirmedCount: Int, + val waitlistCount: Int, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index bbcf16ba..e52f056d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -228,7 +228,8 @@ class EventService( startsAt = event.startsAt, endsAt = event.endsAt, capacity = event.capacity, - totalApplicants = totalApplicants, + confirmedCount = confirmedCount, + waitlistCount = waitlistedCount, registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, ), @@ -297,7 +298,8 @@ class EventService( registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, capacity = event.capacity, - totalApplicants = confirmedCount + waitlistedCount, + confirmedCount = confirmedCount, + waitlistCount = waitlistedCount, ) } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt index 832e3e0b..aa96cba1 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -357,7 +357,8 @@ class EventIntegrationTest .andExpect(jsonPath("$.event.publicId").value(event.publicId)) .andExpect(jsonPath("$.event.title").value("공개 이벤트")) .andExpect(jsonPath("$.event.capacity").value(10)) - .andExpect(jsonPath("$.event.totalApplicants").value(0)) + .andExpect(jsonPath("$.event.confirmedCount").value(0)) + .andExpect(jsonPath("$.event.waitlistCount").value(0)) .andExpect(jsonPath("$.creator.name").value(creator.name)) .andExpect(jsonPath("$.creator.email").value(creator.email)) .andExpect(jsonPath("$.viewer.status").value("NONE")) @@ -536,7 +537,7 @@ class EventIntegrationTest } @Test - fun `totalApplicants는 CONFIRMED + WAITLISTED 수의 합이다`() { + fun `confirmedCnt는 참여자(확정) 수를 반영한다`() { val (creator, _) = dataGenerator.generateUser() val (user1, _) = dataGenerator.generateUser() val (user2, _) = dataGenerator.generateUser() @@ -550,7 +551,25 @@ class EventIntegrationTest .perform( get("/api/events/${event.publicId}"), ).andExpect(status().isOk) - .andExpect(jsonPath("$.event.totalApplicants").value(3)) + .andExpect(jsonPath("$.event.confirmedCount").value(2)) + } + + @Test + fun `waitlistCount는 대기자 수를 반영한다`() { + val (creator, _) = dataGenerator.generateUser() + val (user1, _) = dataGenerator.generateUser() + val (user2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 2, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = creator.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = user1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = user2.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.event.waitlistCount").value(1)) } // ================================================================= @@ -627,7 +646,8 @@ class EventIntegrationTest .andExpect(jsonPath("$.events[0].publicId").value(event.publicId)) .andExpect(jsonPath("$.events[0].title").value("필드 검증 이벤트")) .andExpect(jsonPath("$.events[0].capacity").value(20)) - .andExpect(jsonPath("$.events[0].totalApplicants").value(0)) + .andExpect(jsonPath("$.events[0].confirmedCount").value(0)) + .andExpect(jsonPath("$.events[0].waitlistCount").value(0)) } @Test From 36bd17a7070089b4800c1a6202712802e3fe792f Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 14 Apr 2026 22:50:57 +0900 Subject: [PATCH 124/157] =?UTF-8?q?:recycle:=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=EC=9D=98=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=EB=90=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/common/email/service/EmailService.kt | 4 ++++ .../common/email/template/registration-confirmed.html | 2 +- .../common/email/template/registration-demoted.html | 2 +- .../common/email/template/registration-waitlist-promoted.html | 2 +- .../common/email/template/registration-waitlisted.html | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index 5ae525fb..e6ce6809 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -133,6 +133,7 @@ class EmailService( RegistrationStatus.CONFIRMED -> { val htmlContent = loadTemplate("registration-confirmed.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", data.name) .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) @@ -158,6 +159,7 @@ class EmailService( RegistrationStatus.WAITLISTED -> { val htmlContent = loadTemplate("registration-waitlisted.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", data.name) .replace("{waitingNum}", data.waitingNum?.toString() ?: "-") .replace("{eventTitle}", formatEventTitle(data.eventTitle)) @@ -253,6 +255,7 @@ class EmailService( fun sendDemotionEmail(data: DemotionEmailData) { val htmlContent = loadTemplate("registration-demoted.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", data.name) .replace("{waitingNum}", data.waitingNum?.toString() ?: "-") .replace("{newCapacity}", data.newCapacity.toString()) @@ -293,6 +296,7 @@ class EmailService( ) { val htmlContent = loadTemplate("registration-waitlist-promoted.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", name) .replace("{waitingNum}", waitingNum?.toString() ?: "-") .replace("{eventTitle}", formatEventTitle(eventTitle)) diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html index 69e8991a..df7afd8c 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html @@ -99,7 +99,7 @@ style="font-size: 16px; line-height: 1.7; color: #333; margin-bottom: 50px; white-space: wrap; word-break: break-all;"> {description} - 취소하기 diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html index de5ac2a6..0b40c68c 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html @@ -108,7 +108,7 @@ style="font-size: 16px; line-height: 1.7; color: #333; margin-bottom: 50px; white-space: wrap; word-break: break-all;"> {description} - 취소하기 diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html index f9c23f1c..3b181918 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html @@ -99,7 +99,7 @@ style="font-size: 16px; line-height: 1.7; color: #333; margin-bottom: 50px; white-space: wrap; word-break: break-all;"> {description} - 취소하기 diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html index 9edf0de0..6ba1ae53 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html @@ -108,7 +108,7 @@ style="font-size: 16px; line-height: 1.7; color: #333; margin-bottom: 50px; white-space: wrap; word-break: break-all;"> {description} - 취소하기 From 75e407aee471f3ef2c9d68f5024424b7d1a5b048 Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 14 Apr 2026 23:07:34 +0900 Subject: [PATCH 125/157] refactor(openapi): migrate from swagger annotations to static openapi.yaml --- .github/PULL_REQUEST_TEMPLATE/dev.md | 2 + .github/PULL_REQUEST_TEMPLATE/main.md | 1 + .github/workflows/ci.yml | 8 + .../image/controller/ImageController.kt | 15 +- .../domain/auth/controller/AuthController.kt | 40 - .../auth/controller/SocialAuthController.kt | 11 - .../domain/auth/dto/LoginRequest.kt | 5 - .../domain/auth/dto/RegisterRequest.kt | 7 - .../domain/auth/dto/SignupRequest.kt | 7 - .../domain/auth/dto/SocialLoginRequest.kt | 5 - .../event/controller/EventController.kt | 11 - .../domain/event/dto/core/EventDto.kt | 15 - .../event/dto/request/CreateEventRequest.kt | 23 - .../event/dto/request/UpdateEventRequest.kt | 23 - .../event/dto/response/EventDetailResponse.kt | 52 - .../domain/event/dto/response/GuestPreview.kt | 10 - .../domain/event/dto/response/MyRole.kt | 6 - .../domain/event/dto/response/ViewerStatus.kt | 8 - .../controller/EventRegistrationController.kt | 36 - .../controller/MyRegistrationController.kt | 10 +- .../controller/RegistrationController.kt | 14 - .../registration/dto/core/RegistrationDto.kt | 9 - .../dto/core/RegistrationWithEventDto.kt | 10 - .../dto/request/CreateRegistrationRequest.kt | 4 - .../dto/request/DeleteRegistrationRequest.kt | 5 - .../UpdateRegistrationStatusRequest.kt | 2 - .../response/CreateRegistrationResponse.kt | 4 - .../response/GetEventRegistrationsResponse.kt | 14 - .../response/GetMyRegistrationsResponse.kt | 14 - .../dto/response/GetRegistrationResponse.kt | 2 - .../dto/response/PatchRegistrationResponse.kt | 3 - .../response/RegistrationGuestsResponse.kt | 10 - .../domain/user/controller/UserController.kt | 54 +- .../user/identity/dto/core/UserIdentityDto.kt | 7 - src/main/resources/application.yaml | 7 + src/main/resources/static/docs/openapi.yaml | 1181 +++++++++++++++++ .../resources/static/docs/swagger-ui.html | 36 + 37 files changed, 1239 insertions(+), 432 deletions(-) create mode 100644 src/main/resources/static/docs/openapi.yaml create mode 100644 src/main/resources/static/docs/swagger-ui.html diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index ad0b6ad0..558f1a66 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -2,6 +2,8 @@ ### 🧪 로컬 테스트 여부 (작업자 체크) - [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. +- [ ] API 변경이 있다면 `src/main/resources/static/docs/openapi.yaml`을 함께 갱신했습니다. +- [ ] `/docs/swagger-ui.html`에서 변경 API가 의도대로 보이는지 확인했습니다. ### 📄 documentation 최신화 여부 > 변경사항과 관련된 API의 swagger documentation이 실제 동작과 일치하는지 확인합니다. diff --git a/.github/PULL_REQUEST_TEMPLATE/main.md b/.github/PULL_REQUEST_TEMPLATE/main.md index e46646af..15cd0692 100644 --- a/.github/PULL_REQUEST_TEMPLATE/main.md +++ b/.github/PULL_REQUEST_TEMPLATE/main.md @@ -5,6 +5,7 @@ - [ ] CI 성공 - [ ] 환경 변수 설정 확인 - [ ] dev 서버에서 변경사항에 대해 의도대로 동작함을 확인 +- [ ] API 변경이 있다면 `src/main/resources/static/docs/openapi.yaml` 반영 완료 ### 📝 업데이트 사항 요약 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d75114a4..80cda61f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,14 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Lint OpenAPI YAML + run: npx @redocly/cli@latest lint src/main/resources/static/docs/openapi.yaml + - name: Set up JDK 21 uses: actions/setup-java@v4 with: diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt index d774bdb0..1cdb9ea8 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt @@ -4,10 +4,6 @@ import com.wafflestudio.spring2025.common.image.dto.ImageUploadResponse import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.auth.LoggedInUser import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -22,21 +18,12 @@ import org.springframework.web.multipart.MultipartFile class ImageController( private val imageService: ImageService, ) { - @Operation(summary = "이미지 업로드", description = "서버 저장소에 이미지를 업로드합니다.") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "이미지 업로드 성공"), - ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 누락/형식 오류 등)"), - ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), - ], - ) @PostMapping( consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], ) fun uploadImage( - @Parameter(hidden = true) @LoggedInUser user: User?, + @LoggedInUser user: User?, @RequestPart("image") image: MultipartFile, - @Parameter(description = "이미지를 저장할 상위 경로", required = false) @RequestParam(name = "prefix", required = false) prefix: String?, ): ResponseEntity { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt index fae13d16..f35d874e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt @@ -5,10 +5,6 @@ import com.wafflestudio.spring2025.domain.auth.dto.LoginResponse import com.wafflestudio.spring2025.domain.auth.dto.SignupRequest import com.wafflestudio.spring2025.domain.auth.service.AuthService import com.wafflestudio.spring2025.domain.auth.service.EmailVerificationService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import jakarta.servlet.http.HttpServletRequest import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable @@ -19,23 +15,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/auth") -@Tag(name = "Auth", description = "인증 API") class AuthController( private val authService: AuthService, private val emailVerificationService: EmailVerificationService, ) { - @Operation( - summary = "이메일 회원가입", - description = "새로운 사용자를 등록하고 인증 이메일을 발송합니다. 이메일 인증 후 가입이 완료됩니다.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "204", description = "인증 이메일 발송 성공"), - ApiResponse(responseCode = "400", description = "잘못된 요청 (유효하지 않은 email/password/name)"), - ApiResponse(responseCode = "409", description = "이미 존재하는 email"), - ApiResponse(responseCode = "503", description = "이메일 서비스 장애"), - ], - ) @PostMapping("/signup") fun signup( @RequestBody signupRequest: SignupRequest, @@ -51,16 +34,6 @@ class AuthController( return ResponseEntity.noContent().build() } - @Operation( - summary = "이메일 인증", - description = "이메일로 받은 인증 코드를 검증하고 회원가입을 완료합니다", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "인증 성공, 유저 등록 완료"), - ApiResponse(responseCode = "400", description = "유효하지 않거나 만료된 인증 코드"), - ], - ) @PostMapping("/email-verification/{verificationCode}") fun verifyEmail( @PathVariable verificationCode: String, @@ -69,13 +42,6 @@ class AuthController( return ResponseEntity.ok().build() } - @Operation(summary = "로그인", description = "email로 로그인하여 JWT 토큰을 발급받습니다") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "로그인 성공, JWT 토큰 반환"), - ApiResponse(responseCode = "401", description = "인증 실패"), - ], - ) @PostMapping("/login") fun login( @RequestBody loginRequest: LoginRequest, @@ -84,12 +50,6 @@ class AuthController( return ResponseEntity.ok(LoginResponse(token)) } - @Operation(summary = "로그아웃", description = "현재 JWT 토큰을 블랙리스트에 추가하여 로그아웃합니다") - @ApiResponses( - value = [ - ApiResponse(responseCode = "204", description = "로그아웃 성공"), - ], - ) @PostMapping("/logout") fun logout(request: HttpServletRequest): ResponseEntity { val token = resolveToken(request) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt index b43a4ced..dbd09f73 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt @@ -4,9 +4,6 @@ import com.wafflestudio.spring2025.domain.auth.dto.LoginResponse import com.wafflestudio.spring2025.domain.auth.dto.SocialLoginRequest import com.wafflestudio.spring2025.domain.auth.model.SocialProvider import com.wafflestudio.spring2025.domain.auth.service.SocialAuthService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -18,14 +15,6 @@ import org.springframework.web.bind.annotation.RestController class SocialAuthController( private val socialAuthService: SocialAuthService, ) { - @Operation(summary = "소셜 로그인", description = "소셜 로그인을 요청합니다.") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "소셜 로그인 성공"), - ApiResponse(responseCode = "401", description = "인증 실패"), - ApiResponse(responseCode = "409", description = "이미 가입된 이메일"), - ], - ) @PostMapping suspend fun social( @RequestBody socialLoginRequest: SocialLoginRequest, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt index bf17a881..0d5ffea7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt @@ -1,11 +1,6 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "로그인 요청") data class LoginRequest( - @Schema(description = "사용자 이메일", example = "user@example.com", required = true) val email: String, - @Schema(description = "사용자 비밀번호", example = "mypassword", required = true) val password: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt index 470bcb7f..32ee7ed3 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt @@ -1,15 +1,8 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "회원가입 요청") data class RegisterRequest( - @Schema(description = "사용자 이메일", example = "user@example.com", required = true) val email: String, - @Schema(description = "사용자 이름", example = "홍길동", required = true) val name: String, - @Schema(description = "비밀번호", example = "password123", required = true) val password: String, - @Schema(description = "프로필 이미지 URL", example = "https://cdn.example.com/profile.png") val profileImage: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt index 94c273ff..53618934 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt @@ -1,15 +1,8 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "회원가입 요청") data class SignupRequest( - @Schema(description = "사용자 이메일", example = "user@example.com", required = true) val email: String, - @Schema(description = "사용자 이름", example = "홍길동", required = true) val name: String, - @Schema(description = "비밀번호", example = "password123", required = true) val password: String, - @Schema(description = "프로필 이미지 URL", example = "https://cdn.example.com/profile.png") val profileImage: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt index f83e60f3..28fc2bad 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt @@ -1,11 +1,6 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "소셜 로그인 요청") data class SocialLoginRequest( - @Schema(description = "소셜로그인 제공자", example = "GOOGLE", required = true) val provider: String, - @Schema(description = "인가 코드 (authorization code)", required = true) val code: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt index 7ba5fae8..7e1a9de5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt @@ -10,8 +10,6 @@ import com.wafflestudio.spring2025.domain.event.dto.response.MyEventsInfiniteRes import com.wafflestudio.spring2025.domain.event.dto.response.UpdateEventResponse import com.wafflestudio.spring2025.domain.event.service.EventService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -26,12 +24,10 @@ import java.time.Instant @RestController @RequestMapping("/api/events") -@Tag(name = "Event", description = "이벤트 관리 API") class EventController( private val eventService: EventService, ) { @AuthRequired - @Operation(summary = "이벤트 생성", description = "새로운 이벤트를 생성합니다") @PostMapping // POST /api/events fun create( @LoggedInUser user: User, @@ -54,7 +50,6 @@ class EventController( return ResponseEntity.ok(CreateEventResponse(publicId)) } - @Operation(summary = "이벤트 상세 조회", description = "이벤트 상세 정보를 조회합니다") @GetMapping("/{publicId}") // GET /api/events/{publicId} fun getById( @LoggedInUser user: User?, // nullable @@ -69,10 +64,6 @@ class EventController( } @AuthRequired - @Operation( - summary = "내가 만든 이벤트 목록 조회 (무한 스크롤)", - description = "로그인된 사용자가 생성한 이벤트 목록을 무한 스크롤 방식(cursor)으로 조회합니다", - ) @GetMapping("/me") fun getMyEvents( @LoggedInUser user: User, @@ -89,7 +80,6 @@ class EventController( } @AuthRequired - @Operation(summary = "이벤트 수정", description = "이벤트를 수정합니다") @PutMapping("/{publicId}") // PUT /api/events/{publicId} fun update( @LoggedInUser user: User, @@ -116,7 +106,6 @@ class EventController( } @AuthRequired - @Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다") @DeleteMapping("/{publicId}") // DELETE /api/events/{publicId} fun delete( @LoggedInUser user: User, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt index 2b4765a2..778ea5ca 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt @@ -1,35 +1,20 @@ package com.wafflestudio.spring2025.domain.event.dto.core import com.wafflestudio.spring2025.domain.event.model.Event -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "일정 정보") data class EventDto( - @Schema(description = "일정 ID (publicId)") val id: String, - @Schema(description = "이벤트 제목") val title: String, - @Schema(description = "이벤트 설명") val description: String?, - @Schema(description = "장소") val location: String?, - @Schema(description = "시작 시간 (epoch milliseconds)") val startsAt: Long?, - @Schema(description = "종료 시간 (epoch milliseconds)") val endsAt: Long?, - @Schema(description = "정원") val capacity: Int?, - @Schema(description = "대기 명단 사용 여부") val waitlistEnabled: Boolean, - @Schema(description = "신청 시작 시간 (epoch milliseconds)") val registrationStartsAt: Long?, - @Schema(description = "신청 마감 시간 (epoch milliseconds)") val registrationEndsAt: Long, - @Schema(description = "작성자 ID") val createdBy: Long, - @Schema(description = "생성 일시 (epoch milliseconds)") val createdAt: Long?, - @Schema(description = "수정 일시 (epoch milliseconds)") val updatedAt: Long?, ) { constructor(event: Event) : this( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt index db6254ca..1c46a314 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt @@ -1,38 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.request -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 생성 요청") data class CreateEventRequest( - @Schema(description = "이벤트 제목", example = "정기 모임") val title: String, - @Schema(description = "이벤트 설명", example = "정기 모임입니다") val description: String? = null, - @Schema(description = "장소", example = "강의실 101") val location: String? = null, - @Schema( - description = "시작 시간 (ISO-8601)", - example = "2026-02-02T18:00:00Z", - ) val startsAt: Instant? = null, - @Schema( - description = "종료 시간 (ISO-8601)", - example = "2026-02-02T20:00:00Z", - ) val endsAt: Instant? = null, - @Schema(description = "정원", example = "30") val capacity: Int, - @Schema(description = "대기 명단 사용 여부", example = "true") val waitlistEnabled: Boolean, - @Schema( - description = "신청 시작 시간 (ISO-8601). null인 경우 요청 시점으로 자동 설정됩니다.", - example = "2026-02-02T17:00:00Z", - ) val registrationStartsAt: Instant? = null, - @Schema( - description = "신청 마감 시간 (ISO-8601)", - example = "2026-02-02T17:30:00Z", - ) val registrationEndsAt: Instant, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt index e237b56c..2206d5c5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt @@ -1,38 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.request -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 수정 요청") data class UpdateEventRequest( - @Schema(description = "이벤트 제목", example = "정기 모임") val title: String? = null, - @Schema(description = "이벤트 설명", example = "정기 모임입니다") val description: String? = null, - @Schema(description = "장소", example = "강의실 101") val location: String? = null, - @Schema( - description = "시작 시간 (ISO-8601)", - example = "2026-02-02T18:00:00Z", - ) val startsAt: Instant? = null, - @Schema( - description = "종료 시간 (ISO-8601)", - example = "2026-02-02T20:00:00Z", - ) val endsAt: Instant? = null, - @Schema(description = "정원", example = "30") val capacity: Int? = null, - @Schema(description = "대기 명단 사용 여부", example = "true") val waitlistEnabled: Boolean? = null, - @Schema( - description = "신청 시작 시간 (ISO-8601)", - example = "2026-02-02T17:00:00Z", - ) val registrationStartsAt: Instant? = null, - @Schema( - description = "신청 마감 시간 (ISO-8601)", - example = "2026-02-02T17:30:00Z", - ) val registrationEndsAt: Instant? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt index 08d7cccd..4456f298 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt @@ -1,97 +1,45 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 상세 조회 응답") data class EventDetailResponse( - @Schema(description = "이벤트 정보") val event: EventInfo, - @Schema(description = "이벤트 생성자 정보") val creator: CreatorInfo, - @Schema(description = "조회자(viewer) 정보") val viewer: ViewerInfo, - @Schema(description = "조회자가 할 수 있는 동작") val capabilities: CapabilitiesInfo, - @Schema(description = "참여자 미리보기 목록") val guestsPreview: List, ) -@Schema(description = "이벤트 정보 블록") data class EventInfo( - @Schema(description = "이벤트 publicId", example = "dj8sadj10djnkas") val publicId: String, - @Schema(description = "이벤트 제목", example = "제2회 기획 세미나") val title: String, - @Schema(description = "이벤트 설명", example = "일정 상세 설명...", nullable = true) val description: String?, - @Schema(description = "장소", example = "서울대", nullable = true) val location: String?, - @Schema(description = "시작 시간 (ISO-8601)", example = "2026-02-02T18:00:00Z", nullable = true) val startsAt: Instant?, - @Schema(description = "종료 시간 (ISO-8601)", example = "2026-02-02T20:00:00Z", nullable = true) val endsAt: Instant?, - @Schema(description = "정원", example = "10", nullable = true) val capacity: Int?, - @Schema(description = "총 신청자 수(확정+대기 등)", example = "8") val totalApplicants: Int, - @Schema(description = "신청 시작 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z", nullable = true) val registrationStartsAt: Instant?, - @Schema(description = "신청 마감 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z") val registrationEndsAt: Instant, ) -@Schema(description = "생성자 정보 블록") data class CreatorInfo( - @Schema(description = "이름", example = "홍길동") val name: String, - @Schema(description = "이메일", example = "user@example.com") val email: String, - @Schema(description = "프로필 이미지", example = "https://example.com/profile.png", nullable = true) val profileImage: String?, ) -@Schema(description = "조회자(viewer) 정보 블록") data class ViewerInfo( - @Schema( - description = "viewer의 등록 상태 (RegistrationStatus 사용)", - example = "WAITLISTED", - ) val status: ViewerStatus, - @Schema( - description = "viewer 이름 (비회원이면 null)", - example = "홍길동", - nullable = true, - ) val name: String?, - @Schema( - description = "대기 순번 (WAITLISTED일 때만 숫자, 아니면 null)", - example = "3", - nullable = true, - ) val waitlistPosition: Int?, - @Schema( - description = "registrationPublicId (게스트면 값, 아니면 null)", - example = "1", - nullable = true, - ) val registrationPublicId: String?, - @Schema( - description = "예약 이메일(게스트면 guestEmail, 로그인 유저면 user email 등 정책에 따름)", - example = "skdfsj@gmaldkl", - nullable = true, - ) val reservationEmail: String?, ) -@Schema(description = "가능한 동작 블록") data class CapabilitiesInfo( - @Schema(description = "공유 링크 가능 여부", example = "false") val shareLink: Boolean, - @Schema(description = "참여 신청 가능 여부(확정 자리 신청)", example = "false") val apply: Boolean, - @Schema(description = "대기 신청 가능 여부(정원 찼을 때)", example = "false") val wait: Boolean, - @Schema(description = "신청 취소 가능 여부", example = "true") val cancel: Boolean, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt index 01781f48..2f3314a7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt @@ -1,21 +1,11 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * 이벤트 참여자 미리보기 정보 * (회원/비회원 포함) */ -@Schema(description = "참여자 미리보기 정보") data class GuestPreview( - @Schema(description = "참여자 ID (비회원이면 null)", example = "1", nullable = true) val id: Long?, - @Schema(description = "참여자 이름", example = "홍길동") val name: String, - @Schema( - description = "프로필 이미지 URL", - example = "https://example.com/profile.png", - nullable = true, - ) val profileImage: String?, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt index d1a8b89a..c06024ed 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt @@ -1,21 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * 요청자 역할 * - CREATOR: 이벤트 생성자 * - PARTICIPANT: 이벤트 참여자 (CONFIRMED / WAITLISTED) * - NONE: 로그인은 했지만 참여하지 않음 */ -@Schema(description = "요청자 역할") enum class MyRole { - @Schema(description = "이벤트 생성자") CREATOR, - @Schema(description = "이벤트 참여자") PARTICIPANT, - @Schema(description = "이벤트 미참여자") NONE, } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt index a250a6db..d85fe72a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt @@ -1,25 +1,17 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * 이벤트 상세 조회 시, * "viewer(요청자)"가 해당 이벤트와 어떤 관계에 있는지를 나타내는 상태 */ -@Schema(description = "이벤트 조회자(Viewer)의 상태") enum class ViewerStatus { - @Schema(description = "이벤트 생성자") HOST, - @Schema(description = "참여 확정된 사용자") CONFIRMED, - @Schema(description = "대기 중인 사용자") WAITLISTED, - @Schema(description = "강제 취소(차단)된 사용자") BANNED, - @Schema(description = "이벤트와 아무 관계 없는 사용자") NONE, } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt index 4fb815e9..213bc67d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt @@ -6,11 +6,6 @@ import com.wafflestudio.spring2025.domain.registration.dto.response.CreateRegist import com.wafflestudio.spring2025.domain.registration.dto.response.GetEventRegistrationsResponse import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -22,35 +17,9 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/events/{eventId}/registrations") -@Tag(name = "Registration", description = "이벤트 기준 신청 API") class EventRegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "이벤트 참여 신청", - description = - "특정 이벤트에 신청합니다. 정원이 남아있으면 CONFIRMED로 등록되고, 정원이 찼으나 대기 명단이 허용된 경우 WAITLISTED로 등록됩니다. " + - "정원이 찼고 대기 명단이 비활성화된 경우 신청이 거절됩니다.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "신청 성공"), - ApiResponse( - responseCode = "400", - description = - "유효하지 않은 요청: 3003(Wrong guest name), 3004(Wrong guest email), 2204(Invalid registration window)", - ), - ApiResponse( - responseCode = "403", - description = "권한 없음: 3006(Banned user cannot register)", - ), - ApiResponse(responseCode = "404", description = "이벤트 없음: 2001(Event not found)"), - ApiResponse( - responseCode = "409", - description = "신청 불가: 3001(Registration already exists), 3005(Host cannot register), 2301(Event is full)", - ), - ], - ) @PostMapping fun create( @PathVariable eventId: String, @@ -67,17 +36,12 @@ class EventRegistrationController( return ResponseEntity.ok(registration) } - @Operation( - summary = "이벤트 신청 목록 조회", - description = "특정 이벤트에 속한 신청 목록을 조회합니다. 신청 상태(CONFIRMED/WAITLISTED/BANNED)와 신청 시각을 포함해 반환합니다.", - ) @GetMapping fun list( @PathVariable eventId: String, @LoggedInUser user: User?, @RequestParam(required = false) status: String?, @RequestParam(required = false) orderBy: String?, - @Parameter(description = "다음 페이지 시작 offset (없으면 0부터)") @RequestParam(required = false) cursor: Int?, ): ResponseEntity = ResponseEntity.ok( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt index 6da5b2c9..71022123 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt @@ -5,9 +5,6 @@ import com.wafflestudio.spring2025.domain.auth.LoggedInUser import com.wafflestudio.spring2025.domain.registration.dto.response.GetMyRegistrationsResponse import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -17,17 +14,12 @@ import org.springframework.web.bind.annotation.RestController @AuthRequired @RestController @RequestMapping("/api/registrations") -@Tag(name = "Registration", description = "내 신청 조회 API") class MyRegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "내 이벤트 신청 목록 조회", - description = "로그인한 사용자의 이벤트 신청 목록을 조회합니다. 최신 신청 순으로 반환되며 page/size로 페이징합니다.", - ) @GetMapping("/me") fun myRegistrations( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int, ): ResponseEntity { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt index 2ffccbe6..f2568d12 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt @@ -10,8 +10,6 @@ import com.wafflestudio.spring2025.domain.registration.exception.RegistrationErr import com.wafflestudio.spring2025.domain.registration.exception.RegistrationValidationException import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -23,16 +21,9 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/registrations/{registrationId}") -@Tag(name = "Registration", description = "신청 단건 리소스 API") class RegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "신청 단건 상태 변경", - description = - "신청 단건 리소스의 상태를 변경합니다. " + - "사용자가 본인의 신청을 취소하는 경우와 관리자가 강제 취소/밴 처리하는 경우를 로직 레벨에서 구분합니다.", - ) @PatchMapping fun updateStatus( @PathVariable registrationId: String, @@ -45,11 +36,6 @@ class RegistrationController( return ResponseEntity.ok(response) } - @Operation( - summary = "신청 단건 정보 요청", - description = - "신청 단건의 정보를 반환합니다.", - ) @GetMapping fun getRegistrationInformation( @PathVariable registrationId: String, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt index e6764f91..33f1410e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt @@ -2,23 +2,14 @@ package com.wafflestudio.spring2025.domain.registration.dto.core import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 신청 정보") data class RegistrationDto( - @Schema(description = "신청 공개 ID") val registrationPublicId: String, - @Schema(description = "사용자 ID") val userId: Long?, - @Schema(description = "이벤트 ID") val eventId: Long, - @Schema(description = "비회원 이름") val guestName: String?, - @Schema(description = "비회원 이메일") val guestEmail: String?, - @Schema(description = "신청 상태") val status: RegistrationStatus, - @Schema(description = "신청 일시 (epoch milliseconds)") val createdAt: Long, ) { constructor(registration: Registration) : this( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt index 5255eb9e..70c08a66 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt @@ -2,13 +2,9 @@ package com.wafflestudio.spring2025.domain.registration.dto.core import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.registration.model.Registration -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "내 이벤트 신청 정보") data class RegistrationWithEventDto( - @Schema(description = "신청 정보") val registration: RegistrationDto, - @Schema(description = "이벤트 요약 정보") val event: EventSummaryDto, ) { constructor(registration: Registration, event: Event) : this( @@ -17,17 +13,11 @@ data class RegistrationWithEventDto( ) } -@Schema(description = "이벤트 요약 정보") data class EventSummaryDto( - @Schema(description = "이벤트 ID") val id: Long, - @Schema(description = "이벤트 제목") val title: String, - @Schema(description = "장소") val location: String?, - @Schema(description = "시작 시간 (epoch milliseconds)") val startsAt: Long?, - @Schema(description = "종료 시간 (epoch milliseconds)") val endsAt: Long?, ) { constructor(event: Event) : this( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt index baf7052f..820bd1e5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt @@ -1,14 +1,10 @@ package com.wafflestudio.spring2025.domain.registration.dto.request import com.fasterxml.jackson.annotation.JsonAlias -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 신청 요청") data class CreateRegistrationRequest( - @Schema(description = "비회원 이름") @JsonAlias("guest_name") val guestName: String? = null, - @Schema(description = "비회원 이메일") @JsonAlias("guest_email") val guestEmail: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt index 3f34c5f8..0df46b98 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt @@ -1,11 +1,6 @@ package com.wafflestudio.spring2025.domain.registration.dto.request -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벤트 취소 요청") data class DeleteRegistrationRequest( - @Schema(description = "비회원 이름") val guestName: String? = null, - @Schema(description = "비회원 이메일") val guestEmail: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt index f87d15fb..2182cddc 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt @@ -1,9 +1,7 @@ package com.wafflestudio.spring2025.domain.registration.dto.request import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 신청 상태 변경 요청") data class UpdateRegistrationStatusRequest( val status: RegistrationStatus? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt index eb4ea566..3abe25e8 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt @@ -1,9 +1,5 @@ package com.wafflestudio.spring2025.domain.registration.dto.response -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벤트 신청 응답") data class CreateRegistrationResponse( - @Schema(description = "신청 id") val registrationPublicId: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt index 39a7951b..c5837bd2 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt @@ -3,36 +3,22 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "이벤트 신청 목록 조회 응답") data class GetEventRegistrationsResponse( - @Schema(description = "신청자 목록") val participants: List, - @Schema(description = "필터에 따라 변하는 참여자 수") val totalCount: Int?, - @Schema(description = "다음 페이지 시작 offset (없으면 null)") val nextCursor: Int?, - @Schema(description = "다음 페이지 존재 여부") val hasNext: Boolean, ) -@Schema(description = "신청자 정보") data class EventRegistrationItem( - @Schema(description = "신청 공개 ID") val registrationId: String, - @Schema(description = "이름") val name: String, - @Schema(description = "이메일 (관리자 요청 시 노출)") val email: String?, - @Schema(description = "프로필 이미지 URL") val profileImage: String?, - @Schema(description = "신청 일시") val createdAt: Instant, - @Schema(description = "신청 상태") val status: RegistrationStatus, - @Schema(description = "대기 순번 (status가 WAITLISTED일 때만 아니면 null)") val waitingNum: Int?, ) { constructor( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt index 74c36966..5c597315 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt @@ -3,36 +3,22 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "내 이벤트 신청 목록 응답") data class GetMyRegistrationsResponse( - @Schema(description = "신청 목록") val registrations: List, ) -@Schema(description = "내 이벤트 신청 요약") data class MyRegistrationItem( - @Schema(description = "이벤트 공개 ID") val publicId: String, - @Schema(description = "이벤트 제목") val title: String, - @Schema(description = "이벤트 시작 시간 (ISO-8601)") val startsAt: Instant?, - @Schema(description = "이벤트 종료 시간 (ISO-8601)") val endsAt: Instant?, - @Schema(description = "신청 시작 시간 (ISO-8601)") val registrationStartsAt: Instant?, - @Schema(description = "신청 마감 시간 (ISO-8601)") val registrationEndsAt: Instant?, - @Schema(description = "정원") val capacity: Int?, - @Schema(description = "현재 신청 수 + 대기 수") val registrationCnt: Int, - @Schema(description = "신청 상태") val status: MyRegistrationStatus, - @Schema(description = "대기 순번 (WAITLISTED가 아니면 null)") val waitlistedNum: Int?, ) { constructor( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt index e03dd30c..d241cd32 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt @@ -1,9 +1,7 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 신청 정보 요청 응답") data class GetRegistrationResponse( val status: RegistrationStatus, val guestName: String, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt index 5f8f534a..e328c6db 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt @@ -1,8 +1,5 @@ package com.wafflestudio.spring2025.domain.registration.dto.response -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벤트 신청 상태 변경") data class PatchRegistrationResponse( val patchEmail: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt index 5d0d0855..fe52c458 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt @@ -1,29 +1,19 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.fasterxml.jackson.annotation.JsonProperty -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 참여자 명단 응답") data class RegistrationGuestsResponse( - @Schema(description = "확정 참여자 목록") val guests: List, @JsonProperty("confirmed_count") - @Schema(description = "확정 참여자 수") val confirmedCount: Int, @JsonProperty("waiting_count") - @Schema(description = "대기 참여자 수") val waitingCount: Int, ) { - @Schema(description = "참여자 정보") data class Guest( - @Schema(description = "신청 공개 ID") val registrationPublicId: String, - @Schema(description = "이름") val name: String, - @Schema(description = "이메일 (관리자 요청 시 노출)") val email: String?, @JsonProperty("profile_image") - @Schema(description = "프로필 이미지 URL") val profileImage: String?, ) } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 85c924bd..8f214cf1 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -7,11 +7,6 @@ import com.wafflestudio.spring2025.domain.user.dto.PatchMeRequest import com.wafflestudio.spring2025.domain.user.dto.PatchMeResponse import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.service.UserService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -22,27 +17,19 @@ import org.springframework.web.bind.annotation.RestController @AuthRequired @RestController @RequestMapping("/api/users") -@Tag(name = "User", description = "사용자 API") class UserController( private val userService: UserService, ) { - @Operation(summary = "본인 정보 조회", description = "로그인한 사용자의 정보를 조회합니다") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공"), - ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), - ], - ) @AuthRequired @GetMapping("/me") fun me( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, ): ResponseEntity = ResponseEntity.ok(userService.me(user)) @AuthRequired @PatchMapping("/me") fun patchMe( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, @RequestBody request: PatchMeRequest, ): ResponseEntity { userService.patchMe( @@ -54,41 +41,4 @@ class UserController( ) return ResponseEntity.ok(userService.me(user)) } - -// @Operation(summary = "프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드(교체)합니다") -// @ApiResponses( -// value = [ -// ApiResponse(responseCode = "204", description = "프로필 이미지 업로드 성공"), -// ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 누락/형식 오류 등)"), -// ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), -// ], -// ) -// @AuthRequired -// @PutMapping( -// "/me/profile-image", -// consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], -// ) -// fun uploadProfileImage( -// @Parameter(hidden = true) @LoggedInUser user: User?, -// @RequestPart("image") image: MultipartFile, -// ): ResponseEntity { -// userService.updateProfileImage(user, image) -// return ResponseEntity.noContent().build() -// } -// -// @Operation(summary = "프로필 이미지 삭제", description = "로그인한 사용자의 프로필 이미지를 삭제(기본 이미지로 복귀)합니다") -// @ApiResponses( -// value = [ -// ApiResponse(responseCode = "204", description = "프로필 이미지 삭제 성공"), -// ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), -// ], -// ) -// @AuthRequired -// @DeleteMapping("/me/profile-image") -// fun deleteProfileImage( -// @Parameter(hidden = true) @LoggedInUser user: User?, -// ): ResponseEntity { -// userService.deleteProfileImage(user) -// return ResponseEntity.noContent().build() -// } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt index 092180d5..1a0f9e05 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt @@ -1,19 +1,12 @@ package com.wafflestudio.spring2025.domain.user.identity.dto.core import com.wafflestudio.spring2025.domain.user.identity.model.UserIdentity -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "소셜 계정 정보") data class UserIdentityDto( - @Schema(description = "소셜 계정 ID") val id: Long, - @Schema(description = "사용자 ID") val userId: Long, - @Schema(description = "소셜 로그인 제공자") val provider: String, - @Schema(description = "제공자 사용자 ID") val providerUserId: String, - @Schema(description = "생성 일시 (epoch milliseconds)") val createdAt: Long, ) { constructor(identity: UserIdentity) : this( diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 00626212..69f7caf8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -95,6 +95,13 @@ aws: server: forward-headers-strategy: framework +springdoc: + api-docs: + enabled: false + swagger-ui: + url: /docs/openapi.yaml + disable-swagger-default-url: true + --- spring: config: diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml new file mode 100644 index 00000000..cdfaeb8b --- /dev/null +++ b/src/main/resources/static/docs/openapi.yaml @@ -0,0 +1,1181 @@ +openapi: 3.0.1 +info: + title: Moiming - Event Schedule Management System API + description: 모임 일정 관리 시스템 '모이밍' API 문서 + version: "1.0" +servers: +- url: http://localhost:8080 + description: Generated server url +tags: +- name: Event + description: 이벤트 관리 API +- name: Registration + description: 신청 API +- name: Auth + description: 인증 API +- name: User + description: 사용자 API +- name: Image + description: 이미지 API +paths: + /api/events/{publicId}: + get: + tags: + - Event + summary: 이벤트 상세 조회 + description: 이벤트 상세 정보를 조회합니다 + parameters: + - name: publicId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/EventDetailResponse" + put: + tags: + - Event + summary: 이벤트 수정 + description: 이벤트를 수정합니다 + security: + - bearerAuth: [] + parameters: + - name: publicId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateEventRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/UpdateEventResponse" + "401": + description: 인증 필요 + delete: + tags: + - Event + summary: 이벤트 삭제 + description: 이벤트를 삭제합니다 + security: + - bearerAuth: [] + parameters: + - name: publicId + in: path + required: true + schema: + type: string + responses: + "204": + description: No Content + "401": + description: 인증 필요 + /api/images: + post: + tags: + - Image + summary: 이미지 업로드 + description: 서버 저장소에 이미지를 업로드합니다. + parameters: + - name: prefix + in: query + description: 이미지를 저장할 상위 경로 + required: false + schema: + type: string + requestBody: + content: + multipart/form-data: + schema: + required: + - image + type: object + properties: + image: + type: string + format: binary + responses: + "401": + description: 인증 실패 (유효하지 않은 토큰) + content: + '*/*': + schema: + $ref: "#/components/schemas/ImageUploadResponse" + "200": + description: 이미지 업로드 성공 + content: + '*/*': + schema: + $ref: "#/components/schemas/ImageUploadResponse" + "400": + description: 잘못된 요청 (파일 누락/형식 오류 등) + content: + '*/*': + schema: + $ref: "#/components/schemas/ImageUploadResponse" + /api/events: + post: + tags: + - Event + summary: 이벤트 생성 + description: 새로운 이벤트를 생성합니다 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateEventRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateEventResponse" + "401": + description: 인증 필요 + /api/events/{eventId}/registrations: + get: + tags: + - Registration + summary: 이벤트 신청 목록 조회 + description: 특정 이벤트에 속한 신청 목록을 조회합니다. 신청 상태(CONFIRMED/WAITLISTED/BANNED)와 신청 + 시각을 포함해 반환합니다. + parameters: + - name: eventId + in: path + required: true + schema: + type: string + - name: status + in: query + required: false + schema: + type: string + - name: orderBy + in: query + required: false + schema: + type: string + - name: cursor + in: query + description: 다음 페이지 시작 offset (없으면 0부터) + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetEventRegistrationsResponse" + post: + tags: + - Registration + summary: 이벤트 참여 신청 + description: "특정 이벤트에 신청합니다. 정원이 남아있으면 CONFIRMED로 등록되고, 정원이 찼으나 대기 명단이 허용된 경\ + 우 WAITLISTED로 등록됩니다. 정원이 찼고 대기 명단이 비활성화된 경우 신청이 거절됩니다." + parameters: + - name: eventId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRegistrationRequest" + required: true + responses: + "200": + description: 신청 성공 + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "409": + description: "신청 불가: 3001(Registration already exists), 3005(Host cannot\ + \ register), 2301(Event is full)" + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "400": + description: "유효하지 않은 요청: 3003(Wrong guest name), 3004(Wrong guest email),\ + \ 2204(Invalid registration window)" + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "403": + description: "권한 없음: 3006(Banned user cannot register)" + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "404": + description: "이벤트 없음: 2001(Event not found)" + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + /api/auth/social: + post: + tags: + - Auth + summary: 소셜 로그인 + description: 소셜 로그인을 요청합니다. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SocialLoginRequest" + required: true + responses: + "200": + description: 소셜 로그인 성공 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + "409": + description: 이미 가입된 이메일 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + "401": + description: 인증 실패 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + /api/auth/signup: + post: + tags: + - Auth + summary: 이메일 회원가입 + description: 새로운 사용자를 등록하고 인증 이메일을 발송합니다. 이메일 인증 후 가입이 완료됩니다. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SignupRequest" + required: true + responses: + "409": + description: 이미 존재하는 email + "204": + description: 인증 이메일 발송 성공 + "400": + description: 잘못된 요청 (유효하지 않은 email/password/name) + "503": + description: 이메일 서비스 장애 + /api/auth/logout: + post: + tags: + - Auth + summary: 로그아웃 + description: 현재 JWT 토큰을 블랙리스트에 추가하여 로그아웃합니다 + responses: + "204": + description: 로그아웃 성공 + /api/auth/login: + post: + tags: + - Auth + summary: 로그인 + description: email로 로그인하여 JWT 토큰을 발급받습니다 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + required: true + responses: + "401": + description: 인증 실패 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + "200": + description: "로그인 성공, JWT 토큰 반환" + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + /api/auth/email-verification/{verificationCode}: + post: + tags: + - Auth + summary: 이메일 인증 + description: 이메일로 받은 인증 코드를 검증하고 회원가입을 완료합니다 + parameters: + - name: verificationCode + in: path + required: true + schema: + type: string + responses: + "400": + description: 유효하지 않거나 만료된 인증 코드 + "200": + description: "인증 성공, 유저 등록 완료" + /api/users/me: + get: + tags: + - User + summary: 본인 정보 조회 + description: 로그인한 사용자의 정보를 조회합니다 + security: + - bearerAuth: [] + responses: + "401": + description: 인증 실패 (유효하지 않은 토큰) + content: + '*/*': + schema: + $ref: "#/components/schemas/UserDto" + "200": + description: 사용자 정보 조회 성공 + content: + '*/*': + schema: + $ref: "#/components/schemas/UserDto" + patch: + tags: + - User + summary: 본인 정보 수정 + description: 로그인한 사용자의 정보를 수정합니다 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PatchMeRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/UserDto" + "401": + description: 인증 필요 + /api/registrations/{registrationId}: + get: + tags: + - Registration + summary: 신청 단건 정보 요청 + description: 신청 단건의 정보를 반환합니다. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetRegistrationResponse" + delete: + tags: + - Registration + summary: 신청 단건 취소 + description: 신청 단건 리소스를 취소합니다. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteRegistrationRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/DeleteRegistrationResponse" + patch: + tags: + - Registration + summary: 신청 단건 상태 변경 + description: 신청 단건 리소스의 상태를 변경합니다. 사용자가 본인의 신청을 취소하는 경우와 관리자가 강제 취소/밴 처리하는 경우를 + 로직 레벨에서 구분합니다. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateRegistrationStatusRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/PatchRegistrationResponse" + /api/registrations/me: + get: + tags: + - Registration + summary: 내 이벤트 신청 목록 조회 + description: 로그인한 사용자의 이벤트 신청 목록을 조회합니다. 최신 신청 순으로 반환되며 page/size로 페이징합니다. + security: + - bearerAuth: [] + parameters: + - name: page + in: query + required: false + schema: + type: integer + format: int32 + default: 0 + - name: size + in: query + required: false + schema: + type: integer + format: int32 + default: 20 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetMyRegistrationsResponse" + "401": + description: 인증 필요 + /api/events/me: + get: + tags: + - Event + summary: 내가 만든 이벤트 목록 조회 (무한 스크롤) + description: 로그인된 사용자가 생성한 이벤트 목록을 무한 스크롤 방식(cursor)으로 조회합니다 + security: + - bearerAuth: [] + parameters: + - name: cursor + in: query + required: false + schema: + type: string + format: date-time + - name: size + in: query + required: false + schema: + type: integer + format: int32 + default: 5 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/MyEventsInfiniteResponse" + "401": + description: 인증 필요 +components: + schemas: + UpdateEventRequest: + type: object + properties: + title: + type: string + description: 이벤트 제목 + example: 정기 모임 + description: + type: string + description: 이벤트 설명 + example: 정기 모임입니다 + location: + type: string + description: 장소 + example: 강의실 101 + startsAt: + type: string + description: 시작 시간 (ISO-8601) + format: date-time + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: 종료 시간 (ISO-8601) + format: date-time + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + example: 30 + waitlistEnabled: + type: boolean + description: 대기 명단 사용 여부 + example: true + registrationStartsAt: + type: string + description: 신청 시작 시간 (ISO-8601) + format: date-time + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: 신청 마감 시간 (ISO-8601) + format: date-time + example: 2026-02-02T17:30:00Z + description: 일정 수정 요청 + UpdateEventResponse: + required: + - registrationEndsAt + - title + - waitlistEnabled + type: object + properties: + title: + type: string + description: + type: string + location: + type: string + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + capacity: + type: integer + format: int32 + waitlistEnabled: + type: boolean + registrationStartsAt: + type: string + format: date-time + registrationEndsAt: + type: string + format: date-time + ImageUploadResponse: + required: + - key + - url + type: object + properties: + key: + type: string + url: + type: string + CreateEventRequest: + required: + - capacity + - registrationEndsAt + - title + - waitlistEnabled + type: object + properties: + title: + type: string + description: 이벤트 제목 + example: 정기 모임 + description: + type: string + description: 이벤트 설명 + example: 정기 모임입니다 + location: + type: string + description: 장소 + example: 강의실 101 + startsAt: + type: string + description: 시작 시간 (ISO-8601) + format: date-time + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: 종료 시간 (ISO-8601) + format: date-time + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + example: 30 + waitlistEnabled: + type: boolean + description: 대기 명단 사용 여부 + example: true + registrationStartsAt: + type: string + description: 신청 시작 시간 (ISO-8601). null인 경우 요청 시점으로 자동 설정됩니다. + format: date-time + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: 신청 마감 시간 (ISO-8601) + format: date-time + example: 2026-02-02T17:30:00Z + description: 일정 생성 요청 + CreateEventResponse: + required: + - publicId + type: object + properties: + publicId: + type: string + CreateRegistrationRequest: + type: object + properties: + guestName: + type: string + description: 비회원 이름 + guestEmail: + type: string + description: 비회원 이메일 + description: 이벤트 신청 요청 + CreateRegistrationResponse: + required: + - registrationPublicId + type: object + properties: + registrationPublicId: + type: string + description: 신청 id + description: 이벤트 신청 응답 + SocialLoginRequest: + required: + - code + - provider + type: object + properties: + provider: + type: string + description: 소셜로그인 제공자 + example: GOOGLE + code: + type: string + description: 인가 코드 (authorization code) + description: 소셜 로그인 요청 + LoginResponse: + required: + - token + type: object + properties: + token: + type: string + SignupRequest: + required: + - email + - name + - password + type: object + properties: + email: + type: string + description: 사용자 이메일 + example: user@example.com + name: + type: string + description: 사용자 이름 + example: 홍길동 + password: + type: string + description: 비밀번호 + example: password123 + profileImage: + type: string + description: 프로필 이미지 URL + example: https://cdn.example.com/profile.png + description: 회원가입 요청 + LoginRequest: + required: + - email + - password + type: object + properties: + email: + type: string + description: 사용자 이메일 + example: user@example.com + password: + type: string + description: 사용자 비밀번호 + example: mypassword + description: 로그인 요청 + PatchMeRequest: + type: object + properties: + name: + type: string + email: + type: string + password: + type: string + profileImage: + type: string + UserDto: + required: + - email + - id + - name + type: object + properties: + id: + type: integer + format: int64 + email: + type: string + name: + type: string + profileImage: + type: string + UpdateRegistrationStatusRequest: + type: object + properties: + status: + type: string + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + description: 이벤트 신청 상태 변경 요청 + PatchRegistrationResponse: + type: object + properties: + patchEmail: + type: string + description: 이벤트 신청 상태 변경 + GetRegistrationResponse: + required: + - guestName + - registrationPublicId + - reservationEmail + - status + - waitlistPosition + type: object + properties: + status: + type: string + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + guestName: + type: string + waitlistPosition: + type: integer + format: int32 + registrationPublicId: + type: string + reservationEmail: + type: string + description: 이벤트 신청 정보 요청 응답 + GetMyRegistrationsResponse: + required: + - registrations + type: object + properties: + registrations: + type: array + description: 신청 목록 + items: + $ref: "#/components/schemas/MyRegistrationItem" + description: 내 이벤트 신청 목록 응답 + MyRegistrationItem: + required: + - publicId + - registrationCnt + - status + - title + type: object + properties: + publicId: + type: string + description: 이벤트 공개 ID + title: + type: string + description: 이벤트 제목 + startsAt: + type: string + description: 이벤트 시작 시간 (ISO-8601) + format: date-time + endsAt: + type: string + description: 이벤트 종료 시간 (ISO-8601) + format: date-time + registrationStartsAt: + type: string + description: 신청 시작 시간 (ISO-8601) + format: date-time + registrationEndsAt: + type: string + description: 신청 마감 시간 (ISO-8601) + format: date-time + capacity: + type: integer + description: 정원 + format: int32 + registrationCnt: + type: integer + description: 현재 신청 수 + 대기 수 + format: int32 + status: + type: string + description: 신청 상태 + enum: + - CONFIRMED + - WAITLISTED + - BANNED + waitlistedNum: + type: integer + description: 대기 순번 (WAITLISTED가 아니면 null) + format: int32 + description: 내 이벤트 신청 요약 + CapabilitiesInfo: + required: + - apply + - cancel + - shareLink + - wait + type: object + properties: + shareLink: + type: boolean + description: 공유 링크 가능 여부 + example: false + apply: + type: boolean + description: 참여 신청 가능 여부(확정 자리 신청) + example: false + wait: + type: boolean + description: 대기 신청 가능 여부(정원 찼을 때) + example: false + cancel: + type: boolean + description: 신청 취소 가능 여부 + example: true + description: 가능한 동작 블록 + CreatorInfo: + required: + - email + - name + type: object + properties: + name: + type: string + description: 이름 + example: 홍길동 + email: + type: string + description: 이메일 + example: user@example.com + profileImage: + type: string + description: 프로필 이미지 + nullable: true + example: https://example.com/profile.png + description: 생성자 정보 블록 + EventDetailResponse: + required: + - capabilities + - creator + - event + - guestsPreview + - viewer + type: object + properties: + event: + $ref: "#/components/schemas/EventInfo" + creator: + $ref: "#/components/schemas/CreatorInfo" + viewer: + $ref: "#/components/schemas/ViewerInfo" + capabilities: + $ref: "#/components/schemas/CapabilitiesInfo" + guestsPreview: + type: array + description: 참여자 미리보기 목록 + items: + $ref: "#/components/schemas/GuestPreview" + description: 일정 상세 조회 응답 + EventInfo: + required: + - publicId + - registrationEndsAt + - title + - totalApplicants + type: object + properties: + publicId: + type: string + description: 이벤트 publicId + example: dj8sadj10djnkas + title: + type: string + description: 이벤트 제목 + example: 제2회 기획 세미나 + description: + type: string + description: 이벤트 설명 + nullable: true + example: 일정 상세 설명... + location: + type: string + description: 장소 + nullable: true + example: 서울대 + startsAt: + type: string + description: 시작 시간 (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: 종료 시간 (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + nullable: true + example: 10 + totalApplicants: + type: integer + description: 총 신청자 수(확정+대기 등) + format: int32 + example: 8 + registrationStartsAt: + type: string + description: 신청 시작 시간 (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: 신청 마감 시간 (ISO-8601) + format: date-time + example: 2026-02-02T17:00:00Z + description: 이벤트 정보 블록 + GuestPreview: + required: + - name + type: object + properties: + id: + type: integer + description: 참여자 ID (비회원이면 null) + format: int64 + nullable: true + example: 1 + name: + type: string + description: 참여자 이름 + example: 홍길동 + profileImage: + type: string + description: 프로필 이미지 URL + nullable: true + example: https://example.com/profile.png + description: 참여자 미리보기 정보 + ViewerInfo: + required: + - status + type: object + properties: + status: + type: string + description: 이벤트 조회자(Viewer)의 상태 + example: WAITLISTED + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + - NONE + name: + type: string + description: viewer 이름 (비회원이면 null) + nullable: true + example: 홍길동 + waitlistPosition: + type: integer + description: "대기 순번 (WAITLISTED일 때만 숫자, 아니면 null)" + format: int32 + nullable: true + example: 3 + registrationPublicId: + type: string + description: "registrationPublicId (게스트면 값, 아니면 null)" + nullable: true + example: "1" + reservationEmail: + type: string + description: "예약 이메일(게스트면 guestEmail, 로그인 유저면 user email 등 정책에 따름)" + nullable: true + example: skdfsj@gmaldkl + description: 조회자(viewer) 정보 블록 + EventRegistrationItem: + required: + - createdAt + - name + - registrationId + - status + type: object + properties: + registrationId: + type: string + description: 신청 공개 ID + name: + type: string + description: 이름 + email: + type: string + description: 이메일 (관리자 요청 시 노출) + profileImage: + type: string + description: 프로필 이미지 URL + createdAt: + type: string + description: 신청 일시 + format: date-time + status: + type: string + description: 신청 상태 + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + waitingNum: + type: integer + description: 대기 순번 (status가 WAITLISTED일 때만 아니면 null) + format: int32 + description: 신청자 정보 + GetEventRegistrationsResponse: + required: + - hasNext + - participants + type: object + properties: + participants: + type: array + description: 신청자 목록 + items: + $ref: "#/components/schemas/EventRegistrationItem" + totalCount: + type: integer + description: 필터에 따라 변하는 참여자 수 + format: int32 + nextCursor: + type: integer + description: 다음 페이지 시작 offset (없으면 null) + format: int32 + hasNext: + type: boolean + description: 다음 페이지 존재 여부 + description: 이벤트 신청 목록 조회 응답 + MyEventResponse: + required: + - publicId + - registrationEndsAt + - title + - totalApplicants + type: object + properties: + publicId: + type: string + title: + type: string + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + registrationStartsAt: + type: string + format: date-time + registrationEndsAt: + type: string + format: date-time + capacity: + type: integer + format: int32 + totalApplicants: + type: integer + format: int32 + MyEventsInfiniteResponse: + required: + - events + - hasNext + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/MyEventResponse" + nextCursor: + type: string + format: date-time + hasNext: + type: boolean + DeleteRegistrationRequest: + type: object + properties: + guestName: + type: string + description: 비회원 이름 + guestEmail: + type: string + description: 비회원 이메일 + description: 이벤트 취소 요청 + DeleteRegistrationResponse: + type: object + securitySchemes: + bearerAuth: + type: http + description: JWT 토큰을 입력하세요 + name: bearerAuth + scheme: bearer + bearerFormat: JWT diff --git a/src/main/resources/static/docs/swagger-ui.html b/src/main/resources/static/docs/swagger-ui.html new file mode 100644 index 00000000..517735db --- /dev/null +++ b/src/main/resources/static/docs/swagger-ui.html @@ -0,0 +1,36 @@ + + + + + + MoiMing API Docs + + + + +
+ + + + + From 8cebad2e2c6fcf0090845539c8496c69ecebad0a Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 14 Apr 2026 23:07:34 +0900 Subject: [PATCH 126/157] refactor(openapi): migrate from swagger annotations to static openapi.yaml --- .github/PULL_REQUEST_TEMPLATE/dev.md | 2 + .github/PULL_REQUEST_TEMPLATE/main.md | 1 + .github/workflows/ci.yml | 8 + .../image/controller/ImageController.kt | 15 +- .../domain/auth/controller/AuthController.kt | 40 - .../auth/controller/SocialAuthController.kt | 11 - .../domain/auth/dto/LoginRequest.kt | 5 - .../domain/auth/dto/RegisterRequest.kt | 7 - .../domain/auth/dto/SignupRequest.kt | 7 - .../domain/auth/dto/SocialLoginRequest.kt | 5 - .../event/controller/EventController.kt | 11 - .../domain/event/dto/core/EventDto.kt | 15 - .../event/dto/request/CreateEventRequest.kt | 23 - .../event/dto/request/UpdateEventRequest.kt | 23 - .../event/dto/response/EventDetailResponse.kt | 53 - .../domain/event/dto/response/GuestPreview.kt | 10 - .../domain/event/dto/response/MyRole.kt | 6 - .../domain/event/dto/response/ViewerStatus.kt | 8 - .../controller/EventRegistrationController.kt | 36 - .../controller/MyRegistrationController.kt | 10 +- .../controller/RegistrationController.kt | 14 - .../registration/dto/core/RegistrationDto.kt | 9 - .../dto/core/RegistrationWithEventDto.kt | 10 - .../dto/request/CreateRegistrationRequest.kt | 4 - .../dto/request/DeleteRegistrationRequest.kt | 5 - .../UpdateRegistrationStatusRequest.kt | 2 - .../response/CreateRegistrationResponse.kt | 4 - .../response/GetEventRegistrationsResponse.kt | 14 - .../response/GetMyRegistrationsResponse.kt | 14 - .../dto/response/GetRegistrationResponse.kt | 2 - .../dto/response/PatchRegistrationResponse.kt | 3 - .../response/RegistrationGuestsResponse.kt | 10 - .../domain/user/controller/UserController.kt | 54 +- .../user/identity/dto/core/UserIdentityDto.kt | 7 - src/main/resources/application.yaml | 7 + src/main/resources/static/docs/openapi.yaml | 1181 +++++++++++++++++ .../resources/static/docs/swagger-ui.html | 36 + 37 files changed, 1239 insertions(+), 433 deletions(-) create mode 100644 src/main/resources/static/docs/openapi.yaml create mode 100644 src/main/resources/static/docs/swagger-ui.html diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md index ad0b6ad0..558f1a66 100644 --- a/.github/PULL_REQUEST_TEMPLATE/dev.md +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -2,6 +2,8 @@ ### 🧪 로컬 테스트 여부 (작업자 체크) - [ ] 로컬에서 Swagger 혹은 테스트 코드로 동작을 확인했습니다. +- [ ] API 변경이 있다면 `src/main/resources/static/docs/openapi.yaml`을 함께 갱신했습니다. +- [ ] `/docs/swagger-ui.html`에서 변경 API가 의도대로 보이는지 확인했습니다. ### 📄 documentation 최신화 여부 > 변경사항과 관련된 API의 swagger documentation이 실제 동작과 일치하는지 확인합니다. diff --git a/.github/PULL_REQUEST_TEMPLATE/main.md b/.github/PULL_REQUEST_TEMPLATE/main.md index e46646af..15cd0692 100644 --- a/.github/PULL_REQUEST_TEMPLATE/main.md +++ b/.github/PULL_REQUEST_TEMPLATE/main.md @@ -5,6 +5,7 @@ - [ ] CI 성공 - [ ] 환경 변수 설정 확인 - [ ] dev 서버에서 변경사항에 대해 의도대로 동작함을 확인 +- [ ] API 변경이 있다면 `src/main/resources/static/docs/openapi.yaml` 반영 완료 ### 📝 업데이트 사항 요약 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d75114a4..80cda61f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,14 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Lint OpenAPI YAML + run: npx @redocly/cli@latest lint src/main/resources/static/docs/openapi.yaml + - name: Set up JDK 21 uses: actions/setup-java@v4 with: diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt index d774bdb0..1cdb9ea8 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt @@ -4,10 +4,6 @@ import com.wafflestudio.spring2025.common.image.dto.ImageUploadResponse import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.auth.LoggedInUser import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -22,21 +18,12 @@ import org.springframework.web.multipart.MultipartFile class ImageController( private val imageService: ImageService, ) { - @Operation(summary = "이미지 업로드", description = "서버 저장소에 이미지를 업로드합니다.") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "이미지 업로드 성공"), - ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 누락/형식 오류 등)"), - ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), - ], - ) @PostMapping( consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], ) fun uploadImage( - @Parameter(hidden = true) @LoggedInUser user: User?, + @LoggedInUser user: User?, @RequestPart("image") image: MultipartFile, - @Parameter(description = "이미지를 저장할 상위 경로", required = false) @RequestParam(name = "prefix", required = false) prefix: String?, ): ResponseEntity { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt index fae13d16..f35d874e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt @@ -5,10 +5,6 @@ import com.wafflestudio.spring2025.domain.auth.dto.LoginResponse import com.wafflestudio.spring2025.domain.auth.dto.SignupRequest import com.wafflestudio.spring2025.domain.auth.service.AuthService import com.wafflestudio.spring2025.domain.auth.service.EmailVerificationService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import jakarta.servlet.http.HttpServletRequest import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable @@ -19,23 +15,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/auth") -@Tag(name = "Auth", description = "인증 API") class AuthController( private val authService: AuthService, private val emailVerificationService: EmailVerificationService, ) { - @Operation( - summary = "이메일 회원가입", - description = "새로운 사용자를 등록하고 인증 이메일을 발송합니다. 이메일 인증 후 가입이 완료됩니다.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "204", description = "인증 이메일 발송 성공"), - ApiResponse(responseCode = "400", description = "잘못된 요청 (유효하지 않은 email/password/name)"), - ApiResponse(responseCode = "409", description = "이미 존재하는 email"), - ApiResponse(responseCode = "503", description = "이메일 서비스 장애"), - ], - ) @PostMapping("/signup") fun signup( @RequestBody signupRequest: SignupRequest, @@ -51,16 +34,6 @@ class AuthController( return ResponseEntity.noContent().build() } - @Operation( - summary = "이메일 인증", - description = "이메일로 받은 인증 코드를 검증하고 회원가입을 완료합니다", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "인증 성공, 유저 등록 완료"), - ApiResponse(responseCode = "400", description = "유효하지 않거나 만료된 인증 코드"), - ], - ) @PostMapping("/email-verification/{verificationCode}") fun verifyEmail( @PathVariable verificationCode: String, @@ -69,13 +42,6 @@ class AuthController( return ResponseEntity.ok().build() } - @Operation(summary = "로그인", description = "email로 로그인하여 JWT 토큰을 발급받습니다") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "로그인 성공, JWT 토큰 반환"), - ApiResponse(responseCode = "401", description = "인증 실패"), - ], - ) @PostMapping("/login") fun login( @RequestBody loginRequest: LoginRequest, @@ -84,12 +50,6 @@ class AuthController( return ResponseEntity.ok(LoginResponse(token)) } - @Operation(summary = "로그아웃", description = "현재 JWT 토큰을 블랙리스트에 추가하여 로그아웃합니다") - @ApiResponses( - value = [ - ApiResponse(responseCode = "204", description = "로그아웃 성공"), - ], - ) @PostMapping("/logout") fun logout(request: HttpServletRequest): ResponseEntity { val token = resolveToken(request) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt index b43a4ced..dbd09f73 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt @@ -4,9 +4,6 @@ import com.wafflestudio.spring2025.domain.auth.dto.LoginResponse import com.wafflestudio.spring2025.domain.auth.dto.SocialLoginRequest import com.wafflestudio.spring2025.domain.auth.model.SocialProvider import com.wafflestudio.spring2025.domain.auth.service.SocialAuthService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -18,14 +15,6 @@ import org.springframework.web.bind.annotation.RestController class SocialAuthController( private val socialAuthService: SocialAuthService, ) { - @Operation(summary = "소셜 로그인", description = "소셜 로그인을 요청합니다.") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "소셜 로그인 성공"), - ApiResponse(responseCode = "401", description = "인증 실패"), - ApiResponse(responseCode = "409", description = "이미 가입된 이메일"), - ], - ) @PostMapping suspend fun social( @RequestBody socialLoginRequest: SocialLoginRequest, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt index bf17a881..0d5ffea7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt @@ -1,11 +1,6 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "로그인 요청") data class LoginRequest( - @Schema(description = "사용자 이메일", example = "user@example.com", required = true) val email: String, - @Schema(description = "사용자 비밀번호", example = "mypassword", required = true) val password: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt index 470bcb7f..32ee7ed3 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt @@ -1,15 +1,8 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "회원가입 요청") data class RegisterRequest( - @Schema(description = "사용자 이메일", example = "user@example.com", required = true) val email: String, - @Schema(description = "사용자 이름", example = "홍길동", required = true) val name: String, - @Schema(description = "비밀번호", example = "password123", required = true) val password: String, - @Schema(description = "프로필 이미지 URL", example = "https://cdn.example.com/profile.png") val profileImage: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt index 94c273ff..53618934 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt @@ -1,15 +1,8 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "회원가입 요청") data class SignupRequest( - @Schema(description = "사용자 이메일", example = "user@example.com", required = true) val email: String, - @Schema(description = "사용자 이름", example = "홍길동", required = true) val name: String, - @Schema(description = "비밀번호", example = "password123", required = true) val password: String, - @Schema(description = "프로필 이미지 URL", example = "https://cdn.example.com/profile.png") val profileImage: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt index f83e60f3..28fc2bad 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt @@ -1,11 +1,6 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "소셜 로그인 요청") data class SocialLoginRequest( - @Schema(description = "소셜로그인 제공자", example = "GOOGLE", required = true) val provider: String, - @Schema(description = "인가 코드 (authorization code)", required = true) val code: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt index 7ba5fae8..7e1a9de5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt @@ -10,8 +10,6 @@ import com.wafflestudio.spring2025.domain.event.dto.response.MyEventsInfiniteRes import com.wafflestudio.spring2025.domain.event.dto.response.UpdateEventResponse import com.wafflestudio.spring2025.domain.event.service.EventService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -26,12 +24,10 @@ import java.time.Instant @RestController @RequestMapping("/api/events") -@Tag(name = "Event", description = "이벤트 관리 API") class EventController( private val eventService: EventService, ) { @AuthRequired - @Operation(summary = "이벤트 생성", description = "새로운 이벤트를 생성합니다") @PostMapping // POST /api/events fun create( @LoggedInUser user: User, @@ -54,7 +50,6 @@ class EventController( return ResponseEntity.ok(CreateEventResponse(publicId)) } - @Operation(summary = "이벤트 상세 조회", description = "이벤트 상세 정보를 조회합니다") @GetMapping("/{publicId}") // GET /api/events/{publicId} fun getById( @LoggedInUser user: User?, // nullable @@ -69,10 +64,6 @@ class EventController( } @AuthRequired - @Operation( - summary = "내가 만든 이벤트 목록 조회 (무한 스크롤)", - description = "로그인된 사용자가 생성한 이벤트 목록을 무한 스크롤 방식(cursor)으로 조회합니다", - ) @GetMapping("/me") fun getMyEvents( @LoggedInUser user: User, @@ -89,7 +80,6 @@ class EventController( } @AuthRequired - @Operation(summary = "이벤트 수정", description = "이벤트를 수정합니다") @PutMapping("/{publicId}") // PUT /api/events/{publicId} fun update( @LoggedInUser user: User, @@ -116,7 +106,6 @@ class EventController( } @AuthRequired - @Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다") @DeleteMapping("/{publicId}") // DELETE /api/events/{publicId} fun delete( @LoggedInUser user: User, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt index 2b4765a2..778ea5ca 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt @@ -1,35 +1,20 @@ package com.wafflestudio.spring2025.domain.event.dto.core import com.wafflestudio.spring2025.domain.event.model.Event -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "일정 정보") data class EventDto( - @Schema(description = "일정 ID (publicId)") val id: String, - @Schema(description = "이벤트 제목") val title: String, - @Schema(description = "이벤트 설명") val description: String?, - @Schema(description = "장소") val location: String?, - @Schema(description = "시작 시간 (epoch milliseconds)") val startsAt: Long?, - @Schema(description = "종료 시간 (epoch milliseconds)") val endsAt: Long?, - @Schema(description = "정원") val capacity: Int?, - @Schema(description = "대기 명단 사용 여부") val waitlistEnabled: Boolean, - @Schema(description = "신청 시작 시간 (epoch milliseconds)") val registrationStartsAt: Long?, - @Schema(description = "신청 마감 시간 (epoch milliseconds)") val registrationEndsAt: Long, - @Schema(description = "작성자 ID") val createdBy: Long, - @Schema(description = "생성 일시 (epoch milliseconds)") val createdAt: Long?, - @Schema(description = "수정 일시 (epoch milliseconds)") val updatedAt: Long?, ) { constructor(event: Event) : this( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt index db6254ca..1c46a314 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt @@ -1,38 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.request -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 생성 요청") data class CreateEventRequest( - @Schema(description = "이벤트 제목", example = "정기 모임") val title: String, - @Schema(description = "이벤트 설명", example = "정기 모임입니다") val description: String? = null, - @Schema(description = "장소", example = "강의실 101") val location: String? = null, - @Schema( - description = "시작 시간 (ISO-8601)", - example = "2026-02-02T18:00:00Z", - ) val startsAt: Instant? = null, - @Schema( - description = "종료 시간 (ISO-8601)", - example = "2026-02-02T20:00:00Z", - ) val endsAt: Instant? = null, - @Schema(description = "정원", example = "30") val capacity: Int, - @Schema(description = "대기 명단 사용 여부", example = "true") val waitlistEnabled: Boolean, - @Schema( - description = "신청 시작 시간 (ISO-8601). null인 경우 요청 시점으로 자동 설정됩니다.", - example = "2026-02-02T17:00:00Z", - ) val registrationStartsAt: Instant? = null, - @Schema( - description = "신청 마감 시간 (ISO-8601)", - example = "2026-02-02T17:30:00Z", - ) val registrationEndsAt: Instant, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt index e237b56c..2206d5c5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt @@ -1,38 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.request -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 수정 요청") data class UpdateEventRequest( - @Schema(description = "이벤트 제목", example = "정기 모임") val title: String? = null, - @Schema(description = "이벤트 설명", example = "정기 모임입니다") val description: String? = null, - @Schema(description = "장소", example = "강의실 101") val location: String? = null, - @Schema( - description = "시작 시간 (ISO-8601)", - example = "2026-02-02T18:00:00Z", - ) val startsAt: Instant? = null, - @Schema( - description = "종료 시간 (ISO-8601)", - example = "2026-02-02T20:00:00Z", - ) val endsAt: Instant? = null, - @Schema(description = "정원", example = "30") val capacity: Int? = null, - @Schema(description = "대기 명단 사용 여부", example = "true") val waitlistEnabled: Boolean? = null, - @Schema( - description = "신청 시작 시간 (ISO-8601)", - example = "2026-02-02T17:00:00Z", - ) val registrationStartsAt: Instant? = null, - @Schema( - description = "신청 마감 시간 (ISO-8601)", - example = "2026-02-02T17:30:00Z", - ) val registrationEndsAt: Instant? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt index 84e7616a..685cfd04 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt @@ -1,99 +1,46 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 상세 조회 응답") data class EventDetailResponse( - @Schema(description = "이벤트 정보") val event: EventInfo, - @Schema(description = "이벤트 생성자 정보") val creator: CreatorInfo, - @Schema(description = "조회자(viewer) 정보") val viewer: ViewerInfo, - @Schema(description = "조회자가 할 수 있는 동작") val capabilities: CapabilitiesInfo, - @Schema(description = "참여자 미리보기 목록") val guestsPreview: List, ) -@Schema(description = "이벤트 정보 블록") data class EventInfo( - @Schema(description = "이벤트 publicId", example = "dj8sadj10djnkas") val publicId: String, - @Schema(description = "이벤트 제목", example = "제2회 기획 세미나") val title: String, - @Schema(description = "이벤트 설명", example = "일정 상세 설명...", nullable = true) val description: String?, - @Schema(description = "장소", example = "서울대", nullable = true) val location: String?, - @Schema(description = "시작 시간 (ISO-8601)", example = "2026-02-02T18:00:00Z", nullable = true) val startsAt: Instant?, - @Schema(description = "종료 시간 (ISO-8601)", example = "2026-02-02T20:00:00Z", nullable = true) val endsAt: Instant?, - @Schema(description = "정원", example = "10", nullable = true) val capacity: Int?, - @Schema(description = "참여자 수(참여 확정 인원)", example = "5") val confirmedCount: Int, - @Schema(description = "대기자 수", example = "5") val waitlistCount: Int, - @Schema(description = "신청 시작 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z", nullable = true) val registrationStartsAt: Instant?, - @Schema(description = "신청 마감 시간 (ISO-8601)", example = "2026-02-02T17:00:00Z") val registrationEndsAt: Instant, ) -@Schema(description = "생성자 정보 블록") data class CreatorInfo( - @Schema(description = "이름", example = "홍길동") val name: String, - @Schema(description = "이메일", example = "user@example.com") val email: String, - @Schema(description = "프로필 이미지", example = "https://example.com/profile.png", nullable = true) val profileImage: String?, ) -@Schema(description = "조회자(viewer) 정보 블록") data class ViewerInfo( - @Schema( - description = "viewer의 등록 상태 (RegistrationStatus 사용)", - example = "WAITLISTED", - ) val status: ViewerStatus, - @Schema( - description = "viewer 이름 (비회원이면 null)", - example = "홍길동", - nullable = true, - ) val name: String?, - @Schema( - description = "대기 순번 (WAITLISTED일 때만 숫자, 아니면 null)", - example = "3", - nullable = true, - ) val waitlistPosition: Int?, - @Schema( - description = "registrationPublicId (게스트면 값, 아니면 null)", - example = "1", - nullable = true, - ) val registrationPublicId: String?, - @Schema( - description = "예약 이메일(게스트면 guestEmail, 로그인 유저면 user email 등 정책에 따름)", - example = "skdfsj@gmaldkl", - nullable = true, - ) val reservationEmail: String?, ) -@Schema(description = "가능한 동작 블록") data class CapabilitiesInfo( - @Schema(description = "공유 링크 가능 여부", example = "false") val shareLink: Boolean, - @Schema(description = "참여 신청 가능 여부(확정 자리 신청)", example = "false") val apply: Boolean, - @Schema(description = "대기 신청 가능 여부(정원 찼을 때)", example = "false") val wait: Boolean, - @Schema(description = "신청 취소 가능 여부", example = "true") val cancel: Boolean, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt index 01781f48..2f3314a7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt @@ -1,21 +1,11 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * 이벤트 참여자 미리보기 정보 * (회원/비회원 포함) */ -@Schema(description = "참여자 미리보기 정보") data class GuestPreview( - @Schema(description = "참여자 ID (비회원이면 null)", example = "1", nullable = true) val id: Long?, - @Schema(description = "참여자 이름", example = "홍길동") val name: String, - @Schema( - description = "프로필 이미지 URL", - example = "https://example.com/profile.png", - nullable = true, - ) val profileImage: String?, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt index d1a8b89a..c06024ed 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt @@ -1,21 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * 요청자 역할 * - CREATOR: 이벤트 생성자 * - PARTICIPANT: 이벤트 참여자 (CONFIRMED / WAITLISTED) * - NONE: 로그인은 했지만 참여하지 않음 */ -@Schema(description = "요청자 역할") enum class MyRole { - @Schema(description = "이벤트 생성자") CREATOR, - @Schema(description = "이벤트 참여자") PARTICIPANT, - @Schema(description = "이벤트 미참여자") NONE, } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt index a250a6db..d85fe72a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt @@ -1,25 +1,17 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * 이벤트 상세 조회 시, * "viewer(요청자)"가 해당 이벤트와 어떤 관계에 있는지를 나타내는 상태 */ -@Schema(description = "이벤트 조회자(Viewer)의 상태") enum class ViewerStatus { - @Schema(description = "이벤트 생성자") HOST, - @Schema(description = "참여 확정된 사용자") CONFIRMED, - @Schema(description = "대기 중인 사용자") WAITLISTED, - @Schema(description = "강제 취소(차단)된 사용자") BANNED, - @Schema(description = "이벤트와 아무 관계 없는 사용자") NONE, } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt index 4fb815e9..213bc67d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt @@ -6,11 +6,6 @@ import com.wafflestudio.spring2025.domain.registration.dto.response.CreateRegist import com.wafflestudio.spring2025.domain.registration.dto.response.GetEventRegistrationsResponse import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -22,35 +17,9 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/events/{eventId}/registrations") -@Tag(name = "Registration", description = "이벤트 기준 신청 API") class EventRegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "이벤트 참여 신청", - description = - "특정 이벤트에 신청합니다. 정원이 남아있으면 CONFIRMED로 등록되고, 정원이 찼으나 대기 명단이 허용된 경우 WAITLISTED로 등록됩니다. " + - "정원이 찼고 대기 명단이 비활성화된 경우 신청이 거절됩니다.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "신청 성공"), - ApiResponse( - responseCode = "400", - description = - "유효하지 않은 요청: 3003(Wrong guest name), 3004(Wrong guest email), 2204(Invalid registration window)", - ), - ApiResponse( - responseCode = "403", - description = "권한 없음: 3006(Banned user cannot register)", - ), - ApiResponse(responseCode = "404", description = "이벤트 없음: 2001(Event not found)"), - ApiResponse( - responseCode = "409", - description = "신청 불가: 3001(Registration already exists), 3005(Host cannot register), 2301(Event is full)", - ), - ], - ) @PostMapping fun create( @PathVariable eventId: String, @@ -67,17 +36,12 @@ class EventRegistrationController( return ResponseEntity.ok(registration) } - @Operation( - summary = "이벤트 신청 목록 조회", - description = "특정 이벤트에 속한 신청 목록을 조회합니다. 신청 상태(CONFIRMED/WAITLISTED/BANNED)와 신청 시각을 포함해 반환합니다.", - ) @GetMapping fun list( @PathVariable eventId: String, @LoggedInUser user: User?, @RequestParam(required = false) status: String?, @RequestParam(required = false) orderBy: String?, - @Parameter(description = "다음 페이지 시작 offset (없으면 0부터)") @RequestParam(required = false) cursor: Int?, ): ResponseEntity = ResponseEntity.ok( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt index 6da5b2c9..71022123 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt @@ -5,9 +5,6 @@ import com.wafflestudio.spring2025.domain.auth.LoggedInUser import com.wafflestudio.spring2025.domain.registration.dto.response.GetMyRegistrationsResponse import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -17,17 +14,12 @@ import org.springframework.web.bind.annotation.RestController @AuthRequired @RestController @RequestMapping("/api/registrations") -@Tag(name = "Registration", description = "내 신청 조회 API") class MyRegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "내 이벤트 신청 목록 조회", - description = "로그인한 사용자의 이벤트 신청 목록을 조회합니다. 최신 신청 순으로 반환되며 page/size로 페이징합니다.", - ) @GetMapping("/me") fun myRegistrations( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int, ): ResponseEntity { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt index 2ffccbe6..f2568d12 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt @@ -10,8 +10,6 @@ import com.wafflestudio.spring2025.domain.registration.exception.RegistrationErr import com.wafflestudio.spring2025.domain.registration.exception.RegistrationValidationException import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -23,16 +21,9 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/registrations/{registrationId}") -@Tag(name = "Registration", description = "신청 단건 리소스 API") class RegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "신청 단건 상태 변경", - description = - "신청 단건 리소스의 상태를 변경합니다. " + - "사용자가 본인의 신청을 취소하는 경우와 관리자가 강제 취소/밴 처리하는 경우를 로직 레벨에서 구분합니다.", - ) @PatchMapping fun updateStatus( @PathVariable registrationId: String, @@ -45,11 +36,6 @@ class RegistrationController( return ResponseEntity.ok(response) } - @Operation( - summary = "신청 단건 정보 요청", - description = - "신청 단건의 정보를 반환합니다.", - ) @GetMapping fun getRegistrationInformation( @PathVariable registrationId: String, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt index e6764f91..33f1410e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt @@ -2,23 +2,14 @@ package com.wafflestudio.spring2025.domain.registration.dto.core import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 신청 정보") data class RegistrationDto( - @Schema(description = "신청 공개 ID") val registrationPublicId: String, - @Schema(description = "사용자 ID") val userId: Long?, - @Schema(description = "이벤트 ID") val eventId: Long, - @Schema(description = "비회원 이름") val guestName: String?, - @Schema(description = "비회원 이메일") val guestEmail: String?, - @Schema(description = "신청 상태") val status: RegistrationStatus, - @Schema(description = "신청 일시 (epoch milliseconds)") val createdAt: Long, ) { constructor(registration: Registration) : this( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt index 5255eb9e..70c08a66 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt @@ -2,13 +2,9 @@ package com.wafflestudio.spring2025.domain.registration.dto.core import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.registration.model.Registration -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "내 이벤트 신청 정보") data class RegistrationWithEventDto( - @Schema(description = "신청 정보") val registration: RegistrationDto, - @Schema(description = "이벤트 요약 정보") val event: EventSummaryDto, ) { constructor(registration: Registration, event: Event) : this( @@ -17,17 +13,11 @@ data class RegistrationWithEventDto( ) } -@Schema(description = "이벤트 요약 정보") data class EventSummaryDto( - @Schema(description = "이벤트 ID") val id: Long, - @Schema(description = "이벤트 제목") val title: String, - @Schema(description = "장소") val location: String?, - @Schema(description = "시작 시간 (epoch milliseconds)") val startsAt: Long?, - @Schema(description = "종료 시간 (epoch milliseconds)") val endsAt: Long?, ) { constructor(event: Event) : this( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt index baf7052f..820bd1e5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt @@ -1,14 +1,10 @@ package com.wafflestudio.spring2025.domain.registration.dto.request import com.fasterxml.jackson.annotation.JsonAlias -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 신청 요청") data class CreateRegistrationRequest( - @Schema(description = "비회원 이름") @JsonAlias("guest_name") val guestName: String? = null, - @Schema(description = "비회원 이메일") @JsonAlias("guest_email") val guestEmail: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt index 3f34c5f8..0df46b98 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt @@ -1,11 +1,6 @@ package com.wafflestudio.spring2025.domain.registration.dto.request -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벤트 취소 요청") data class DeleteRegistrationRequest( - @Schema(description = "비회원 이름") val guestName: String? = null, - @Schema(description = "비회원 이메일") val guestEmail: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt index f87d15fb..2182cddc 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt @@ -1,9 +1,7 @@ package com.wafflestudio.spring2025.domain.registration.dto.request import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 신청 상태 변경 요청") data class UpdateRegistrationStatusRequest( val status: RegistrationStatus? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt index eb4ea566..3abe25e8 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt @@ -1,9 +1,5 @@ package com.wafflestudio.spring2025.domain.registration.dto.response -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벤트 신청 응답") data class CreateRegistrationResponse( - @Schema(description = "신청 id") val registrationPublicId: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt index 39a7951b..c5837bd2 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt @@ -3,36 +3,22 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "이벤트 신청 목록 조회 응답") data class GetEventRegistrationsResponse( - @Schema(description = "신청자 목록") val participants: List, - @Schema(description = "필터에 따라 변하는 참여자 수") val totalCount: Int?, - @Schema(description = "다음 페이지 시작 offset (없으면 null)") val nextCursor: Int?, - @Schema(description = "다음 페이지 존재 여부") val hasNext: Boolean, ) -@Schema(description = "신청자 정보") data class EventRegistrationItem( - @Schema(description = "신청 공개 ID") val registrationId: String, - @Schema(description = "이름") val name: String, - @Schema(description = "이메일 (관리자 요청 시 노출)") val email: String?, - @Schema(description = "프로필 이미지 URL") val profileImage: String?, - @Schema(description = "신청 일시") val createdAt: Instant, - @Schema(description = "신청 상태") val status: RegistrationStatus, - @Schema(description = "대기 순번 (status가 WAITLISTED일 때만 아니면 null)") val waitingNum: Int?, ) { constructor( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt index 74c36966..5c597315 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt @@ -3,36 +3,22 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "내 이벤트 신청 목록 응답") data class GetMyRegistrationsResponse( - @Schema(description = "신청 목록") val registrations: List, ) -@Schema(description = "내 이벤트 신청 요약") data class MyRegistrationItem( - @Schema(description = "이벤트 공개 ID") val publicId: String, - @Schema(description = "이벤트 제목") val title: String, - @Schema(description = "이벤트 시작 시간 (ISO-8601)") val startsAt: Instant?, - @Schema(description = "이벤트 종료 시간 (ISO-8601)") val endsAt: Instant?, - @Schema(description = "신청 시작 시간 (ISO-8601)") val registrationStartsAt: Instant?, - @Schema(description = "신청 마감 시간 (ISO-8601)") val registrationEndsAt: Instant?, - @Schema(description = "정원") val capacity: Int?, - @Schema(description = "현재 신청 수 + 대기 수") val registrationCnt: Int, - @Schema(description = "신청 상태") val status: MyRegistrationStatus, - @Schema(description = "대기 순번 (WAITLISTED가 아니면 null)") val waitlistedNum: Int?, ) { constructor( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt index e03dd30c..d241cd32 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt @@ -1,9 +1,7 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 신청 정보 요청 응답") data class GetRegistrationResponse( val status: RegistrationStatus, val guestName: String, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt index 5f8f534a..e328c6db 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt @@ -1,8 +1,5 @@ package com.wafflestudio.spring2025.domain.registration.dto.response -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벤트 신청 상태 변경") data class PatchRegistrationResponse( val patchEmail: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt index 5d0d0855..fe52c458 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt @@ -1,29 +1,19 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.fasterxml.jackson.annotation.JsonProperty -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벤트 참여자 명단 응답") data class RegistrationGuestsResponse( - @Schema(description = "확정 참여자 목록") val guests: List, @JsonProperty("confirmed_count") - @Schema(description = "확정 참여자 수") val confirmedCount: Int, @JsonProperty("waiting_count") - @Schema(description = "대기 참여자 수") val waitingCount: Int, ) { - @Schema(description = "참여자 정보") data class Guest( - @Schema(description = "신청 공개 ID") val registrationPublicId: String, - @Schema(description = "이름") val name: String, - @Schema(description = "이메일 (관리자 요청 시 노출)") val email: String?, @JsonProperty("profile_image") - @Schema(description = "프로필 이미지 URL") val profileImage: String?, ) } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 85c924bd..8f214cf1 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -7,11 +7,6 @@ import com.wafflestudio.spring2025.domain.user.dto.PatchMeRequest import com.wafflestudio.spring2025.domain.user.dto.PatchMeResponse import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.service.UserService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -22,27 +17,19 @@ import org.springframework.web.bind.annotation.RestController @AuthRequired @RestController @RequestMapping("/api/users") -@Tag(name = "User", description = "사용자 API") class UserController( private val userService: UserService, ) { - @Operation(summary = "본인 정보 조회", description = "로그인한 사용자의 정보를 조회합니다") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공"), - ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), - ], - ) @AuthRequired @GetMapping("/me") fun me( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, ): ResponseEntity = ResponseEntity.ok(userService.me(user)) @AuthRequired @PatchMapping("/me") fun patchMe( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, @RequestBody request: PatchMeRequest, ): ResponseEntity { userService.patchMe( @@ -54,41 +41,4 @@ class UserController( ) return ResponseEntity.ok(userService.me(user)) } - -// @Operation(summary = "프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드(교체)합니다") -// @ApiResponses( -// value = [ -// ApiResponse(responseCode = "204", description = "프로필 이미지 업로드 성공"), -// ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 누락/형식 오류 등)"), -// ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), -// ], -// ) -// @AuthRequired -// @PutMapping( -// "/me/profile-image", -// consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], -// ) -// fun uploadProfileImage( -// @Parameter(hidden = true) @LoggedInUser user: User?, -// @RequestPart("image") image: MultipartFile, -// ): ResponseEntity { -// userService.updateProfileImage(user, image) -// return ResponseEntity.noContent().build() -// } -// -// @Operation(summary = "프로필 이미지 삭제", description = "로그인한 사용자의 프로필 이미지를 삭제(기본 이미지로 복귀)합니다") -// @ApiResponses( -// value = [ -// ApiResponse(responseCode = "204", description = "프로필 이미지 삭제 성공"), -// ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), -// ], -// ) -// @AuthRequired -// @DeleteMapping("/me/profile-image") -// fun deleteProfileImage( -// @Parameter(hidden = true) @LoggedInUser user: User?, -// ): ResponseEntity { -// userService.deleteProfileImage(user) -// return ResponseEntity.noContent().build() -// } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt index 092180d5..1a0f9e05 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt @@ -1,19 +1,12 @@ package com.wafflestudio.spring2025.domain.user.identity.dto.core import com.wafflestudio.spring2025.domain.user.identity.model.UserIdentity -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "소셜 계정 정보") data class UserIdentityDto( - @Schema(description = "소셜 계정 ID") val id: Long, - @Schema(description = "사용자 ID") val userId: Long, - @Schema(description = "소셜 로그인 제공자") val provider: String, - @Schema(description = "제공자 사용자 ID") val providerUserId: String, - @Schema(description = "생성 일시 (epoch milliseconds)") val createdAt: Long, ) { constructor(identity: UserIdentity) : this( diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7fefbb20..473a1ff9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -95,6 +95,13 @@ aws: server: forward-headers-strategy: framework +springdoc: + api-docs: + enabled: false + swagger-ui: + url: /docs/openapi.yaml + disable-swagger-default-url: true + --- spring: config: diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml new file mode 100644 index 00000000..cdfaeb8b --- /dev/null +++ b/src/main/resources/static/docs/openapi.yaml @@ -0,0 +1,1181 @@ +openapi: 3.0.1 +info: + title: Moiming - Event Schedule Management System API + description: 모임 일정 관리 시스템 '모이밍' API 문서 + version: "1.0" +servers: +- url: http://localhost:8080 + description: Generated server url +tags: +- name: Event + description: 이벤트 관리 API +- name: Registration + description: 신청 API +- name: Auth + description: 인증 API +- name: User + description: 사용자 API +- name: Image + description: 이미지 API +paths: + /api/events/{publicId}: + get: + tags: + - Event + summary: 이벤트 상세 조회 + description: 이벤트 상세 정보를 조회합니다 + parameters: + - name: publicId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/EventDetailResponse" + put: + tags: + - Event + summary: 이벤트 수정 + description: 이벤트를 수정합니다 + security: + - bearerAuth: [] + parameters: + - name: publicId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateEventRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/UpdateEventResponse" + "401": + description: 인증 필요 + delete: + tags: + - Event + summary: 이벤트 삭제 + description: 이벤트를 삭제합니다 + security: + - bearerAuth: [] + parameters: + - name: publicId + in: path + required: true + schema: + type: string + responses: + "204": + description: No Content + "401": + description: 인증 필요 + /api/images: + post: + tags: + - Image + summary: 이미지 업로드 + description: 서버 저장소에 이미지를 업로드합니다. + parameters: + - name: prefix + in: query + description: 이미지를 저장할 상위 경로 + required: false + schema: + type: string + requestBody: + content: + multipart/form-data: + schema: + required: + - image + type: object + properties: + image: + type: string + format: binary + responses: + "401": + description: 인증 실패 (유효하지 않은 토큰) + content: + '*/*': + schema: + $ref: "#/components/schemas/ImageUploadResponse" + "200": + description: 이미지 업로드 성공 + content: + '*/*': + schema: + $ref: "#/components/schemas/ImageUploadResponse" + "400": + description: 잘못된 요청 (파일 누락/형식 오류 등) + content: + '*/*': + schema: + $ref: "#/components/schemas/ImageUploadResponse" + /api/events: + post: + tags: + - Event + summary: 이벤트 생성 + description: 새로운 이벤트를 생성합니다 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateEventRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateEventResponse" + "401": + description: 인증 필요 + /api/events/{eventId}/registrations: + get: + tags: + - Registration + summary: 이벤트 신청 목록 조회 + description: 특정 이벤트에 속한 신청 목록을 조회합니다. 신청 상태(CONFIRMED/WAITLISTED/BANNED)와 신청 + 시각을 포함해 반환합니다. + parameters: + - name: eventId + in: path + required: true + schema: + type: string + - name: status + in: query + required: false + schema: + type: string + - name: orderBy + in: query + required: false + schema: + type: string + - name: cursor + in: query + description: 다음 페이지 시작 offset (없으면 0부터) + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetEventRegistrationsResponse" + post: + tags: + - Registration + summary: 이벤트 참여 신청 + description: "특정 이벤트에 신청합니다. 정원이 남아있으면 CONFIRMED로 등록되고, 정원이 찼으나 대기 명단이 허용된 경\ + 우 WAITLISTED로 등록됩니다. 정원이 찼고 대기 명단이 비활성화된 경우 신청이 거절됩니다." + parameters: + - name: eventId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRegistrationRequest" + required: true + responses: + "200": + description: 신청 성공 + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "409": + description: "신청 불가: 3001(Registration already exists), 3005(Host cannot\ + \ register), 2301(Event is full)" + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "400": + description: "유효하지 않은 요청: 3003(Wrong guest name), 3004(Wrong guest email),\ + \ 2204(Invalid registration window)" + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "403": + description: "권한 없음: 3006(Banned user cannot register)" + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "404": + description: "이벤트 없음: 2001(Event not found)" + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + /api/auth/social: + post: + tags: + - Auth + summary: 소셜 로그인 + description: 소셜 로그인을 요청합니다. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SocialLoginRequest" + required: true + responses: + "200": + description: 소셜 로그인 성공 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + "409": + description: 이미 가입된 이메일 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + "401": + description: 인증 실패 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + /api/auth/signup: + post: + tags: + - Auth + summary: 이메일 회원가입 + description: 새로운 사용자를 등록하고 인증 이메일을 발송합니다. 이메일 인증 후 가입이 완료됩니다. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SignupRequest" + required: true + responses: + "409": + description: 이미 존재하는 email + "204": + description: 인증 이메일 발송 성공 + "400": + description: 잘못된 요청 (유효하지 않은 email/password/name) + "503": + description: 이메일 서비스 장애 + /api/auth/logout: + post: + tags: + - Auth + summary: 로그아웃 + description: 현재 JWT 토큰을 블랙리스트에 추가하여 로그아웃합니다 + responses: + "204": + description: 로그아웃 성공 + /api/auth/login: + post: + tags: + - Auth + summary: 로그인 + description: email로 로그인하여 JWT 토큰을 발급받습니다 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + required: true + responses: + "401": + description: 인증 실패 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + "200": + description: "로그인 성공, JWT 토큰 반환" + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + /api/auth/email-verification/{verificationCode}: + post: + tags: + - Auth + summary: 이메일 인증 + description: 이메일로 받은 인증 코드를 검증하고 회원가입을 완료합니다 + parameters: + - name: verificationCode + in: path + required: true + schema: + type: string + responses: + "400": + description: 유효하지 않거나 만료된 인증 코드 + "200": + description: "인증 성공, 유저 등록 완료" + /api/users/me: + get: + tags: + - User + summary: 본인 정보 조회 + description: 로그인한 사용자의 정보를 조회합니다 + security: + - bearerAuth: [] + responses: + "401": + description: 인증 실패 (유효하지 않은 토큰) + content: + '*/*': + schema: + $ref: "#/components/schemas/UserDto" + "200": + description: 사용자 정보 조회 성공 + content: + '*/*': + schema: + $ref: "#/components/schemas/UserDto" + patch: + tags: + - User + summary: 본인 정보 수정 + description: 로그인한 사용자의 정보를 수정합니다 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PatchMeRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/UserDto" + "401": + description: 인증 필요 + /api/registrations/{registrationId}: + get: + tags: + - Registration + summary: 신청 단건 정보 요청 + description: 신청 단건의 정보를 반환합니다. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetRegistrationResponse" + delete: + tags: + - Registration + summary: 신청 단건 취소 + description: 신청 단건 리소스를 취소합니다. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteRegistrationRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/DeleteRegistrationResponse" + patch: + tags: + - Registration + summary: 신청 단건 상태 변경 + description: 신청 단건 리소스의 상태를 변경합니다. 사용자가 본인의 신청을 취소하는 경우와 관리자가 강제 취소/밴 처리하는 경우를 + 로직 레벨에서 구분합니다. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateRegistrationStatusRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/PatchRegistrationResponse" + /api/registrations/me: + get: + tags: + - Registration + summary: 내 이벤트 신청 목록 조회 + description: 로그인한 사용자의 이벤트 신청 목록을 조회합니다. 최신 신청 순으로 반환되며 page/size로 페이징합니다. + security: + - bearerAuth: [] + parameters: + - name: page + in: query + required: false + schema: + type: integer + format: int32 + default: 0 + - name: size + in: query + required: false + schema: + type: integer + format: int32 + default: 20 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetMyRegistrationsResponse" + "401": + description: 인증 필요 + /api/events/me: + get: + tags: + - Event + summary: 내가 만든 이벤트 목록 조회 (무한 스크롤) + description: 로그인된 사용자가 생성한 이벤트 목록을 무한 스크롤 방식(cursor)으로 조회합니다 + security: + - bearerAuth: [] + parameters: + - name: cursor + in: query + required: false + schema: + type: string + format: date-time + - name: size + in: query + required: false + schema: + type: integer + format: int32 + default: 5 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/MyEventsInfiniteResponse" + "401": + description: 인증 필요 +components: + schemas: + UpdateEventRequest: + type: object + properties: + title: + type: string + description: 이벤트 제목 + example: 정기 모임 + description: + type: string + description: 이벤트 설명 + example: 정기 모임입니다 + location: + type: string + description: 장소 + example: 강의실 101 + startsAt: + type: string + description: 시작 시간 (ISO-8601) + format: date-time + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: 종료 시간 (ISO-8601) + format: date-time + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + example: 30 + waitlistEnabled: + type: boolean + description: 대기 명단 사용 여부 + example: true + registrationStartsAt: + type: string + description: 신청 시작 시간 (ISO-8601) + format: date-time + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: 신청 마감 시간 (ISO-8601) + format: date-time + example: 2026-02-02T17:30:00Z + description: 일정 수정 요청 + UpdateEventResponse: + required: + - registrationEndsAt + - title + - waitlistEnabled + type: object + properties: + title: + type: string + description: + type: string + location: + type: string + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + capacity: + type: integer + format: int32 + waitlistEnabled: + type: boolean + registrationStartsAt: + type: string + format: date-time + registrationEndsAt: + type: string + format: date-time + ImageUploadResponse: + required: + - key + - url + type: object + properties: + key: + type: string + url: + type: string + CreateEventRequest: + required: + - capacity + - registrationEndsAt + - title + - waitlistEnabled + type: object + properties: + title: + type: string + description: 이벤트 제목 + example: 정기 모임 + description: + type: string + description: 이벤트 설명 + example: 정기 모임입니다 + location: + type: string + description: 장소 + example: 강의실 101 + startsAt: + type: string + description: 시작 시간 (ISO-8601) + format: date-time + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: 종료 시간 (ISO-8601) + format: date-time + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + example: 30 + waitlistEnabled: + type: boolean + description: 대기 명단 사용 여부 + example: true + registrationStartsAt: + type: string + description: 신청 시작 시간 (ISO-8601). null인 경우 요청 시점으로 자동 설정됩니다. + format: date-time + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: 신청 마감 시간 (ISO-8601) + format: date-time + example: 2026-02-02T17:30:00Z + description: 일정 생성 요청 + CreateEventResponse: + required: + - publicId + type: object + properties: + publicId: + type: string + CreateRegistrationRequest: + type: object + properties: + guestName: + type: string + description: 비회원 이름 + guestEmail: + type: string + description: 비회원 이메일 + description: 이벤트 신청 요청 + CreateRegistrationResponse: + required: + - registrationPublicId + type: object + properties: + registrationPublicId: + type: string + description: 신청 id + description: 이벤트 신청 응답 + SocialLoginRequest: + required: + - code + - provider + type: object + properties: + provider: + type: string + description: 소셜로그인 제공자 + example: GOOGLE + code: + type: string + description: 인가 코드 (authorization code) + description: 소셜 로그인 요청 + LoginResponse: + required: + - token + type: object + properties: + token: + type: string + SignupRequest: + required: + - email + - name + - password + type: object + properties: + email: + type: string + description: 사용자 이메일 + example: user@example.com + name: + type: string + description: 사용자 이름 + example: 홍길동 + password: + type: string + description: 비밀번호 + example: password123 + profileImage: + type: string + description: 프로필 이미지 URL + example: https://cdn.example.com/profile.png + description: 회원가입 요청 + LoginRequest: + required: + - email + - password + type: object + properties: + email: + type: string + description: 사용자 이메일 + example: user@example.com + password: + type: string + description: 사용자 비밀번호 + example: mypassword + description: 로그인 요청 + PatchMeRequest: + type: object + properties: + name: + type: string + email: + type: string + password: + type: string + profileImage: + type: string + UserDto: + required: + - email + - id + - name + type: object + properties: + id: + type: integer + format: int64 + email: + type: string + name: + type: string + profileImage: + type: string + UpdateRegistrationStatusRequest: + type: object + properties: + status: + type: string + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + description: 이벤트 신청 상태 변경 요청 + PatchRegistrationResponse: + type: object + properties: + patchEmail: + type: string + description: 이벤트 신청 상태 변경 + GetRegistrationResponse: + required: + - guestName + - registrationPublicId + - reservationEmail + - status + - waitlistPosition + type: object + properties: + status: + type: string + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + guestName: + type: string + waitlistPosition: + type: integer + format: int32 + registrationPublicId: + type: string + reservationEmail: + type: string + description: 이벤트 신청 정보 요청 응답 + GetMyRegistrationsResponse: + required: + - registrations + type: object + properties: + registrations: + type: array + description: 신청 목록 + items: + $ref: "#/components/schemas/MyRegistrationItem" + description: 내 이벤트 신청 목록 응답 + MyRegistrationItem: + required: + - publicId + - registrationCnt + - status + - title + type: object + properties: + publicId: + type: string + description: 이벤트 공개 ID + title: + type: string + description: 이벤트 제목 + startsAt: + type: string + description: 이벤트 시작 시간 (ISO-8601) + format: date-time + endsAt: + type: string + description: 이벤트 종료 시간 (ISO-8601) + format: date-time + registrationStartsAt: + type: string + description: 신청 시작 시간 (ISO-8601) + format: date-time + registrationEndsAt: + type: string + description: 신청 마감 시간 (ISO-8601) + format: date-time + capacity: + type: integer + description: 정원 + format: int32 + registrationCnt: + type: integer + description: 현재 신청 수 + 대기 수 + format: int32 + status: + type: string + description: 신청 상태 + enum: + - CONFIRMED + - WAITLISTED + - BANNED + waitlistedNum: + type: integer + description: 대기 순번 (WAITLISTED가 아니면 null) + format: int32 + description: 내 이벤트 신청 요약 + CapabilitiesInfo: + required: + - apply + - cancel + - shareLink + - wait + type: object + properties: + shareLink: + type: boolean + description: 공유 링크 가능 여부 + example: false + apply: + type: boolean + description: 참여 신청 가능 여부(확정 자리 신청) + example: false + wait: + type: boolean + description: 대기 신청 가능 여부(정원 찼을 때) + example: false + cancel: + type: boolean + description: 신청 취소 가능 여부 + example: true + description: 가능한 동작 블록 + CreatorInfo: + required: + - email + - name + type: object + properties: + name: + type: string + description: 이름 + example: 홍길동 + email: + type: string + description: 이메일 + example: user@example.com + profileImage: + type: string + description: 프로필 이미지 + nullable: true + example: https://example.com/profile.png + description: 생성자 정보 블록 + EventDetailResponse: + required: + - capabilities + - creator + - event + - guestsPreview + - viewer + type: object + properties: + event: + $ref: "#/components/schemas/EventInfo" + creator: + $ref: "#/components/schemas/CreatorInfo" + viewer: + $ref: "#/components/schemas/ViewerInfo" + capabilities: + $ref: "#/components/schemas/CapabilitiesInfo" + guestsPreview: + type: array + description: 참여자 미리보기 목록 + items: + $ref: "#/components/schemas/GuestPreview" + description: 일정 상세 조회 응답 + EventInfo: + required: + - publicId + - registrationEndsAt + - title + - totalApplicants + type: object + properties: + publicId: + type: string + description: 이벤트 publicId + example: dj8sadj10djnkas + title: + type: string + description: 이벤트 제목 + example: 제2회 기획 세미나 + description: + type: string + description: 이벤트 설명 + nullable: true + example: 일정 상세 설명... + location: + type: string + description: 장소 + nullable: true + example: 서울대 + startsAt: + type: string + description: 시작 시간 (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: 종료 시간 (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + nullable: true + example: 10 + totalApplicants: + type: integer + description: 총 신청자 수(확정+대기 등) + format: int32 + example: 8 + registrationStartsAt: + type: string + description: 신청 시작 시간 (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: 신청 마감 시간 (ISO-8601) + format: date-time + example: 2026-02-02T17:00:00Z + description: 이벤트 정보 블록 + GuestPreview: + required: + - name + type: object + properties: + id: + type: integer + description: 참여자 ID (비회원이면 null) + format: int64 + nullable: true + example: 1 + name: + type: string + description: 참여자 이름 + example: 홍길동 + profileImage: + type: string + description: 프로필 이미지 URL + nullable: true + example: https://example.com/profile.png + description: 참여자 미리보기 정보 + ViewerInfo: + required: + - status + type: object + properties: + status: + type: string + description: 이벤트 조회자(Viewer)의 상태 + example: WAITLISTED + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + - NONE + name: + type: string + description: viewer 이름 (비회원이면 null) + nullable: true + example: 홍길동 + waitlistPosition: + type: integer + description: "대기 순번 (WAITLISTED일 때만 숫자, 아니면 null)" + format: int32 + nullable: true + example: 3 + registrationPublicId: + type: string + description: "registrationPublicId (게스트면 값, 아니면 null)" + nullable: true + example: "1" + reservationEmail: + type: string + description: "예약 이메일(게스트면 guestEmail, 로그인 유저면 user email 등 정책에 따름)" + nullable: true + example: skdfsj@gmaldkl + description: 조회자(viewer) 정보 블록 + EventRegistrationItem: + required: + - createdAt + - name + - registrationId + - status + type: object + properties: + registrationId: + type: string + description: 신청 공개 ID + name: + type: string + description: 이름 + email: + type: string + description: 이메일 (관리자 요청 시 노출) + profileImage: + type: string + description: 프로필 이미지 URL + createdAt: + type: string + description: 신청 일시 + format: date-time + status: + type: string + description: 신청 상태 + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + waitingNum: + type: integer + description: 대기 순번 (status가 WAITLISTED일 때만 아니면 null) + format: int32 + description: 신청자 정보 + GetEventRegistrationsResponse: + required: + - hasNext + - participants + type: object + properties: + participants: + type: array + description: 신청자 목록 + items: + $ref: "#/components/schemas/EventRegistrationItem" + totalCount: + type: integer + description: 필터에 따라 변하는 참여자 수 + format: int32 + nextCursor: + type: integer + description: 다음 페이지 시작 offset (없으면 null) + format: int32 + hasNext: + type: boolean + description: 다음 페이지 존재 여부 + description: 이벤트 신청 목록 조회 응답 + MyEventResponse: + required: + - publicId + - registrationEndsAt + - title + - totalApplicants + type: object + properties: + publicId: + type: string + title: + type: string + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + registrationStartsAt: + type: string + format: date-time + registrationEndsAt: + type: string + format: date-time + capacity: + type: integer + format: int32 + totalApplicants: + type: integer + format: int32 + MyEventsInfiniteResponse: + required: + - events + - hasNext + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/MyEventResponse" + nextCursor: + type: string + format: date-time + hasNext: + type: boolean + DeleteRegistrationRequest: + type: object + properties: + guestName: + type: string + description: 비회원 이름 + guestEmail: + type: string + description: 비회원 이메일 + description: 이벤트 취소 요청 + DeleteRegistrationResponse: + type: object + securitySchemes: + bearerAuth: + type: http + description: JWT 토큰을 입력하세요 + name: bearerAuth + scheme: bearer + bearerFormat: JWT diff --git a/src/main/resources/static/docs/swagger-ui.html b/src/main/resources/static/docs/swagger-ui.html new file mode 100644 index 00000000..517735db --- /dev/null +++ b/src/main/resources/static/docs/swagger-ui.html @@ -0,0 +1,36 @@ + + + + + + MoiMing API Docs + + + + +
+ + + + + From bd0c580f019074351099b9e8f766889f849c4811 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 18 Apr 2026 22:18:27 +0900 Subject: [PATCH 127/157] =?UTF-8?q?:memo:=20swagger=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/docs/openapi.yaml | 125 ++++++++++++++++---- 1 file changed, 99 insertions(+), 26 deletions(-) diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml index cdfaeb8b..6fa1daac 100644 --- a/src/main/resources/static/docs/openapi.yaml +++ b/src/main/resources/static/docs/openapi.yaml @@ -108,12 +108,6 @@ paths: type: string format: binary responses: - "401": - description: 인증 실패 (유효하지 않은 토큰) - content: - '*/*': - schema: - $ref: "#/components/schemas/ImageUploadResponse" "200": description: 이미지 업로드 성공 content: @@ -125,7 +119,13 @@ paths: content: '*/*': schema: - $ref: "#/components/schemas/ImageUploadResponse" + $ref: "#/components/schemas/ErrorResponse" + "401": + description: 인증 실패 (유효하지 않은 토큰) + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" /api/events: post: tags: @@ -212,31 +212,29 @@ paths: schema: $ref: "#/components/schemas/CreateRegistrationResponse" "409": - description: "신청 불가: 3001(Registration already exists), 3005(Host cannot\ - \ register), 2301(Event is full)" + description: "신청 불가: REGISTRATION_ALREADY_EXISTS, REGISTRATION_BLOCKED_HOST, EVENT_FULL" content: '*/*': schema: - $ref: "#/components/schemas/CreateRegistrationResponse" + $ref: "#/components/schemas/ErrorResponse" "400": - description: "유효하지 않은 요청: 3003(Wrong guest name), 3004(Wrong guest email),\ - \ 2204(Invalid registration window)" + description: "유효하지 않은 요청: REGISTRATION_WRONG_NAME, REGISTRATION_WRONG_EMAIL, NOT_WITHIN_REGISTRATION_WINDOW" content: '*/*': schema: - $ref: "#/components/schemas/CreateRegistrationResponse" + $ref: "#/components/schemas/ErrorResponse" "403": - description: "권한 없음: 3006(Banned user cannot register)" + description: "권한 없음: REGISTRATION_BLOCKED_BANNED" content: '*/*': schema: - $ref: "#/components/schemas/CreateRegistrationResponse" + $ref: "#/components/schemas/ErrorResponse" "404": - description: "이벤트 없음: 2001(Event not found)" + description: "이벤트 없음: EVENT_NOT_FOUND" content: '*/*': schema: - $ref: "#/components/schemas/CreateRegistrationResponse" + $ref: "#/components/schemas/ErrorResponse" /api/auth/social: post: tags: @@ -261,13 +259,13 @@ paths: content: '*/*': schema: - $ref: "#/components/schemas/LoginResponse" + $ref: "#/components/schemas/ErrorResponse" "401": description: 인증 실패 content: '*/*': schema: - $ref: "#/components/schemas/LoginResponse" + $ref: "#/components/schemas/ErrorResponse" /api/auth/signup: post: tags: @@ -316,7 +314,7 @@ paths: content: '*/*': schema: - $ref: "#/components/schemas/LoginResponse" + $ref: "#/components/schemas/ErrorResponse" "200": description: "로그인 성공, JWT 토큰 반환" content: @@ -354,7 +352,7 @@ paths: content: '*/*': schema: - $ref: "#/components/schemas/UserDto" + $ref: "#/components/schemas/ErrorResponse" "200": description: 사용자 정보 조회 성공 content: @@ -522,43 +520,52 @@ components: title: type: string description: 이벤트 제목 + nullable: true example: 정기 모임 description: type: string description: 이벤트 설명 + nullable: true example: 정기 모임입니다 location: type: string description: 장소 + nullable: true example: 강의실 101 startsAt: type: string description: 시작 시간 (ISO-8601) format: date-time + nullable: true example: 2026-02-02T18:00:00Z endsAt: type: string description: 종료 시간 (ISO-8601) format: date-time + nullable: true example: 2026-02-02T20:00:00Z capacity: type: integer description: 정원 format: int32 + nullable: true example: 30 waitlistEnabled: type: boolean description: 대기 명단 사용 여부 + nullable: true example: true registrationStartsAt: type: string description: 신청 시작 시간 (ISO-8601) format: date-time + nullable: true example: 2026-02-02T17:00:00Z registrationEndsAt: type: string description: 신청 마감 시간 (ISO-8601) format: date-time + nullable: true example: 2026-02-02T17:30:00Z description: 일정 수정 요청 UpdateEventResponse: @@ -572,22 +579,28 @@ components: type: string description: type: string + nullable: true location: type: string + nullable: true startsAt: type: string format: date-time + nullable: true endsAt: type: string format: date-time + nullable: true capacity: type: integer format: int32 + nullable: true waitlistEnabled: type: boolean registrationStartsAt: type: string format: date-time + nullable: true registrationEndsAt: type: string format: date-time @@ -616,20 +629,24 @@ components: description: type: string description: 이벤트 설명 + nullable: true example: 정기 모임입니다 location: type: string description: 장소 + nullable: true example: 강의실 101 startsAt: type: string description: 시작 시간 (ISO-8601) format: date-time + nullable: true example: 2026-02-02T18:00:00Z endsAt: type: string description: 종료 시간 (ISO-8601) format: date-time + nullable: true example: 2026-02-02T20:00:00Z capacity: type: integer @@ -644,6 +661,7 @@ components: type: string description: 신청 시작 시간 (ISO-8601). null인 경우 요청 시점으로 자동 설정됩니다. format: date-time + nullable: true example: 2026-02-02T17:00:00Z registrationEndsAt: type: string @@ -664,9 +682,11 @@ components: guestName: type: string description: 비회원 이름 + nullable: true guestEmail: type: string description: 비회원 이메일 + nullable: true description: 이벤트 신청 요청 CreateRegistrationResponse: required: @@ -720,6 +740,7 @@ components: profileImage: type: string description: 프로필 이미지 URL + nullable: true example: https://cdn.example.com/profile.png description: 회원가입 요청 LoginRequest: @@ -742,12 +763,16 @@ components: properties: name: type: string + nullable: true email: type: string + nullable: true password: type: string + nullable: true profileImage: type: string + nullable: true UserDto: required: - email @@ -764,11 +789,13 @@ components: type: string profileImage: type: string + nullable: true UpdateRegistrationStatusRequest: type: object properties: status: type: string + nullable: true enum: - HOST - CONFIRMED @@ -780,6 +807,7 @@ components: properties: patchEmail: type: string + nullable: true description: 이벤트 신청 상태 변경 GetRegistrationResponse: required: @@ -836,22 +864,27 @@ components: type: string description: 이벤트 시작 시간 (ISO-8601) format: date-time + nullable: true endsAt: type: string description: 이벤트 종료 시간 (ISO-8601) format: date-time + nullable: true registrationStartsAt: type: string description: 신청 시작 시간 (ISO-8601) format: date-time + nullable: true registrationEndsAt: type: string description: 신청 마감 시간 (ISO-8601) format: date-time + nullable: true capacity: type: integer description: 정원 format: int32 + nullable: true registrationCnt: type: integer description: 현재 신청 수 + 대기 수 @@ -867,6 +900,7 @@ components: type: integer description: 대기 순번 (WAITLISTED가 아니면 null) format: int32 + nullable: true description: 내 이벤트 신청 요약 CapabilitiesInfo: required: @@ -941,7 +975,8 @@ components: - publicId - registrationEndsAt - title - - totalApplicants + - confirmedCount + - waitlistCount type: object properties: publicId: @@ -980,9 +1015,14 @@ components: format: int32 nullable: true example: 10 - totalApplicants: + confirmedCount: + type: integer + description: 참여 확정 신청자 수 + format: int32 + example: 8 + waitlistCount: type: integer - description: 총 신청자 수(확정+대기 등) + description: 대기자 수 format: int32 example: 8 registrationStartsAt: @@ -1072,9 +1112,11 @@ components: email: type: string description: 이메일 (관리자 요청 시 노출) + nullable: true profileImage: type: string description: 프로필 이미지 URL + nullable: true createdAt: type: string description: 신청 일시 @@ -1091,6 +1133,7 @@ components: type: integer description: 대기 순번 (status가 WAITLISTED일 때만 아니면 null) format: int32 + nullable: true description: 신청자 정보 GetEventRegistrationsResponse: required: @@ -1107,10 +1150,12 @@ components: type: integer description: 필터에 따라 변하는 참여자 수 format: int32 + nullable: true nextCursor: type: integer description: 다음 페이지 시작 offset (없으면 null) format: int32 + nullable: true hasNext: type: boolean description: 다음 페이지 존재 여부 @@ -1120,7 +1165,8 @@ components: - publicId - registrationEndsAt - title - - totalApplicants + - confirmedCount + - waitlistCount type: object properties: publicId: @@ -1130,19 +1176,26 @@ components: startsAt: type: string format: date-time + nullable: true endsAt: type: string format: date-time + nullable: true registrationStartsAt: type: string format: date-time + nullable: true registrationEndsAt: type: string format: date-time capacity: type: integer format: int32 - totalApplicants: + nullable: true + confirmedCount: + type: integer + format: int32 + waitlistCount: type: integer format: int32 MyEventsInfiniteResponse: @@ -1158,6 +1211,7 @@ components: nextCursor: type: string format: date-time + nullable: true hasNext: type: boolean DeleteRegistrationRequest: @@ -1166,12 +1220,31 @@ components: guestName: type: string description: 비회원 이름 + nullable: true guestEmail: type: string description: 비회원 이메일 + nullable: true description: 이벤트 취소 요청 DeleteRegistrationResponse: type: object + ErrorResponse: + required: + - code + - message + - title + type: object + properties: + code: + type: string + description: 에러 코드 + title: + type: string + description: 에러 제목 + message: + type: string + description: 에러 메시지 + description: 에러 응답 securitySchemes: bearerAuth: type: http From ab84eb88176ca0bd3a8c136b97cc2d18f5d04ecd Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 21 Apr 2026 22:50:51 +0900 Subject: [PATCH 128/157] =?UTF-8?q?:seedling:=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8C=85=20config=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/resources/application-local.yaml diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml new file mode 100644 index 00000000..920575c1 --- /dev/null +++ b/src/main/resources/application-local.yaml @@ -0,0 +1,7 @@ +spring: + config: + activate: + on-profile: local + data: + redis: + host: localhost \ No newline at end of file From 5e27f66366a7f0a4de12091f7ac024a42a602cd9 Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 28 Apr 2026 18:00:02 +0900 Subject: [PATCH 129/157] =?UTF-8?q?feat(registration):=20/api/registration?= =?UTF-8?q?s/me=20=EC=9D=91=EB=8B=B5=EC=97=90=20confirmedCount/waitlistCou?= =?UTF-8?q?nt=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20registrationCnt=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/GetMyRegistrationsResponse.kt | 12 ++++--- .../service/RegistrationService.kt | 14 +++++++-- .../spring2025/RegistrationIntegrationTest.kt | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt index 74c36966..f3a63da3 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt @@ -28,8 +28,10 @@ data class MyRegistrationItem( val registrationEndsAt: Instant?, @Schema(description = "정원") val capacity: Int?, - @Schema(description = "현재 신청 수 + 대기 수") - val registrationCnt: Int, + @Schema(description = "확정 참여자 수") + val confirmedCount: Int, + @Schema(description = "대기자 수") + val waitlistCount: Int, @Schema(description = "신청 상태") val status: MyRegistrationStatus, @Schema(description = "대기 순번 (WAITLISTED가 아니면 null)") @@ -38,7 +40,8 @@ data class MyRegistrationItem( constructor( registration: Registration, event: Event, - registrationCnt: Int, + confirmedCount: Int, + waitlistCount: Int, waitlistedNum: Int?, ) : this( publicId = event.publicId, @@ -48,7 +51,8 @@ data class MyRegistrationItem( registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, capacity = event.capacity, - registrationCnt = registrationCnt, + confirmedCount = confirmedCount, + waitlistCount = waitlistCount, status = MyRegistrationStatus.from(registration.status), waitlistedNum = waitlistedNum, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 787e9972..a87b211a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -337,11 +337,18 @@ RegistrationService( event.id ?: throw EventNotFoundException() } - val countsByEventId = + val confirmedCountsByEventId = registrationRepository .countByEventIdsAndStatuses( eventIds = eventIds, - statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED), + statuses = listOf(RegistrationStatus.CONFIRMED), + ).associate { it.eventId to it.totalCount.toInt() } + + val waitlistCountsByEventId = + registrationRepository + .countByEventIdsAndStatuses( + eventIds = eventIds, + statuses = listOf(RegistrationStatus.WAITLISTED), ).associate { it.eventId to it.totalCount.toInt() } val waitlistedByRegistrationId = @@ -362,7 +369,8 @@ RegistrationService( MyRegistrationItem( registration = registration, event = event, - registrationCnt = countsByEventId[registration.eventId] ?: 0, + confirmedCount = confirmedCountsByEventId[registration.eventId] ?: 0, + waitlistCount = waitlistCountsByEventId[registration.eventId] ?: 0, waitlistedNum = waitlistedNumber, ) } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index 8b2b2649..48b9cd3d 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -705,6 +705,37 @@ class RegistrationIntegrationTest .andExpect(jsonPath("$.registrations.length()").value(1)) } + @Test + fun `my registrations endpoint includes confirmed and waitlist counts`() { + val (host, _) = dataGenerator.generateUser() + val (_, participantToken) = dataGenerator.generateUser() + val (_, anotherParticipantToken) = dataGenerator.generateUser() + + val event = + createEvent( + createdBy = host.id!!, + title = "count-check", + capacity = 1, + waitlistEnabled = true, + ) + + registerAsUser(event.publicId, participantToken) + registerAsUser(event.publicId, anotherParticipantToken) + + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(1)) + .andExpect(jsonPath("$.registrations[0].confirmedCount").value(1)) + .andExpect(jsonPath("$.registrations[0].waitlistCount").value(1)) + .andExpect(jsonPath("$.registrations[0].registrationCnt").doesNotExist()) + } + private fun registerAsUser( eventPublicId: String, token: String, From 33f66300703b9d476ecf61f3cda116f2b49c477d Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 19:55:45 +0900 Subject: [PATCH 130/157] =?UTF-8?q?:zap:=20=EB=8C=80=EA=B8=B0=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B3=84=EC=82=B0=20=EC=8B=9C=20=EB=AA=A8=EB=93=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0=20=EC=8B=A0=EC=B2=AD=20=EA=B1=B4=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(=EB=A9=94=EB=AA=A8=EB=A6=AC?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/service/EventService.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index e52f056d..4275a045 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -121,17 +121,16 @@ class EventService( .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.WAITLISTED) .toInt() - val totalApplicants = confirmedCount + waitlistedCount - val waitlistPosition: Int? = if (myReg?.status == RegistrationStatus.WAITLISTED) { - val waitlistedRegs = - registrationRepository.findByEventIdAndStatusOrderByCreatedAtAsc( - eventID = eventId, - registrationStatus = RegistrationStatus.WAITLISTED, - ) - val idx = waitlistedRegs.indexOfFirst { it.id == myReg.id } - if (idx >= 0) idx + 1 else null + registrationRepository + .findWaitlistPositionsByRegistrationPublicIds( + eventId = eventId, + status = RegistrationStatus.WAITLISTED, + registrationPublicIds = listOf(myReg.registrationPublicId), + ).firstOrNull() + ?.waitlistNumber + ?.toInt() } else { null } From adb4a94ab5b6a9ce03d34751b1571b70d4b810d7 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 19:58:35 +0900 Subject: [PATCH 131/157] =?UTF-8?q?:zap:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20user=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=ED=9A=9F=EC=88=98=20=EC=A4=84=EC=9D=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/service/EventService.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 4275a045..762ec817 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -96,10 +96,9 @@ class EventService( val event = getEventByPublicId(publicId) val eventId = requireNotNull(event.id) { "Event id is null: publicId=$publicId" } - val creatorUser = - userRepository.findById(event.createdBy).orElseThrow { - EventNotFoundException() - } + val userIdsToFetch = listOfNotNull(event.createdBy, requesterId).distinct() + val usersById = userRepository.findAllById(userIdsToFetch).associateBy { it.id!! } + val creatorUser = usersById[event.createdBy] ?: throw EventNotFoundException() val myReg = if (requesterId == null) { @@ -149,10 +148,7 @@ class EventService( } } - val viewerName: String? = - requesterId?.let { - userRepository.findById(it).orElse(null)?.name - } + val viewerName: String? = requesterId?.let { usersById[it]?.name } val viewer = if (viewerStatus == ViewerStatus.NONE) { From f36546d7abb77ffededb83c3a30ea3aee81d1634 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:00:57 +0900 Subject: [PATCH 132/157] =?UTF-8?q?:zap:=20guest=20preview=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20db=EC=97=90=EC=84=9C=20=EC=B2=98?= =?UTF-8?q?=EC=9D=8C=EB=B6=80=ED=84=B0=205=EA=B1=B4=EB=A7=8C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=B5=9C=EC=A0=81=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/service/EventService.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 762ec817..e451a6b9 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -23,6 +23,7 @@ import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRe import com.wafflestudio.spring2025.domain.registration.service.WaitlistReconciliationService import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -179,18 +180,17 @@ class EventService( registrationEndsAt = event.registrationEndsAt, ) - val confirmedRegs = + val previewRegs = registrationRepository.findByEventIdAndStatusOrderByCreatedAtAsc( eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED, + pageable = Pageable.ofSize(5), ) - val previewRegs = confirmedRegs.take(5) - val previewUserIds = previewRegs.mapNotNull { it.userId }.distinct() - val usersById = + val previewUsersById = userRepository.findAllById(previewUserIds).associateBy { it.id!! } val guestsPreview = @@ -203,7 +203,7 @@ class EventService( profileImage = null, ) } else { - usersById[uid]?.let { + previewUsersById[uid]?.let { GuestPreview( id = it.id!!, name = it.name, From 4186c111cd27f69f68f75c9c771860c04e47a340 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:02:38 +0900 Subject: [PATCH 133/157] =?UTF-8?q?:zap:=20event=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20N+1=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/service/EventService.kt | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index e451a6b9..6392e094 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -271,19 +271,31 @@ class EventService( val sliced = fetched.take(pageSize) val nextCursor = sliced.lastOrNull()?.createdAt + val eventIds = sliced.map { requireNotNull(it.id) } + + val confirmedCounts = + if (eventIds.isEmpty()) { + emptyMap() + } else { + registrationRepository + .countByEventIdsAndStatuses(eventIds = eventIds, listOf(RegistrationStatus.CONFIRMED)) + .associate { it.eventId to it.totalCount.toInt() } + } + + val waitlistedCounts = + if (eventIds.isEmpty()) { + emptyMap() + } else { + registrationRepository + .countByEventIdsAndStatuses(eventIds = eventIds, listOf(RegistrationStatus.WAITLISTED)) + .associate { it.eventId to it.totalCount.toInt() } + } + val responses = sliced.map { event -> val eventId = requireNotNull(event.id) - - val confirmedCount = - registrationRepository - .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED) - .toInt() - - val waitlistedCount = - registrationRepository - .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.WAITLISTED) - .toInt() + val confirmedCount = confirmedCounts[eventId] ?: 0 + val waitlistedCount = waitlistedCounts[eventId] ?: 0 MyEventResponse( publicId = event.publicId, From 8080f1f0b455e1628e0778847e500ce01db9a70c Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:03:53 +0900 Subject: [PATCH 134/157] =?UTF-8?q?:zap:=20event=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=8C=80=EC=83=81=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=B2=98=EC=9D=8C=EB=B6=80=ED=84=B0=20CONFIRMED+WA?= =?UTF-8?q?ITLISTED=EB=A7=8C=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/service/EventService.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 6392e094..7d41b3e9 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -385,9 +385,10 @@ class EventService( // 알림 대상: CONFIRMED + WAITLISTED (BANNED 제외) val registrationsToNotify = - registrationRepository - .findByEventId(eventId) - .filter { it.status == RegistrationStatus.CONFIRMED || it.status == RegistrationStatus.WAITLISTED } + registrationRepository.findByEventIdAndStatusIn( + eventID = eventId, + statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED), + ) // 이메일 데이터 구성 (삭제 전에 user 정보 조회) val hostUser = userRepository.findById(event.createdBy).orElse(null) From 2cc448120d04ce50352f7106007cf39cda140776 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:04:27 +0900 Subject: [PATCH 135/157] =?UTF-8?q?:zap:=20event=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=BF=BC=EB=A6=AC=20=EC=B5=9C=EC=A0=81=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20repository=20method=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../registration/repository/RegistrationRepository.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 5ed64233..3c5afa24 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -41,6 +41,11 @@ interface RegistrationRepository : eventId: Long, ): Registration? + fun findByEventIdAndStatusIn( + eventID: Long, + statuses: Collection, + ): List + fun countByEventId(eventID: Long): Long fun countByEventIdAndStatus( @@ -66,6 +71,12 @@ interface RegistrationRepository : registrationStatus: RegistrationStatus, ): List + fun findByEventIdAndStatusOrderByCreatedAtAsc( + eventID: Long, + registrationStatus: RegistrationStatus, + pageable: Pageable, + ): List + @Query( """ SELECT * FROM registrations From 63b791ffd88b15bce982d666ffbb2eb70aea6699 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:33:51 +0900 Subject: [PATCH 136/157] =?UTF-8?q?:zap:=20reconcileWaitlist=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20waitlist=EC=8B=A0=EC=B2=AD=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../registration/service/RegistrationService.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index a87b211a..a3a8fd01 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -28,6 +28,7 @@ import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.springframework.dao.DuplicateKeyException import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionSynchronization @@ -755,23 +756,26 @@ RegistrationService( val available = capacity - confirmed if (available <= 0) return - val waitlistedRegs = + val totalWaitlisted = + registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.WAITLISTED).toInt() + + val promoted = registrationRepository.findByEventIdAndStatusOrderByCreatedAtAsc( eventId, RegistrationStatus.WAITLISTED, + Pageable.ofSize(available), ) val waitlistNumbers = - waitlistedRegs.withIndex().associate { indexed -> - indexed.value.registrationPublicId to (indexed.index + 1) + promoted.withIndex().associate { (index, reg) -> + reg.registrationPublicId to (index + 1) } - val promoted = waitlistedRegs.take(available) promoted.forEach { it.status = RegistrationStatus.CONFIRMED } registrationRepository.saveAll(promoted) val confirmedAfter = confirmed + promoted.size - val remainingWaitlisted = waitlistedRegs.size - promoted.size + val remainingWaitlisted = totalWaitlisted - promoted.size val totalCount = confirmedAfter + remainingWaitlisted val emailDataList = From 5ed23a64f1f7d1c53c8dbd7cb2682b12335700c7 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:35:23 +0900 Subject: [PATCH 137/157] =?UTF-8?q?:zap:=20registration=20create=20?= =?UTF-8?q?=EC=8B=9C=20user=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=9A=9F=EC=88=98=20=EC=A4=84=EC=9D=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/service/RegistrationService.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index a3a8fd01..87dfaaba 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -97,6 +97,11 @@ RegistrationService( registrationRepository.findByUserIdAndEventId(userId, eventPk) } + val userIdsToFetch = listOfNotNull(lockedEvent.createdBy, userId).distinct() + val fetchedUsersById = userRepository.findAllById(userIdsToFetch).associateBy { it.id!! } + val hostEmail = fetchedUsersById[lockedEvent.createdBy]?.email + val user = userId?.let { fetchedUsersById[it] } + val saved = try { if (existingRegistration != null) { @@ -113,8 +118,7 @@ RegistrationService( -> throw RegistrationConflictException(RegistrationErrorCode.REGISTRATION_ALREADY_EXISTS) } } else { - val hostEmail = userRepository.findById(lockedEvent.createdBy).orElse(null)?.email - val registrationEmail = userId?.let { userRepository.findById(it).orElse(null)?.email } ?: guestEmail + val registrationEmail = user?.email ?: guestEmail if (hostEmail != null && hostEmail == registrationEmail) { throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_BLOCKED_HOST) From 0848edd14828d293bf893a1b80ad5908617eec33 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:36:57 +0900 Subject: [PATCH 138/157] =?UTF-8?q?:zap:=20registration=20create=20?= =?UTF-8?q?=EC=8B=9C=20waitlist=20count=20=EC=A4=91=EB=B3=B5=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RegistrationService.kt | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 87dfaaba..36800e33 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -148,19 +148,11 @@ RegistrationService( throw RegistrationConflictException(RegistrationErrorCode.REGISTRATION_ALREADY_EXISTS) } - val waitlistedNumber: Int? = - if (saved.status == RegistrationStatus.WAITLISTED) { - registrationRepository - .countByEventIdAndStatus(eventPk, RegistrationStatus.WAITLISTED) - .toInt() - } else { - null - } - - val user = - userId?.let { id -> - userRepository.findById(id).orElse(null) - } + val waitlistedCount = + registrationRepository + .countByEventIdAndStatus(eventPk, RegistrationStatus.WAITLISTED) + .toInt() + val waitlistedNumber: Int? = if (saved.status == RegistrationStatus.WAITLISTED) waitlistedCount else null val recipientEmail = user?.email ?: guestEmail @@ -169,10 +161,6 @@ RegistrationService( registrationRepository .countByEventIdAndStatus(eventPk, RegistrationStatus.CONFIRMED) .toInt() - val waitlistedCount = - registrationRepository - .countByEventIdAndStatus(eventPk, RegistrationStatus.WAITLISTED) - .toInt() val totalCount = confirmedCount + waitlistedCount val emailData = From 4f822e0f4bb866b1fdaa90ecc2cf943bc7a0fbae Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:37:18 +0900 Subject: [PATCH 139/157] =?UTF-8?q?:zap:=20registration=20delete=20?= =?UTF-8?q?=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20lock=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/service/RegistrationService.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 36800e33..f5061e42 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -253,8 +253,6 @@ RegistrationService( } } - eventLockRepository.lockById(registration.eventId) - if (!isRegistrationEnabled(event)) { throw RegistrationValidationException(RegistrationErrorCode.NOT_WITHIN_REGISTRATION_WINDOW) } From 9ff360af56fcf0dcf0a45f4420a4c36e8f172a84 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:38:03 +0900 Subject: [PATCH 140/157] =?UTF-8?q?:zap:=20=EB=8C=80=EA=B8=B0=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B3=84=EC=82=B0=20=EC=8B=9C=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../registration/service/RegistrationService.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index f5061e42..a7a3dffc 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -576,13 +576,14 @@ RegistrationService( val status = registration.status val waitlistPosition = if (status == RegistrationStatus.WAITLISTED) { - val waitlistedRegs = - registrationRepository.findByEventIdAndStatusOrderByCreatedAtAsc( - registration.eventId, - RegistrationStatus.WAITLISTED, - ) - val idx = waitlistedRegs.indexOfFirst { it.id == registration.id } - if (idx >= 0) idx + 1 else 0 + registrationRepository + .findWaitlistPositionsByRegistrationPublicIds( + eventId = registration.eventId, + status = RegistrationStatus.WAITLISTED, + registrationPublicIds = listOf(registration.registrationPublicId), + ).firstOrNull() + ?.waitlistNumber + ?.toInt() ?: 0 } else { 0 } From 936cfb1d80e09886ac22a5e0c35672ffc1cbc188 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 2 May 2026 20:38:43 +0900 Subject: [PATCH 141/157] =?UTF-8?q?:zap:=20reconcileWaitlist=EC=97=90?= =?UTF-8?q?=EC=84=9C=20user=20=EC=A1=B0=ED=9A=8C=20N+1=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/service/RegistrationService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index a7a3dffc..00df08fc 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -769,12 +769,12 @@ RegistrationService( val remainingWaitlisted = totalWaitlisted - promoted.size val totalCount = confirmedAfter + remainingWaitlisted + val promotedUserIds = promoted.mapNotNull { it.userId }.distinct() + val promotedUsersById = userRepository.findAllById(promotedUserIds).associateBy { it.id!! } + val emailDataList = promoted.mapNotNull { registration -> - val user: User? = - registration.userId?.let { uid -> - userRepository.findById(uid).orElse(null) - } + val user: User? = registration.userId?.let { promotedUsersById[it] } val recipientEmail = user?.email ?: registration.guestEmail val recipientName = user?.name ?: registration.guestName ?: "참여자" From fe12ce10e6c2f3ee220ecfa577ef5b18adb0bb0d Mon Sep 17 00:00:00 2001 From: uykhc Date: Sat, 2 May 2026 22:53:53 +0900 Subject: [PATCH 142/157] docs(openapi): fix Redocly lint errors for security scheme and operation security --- src/main/resources/static/docs/openapi.yaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml index 6fa1daac..ee0b86b3 100644 --- a/src/main/resources/static/docs/openapi.yaml +++ b/src/main/resources/static/docs/openapi.yaml @@ -20,6 +20,7 @@ tags: paths: /api/events/{publicId}: get: + security: [] tags: - Event summary: 이벤트 상세 조회 @@ -85,6 +86,8 @@ paths: description: 인증 필요 /api/images: post: + security: + - bearerAuth: [] tags: - Image summary: 이미지 업로드 @@ -151,6 +154,8 @@ paths: description: 인증 필요 /api/events/{eventId}/registrations: get: + security: + - bearerAuth: [] tags: - Registration summary: 이벤트 신청 목록 조회 @@ -187,6 +192,7 @@ paths: schema: $ref: "#/components/schemas/GetEventRegistrationsResponse" post: + security: [] tags: - Registration summary: 이벤트 참여 신청 @@ -237,6 +243,7 @@ paths: $ref: "#/components/schemas/ErrorResponse" /api/auth/social: post: + security: [] tags: - Auth summary: 소셜 로그인 @@ -268,6 +275,7 @@ paths: $ref: "#/components/schemas/ErrorResponse" /api/auth/signup: post: + security: [] tags: - Auth summary: 이메일 회원가입 @@ -289,6 +297,8 @@ paths: description: 이메일 서비스 장애 /api/auth/logout: post: + security: + - bearerAuth: [] tags: - Auth summary: 로그아웃 @@ -298,6 +308,7 @@ paths: description: 로그아웃 성공 /api/auth/login: post: + security: [] tags: - Auth summary: 로그인 @@ -323,6 +334,7 @@ paths: $ref: "#/components/schemas/LoginResponse" /api/auth/email-verification/{verificationCode}: post: + security: [] tags: - Auth summary: 이메일 인증 @@ -383,6 +395,7 @@ paths: description: 인증 필요 /api/registrations/{registrationId}: get: + security: [] tags: - Registration summary: 신청 단건 정보 요청 @@ -401,6 +414,7 @@ paths: schema: $ref: "#/components/schemas/GetRegistrationResponse" delete: + security: [] tags: - Registration summary: 신청 단건 취소 @@ -425,6 +439,8 @@ paths: schema: $ref: "#/components/schemas/DeleteRegistrationResponse" patch: + security: + - bearerAuth: [] tags: - Registration summary: 신청 단건 상태 변경 @@ -1249,6 +1265,5 @@ components: bearerAuth: type: http description: JWT 토큰을 입력하세요 - name: bearerAuth scheme: bearer bearerFormat: JWT From b045dfc2cab132ef8e1cb8e4148d730eb6ac5a96 Mon Sep 17 00:00:00 2001 From: uykhc Date: Sat, 2 May 2026 23:02:41 +0900 Subject: [PATCH 143/157] refactor(swagger): remove leftover @Schema annotations for static OpenAPI docs --- .../registration/dto/response/GetMyRegistrationsResponse.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt index ced9b0f2..0aca3e06 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt @@ -17,11 +17,8 @@ data class MyRegistrationItem( val registrationStartsAt: Instant?, val registrationEndsAt: Instant?, val capacity: Int?, - @Schema(description = "확정 참여자 수") val confirmedCount: Int, - @Schema(description = "대기자 수") val waitlistCount: Int, - @Schema(description = "신청 상태") val status: MyRegistrationStatus, val waitlistedNum: Int?, ) { From 5e1d0d9824a8c46db03a85be8160f8d0178d496f Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Mon, 4 May 2026 17:57:28 +0900 Subject: [PATCH 144/157] =?UTF-8?q?:bug:=20api=20spec=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20registration=20delete=20reques?= =?UTF-8?q?t=20dto=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RegistrationController.kt | 4 ---- .../dto/request/DeleteRegistrationRequest.kt | 11 ---------- .../service/RegistrationService.kt | 22 ------------------- .../spring2025/RegistrationIntegrationTest.kt | 6 ----- 4 files changed, 43 deletions(-) delete mode 100644 src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt index 2ffccbe6..b3a09f28 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt @@ -1,7 +1,6 @@ package com.wafflestudio.spring2025.domain.registration.controller import com.wafflestudio.spring2025.domain.auth.LoggedInUser -import com.wafflestudio.spring2025.domain.registration.dto.request.DeleteRegistrationRequest import com.wafflestudio.spring2025.domain.registration.dto.request.UpdateRegistrationStatusRequest import com.wafflestudio.spring2025.domain.registration.dto.response.DeleteRegistrationResponse import com.wafflestudio.spring2025.domain.registration.dto.response.GetRegistrationResponse @@ -62,15 +61,12 @@ class RegistrationController( @DeleteMapping fun delete( @PathVariable registrationId: String, - @RequestBody request: DeleteRegistrationRequest, @LoggedInUser user: User?, ): ResponseEntity { val response = registrationService.delete( registrationPublicId = registrationId, userId = user?.id, - guestName = request.guestName, - guestEmail = request.guestEmail, ) return ResponseEntity.ok(response) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt deleted file mode 100644 index 3f34c5f8..00000000 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.wafflestudio.spring2025.domain.registration.dto.request - -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벤트 취소 요청") -data class DeleteRegistrationRequest( - @Schema(description = "비회원 이름") - val guestName: String? = null, - @Schema(description = "비회원 이메일") - val guestEmail: String? = null, -) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index a87b211a..47660431 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -205,22 +205,9 @@ RegistrationService( fun delete( registrationPublicId: String, userId: Long?, - guestName: String?, - guestEmail: String?, ): DeleteRegistrationResponse { - if (userId == null) { - if (guestName.isNullOrBlank()) { - throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_NAME) - } - if (guestEmail.isNullOrBlank()) { - throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_EMAIL) - } - } - deleteInternal( userId = userId, - guestName = guestName, - guestEmail = guestEmail, registrationPublicId = registrationPublicId, ) return DeleteRegistrationResponse() @@ -228,8 +215,6 @@ RegistrationService( private fun deleteInternal( userId: Long?, - guestName: String?, - guestEmail: String?, registrationPublicId: String, ) { // eventId를 얻기 위한 미리보기 조회 (락 없음) @@ -251,13 +236,6 @@ RegistrationService( if (registration.userId != userId) { throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_DELETE_UNAUTHORIZED) } - } else { - if (registration.userId != null || - registration.guestName != guestName || - registration.guestEmail != guestEmail - ) { - throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_DELETE_UNAUTHORIZED) - } } eventLockRepository.lockById(registration.eventId) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index 48b9cd3d..f2b1564a 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -6,7 +6,6 @@ import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.event.repository.EventRepository import com.wafflestudio.spring2025.domain.registration.dto.request.CreateRegistrationRequest -import com.wafflestudio.spring2025.domain.registration.dto.request.DeleteRegistrationRequest import com.wafflestudio.spring2025.domain.registration.dto.request.UpdateRegistrationStatusRequest import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus @@ -243,7 +242,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/$participantRegistrationId") .header("Authorization", "Bearer $hostToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isForbidden) .andExpect(jsonPath("$.code").value("REGISTRATION_DELETE_UNAUTHORIZED")) @@ -252,7 +250,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/$participantRegistrationId") .header("Authorization", "Bearer $participantToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) @@ -276,7 +273,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/$confirmedRegistrationId") .header("Authorization", "Bearer $confirmedUserToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) @@ -347,7 +343,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/${registration.registrationPublicId}") .header("Authorization", "Bearer $participantToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) @@ -378,7 +373,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/${registration.registrationPublicId}") .header("Authorization", "Bearer $participantToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) From 7cf4198147f90b61476f4391e791bc5f314cffb2 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Mon, 4 May 2026 20:29:59 +0900 Subject: [PATCH 145/157] =?UTF-8?q?:bug:=20=EC=B0=B8=EC=97=AC=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=8B=9C=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/service/RegistrationService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 47660431..1eb74ea2 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -232,7 +232,7 @@ RegistrationService( registrationRepository.lockByRegistrationPublicId(registrationPublicId) ?: throw RegistrationNotFoundException() - if (userId != null) { + if (registration.userId != null) { if (registration.userId != userId) { throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_DELETE_UNAUTHORIZED) } From 04ca11974c40e52205a6a57f1a2b0d144f158b6e Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Mon, 4 May 2026 21:20:29 +0900 Subject: [PATCH 146/157] =?UTF-8?q?:memo:=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/controller/SocialAuthController.kt | 2 +- src/main/resources/static/docs/openapi.yaml | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt index dbd09f73..14da8c08 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("api/auth/social") +@RequestMapping("/api/auth/social") class SocialAuthController( private val socialAuthService: SocialAuthService, ) { diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml index 6fa1daac..bb9bacb4 100644 --- a/src/main/resources/static/docs/openapi.yaml +++ b/src/main/resources/static/docs/openapi.yaml @@ -572,6 +572,7 @@ components: required: - registrationEndsAt - title + - capacity - waitlistEnabled type: object properties: @@ -849,7 +850,8 @@ components: MyRegistrationItem: required: - publicId - - registrationCnt + - confirmedCount + - waitlistCount - status - title type: object @@ -885,9 +887,13 @@ components: description: 정원 format: int32 nullable: true - registrationCnt: + confirmedCount: + type: integer + description: 참여 확정자 수 + format: int32 + waitlistCount: type: integer - description: 현재 신청 수 + 대기 수 + description: 참여 확정자 수 format: int32 status: type: string From e4c5c724bc02fc260c2d87a30de675bb883d3a9c Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Mon, 4 May 2026 21:24:27 +0900 Subject: [PATCH 147/157] =?UTF-8?q?:memo:=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/docs/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml index 7fcbefff..e83c7598 100644 --- a/src/main/resources/static/docs/openapi.yaml +++ b/src/main/resources/static/docs/openapi.yaml @@ -909,7 +909,7 @@ components: format: int32 waitlistCount: type: integer - description: 참여 확정자 수 + description: 대기자 수 format: int32 status: type: string From 8a06dfba03bc11a6aaaf1a6d14cc2b2fa52bedec Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Mon, 4 May 2026 21:25:14 +0900 Subject: [PATCH 148/157] =?UTF-8?q?:fire:=20=EC=95=88=20=EC=93=B0=EC=9D=B4?= =?UTF-8?q?=EB=8A=94=20RegisterRequest=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/auth/dto/RegisterRequest.kt | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt deleted file mode 100644 index 32ee7ed3..00000000 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.wafflestudio.spring2025.domain.auth.dto - -data class RegisterRequest( - val email: String, - val name: String, - val password: String, - val profileImage: String? = null, -) From e45ead73669fb081994e7597c416d582741def07 Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 5 May 2026 20:34:50 +0900 Subject: [PATCH 149/157] =?UTF-8?q?feat:=20prometheus=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spring Boot Actuator 및 Micrometer Prometheus registry 의존성 추가 - docker-compose.dev에 prometheus 서비스/볼륨 구성 추가 - management endpoint 노출 범위에 info, metrics, prometheus 추가 --- build.gradle.kts | 5 ++++- docker-compose.dev.yml | 17 +++++++++++++++++ src/main/resources/application.yaml | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index cfebb3cb..5b8d2200 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,7 +44,6 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework.boot:spring-boot-starter-data-redis") - implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.mindrot:jbcrypt:0.4") implementation("io.jsonwebtoken:jjwt-api:0.11.5") @@ -73,6 +72,10 @@ dependencies { // OCI Vault - SDK 직접 사용 (waffle-oci-vault 2.1.0은 Spring Boot 3.5와 호환 안됨) implementation("com.oracle.oci.sdk:oci-java-sdk-secrets:3.80.1") implementation("com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.80.1") + + // prometheus 의존성 추가 + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") } kotlin { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 99b4dc2e..b723e04d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -76,6 +76,23 @@ services: limits: memory: 64M + prometheus: + image: prom/prometheus:latest + container_name: prometheus-dev + restart: unless-stopped + ports: + - "127.0.0.1:9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data_dev:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + depends_on: + app: + condition: service_healthy + volumes: mysql_data_dev: redis_data_dev: + prometheus_data_dev: diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 473a1ff9..ee7ef521 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -68,7 +68,7 @@ management: endpoints: web: exposure: - include: health + include: health,info,prometheus,metrics endpoint: health: show-details: always From 810e3976fe42b36d8fd5d058caec1c0465b47f33 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 5 May 2026 20:56:45 +0900 Subject: [PATCH 150/157] =?UTF-8?q?:bug:=20=EB=8C=80=EA=B8=B0=20=EC=88=9C?= =?UTF-8?q?=EB=B2=88=20=EA=B3=84=EC=82=B0=20=EB=B0=A9=EC=8B=9D=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/repository/RegistrationRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 3c5afa24..1c91aa06 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -186,7 +186,7 @@ interface RegistrationRepository : ranked.waitlist_number AS waitlist_number FROM ( SELECT r.registration_public_id, - ROW_NUMBER() OVER (ORDER BY r.created_at ASC, r.registration_public_id ASC) AS waitlist_number + ROW_NUMBER() OVER (ORDER BY r.created_at ASC, r.id ASC) AS waitlist_number FROM registrations r WHERE r.event_id = :eventId AND r.status = :status ) ranked From 22762d1ca9dcd3d4c855254e70b3f5dc976b8003 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Mon, 4 May 2026 17:57:28 +0900 Subject: [PATCH 151/157] =?UTF-8?q?:bug:=20api=20spec=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20registration=20delete=20reques?= =?UTF-8?q?t=20dto=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RegistrationController.kt | 4 ---- .../dto/request/DeleteRegistrationRequest.kt | 6 ----- .../service/RegistrationService.kt | 22 ------------------- .../spring2025/RegistrationIntegrationTest.kt | 6 ----- 4 files changed, 38 deletions(-) delete mode 100644 src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt index f2568d12..28381a59 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt @@ -1,7 +1,6 @@ package com.wafflestudio.spring2025.domain.registration.controller import com.wafflestudio.spring2025.domain.auth.LoggedInUser -import com.wafflestudio.spring2025.domain.registration.dto.request.DeleteRegistrationRequest import com.wafflestudio.spring2025.domain.registration.dto.request.UpdateRegistrationStatusRequest import com.wafflestudio.spring2025.domain.registration.dto.response.DeleteRegistrationResponse import com.wafflestudio.spring2025.domain.registration.dto.response.GetRegistrationResponse @@ -48,15 +47,12 @@ class RegistrationController( @DeleteMapping fun delete( @PathVariable registrationId: String, - @RequestBody request: DeleteRegistrationRequest, @LoggedInUser user: User?, ): ResponseEntity { val response = registrationService.delete( registrationPublicId = registrationId, userId = user?.id, - guestName = request.guestName, - guestEmail = request.guestEmail, ) return ResponseEntity.ok(response) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt deleted file mode 100644 index 0df46b98..00000000 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.wafflestudio.spring2025.domain.registration.dto.request - -data class DeleteRegistrationRequest( - val guestName: String? = null, - val guestEmail: String? = null, -) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 00df08fc..89d2f788 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -198,22 +198,9 @@ RegistrationService( fun delete( registrationPublicId: String, userId: Long?, - guestName: String?, - guestEmail: String?, ): DeleteRegistrationResponse { - if (userId == null) { - if (guestName.isNullOrBlank()) { - throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_NAME) - } - if (guestEmail.isNullOrBlank()) { - throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_EMAIL) - } - } - deleteInternal( userId = userId, - guestName = guestName, - guestEmail = guestEmail, registrationPublicId = registrationPublicId, ) return DeleteRegistrationResponse() @@ -221,8 +208,6 @@ RegistrationService( private fun deleteInternal( userId: Long?, - guestName: String?, - guestEmail: String?, registrationPublicId: String, ) { // eventId를 얻기 위한 미리보기 조회 (락 없음) @@ -244,13 +229,6 @@ RegistrationService( if (registration.userId != userId) { throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_DELETE_UNAUTHORIZED) } - } else { - if (registration.userId != null || - registration.guestName != guestName || - registration.guestEmail != guestEmail - ) { - throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_DELETE_UNAUTHORIZED) - } } if (!isRegistrationEnabled(event)) { diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index 48b9cd3d..f2b1564a 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -6,7 +6,6 @@ import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.event.repository.EventRepository import com.wafflestudio.spring2025.domain.registration.dto.request.CreateRegistrationRequest -import com.wafflestudio.spring2025.domain.registration.dto.request.DeleteRegistrationRequest import com.wafflestudio.spring2025.domain.registration.dto.request.UpdateRegistrationStatusRequest import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus @@ -243,7 +242,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/$participantRegistrationId") .header("Authorization", "Bearer $hostToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isForbidden) .andExpect(jsonPath("$.code").value("REGISTRATION_DELETE_UNAUTHORIZED")) @@ -252,7 +250,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/$participantRegistrationId") .header("Authorization", "Bearer $participantToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) @@ -276,7 +273,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/$confirmedRegistrationId") .header("Authorization", "Bearer $confirmedUserToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) @@ -347,7 +343,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/${registration.registrationPublicId}") .header("Authorization", "Bearer $participantToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) @@ -378,7 +373,6 @@ class RegistrationIntegrationTest .perform( delete("/api/registrations/${registration.registrationPublicId}") .header("Authorization", "Bearer $participantToken") - .content(mapper.writeValueAsString(DeleteRegistrationRequest())) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) From 942b79e64e4dd0e76657c3f1911732672f7c6395 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Mon, 4 May 2026 20:29:59 +0900 Subject: [PATCH 152/157] =?UTF-8?q?:bug:=20=EC=B0=B8=EC=97=AC=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=8B=9C=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/service/RegistrationService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 89d2f788..46ea8c70 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -225,7 +225,7 @@ RegistrationService( registrationRepository.lockByRegistrationPublicId(registrationPublicId) ?: throw RegistrationNotFoundException() - if (userId != null) { + if (registration.userId != null) { if (registration.userId != userId) { throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_DELETE_UNAUTHORIZED) } From 458048cc31eb0c670d68226a35de22c135addcc1 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 5 May 2026 22:23:34 +0900 Subject: [PATCH 153/157] =?UTF-8?q?:memo:=20swagger=EC=97=90=EC=84=9C=20De?= =?UTF-8?q?leteRegistrationRequest=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/docs/openapi.yaml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml index e83c7598..9f62c069 100644 --- a/src/main/resources/static/docs/openapi.yaml +++ b/src/main/resources/static/docs/openapi.yaml @@ -425,12 +425,6 @@ paths: required: true schema: type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/DeleteRegistrationRequest" - required: true responses: "200": description: OK @@ -1236,18 +1230,6 @@ components: nullable: true hasNext: type: boolean - DeleteRegistrationRequest: - type: object - properties: - guestName: - type: string - description: 비회원 이름 - nullable: true - guestEmail: - type: string - description: 비회원 이메일 - nullable: true - description: 이벤트 취소 요청 DeleteRegistrationResponse: type: object ErrorResponse: From ed471ddb3f7f4e302031ef5695513f0cdf5f174f Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Thu, 4 Jun 2026 09:41:48 +0900 Subject: [PATCH 154/157] =?UTF-8?q?:bug:=20=EB=8C=80=EA=B8=B0=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=98=A4=EB=A6=84=EC=B0=A8=EC=88=9C?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/registration/repository/RegistrationRepository.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 1c91aa06..5d6ddac1 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -121,7 +121,7 @@ interface RegistrationRepository : SELECT r.* FROM registrations r WHERE r.event_id = :eventId - ORDER BY r.created_at DESC, r.registration_public_id DESC + ORDER BY r.created_at ASC, r.registration_public_id ASC LIMIT :limit OFFSET :offset """, ) @@ -136,7 +136,7 @@ interface RegistrationRepository : SELECT r.* FROM registrations r WHERE r.event_id = :eventId AND r.status = :status - ORDER BY r.created_at DESC, r.registration_public_id DESC + ORDER BY r.created_at ASC, r.registration_public_id ASC LIMIT :limit OFFSET :offset """, ) From 3f9ea7ddb720b46dc053d8659baaf108bb6ae8e3 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 7 Jun 2026 03:05:04 +0900 Subject: [PATCH 155/157] =?UTF-8?q?:lipstick:=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=A0=95=EC=9B=90=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=20=ED=98=95=EC=8B=9D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/email/service/EmailService.kt | 32 ++++++++++++------- .../service/RegistrationService.kt | 4 +++ .../email/template/registration-banned.html | 2 +- .../template/registration-confirmed.html | 2 +- .../email/template/registration-delete.html | 2 +- .../email/template/registration-demoted.html | 2 +- .../registration-waitlist-promoted.html | 2 +- .../template/registration-waitlisted.html | 2 +- 8 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index e6ce6809..dd663a46 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -120,6 +120,7 @@ class EmailService( val endsAt: Instant?, val location: String?, val newCapacity: Int, + val totalCount: Int, val registrationStartsAt: Instant?, val registrationEndsAt: Instant, val description: String?, @@ -138,8 +139,7 @@ class EmailService( .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{totalCount}", data.totalCount?.toString() ?: "null") - .replace("{capacity}", data.capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), @@ -165,8 +165,7 @@ class EmailService( .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{totalCount}", data.totalCount?.toString() ?: "null") - .replace("{capacity}", data.capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), @@ -189,8 +188,7 @@ class EmailService( .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{totalCount}", data.totalCount?.toString() ?: "null") - .replace("{capacity}", data.capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), @@ -217,8 +215,7 @@ class EmailService( .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{totalCount}", data.totalCount?.toString() ?: "null") - .replace("{capacity}", data.capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), @@ -258,7 +255,7 @@ class EmailService( .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", data.name) .replace("{waitingNum}", data.waitingNum?.toString() ?: "-") - .replace("{newCapacity}", data.newCapacity.toString()) + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.newCapacity)) .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) @@ -302,8 +299,7 @@ class EmailService( .replace("{eventTitle}", formatEventTitle(eventTitle)) .replace("{eventDateRange}", formatEventDateRange(startsAt, endsAt, "~")) .replace("{location}", formatLocation(location)) - .replace("{totalCount}", totalCount?.toString() ?: "null") - .replace("{capacity}", capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(totalCount, capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(registrationStartsAt, registrationEndsAt), @@ -368,6 +364,20 @@ class EmailService( } } + private fun formatCapacity( + totalCount: Int?, + capacity: Int?, + ): String { + if (capacity == null) return "${totalCount ?: "-"}명" + val confirmed = if (totalCount != null) minOf(totalCount, capacity) else capacity + val waitlisted = if (totalCount != null) maxOf(0, totalCount - capacity) else 0 + return if (waitlisted > 0) { + "$confirmed/${capacity}명 (대기자 ${waitlisted}명)" + } else { + "$confirmed/${capacity}명" + } + } + private fun formatDescription(description: String?): String { if (description == null) return "-" return description.replace("\r\n", "\n").replace("\n", "
") diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 46ea8c70..286a85a9 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -673,6 +673,9 @@ RegistrationService( toDemote.forEach { it.status = RegistrationStatus.WAITLISTED } registrationRepository.saveAll(toDemote) + val waitlistedCount = registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.WAITLISTED).toInt() + val totalCount = newCapacity + waitlistedCount + val demotedPublicIds = toDemote.map { it.registrationPublicId } val waitlistPositions = registrationRepository @@ -698,6 +701,7 @@ RegistrationService( endsAt = event.endsAt, location = event.location, newCapacity = newCapacity, + totalCount = totalCount, registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, description = event.description, diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-banned.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-banned.html index 7bd8a682..573ae045 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-banned.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-banned.html @@ -73,7 +73,7 @@ alt="user" width="20" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}명 + 정원 {capacityDisplay} diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html index df7afd8c..2e5e62d9 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html @@ -66,7 +66,7 @@ alt="user" width="18" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}명 + 정원 {capacityDisplay} diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html index 6c216b92..af2ac0b0 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html @@ -74,7 +74,7 @@ alt="user" width="20" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}명 + 정원 {capacityDisplay} diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html index 0b40c68c..4f790f11 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html @@ -74,7 +74,7 @@ alt="user" width="18" style="display: block; border: 0;"> - 정원 {newCapacity}명 + 정원 {capacityDisplay} diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html index 3b181918..08524c5b 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html @@ -66,7 +66,7 @@ alt="user" width="18" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}명 + 정원 {capacityDisplay} diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html index 6ba1ae53..ebdfbdd2 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html @@ -74,7 +74,7 @@ alt="user" width="18" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}명 + 정원 {capacityDisplay} From 322d5a3786ab8bdc655edac8561c69b481450d42 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sun, 7 Jun 2026 03:31:47 +0900 Subject: [PATCH 156/157] =?UTF-8?q?:children=5Fcrossing:=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20div?= =?UTF-8?q?=EC=97=90=20=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/common/email/service/EmailService.kt | 3 +++ .../domain/registration/service/RegistrationService.kt | 1 + .../common/email/template/registration-confirmed.html | 2 ++ .../spring2025/common/email/template/registration-delete.html | 2 ++ .../spring2025/common/email/template/registration-demoted.html | 2 ++ .../common/email/template/registration-waitlist-promoted.html | 2 ++ .../common/email/template/registration-waitlisted.html | 2 ++ 7 files changed, 14 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index dd663a46..f05cf475 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -99,6 +99,7 @@ class EmailService( val registrationStartsAt: Instant?, val registrationEndsAt: Instant?, val description: String?, + val publicId: String?, ) data class EventCancellationEmailData( @@ -211,6 +212,7 @@ class EmailService( fun sendRegistrationDeleteEmail(data: RegistrationDeleteEmailData) { val htmlContent = loadTemplate("registration-delete.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", data.name) .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) @@ -220,6 +222,7 @@ class EmailService( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), ).replace("{description}", formatDescription(data.description)) + .replace("{publicId}", data.publicId ?: "-") sendHtmlEmail( to = data.toEmail, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 286a85a9..6aff4dd7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -273,6 +273,7 @@ RegistrationService( registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, description = event.description, + publicId = event.publicId, ) afterCommit { diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html index 2e5e62d9..4da10477 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html @@ -37,6 +37,7 @@ +
{eventTitle}
@@ -74,6 +75,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html index af2ac0b0..0b02d08f 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html @@ -44,6 +44,7 @@
+
{eventTitle}
@@ -82,6 +83,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html index 4f790f11..541ef4cd 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html @@ -44,6 +44,7 @@
+
{eventTitle}
@@ -82,6 +83,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html index 08524c5b..8cdc2f3f 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html @@ -37,6 +37,7 @@
+
{eventTitle}
@@ -74,6 +75,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html index ebdfbdd2..491e2959 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html @@ -44,6 +44,7 @@
+
{eventTitle}
@@ -82,6 +83,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
From c4049cd0c9b6d8ea1a04a8680e4ffdf3c2e7c773 Mon Sep 17 00:00:00 2001 From: uykhc Date: Mon, 8 Jun 2026 18:20:48 +0900 Subject: [PATCH 157/157] =?UTF-8?q?refactor:=20=EC=B0=A8=EB=8B=A8=EB=90=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=AC=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=97=90=EB=9F=AC=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 "차단된 신청입니다. 차단된 신청은 다시 신청할 수 없습니다." 메시지를 "차단된 사용자입니다. 해당 모임의 주최자에 의해 차단되어 신청할 수 없습니다."로 변경하여 사용자에게 더 명확하고 자연스러운 안내 제공 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../domain/registration/exception/RegistrationErrorCode.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/exception/RegistrationErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/exception/RegistrationErrorCode.kt index cd978b1e..7f7d9d7a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/exception/RegistrationErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/exception/RegistrationErrorCode.kt @@ -74,8 +74,8 @@ enum class RegistrationErrorCode( REGISTRATION_BLOCKED_BANNED( httpStatusCode = HttpStatus.FORBIDDEN, - title = "차단된 신청입니다.", - message = "차단된 신청은\n다시 신청할 수 없습니다.", + title = "차단된 사용자입니다.", + message = "해당 모임의 주최자에 의해\n차단되어 신청할 수 없습니다.", ), NOT_WITHIN_REGISTRATION_WINDOW(