diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..2590a64 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,14 @@ +language: "ko-KR" +early_access: false +reviews: + profile: "chill" + request_changes_workflow: false + high_level_summary: true + poem: true + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false +chat: + auto_reply: true \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..290ada1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,41 @@ +name: ๐Ÿ› ๋ฒ„๊ทธ ๋ฆฌํฌํŠธ +description: ๋ฒ„๊ทธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ ์‚ฌ์šฉํ•˜๋Š” ํ…œํ”Œ๋ฆฟ์ž…๋‹ˆ๋‹ค. +title: "fix: " +labels: ["๐Ÿ›bug"] +assignees: [] + +body: + - type: textarea + id: bug_summary + attributes: + label: ๐Ÿ› ์–ด๋–ค ๋ฒ„๊ทธ์ธ๊ฐ€์š”? + description: ๋ฐœ๊ฒฌ๋œ ๋ฒ„๊ทธ๋ฅผ ๊ฐ„๋‹จํ•˜๊ฒŒ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. + placeholder: | + ์˜ˆ) ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ 500 ์—๋Ÿฌ ๋ฐœ์ƒ + value: "- " + validations: + required: true + + - type: textarea + id: bug_scenario + attributes: + label: ๐Ÿคทโ€โ™‚๏ธ ์–ด๋–ค ์ƒํ™ฉ์—์„œ ๋ฐœ์ƒํ•œ ๋ฒ„๊ทธ์ธ๊ฐ€์š”? + description: ๊ฐ€๋Šฅํ•˜๋‹ค๋ฉด Given-When-Then ํ˜•์‹์œผ๋กœ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + placeholder: | + **Given:** ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€์— ์ ‘์†ํ•œ ์ƒํƒœ์—์„œ + **When:** ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ์„ ํด๋ฆญํ–ˆ์„ ๋•Œ + **Then:** ๋ฉ”์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ด์•ผ ํ•˜์ง€๋งŒ 500 ์—๋Ÿฌ ๋ฐœ์ƒ + value: "> " + validations: + required: true + + - type: textarea + id: expected_result + attributes: + label: ๐Ÿค” ์˜ˆ์ƒ ๊ฒฐ๊ณผ + description: ๊ธฐ๋Œ€ํ–ˆ๋˜ ์ •์ƒ ๋™์ž‘์„ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. + placeholder: | + ์˜ˆ) ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์ •์ƒ์ ์œผ๋กœ ๋ฉ”์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + value: "- " + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/chore.yml b/.github/ISSUE_TEMPLATE/chore.yml new file mode 100644 index 0000000..223abc3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore.yml @@ -0,0 +1,30 @@ +name: โš™๏ธ ์ž‘์—… +description: ์„ธํŒ…, ๋ฐฐํฌ, ํ™˜๊ฒฝ์„ค์ • ๋“ฑ ๊ธฐ๋Šฅ ์™ธ ์ž‘์—…์„ ๊ธฐ๋กํ•˜๋Š” ํ…œํ”Œ๋ฆฟ์ž…๋‹ˆ๋‹ค. +title: "chore: " +labels: ["โš™๏ธsetting"] +assignees: [] + +body: + - type: textarea + id: chore_type + attributes: + label: โš™๏ธ ์–ด๋–ค ์ž‘์—…์ธ๊ฐ€์š”? + description: ์ž‘์—…์˜ ๋ฒ”์œ„์™€ ๋ชฉ์ ์„ ๊ฐ„๋‹จํžˆ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. (CI/CD ํŒŒ์ดํ”„๋ผ์ธ ์„ธํŒ…, Dockerfile ์ˆ˜์ • ๋“ฑ) + placeholder: | + ์˜ˆ) GitHub Actions ์›Œํฌํ”Œ๋กœ์šฐ์—์„œ ํ…Œ์ŠคํŠธ ๋‹จ๊ณ„ ์ถ”๊ฐ€ + value: "- " + validations: + required: true + + - type: textarea + id: tasks + attributes: + label: ๐Ÿ“ ์ž‘์—… ์ƒ์„ธ ๋‚ด์šฉ + description: ์ž‘์—…ํ•ด์•ผ ํ•  ํ•ญ๋ชฉ๋“ค์„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ ํ˜•์‹์œผ๋กœ ์ •๋ฆฌํ•ด์ฃผ์„ธ์š”. + placeholder: | + - [ ] Dockerfile ์ˆ˜์ • + - [ ] Nginx ์„ค์ • ์—…๋ฐ์ดํŠธ + - [ ] ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ ๊ฐœ์„  + value: "- [ ] " + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..d26f14f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,30 @@ +name: ๐Ÿ“ ๋ฌธ์„œ ์ž‘์—… +description: README, ์ด์Šˆ/PR ํ…œํ”Œ๋ฆฟ, ๊ฐ€์ด๋“œ ๋“ฑ ๋ฌธ์„œ ๊ด€๋ จ ์ž‘์—…์„ ๊ธฐ๋กํ•˜๋Š” ํ…œํ”Œ๋ฆฟ์ž…๋‹ˆ๋‹ค. +title: "docs: " +labels: ["๐Ÿ“docs"] +assignees: [] + +body: + - type: textarea + id: doc_type + attributes: + label: ๐Ÿ“„ ์–ด๋–ค ๋ฌธ์„œ์ธ๊ฐ€์š”? + description: ์ž‘์„ฑํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•˜๋ ค๋Š” ๋ฌธ์„œ์˜ ์ข…๋ฅ˜์™€ ๋ชฉ์ ์„ ๊ฐ„๋‹จํžˆ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. + placeholder: | + ์˜ˆ) README์— ํ”„๋กœ์ ํŠธ ๋นŒ๋“œ ๋ฐฉ๋ฒ• ์ถ”๊ฐ€ + value: "- " + validations: + required: true + + - type: textarea + id: tasks + attributes: + label: ๐Ÿ“ ์ž‘์—… ์ƒ์„ธ ๋‚ด์šฉ + description: ๋ฌธ์„œ ์ž‘์—…์˜ ์ฃผ์š” ๋‚ด์šฉ์„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋กœ ์ •๋ฆฌํ•ด์ฃผ์„ธ์š”. + placeholder: | + - [ ] README์— ๋นŒ๋“œ ๋ฐ ์‹คํ–‰ ๋ฐฉ๋ฒ• ์ถ”๊ฐ€ + - [ ] CONTRIBUTING.md ์ž‘์„ฑ + - [ ] API ์‚ฌ์šฉ ๊ฐ€์ด๋“œ ์—…๋ฐ์ดํŠธ + value: "- [ ] " + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..53b467d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,29 @@ +name: โœจ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ +description: ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•  ๋•Œ ์‚ฌ์šฉํ•˜๋Š” ํ…œํ”Œ๋ฆฟ์ž…๋‹ˆ๋‹ค. +title: "feat: " +labels: ["โœจfeature"] +assignees: [] + +body: + - type: textarea + id: summary + attributes: + label: ๐Ÿ“Œ ์–ด๋–ค ๊ธฐ๋Šฅ์ธ๊ฐ€์š”? + description: ์ถ”๊ฐ€ํ•˜๋ ค๋Š” ๊ธฐ๋Šฅ์— ๋Œ€ํ•ด ๊ฐ„๊ฒฐํ•˜๊ฒŒ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. + placeholder: | + ์˜ˆ) ์‚ฌ์šฉ์ž ํšŒ์›๊ฐ€์ž…, ๋กœ๊ทธ์ธ ๊ธฐ๋Šฅ ๊ตฌํ˜„ + value: "- " + validations: + required: true + + - type: textarea + id: tasks + attributes: + label: ๐Ÿ“ ์ž‘์—… ์ƒ์„ธ ๋‚ด์šฉ + description: ๊ตฌํ˜„ํ•ด์•ผ ํ•  ์ž‘์—… ํ•ญ๋ชฉ์„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋กœ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + placeholder: | + - [ ] ์‚ฌ์šฉ์ž ๋กœ๊ทธ์ธ API + - [ ] ์‚ฌ์šฉ์ž ์˜จ๋ณด๋”ฉ API + value: "- [ ] " + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/refactor.yml b/.github/ISSUE_TEMPLATE/refactor.yml new file mode 100644 index 0000000..6823224 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.yml @@ -0,0 +1,41 @@ +name: โ™ป๏ธ ๋ฆฌํŒฉํ† ๋ง +description: ๋ฆฌํŒฉํ† ๋ง ์ž‘์—…์„ ๊ธฐ๋กํ•˜๋Š” ํ…œํ”Œ๋ฆฟ์ž…๋‹ˆ๋‹ค. +title: "refactor: " +labels: ["โ™ป๏ธrefactor"] +assignees: [] + +body: + - type: textarea + id: target + attributes: + label: ๐Ÿ“Œ ๋ฆฌํŒฉํ† ๋ง ๋Œ€์ƒ + description: ์–ด๋–ค ๊ธฐ๋Šฅ/๋ชจ๋“ˆ์„ ๋ฆฌํŒฉํ† ๋งํ–ˆ๋Š”์ง€ ๋ช…ํ™•ํžˆ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + placeholder: | + ์˜ˆ) UserService ํšŒ์›๊ฐ€์ž… ๋กœ์ง + value: "- " + validations: + required: true + + - type: textarea + id: reason + attributes: + label: ๐Ÿ› ๏ธ ๋ฆฌํŒฉํ† ๋ง ์‚ฌ์œ  + description: ๋ฆฌํŒฉํ† ๋ง์„ ํ•˜๊ฒŒ ๋œ ๋ฐฐ๊ฒฝ์ด๋‚˜ ํ•„์š”์„ฑ์„ ๊ฐ„๋‹จํžˆ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. + placeholder: | + ์˜ˆ) ์ฝ”๋“œ ์ค‘๋ณต ์ œ๊ฑฐ ๋ฐ ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ ํ–ฅ์ƒ + value: "- " + validations: + required: true + + - type: textarea + id: tasks + attributes: + label: ๐Ÿ“ ์ž‘์—… ์ƒ์„ธ ๋‚ด์šฉ + description: ์ฃผ์š” ๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ ํ˜•์‹์œผ๋กœ ์ •๋ฆฌํ•ด์ฃผ์„ธ์š”. + placeholder: | + - [ ] ์ค‘๋ณต๋œ ๋กœ์ง ๋ถ„๋ฆฌ + - [ ] ๋ฉ”์„œ๋“œ ๋„ค์ด๋ฐ ๊ฐœ์„  + - [ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๋ณด๊ฐ• + value: "- [ ] " + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/test.yml b/.github/ISSUE_TEMPLATE/test.yml new file mode 100644 index 0000000..05cf479 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test.yml @@ -0,0 +1,40 @@ +name: ๐Ÿงช ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ +description: ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ ์ด์Šˆ๋ฅผ ๊ธฐ๋กํ•˜๋Š” ํ…œํ”Œ๋ฆฟ์ž…๋‹ˆ๋‹ค. +title: "test: " +labels: ["๐Ÿงชtest"] +assignees: [] + +body: + - type: textarea + id: test_scope + attributes: + label: ๐Ÿงช ์–ด๋–ค ํ…Œ์ŠคํŠธ์ธ๊ฐ€์š”? + description: ์ž‘์„ฑํ•  ํ…Œ์ŠคํŠธ์˜ ๋ฒ”์œ„์™€ ๋ชฉ์ ์„ ๊ฐ„๋‹จํžˆ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. + placeholder: | + ์˜ˆ) UserService ํšŒ์›๊ฐ€์ž… ๋กœ์ง ๋‹จ์œ„ ํ…Œ์ŠคํŠธ + value: "- " + validations: + required: true + + - type: textarea + id: test_items + attributes: + label: ๐Ÿ“ ํ…Œ์ŠคํŠธ ํ•ญ๋ชฉ + description: ์ž‘์„ฑํ•  ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋“ค์„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋กœ ์ •๋ฆฌํ•ด์ฃผ์„ธ์š”. + placeholder: | + - [ ] ์ž˜๋ชป๋œ ID๋กœ ์กฐํšŒ ์‹œ 404 ๋ฐ˜ํ™˜ + - [ ] ์œ ํšจํ•œ ์ž…๋ ฅ๊ฐ’์œผ๋กœ ์ €์žฅ ์‹œ ์ •์ƒ์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฐ˜์˜๋จ + value: "- [ ] " + validations: + required: false + + - type: textarea + id: expected_result + attributes: + label: ๐ŸŽฏ ๊ธฐ๋Œ€ ๊ฒฐ๊ณผ + description: ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋Š” ๊ธฐ์ค€์ด๋‚˜ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + placeholder: | + ์˜ˆ) ์ž˜๋ชป๋œ ID๋กœ ์กฐํšŒ ์‹œ 404 ๋ฐ˜ํ™˜ + value: "- " + validations: + required: false \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a63e0a2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ +## ๐Ÿ”— ์—ฐ๊ด€๋œ ์ด์Šˆ +- # + +## ๐Ÿš€ ๋ณ€๊ฒฝ ์œ ํ˜• +- [ ] โœจ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ (feature) +- [ ] ๐Ÿ› ๋ฒ„๊ทธ ์ˆ˜์ • (fix) +- [ ] ๐Ÿ“ ๋ฌธ์„œ ๋ณ€๊ฒฝ (docs) +- [ ] โ™ป๏ธ ๋ฆฌํŒฉํ† ๋ง (refactor) +- [ ] ๐Ÿงช ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ / ์ˆ˜์ • (test) +- [ ] โš™๏ธ ์„ค์ • ๋ณ€๊ฒฝ (chore) + +## ๐Ÿ“ ์ž‘์—… ๋‚ด์šฉ + +- + +## ๐Ÿ“ธ ์Šคํฌ๋ฆฐ์ƒท +> + +## ๐Ÿ’ฌ ๋ฆฌ๋ทฐ ์š”๊ตฌ์‚ฌํ•ญ + + +### ๐Ÿ“œ ๋ฆฌ๋ทฐ ๊ทœ์น™ + +Reviewer๋Š” ์•„๋ž˜ **P5 Rule**์„ ์ฐธ๊ณ ํ•˜์—ฌ ๋ฆฌ๋ทฐ๋ฅผ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. +P5 Rule์„ ํ†ตํ•ด Reviewer๋Š” Reviewee์—๊ฒŒ ๋ฆฌ๋ทฐ์˜ ์˜๋„๋ฅผ ๋ณด๋‹ค ์ •ํ™•ํžˆ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +> - **P1:** ๊ผญ ๋ฐ˜์˜ํ•ด์ฃผ์„ธ์š” (Comment) +> - **P2:** ์ ๊ทน์ ์œผ๋กœ ๊ณ ๋ คํ•ด์ฃผ์„ธ์š” (Comment) +> - **P3:** ์›ฌ๋งŒํ•˜๋ฉด ๋ฐ˜์˜ํ•ด ์ฃผ์„ธ์š” (Comment) +> - **P4:** ๋ฐ˜์˜ํ•ด๋„ ์ข‹๊ณ  ๋„˜์–ด๊ฐ€๋„ ์ข‹์Šต๋‹ˆ๋‹ค (Approve) +> - **P5:** ๊ทธ๋ƒฅ ์‚ฌ์†Œํ•œ ์˜๊ฒฌ์ž…๋‹ˆ๋‹ค (Approve) + +- \ No newline at end of file diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 0000000..33d7dff --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,152 @@ +name: CI/CD Workflow + +on: + push: + branches: [ "main", "develop"] + +jobs: + CI-CD: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'corretto' + + - name: Gradle Caching + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew clean build -x test + + - name: Create secrets files from GitHub Secrets + run: | + mkdir -p ./src/main/resources/secret + echo "${{ secrets.PROD_ENV_CONTENT }}" > ./src/main/resources/secret/prod.env + echo "${{ secrets.DEV_ENV_CONTENT }}" > ./src/main/resources/secret/dev.env + cat <<'EOF' > ./src/main/resources/secret/deploy.sh + ${{ secrets.DEPLOY_FILE }} + EOF + chmod 600 ./src/main/resources/secret/*.env + chmod 600 ./src/main/resources/secret/deploy.sh + echo "โœ… .env & .sh ํŒŒ์ผ ์ƒ์„ฑ ์™„๋ฃŒ" + + - name: Docker Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # ------------------ ํŒŒ์ผ๋ณ„ ๊ฐœ๋ณ„ ์ „์†ก ------------------ + - name: Copy prod.env to NCP server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + source: ./src/main/resources/secret/prod.env + target: /root + strip_components: 5 + + - name: Copy dev.env to NCP server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + source: ./src/main/resources/secret/dev.env + target: /root + strip_components: 5 + + - name: Copy deploy.sh to NCP server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + source: ./src/main/resources/secret/deploy.sh + target: /root + strip_components: 5 + + - name: Copy docker-compose.yml to NCP server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + source: ./docker-compose.yml + target: /root + + # ---------------------- PROD Deployment ---------------------- + - name: Docker build & push (prod) + if: github.ref == 'refs/heads/main' + run: | + SHA=${{ github.sha }} + SHORT_SHA=${SHA:0:7} + IMAGE_NAME=${{ secrets.DOCKER_REPO }}/${{ secrets.DOCKER_IMAGE_NAME }} + + echo "โœ… Building $IMAGE_NAME with tags $SHORT_SHA and prod" + + docker build -f Dockerfile -t $IMAGE_NAME:$SHORT_SHA . + docker tag $IMAGE_NAME:$SHORT_SHA $IMAGE_NAME:prod + + docker push $IMAGE_NAME:$SHORT_SHA + docker push $IMAGE_NAME:prod + + echo "โœ… Pushed $IMAGE_NAME:$SHORT_SHA and $IMAGE_NAME:prod" + + - name: Deploy to prod + if: github.ref == 'refs/heads/main' + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + echo "๐Ÿš€ Deploying to PROD server..." + + cd /root + chmod +x deploy.sh + ./deploy.sh prod + + # ---------------------- DEV Deployment ---------------------- + - name: Docker build & push (develop) + if: github.ref == 'refs/heads/develop' + run: | + IMAGE_NAME=${{ secrets.DOCKER_REPO }}/${{ secrets.DOCKER_IMAGE_NAME }} + + echo "โœ… Building $IMAGE_NAME with tags dev" + + docker build -f Dockerfile -t $IMAGE_NAME:dev . + docker push $IMAGE_NAME:dev + + echo "โœ… Pushed $IMAGE_NAME:dev" + + - name: Deploy to develop + if: github.ref == 'refs/heads/develop' + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + echo "๐Ÿš€ Deploying to DEV server..." + + cd /root + chmod +x deploy.sh + ./deploy.sh dev \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b675af --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +src/main/resources/secret/ + +### application.yml ### +application.yml +application-local.yml +application-dev.yml +application-prod.yml +application-test.yml + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5cc0bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM amazoncorretto:21-alpine +WORKDIR /app +COPY ./build/libs/cheeeese-0.0.1-SNAPSHOT.jar /app/cheeeese.jar +EXPOSE 8080 +ENTRYPOINT ["java"] +CMD ["-jar", "cheeeese.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 554ba03..328f1c2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,82 @@ -# BE -๐Ÿง€ Kusitms 32nd Cheeeese Backend Repository ๐Ÿง€ +# ๐Ÿง€ ์น˜์ด์ด์ฆˆ : ๋”ฑ 7์ผ๋งŒ ์—ด๋ฆฌ๋Š” ํŠน๋ณ„ํ•œ ๊ณต์œ  ์•จ๋ฒ” ์„œ๋น„์Šค +> ๐Ÿ”— ์„œ๋น„์Šค ๋งํฌ: [https://say-cheese.me](https://say-cheese.me) + +![์›น ์ธ๋„ค์ผ](https://github.com/user-attachments/assets/f5a6c97a-21b9-4dff-a7b7-8c12fe6e27db) + +## ๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ Backend Members + +|**์šฐ๋‹คํ˜„** | **์ฃผ์ •๋นˆ** | +| :--------: |:----------:| +| | | +| `backend` | `backend` | + +## ๐Ÿ“œ API ๋ช…์„ธ์„œ +[[Swagger] ๐Ÿง€ ์น˜์ด์ด์ฆˆ API ๋ช…์„ธ์„œ](https://dev.say-cheese.me/swagger-ui/index.html#/) + +## ๐Ÿ—ƒ๏ธ ERD +Cheeeese (1) + +## ๐Ÿ›๏ธ System Architecture +image + +## ๐Ÿ› ๏ธ ๊ธฐ์ˆ  ์Šคํƒ +- Language & Framework + - Spring Boot 3.5.6 + - Java 21 + - JPA + +- Database & Cache + - MySQL + - Redis + +- CI/CD & Deployment + - GitHub Actions + Docker + - ์ฝ”๋“œ ํ‘ธ์‹œ โ†’ ์ž๋™ ๋นŒ๋“œ โ†’ ํ…Œ์ŠคํŠธ โ†’ Docker ์ด๋ฏธ์ง€ ์ƒ์„ฑ โ†’ ๋ฐฐํฌ๊นŒ์ง€ ์ž๋™ํ™” + - ์ปจํ…Œ์ด๋„ˆ ๊ธฐ๋ฐ˜์œผ๋กœ ํ™˜๊ฒฝ ์ผ๊ด€์„ฑ ํ™•๋ณด ๋ฐ ๋ฌด์ค‘๋‹จ ๋ฐฐํฌ ๊ฐ€๋Šฅ + - ์šด์˜ ํ™˜๊ฒฝ์— ๋งž์ถ˜ Blue-Green ๋ฐฉ์‹ ๊ตฌํ˜„ + +- Monitoring & Logging + - Promtail : ์„œ๋ฒ„ ๋กœ๊ทธ๋ฅผ ์ˆ˜์ง‘ํ•˜์—ฌ ์ค‘์•™ ์ง‘์ค‘ํ˜• ๋กœ๊น… ๊ตฌ์„ฑ + - Loki : ๋น„์šฉ ์ตœ์ ํ™”๋œ ๋กœ๊ทธ ์ €์žฅ์†Œ๋กœ ๋Œ€๋Ÿ‰ ๋กœ๊ทธ ์ฒ˜๋ฆฌ์— ์ ํ•ฉ + - Grafana : ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ํ†ตํ•ด ์„œ๋ฒ„ ์ƒํƒœยท๋กœ๊ทธยท์ง€ํ‘œ ์‹ค์‹œ๊ฐ„ ์‹œ๊ฐํ™” + - Prometheus : ์„œ๋ฒ„/์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์ง‘ ๋ฐ Alert ๊ธฐ๋ฐ˜ ๋ชจ๋‹ˆํ„ฐ๋ง ๊ตฌํ˜„ + +## ๐Ÿ“‚ ํด๋” ๊ตฌ์กฐ +```csharp +src +โ””โ”€โ”€ main + โ””โ”€โ”€ java + โ””โ”€โ”€ com.cheeeese + โ”œโ”€โ”€ album # ๐Ÿ“ธ ์•จ๋ฒ” ๋„๋ฉ”์ธ + โ”œโ”€โ”€ photo # ๐Ÿ–ผ๏ธ ์‚ฌ์ง„ ๋„๋ฉ”์ธ + โ”œโ”€โ”€ cheese4cut # ๐ŸŽž๏ธ ์น˜์ฆˆ๋„ค์ปท ๋„๋ฉ”์ธ + โ”œโ”€โ”€ user # ๐Ÿ‘ค ์‚ฌ์šฉ์ž ๋„๋ฉ”์ธ + โ”œโ”€โ”€ auth # ๐Ÿ” ์ธ์ฆ / ์ธ๊ฐ€ + โ”œโ”€โ”€ oauth2 # ๐Ÿ”‘ ์†Œ์…œ ๋กœ๊ทธ์ธ(OAuth2) + โ”‚ โ”œโ”€โ”€ application # ์„œ๋น„์Šค / ์œ ์Šค์ผ€์ด์Šค + โ”‚ โ”œโ”€โ”€ domain # ์—”ํ‹ฐํ‹ฐ / ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ + โ”‚ โ”œโ”€โ”€ dto # ์š”์ฒญยท์‘๋‹ต DTO + โ”‚ โ”œโ”€โ”€ exception # ์˜ˆ์™ธ + โ”‚ โ”œโ”€โ”€ infrastructure # JPA / ์™ธ๋ถ€ ์—ฐ๋™ + โ”‚ โ””โ”€โ”€ presentation # ์ปจํŠธ๋กค๋Ÿฌ / API + โ”‚ + โ”œโ”€โ”€ global # ๐ŸŒ ์ „์—ญ ์„ค์ • / ์œ ํ‹ธ / AOP / ๊ณตํ†ต ์˜ˆ์™ธ + โ””โ”€โ”€ CheeeeseApplication.java # ๐Ÿš€ ๋ฉ”์ธ ์‹คํ–‰ ํŒŒ์ผ +``` + + +## ๐Ÿ’ฌ Commit Convention +#์ด์Šˆ ๋ฒˆํ˜ธ ํƒœ๊ทธ: ์ปค๋ฐ‹ ๋ฉ”์‹œ์ง€ ํ˜•ํƒœ๋กœ ์ž‘์„ฑ +> e.g. #1 feat: ์นด์นด์˜ค ๋กœ๊ทธ์ธ ๊ตฌํ˜„ + +| Type | ๋‚ด์šฉ | +| ---------- | ---------------------------------- | +| `feat` | ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ๊ตฌํ˜„ | +| `chore` | ๋ถ€์ˆ˜์ ์ธ ์ฝ”๋“œ ์ˆ˜์ • ๋ฐ ๊ธฐํƒ€ ๋ณ€๊ฒฝ์‚ฌํ•ญ | +| `docs` | ๋ฌธ์„œ ์ถ”๊ฐ€ ๋ฐ ์ˆ˜์ •, ์‚ญ์ œ | +| `fix` | ๋ฒ„๊ทธ ์ˆ˜์ • | +| `hotfix` | ์„œ๋น„์Šค ์žฅ์•  ๋“ฑ ๊ธด๊ธ‰ ์ด์Šˆ ์ˆ˜์ • | +| `test` | ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€ ๋ฐ ์ˆ˜์ •, ์‚ญ์ œ | +| `refactor` | ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง | +| `style` | ์ฝ”๋“œ ํฌ๋งทํŒ…, ์„ธ๋ฏธ์ฝœ๋ก  ๋ˆ„๋ฝ ๋“ฑ ๊ธฐ๋Šฅ ๋ณ€๊ฒฝ ์—†๋Š” ์Šคํƒ€์ผ ์ˆ˜์ • | +| `deploy` | ๋ฐฐํฌ ๊ด€๋ จ ์ž‘์—… (CI/CD, ์„œ๋ฒ„ ์„ค์ •, ๋ฐฐํฌ ์Šคํฌ๋ฆฝํŠธ ๋“ฑ) | diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..b1a889f --- /dev/null +++ b/build.gradle @@ -0,0 +1,80 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com' +version = '0.0.1-SNAPSHOT' +description = 'Kusitms 32nd Cheeeese Server' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + + // security + testImplementation 'org.springframework.security:spring-security-test' + + // oauth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // uuid v7 + implementation 'com.github.f4b6a3:uuid-creator:5.2.0' + + // AWS SDK for S3 + implementation 'software.amazon.awssdk:s3:2.25.46' + implementation 'software.amazon.awssdk:auth:2.25.46' + implementation 'software.amazon.awssdk:regions:2.25.46' + + testImplementation 'com.h2database:h2' + // aop + implementation 'org.springframework.boot:spring-boot-starter-aop' + + // monitoring + implementation 'net.logstash.logback:logstash-logback-encoder:6.6' + + // prometheus + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' +} + +tasks.named('test') { + useJUnitPlatform { + excludeTags 'benchmark' + } + + jvmArgs '-XX:+EnableDynamicAgentLoading' +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..595798f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,87 @@ +version: "3.9" + +services: + # ------------------------------------------------------------ + # โœ… Redis ์„œ๋น„์Šค (ํ•ญ์ƒ ์‹คํ–‰) + # ------------------------------------------------------------ + redis: + image: redis:7.2-alpine + container_name: redis + restart: always + command: [ "redis-server", "--requirepass", "${SPRING_DATA_REDIS_PASSWORD}" ] + ports: + - "6379:6379" + env_file: + - /root/.env + volumes: + - redis-data:/data + networks: + - cheeeeese-network + + # ------------------------------------------------------------ + # โœ… Backend ์„œ๋น„์Šค (Blue-Green ์ „ํ™˜ ๋Œ€์ƒ) + # ------------------------------------------------------------ + backend-green-prod: + image: mozzarella32/backend:prod # deploy.sh์—์„œ docker pull๋กœ ์ตœ์‹  ์ด๋ฏธ์ง€ ๊ฐฑ์‹  + container_name: cheeeeese-green-prod + env_file: + - /root/prod.env + depends_on: + - redis + ports: + - "8080:8080" # Green ์ธ์Šคํ„ด์Šค ํฌํŠธ + restart: always + networks: + - cheeeeese-network + + backend-blue-prod: + image: mozzarella32/backend:prod + container_name: cheeeeese-blue-prod + env_file: + - /root/prod.env + depends_on: + - redis + ports: + - "8081:8080" # Blue ์ธ์Šคํ„ด์Šค ํฌํŠธ + restart: always + networks: + - cheeeeese-network + + # ------------------------------------------------------------ + # โœ… ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์šฉ + # ------------------------------------------------------------ + backend-green-dev: + image: mozzarella32/backend:dev + container_name: cheeeeese-green-dev + env_file: + - /root/dev.env + depends_on: + - redis + ports: + - "8082:8080" + restart: always + networks: + - cheeeeese-network + + backend-blue-dev: + image: mozzarella32/backend:dev + container_name: cheeeeese-blue-dev + env_file: + - /root/dev.env + depends_on: + - redis + ports: + - "8083:8080" + restart: always + networks: + - cheeeeese-network + +# ------------------------------------------------------------ +# โœ… ๋„คํŠธ์›Œํฌ ๋ฐ ๋ณผ๋ฅจ ์ •์˜ +# ------------------------------------------------------------ +networks: + cheeeeese-network: + driver: bridge + +volumes: + redis-data: \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..116850a --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'cheeeese' diff --git a/src/main/java/com/cheeeese/CheeeeseApplication.java b/src/main/java/com/cheeeese/CheeeeseApplication.java new file mode 100644 index 0000000..e920eb3 --- /dev/null +++ b/src/main/java/com/cheeeese/CheeeeseApplication.java @@ -0,0 +1,19 @@ +package com.cheeeese; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableJpaAuditing +@EnableRedisRepositories(basePackages = "com.cheeeese.auth.infrastructure.persistence") +@EnableScheduling +public class CheeeeseApplication { + + public static void main(String[] args) { + SpringApplication.run(CheeeeseApplication.class, args); + } + +} diff --git a/src/main/java/com/cheeeese/album/application/AlbumExpirationScheduler.java b/src/main/java/com/cheeeese/album/application/AlbumExpirationScheduler.java new file mode 100644 index 0000000..f0af29a --- /dev/null +++ b/src/main/java/com/cheeeese/album/application/AlbumExpirationScheduler.java @@ -0,0 +1,36 @@ +package com.cheeeese.album.application; + +import com.cheeeese.album.infrastructure.persistence.AlbumExpirationRedisRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AlbumExpirationScheduler { + + private final AlbumExpirationRedisRepository albumExpirationRedisRepository; + private final AlbumExpirationService albumExpirationService; + + @Scheduled(fixedDelay = 10000L) + public void handleAlbumExpirations() { + Set expiredAlbumIds = albumExpirationRedisRepository.getExpiredAlbumIds(); + + if (expiredAlbumIds.isEmpty()) { + return; + } + + for (Long albumId : expiredAlbumIds) { + try { + albumExpirationService.expireAlbum(albumId); + albumExpirationRedisRepository.unregister(albumId); + } catch (Exception exception) { + log.error("[AlbumExpiration] Failed to process album id={}", albumId, exception); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/application/AlbumExpirationService.java b/src/main/java/com/cheeeese/album/application/AlbumExpirationService.java new file mode 100644 index 0000000..6d6772e --- /dev/null +++ b/src/main/java/com/cheeeese/album/application/AlbumExpirationService.java @@ -0,0 +1,81 @@ +package com.cheeeese.album.application; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.exception.AlbumException; +import com.cheeeese.album.exception.code.AlbumErrorCode; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.cheese4cut.infrastructure.mapper.Cheese4cutMapper; +import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutRepository; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AlbumExpirationService { + + private static final int CHEESE4CUT_PHOTO_COUNT = 4; + + private final AlbumRepository albumRepository; + private final PhotoRepository photoRepository; + private final Cheese4cutRepository cheese4cutRepository; + + @Transactional + public void expireAlbum(Long albumId) { + Album album = albumRepository.findById(albumId) + .orElseThrow(() -> new AlbumException(AlbumErrorCode.ALBUM_NOT_FOUND)); + + if (album.getStatus() != Album.AlbumStatus.EXPIRED) { + albumRepository.updateStatus(albumId, Album.AlbumStatus.EXPIRED); + log.info("[AlbumExpiration] Album id={} status updated to EXPIRED", albumId); + } + + if (cheese4cutRepository.findByAlbumId(albumId).isPresent()) { + return; + } + + List topPhotoIds = photoRepository.findTop4CompletedPhotoIdsByLikes( + albumId, + PhotoStatus.COMPLETED, + PageRequest.of(0, CHEESE4CUT_PHOTO_COUNT) + ); + + if (topPhotoIds.size() < CHEESE4CUT_PHOTO_COUNT) { + log.warn( + "[AlbumExpiration] Album id={} does not have enough photos to create cheese4cut (found={})", + albumId, + topPhotoIds.size() + ); + return; + } + + List photos = photoRepository.findAllByIdIn(topPhotoIds); + Map photoMap = photos.stream() + .collect(Collectors.toMap(Photo::getId, Function.identity())); + + List orderedPhotos = topPhotoIds.stream() + .map(photoMap::get) + .collect(Collectors.toList()); + + if (orderedPhotos.stream().anyMatch(Objects::isNull)) { + log.warn("[AlbumExpiration] Album id={} has missing photos for cheese4cut creation", albumId); + return; + } + + cheese4cutRepository.save(Cheese4cutMapper.toEntity(album, orderedPhotos)); + + log.info("[AlbumExpiration] Cheese4cut created automatically for album id={}", albumId); + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/application/AlbumQueryService.java b/src/main/java/com/cheeeese/album/application/AlbumQueryService.java new file mode 100644 index 0000000..76f5079 --- /dev/null +++ b/src/main/java/com/cheeeese/album/application/AlbumQueryService.java @@ -0,0 +1,187 @@ +package com.cheeeese.album.application; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.album.dto.response.ClosedAlbumPageResponse; +import com.cheeeese.album.dto.response.ClosedAlbumSummaryResponse; +import com.cheeeese.album.dto.response.OpenAlbumPageResponse; +import com.cheeeese.album.dto.response.OpenAlbumSummaryResponse; +import com.cheeeese.album.infrastructure.mapper.AlbumQueryMapper; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.cheese4cut.domain.Cheese4cutPhoto; +import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutPhotoRepository; +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.exception.UserException; +import com.cheeeese.user.exception.code.UserErrorCode; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AlbumQueryService { + + private static final int RECENT_THUMBNAIL_COUNT = 3; + + private final UserAlbumRepository userAlbumRepository; + private final UserRepository userRepository; + private final PhotoRepository photoRepository; + private final Cheese4cutPhotoRepository cheese4cutPhotoRepository; + private final CdnUrlResolver cdnUrlResolver; + + public OpenAlbumPageResponse getOpenAlbums(User user, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + + Slice albums = userAlbumRepository.findOpenAlbumsByUserId( + user.getId(), + Album.AlbumStatus.ACTIVE, + LocalDateTime.now(), + pageable + ); + + List responses = buildOpenAlbumResponses(albums.getContent()); + + return AlbumQueryMapper.toOpenAlbumPageResponse(responses, albums); + } + + public OpenAlbumPageResponse getMyOpenAlbums(User user, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Slice albums = userAlbumRepository.findOpenAlbumsByUserIdAndRole( + user.getId(), + Role.MAKER, + Album.AlbumStatus.ACTIVE, + LocalDateTime.now(), + pageable + ); + + List responses = buildOpenAlbumResponses(albums.getContent()); + + return AlbumQueryMapper.toOpenAlbumPageResponse(responses, albums); + } + + public ClosedAlbumPageResponse getClosedAlbums(User user, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + + Slice expiredAlbums = userAlbumRepository.findClosedAlbumsByUserId( + user.getId(), + Album.AlbumStatus.EXPIRED, + pageable + ); + + List albumIds = expiredAlbums.getContent().stream() + .map(Album::getId) + .toList(); + + if (albumIds.isEmpty()) { + return AlbumQueryMapper.toClosedAlbumPageResponse(List.of(), expiredAlbums); + } + + Map makerMap = getMakers(expiredAlbums.getContent()); + + List allCheese4cutPhotos = cheese4cutPhotoRepository.findAllCheese4cutPhotosByAlbumIds(albumIds); + + Map> cheese4cutPhotoMap = allCheese4cutPhotos.stream() + .collect(Collectors.groupingBy( + c4p -> c4p.getCheese4cut().getAlbum().getId(), + Collectors.toList() + )); + + List responses = expiredAlbums.getContent().stream() + .map(album -> { + List c4pList = cheese4cutPhotoMap.getOrDefault(album.getId(), List.of()); + + User maker = Optional.ofNullable(makerMap.get(album.getMakerId())) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + List thumbnails = c4pList.stream() + .sorted(Comparator.comparingInt(Cheese4cutPhoto::getPhotoRank)) // photoRank ์ˆœ์œผ๋กœ ์ •๋ ฌ + .map(Cheese4cutPhoto::getThumbnailImageUrl) + .map(cdnUrlResolver::resolveThumbnail) + .collect(Collectors.toList()); + + return AlbumQueryMapper.toClosedAlbumSummaryResponse(album, maker, thumbnails); + }) + .collect(Collectors.toList()); + + return AlbumQueryMapper.toClosedAlbumPageResponse(responses, expiredAlbums); + } + + private List buildOpenAlbumResponses(List albums) { + if (albums.isEmpty()) { + return List.of(); + } + + Map makerMap = getMakers(albums); + Map> recentThumbnailsMap = getRecentThumbnailsMap(albums); + + return albums.stream() + .map(album -> { + User maker = Optional.ofNullable(makerMap.get(album.getMakerId())) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + List recentThumbnails = recentThumbnailsMap.getOrDefault(album.getId(), List.of()); + return AlbumQueryMapper.toOpenAlbumSummaryResponse(album, maker, recentThumbnails); + }) + .toList(); + } + + private Map getMakers(List albums) { + List makerIds = albums.stream() + .map(Album::getMakerId) + .distinct() + .toList(); + Map makers = userRepository.findAllById(makerIds).stream() + .collect(Collectors.toMap(User::getId, Function.identity())); + if (makers.size() != makerIds.size()) { + throw new UserException(UserErrorCode.USER_NOT_FOUND); + } + return makers; + } + + private Map> getRecentThumbnailsMap(List albums) { + List albumIds = albums.stream() + .map(Album::getId) + .toList(); + + if (albumIds.isEmpty()) { + return Map.of(); + } + + List photos = photoRepository.findTop3RecentPhotosInEachAlbum( + albumIds, + PhotoStatus.COMPLETED + ); + + Map> thumbnailsMap = new HashMap<>(); + + for (Photo photo : photos) { + Long albumId = photo.getAlbum().getId(); + List thumbnails = thumbnailsMap.computeIfAbsent(albumId, key -> new ArrayList<>()); + + if (thumbnails.size() < RECENT_THUMBNAIL_COUNT) { + thumbnails.add(cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl())); + } + } + + return thumbnailsMap.entrySet().stream() + .filter(entry -> entry.getValue().size() == RECENT_THUMBNAIL_COUNT) + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> List.copyOf(entry.getValue()) + )); + } +} diff --git a/src/main/java/com/cheeeese/album/application/AlbumService.java b/src/main/java/com/cheeeese/album/application/AlbumService.java new file mode 100644 index 0000000..719772b --- /dev/null +++ b/src/main/java/com/cheeeese/album/application/AlbumService.java @@ -0,0 +1,336 @@ +package com.cheeeese.album.application; + +import com.cheeeese.album.application.logger.AlbumLogger; +import com.cheeeese.album.application.support.AlbumReader; +import com.cheeeese.album.application.validator.AlbumValidator; +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.type.AlbumJoinStatus; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.album.dto.request.AlbumCreationRequest; +import com.cheeeese.album.dto.response.*; +import com.cheeeese.album.exception.AlbumException; +import com.cheeeese.album.exception.code.AlbumErrorCode; +import com.cheeeese.album.infrastructure.mapper.AlbumMapper; +import com.cheeeese.album.infrastructure.mapper.UserAlbumMapper; +import com.cheeeese.album.infrastructure.persistence.AlbumExpirationRedisRepository; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.global.security.CustomUserDetails; +import com.cheeeese.global.util.ProfileImageUtil; +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.photo.application.PhotoService; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.album.dto.response.AlbumBest4CutResponse; +import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.domain.type.ProfileImageType; +import com.cheeeese.user.exception.UserException; +import com.cheeeese.user.exception.code.UserErrorCode; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import com.github.f4b6a3.uuid.UuidCreator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AlbumService { + + private final AlbumValidator albumValidator; + private final AlbumRepository albumRepository; + private final UserAlbumRepository userAlbumRepository; + private final UserRepository userRepository; + private final PhotoRepository photoRepository; + private final PhotoLikesRepository photoLikesRepository; + private final PhotoService photoService; + private final AlbumExpirationRedisRepository albumExpirationRedisRepository; + private final CdnUrlResolver cdnUrlResolver; + private final AlbumReader albumReader; + private final AlbumLogger albumLogger; + + @Transactional + public AlbumCreationResponse createAlbum(User user, AlbumCreationRequest request) { + String code = UuidCreator.getTimeOrdered().toString(); + + long createdThisWeek = countUserAlbumsCreatedThisWeek(user); + + albumValidator.validateAlbumCreation(createdThisWeek, request); + + LocalDateTime expiredAt = LocalDateTime.now().plusDays(7); + + Album album = AlbumMapper.toEntity( + user.getId(), + request.title(), + code, + request.themeEmoji(), + request.participant(), + request.eventDate(), + true, + expiredAt + ); + boolean isFirst = !albumRepository.existsByMakerId(user.getId()); + + albumRepository.save(album); + + userAlbumRepository.save(UserAlbumMapper.toEntity( + user, + album, + Role.MAKER + )); + userRepository.incrementAlbumCnt(user.getId()); + + albumExpirationRedisRepository.registerAlbum(album.getId(), expiredAt); + + albumLogger.logAlbumCreated(user.getId(), album.getCode(), request.participant()); + + return AlbumMapper.toCreationResponse(album, isFirst); + } + + public AlbumInvitationResponse getInvitationInfo(String code) { + Album album = albumValidator.validateAlbumCode(code); + + if (album.isExpired()) { + return AlbumMapper.toExpiredInvitationResponse(album); + } + User maker = getMaker(album.getMakerId()); + String makerProfileUrl = ProfileImageUtil.resolveProfileImage(maker, cdnUrlResolver); + + return AlbumMapper.toInvitationResponse(album, maker, makerProfileUrl); + } + + @Transactional + public AlbumEnterResponse enterAlbum(String code, User currentUser) { + Album album = albumValidator.validateAlbumCode(code); + albumValidator.validateAlbumEntry(album, currentUser); + + Optional existing = userAlbumRepository.findByUserIdAndAlbumId(currentUser.getId(), album.getId()); + + User maker = getMaker(album.getMakerId()); + String makerProfileUrl = ProfileImageUtil.resolveProfileImage(maker, cdnUrlResolver); + AlbumMakerInfo makerInfo = AlbumMapper.toMakerInfo(maker, makerProfileUrl); + + // Case 1: ๊ธฐ์กด ์ฐธ์—ฌ ์ด๋ ฅ ์กด์žฌ + if (existing.isPresent()) { + UserAlbum userAlbum = existing.get(); + + // ์žฌ๋ฐฉ๋ฌธ ๋กœ๊ทธ + albumLogger.logAlbumViewed(currentUser.getId(), album.getCode(), userAlbum.getRole()); + + if (!userAlbum.isVisible()) { + userAlbum.show(); + return AlbumMapper.toExistingResponse(album, AlbumJoinStatus.REJOINED, makerInfo); + } + + return AlbumMapper.toExistingResponse(album, AlbumJoinStatus.EXISTING, makerInfo); + } + + // Case 2: ์‹ ๊ทœ ์ฐธ์—ฌ + albumValidator.validateAlbumCapacity(album); + userAlbumRepository.save(UserAlbumMapper.toEntity( + currentUser, + album, + Role.GUEST + )); + + int updated = albumRepository.incrementParticipantCountAtomically(album.getId()); + if (updated == 0) { + throw new AlbumException(AlbumErrorCode.ALBUM_MAX_PARTICIPANT_REACHED); + } + userRepository.incrementAlbumCnt(currentUser.getId()); + + List recentPhotos = getRecentPhotosWithUploaderInfo(album.getId()); + + int remainingUploadSlots = calculateRemainingUploadSlots(album); + + boolean photoExist = album.getCurrentPhotoCount() > 0; + albumLogger.logAlbumJoined(currentUser.getId(), album.getCode(), photoExist); + + return AlbumMapper.toNewResponse(album, makerInfo, remainingUploadSlots, recentPhotos); + } + + public UploadAvailableCountResponse getAvailablePhotoCount(String code) { + Album album = albumValidator.validateAlbumCode(code); + + int availableCount = calculateRemainingUploadSlots(album); + + return AlbumMapper.toAvailableCountResponse( + availableCount, + album.getMaxPhotoCount(), + album.getCurrentPhotoCount() + ); + } + + public AlbumParticipantResponse getAlbumParticipantList(Authentication authentication, String code) { + + User currentUser = extractUser(authentication); + + Album album = albumValidator.validateAlbumCode(code); + + boolean isExpired = album.isExpired(); + + Role myRole = null; + Long currentUserId = currentUser != null ? currentUser.getId() : null; + + if (currentUserId != null) { + Optional myUserAlbumOptional = userAlbumRepository.findByUserIdAndAlbumId(currentUserId, album.getId()); + + if (myUserAlbumOptional.isPresent()) { + myRole = myUserAlbumOptional.get().getRole(); + } + } + + // ์•จ๋ฒ”์˜ ์ „์ฒด ์ฐธ์—ฌ์ž ๋ชฉ๋ก + List userAlbums = userAlbumRepository.findAllByAlbumId(album.getId()); + + List participantInfos = buildSortedParticipantInfos(userAlbums, currentUserId); + + return UserAlbumMapper.toAlbumParticipantResponse( + album, + isExpired, + myRole, + participantInfos + ); + } + + public AlbumInfoResponse getAlbumInfo(String code) { + Album album = albumValidator.validateAlbumCode(code); + + User maker = userAlbumRepository.findMakerByAlbumId(album.getId(), Role.MAKER) + .map(UserAlbum::getUser) + .orElseThrow(() -> new AlbumException(AlbumErrorCode.USER_NOT_MAKER)); + + return AlbumMapper.toAlbumInfoResponse(album, maker); + } + + public List getAlbumBest4Cut(User user, String code) { + Album album = albumValidator.validateAlbumCode(code); + + albumValidator.validateAlbumParticipant(album, user); + + List topPhotos = photoRepository.findTop4CompletedPhotosByLikes( + album.getId(), + PhotoStatus.COMPLETED, + PageRequest.of(0, 4) + ); + + List photoIds = topPhotos.stream() + .map(Photo::getId) + .toList(); + + Set likedPhotoIds = photoLikesRepository.findAllLikedPhotoIds(user.getId(), photoIds); + + return topPhotos.stream() + .map(photo -> { + String thumbnailUrl = cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl()); + boolean isLiked = likedPhotoIds.contains(photo.getId()); + return AlbumMapper.toBest4CutResponse(photo, thumbnailUrl, isLiked); + }) + .toList(); + } + + @Transactional + public void leaveAlbum(User user, String code) { + Album album = albumValidator.validateAlbumCode(code); + albumValidator.validateMakerLeaveAllowed(album, user); + + UserAlbum userAlbum = albumReader.getAlbumParticipant(album, user); + + userAlbum.hide(); + } + + private User extractUser(Authentication authentication) { + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomUserDetails customUserDetails) { + return customUserDetails.getUser(); + } + return null; + } + + private int calculateRemainingUploadSlots(Album album) { + int current = album.getCurrentPhotoCount(); + int max = album.getMaxPhotoCount(); + return Math.max(0, max - current); + } + + private long countUserAlbumsCreatedThisWeek(User user) { + return albumRepository.countByUserAndCreatedAtBetween( + user.getId(), + LocalDateTime.now().minusDays(7), + LocalDateTime.now() + ); + } + + private User getMaker(Long makerId) { + return userRepository.findById(makerId) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + } + + private List getRecentPhotosWithUploaderInfo(Long albumId) { + List photos = photoService.getRecentPhotosForNewEnter(albumId); + + if (photos.isEmpty()) { + return List.of(); + } + + // 1~4๊ฐœ์ธ ๊ฒฝ์šฐ, 1๊ฐœ๋งŒ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ ์šฉ + if (photos.size() < 5) { + Photo photo = photos.get(0); + String profileUrl = ProfileImageUtil.resolveProfileImage(photo.getUser(), cdnUrlResolver); + return List.of(AlbumMapper.toRecentPhotoResponse(photo, profileUrl)); + } + + // 5๊ฐœ์ธ ๊ฒฝ์šฐ, 5๊ฐœ ๋ชจ๋‘ ๋ฐ˜ํ™˜ + return photos.stream() + .map(photo -> { + String profileUrl = ProfileImageUtil.resolveProfileImage( + photo.getUser(), + cdnUrlResolver + ); + return AlbumMapper.toRecentPhotoResponse(photo, profileUrl); + }) + .toList(); + } + + private List buildSortedParticipantInfos( + List userAlbums, + Long currentUserId + ) { + return userAlbums.stream() + .map(userAlbum -> { + User user = userAlbum.getUser(); + Role role = userAlbum.getRole(); + ProfileImageType type = ProfileImageType.fromName(user.getProfileImage()); + String profileImageUrl = cdnUrlResolver.resolveProfile(type.getPath()); + boolean isMe = currentUserId != null && user.getId().equals(currentUserId); + + return UserAlbumMapper.toParticipantInfo(user, profileImageUrl, role, isMe); + }) + .sorted(Comparator + .comparing(AlbumParticipantListResponse.ParticipantInfo::isMe, Comparator.reverseOrder()) + .thenComparing(p -> p.role() == Role.MAKER ? 0 : 1) + .thenComparing(AlbumParticipantListResponse.ParticipantInfo::name) + ) + .toList(); + } +} diff --git a/src/main/java/com/cheeeese/album/application/logger/AlbumLogger.java b/src/main/java/com/cheeeese/album/application/logger/AlbumLogger.java new file mode 100644 index 0000000..7284152 --- /dev/null +++ b/src/main/java/com/cheeeese/album/application/logger/AlbumLogger.java @@ -0,0 +1,57 @@ +package com.cheeeese.album.application.logger; + +import com.cheeeese.album.domain.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +public class AlbumLogger { + + private static final String STAT_PREFIX = "[STAT]"; + + /** + * [์ง€ํ‘œ 1] ์•จ๋ฒ” ์ƒ์„ฑ + * album_created(user_id, album_code, expected_participant, created_at) + */ + public void logAlbumCreated(Long userId, String albumCode, int expectedParticipant) { + try { + MDC.put("type", "album"); + log.info("{} album_created | user_id={} album_code={} expected_participant={} created_at={}", + STAT_PREFIX, userId, albumCode, expectedParticipant, LocalDateTime.now()); + } finally { + MDC.remove("type"); + } + } + + /** + * [์ง€ํ‘œ 1, 2] ์•จ๋ฒ” ์ž…์žฅ (์‹ ๊ทœ) + * album_joined(user_id, album_code, is_first_join, photo_exist_on_join, joined_at) + */ + public void logAlbumJoined(Long userId, String albumCode, boolean photoExistOnJoin) { + try { + MDC.put("type", "album"); + log.info("{} album_joined | user_id={} album_code={} is_first_join=true photo_exist_on_join={} joined_at={}", + STAT_PREFIX, userId, albumCode, photoExistOnJoin, LocalDateTime.now()); + } finally { + MDC.remove("type"); + } + } + + /** + * [์ง€ํ‘œ 2, 4] ์•จ๋ฒ” ์žฌ๋ฐฉ๋ฌธ (์กฐํšŒ) + * album_view_start(user_id, album_code, role, viewed_at) + */ + public void logAlbumViewed(Long userId, String albumCode, Role role) { + try { + MDC.put("type", "album"); + log.info("{} album_view_start | user_id={} album_code={} role={} viewed_at={}", + STAT_PREFIX, userId, albumCode, role, LocalDateTime.now()); + } finally { + MDC.remove("type"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/application/support/AlbumReader.java b/src/main/java/com/cheeeese/album/application/support/AlbumReader.java new file mode 100644 index 0000000..d3093c0 --- /dev/null +++ b/src/main/java/com/cheeeese/album/application/support/AlbumReader.java @@ -0,0 +1,22 @@ +package com.cheeeese.album.application.support; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.exception.AlbumException; +import com.cheeeese.album.exception.code.AlbumErrorCode; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AlbumReader { + + private final UserAlbumRepository userAlbumRepository; + + public UserAlbum getAlbumParticipant(Album album, User user) { + return userAlbumRepository.findByUserIdAndAlbumId(user.getId(), album.getId()) + .orElseThrow(() -> new AlbumException(AlbumErrorCode.USER_NOT_PARTICIPANT)); + } +} diff --git a/src/main/java/com/cheeeese/album/application/validator/AlbumValidator.java b/src/main/java/com/cheeeese/album/application/validator/AlbumValidator.java new file mode 100644 index 0000000..4a598f3 --- /dev/null +++ b/src/main/java/com/cheeeese/album/application/validator/AlbumValidator.java @@ -0,0 +1,120 @@ +package com.cheeeese.album.application.validator; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.album.dto.request.AlbumCreationRequest; +import com.cheeeese.album.exception.AlbumException; +import com.cheeeese.album.exception.code.AlbumErrorCode; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.exception.PhotoException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class AlbumValidator { + + private static final ZoneOffset KST_ZONE = ZoneOffset.of("+09:00"); + private final AlbumRepository albumRepository; + private final UserAlbumRepository userAlbumRepository; + + public void validateAlbumCreation(long createdThisWeek, AlbumCreationRequest request) { + if (request.themeEmoji() == null || request.themeEmoji().isBlank()) { + throw new AlbumException(AlbumErrorCode.ALBUM_THEME_EMOJI_NOT_SELECTED); + } + + if (request.title() == null || request.title().isBlank()) { + throw new AlbumException(AlbumErrorCode.ALBUM_TITLE_REQUIRED); + } + + if (request.eventDate() == null) { + throw new AlbumException(AlbumErrorCode.ALBUM_EVENT_DATE_REQUIRED); + } + + if (request.eventDate().isAfter(LocalDate.now(KST_ZONE))) { + throw new AlbumException(AlbumErrorCode.ALBUM_EVENT_DATE_INVALID); + } + + if (request.participant() < 1 || request.participant() > 64) { + throw new AlbumException(AlbumErrorCode.ALBUM_INVALID_CAPACITY); + } + + if (createdThisWeek >= 3) { + throw new AlbumException(AlbumErrorCode.ALBUM_CREATION_LIMIT_EXCEEDED); + } + } + + public Album validateAlbumCode(String code) { + return albumRepository.findByCode(code) + .orElseThrow(() -> new AlbumException(AlbumErrorCode.ALBUM_NOT_FOUND)); + } + + public void validateAlbumEntry(Album album, User user) { + // 1. ์•จ๋ฒ” ๋งŒ๋ฃŒ ํ™•์ธ + validateAlbumExpiration(album); + + // 2. ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ํ™•์ธ (๊ถŒํ•œ ์ฒดํฌ) + validateUserBlacklisted(album, user); + } + + public void validateAlbumCapacity(Album album) { + int current = album.getCurrentParticipant(); + int max = album.getParticipant(); + + if (current >= max) { + throw new AlbumException(AlbumErrorCode.ALBUM_MAX_PARTICIPANT_REACHED); + } + } + + public void validateDownloadPermission(Album album, User user, List photos) { + validateAlbumParticipant(album, user); + + boolean existsPhotoInAlbum = photos.stream().allMatch(photo -> photo.getAlbum().getId().equals(album.getId())); + + if (!existsPhotoInAlbum) { + throw new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND_IN_ALBUM); + } + } + + public void validateAlbumParticipant(Album album, User user) { + validateAlbumEntry(album, user); + + userAlbumRepository.findByUserIdAndAlbumId(user.getId(), album.getId()) + .orElseThrow(() -> new AlbumException(AlbumErrorCode.USER_NOT_PARTICIPANT)); + } + + public void validateMakerLeaveAllowed(Album album, User user) { + if (!album.getMakerId().equals(user.getId())) { + return; + } + + // Maker์ผ ๊ฒฝ์šฐ, ๋งŒ๋ฃŒ๋œ ์•จ๋ฒ”๋งŒ ๋‚˜๊ฐ€๊ธฐ ๊ฐ€๋Šฅ + if (album.getStatus() != Album.AlbumStatus.EXPIRED) { + throw new AlbumException(AlbumErrorCode.MAKER_CANNOT_LEAVE_UNTIL_CLOSED); + } + } + + private void validateAlbumExpiration(Album album) { + if (album.isExpired()) { + throw new AlbumException(AlbumErrorCode.ALBUM_EXPIRED); + } + } + + /** + * ์‚ฌ์šฉ์ž๊ฐ€ ์•จ๋ฒ”์˜ ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ์— ๋“ฑ๋ก๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + */ + private void validateUserBlacklisted(Album album, User user) { + userAlbumRepository.findByAlbumIdAndUserIdAndRole(album.getId(), user.getId(), Role.BLACK) + .ifPresent(userAlbum -> { + throw new AlbumException(AlbumErrorCode.USER_IS_BLACKLISTED); + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/domain/Album.java b/src/main/java/com/cheeeese/album/domain/Album.java new file mode 100644 index 0000000..c236aea --- /dev/null +++ b/src/main/java/com/cheeeese/album/domain/Album.java @@ -0,0 +1,97 @@ +package com.cheeeese.album.domain; + +import com.cheeeese.global.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "album") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Album extends BaseEntity { + + @Id + @Column(name = "album_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "maker_id", nullable = false) + private Long makerId; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "code", nullable = false, unique = true) + private String code; + + @Column(name = "theme_emoji") + private String themeEmoji; + + @Column(name = "participant", nullable = false) + private int participant; + + @Column(name = "current_participant", nullable = false) + private int currentParticipant; + + @Column(name = "event_date", nullable = false) + private LocalDate eventDate; + + @Column(name = "max_photo_count", nullable = false) + private int maxPhotoCount; + + @Column(name = "current_photo_count", nullable = false) + private int currentPhotoCount; + + @Column(name = "is_info_available", nullable = false) + private boolean isInfoAvailable; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private AlbumStatus status; + + public enum AlbumStatus { + ACTIVE, EXPIRED, DELETED + } + + public boolean isExpired() { + return this.expiredAt.isBefore(LocalDateTime.now()) || this.status == AlbumStatus.EXPIRED; + } + + @Builder + private Album( + Long makerId, + String title, + String code, + String themeEmoji, + int participant, + int currentParticipant, + LocalDate eventDate, + int maxPhotoCount, + int currentPhotoCount, + boolean isInfoAvailable, + LocalDateTime expiredAt, + AlbumStatus status + ) { + this.makerId = makerId; + this.title = title; + this.code = code; + this.themeEmoji = themeEmoji; + this.participant = participant; + this.currentParticipant = currentParticipant; + this.eventDate = eventDate; + this.maxPhotoCount = maxPhotoCount; + this.currentPhotoCount = currentPhotoCount; + this.isInfoAvailable = isInfoAvailable; + this.expiredAt = expiredAt; + this.status = status; + } +} diff --git a/src/main/java/com/cheeeese/album/domain/UserAlbum.java b/src/main/java/com/cheeeese/album/domain/UserAlbum.java new file mode 100644 index 0000000..cf0955d --- /dev/null +++ b/src/main/java/com/cheeeese/album/domain/UserAlbum.java @@ -0,0 +1,53 @@ +package com.cheeeese.album.domain; + +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.global.domain.BaseEntity; +import com.cheeeese.user.domain.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "user_album", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "album_id"})) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserAlbum extends BaseEntity { + + @Id + @Column(name = "user_album_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "album_id", nullable = false) + private Album album; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private Role role; + + @Column(name = "is_visible", nullable = false) + private boolean isVisible; + + @Builder + private UserAlbum(User user, Album album, Role role, boolean isVisible) { + this.user = user; + this.album = album; + this.role = role; + this.isVisible = isVisible; + } + + public void hide() { + this.isVisible = false; + } + + public void show() { + this.isVisible = true; + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/domain/type/AlbumJoinStatus.java b/src/main/java/com/cheeeese/album/domain/type/AlbumJoinStatus.java new file mode 100644 index 0000000..62da612 --- /dev/null +++ b/src/main/java/com/cheeeese/album/domain/type/AlbumJoinStatus.java @@ -0,0 +1,7 @@ +package com.cheeeese.album.domain.type; + +public enum AlbumJoinStatus { + NEW, + EXISTING, + REJOINED +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/domain/type/AlbumSorting.java b/src/main/java/com/cheeeese/album/domain/type/AlbumSorting.java new file mode 100644 index 0000000..549d503 --- /dev/null +++ b/src/main/java/com/cheeeese/album/domain/type/AlbumSorting.java @@ -0,0 +1,15 @@ +package com.cheeeese.album.domain.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AlbumSorting { + POPULAR("popular", "๋ฑ ๋งŽ์€์ˆœ"), + CAPTURED_AT("captured", "์ตœ๊ทผ ์ดฌ์˜ํ•œ ์‹œ๊ฐ„์ˆœ"), + CREATED_AT("uploaded", "์ตœ๊ทผ ์—…๋กœ๋“œ๋œ ์‚ฌ์ง„์ˆœ"); + + private final String param; + private final String description; +} diff --git a/src/main/java/com/cheeeese/album/domain/type/Role.java b/src/main/java/com/cheeeese/album/domain/type/Role.java new file mode 100644 index 0000000..3ba0eeb --- /dev/null +++ b/src/main/java/com/cheeeese/album/domain/type/Role.java @@ -0,0 +1,7 @@ +package com.cheeeese.album.domain.type; + +public enum Role { + MAKER, + GUEST, + BLACK +} diff --git a/src/main/java/com/cheeeese/album/dto/request/AlbumCreationRequest.java b/src/main/java/com/cheeeese/album/dto/request/AlbumCreationRequest.java new file mode 100644 index 0000000..8cc8f21 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/request/AlbumCreationRequest.java @@ -0,0 +1,31 @@ +package com.cheeeese.album.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDate; + +@Builder +@Schema( + description = "์•จ๋ฒ” ์ƒ์„ฑ ์š”์ฒญ", + requiredProperties = { + "themeEmoji", + "title", + "participant", + "eventDate" + } +) +public record AlbumCreationRequest( + @Schema(description = "์•จ๋ฒ” ํ…Œ๋งˆ ์ด๋ชจ์ง€", example = "U+1F9C0") + String themeEmoji, + + @Schema(description = "์•จ๋ฒ” ์ด๋ฆ„", example = "์กธ์—…์‹") + String title, + + @Schema(description = "์ฐธ์—ฌ์ž ์ˆ˜", example = "64") + int participant, + + @Schema(description = "ํ–‰์‚ฌ ๋‚ ์งœ", example = "2025-02-01") + LocalDate eventDate +) { +} diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumBest4CutResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumBest4CutResponse.java new file mode 100644 index 0000000..1b896ec --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/AlbumBest4CutResponse.java @@ -0,0 +1,18 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "๋ฒ ์ŠคํŠธ ์•จ๋ฒ”์ปท ์กฐํšŒ API") +public record AlbumBest4CutResponse( + @Schema(description = "์ธ๋„ค์ผ ์ด๋ฏธ์ง€ url", example = "https://cdn.say-cheese.me/...") + String thumbnailUrl, + + @Schema(description = "์ข‹์•„์š” ์ˆ˜", example = "1") + int likeCnt, + + @Schema(description = "์ข‹์•„์š” ์—ฌ๋ถ€", example = "true") + boolean isLiked +) { +} diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumCreationResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumCreationResponse.java new file mode 100644 index 0000000..f41d4ec --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/AlbumCreationResponse.java @@ -0,0 +1,43 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Builder +@Schema( + description = "์•จ๋ฒ” ์ƒ์„ฑ ์‘๋‹ต", + requiredProperties = { + "themeEmoji", + "title", + "eventDate", + "createdAt", + "isFirst", + "currentPhotoCnt", + "code" + } +)public record AlbumCreationResponse( + @Schema(description = "์•จ๋ฒ” ํ…Œ๋งˆ ์ด๋ชจ์ง€", example = "U+1F9C0") + String themeEmoji, + + @Schema(description = "ํ–‰์‚ฌ ์ด๋ฆ„", example = "ํ์‹œ์ฆ˜ MT") + String title, + + @Schema(description = "ํ–‰์‚ฌ ๋‚ ์งœ", example = "2025.02.01") + LocalDate eventDate, + + @Schema(description = "์•จ๋ฒ” ์ƒ์„ฑ์ผ์‹œ", example = "2026-01-05T17:23:16.201417") + LocalDateTime createdAt, + + @Schema(description = "ํ•ด๋‹น ์‚ฌ์šฉ์ž๊ฐ€ ์ตœ์ดˆ๋กœ ์ƒ์„ฑํ•œ ์•จ๋ฒ”์ธ์ง€ ์—ฌ๋ถ€", example = "true") + boolean isFirst, + + @Schema(description = "ํ˜„์žฌ๊นŒ์ง€ ์—…๋กœ๋“œ๋œ ์‚ฌ์ง„ ์ˆ˜", example = "1") + int currentPhotoCnt, + + @Schema(description = "์•จ๋ฒ” ์ฝ”๋“œ", example = "786ccd09-5f22-4aa9-a32b-f62dd2e94cc8") + String code +) { +} diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumEnterResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumEnterResponse.java new file mode 100644 index 0000000..2126298 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/AlbumEnterResponse.java @@ -0,0 +1,10 @@ +package com.cheeeese.album.dto.response; + +import com.cheeeese.album.domain.type.AlbumJoinStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์•จ๋ฒ” ์ž…์žฅ ์‘๋‹ต (๋‹คํ˜• ๊ตฌ์กฐ)") +public sealed interface AlbumEnterResponse + permits NewEnterResponse, ExistingEnterResponse { + AlbumJoinStatus joinStatus(); +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumInfoResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumInfoResponse.java new file mode 100644 index 0000000..de7bafa --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/AlbumInfoResponse.java @@ -0,0 +1,39 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Builder +@Schema(description = "์•จ๋ฒ” ์ •๋ณด API") +public record AlbumInfoResponse( + @Schema(description = "์ƒ์„ฑ์ž ID", example = "1") + Long makerId, + + @Schema(description = "์ƒ์„ฑ์ž ์ด๋ฆ„", example = "์ฃผ์ •๋นˆ") + String name, + + @Schema(description = "์•จ๋ฒ” ์ œ๋ชฉ", example = "๊น€์ˆ˜ํ•œ๋ฌด๊ฑฐ๋ถ์ด") + String title, + + @Schema(description = "ํ…Œ๋งˆ ์ด๋ชจ์ง€", example = "U+1F9C0") + String themeEmoji, + + @Schema(description = "์ฐธ์—ฌ ๊ฐ€๋Šฅ ์ธ์› ์ˆ˜", example = "64") + int participant, + + @Schema(description = "ํ˜„์žฌ ์ฐธ์—ฌ์ž ์ˆ˜", example = "30") + int currentParticipant, + + @Schema(description = "์ด๋ฒคํŠธ ๋‚ ์งœ", example = "2025-02-01") + LocalDate eventDate, + + @Schema(description = "ํ˜„์žฌ ์‚ฌ์ง„ ์ˆ˜", example = "1212") + int currentPhotoCnt, + + @Schema(description = "์•จ๋ฒ” ๋งŒ๋ฃŒ์ผ์ž", example = "2025-11-17 16:53:35.430336") + LocalDateTime expiredAt +) { +} diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumInvitationResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumInvitationResponse.java new file mode 100644 index 0000000..068e854 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/AlbumInvitationResponse.java @@ -0,0 +1,42 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@Schema( + description = "์•จ๋ฒ” ์ดˆ๋Œ€์žฅ ํ™•์ธ ์‘๋‹ต DTO", + requiredProperties = { + "title", + "themeEmoji", + "eventDate", + "expiredAt", + "makerName", + "makerProfileImage", + "isExpired" + } +)public record AlbumInvitationResponse( + @Schema(description = "์•จ๋ฒ” ์ œ๋ชฉ", example = "๊ฒฝ์˜ํ•™๋ถ€ ์กธ์—…์‹") + String title, + + @Schema(description = "์•จ๋ฒ” ํ…Œ๋งˆ ์ด๋ชจ์ง€", example = "U+1F9C0") + String themeEmoji, + + @Schema(description = "์ด๋ฒคํŠธ ๋‚ ์งœ", example = "2025-02-26") + String eventDate, + + @Schema(description = "์—ด๋žŒ ์ข…๋ฃŒ ์‹œ๊ฐ (๋งŒ๋ฃŒ์ผ)", example = "2025-03-05T00:00:00") + LocalDateTime expiredAt, + + @Schema(description = "๋ฉ”์ด์ปค ์ด๋ฆ„", example = "์ด์œ ") + String makerName, + + @Schema(description = "๋ฉ”์ด์ปค ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL", example = "http://example.com/host_profile.png") + String makerProfileImage, + + @Schema(description = "์•จ๋ฒ” ๋งŒ๋ฃŒ ์—ฌ๋ถ€", example = "false") + boolean isExpired +) { +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumMakerInfo.java b/src/main/java/com/cheeeese/album/dto/response/AlbumMakerInfo.java new file mode 100644 index 0000000..2c33888 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/AlbumMakerInfo.java @@ -0,0 +1,20 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Schema( + description = "์•จ๋ฒ” ๋ฉ”์ด์ปค ์ •๋ณด ์‘๋‹ต DTO", + requiredProperties = { + "makerName", + "makerProfileImage" + } +) +@Builder +public record AlbumMakerInfo( + @Schema(description = "๋ฉ”์ด์ปค ์ด๋ฆ„", example = "์šฐ๋‹คํ˜„") + String makerName, + + @Schema(description = "๋ฉ”์ด์ปค ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL", example = "https://cdn.cheeeese.com/users/1/profile.jpg") + String makerProfileImage +) {} diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantListResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantListResponse.java new file mode 100644 index 0000000..b8a3175 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantListResponse.java @@ -0,0 +1,43 @@ +package com.cheeeese.album.dto.response; + +import com.cheeeese.album.domain.type.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema( + description = "์•จ๋ฒ” ์ฐธ์—ฌ์ž ๊ณตํ†ต ์ •๋ณด ๊ตฌ์กฐ", + requiredProperties = { + "participants" + } +) +public record AlbumParticipantListResponse( + @Schema(description = "์ฐธ๊ฐ€์ž ๋ชฉ๋ก (์ •๋ ฌ ํฌํ•จ)") + List participants +) { + @Builder + @Schema( + description = "์ฐธ๊ฐ€์ž ๊ฐœ๋ณ„ ์ •๋ณด", + requiredProperties = { + "name", + "profileImage", + "role", + "isMe" + } + ) + public record ParticipantInfo( + @Schema(description = "์ด๋ฆ„", example = "์šฐ๋‹คํ˜„") + String name, + + @Schema(description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL", example = "https://cdn.cheeeese.com/users/1/profile.jpg") + String profileImage, + + @Schema(description = "์—ญํ•  (MAKER/GUEST)", example = "GUEST") + Role role, + + @Schema(description = "ํ˜„์žฌ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์—ฌ๋ถ€", example = "true") + boolean isMe + ) {} +} diff --git a/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantResponse.java b/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantResponse.java new file mode 100644 index 0000000..d0d4c88 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/AlbumParticipantResponse.java @@ -0,0 +1,53 @@ +package com.cheeeese.album.dto.response; + +import com.cheeeese.album.domain.type.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Builder +@Schema( + description = "์•จ๋ฒ” ์ฐธ์—ฌ์ž ๋ชฉ๋ก ์‘๋‹ต DTO (ํ™œ์„ฑ/๋งŒ๋ฃŒ ๊ณต์šฉ)", + requiredProperties = { + "isExpired", + "title", + "themeEmoji", + "eventDate", + "expiredAt", + "maxParticipantCount", + "currentParticipantCount", + "participants" + } +) +public record AlbumParticipantResponse( + + @Schema(description = "๋งŒ๋ฃŒ ์—ฌ๋ถ€", example = "false") + boolean isExpired, + + @Schema(description = "์•จ๋ฒ” ์ œ๋ชฉ", example = "์กธ์—…์‹") + String title, + + @Schema(description = "์•จ๋ฒ” ํ…Œ๋งˆ ์ด๋ชจ์ง€ (๋˜๋Š” ์ด๋ฏธ์ง€ URL)", example = "U+1F9C0") + String themeEmoji, + + @Schema(description = "์ด๋ฒคํŠธ ๋‚ ์งœ (yyyy-MM-dd)", example = "2025-02-01") + LocalDate eventDate, + + @Schema(description = "๋งŒ๋ฃŒ ์ผ์‹œ (ISO ํ˜•์‹)", example = "2025-10-30T12:11:27.282") + LocalDateTime expiredAt, + + @Schema(description = "์ด ์ฐธ์—ฌ ๊ฐ€๋Šฅ ์ธ์›", example = "64") + Integer maxParticipantCount, + + @Schema(description = "ํ˜„์žฌ ์•จ๋ฒ” ์ฐธ๊ฐ€ ์ธ์›", example = "58") + Integer currentParticipantCount, + + @Schema(description = "์ฐธ๊ฐ€์ž ๋ชฉ๋ก (์ •๋ ฌ ํฌํ•จ)") + List participants, + + @Schema(description = "ํ˜„์žฌ ์‚ฌ์šฉ์ž์˜ ์—ญํ•  (MAKER / GUEST)", example = "GUEST", nullable = true) + Role myRole +) {} diff --git a/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumPageResponse.java b/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumPageResponse.java new file mode 100644 index 0000000..ebfa295 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumPageResponse.java @@ -0,0 +1,35 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema( + description = "๋‹ซํžŒ ์•จ๋ฒ” ๋ชฉ๋ก ํŽ˜์ด์ง€ ์‘๋‹ต", + requiredProperties = { + "responses", + "listSize", + "isFirst", + "isLast", + "hasNext" + } +) +public record ClosedAlbumPageResponse( + @Schema(description = "๋‹ซํžŒ ์•จ๋ฒ” ๋ชฉ๋ก") + List responses, + + @Schema(description = "ํ˜„์žฌ ํŽ˜์ด์ง€์˜ ์•จ๋ฒ” ์ˆ˜", example = "2") + int listSize, + + @Schema(description = "์ฒซ ํŽ˜์ด์ง€ ์—ฌ๋ถ€", example = "true") + boolean isFirst, + + @Schema(description = "๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€ ์—ฌ๋ถ€", example = "false") + boolean isLast, + + @Schema(description = "๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€", example = "true") + boolean hasNext +) { +} diff --git a/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumSummaryResponse.java b/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumSummaryResponse.java new file mode 100644 index 0000000..3d39fad --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/ClosedAlbumSummaryResponse.java @@ -0,0 +1,34 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +@Builder +@Schema( + description = "๋‹ซํžŒ ์•จ๋ฒ” ์š”์•ฝ ์ •๋ณด", + requiredProperties = { + "code", + "title", + "makerName", + "eventDate" + } +) +public record ClosedAlbumSummaryResponse( + @Schema(description = "์•จ๋ฒ” ์ฝ”๋“œ", example = "786ccd09-...") + String code, + + @Schema(description = "์•จ๋ฒ” ์ œ๋ชฉ", example = "๋ด„ ์†Œํ’") + String title, + + @Schema(description = "์•จ๋ฒ” ์ƒ์„ฑ์ž ์ด๋ฆ„", example = "์น˜์ฆˆ๋ฉ”์ด์ปค") + String makerName, + + @Schema(description = "์ด๋ฒคํŠธ ๋‚ ์งœ", example = "2025-05-01") + LocalDate eventDate, + + @Schema(description = "์น˜์ฆˆ๋„ค์ปท ์ธ๋„ค์ผ ๋ชฉ๋ก (4๊ฐœ)", nullable = true) + List thumbnails +) {} diff --git a/src/main/java/com/cheeeese/album/dto/response/ExistingEnterResponse.java b/src/main/java/com/cheeeese/album/dto/response/ExistingEnterResponse.java new file mode 100644 index 0000000..e553007 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/ExistingEnterResponse.java @@ -0,0 +1,40 @@ +package com.cheeeese.album.dto.response; + +import com.cheeeese.album.domain.type.AlbumJoinStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Schema( + description = "์•จ๋ฒ” ์žฌ์ž…์žฅ ๋˜๋Š” ๊ธฐ์กด ์ฐธ์—ฌ์ž ์‘๋‹ต DTO", + requiredProperties = { + "joinStatus", + "title", + "themeEmoji", + "eventDate", + "expiredAt", + "makerInfo" + } +) +@Builder +public record ExistingEnterResponse( + @Schema(description = "์ฐธ์—ฌ ์ƒํƒœ (EXISTING | REJOINED)", example = "EXISTING") + AlbumJoinStatus joinStatus, + + @Schema(description = "์•จ๋ฒ” ์ œ๋ชฉ", example = "์šฐ๋ฆฌ ์—ฌํ–‰ ์•จ๋ฒ”") + String title, + + @Schema(description = "์•จ๋ฒ” ํ…Œ๋งˆ ์ด๋ชจ์ง€", example = "๐Ÿ“ธ") + String themeEmoji, + + @Schema(description = "์ด๋ฒคํŠธ ๋‚ ์งœ (YYYY-MM-DD ํ˜•์‹ ๋ฌธ์ž์—ด)", example = "2025-10-31") + String eventDate, + + @Schema(description = "์•จ๋ฒ” ๋งŒ๋ฃŒ ์‹œ๊ฐ", example = "2025-11-30T23:59:59") + LocalDateTime expiredAt, + + @Schema(description = "์•จ๋ฒ” ์ƒ์„ฑ์ž(ํ˜ธ์ŠคํŠธ) ์ •๋ณด") + AlbumMakerInfo makerInfo + +) implements AlbumEnterResponse {} diff --git a/src/main/java/com/cheeeese/album/dto/response/NewEnterResponse.java b/src/main/java/com/cheeeese/album/dto/response/NewEnterResponse.java new file mode 100644 index 0000000..d652f80 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/NewEnterResponse.java @@ -0,0 +1,72 @@ +package com.cheeeese.album.dto.response; + +import com.cheeeese.album.domain.type.AlbumJoinStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema( + description = "์•จ๋ฒ” ์ฒซ ์ž…์žฅ(์‹ ๊ทœ ์ฐธ์—ฌ์ž) ์‘๋‹ต DTO", + requiredProperties = { + "joinStatus", + "title", + "themeEmoji", + "eventDate", + "expiredAt", + "makerInfo", + "remainingUploadSlots", + "recentPhotos" + } +) +@Builder +public record NewEnterResponse( + @Schema(description = "์ฐธ์—ฌ ์ƒํƒœ (ํ•ญ์ƒ NEW)", example = "NEW") + AlbumJoinStatus joinStatus, + + @Schema(description = "์•จ๋ฒ” ์ œ๋ชฉ", example = "์—ฌ๋ฆ„ ๋ฐ”์บ‰์Šค") + String title, + + @Schema(description = "์•จ๋ฒ” ํ…Œ๋งˆ ์ด๋ชจ์ง€", example = "๐ŸŒด") + String themeEmoji, + + @Schema(description = "์ด๋ฒคํŠธ ๋‚ ์งœ (YYYY-MM-DD ํ˜•์‹ ๋ฌธ์ž์—ด)", example = "2025-08-14") + String eventDate, + + @Schema(description = "์•จ๋ฒ” ๋งŒ๋ฃŒ ์‹œ๊ฐ", example = "2025-09-01T00:00:00") + LocalDateTime expiredAt, + + @Schema(description = "์•จ๋ฒ” ์ƒ์„ฑ์ž ์ •๋ณด") + AlbumMakerInfo makerInfo, + + @Schema(description = "๋‚จ์€ ์—…๋กœ๋“œ ๊ฐ€๋Šฅ ์‚ฌ์ง„ ์ˆ˜", example = "5") + Integer remainingUploadSlots, + + @Schema(description = "์ตœ๊ทผ ์—…๋กœ๋“œ๋œ ์‚ฌ์ง„ ๋ชฉ๋ก (์ตœ๋Œ€ 5๊ฐœ)", implementation = RecentPhotoResponse.class) + List recentPhotos +) implements AlbumEnterResponse { + @Builder + @Schema( + description = "์ตœ๊ทผ ์—…๋กœ๋“œ ์‚ฌ์ง„ ์ •๋ณด DTO", + requiredProperties = { + "thumbnailUrl", + "uploaderName", + "uploaderProfileImage" + } + ) + public record RecentPhotoResponse( + @NotNull + @Schema(description = "์‚ฌ์ง„ ์ธ๋„ค์ผ URL", example = "https://cdn.cheeeese.com/album/1/thumb1.jpg") + String thumbnailUrl, + + @NotNull + @Schema(description = "์—…๋กœ๋“œ ์‚ฌ์šฉ์ž ์ด๋ฆ„", example = "๊น€์น˜์ฆˆ") + String uploaderName, + + @NotNull + @Schema(description = "์—…๋กœ๋“œ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL", example = "https://cdn.cheeeese.com/user/1/profile.jpg") + String uploaderProfileImage + ) {} +} diff --git a/src/main/java/com/cheeeese/album/dto/response/OpenAlbumPageResponse.java b/src/main/java/com/cheeeese/album/dto/response/OpenAlbumPageResponse.java new file mode 100644 index 0000000..4b524ce --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/OpenAlbumPageResponse.java @@ -0,0 +1,35 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema( + description = "์—ด๋ฆฐ ์•จ๋ฒ” ๋ชฉ๋ก ํŽ˜์ด์ง€ ์‘๋‹ต", + requiredProperties = { + "responses", + "listSize", + "isFirst", + "isLast", + "hasNext" + } +) +public record OpenAlbumPageResponse( + @Schema(description = "์—ด๋ฆฐ ์•จ๋ฒ” ๋ชฉ๋ก") + List responses, + + @Schema(description = "ํ˜„์žฌ ํŽ˜์ด์ง€์˜ ์•จ๋ฒ” ์ˆ˜", example = "2") + int listSize, + + @Schema(description = "์ฒซ ํŽ˜์ด์ง€ ์—ฌ๋ถ€", example = "true") + boolean isFirst, + + @Schema(description = "๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€ ์—ฌ๋ถ€", example = "false") + boolean isLast, + + @Schema(description = "๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€", example = "true") + boolean hasNext +) { +} diff --git a/src/main/java/com/cheeeese/album/dto/response/OpenAlbumSummaryResponse.java b/src/main/java/com/cheeeese/album/dto/response/OpenAlbumSummaryResponse.java new file mode 100644 index 0000000..70a80b2 --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/OpenAlbumSummaryResponse.java @@ -0,0 +1,52 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Builder +@Schema( + description = "์—ด๋ฆฐ ์•จ๋ฒ” ์š”์•ฝ ์ •๋ณด", + requiredProperties = { + "code", + "themeEmoji", + "title", + "eventDate", + "makerName", + "currentParticipant", + "participant", + "expiredAt" + } +) +public record OpenAlbumSummaryResponse( + @Schema(description = "์•จ๋ฒ” ์ฝ”๋“œ", example = "1f0ba577-39f3-69b6-abab-455897f404fe") + String code, + + @Schema(description = "์•จ๋ฒ” ํ…Œ๋งˆ ์ด๋ชจ์ง€", example = "U+1F36F") + String themeEmoji, + + @Schema(description = "์•จ๋ฒ” ์ œ๋ชฉ", example = "๋ด„ ์†Œํ’") + String title, + + @Schema(description = "์ด๋ฒคํŠธ ๋‚ ์งœ", example = "2025-05-01") + LocalDate eventDate, + + @Schema(description = "์•จ๋ฒ” ์ƒ์„ฑ์ž ์ด๋ฆ„", example = "์น˜์ฆˆ๋ฉ”์ด์ปค") + String makerName, + + @Schema(description = "ํ˜„์žฌ ์ฐธ์—ฌ์ž ์ˆ˜", example = "8") + int currentParticipant, + + @Schema(description = "์ „์ฒด ์ฐธ์—ฌ์ž ์ˆ˜", example = "10") + int participant, + + @Schema(description = "์•จ๋ฒ” ๋งŒ๋ฃŒ ์˜ˆ์ • ์ผ์‹œ", example = "2025-05-05T12:00:00") + LocalDateTime expiredAt, + + @Schema(description = "์ตœ๊ทผ ์—…๋กœ๋“œ๋œ ์‚ฌ์ง„ ์ธ๋„ค์ผ 3์žฅ", nullable = true) + List recentPhotoThumbnails +) { +} diff --git a/src/main/java/com/cheeeese/album/dto/response/UploadAvailableCountResponse.java b/src/main/java/com/cheeeese/album/dto/response/UploadAvailableCountResponse.java new file mode 100644 index 0000000..614bf8e --- /dev/null +++ b/src/main/java/com/cheeeese/album/dto/response/UploadAvailableCountResponse.java @@ -0,0 +1,25 @@ +package com.cheeeese.album.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema( + description = "์•จ๋ฒ” ์—…๋กœ๋“œ ๊ฐ€๋Šฅ ์‚ฌ์ง„ ์ˆ˜ ์‘๋‹ต DTO", + requiredProperties = { + "availableCount", + "maxPhotoCount", + "currentPhotoCount" + } +) +public record UploadAvailableCountResponse( + @Schema(description = "ํ˜„์žฌ ์•จ๋ฒ”์— ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•œ ์ตœ๋Œ€ ์‚ฌ์ง„ ์ˆ˜", example = "850") + int availableCount, + + @Schema(description = "์•จ๋ฒ”์˜ ์ตœ๋Œ€ ์‚ฌ์ง„ ๊ฐœ์ˆ˜", example = "1000") + int maxPhotoCount, + + @Schema(description = "ํ˜„์žฌ ์—…๋กœ๋“œ๋œ ์‚ฌ์ง„ ์ˆ˜ (UPLOADING, PROCESSING, COMPLETED ํฌํ•จ)", example = "150") + int currentPhotoCount +) { +} diff --git a/src/main/java/com/cheeeese/album/exception/AlbumException.java b/src/main/java/com/cheeeese/album/exception/AlbumException.java new file mode 100644 index 0000000..630fb61 --- /dev/null +++ b/src/main/java/com/cheeeese/album/exception/AlbumException.java @@ -0,0 +1,12 @@ +package com.cheeeese.album.exception; + +import com.cheeeese.album.exception.code.AlbumErrorCode; +import com.cheeeese.global.exception.BusinessException; +import lombok.Getter; + +@Getter +public class AlbumException extends BusinessException { + public AlbumException(AlbumErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/cheeeese/album/exception/code/AlbumErrorCode.java b/src/main/java/com/cheeeese/album/exception/code/AlbumErrorCode.java new file mode 100644 index 0000000..46ddb8b --- /dev/null +++ b/src/main/java/com/cheeeese/album/exception/code/AlbumErrorCode.java @@ -0,0 +1,30 @@ +package com.cheeeese.album.exception.code; + +import com.cheeeese.global.common.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AlbumErrorCode implements BaseCode { + + ALBUM_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค."), + ALBUM_EXPIRED(HttpStatus.BAD_REQUEST, "๋งŒ๋ฃŒ๋œ ์•จ๋ฒ”์ž…๋‹ˆ๋‹ค."), + ALBUM_MAX_PARTICIPANT_REACHED(HttpStatus.BAD_REQUEST, "์•จ๋ฒ”์˜ ์ตœ๋Œ€ ์ฐธ๊ฐ€ ์ธ์›์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."), + USER_IS_BLACKLISTED(HttpStatus.FORBIDDEN, "์•จ๋ฒ” ๊ด€๋ฆฌ์ž์— ์˜ํ•ด ์ ‘๊ทผ์ด ๊ธˆ์ง€๋œ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), + USER_ALREADY_JOINED_CONCURRENTLY(HttpStatus.CONFLICT, "๋™์‹œ์„ฑ ์˜ค๋ฅ˜: ์ด๋ฏธ ์•จ๋ฒ”์— ์ฐธ์—ฌ ์š”์ฒญ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_THEME_EMOJI_NOT_SELECTED(HttpStatus.BAD_REQUEST, "์•จ๋ฒ” ์ธ๋„ค์ผ ์ด๋ชจ์ง€๊ฐ€ ์„ ํƒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."), + ALBUM_TITLE_REQUIRED(HttpStatus.BAD_REQUEST, "ํ–‰์‚ฌ ์ด๋ฆ„์ด ์ž…๋ ฅ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."), + ALBUM_EVENT_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "ํ–‰์‚ฌ ๋‚ ์งœ๊ฐ€ ์ž…๋ ฅ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."), + ALBUM_EVENT_DATE_INVALID(HttpStatus.BAD_REQUEST, "ํ–‰์‚ฌ ๋‚ ์งœ๋Š” ์˜ค๋Š˜ ๋˜๋Š” ๊ณผ๊ฑฐ๋งŒ ์„ ํƒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + ALBUM_INVALID_CAPACITY(HttpStatus.BAD_REQUEST, "์•จ๋ฒ” ์ธ์›์€ ์ตœ์†Œ 1๋ช… ์ด์ƒ ์ตœ๋Œ€ 64๋ช… ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + ALBUM_CREATION_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "์‚ฌ์šฉ์ž๋Š” ์ผ์ฃผ์ผ์— ์ตœ๋Œ€ 3๊ฐœ์˜ ์•จ๋ฒ”๋งŒ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + USER_NOT_PARTICIPANT(HttpStatus.FORBIDDEN, "์‚ฌ์šฉ์ž๋Š” ํ•ด๋‹น ์•จ๋ฒ”์˜ ์ฐธ๊ฐ€์ž๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."), + USER_NOT_MAKER(HttpStatus.FORBIDDEN, "ํ•ด๋‹น ์‚ฌ์šฉ์ž๋Š” MAKER๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."), + MAKER_CANNOT_LEAVE_UNTIL_CLOSED(HttpStatus.BAD_REQUEST, "์•จ๋ฒ” ๋ฉ”์ด์ปค๋Š” ์•จ๋ฒ”์ด ๋‹ซํžŒ ์ƒํƒœ์—์„œ๋งŒ ๋‚˜๊ฐˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + ; + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumMapper.java b/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumMapper.java new file mode 100644 index 0000000..4798d02 --- /dev/null +++ b/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumMapper.java @@ -0,0 +1,172 @@ +package com.cheeeese.album.infrastructure.mapper; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.type.AlbumJoinStatus; +import com.cheeeese.album.dto.response.*; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.album.dto.response.AlbumBest4CutResponse; +import com.cheeeese.user.domain.User; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class AlbumMapper { + + /** + * Album Entity ์ƒ์„ฑ + */ + public static Album toEntity( + Long makerId, + String title, + String code, + String themeEmoji, + int participant, + LocalDate eventDate, + boolean isInfoAvailable, + LocalDateTime expiredAt + ) { + return Album.builder() + .makerId(makerId) + .title(title) + .code(code) + .themeEmoji(themeEmoji) + .participant(participant) + .currentParticipant(1) + .eventDate(eventDate) + .maxPhotoCount(2000) + .currentPhotoCount(0) + .isInfoAvailable(isInfoAvailable) + .expiredAt(expiredAt) + .status(Album.AlbumStatus.ACTIVE) + .build(); + } + + /** + * Album ์ƒ์„ฑ ํ›„, UUID Code ๋ฐœ๊ธ‰ + */ + public static AlbumCreationResponse toCreationResponse(Album album, boolean isFirst) { + return AlbumCreationResponse.builder() + .themeEmoji(album.getThemeEmoji()) + .title(album.getTitle()) + .eventDate(album.getEventDate()) + .createdAt(album.getCreatedAt()) + .isFirst(isFirst) + .currentPhotoCnt(album.getCurrentPhotoCount()) + .code(album.getCode()) + .build(); + } + + /** + * Album ์—”ํ‹ฐํ‹ฐ์™€ Maker User ์ •๋ณด๋ฅผ ์ดˆ๋Œ€์žฅ ์‘๋‹ต DTO๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + */ + public static AlbumInvitationResponse toInvitationResponse(Album album, User user, String profileImage) { + return AlbumInvitationResponse.builder() + .title(album.getTitle()) + .themeEmoji(album.getThemeEmoji()) + .eventDate(album.getEventDate().toString()) + .expiredAt(album.getExpiredAt()) + .makerName(user.getName()) + .makerProfileImage(profileImage) + .isExpired(false) + .build(); + } + + /** + * ์•จ๋ฒ” ๋งŒ๋ฃŒ ์‹œ, ์ตœ์†Œ ์ •๋ณด๋งŒ ๋‹ด์•„ ์‘๋‹ต DTO๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + */ + public static AlbumInvitationResponse toExpiredInvitationResponse(Album album) { + return AlbumInvitationResponse.builder() + .title(album.getTitle()) + .themeEmoji(album.getThemeEmoji()) + .eventDate(album.getEventDate().toString()) + .expiredAt(album.getExpiredAt()) + .makerName(null) + .makerProfileImage(null) + .isExpired(true) + .build(); + } + + public static ExistingEnterResponse toExistingResponse(Album album, AlbumJoinStatus status, AlbumMakerInfo makerInfo) { + return ExistingEnterResponse.builder() + .joinStatus(status) + .title(album.getTitle()) + .themeEmoji(album.getThemeEmoji()) + .eventDate(album.getEventDate().toString()) + .expiredAt(album.getExpiredAt()) + .makerInfo(makerInfo) + .build(); + } + + public static NewEnterResponse toNewResponse( + Album album, + AlbumMakerInfo makerInfo, + int remainingUploadSlots, + List recentPhotos + ) { + return NewEnterResponse.builder() + .joinStatus(AlbumJoinStatus.NEW) + .title(album.getTitle()) + .themeEmoji(album.getThemeEmoji()) + .eventDate(album.getEventDate().toString()) + .expiredAt(album.getExpiredAt()) + .makerInfo(makerInfo) + .remainingUploadSlots(remainingUploadSlots) + .recentPhotos(recentPhotos) + .build(); + } + + public static NewEnterResponse.RecentPhotoResponse toRecentPhotoResponse(Photo photo, String profileImage) { + User uploader = photo.getUser(); + + return NewEnterResponse.RecentPhotoResponse.builder() + .thumbnailUrl(photo.getThumbnailUrl()) + .uploaderName(uploader.getName()) + .uploaderProfileImage(profileImage) + .build(); + } + + /** + * ๋ฉ”์ด์ปค User ์—”ํ‹ฐํ‹ฐ๋ฅผ ํ˜ธ์ŠคํŠธ ์ •๋ณด ์‘๋‹ต DTO๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + */ + public static AlbumMakerInfo toMakerInfo(User user, String profileImage) { + return AlbumMakerInfo.builder() + .makerName(user.getName()) + .makerProfileImage(profileImage) + .build(); + } + + public static UploadAvailableCountResponse toAvailableCountResponse( + int availableCount, + int maxCount, + int currentCount + ){ + return UploadAvailableCountResponse.builder() + .availableCount(availableCount) + .maxPhotoCount(maxCount) + .currentPhotoCount(currentCount) + .build(); + } + + public static AlbumBest4CutResponse toBest4CutResponse(Photo photo, String thumbnailUrl, boolean isLiked) { + return AlbumBest4CutResponse.builder() + .thumbnailUrl(thumbnailUrl) + .likeCnt(photo.getLikesCnt()) + .isLiked(isLiked) + .build(); + } + + public static AlbumInfoResponse toAlbumInfoResponse(Album album, User user) { + return AlbumInfoResponse.builder() + .title(album.getTitle()) + .makerId(album.getMakerId()) + .name(user.getName()) + .themeEmoji(album.getThemeEmoji()) + .participant(album.getParticipant()) + .currentParticipant(album.getCurrentParticipant()) + .eventDate(album.getEventDate()) + .currentPhotoCnt(album.getCurrentPhotoCount()) + .expiredAt(album.getExpiredAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumQueryMapper.java b/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumQueryMapper.java new file mode 100644 index 0000000..6e5ea4a --- /dev/null +++ b/src/main/java/com/cheeeese/album/infrastructure/mapper/AlbumQueryMapper.java @@ -0,0 +1,73 @@ +package com.cheeeese.album.infrastructure.mapper; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.dto.response.ClosedAlbumPageResponse; +import com.cheeeese.album.dto.response.ClosedAlbumSummaryResponse; +import com.cheeeese.album.dto.response.OpenAlbumPageResponse; +import com.cheeeese.album.dto.response.OpenAlbumSummaryResponse; +import com.cheeeese.user.domain.User; +import org.springframework.data.domain.Slice; + +import java.util.List; + +public class AlbumQueryMapper { + + public static OpenAlbumPageResponse toOpenAlbumPageResponse( + List responses, + Slice albums + ){ + return OpenAlbumPageResponse.builder() + .responses(responses) + .listSize(responses.size()) + .isFirst(albums.isFirst()) + .isLast(albums.isLast()) + .hasNext(albums.hasNext()) + .build(); + } + + public static ClosedAlbumPageResponse toClosedAlbumPageResponse( + List responses, + Slice albums + ){ + return ClosedAlbumPageResponse.builder() + .responses(responses) + .listSize(responses.size()) + .isFirst(albums.isFirst()) + .isLast(albums.isLast()) + .hasNext(albums.hasNext()) + .build(); + } + + public static OpenAlbumSummaryResponse toOpenAlbumSummaryResponse( + Album album, + User maker, + List thumbnails + ){ + return OpenAlbumSummaryResponse.builder() + .code(album.getCode()) + .themeEmoji(album.getThemeEmoji()) + .title(album.getTitle()) + .eventDate(album.getEventDate()) + .makerName(maker.getName()) + .currentParticipant(album.getCurrentParticipant()) + .participant(album.getParticipant()) + .expiredAt(album.getExpiredAt()) + .recentPhotoThumbnails(thumbnails.isEmpty() ? null : thumbnails) + .build(); + } + + public static ClosedAlbumSummaryResponse toClosedAlbumSummaryResponse( + Album album, + User maker, + List thumbnails + ) { + return ClosedAlbumSummaryResponse.builder() + .code(album.getCode()) + .title(album.getTitle()) + .makerName(maker.getName()) + .eventDate(album.getEventDate()) + .thumbnails(thumbnails.isEmpty() ? null : thumbnails) + .build(); + } + +} diff --git a/src/main/java/com/cheeeese/album/infrastructure/mapper/UserAlbumMapper.java b/src/main/java/com/cheeeese/album/infrastructure/mapper/UserAlbumMapper.java new file mode 100644 index 0000000..7ee737e --- /dev/null +++ b/src/main/java/com/cheeeese/album/infrastructure/mapper/UserAlbumMapper.java @@ -0,0 +1,50 @@ +package com.cheeeese.album.infrastructure.mapper; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.album.dto.response.AlbumParticipantListResponse; +import com.cheeeese.album.dto.response.AlbumParticipantResponse; +import com.cheeeese.user.domain.User; + +import java.util.List; + +public class UserAlbumMapper { + + public static UserAlbum toEntity(User user, Album album, Role role) { + return UserAlbum.builder() + .user(user) + .album(album) + .role(role) + .isVisible(true) + .build(); + } + + public static AlbumParticipantListResponse.ParticipantInfo toParticipantInfo(User user, String profileImage, Role role, boolean isMe) { + return AlbumParticipantListResponse.ParticipantInfo.builder() + .name(user.getName()) + .role(role) + .profileImage(profileImage) + .isMe(isMe) + .build(); + } + + public static AlbumParticipantResponse toAlbumParticipantResponse( + Album album, + boolean isExpired, + Role myRole, + List participants + ) { + return AlbumParticipantResponse.builder() + .isExpired(isExpired) + .title(album.getTitle()) + .themeEmoji(album.getThemeEmoji()) + .eventDate(album.getEventDate()) + .expiredAt(album.getExpiredAt()) + .maxParticipantCount(album.getParticipant()) + .currentParticipantCount(album.getCurrentParticipant()) + .participants(participants) + .myRole(myRole) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumExpirationRedisRepository.java b/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumExpirationRedisRepository.java new file mode 100644 index 0000000..e02b27c --- /dev/null +++ b/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumExpirationRedisRepository.java @@ -0,0 +1,65 @@ +package com.cheeeese.album.infrastructure.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class AlbumExpirationRedisRepository { + + private static final String EXPIRATION_ZSET_KEY = "expired:album:zset"; + + @Qualifier("cacheRedisTemplate") + private final RedisTemplate cacheRedisTemplate; + + /** + * ZSET์— ์•จ๋ฒ” ID์™€ ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ Score๋กœ ๋“ฑ๋ก + * Score๋Š” Unix Timestamp(๋ฐ€๋ฆฌ์ดˆ)๋กœ ์‚ฌ์šฉ + * @param albumId ์•จ๋ฒ” ๊ณ ์œ  ID + * @param expiredAt ์•จ๋ฒ” ๋งŒ๋ฃŒ ์‹œ๊ฐ (LocalDateTime) + */ + public void registerAlbum(Long albumId, LocalDateTime expiredAt) { + // LocalDateTime์„ Unix Timestamp (ms)๋กœ ๋ณ€ํ™˜ + long expirationMillis = expiredAt.toInstant(ZoneOffset.UTC).toEpochMilli(); + + // ZADD key score member + cacheRedisTemplate.opsForZSet().add(EXPIRATION_ZSET_KEY, albumId.toString(), (double) expirationMillis); + } + + /** + * ํ˜„์žฌ ์‹œ๊ฐ์„ ๊ธฐ์ค€์œผ๋กœ ๋งŒ๋ฃŒ๋œ ์•จ๋ฒ” ID ๋ชฉ๋ก๋งŒ ZSET์—์„œ ์กฐํšŒ (O(log N + k)) + * @return ๋งŒ๋ฃŒ๋œ ์•จ๋ฒ” ID Set + */ + public Set getExpiredAlbumIds() { + // ํ˜„์žฌ ์‹œ๊ฐ์˜ Unix Timestamp (๋ฐ€๋ฆฌ์ดˆ) + long currentTimestamp = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli(); + + // ZRANGEBYSCORE key min max: Score๊ฐ€ 0๋ถ€ํ„ฐ ํ˜„์žฌ ์‹œ๊ฐ๊นŒ์ง€์ธ ๋ชจ๋“  Member๋ฅผ ์กฐํšŒ + Set members = cacheRedisTemplate.opsForZSet().rangeByScore(EXPIRATION_ZSET_KEY, 0, currentTimestamp); + + if (members == null || members.isEmpty()) { + return Collections.emptySet(); + } + + return members.stream() + .map(Object::toString) + .map(Long::valueOf) + .collect(Collectors.toSet()); + } + + /** + * ZSET์—์„œ ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ๋œ ์•จ๋ฒ”์„ ์ œ๊ฑฐ (O(log N)) + * @param albumId ์•จ๋ฒ” ๊ณ ์œ  ID + */ + public void unregister(Long albumId) { + cacheRedisTemplate.opsForZSet().remove(EXPIRATION_ZSET_KEY, albumId.toString()); + } +} diff --git a/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumRepository.java b/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumRepository.java new file mode 100644 index 0000000..dfcdf04 --- /dev/null +++ b/src/main/java/com/cheeeese/album/infrastructure/persistence/AlbumRepository.java @@ -0,0 +1,53 @@ +package com.cheeeese.album.infrastructure.persistence; + +import com.cheeeese.album.domain.Album; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface AlbumRepository extends JpaRepository { + Optional findByCode(String code); + + Optional findByMakerId(Long makerId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Album a SET a.currentParticipant = a.currentParticipant + 1 WHERE a.id = :albumId AND a.currentParticipant < a.participant") + int incrementParticipantCountAtomically(@Param("albumId") Long albumId); + + @Query(""" + SELECT COUNT(a) + FROM Album a + WHERE a.makerId = :userId + AND a.createdAt >= :start + AND a.createdAt < :end + """) + long countByUserAndCreatedAtBetween( + @Param("userId") Long userId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); + + @Modifying(flushAutomatically = true) + @Query("UPDATE Album a SET a.currentPhotoCount = a.currentPhotoCount + :count WHERE a.id = :albumId AND a.currentPhotoCount + :count <= a.maxPhotoCount") + int incrementPhotoCount(@Param("albumId") Long albumId, @Param("count") int count); + + @Modifying(flushAutomatically = true) + @Query("UPDATE Album a SET a.currentPhotoCount = a.currentPhotoCount - :count WHERE a.id = :albumId AND a.currentPhotoCount >= :count") + int decrementPhotoCount(@Param("albumId") Long albumId, @Param("count") int count); + + @Modifying + @Query("UPDATE Album a SET a.status = :status WHERE a.id = :id AND a.status <> :status") + void updateStatus(Long id, Album.AlbumStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT a FROM Album a WHERE a.id = :id") + Album findByIdForUpdate(@Param("id") Long id); + + boolean existsByMakerId(Long makerId); +} diff --git a/src/main/java/com/cheeeese/album/infrastructure/persistence/UserAlbumRepository.java b/src/main/java/com/cheeeese/album/infrastructure/persistence/UserAlbumRepository.java new file mode 100644 index 0000000..526591c --- /dev/null +++ b/src/main/java/com/cheeeese/album/infrastructure/persistence/UserAlbumRepository.java @@ -0,0 +1,86 @@ +package com.cheeeese.album.infrastructure.persistence; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.domain.type.Role; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface UserAlbumRepository extends JpaRepository { + @Query("SELECT ua FROM UserAlbum ua WHERE ua.user.id = :userId AND ua.album.id = :albumId") + Optional findByUserIdAndAlbumId(@Param("userId") Long userId, @Param("albumId") Long albumId); + + @Query("SELECT ua FROM UserAlbum ua JOIN FETCH ua.user WHERE ua.album.id = :albumId") + List findAllByAlbumId(@Param("albumId") Long albumId); + + @Query("SELECT ua FROM UserAlbum ua WHERE ua.album.id = :albumId AND ua.user.id = :userId AND ua.role = :role") + Optional findByAlbumIdAndUserIdAndRole(@Param("albumId") Long albumId, @Param("userId") Long userId, @Param("role") Role role); + + @Query(""" + SELECT a + FROM UserAlbum ua + JOIN ua.album a + WHERE ua.user.id = :userId + AND ua.isVisible = TRUE + AND a.status = :status + AND a.expiredAt > :now + ORDER BY a.expiredAt ASC + """) + Slice findOpenAlbumsByUserId( + @Param("userId") Long userId, + @Param("status") Album.AlbumStatus status, + @Param("now") LocalDateTime now, + Pageable pageable + ); + + @Query(""" + SELECT a + FROM UserAlbum ua + JOIN ua.album a + WHERE ua.user.id = :userId + AND ua.role = :role + AND ua.isVisible = TRUE + AND a.status = :status + AND a.expiredAt > :now + ORDER BY a.expiredAt ASC + """) + Slice findOpenAlbumsByUserIdAndRole( + @Param("userId") Long userId, + @Param("role") Role role, + @Param("status") Album.AlbumStatus status, + @Param("now") LocalDateTime now, + Pageable pageable + ); + + @Query(""" + SELECT a + FROM UserAlbum ua + JOIN ua.album a + WHERE ua.user.id = :userId + AND ua.isVisible = TRUE + AND a.status = :status + ORDER BY a.createdAt DESC + """) + Slice findClosedAlbumsByUserId( + @Param("userId") Long userId, + @Param("status") Album.AlbumStatus status, + Pageable pageable + ); + + @Query(""" + SELECT ua + FROM UserAlbum ua + JOIN FETCH ua.user + WHERE ua.album.id = :albumId + AND ua.role = :role + """) + Optional findMakerByAlbumId(@Param("albumId") Long albumId, @Param("role") Role role); + +} diff --git a/src/main/java/com/cheeeese/album/presentation/AlbumController.java b/src/main/java/com/cheeeese/album/presentation/AlbumController.java new file mode 100644 index 0000000..bd7e563 --- /dev/null +++ b/src/main/java/com/cheeeese/album/presentation/AlbumController.java @@ -0,0 +1,94 @@ +package com.cheeeese.album.presentation; + +import com.cheeeese.album.application.AlbumService; +import com.cheeeese.album.dto.request.AlbumCreationRequest; +import com.cheeeese.album.dto.response.*; +import com.cheeeese.album.presentation.swagger.AlbumSwagger; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.album.dto.response.AlbumBest4CutResponse; +import com.cheeeese.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.cheeeese.global.common.code.SuccessCode.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/album") +public class AlbumController implements AlbumSwagger { + + private final AlbumService albumService; + + @Override + @PostMapping + public CommonResponse createAlbum( + @CurrentUser User user, + @RequestBody @Valid AlbumCreationRequest request + ) { + return CommonResponse.success(ALBUM_CREATE_SUCCESS, albumService.createAlbum(user, request)); + } + + @Override + @GetMapping("/{code}/invitation") + public CommonResponse getInvitationInfo(@PathVariable String code) { + return CommonResponse.success(ALBUM_INVITATION_FETCH_SUCCESS, albumService.getInvitationInfo(code)); + } + + @Override + @PostMapping("/{code}/enter") + public CommonResponse enterAlbum( + @CurrentUser User user, + @PathVariable String code + ) { + AlbumEnterResponse response = albumService.enterAlbum(code, user); + return CommonResponse.success(ALBUM_ENTER_SUCCESS, response); + } + + @Override + @GetMapping("/{code}/available-count") + public CommonResponse getAvailableUploadCount(@PathVariable String code) { + return CommonResponse.success(PHOTO_AVAILABLE_COUNT_FETCH_SUCCESS, albumService.getAvailablePhotoCount(code)); + } + + @Override + @GetMapping("/{code}/participants") + public CommonResponse getAlbumParticipants( + Authentication authentication, + @PathVariable String code + ) { + return CommonResponse.success( + ALBUM_PARTICIPANT_FETCH_SUCCESS, + albumService.getAlbumParticipantList(authentication, code) + ); + } + + @Override + @GetMapping("/{code}/info") + public CommonResponse getAlbumInfo(@PathVariable String code) { + return CommonResponse.success(ALBUM_INFO_GET_SUCCESS, albumService.getAlbumInfo(code)); + } + + @Override + @GetMapping("/{code}/best-4cut") + public CommonResponse> getAlbumBest4Cut( + @CurrentUser User user, + @PathVariable String code + ) { + return CommonResponse.success( + ALBUM_BEST4CUT_GET_SUCCESS, + albumService.getAlbumBest4Cut(user, code) + ); + } + + @Override + @DeleteMapping("/{code}/participants/me") + public CommonResponse leaveAlbum(@CurrentUser User user, @PathVariable String code) { + albumService.leaveAlbum(user, code); + return CommonResponse.success(ALBUM_LEAVE_SUCCESS); + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/album/presentation/AlbumQueryController.java b/src/main/java/com/cheeeese/album/presentation/AlbumQueryController.java new file mode 100644 index 0000000..2994c0f --- /dev/null +++ b/src/main/java/com/cheeeese/album/presentation/AlbumQueryController.java @@ -0,0 +1,67 @@ +package com.cheeeese.album.presentation; + +import com.cheeeese.album.application.AlbumQueryService; +import com.cheeeese.album.dto.response.ClosedAlbumPageResponse; +import com.cheeeese.album.dto.response.OpenAlbumPageResponse; +import com.cheeeese.album.presentation.swagger.AlbumQuerySwagger; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.user.domain.User; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static com.cheeeese.global.common.code.SuccessCode.ALBUM_CLOSED_LIST_FETCH_SUCCESS; +import static com.cheeeese.global.common.code.SuccessCode.ALBUM_MY_OPEN_LIST_FETCH_SUCCESS; +import static com.cheeeese.global.common.code.SuccessCode.ALBUM_OPEN_LIST_FETCH_SUCCESS; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/album") +public class AlbumQueryController implements AlbumQuerySwagger { + + private final AlbumQueryService albumQueryService; + + @Override + @GetMapping("/open") + public CommonResponse getOpenAlbums( + @CurrentUser User user, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "2") @Min(1) @Max(10) int size + ) { + return CommonResponse.success( + ALBUM_OPEN_LIST_FETCH_SUCCESS, + albumQueryService.getOpenAlbums(user, page, size) + ); + } + + @Override + @GetMapping("/open/me") + public CommonResponse getMyOpenAlbums( + @CurrentUser User user, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "2") @Min(1) @Max(10) int size + ) { + return CommonResponse.success( + ALBUM_MY_OPEN_LIST_FETCH_SUCCESS, + albumQueryService.getMyOpenAlbums(user, page, size) + ); + } + + @Override + @GetMapping("/closed") + public CommonResponse getClosedAlbums( + @CurrentUser User user, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "6") @Min(1) @Max(20) int size + ) { + return CommonResponse.success( + ALBUM_CLOSED_LIST_FETCH_SUCCESS, + albumQueryService.getClosedAlbums(user, page, size) + ); + } +} diff --git a/src/main/java/com/cheeeese/album/presentation/swagger/AlbumQuerySwagger.java b/src/main/java/com/cheeeese/album/presentation/swagger/AlbumQuerySwagger.java new file mode 100644 index 0000000..7dada79 --- /dev/null +++ b/src/main/java/com/cheeeese/album/presentation/swagger/AlbumQuerySwagger.java @@ -0,0 +1,71 @@ +package com.cheeeese.album.presentation.swagger; + +import com.cheeeese.album.dto.response.ClosedAlbumPageResponse; +import com.cheeeese.album.dto.response.OpenAlbumPageResponse; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +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.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[๋งˆ์ดํŽ˜์ด์ง€-์•จ๋ฒ”-์กฐํšŒ]", description = "์•จ๋ฒ” ๋ชฉ๋ก ์กฐํšŒ API") +@RequestMapping("/v1/album") +public interface AlbumQuerySwagger { + + @Operation( + summary = "์—ด๋ฆฐ ์•จ๋ฒ” ์ „์ฒด ์กฐํšŒ", + description = "์‚ฌ์šฉ์ž๊ฐ€ ์ฐธ์—ฌ ์ค‘์ธ ๋ชจ๋“  ์—ด๋ฆฐ ์•จ๋ฒ”์„ ๋งŒ๋ฃŒ ์ž„๋ฐ• ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "์—ด๋ฆฐ ์•จ๋ฒ” ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต") + }) + @GetMapping("/open") + CommonResponse getOpenAlbums( + @CurrentUser User user, + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ", schema = @Schema(defaultValue = "0")) + @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", schema = @Schema(defaultValue = "2")) + @RequestParam(defaultValue = "2") @Min(1) @Max(10) int size + ); + + @Operation( + summary = "์—ด๋ฆฐ ์•จ๋ฒ” ์ค‘ ๋‚ด๊ฐ€ ๋งŒ๋“  ์•จ๋ฒ” ์กฐํšŒ", + description = "์‚ฌ์šฉ์ž๊ฐ€ ๋ฉ”์ด์ปค์ธ ์—ด๋ฆฐ ์•จ๋ฒ”์„ ๋งŒ๋ฃŒ ์ž„๋ฐ• ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "๋‚ด๊ฐ€ ๋งŒ๋“  ์—ด๋ฆฐ ์•จ๋ฒ” ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต") + }) + @GetMapping("/open/me") + CommonResponse getMyOpenAlbums( + @CurrentUser User user, + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ", schema = @Schema(defaultValue = "0")) + @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", schema = @Schema(defaultValue = "2")) + @RequestParam(defaultValue = "2") @Min(1) @Max(10) int size + ); + + @Operation( + summary = "๋‹ซํžŒ ์•จ๋ฒ” ๋ชฉ๋ก ์กฐํšŒ", + description = "์‚ฌ์šฉ์ž๊ฐ€ ์ฐธ์—ฌํ–ˆ๋˜ ๋‹ซํžŒ ์•จ๋ฒ”์„ ์ƒ์„ฑ์ผ ์ตœ์‹  ์ˆœ์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "๋‹ซํžŒ ์•จ๋ฒ” ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต") + }) + @GetMapping("/closed") + CommonResponse getClosedAlbums( + @CurrentUser User user, + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ", schema = @Schema(defaultValue = "0")) + @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ", schema = @Schema(defaultValue = "6")) + @RequestParam(defaultValue = "6") @Min(1) @Max(20) int size + ); +} diff --git a/src/main/java/com/cheeeese/album/presentation/swagger/AlbumSwagger.java b/src/main/java/com/cheeeese/album/presentation/swagger/AlbumSwagger.java new file mode 100644 index 0000000..63d6c35 --- /dev/null +++ b/src/main/java/com/cheeeese/album/presentation/swagger/AlbumSwagger.java @@ -0,0 +1,307 @@ +package com.cheeeese.album.presentation.swagger; + +import com.cheeeese.album.dto.request.AlbumCreationRequest; +import com.cheeeese.album.dto.response.*; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.album.dto.response.AlbumBest4CutResponse; +import com.cheeeese.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +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.validation.Valid; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +@Tag(name = "[์•จ๋ฒ”]", description = "์•จ๋ฒ” ๊ด€๋ จ API") +public interface AlbumSwagger { + @Operation( + summary = "์•จ๋ฒ” ์ƒ์„ฑ API", + description = """ + ### RequestBody + --- + `themeEmoji`: ์•จ๋ฒ” ์ธ๋„ค์ผ ์ด๋ชจ์ง€ (String) \n + `title`: ์•จ๋ฒ” ์ด๋ฆ„ (String) \n + `participant`: ์ฐธ์—ฌ์ž ์ˆ˜ (int) \n + `eventDate`: ํ–‰์‚ฌ ๋‚ ์งœ (LocalDate) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์•จ๋ฒ” ์ƒ์„ฑ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse createAlbum( + @CurrentUser User user, + @RequestBody @Valid AlbumCreationRequest request + ); + + @Operation( + summary = "์•จ๋ฒ” ์ดˆ๋Œ€์žฅ ๊ธฐ๋ณธ ์ •๋ณด ํ™•์ธ API (๋กœ๊ทธ์ธ ๋ถˆํ•„์š”)", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ ‘๊ทผ ์ฝ”๋“œ (URL์˜ ์ผ๋ถ€) + +
+ + ### API ์„ค๋ช… + --- + URL/QR์„ ํ†ตํ•ด ์•จ๋ฒ” ์ฝ”๋“œ๋ฅผ ์ „๋‹ฌ ๋ฐ›์•„ ์ œ๋ชฉ, ๋งŒ๋ฃŒ์ผ, ๋ฉ”์ด์ปค ๋“ฑ ๊ธฐ๋ณธ ์ •๋ณด๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ์—ฌ๋ถ€์™€ ๊ด€๊ณ„์—†์ด ํ˜ธ์ถœ ๊ฐ€๋Šฅ + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์ดˆ๋Œ€์žฅ ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ), + @ApiResponse( + responseCode = "404", + description = "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 404, + "message": "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค." + } + """) + ) + ) + }) + CommonResponse getInvitationInfo( + @PathVariable String code + ); + + @Operation( + summary = "์•จ๋ฒ” ์ž…์žฅ ๋ฐ ์ •๋ณด ํ™•์ธ API", + description = """ + ### Path Variable + --- + - `code` : ์•จ๋ฒ” ์ ‘๊ทผ ์ฝ”๋“œ (์ดˆ๋Œ€ URL์˜ ์ผ๋ถ€) + +
+ + ### ์ฒ˜๋ฆฌ ๋กœ์ง + --- + 1. **์ธ์ฆ ๊ฒ€์ฆ** : ๋กœ๊ทธ์ธ ์—ฌ๋ถ€ ํ™•์ธ + 2. **์•จ๋ฒ” ๊ฒ€์ฆ** : ์ „๋‹ฌ๋œ ์ฝ”๋“œ๋กœ ์•จ๋ฒ” ์กด์žฌ ์—ฌ๋ถ€ ๋ฐ ๋งŒ๋ฃŒ ์ƒํƒœ ํ™•์ธ + 3. **์ ‘๊ทผ ๊ถŒํ•œ ๊ฒ€์ฆ** : ๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ์‚ฌ์šฉ์ž ์—ฌ๋ถ€ ํ™•์ธ + 4. **์ •์› ํ™•์ธ** : ์‹ ๊ทœ ์ฐธ์—ฌ์ž์ผ ๊ฒฝ์šฐ, ์ตœ๋Œ€ ์ธ์› ์ดˆ๊ณผ ์—ฌ๋ถ€ ๊ฒ€์‚ฌ + 5. **์ฐธ์—ฌ ๋“ฑ๋ก** : ์ฒซ ์ž…์žฅ ์‹œ `GUEST` ์—ญํ• ๋กœ ๋“ฑ๋กํ•˜๊ณ , ํ˜„์žฌ ์ฐธ์—ฌ ์ธ์› ์ˆ˜ ์ฆ๊ฐ€ + 6. **์‘๋‹ต ๋ฐ˜ํ™˜** : ์ฐธ์—ฌ ์ƒํƒœ(`NEW`, `EXISTING`, `RESTORED`) ๋ฐ ์•จ๋ฒ” ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ์‘๋‹ต ๋ฐ˜ํ™˜ + """ + + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์•จ๋ฒ” ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ ์‹คํŒจ (๋กœ๊ทธ์ธ ํ•„์š”)", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 401, + "message": "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 404, + "message": "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "์•จ๋ฒ” ๋งŒ๋ฃŒ ๋˜๋Š” ์ตœ๋Œ€ ์ฐธ๊ฐ€ ์ธ์› ์ดˆ๊ณผ", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 400, + "message": "์•จ๋ฒ”์ด ๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ ์ตœ๋Œ€ ์ธ์›์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ์‚ฌ์šฉ์ž", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 403, + "message": "์•จ๋ฒ” ๊ด€๋ฆฌ์ž์— ์˜ํ•ด ์ ‘๊ทผ์ด ๊ธˆ์ง€๋œ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค." + } + """) + ) + ) + }) + CommonResponse enterAlbum( + @CurrentUser User user, + @PathVariable String code + ); + + @Operation( + summary = "์—…๋กœ๋“œ ๊ฐ€๋Šฅ ์‚ฌ์ง„ ์ˆ˜ ์กฐํšŒ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ + + ### ๋กœ์ง ์ƒ์„ธ + --- + 1. ์•จ๋ฒ”์˜ ์กด์žฌ, ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ๋ฐ ์‚ฌ์šฉ์ž ์ฐธ๊ฐ€ ๊ถŒํ•œ ํ™•์ธ + 2. `maxPhotoCount` - `currentPhotoCount`๋ฅผ ๊ณ„์‚ฐํ•˜์—ฌ ๋ฐ˜ํ™˜ + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์—…๋กœ๋“œ ๊ฐ€๋Šฅ ์‚ฌ์ง„ ์ˆ˜ ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ), + @ApiResponse( + responseCode = "404", + description = "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 404, + "message": "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค." + } + """) + ) + ) + }) + CommonResponse getAvailableUploadCount( + @PathVariable String code + ); + + @Operation( + summary = "์•จ๋ฒ” ์ฐธ์—ฌ์ž ์ •๋ณด ์ œ๊ณต API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ + + ### ๋กœ์ง ์ƒ์„ธ + --- + 1. ๋งŒ๋ฃŒ๋œ ์•จ๋ฒ”์˜ ๊ฒฝ์šฐ -> title, emoji, eventDate, participant, ์ฐธ์—ฌ์ž ๋ฆฌ์ŠคํŠธ ์ œ๊ณต + 2. ์œ ํšจํ•œ ์•จ๋ฒ”์˜ ๊ฒฝ์šฐ -> ํ˜„์žฌ ์ฐธ์—ฌํ•œ ์ˆ˜, ์ „์ฒด ์•จ๋ฒ” ์ˆ˜, ์ฐธ์—ฌ์ž ๋ฆฌ์ŠคํŠธ ์ œ๊ณต(์ •๋ ฌ O) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์•จ๋ฒ” ์ฐธ์—ฌ์ž ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ), + @ApiResponse( + responseCode = "403", + description = "์ฐธ์—ฌ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ์šฉ์ž์˜ ๊ฒฝ์šฐ", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 403, + "message": "์‚ฌ์šฉ์ž๊ฐ€ ํ•ด๋‹น ์•จ๋ฒ”์˜ ์ฐธ๊ฐ€์ž๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค." + } + """) + ) + ) + }) + CommonResponse getAlbumParticipants( + Authentication authentication, + @PathVariable String code + ); + + @Operation( + summary = "์•จ๋ฒ” ์ •๋ณด ์กฐํšŒ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ (String) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์•จ๋ฒ” ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse getAlbumInfo( + @PathVariable String code + ); + + @Operation( + summary = "์•จ๋ฒ” ๋‚ด ๋ฒ ์ŠคํŠธ์ปท (์ข‹์•„์š”์ˆœ ์‚ฌ์ง„ 4๊ฐœ) ์กฐํšŒ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ (String) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์•จ๋ฒ” ๋‚ด ๋ฒ ์ŠคํŠธ์ปท ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse> getAlbumBest4Cut( + @CurrentUser User user, + @PathVariable String code + ); + + @Operation( + summary = "์•จ๋ฒ” ๋‚˜๊ฐ€๊ธฐ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ (String) \n + + ### ๋กœ์ง ์ƒ์„ธ + --- + 1. MAKER์ผ ๊ฒฝ์šฐ, ๋งŒ๋ฃŒ๋œ ์•จ๋ฒ”๋งŒ ๋‚˜๊ฐ€๊ธฐ ๊ฐ€๋Šฅ + 2. GUEST๋Š” ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ์ƒ๊ด€์—†์ด ๋‚˜๊ฐ€๊ธฐ ๊ฐ€๋Šฅ + 3. ๋‚˜๊ฐ„ ์•จ๋ฒ”์€ ๋งˆ์ด ํŽ˜์ด์ง€์—์„œ ๋ณด์ด์ง€ ์•Š์Œ + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์•จ๋ฒ” ๋‚˜๊ฐ€๊ธฐ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse leaveAlbum( + @CurrentUser User user, + @PathVariable String code + ); +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/auth/application/AuthService.java b/src/main/java/com/cheeeese/auth/application/AuthService.java new file mode 100644 index 0000000..a2b4834 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/application/AuthService.java @@ -0,0 +1,114 @@ +package com.cheeeese.auth.application; + +import com.cheeeese.auth.application.validator.AuthValidator; +import com.cheeeese.auth.dto.request.AuthReissueRequest; +import com.cheeeese.auth.dto.response.AuthReissueResponse; +import com.cheeeese.auth.dto.response.AuthExchangeResponse; +import com.cheeeese.auth.exception.AuthException; +import com.cheeeese.auth.exception.code.AuthErrorCode; +import com.cheeeese.auth.infrastructure.mapper.AuthMapper; +import com.cheeeese.global.security.jwt.JwtProvider; +import com.cheeeese.global.util.RedisUtil; +import com.cheeeese.auth.domain.RefreshToken; +import com.cheeeese.auth.infrastructure.persistence.RefreshTokenRepository; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.exception.UserException; +import com.cheeeese.user.exception.code.UserErrorCode; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final ObjectMapper objectMapper; + private final RedisUtil redisUtil; + private final AuthValidator authValidator; + private final TokenBlacklistService tokenBlacklistService; + + public AuthExchangeResponse exchangeTempCode(String code) { + Map tokens = getTokenFromTempCode(code); + User user = getUserFromToken(tokens.get("accessToken")); + + redisUtil.deleteValue("auth:" + code); + + log.info("[AUTH] Token Exchange Success | user_id={}", user.getId()); + + return AuthMapper.toExchangeResponse(tokens.get("accessToken"), tokens.get("refreshToken"), user); + } + + @Transactional + public AuthReissueResponse reissueToken(AuthReissueRequest request) { + jwtProvider.validateToken(request.refreshToken()); + + User user = getUserFromToken(request.refreshToken()); + + RefreshToken savedToken = authValidator.validateRefreshToken(user.getId(), request.refreshToken()); + + String newAccessToken = jwtProvider.createAccessToken(user.getId()); + String newRefreshToken = jwtProvider.createRefreshToken(user.getId()); + + savedToken.updateRefreshToken(newRefreshToken); + refreshTokenRepository.save(savedToken); + + log.info("[AUTH] Token Reissue Success | user_id={}", user.getId()); + return AuthMapper.toReissueResponse(newAccessToken, newRefreshToken); + } + + @Transactional + public void logout(String accessToken) { + User user = getUserFromToken(accessToken); + Claims claims = jwtProvider.getClaims(accessToken); + + RefreshToken refreshToken = authValidator.getRefreshTokenByUserId(user.getId()); + refreshTokenRepository.delete(refreshToken); + + long expiration = claims.getExpiration().getTime() - System.currentTimeMillis(); + + if (expiration <= 0) { + expiration = 1000; + } + + log.info("[AUTH] Logout Success | user_id={}", user.getId()); + tokenBlacklistService.addBlackList(accessToken, "logout", Duration.ofMillis(expiration)); + } + + private Map getTokenFromTempCode(String code) { + String key = "auth:" + code; + String json = redisUtil.getValue(key); + + if (json == null) { + throw new AuthException(AuthErrorCode.INVALID_AUTH_CODE); + } + + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + redisUtil.deleteValue(key); + throw new AuthException(AuthErrorCode.TOKEN_PARSE_FAILED); + } + } + + private User getUserFromToken(String token) { + Claims claims = jwtProvider.getClaims(token); + String userId = claims.getSubject(); + + return userRepository.findById(Long.valueOf(userId)) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/cheeeese/auth/application/TokenBlacklistService.java b/src/main/java/com/cheeeese/auth/application/TokenBlacklistService.java new file mode 100644 index 0000000..04eed56 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/application/TokenBlacklistService.java @@ -0,0 +1,30 @@ +package com.cheeeese.auth.application; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +public class TokenBlacklistService { + + private final RedisTemplate redisTemplate; + private static final String BLACKLIST_PREFIX = "accessTokenBlackList:"; + + public TokenBlacklistService( + @Qualifier("tokenRedisTemplate") RedisTemplate redisTemplate + ) { + this.redisTemplate = redisTemplate; + } + + public void addBlackList(String token, Object o, Duration expiration) { + String key = BLACKLIST_PREFIX + token; + redisTemplate.opsForValue().set(key, o, expiration); + } + + public boolean isBlackListed(String token) { + String key = BLACKLIST_PREFIX + token; + return redisTemplate.hasKey(key); + } +} diff --git a/src/main/java/com/cheeeese/auth/application/validator/AuthValidator.java b/src/main/java/com/cheeeese/auth/application/validator/AuthValidator.java new file mode 100644 index 0000000..a9ec329 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/application/validator/AuthValidator.java @@ -0,0 +1,29 @@ +package com.cheeeese.auth.application.validator; + +import com.cheeeese.auth.domain.RefreshToken; +import com.cheeeese.auth.exception.AuthException; +import com.cheeeese.auth.exception.code.AuthErrorCode; +import com.cheeeese.auth.infrastructure.persistence.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AuthValidator { + + private final RefreshTokenRepository refreshTokenRepository; + + public RefreshToken getRefreshTokenByUserId(Long userId) { + return refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND)); + } + + public RefreshToken validateRefreshToken(Long userId, String refreshToken) { + RefreshToken savedToken = getRefreshTokenByUserId(userId); + + if (!savedToken.getRefreshToken().equals(refreshToken)) { + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + return savedToken; + } +} diff --git a/src/main/java/com/cheeeese/auth/domain/RefreshToken.java b/src/main/java/com/cheeeese/auth/domain/RefreshToken.java new file mode 100644 index 0000000..8960e8a --- /dev/null +++ b/src/main/java/com/cheeeese/auth/domain/RefreshToken.java @@ -0,0 +1,32 @@ +package com.cheeeese.auth.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@NoArgsConstructor +@RedisHash(value = "refreshToken") +public class RefreshToken { + + @Id + private Long userId; + + private String refreshToken; + + @TimeToLive + private Long expiration = 604800L; + + @Builder + private RefreshToken(Long userId, String refreshToken) { + this.userId = userId; + this.refreshToken = refreshToken; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/cheeeese/auth/dto/request/AuthReissueRequest.java b/src/main/java/com/cheeeese/auth/dto/request/AuthReissueRequest.java new file mode 100644 index 0000000..7847205 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/dto/request/AuthReissueRequest.java @@ -0,0 +1,20 @@ +package com.cheeeese.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema( + description = "token ์žฌ๋ฐœ๊ธ‰", + requiredProperties = { + "refreshToken" + } +) +public record AuthReissueRequest( + @Schema( + description = "์œ ํšจํ•œ refreshToken", + example = "eyJh.eqi57hK" + ) + String refreshToken +) { +} diff --git a/src/main/java/com/cheeeese/auth/dto/response/AuthExchangeResponse.java b/src/main/java/com/cheeeese/auth/dto/response/AuthExchangeResponse.java new file mode 100644 index 0000000..4c46721 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/dto/response/AuthExchangeResponse.java @@ -0,0 +1,56 @@ +package com.cheeeese.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema( + description = "token ๊ตํ™˜ ์‘๋‹ต", + requiredProperties = { + "accessToken", + "refreshToken", + "isOnboarded", + "userId", + "name", + "email" + } +) + +public record AuthExchangeResponse( + @Schema( + description = "accessToken", + example = "eyJh.eqi57hK" + ) + String accessToken, + + @Schema( + description = "refreshToken", + example = "eyJh.eqi57hK" + ) + String refreshToken, + + @Schema( + description = "์˜จ๋ณด๋”ฉ ์—ฌ๋ถ€", + example = "true" + ) + boolean isOnboarded, + + @Schema( + description = "์‚ฌ์šฉ์ž ๊ณ ์œ  ์‹๋ณ„ ID", + example = "1" + ) + Long userId, + + @Schema( + description = "์‚ฌ์šฉ์ž ์ด๋ฆ„", + example = "์ฃผ์ •๋นˆ" + ) + String name, + + @Schema( + description = "์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ", + example = "yui2507@naver.com" + ) + String email +) { +} diff --git a/src/main/java/com/cheeeese/auth/dto/response/AuthReissueResponse.java b/src/main/java/com/cheeeese/auth/dto/response/AuthReissueResponse.java new file mode 100644 index 0000000..c105f94 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/dto/response/AuthReissueResponse.java @@ -0,0 +1,27 @@ +package com.cheeeese.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema( + description = "token ์žฌ๋ฐœ๊ธ‰", + requiredProperties = { + "accessToken", + "refreshToken" + } +) +public record AuthReissueResponse( + @Schema( + description = "์ƒˆ๋กœ ๋ฐœ๊ธ‰๋œ accessToken", + example = "eyJh.eqi57hK" + ) + String accessToken, + + @Schema( + description = "์ƒˆ๋กœ ๋ฐœ๊ธ‰๋œ refreshToken", + example = "eyJh.eqi57hK" + ) + String refreshToken +) { +} diff --git a/src/main/java/com/cheeeese/auth/exception/AuthException.java b/src/main/java/com/cheeeese/auth/exception/AuthException.java new file mode 100644 index 0000000..0590f0d --- /dev/null +++ b/src/main/java/com/cheeeese/auth/exception/AuthException.java @@ -0,0 +1,12 @@ +package com.cheeeese.auth.exception; + +import com.cheeeese.auth.exception.code.AuthErrorCode; +import com.cheeeese.global.exception.BusinessException; +import lombok.Getter; + +@Getter +public class AuthException extends BusinessException { + public AuthException(AuthErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/cheeeese/auth/exception/code/AuthErrorCode.java b/src/main/java/com/cheeeese/auth/exception/code/AuthErrorCode.java new file mode 100644 index 0000000..d9667e8 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/exception/code/AuthErrorCode.java @@ -0,0 +1,22 @@ +package com.cheeeese.auth.exception.code; + +import com.cheeeese.global.common.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements BaseCode { + + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + TOKEN_PARSE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ํ† ํฐ ํŒŒ์‹ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + INVALID_AUTH_CODE(HttpStatus.BAD_REQUEST, "์œ ํšจํ•˜์ง€ ์•Š์€ ์ธ์ฆ ์ฝ”๋“œ ์ž…๋‹ˆ๋‹ค."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "๋งŒ๋ฃŒ๋œ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + LOGGED_OUT_TOKEN(HttpStatus.UNAUTHORIZED, "์ด๋ฏธ ๋กœ๊ทธ์•„์›ƒ๋œ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cheeeese/auth/infrastructure/mapper/AuthMapper.java b/src/main/java/com/cheeeese/auth/infrastructure/mapper/AuthMapper.java new file mode 100644 index 0000000..77ecd28 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/infrastructure/mapper/AuthMapper.java @@ -0,0 +1,26 @@ +package com.cheeeese.auth.infrastructure.mapper; + +import com.cheeeese.auth.dto.response.AuthReissueResponse; +import com.cheeeese.auth.dto.response.AuthExchangeResponse; +import com.cheeeese.user.domain.User; + +public class AuthMapper { + + public static AuthExchangeResponse toExchangeResponse(String accessToken, String refreshToken, User user) { + return AuthExchangeResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .isOnboarded(user.isOnboarded()) + .userId(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .build(); + } + + public static AuthReissueResponse toReissueResponse(String accessToken, String refreshToken) { + return AuthReissueResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/auth/infrastructure/mapper/RefreshTokenMapper.java b/src/main/java/com/cheeeese/auth/infrastructure/mapper/RefreshTokenMapper.java new file mode 100644 index 0000000..ce68988 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/infrastructure/mapper/RefreshTokenMapper.java @@ -0,0 +1,14 @@ +package com.cheeeese.auth.infrastructure.mapper; + +import com.cheeeese.auth.domain.RefreshToken; +import com.cheeeese.user.domain.User; + +public class RefreshTokenMapper { + + public static RefreshToken toRefreshToken(User user, String refreshToken) { + return RefreshToken.builder() + .userId(user.getId()) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/auth/infrastructure/persistence/RefreshTokenRepository.java b/src/main/java/com/cheeeese/auth/infrastructure/persistence/RefreshTokenRepository.java new file mode 100644 index 0000000..3d29490 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/infrastructure/persistence/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package com.cheeeese.auth.infrastructure.persistence; + +import com.cheeeese.auth.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends CrudRepository { + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/cheeeese/auth/presentation/AuthController.java b/src/main/java/com/cheeeese/auth/presentation/AuthController.java new file mode 100644 index 0000000..c56fafc --- /dev/null +++ b/src/main/java/com/cheeeese/auth/presentation/AuthController.java @@ -0,0 +1,43 @@ +package com.cheeeese.auth.presentation; + +import com.cheeeese.auth.application.AuthService; +import com.cheeeese.auth.dto.request.AuthReissueRequest; +import com.cheeeese.auth.dto.response.AuthReissueResponse; +import com.cheeeese.auth.dto.response.AuthExchangeResponse; +import com.cheeeese.auth.presentation.swagger.AuthSwagger; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.security.jwt.JwtProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import static com.cheeeese.global.common.code.SuccessCode.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/auth") +public class AuthController implements AuthSwagger { + + private final JwtProvider jwtProvider; + private final AuthService authService; + + @Override + @GetMapping("/exchange") + public CommonResponse exchangeTempCode(@RequestParam String code) { + return CommonResponse.success(TOKEN_EXCHANGE_SUCCESS, authService.exchangeTempCode(code)); + } + + @Override + @PostMapping("/reissue") + public CommonResponse reissueToken(@RequestBody @Valid AuthReissueRequest request) { + return CommonResponse.success(TOKEN_REISSUE_SUCCESS, authService.reissueToken(request)); + } + + @Override + @PostMapping("/logout") + public CommonResponse logout(HttpServletRequest request) { + authService.logout(jwtProvider.resolveToken(request)); + return CommonResponse.success(LOGOUT_SUCCESS); + } +} diff --git a/src/main/java/com/cheeeese/auth/presentation/swagger/AuthSwagger.java b/src/main/java/com/cheeeese/auth/presentation/swagger/AuthSwagger.java new file mode 100644 index 0000000..debdde0 --- /dev/null +++ b/src/main/java/com/cheeeese/auth/presentation/swagger/AuthSwagger.java @@ -0,0 +1,65 @@ +package com.cheeeese.auth.presentation.swagger; + +import com.cheeeese.auth.dto.request.AuthReissueRequest; +import com.cheeeese.auth.dto.response.AuthExchangeResponse; +import com.cheeeese.auth.dto.response.AuthReissueResponse; +import com.cheeeese.global.common.CommonResponse; +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 jakarta.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[์‚ฌ์šฉ์ž ์ธ์ฆ/์ธ๊ฐ€]", description = "์‚ฌ์šฉ์ž ๋กœ๊ทธ์•„์›ƒ, ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ๊ด€๋ จ API") +public interface AuthSwagger { + @Operation( + summary = "์‚ฌ์šฉ์ž ํ† ํฐ ๋ฐ ์ •๋ณด ์กฐํšŒ API", + description = """ + ### RequestBody + --- + `code`: ๋ฐœ๊ธ‰๋œ ์ž„์‹œ ์ฝ”๋“œ + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์šฉ์ž ํ† ํฐ ๋ฐ ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse exchangeTempCode( + @RequestParam String code + ); + + @Operation( + summary = "token ์žฌ๋ฐœ๊ธ‰ API", + description = """ + ### RequestBody + --- + `refreshToken`: ์œ ํšจํ•œ refreshToken + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "token ์žฌ๋ฐœ๊ธ‰์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse reissueToken( + @RequestBody @Valid AuthReissueRequest request + ); + + @Operation( + summary = "์‚ฌ์šฉ์ž ๋กœ๊ทธ์•„์›ƒ API", + description = "์‚ฌ์šฉ์ž ๋กœ๊ทธ์•„์›ƒ์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์šฉ์ž ๋กœ๊ทธ์•„์›ƒ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse logout(HttpServletRequest request); +} diff --git a/src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutService.java b/src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutService.java new file mode 100644 index 0000000..8a074ee --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/application/Cheese4cutService.java @@ -0,0 +1,183 @@ +package com.cheeeese.cheese4cut.application; + +import com.cheeeese.album.application.validator.AlbumValidator; +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.album.exception.AlbumException; +import com.cheeeese.album.exception.code.AlbumErrorCode; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.cheese4cut.application.validator.Cheese4cutValidator; +import com.cheeeese.cheese4cut.domain.Cheese4cut; +import com.cheeeese.cheese4cut.dto.request.Cheese4cutFixedRequest; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutFinalResponse; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutPreviewResponse; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutResponse; +import com.cheeeese.cheese4cut.exception.Cheese4cutException; +import com.cheeeese.cheese4cut.exception.code.Cheese4cutErrorCode; +import com.cheeeese.cheese4cut.infrastructure.mapper.Cheese4cutMapper; +import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutRepository; +import com.cheeeese.global.security.CustomUserDetails; +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class Cheese4cutService { + + private final Cheese4cutRepository cheese4cutRepository; + private final AlbumRepository albumRepository; + private final PhotoRepository photoRepository; + private final PhotoLikesRepository photoLikesRepository; + private final UserAlbumRepository userAlbumRepository; + private final AlbumValidator albumValidator; + private final Cheese4cutValidator cheese4cutValidator; + private final CdnUrlResolver cdnUrlResolver; + + @Transactional(readOnly = true) + public Cheese4cutResponse getCheese4cutByAlbumCode(Authentication authentication, String code) { + Album album = albumRepository.findByCode(code) + .orElseThrow(() -> new AlbumException(AlbumErrorCode.ALBUM_NOT_FOUND)); + + Optional cheese4cutOptional = cheese4cutRepository.findByAlbumId(album.getId()); + + if (cheese4cutOptional.isPresent()) { + Cheese4cut cheese4cut = cheese4cutOptional.get(); + + List photos = cheese4cut.getPhotos().stream() + .map(p -> Cheese4cutMapper.toFinalPhotoInfo( + p.getPhotoId(), + cdnUrlResolver.resolveThumbnail(p.getThumbnailImageUrl()), + p.getPhotoRank() + )) + .toList(); + + return Cheese4cutMapper.toFinalResponse(photos); + } + + User currentUser = extractUser(authentication); + + Role myRole = null; + Long currentUserId = currentUser != null ? currentUser.getId() : null; + + if (currentUserId != null) { + Optional myUserAlbumOptional = userAlbumRepository.findByUserIdAndAlbumId(currentUserId, album.getId()); + + if (myUserAlbumOptional.isPresent()) { + myRole = myUserAlbumOptional.get().getRole(); + } + } + + return getPreviewResponse(album.getId(), album.getParticipant(), myRole); + } + + private Cheese4cutResponse getPreviewResponse(Long albumId, int participant, Role myRole) { + List topPhotoIds = photoRepository.findTop4CompletedPhotoIdsByLikes( + albumId, + PhotoStatus.COMPLETED, + PageRequest.of(0, 4) + ); + + if (topPhotoIds.size() < 4) { + throw new Cheese4cutException(Cheese4cutErrorCode.INSUFFICIENT_COUNT_FOR_CHEESE4CUT); + } + + List orderedPhotos = getOrderedPhotos(topPhotoIds); + + long uniqueLikesCount = photoLikesRepository.countDistinctUserIdsByPhotoIds(topPhotoIds); + + List resolvedPhotoInfos = + IntStream.range(0, orderedPhotos.size()) + .mapToObj(index -> { + Photo p = orderedPhotos.get(index); + return Cheese4cutMapper.toPreviewPhotoInfo( + p.getId(), cdnUrlResolver.resolveThumbnail(p.getThumbnailUrl()), index+1 + ); + }) + .toList(); + + return Cheese4cutMapper.toPreviewResponse(resolvedPhotoInfos, uniqueLikesCount, participant, myRole); + } + + public void finalizeCheese4cut(User user, String code, Cheese4cutFixedRequest request) { + Album album = albumValidator.validateAlbumCode(code); + + if (album.isExpired()) { + throw new AlbumException(AlbumErrorCode.ALBUM_EXPIRED); + } + + cheese4cutValidator.validateUserIsMaker(album, user); + + if (cheese4cutRepository.findByAlbumId(album.getId()).isPresent()) { + throw new Cheese4cutException(Cheese4cutErrorCode.CHEESE4CUT_ALREADY_FINALIZED); + } + + cheese4cutValidator.validateFinalizePhotos(album, request.photoIds()); + + List orderedPhotos = + photoRepository.findAllByIdInOrderByLikesDescCreatedDesc(request.photoIds()); + + if (orderedPhotos.size() != request.photoIds().size()) + throw new Cheese4cutException(Cheese4cutErrorCode.INSUFFICIENT_COUNT_FOR_CHEESE4CUT); + + cheese4cutRepository.save(Cheese4cutMapper.toEntity(album, orderedPhotos)); + + log.info("[Cheese4cut] Cheese4cut Finalized | album_id={} maker_id={} photo_ids={} finalized_at={}", + album.getId(), user.getId(), request.photoIds(), LocalDateTime.now()); + } + + private List getOrderedPhotos(List photoIds) { + List photos = photoRepository.findAllByIdIn(photoIds); + + Map photoMap = photos.stream() + .collect(Collectors.toMap(Photo::getId, Function.identity())); + + List orderedPhotos = photoIds.stream() + .map(photoId -> { + Photo photo = photoMap.get(photoId); + if (photo == null) { + throw new Cheese4cutException(Cheese4cutErrorCode.INSUFFICIENT_COUNT_FOR_CHEESE4CUT); + } + return photo; + }) + .toList(); + + if (orderedPhotos.size() != photoIds.size()) { + throw new Cheese4cutException(Cheese4cutErrorCode.INSUFFICIENT_COUNT_FOR_CHEESE4CUT); + } + return orderedPhotos; + } + + private User extractUser(Authentication authentication) { + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomUserDetails customUserDetails) { + return customUserDetails.getUser(); + } + return null; + } +} diff --git a/src/main/java/com/cheeeese/cheese4cut/application/validator/Cheese4cutValidator.java b/src/main/java/com/cheeeese/cheese4cut/application/validator/Cheese4cutValidator.java new file mode 100644 index 0000000..ce71eec --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/application/validator/Cheese4cutValidator.java @@ -0,0 +1,55 @@ +package com.cheeeese.cheese4cut.application.validator; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.album.exception.AlbumException; +import com.cheeeese.album.exception.code.AlbumErrorCode; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.cheese4cut.exception.Cheese4cutException; +import com.cheeeese.cheese4cut.exception.code.Cheese4cutErrorCode; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.exception.PhotoException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class Cheese4cutValidator { + + private final UserAlbumRepository userAlbumRepository; + private final PhotoRepository photoRepository; + + public void validateUserIsMaker(Album album, User user) { + boolean isMaker = userAlbumRepository.findByAlbumIdAndUserIdAndRole(album.getId(), user.getId(), Role.MAKER).isPresent(); + if (!isMaker) { + throw new AlbumException(AlbumErrorCode.USER_NOT_MAKER); + } + } + + public void validateFinalizePhotos(Album album, List photoIds) { + if (new HashSet<>(photoIds).size() != 4) { + throw new Cheese4cutException(Cheese4cutErrorCode.CHEESE4CUT_INVALID_PHOTO_COUNT); + } + + List photos = photoRepository.findAllById(photoIds); + + if (photos.size() != 4) { + throw new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND); + } + + boolean allValid = photos.stream().allMatch(photo -> + photo.getAlbum().getId().equals(album.getId()) && photo.getStatus() == PhotoStatus.COMPLETED + ); + + if (!allValid) { + throw new Cheese4cutException(Cheese4cutErrorCode.CHEESE4CUT_PHOTO_INVALID_STATUS_OR_ALBUM); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/cheese4cut/domain/Cheese4cut.java b/src/main/java/com/cheeeese/cheese4cut/domain/Cheese4cut.java new file mode 100644 index 0000000..b169ed9 --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/domain/Cheese4cut.java @@ -0,0 +1,47 @@ +package com.cheeeese.cheese4cut.domain; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.global.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "cheese4cut") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Cheese4cut extends BaseEntity { + + @Id + @Column(name = "cheese4cut_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "album_id", nullable = false, unique = true) + private Album album; + + @OneToMany(mappedBy = "cheese4cut", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("photoRank ASC") + private List photos = new ArrayList<>(); + + + @Builder + private Cheese4cut(Album album, List photos) { + this.album = album; + if (photos != null) { + photos.forEach(this::addPhoto); + } + } + + private void addPhoto(Cheese4cutPhoto photo) { + photo.assignToCheese4cut(this); + this.photos.add(photo); + } +} + diff --git a/src/main/java/com/cheeeese/cheese4cut/domain/Cheese4cutPhoto.java b/src/main/java/com/cheeeese/cheese4cut/domain/Cheese4cutPhoto.java new file mode 100644 index 0000000..274998c --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/domain/Cheese4cutPhoto.java @@ -0,0 +1,48 @@ +package com.cheeeese.cheese4cut.domain; + +import com.cheeeese.global.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "cheese4cut_photo") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Cheese4cutPhoto extends BaseEntity { + + @Id + @Column(name = "cheese4cut_photo_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cheese4cut_id", nullable = false) + private Cheese4cut cheese4cut; + + @Column(name = "photo_id", nullable = false) + private Long photoId; + + @Column(name = "image_url", columnDefinition = "TEXT", nullable = false) + private String imageUrl; + + @Column(name = "thumbnail_image_url", columnDefinition = "TEXT") + private String thumbnailImageUrl; + + @Column(name = "photo_rank", nullable = false) + private int photoRank; + + @Builder + private Cheese4cutPhoto(Long photoId, String imageUrl, String thumbnailImageUrl, int photoRank) { + this.photoId = photoId; + this.imageUrl = imageUrl; + this.thumbnailImageUrl = thumbnailImageUrl; + this.photoRank = photoRank; + } + + void assignToCheese4cut(Cheese4cut cheese4cut) { + this.cheese4cut = cheese4cut; + } +} diff --git a/src/main/java/com/cheeeese/cheese4cut/dto/request/Cheese4cutFixedRequest.java b/src/main/java/com/cheeeese/cheese4cut/dto/request/Cheese4cutFixedRequest.java new file mode 100644 index 0000000..604761a --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/dto/request/Cheese4cutFixedRequest.java @@ -0,0 +1,19 @@ +package com.cheeeese.cheese4cut.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "์น˜์ฆˆ๋„ค์ปท ์ˆ˜๋™ ํ™•์ • ์š”์ฒญ DTO") +public record Cheese4cutFixedRequest( + @NotEmpty + @Size(min = 4, max = 4, message = "4์žฅ์˜ ์‚ฌ์ง„ ID๋ฅผ ์„ ํƒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Schema(description = "์„ ํƒ๋œ ์‚ฌ์ง„ ID ๋ชฉ๋ก (์ •ํ™•ํžˆ 4๊ฐœ)", example = "[101, 105, 122, 140]") + List<@NotNull Long> photoIds +) { +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutFinalResponse.java b/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutFinalResponse.java new file mode 100644 index 0000000..9e91bc7 --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutFinalResponse.java @@ -0,0 +1,42 @@ +package com.cheeeese.cheese4cut.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema( + description = "์น˜์ฆˆ๋„ค์ปท ํ™•์ • ์™„๋ฃŒ ์‘๋‹ต DTO (์„ ์ •๋œ ์‚ฌ์ง„ 4์žฅ)", + requiredProperties = { + "isFinalized", + "photos" + } +) +public record Cheese4cutFinalResponse( + @Schema(description = "ํ™•์ • ์—ฌ๋ถ€ (ํ•ญ์ƒ true)", example = "true") + boolean isFinalized, + + @Schema(description = "ํ™•์ •๋œ ์‚ฌ์ง„ ์ •๋ณด ๋ชฉ๋ก (์ •ํ™•ํžˆ 4๊ฐœ)") + List photos +) implements Cheese4cutResponse { + @Builder + @Schema( + description = "ํ™•์ •๋œ ์‚ฌ์ง„ ์ •๋ณด", + requiredProperties = { + "photoId", + "imageUrl", + "photoRank" + } + ) + public record FinalPhotoInfo( + @Schema(description = "์‚ฌ์ง„ ID", example = "101") + Long photoId, + + @Schema(description = "์‚ฌ์ง„ ์ธ๋„ค์ผ URL", example = "https://cdn.cheeeese.com/album/1/original/101.jpg") + String imageUrl, + + @Schema(description = "์„ ์ • ์ˆœ์œ„ (1~4)", example = "1") + int photoRank + ) {} +} diff --git a/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutPreviewResponse.java b/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutPreviewResponse.java new file mode 100644 index 0000000..9f5b329 --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutPreviewResponse.java @@ -0,0 +1,55 @@ +package com.cheeeese.cheese4cut.dto.response; + +import com.cheeeese.album.domain.type.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema( + description = "์น˜์ฆˆ๋„ค์ปท ํ™•์ • ์ „ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์‘๋‹ต DTO (์ข‹์•„์š” TOP 4)", + requiredProperties = { + "isFinalized", + "previewPhotos", + "uniqueLikesCount", + "participant" + } +) +public record Cheese4cutPreviewResponse( + @Schema(description = "ํ™•์ • ์—ฌ๋ถ€ (ํ•ญ์ƒ false)", example = "false") + boolean isFinalized, + + @Schema(description = "๋ฏธ๋ฆฌ๋ณด๊ธฐ ์‚ฌ์ง„ ๋ชฉ๋ก (์ตœ๋Œ€ 4๊ฐœ)") + List previewPhotos, + + @Schema(description = "4๊ฐœ ์‚ฌ์ง„์— ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ ์œ ๋‹ˆํฌํ•œ ์ฐธ์—ฌ์ž ์ˆ˜", example = "5") + int uniqueLikesCount, + + @Schema(description = "์ „์ฒด ์ฐธ์—ฌ์ž ์ˆ˜", example = "6") + int participant, + + @Schema(description = "์‚ฌ์šฉ์ž ์—ญํ• ", example = "MAKER") + Role myRole + +) implements Cheese4cutResponse { + @Builder + @Schema( + description = "๋ฏธ๋ฆฌ๋ณด๊ธฐ ์‚ฌ์ง„ ์ •๋ณด DTO", + requiredProperties = { + "photoId", + "imageUrl", + "photoRank" + } + ) + public record PreviewPhotoInfo( + @Schema(description = "์‚ฌ์ง„ ID", example = "101") + Long photoId, + + @Schema(description = "์‚ฌ์ง„ ์ธ๋„ค์ผ URL", example = "https://cdn.cheeeese.com/album/1/original/101.jpg") + String imageUrl, + + @Schema(description = "์„ ์ • ์ˆœ์œ„ (1~4)", example = "1") + int photoRank + ) {} +} diff --git a/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutResponse.java b/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutResponse.java new file mode 100644 index 0000000..b012d8a --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/dto/response/Cheese4cutResponse.java @@ -0,0 +1,9 @@ +package com.cheeeese.cheese4cut.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์น˜์ฆˆ๋„ค์ปท ์กฐํšŒ ์‘๋‹ต (๋‹คํ˜• ๊ตฌ์กฐ)") +public sealed interface Cheese4cutResponse + permits Cheese4cutPreviewResponse, Cheese4cutFinalResponse { + boolean isFinalized(); +} diff --git a/src/main/java/com/cheeeese/cheese4cut/exception/Cheese4cutException.java b/src/main/java/com/cheeeese/cheese4cut/exception/Cheese4cutException.java new file mode 100644 index 0000000..0428f11 --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/exception/Cheese4cutException.java @@ -0,0 +1,12 @@ +package com.cheeeese.cheese4cut.exception; + +import com.cheeeese.cheese4cut.exception.code.Cheese4cutErrorCode; +import com.cheeeese.global.exception.BusinessException; +import lombok.Getter; + +@Getter +public class Cheese4cutException extends BusinessException { + public Cheese4cutException(Cheese4cutErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/cheeeese/cheese4cut/exception/code/Cheese4cutErrorCode.java b/src/main/java/com/cheeeese/cheese4cut/exception/code/Cheese4cutErrorCode.java new file mode 100644 index 0000000..4fafe5b --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/exception/code/Cheese4cutErrorCode.java @@ -0,0 +1,21 @@ +package com.cheeeese.cheese4cut.exception.code; + +import com.cheeeese.global.common.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum Cheese4cutErrorCode implements BaseCode { + + CHEESE4CUT_NOT_FOUND(HttpStatus.NOT_FOUND, "์น˜์ฆˆ๋„ค์ปท์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + INSUFFICIENT_COUNT_FOR_CHEESE4CUT(HttpStatus.BAD_REQUEST, "์น˜์ฆˆ๋„ค์ปท ์ƒ์„ฑ์„ ์œ„ํ•œ ์™„๋ฃŒ๋œ ์‚ฌ์ง„์ด 4์žฅ ๋ฏธ๋งŒ์ž…๋‹ˆ๋‹ค."), + CHEESE4CUT_ALREADY_FINALIZED(HttpStatus.CONFLICT, "์น˜์ฆˆ๋„ค์ปท์ด ์ด๋ฏธ ํ™•์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + CHEESE4CUT_INVALID_PHOTO_COUNT(HttpStatus.BAD_REQUEST, "์น˜์ฆˆ๋„ค์ปท ํ™•์ • ์‹œ 4์žฅ์˜ ์‚ฌ์ง„ ID๋งŒ ํ—ˆ์šฉ๋ฉ๋‹ˆ๋‹ค."), + CHEESE4CUT_PHOTO_INVALID_STATUS_OR_ALBUM(HttpStatus.BAD_REQUEST, "ํ™•์ •ํ•˜๋ ค๋Š” ์‚ฌ์ง„์€ ํ•ด๋‹น ์•จ๋ฒ”์— ์†ํ•˜๋ฉฐ, ์—…๋กœ๋“œ๊ฐ€ ์™„๋ฃŒ(COMPLETED)๋œ ์ƒํƒœ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + ; + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/cheese4cut/infrastructure/mapper/Cheese4cutMapper.java b/src/main/java/com/cheeeese/cheese4cut/infrastructure/mapper/Cheese4cutMapper.java new file mode 100644 index 0000000..bb67e89 --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/infrastructure/mapper/Cheese4cutMapper.java @@ -0,0 +1,86 @@ +package com.cheeeese.cheese4cut.infrastructure.mapper; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.cheese4cut.domain.Cheese4cut; +import com.cheeeese.cheese4cut.domain.Cheese4cutPhoto; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutFinalResponse; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutPreviewResponse; +import com.cheeeese.photo.domain.Photo; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class Cheese4cutMapper { + + private Cheese4cutMapper() {} + + /** + * ํ™•์ • ํ›„ ์‘๋‹ต (Cheese4cut ์—”ํ‹ฐํ‹ฐ ๊ธฐ๋ฐ˜) + */ + public static Cheese4cutFinalResponse toFinalResponse(List photos) { + return Cheese4cutFinalResponse.builder() + .isFinalized(true) + .photos(photos) + .build(); + } + + /** + * ํ™•์ • ์ „ ์‘๋‹ต (์ข‹์•„์š” TOP 4 ์‚ฌ์ง„ ๋ชฉ๋ก ๊ธฐ๋ฐ˜) + */ + public static Cheese4cutPreviewResponse toPreviewResponse( + List photoInfos, + long uniqueLikesCount, + int participant, + Role myRole + ) { + return Cheese4cutPreviewResponse.builder() + .isFinalized(false) + .previewPhotos(photoInfos) + .uniqueLikesCount((int) uniqueLikesCount) + .participant(participant) + .myRole(myRole) + .build(); + } + + /** + * ํ™•์ • ์‹œ (์ข‹์•„์š” TOP4 ๋˜๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒํ•œ ์‚ฌ์ง„ ๋ชฉ๋ก ๊ธฐ๋ฐ˜) + */ + public static Cheese4cut toEntity(Album album, List orderedPhotos) { + return Cheese4cut.builder() + .album(album) + .photos(IntStream.range(0, orderedPhotos.size()) + .mapToObj(index -> { + Photo photo = orderedPhotos.get(index); + return Cheese4cutPhoto.builder() + .photoId(photo.getId()) + .imageUrl(photo.getImageUrl()) + .thumbnailImageUrl(photo.getThumbnailUrl()) + .photoRank(index + 1) + .build(); + }) + .collect(Collectors.toList())) + .build(); + } + + public static Cheese4cutFinalResponse.FinalPhotoInfo toFinalPhotoInfo( + Long photoId, String imageUrl, int rank) { + + return Cheese4cutFinalResponse.FinalPhotoInfo.builder() + .photoId(photoId) + .imageUrl(imageUrl) + .photoRank(rank) + .build(); + } + + public static Cheese4cutPreviewResponse.PreviewPhotoInfo toPreviewPhotoInfo( + Long photoId, String imageUrl, int rank) { + return Cheese4cutPreviewResponse.PreviewPhotoInfo.builder() + .photoId(photoId) + .imageUrl(imageUrl) + .photoRank(rank) + .build(); + } + +} diff --git a/src/main/java/com/cheeeese/cheese4cut/infrastructure/persistence/Cheese4cutPhotoRepository.java b/src/main/java/com/cheeeese/cheese4cut/infrastructure/persistence/Cheese4cutPhotoRepository.java new file mode 100644 index 0000000..6a40250 --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/infrastructure/persistence/Cheese4cutPhotoRepository.java @@ -0,0 +1,19 @@ +package com.cheeeese.cheese4cut.infrastructure.persistence; + +import com.cheeeese.cheese4cut.domain.Cheese4cutPhoto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface Cheese4cutPhotoRepository extends JpaRepository { + @Query(""" + SELECT c4p + FROM Cheese4cutPhoto c4p + JOIN FETCH c4p.cheese4cut c4c + WHERE c4c.album.id IN :albumIds + ORDER BY c4c.album.createdAt DESC, c4p.photoRank ASC +""") + List findAllCheese4cutPhotosByAlbumIds(@Param("albumIds") List albumIds); +} diff --git a/src/main/java/com/cheeeese/cheese4cut/infrastructure/persistence/Cheese4cutRepository.java b/src/main/java/com/cheeeese/cheese4cut/infrastructure/persistence/Cheese4cutRepository.java new file mode 100644 index 0000000..16fee3a --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/infrastructure/persistence/Cheese4cutRepository.java @@ -0,0 +1,10 @@ +package com.cheeeese.cheese4cut.infrastructure.persistence; + +import com.cheeeese.cheese4cut.domain.Cheese4cut; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface Cheese4cutRepository extends JpaRepository { + Optional findByAlbumId(Long albumId); +} diff --git a/src/main/java/com/cheeeese/cheese4cut/presentation/Cheese4cutController.java b/src/main/java/com/cheeeese/cheese4cut/presentation/Cheese4cutController.java new file mode 100644 index 0000000..e8e2a1f --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/presentation/Cheese4cutController.java @@ -0,0 +1,47 @@ +package com.cheeeese.cheese4cut.presentation; + +import com.cheeeese.cheese4cut.application.Cheese4cutService; +import com.cheeeese.cheese4cut.dto.request.Cheese4cutFixedRequest; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutResponse; +import com.cheeeese.cheese4cut.presentation.swagger.Cheese4cutSwagger; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.user.domain.User; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static com.cheeeese.global.common.code.SuccessCode.*; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/cheese4cut/{code}") +public class Cheese4cutController implements Cheese4cutSwagger { + + private final Cheese4cutService cheese4cutService; + + @Override + @GetMapping("/preview") + public CommonResponse getCheese4cut( + Authentication authentication, + @PathVariable @NotBlank String code + ) { + return CommonResponse.success(CHEESE4CUT_GET_SUCCESS, + cheese4cutService.getCheese4cutByAlbumCode(authentication, code)); + } + + @Override + @PostMapping("/fixed") + public CommonResponse finalizeCheese4cut( + @CurrentUser User user, + @PathVariable @NotBlank String code, + @RequestBody @Valid Cheese4cutFixedRequest request + ) { + cheese4cutService.finalizeCheese4cut(user, code, request); + return CommonResponse.success(CHEESE4CUT_FINALIZE_SUCCESS); + } +} diff --git a/src/main/java/com/cheeeese/cheese4cut/presentation/swagger/Cheese4cutSwagger.java b/src/main/java/com/cheeeese/cheese4cut/presentation/swagger/Cheese4cutSwagger.java new file mode 100644 index 0000000..c49162f --- /dev/null +++ b/src/main/java/com/cheeeese/cheese4cut/presentation/swagger/Cheese4cutSwagger.java @@ -0,0 +1,123 @@ +package com.cheeeese.cheese4cut.presentation.swagger; + +import com.cheeeese.cheese4cut.dto.request.Cheese4cutFixedRequest; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +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.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[์น˜์ฆˆ๋„ค์ปท]", description = "์น˜์ฆˆ๋„ค์ปท ๊ด€๋ จ API") +public interface Cheese4cutSwagger { + + @Operation( + summary = "์น˜์ฆˆ๋„ค์ปท ์ •๋ณด ์กฐํšŒ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ + + ### API ์„ค๋ช… (ํ™•์ • ์ „/ํ›„ ๋ถ„๊ธฐ) + --- + ์ด API๋Š” ์•จ๋ฒ”์˜ **์น˜์ฆˆ๋„ค์ปท ํ™•์ • ์ƒํƒœ**์— ๋”ฐ๋ผ ์‘๋‹ต ํ˜•ํƒœ๊ฐ€ ๋‹ฌ๋ผ์ง€๋Š” ๋‹คํ˜•์ (Polymorphic) ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + + 1. **ํ™•์ • ์ „ (isFinalized: false)**: ์ข‹์•„์š” TOP 4 ์‚ฌ์ง„์˜ ์ธ๋„ค์ผ URL, ์œ ๋‹ˆํฌ ์ข‹์•„์š” ์ˆ˜, ์ „์ฒด ์ฐธ์—ฌ์ž ์ˆ˜๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + 2. **ํ™•์ • ํ›„ (isFinalized: true)**: ํ™•์ •๋œ ์ข‹์•„์š” TOP 4 ์‚ฌ์ง„์˜ ์ธ๋„ค์ผ URL + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์น˜์ฆˆ๋„ค์ปท ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ), + @ApiResponse( + responseCode = "400", + description = "์™„๋ฃŒ๋œ ์‚ฌ์ง„ ๋ถ€์กฑ์œผ๋กœ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ถˆ๊ฐ€", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "isSuccess": false, + "code": 400, + "message": "์น˜์ฆˆ๋„ค์ปท ์ƒ์„ฑ์„ ์œ„ํ•œ ์™„๋ฃŒ๋œ ์‚ฌ์ง„์ด 4์žฅ ๋ฏธ๋งŒ์ž…๋‹ˆ๋‹ค." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "isSuccess": false, + "code": 404, + "message": "์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ์•จ๋ฒ” ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค." + } + """ + ) + ) + ) + }) + CommonResponse getCheese4cut( + Authentication authentication, + @PathVariable @NotBlank String code + ); + + @Operation( + summary = "์น˜์ฆˆ๋„ค์ปท ์ˆ˜๋™ ํ™•์ • API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ + + ### RequestBody + --- + `photoIds`: ์‚ฌ์šฉ์ž๊ฐ€ ์ตœ์ข… ์„ ํƒํ•œ 4์žฅ์˜ ์‚ฌ์ง„ ID \n + + ### ๋กœ์ง ์ƒ์„ธ + --- + 1. ์‚ฌ์šฉ์ž ๊ถŒํ•œ ํ™•์ธ (MAKER๋งŒ ๊ฐ€๋Šฅ). + 2. ์•จ๋ฒ” ๋งŒ๋ฃŒ ๋ฐ ์ด๋ฏธ ํ™•์ • ์—ฌ๋ถ€ ํ™•์ธ. + 3. ์š”์ฒญ๋œ 4์žฅ์˜ ์‚ฌ์ง„ ID๊ฐ€ ๋ชจ๋‘ **COMPLETED ์ƒํƒœ**์ด๊ณ  ํ•ด๋‹น ์•จ๋ฒ”์— ์†ํ•˜๋Š”์ง€ ๊ฒ€์ฆ. + 4. `Cheese4cut` ๋ ˆ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์ €์žฅ. + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์น˜์ฆˆ๋„ค์ปท ์ˆ˜๋™ ํ™•์ •์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ), + @ApiResponse( + responseCode = "400", + description = "์œ ํšจ์„ฑ ๊ฒ€์ฆ ์‹คํŒจ (์‚ฌ์ง„ ๊ฐœ์ˆ˜/์ƒํƒœ ์˜ค๋ฅ˜)" + ), + @ApiResponse( + responseCode = "403", + description = "๊ถŒํ•œ ์—†์Œ (MAKER๊ฐ€ ์•„๋‹˜) ๋˜๋Š” ๋งŒ๋ฃŒ๋œ ์•จ๋ฒ”" + ), + @ApiResponse( + responseCode = "409", + description = "์ด๋ฏธ ์น˜์ฆˆ๋„ค์ปท์ด ํ™•์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse finalizeCheese4cut( + @CurrentUser User user, + @PathVariable @NotBlank String code, + @RequestBody @Valid Cheese4cutFixedRequest request + ); +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/global/common/CommonResponse.java b/src/main/java/com/cheeeese/global/common/CommonResponse.java new file mode 100644 index 0000000..e7d471b --- /dev/null +++ b/src/main/java/com/cheeeese/global/common/CommonResponse.java @@ -0,0 +1,28 @@ +package com.cheeeese.global.common; + +import com.cheeeese.global.common.code.BaseCode; +import com.fasterxml.jackson.annotation.JsonInclude; + +public record CommonResponse( + boolean isSuccess, + int code, + String message, + @JsonInclude(JsonInclude.Include.NON_NULL) + T result +) { + public static CommonResponse success(BaseCode successCode, T data) { + return new CommonResponse<>(true, successCode.getHttpStatus().value(), successCode.getMessage(), data); + } + + public static CommonResponse success(BaseCode successCode) { + return new CommonResponse<>(true, successCode.getHttpStatus().value(), successCode.getMessage(), null); + } + + public static CommonResponse failure(BaseCode errorCode, T data) { + return new CommonResponse<>(false, errorCode.getHttpStatus().value(), errorCode.getMessage(), data); + } + + public static CommonResponse failure(BaseCode errorCode) { + return new CommonResponse<>(false, errorCode.getHttpStatus().value(), errorCode.getMessage(), null); + } +} diff --git a/src/main/java/com/cheeeese/global/common/code/BaseCode.java b/src/main/java/com/cheeeese/global/common/code/BaseCode.java new file mode 100644 index 0000000..ff4304f --- /dev/null +++ b/src/main/java/com/cheeeese/global/common/code/BaseCode.java @@ -0,0 +1,8 @@ +package com.cheeeese.global.common.code; + +import org.springframework.http.HttpStatus; + +public interface BaseCode { + HttpStatus getHttpStatus(); + String getMessage(); +} diff --git a/src/main/java/com/cheeeese/global/common/code/ErrorCode.java b/src/main/java/com/cheeeese/global/common/code/ErrorCode.java new file mode 100644 index 0000000..a181663 --- /dev/null +++ b/src/main/java/com/cheeeese/global/common/code/ErrorCode.java @@ -0,0 +1,20 @@ +package com.cheeeese.global.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements BaseCode { + + // ๊ณตํ†ต ์—๋Ÿฌ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."), + FORBIDDEN(HttpStatus.FORBIDDEN, "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cheeeese/global/common/code/SuccessCode.java b/src/main/java/com/cheeeese/global/common/code/SuccessCode.java new file mode 100644 index 0000000..f0ab4ab --- /dev/null +++ b/src/main/java/com/cheeeese/global/common/code/SuccessCode.java @@ -0,0 +1,59 @@ +package com.cheeeese.global.common.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SuccessCode implements BaseCode { + + // health_check + HEALTH_CHECK_SUCCESS(HttpStatus.OK, "๐Ÿง€ ์น˜์ด์ด์ฆˆ ์„œ๋ฒ„๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ ์ค‘์ž…๋‹ˆ๋‹ค."), + + // auth + TOKEN_EXCHANGE_SUCCESS(HttpStatus.OK, "ํ† ํฐ ๊ตํ™˜์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + TOKEN_REISSUE_SUCCESS(HttpStatus.OK, "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + LOGOUT_SUCCESS(HttpStatus.OK, "๋กœ๊ทธ์•„์›ƒ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + + // user + USER_INFO_FETCH_SUCCESS(HttpStatus.OK, "์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + USER_PROFILE_UPDATE_SUCCESS(HttpStatus.OK, "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + USER_NAME_UPDATE_SUCCESS(HttpStatus.OK, "์‚ฌ์šฉ์ž ์ด๋ฆ„ ์—…๋ฐ์ดํŠธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + USER_PROFILE_IMAGE_OPT_GET_SUCCESS(HttpStatus.OK, "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์„ ํƒ ์˜ต์…˜ ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + USER_PROFILE_IMAGE_UPDATE_SUCCESS(HttpStatus.OK, "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋ฐ์ดํŠธ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + USER_ONBOARDING_SUCCESS(HttpStatus.OK, "์‚ฌ์šฉ์ž ์˜จ๋ณด๋”ฉ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + + // album + ALBUM_OPEN_LIST_FETCH_SUCCESS(HttpStatus.OK, "์—ด๋ฆฐ ์•จ๋ฒ” ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_MY_OPEN_LIST_FETCH_SUCCESS(HttpStatus.OK, "๋‚ด๊ฐ€ ๋งŒ๋“  ์—ด๋ฆฐ ์•จ๋ฒ” ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_CLOSED_LIST_FETCH_SUCCESS(HttpStatus.OK, "๋‹ซํžŒ ์•จ๋ฒ” ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_INVITATION_FETCH_SUCCESS(HttpStatus.OK, "์•จ๋ฒ” ์ดˆ๋Œ€์žฅ ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_ENTER_SUCCESS(HttpStatus.OK, "์•จ๋ฒ” ์ž…์žฅ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_CREATE_SUCCESS(HttpStatus.OK, "์•จ๋ฒ” ์ƒ์„ฑ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_PARTICIPANT_FETCH_SUCCESS(HttpStatus.OK, "์•จ๋ฒ” ์ฐธ์—ฌ์ž ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_INFO_GET_SUCCESS(HttpStatus.OK, "์•จ๋ฒ” ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_BEST4CUT_GET_SUCCESS(HttpStatus.OK, "๋ฒ ์ŠคํŠธ ์•จ๋ฒ”์ปท ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ALBUM_LEAVE_SUCCESS(HttpStatus.OK, "์•จ๋ฒ” ๋‚˜๊ฐ€๊ธฐ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + + // photo + PHOTO_AVAILABLE_COUNT_FETCH_SUCCESS(HttpStatus.OK, "์—…๋กœ๋“œ ๊ฐ€๋Šฅ ์‚ฌ์ง„ ์ˆ˜ ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PRESIGNED_URL_ISSUE_SUCCESS(HttpStatus.OK, "Presigned URL ๋ฐœ๊ธ‰์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_UPLOAD_REPORT_SUCCESS(HttpStatus.OK, "์‚ฌ์ง„ ์—…๋กœ๋“œ ๊ฒฐ๊ณผ ๋ณด๊ณ ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + THUMBNAIL_PRODUCE_COMPLETE(HttpStatus.OK, "์ธ๋„ค์ผ ์ƒ์„ฑ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_LIST_GET_SUCCESS(HttpStatus.OK, "์•จ๋ฒ” ๋‚ด ์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_LIKES_LIST_GET_SUCCESS(HttpStatus.OK, "๋‚ด๊ฐ€ ๋ฑํ•œ ์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_DETAIL_GET_SUCCESS(HttpStatus.OK, "์•จ๋ฒ” ๋‚ด ์‚ฌ์ง„ ์ƒ์„ธ ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_LIKES_CREATE_SUCCESS(HttpStatus.OK, "์‚ฌ์ง„ ์ข‹์•„์š” ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_LIKES_DELETE_SUCCESS(HttpStatus.OK, "์‚ฌ์ง„ ์ข‹์•„์š” ์‚ญ์ œ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_LIKERS_GET_SUCCESS(HttpStatus.OK, "๋ฑํ•œ ์‚ฌ๋žŒ ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_DELETE_SUCCESS(HttpStatus.OK, "์‚ฌ์ง„ ์‚ญ์ œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + + // cheese4cut + CHEESE4CUT_GET_SUCCESS(HttpStatus.OK, "์น˜์ฆˆ๋„ค์ปท ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + CHEESE4CUT_FINALIZE_SUCCESS(HttpStatus.OK, "์น˜์ฆˆ๋„ค์ปท ์ˆ˜๋™ ํ™•์ •์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cheeeese/global/config/ConfigurationPropertiesConfig.java b/src/main/java/com/cheeeese/global/config/ConfigurationPropertiesConfig.java new file mode 100644 index 0000000..5ff0a71 --- /dev/null +++ b/src/main/java/com/cheeeese/global/config/ConfigurationPropertiesConfig.java @@ -0,0 +1,10 @@ +package com.cheeeese.global.config; + +import com.cheeeese.global.security.jwt.JwtProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(value = JwtProperties.class) +public class ConfigurationPropertiesConfig { +} diff --git a/src/main/java/com/cheeeese/global/config/RedisConfig.java b/src/main/java/com/cheeeese/global/config/RedisConfig.java new file mode 100644 index 0000000..6b3fb9b --- /dev/null +++ b/src/main/java/com/cheeeese/global/config/RedisConfig.java @@ -0,0 +1,58 @@ +package com.cheeeese.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password}") + private String password; + + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + config.setPassword(password); + + return new LettuceConnectionFactory(config); + } + + @Bean(name = "tokenRedisTemplate") + public RedisTemplate tokenRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + + @Bean(name = "cacheRedisTemplate") + public RedisTemplate cacheRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(serializer); + redisTemplate.setHashValueSerializer(serializer); + + return redisTemplate; + } +} diff --git a/src/main/java/com/cheeeese/global/config/S3Config.java b/src/main/java/com/cheeeese/global/config/S3Config.java new file mode 100644 index 0000000..fdcc54a --- /dev/null +++ b/src/main/java/com/cheeeese/global/config/S3Config.java @@ -0,0 +1,54 @@ +package com.cheeeese.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +@Configuration +public class S3Config { + + @Value("${ncp.object-storage.access-key}") + private String accessKey; + + @Value("${ncp.object-storage.secret-key}") + private String secretKey; + + @Value("${ncp.object-storage.endpoint}") + private String endpoint; + + @Value("${ncp.object-storage.region}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .endpointOverride(URI.create(endpoint)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .endpointOverride(URI.create(endpoint)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/global/config/SecurityConfig.java b/src/main/java/com/cheeeese/global/config/SecurityConfig.java new file mode 100644 index 0000000..6ca2946 --- /dev/null +++ b/src/main/java/com/cheeeese/global/config/SecurityConfig.java @@ -0,0 +1,79 @@ +package com.cheeeese.global.config; + +import com.cheeeese.global.security.filter.RedirectFilter; +import com.cheeeese.global.security.handler.JwtAccessDeniedHandler; +import com.cheeeese.global.security.handler.JwtAuthenticationEntryPoint; +import com.cheeeese.global.security.jwt.JwtAuthenticationFilter; +import com.cheeeese.oauth2.application.CustomOAuth2UserService; +import com.cheeeese.oauth2.handler.OAuth2FailureHandler; +import com.cheeeese.oauth2.handler.OAuth2SuccessHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + private final RedirectFilter redirectFilter; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + private final String[] WHITE_LIST = { + "/swagger-ui/**", + "/v3/api-docs/**", + "/v1/global/health-check", + "/login/oauth2/**", + "/v1/auth/exchange", + "/v1/auth/reissue", + "/v1/album/*/invitation", + "/v1/cheese4cut/*/preview", + "/v1/album/*/participants", + "/internal/thumbnail/complete", + "/v1/user/profile-images", + "/v1/album/*/info", + "/v1/album/*/available-count", + "/actuator/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement((sessionManagement) -> sessionManagement + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .authorizeHttpRequests((requests) -> requests + .requestMatchers(WHITE_LIST).permitAll() + .anyRequest().authenticated()) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) + .addFilterBefore(redirectFilter, OAuth2AuthorizationRequestRedirectFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/com/cheeeese/global/config/SwaggerConfig.java b/src/main/java/com/cheeeese/global/config/SwaggerConfig.java new file mode 100644 index 0000000..ff164fa --- /dev/null +++ b/src/main/java/com/cheeeese/global/config/SwaggerConfig.java @@ -0,0 +1,49 @@ +package com.cheeeese.global.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Collections; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "๐Ÿง€ ์น˜์ด์ด์ฆˆ API ๋ช…์„ธ์„œ", + description = "๐Ÿง€ ์น˜์ด์ด์ฆˆ API ๋ช…์„ธ์„œ", + version = "v1.0.0" + ), + servers = { + @Server( + url = "${springdoc.local-server-url}", + description = "Local Server URL" + ), + @Server( + url = "${springdoc.dev-server-url}", + description = "Develop Server URL" + ), + @Server( + url = "${springdoc.prod-server-url}", + description = "Production Server URL" + ) + } +) +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP).scheme("Bearer").bearerFormat("JWT") + .in(SecurityScheme.In.HEADER).name("Authorization"); + SecurityRequirement securityRequirement = new SecurityRequirement().addList("BearerAuth"); + + return new OpenAPI() + .components(new Components().addSecuritySchemes("BearerAuth", securityScheme)) + .security(Collections.singletonList(securityRequirement)); + } +} diff --git a/src/main/java/com/cheeeese/global/config/WebConfig.java b/src/main/java/com/cheeeese/global/config/WebConfig.java new file mode 100644 index 0000000..470e658 --- /dev/null +++ b/src/main/java/com/cheeeese/global/config/WebConfig.java @@ -0,0 +1,21 @@ +package com.cheeeese.global.config; + +import com.cheeeese.global.util.resolver.CurrentUserResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final CurrentUserResolver currentUserResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserResolver); + } +} diff --git a/src/main/java/com/cheeeese/global/domain/BaseEntity.java b/src/main/java/com/cheeeese/global/domain/BaseEntity.java new file mode 100644 index 0000000..d61a51d --- /dev/null +++ b/src/main/java/com/cheeeese/global/domain/BaseEntity.java @@ -0,0 +1,29 @@ +package com.cheeeese.global.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + protected void markUpdated() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/cheeeese/global/exception/BusinessException.java b/src/main/java/com/cheeeese/global/exception/BusinessException.java new file mode 100644 index 0000000..c26c79c --- /dev/null +++ b/src/main/java/com/cheeeese/global/exception/BusinessException.java @@ -0,0 +1,14 @@ +package com.cheeeese.global.exception; + +import com.cheeeese.global.common.code.BaseCode; +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final BaseCode errorCode; + + public BusinessException(BaseCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/cheeeese/global/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/cheeeese/global/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..5fd05ab --- /dev/null +++ b/src/main/java/com/cheeeese/global/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,59 @@ +package com.cheeeese.global.exception.handler; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.common.code.BaseCode; +import com.cheeeese.global.common.code.ErrorCode; +import com.cheeeese.global.exception.BusinessException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + protected ResponseEntity> handleBusinessException(BusinessException e) { + BaseCode errorCode = e.getErrorCode(); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(CommonResponse.failure(errorCode)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.warn("โš ๏ธ Validation failed: {}", e.getBindingResult().getAllErrors()); + + return ResponseEntity + .status(ErrorCode.BAD_REQUEST.getHttpStatus()) + .body(CommonResponse.failure(ErrorCode.BAD_REQUEST)); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + return ResponseEntity + .status(ErrorCode.BAD_REQUEST.getHttpStatus()) + .body(CommonResponse.failure(ErrorCode.BAD_REQUEST)); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + ResponseEntity> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { + return ResponseEntity + .status(ErrorCode.BAD_REQUEST.getHttpStatus()) + .body(CommonResponse.failure(ErrorCode.BAD_REQUEST)); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity> handleUnexpectedException(Exception e) { + log.error("๐Ÿšจ Unexpected Error Log: {}", e.getMessage()); + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(CommonResponse.failure(ErrorCode.INTERNAL_SERVER_ERROR)); + } +} diff --git a/src/main/java/com/cheeeese/global/logging/LoggingAspect.java b/src/main/java/com/cheeeese/global/logging/LoggingAspect.java new file mode 100644 index 0000000..e47e713 --- /dev/null +++ b/src/main/java/com/cheeeese/global/logging/LoggingAspect.java @@ -0,0 +1,43 @@ +package com.cheeeese.global.logging; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class LoggingAspect { + + private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class); + + @Pointcut("execution(* com.cheeeese..application..*Service.*(..))") + public void applicationLayer() {} + + @Around("applicationLayer()") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + String className = joinPoint.getSignature().getDeclaringTypeName(); + String methodName = joinPoint.getSignature().getName(); + Object[] args = joinPoint.getArgs(); + + logger.info("[โ–ถ๏ธ Start] {}.{} | args = {}", className, methodName, args); + + long start = System.currentTimeMillis(); + try { + Object result = joinPoint.proceed(); + long end = System.currentTimeMillis(); + + logger.info("[โœ… End] {}.{} | took = {}ms | return = {}", className, methodName, (end - start), result); + + return result; + } catch (Throwable ex) { + long end = System.currentTimeMillis(); + logger.error("[โ€ผ๏ธ Exception] {}.{} | took = {}ms | message = {}", className, methodName, (end - start), ex.getMessage(), ex); + + throw ex; + } + } +} diff --git a/src/main/java/com/cheeeese/global/logging/MDCLoggingFilter.java b/src/main/java/com/cheeeese/global/logging/MDCLoggingFilter.java new file mode 100644 index 0000000..b81b7e1 --- /dev/null +++ b/src/main/java/com/cheeeese/global/logging/MDCLoggingFilter.java @@ -0,0 +1,46 @@ +package com.cheeeese.global.logging; + +import com.cheeeese.global.security.CustomUserDetails; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +public class MDCLoggingFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain + ) throws ServletException, IOException { + String traceId = UUID.randomUUID().toString().substring(0, 8); + MDC.put("traceId", traceId); + + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null + && authentication.isAuthenticated() + && !"anonymousUser".equals(authentication.getPrincipal()) + ) { + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + MDC.put("userId", String.valueOf(userDetails.getUser().getId())); + } else { + MDC.put("userId", "null"); + } + chain.doFilter(request, response); + } finally { + MDC.clear(); + } + } +} diff --git a/src/main/java/com/cheeeese/global/presentation/GlobalController.java b/src/main/java/com/cheeeese/global/presentation/GlobalController.java new file mode 100644 index 0000000..1b96205 --- /dev/null +++ b/src/main/java/com/cheeeese/global/presentation/GlobalController.java @@ -0,0 +1,22 @@ +package com.cheeeese.global.presentation; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.presentation.swagger.GlobalSwagger; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.cheeeese.global.common.code.SuccessCode.HEALTH_CHECK_SUCCESS; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/global") +public class GlobalController implements GlobalSwagger { + + @Override + @GetMapping("/health-check") + public CommonResponse healthCheck() { + return CommonResponse.success(HEALTH_CHECK_SUCCESS, "OK"); + } +} diff --git a/src/main/java/com/cheeeese/global/presentation/swagger/GlobalSwagger.java b/src/main/java/com/cheeeese/global/presentation/swagger/GlobalSwagger.java new file mode 100644 index 0000000..2c2e031 --- /dev/null +++ b/src/main/java/com/cheeeese/global/presentation/swagger/GlobalSwagger.java @@ -0,0 +1,22 @@ +package com.cheeeese.global.presentation.swagger; + +import com.cheeeese.global.common.CommonResponse; +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; + +@Tag(name = "[Global]", description = "์„ค์ • ํ™•์ธ์„ ์œ„ํ•œ API") +public interface GlobalSwagger { + @Operation( + summary = "health check", + description = "health check API" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "health check success" + ) + }) + CommonResponse healthCheck(); +} diff --git a/src/main/java/com/cheeeese/global/security/CurrentUserProvider.java b/src/main/java/com/cheeeese/global/security/CurrentUserProvider.java new file mode 100644 index 0000000..a03cc3f --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/CurrentUserProvider.java @@ -0,0 +1,21 @@ +package com.cheeeese.global.security; + +import com.cheeeese.user.domain.User; +import com.cheeeese.user.exception.UserException; +import com.cheeeese.user.exception.code.UserErrorCode; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CurrentUserProvider { + + private final UserRepository userRepository; + + public User getCurrentUser() { + Long userId = SecurityUtils.getCurrentUserId(); + return userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/cheeeese/global/security/CustomUserDetailService.java b/src/main/java/com/cheeeese/global/security/CustomUserDetailService.java new file mode 100644 index 0000000..e0e550c --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/CustomUserDetailService.java @@ -0,0 +1,26 @@ +package com.cheeeese.global.security; + +import com.cheeeese.user.domain.User; +import com.cheeeese.user.exception.UserException; +import com.cheeeese.user.exception.code.UserErrorCode; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { + User user = userRepository.findById(Long.valueOf(userId)) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/com/cheeeese/global/security/CustomUserDetails.java b/src/main/java/com/cheeeese/global/security/CustomUserDetails.java new file mode 100644 index 0000000..abaf7ff --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/CustomUserDetails.java @@ -0,0 +1,81 @@ +package com.cheeeese.global.security; + +import com.cheeeese.user.domain.User; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class CustomUserDetails implements UserDetails, OAuth2User { + + private final User user; + private final Map attributes; + + public CustomUserDetails(User user) { + this.user = user; + this.attributes = Collections.emptyMap(); + } + + public CustomUserDetails(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + public Long getId() { + return user.getId(); + } + + public String getEmail() { + return user.getEmail(); + } + + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return user.getName(); + } +} diff --git a/src/main/java/com/cheeeese/global/security/SecurityUtils.java b/src/main/java/com/cheeeese/global/security/SecurityUtils.java new file mode 100644 index 0000000..3805805 --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/SecurityUtils.java @@ -0,0 +1,34 @@ +package com.cheeeese.global.security; + +import com.cheeeese.global.common.code.ErrorCode; +import com.cheeeese.global.exception.BusinessException; +import com.cheeeese.user.domain.User; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class SecurityUtils { + + public static User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || + !authentication.isAuthenticated() || + authentication instanceof AnonymousAuthenticationToken + ) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + Object principal = authentication.getPrincipal(); + + if (!(principal instanceof CustomUserDetails customUserDetails)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + return customUserDetails.getUser(); + } + + public static Long getCurrentUserId() { + return getCurrentUser().getId(); + } +} diff --git a/src/main/java/com/cheeeese/global/security/filter/RedirectFilter.java b/src/main/java/com/cheeeese/global/security/filter/RedirectFilter.java new file mode 100644 index 0000000..f706a11 --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/filter/RedirectFilter.java @@ -0,0 +1,36 @@ +package com.cheeeese.global.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Component +public class RedirectFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String redirect = request.getParameter("redirect"); + + if (redirect != null && !redirect.isBlank()) { + String encodedRedirect = URLEncoder.encode(redirect, StandardCharsets.UTF_8); + Cookie cookie = new Cookie("REDIRECT_URI", encodedRedirect); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge(300); + response.addCookie(cookie); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/cheeeese/global/security/handler/JwtAccessDeniedHandler.java b/src/main/java/com/cheeeese/global/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..5e81950 --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package com.cheeeese.global.security.handler; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.common.code.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), CommonResponse.failure(ErrorCode.FORBIDDEN)); + } +} diff --git a/src/main/java/com/cheeeese/global/security/handler/JwtAuthenticationEntryPoint.java b/src/main/java/com/cheeeese/global/security/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..af64178 --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,34 @@ +package com.cheeeese.global.security.handler; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.common.code.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), CommonResponse.failure(ErrorCode.UNAUTHORIZED)); + } +} diff --git a/src/main/java/com/cheeeese/global/security/handler/TokenBlacklistHandler.java b/src/main/java/com/cheeeese/global/security/handler/TokenBlacklistHandler.java new file mode 100644 index 0000000..afae35c --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/handler/TokenBlacklistHandler.java @@ -0,0 +1,26 @@ +package com.cheeeese.global.security.handler; + +import com.cheeeese.auth.exception.code.AuthErrorCode; +import com.cheeeese.global.common.CommonResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class TokenBlacklistHandler { + + private final ObjectMapper objectMapper; + + public void handleBlacklistedToken(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + + CommonResponse errorResponse = CommonResponse.failure(AuthErrorCode.LOGGED_OUT_TOKEN); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/cheeeese/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/cheeeese/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..213d319 --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.cheeeese.global.security.jwt; + +import com.cheeeese.auth.application.TokenBlacklistService; +import com.cheeeese.global.security.CustomUserDetailService; +import com.cheeeese.global.security.handler.TokenBlacklistHandler; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final CustomUserDetailService customUserDetailService; + private final TokenBlacklistService tokenBlacklistService; + private final TokenBlacklistHandler tokenBlacklistHandler; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String token = jwtProvider.resolveToken(request); + + if (token != null && jwtProvider.validateToken(token)) { + if (tokenBlacklistService.isBlackListed(token)) { + tokenBlacklistHandler.handleBlacklistedToken(response); + return; + } + Claims claims = jwtProvider.getClaims(token); + String userId = claims.getSubject(); + + UserDetails userDetails = customUserDetailService.loadUserByUsername(userId); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/cheeeese/global/security/jwt/JwtProperties.java b/src/main/java/com/cheeeese/global/security/jwt/JwtProperties.java new file mode 100644 index 0000000..63be647 --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/jwt/JwtProperties.java @@ -0,0 +1,11 @@ +package com.cheeeese.global.security.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String secret, + Long accessExp, + Long refreshExp +) { +} diff --git a/src/main/java/com/cheeeese/global/security/jwt/JwtProvider.java b/src/main/java/com/cheeeese/global/security/jwt/JwtProvider.java new file mode 100644 index 0000000..fce5fba --- /dev/null +++ b/src/main/java/com/cheeeese/global/security/jwt/JwtProvider.java @@ -0,0 +1,84 @@ +package com.cheeeese.global.security.jwt; + +import com.cheeeese.auth.exception.AuthException; +import com.cheeeese.auth.exception.code.AuthErrorCode; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtProperties jwtProperties; + + public String createAccessToken(Long userId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + jwtProperties.accessExp()); + + return Jwts.builder() + .setSubject(userId.toString()) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken(Long userId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + jwtProperties.refreshExp()); + + return Jwts.builder() + .setSubject(userId.toString()) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public Claims getClaims(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token); + + return true; + } catch (ExpiredJwtException e) { + throw new AuthException(AuthErrorCode.EXPIRED_TOKEN); + } catch (MalformedJwtException | UnsupportedJwtException e) { + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + } + + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/cheeeese/global/util/CurrentUser.java b/src/main/java/com/cheeeese/global/util/CurrentUser.java new file mode 100644 index 0000000..16c9bec --- /dev/null +++ b/src/main/java/com/cheeeese/global/util/CurrentUser.java @@ -0,0 +1,14 @@ +package com.cheeeese.global.util; + +import io.swagger.v3.oas.annotations.Parameter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Parameter(hidden = true) +public @interface CurrentUser { +} diff --git a/src/main/java/com/cheeeese/global/util/ProfileImageUtil.java b/src/main/java/com/cheeeese/global/util/ProfileImageUtil.java new file mode 100644 index 0000000..152a205 --- /dev/null +++ b/src/main/java/com/cheeeese/global/util/ProfileImageUtil.java @@ -0,0 +1,14 @@ +package com.cheeeese.global.util; + +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.domain.type.ProfileImageType; + +public class ProfileImageUtil { + + public static String resolveProfileImage(User user, CdnUrlResolver resolver) { + ProfileImageType type = ProfileImageType.fromName(user.getProfileImage()); + if (type == null) return null; + return resolver.resolveProfile(type.getPath()); + } +} diff --git a/src/main/java/com/cheeeese/global/util/RedisCacheUtil.java b/src/main/java/com/cheeeese/global/util/RedisCacheUtil.java new file mode 100644 index 0000000..6391c72 --- /dev/null +++ b/src/main/java/com/cheeeese/global/util/RedisCacheUtil.java @@ -0,0 +1,57 @@ +package com.cheeeese.global.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class RedisCacheUtil { + + @Qualifier("cacheRedisTemplate") + private final RedisTemplate cacheRedisTemplate; + + /** + * ์บ์‹œ ์ €์žฅ + */ + public void setValue(String key, Object value, Long expiredTime) { + if (expiredTime != null) { + cacheRedisTemplate.opsForValue().set(key, value, expiredTime, TimeUnit.SECONDS); + } else { + cacheRedisTemplate.opsForValue().set(key, value); + } + } + + public Long getValue(String key) { + Object value = cacheRedisTemplate.opsForValue().get(key); + if (value instanceof Long) return (Long) value; + if (value instanceof Integer) return ((Integer) value).longValue(); + return null; + } + + /** + * ์บ์‹œ ๊ฐ์ฒด ์กฐํšŒ + */ + @SuppressWarnings("unchecked") + public T getObject(String key, Class clazz) { + return (T) cacheRedisTemplate.opsForValue().get(key); + } + + /** + * ํŒจํ„ด ๊ธฐ๋ฐ˜ ํ‚ค ์‚ญ์ œ (๋Œ€๊ทœ๋ชจ ์‚ญ์ œ) + */ + public void deletePattern(String pattern) { + Set keys = cacheRedisTemplate.keys(pattern); + if (!keys.isEmpty()) { + cacheRedisTemplate.delete(keys); + } + } + + public boolean exists(String key) { + return cacheRedisTemplate.hasKey(key); + } +} diff --git a/src/main/java/com/cheeeese/global/util/RedisUtil.java b/src/main/java/com/cheeeese/global/util/RedisUtil.java new file mode 100644 index 0000000..3aafe33 --- /dev/null +++ b/src/main/java/com/cheeeese/global/util/RedisUtil.java @@ -0,0 +1,28 @@ +package com.cheeeese.global.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class RedisUtil { + + @Qualifier("tokenRedisTemplate") + private final RedisTemplate tokenRedisTemplate; + + public void setValue(String key, Object value, Long expiredTime) { + tokenRedisTemplate.opsForValue().set(key, value, expiredTime, TimeUnit.MILLISECONDS); + } + + public String getValue(String key) { + return (String) tokenRedisTemplate.opsForValue().get(key); + } + + public void deleteValue(String key) { + tokenRedisTemplate.delete(key); + } +} diff --git a/src/main/java/com/cheeeese/global/util/S3Util.java b/src/main/java/com/cheeeese/global/util/S3Util.java new file mode 100644 index 0000000..6cba0f0 --- /dev/null +++ b/src/main/java/com/cheeeese/global/util/S3Util.java @@ -0,0 +1,52 @@ +package com.cheeeese.global.util; + +import java.net.URI; +import java.net.URISyntaxException; + +public class S3Util { + + private static final String PREFIX = "say-cheeeese/"; + + public static String extractObjectKey(String imageUrl) { + if (imageUrl == null) { + throw new NullPointerException("image url is null"); + } + + try { + URI uri = new URI(imageUrl); + String path = uri.getPath(); + + if (path != null && !path.isBlank()) { + if (path.startsWith("/")) { + path = path.substring(1); + } + + if (path.startsWith(PREFIX)) { + path = path.substring(PREFIX.length()); + } + + return path; + } + } catch (URISyntaxException ignored) { + } + return imageUrl; + } + + public static String extractFileName(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) { + return "unnamed.jpg"; + } + String normalized = imageUrl.replace('\\', '/'); + + int lastSlashIdx = normalized.lastIndexOf('/'); + String fileName = (lastSlashIdx >= 0) + ? normalized.substring(lastSlashIdx + 1) + : normalized; + + int underscoreIdx = fileName.indexOf('_'); + if (underscoreIdx >= 0 && underscoreIdx < fileName.length() - 1) { + fileName = fileName.substring(underscoreIdx + 1); + } + return fileName; + } +} diff --git a/src/main/java/com/cheeeese/global/util/resolver/CdnUrlResolver.java b/src/main/java/com/cheeeese/global/util/resolver/CdnUrlResolver.java new file mode 100644 index 0000000..f206f75 --- /dev/null +++ b/src/main/java/com/cheeeese/global/util/resolver/CdnUrlResolver.java @@ -0,0 +1,52 @@ +package com.cheeeese.global.util.resolver; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class CdnUrlResolver { + + private static final List PREFIXES = List.of( + "say-cheeeese/", + "say-cheeeese-thumbnail/", + "say-cheeeese-profile/" + ); + + @Value("${cdn.original-domain}") + private String originalDomain; + + @Value("${cdn.thumbnail-domain}") + private String thumbnailDomain; + + @Value("${cdn.profile-domain}") + private String profileDomain; + + public String resolveOriginal(String path) { + return resolve(originalDomain, path); + } + + public String resolveThumbnail(String path) { + return resolve(thumbnailDomain, path); + } + + public String resolveProfile(String path) { + return resolve(profileDomain, path); + } + + private String resolve(String domain, String path) { + if (path == null || path.isBlank()) return null; + if (path.startsWith("http")) return path; + + for (String prefix : PREFIXES) { + if (path.startsWith(prefix)) { + path = path.substring(prefix.length()); + break; + } + } + + if (path.startsWith("/")) path = path.substring(1); + return domain + "/" + path; + } +} diff --git a/src/main/java/com/cheeeese/global/util/resolver/CurrentUserResolver.java b/src/main/java/com/cheeeese/global/util/resolver/CurrentUserResolver.java new file mode 100644 index 0000000..0616166 --- /dev/null +++ b/src/main/java/com/cheeeese/global/util/resolver/CurrentUserResolver.java @@ -0,0 +1,39 @@ +package com.cheeeese.global.util.resolver; + +import com.cheeeese.global.security.SecurityUtils; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.exception.UserException; +import com.cheeeese.user.exception.code.UserErrorCode; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +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; + +@Component +@RequiredArgsConstructor +public class CurrentUserResolver implements HandlerMethodArgumentResolver { + + private final UserRepository userRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) && parameter.getParameterType().equals(User.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + Long userId = SecurityUtils.getCurrentUserId(); + return userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/cheeeese/oauth2/application/CustomOAuth2UserService.java b/src/main/java/com/cheeeese/oauth2/application/CustomOAuth2UserService.java new file mode 100644 index 0000000..0e92b20 --- /dev/null +++ b/src/main/java/com/cheeeese/oauth2/application/CustomOAuth2UserService.java @@ -0,0 +1,44 @@ +package com.cheeeese.oauth2.application; + +import com.cheeeese.global.security.CustomUserDetails; +import com.cheeeese.oauth2.infrastructure.userinfo.KakaoUserInfo; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.infrastructure.mapper.UserMapper; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + Map attributes = oAuth2User.getAttributes(); + + KakaoUserInfo userInfo = extractKakaoUserInfo(attributes); + + User user = userRepository.findByProviderId(userInfo.getProviderId()) + .orElseGet(() -> { + User newUser = UserMapper.toEntity(userInfo); + return userRepository.save(newUser); + }); + + return new CustomUserDetails(user, attributes); + } + + private KakaoUserInfo extractKakaoUserInfo(Map attributes) { + return new KakaoUserInfo(attributes); + } +} diff --git a/src/main/java/com/cheeeese/oauth2/domain/OAuth2UserInfo.java b/src/main/java/com/cheeeese/oauth2/domain/OAuth2UserInfo.java new file mode 100644 index 0000000..f1f550b --- /dev/null +++ b/src/main/java/com/cheeeese/oauth2/domain/OAuth2UserInfo.java @@ -0,0 +1,7 @@ +package com.cheeeese.oauth2.domain; + +public interface OAuth2UserInfo { + String getProviderId(); + String getEmail(); + String getName(); +} diff --git a/src/main/java/com/cheeeese/oauth2/handler/OAuth2FailureHandler.java b/src/main/java/com/cheeeese/oauth2/handler/OAuth2FailureHandler.java new file mode 100644 index 0000000..9fcb631 --- /dev/null +++ b/src/main/java/com/cheeeese/oauth2/handler/OAuth2FailureHandler.java @@ -0,0 +1,37 @@ +package com.cheeeese.oauth2.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Value("${frontend.oauth2.redirect-uri}") + private String redirectUri; + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + log.warn("โš ๏ธ ๋กœ๊ทธ์ธ ์‹คํŒจ: {}", exception.getMessage()); + + String targetUri = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("error",exception.getLocalizedMessage()) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUri); + } +} diff --git a/src/main/java/com/cheeeese/oauth2/handler/OAuth2SuccessHandler.java b/src/main/java/com/cheeeese/oauth2/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..7c6e75a --- /dev/null +++ b/src/main/java/com/cheeeese/oauth2/handler/OAuth2SuccessHandler.java @@ -0,0 +1,86 @@ +package com.cheeeese.oauth2.handler; + +import com.cheeeese.global.security.CustomUserDetails; +import com.cheeeese.global.security.jwt.JwtProvider; +import com.cheeeese.global.util.RedisUtil; +import com.cheeeese.auth.infrastructure.mapper.RefreshTokenMapper; +import com.cheeeese.auth.infrastructure.persistence.RefreshTokenRepository; +import com.cheeeese.user.domain.User; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final ObjectMapper objectMapper; + private final RedisUtil redisUtil; + + @Value("${frontend.oauth2.redirect-uri}") + private String redirectUri; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal(); + User user = customUserDetails.getUser(); + + String accessToken = jwtProvider.createAccessToken(user.getId()); + String refreshToken = jwtProvider.createRefreshToken(user.getId()); + + refreshTokenRepository.save(RefreshTokenMapper.toRefreshToken(user, refreshToken)); + + String tempCode = UUID.randomUUID().toString(); + + redisUtil.setValue("auth:" + tempCode, + objectMapper.writeValueAsString(Map.of( + "accessToken", accessToken, + "refreshToken", refreshToken + )), + 1000 * 60L + ); + + String redirect = null; + if (request.getCookies() != null) { + for (Cookie c : request.getCookies()) { + if ("REDIRECT_URI".equals(c.getName())) { + redirect = URLDecoder.decode(c.getValue(), StandardCharsets.UTF_8); + } + } + } + Cookie del = new Cookie("REDIRECT_URI", null); + del.setPath("/"); + del.setMaxAge(0); + response.addCookie(del); + + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(redirectUri) + .queryParam("code", tempCode); + + if (redirect != null && !redirect.isBlank()) { + builder.queryParam("redirect", redirect); + } + String callbackUri = builder.toUriString(); + + response.sendRedirect(callbackUri); + } +} diff --git a/src/main/java/com/cheeeese/oauth2/infrastructure/userinfo/KakaoUserInfo.java b/src/main/java/com/cheeeese/oauth2/infrastructure/userinfo/KakaoUserInfo.java new file mode 100644 index 0000000..b3a4184 --- /dev/null +++ b/src/main/java/com/cheeeese/oauth2/infrastructure/userinfo/KakaoUserInfo.java @@ -0,0 +1,47 @@ +package com.cheeeese.oauth2.infrastructure.userinfo; + +import com.cheeeese.oauth2.domain.OAuth2UserInfo; +import lombok.RequiredArgsConstructor; + +import java.util.Collections; +import java.util.Map; + +@RequiredArgsConstructor +public class KakaoUserInfo implements OAuth2UserInfo { + + private final Map attributes; + + @Override + public String getProviderId() { + return String.valueOf(attributes.get("id")); + } + + @Override + public String getEmail() { + return (String) getKakaoAccount().get("email"); + } + + @Override + public String getName() { + return (String) getKakaoProfile().get("nickname"); + } + + @SuppressWarnings("unchecked") + private Map getKakaoAccount() { + Object account = attributes.get("kakao_account"); + if (account instanceof Map) { + return (Map) account; + } + return Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private Map getKakaoProfile() { + Map kakaoAccount = getKakaoAccount(); + Object profile = kakaoAccount.get("profile"); + if (profile instanceof Map) { + return (Map) profile; + } + return Collections.emptyMap(); + } +} diff --git a/src/main/java/com/cheeeese/photo/application/PhotoCallbackService.java b/src/main/java/com/cheeeese/photo/application/PhotoCallbackService.java new file mode 100644 index 0000000..c40f8e9 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/PhotoCallbackService.java @@ -0,0 +1,64 @@ +package com.cheeeese.photo.application; + +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.photo.application.logger.PhotoLogger; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.dto.request.PhotoCompleteRequest; +import com.cheeeese.photo.exception.PhotoException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.application.UserService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class PhotoCallbackService { + + private final PhotoRepository photoRepository; + private final PhotoQueryService photoQueryService; + private final AlbumRepository albumRepository; + private final UserService userService; + + private final PhotoLogger photoLogger; + + public void markUploadCompleted(PhotoCompleteRequest request) { + int updated = photoRepository.updateStatusAndUrl( + request.photoId(), + PhotoStatus.UPLOADING, + PhotoStatus.COMPLETED, + request.thumbnailUrl() + ); + + if (updated == 0) { + throw new PhotoException(PhotoErrorCode.THUMBNAIL_UPDATE_FAILED); + } + + Photo photo = photoRepository.findById(request.photoId()) + .orElseThrow(() -> new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND)); + + int albumUpdated = albumRepository.incrementPhotoCount(photo.getAlbum().getId(), 1); + if (albumUpdated == 0) { + photoRepository.updateStatusAndUrl( + photo.getId(), + PhotoStatus.COMPLETED, + PhotoStatus.FAILED, + photo.getThumbnailUrl() + ); + throw new PhotoException(PhotoErrorCode.PHOTO_COUNT_INCREMENT_FAILED); + } + + userService.incrementPhotoCount(photo.getUser().getId(), 1); + + String albumCode = photoRepository.findAlbumCodeByPhotoId(request.photoId()); + + photoLogger.logUploadCompleted(photo.getUser().getId(), albumCode, photo.getId()); + + if (albumCode != null) { + photoQueryService.invalidatePhotoCache(albumCode); + } + } +} diff --git a/src/main/java/com/cheeeese/photo/application/PhotoCleanupScheduler.java b/src/main/java/com/cheeeese/photo/application/PhotoCleanupScheduler.java new file mode 100644 index 0000000..c76367d --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/PhotoCleanupScheduler.java @@ -0,0 +1,17 @@ +package com.cheeeese.photo.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PhotoCleanupScheduler { + + private final PhotoService photoService; + + @Scheduled(fixedDelay = 600000L) + public void runCleanup() { + photoService.cleanupOldUploadingPhotos(); + } +} diff --git a/src/main/java/com/cheeeese/photo/application/PhotoInfoService.java b/src/main/java/com/cheeeese/photo/application/PhotoInfoService.java new file mode 100644 index 0000000..1b60963 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/PhotoInfoService.java @@ -0,0 +1,52 @@ +package com.cheeeese.photo.application; + +import com.cheeeese.album.application.validator.AlbumValidator; +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.global.util.ProfileImageUtil; +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.photo.application.support.PhotoReader; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.dto.response.PhotoLikedUserResponse; +import com.cheeeese.photo.infrastructure.mapper.PhotoMapper; +import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PhotoInfoService { + + private final PhotoLikesRepository photoLikesRepository; + private final PhotoReader photoReader; + private final AlbumValidator albumValidator; + private final CdnUrlResolver cdnUrlResolver; + + public PhotoLikedUserResponse getPhotoLikedUsers(User user, String code, Long photoId) { + Album album = albumValidator.validateAlbumCode(code); + + albumValidator.validateAlbumParticipant(album, user); + + Photo photo = photoReader.getPhotoInAlbum(photoId, code); + + List users = photoLikesRepository.findLikersByPhotoId(photo.getId()); + + List likers = users.stream() + .map(liker -> { + String profileImage = ProfileImageUtil.resolveProfileImage(liker, cdnUrlResolver); + boolean isMe = liker.getId().equals(user.getId()); + Role role = liker.getId().equals(album.getMakerId()) ? Role.MAKER : Role.GUEST; + + return PhotoMapper.toPhotoLiker(liker, profileImage, isMe, role); + }) + .toList(); + + return PhotoMapper.toPhotoLikerResponse(photo, likers); + } +} diff --git a/src/main/java/com/cheeeese/photo/application/PhotoQueryService.java b/src/main/java/com/cheeeese/photo/application/PhotoQueryService.java new file mode 100644 index 0000000..edb82bf --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/PhotoQueryService.java @@ -0,0 +1,193 @@ +package com.cheeeese.photo.application; + +import com.cheeeese.album.domain.type.AlbumSorting; +import com.cheeeese.global.util.ProfileImageUtil; +import com.cheeeese.global.util.RedisCacheUtil; +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.photo.application.support.PhotoReader; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.dto.response.*; +import com.cheeeese.photo.exception.PhotoException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import com.cheeeese.photo.infrastructure.mapper.PhotoMapper; +import com.cheeeese.photo.infrastructure.persistence.PhotoHistoryRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PhotoQueryService { + + private final PhotoRepository photoRepository; + private final PhotoLikesRepository photoLikesRepository; + private final PhotoHistoryRepository photoHistoryRepository; + private final PhotoReader photoReader; + private final RedisCacheUtil redisCacheUtil; + private final CdnUrlResolver cdnUrlResolver; + + private static final String PHOTO_KEY = "cache:album:%s:photos:sort:%s:page:%d:size:%d:version:%d"; + private static final String VERSION_KEY = "cache:album:%s:version"; + + public PhotoPageResponse getPhotoPage(User user, String code, int page, int size, AlbumSorting albumSorting) { + String versionKey = String.format(VERSION_KEY, code); + Long curVersion = Optional.ofNullable(redisCacheUtil.getValue(versionKey)).orElse(0L); + + String photoKey = String.format(PHOTO_KEY, code, albumSorting.getParam(), page, size, curVersion); + PhotoPageResponse cachedList = redisCacheUtil.getObject(photoKey, PhotoPageResponse.class); + + // redis์— ์กด์žฌํ•  ๊ฒฝ์šฐ, db ์ ‘๊ทผ X + ๋ฐ”๋กœ ๋ฐ˜ํ™˜ + if (cachedList != null) { + return attachUserStatus(user, cachedList); + } + PhotoPageResponse responses = getPhotoPageFromDB(code, page, size, albumSorting); + + redisCacheUtil.setValue(photoKey, responses, 300000L); + + return attachUserStatus(user, responses); + } + + @Transactional + public void invalidatePhotoCache(String code) { // TODO: ์‚ฌ์ง„ ์‚ญ์ œ, ์—…๋กœ๋“œ ๋“ฑ ๋ณ€ํ™”๊ฐ€ ์ผ์–ด๋‚œ ๋ถ€๋ถ„์— ํ•ด๋‹น ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ + String versionKey = String.format(VERSION_KEY, code); + Long version = Optional.ofNullable(redisCacheUtil.getValue(versionKey)).orElse(0L); + + redisCacheUtil.setValue(versionKey, version + 1, null); + + redisCacheUtil.deletePattern("album:" + code + ":photos:*"); + } + + // TODO: ๋ฐ์ดํ„ฐ ์กฐํšŒ ๋กœ์ง์„ Reader ํด๋ž˜์Šค๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ ๊ณ ๋ ค + public PhotoLikedPageResponse getPhotoLiked(User user, String code, int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size); + Slice photos = photoRepository.findLikedPhotosByAlbumAndUser(code, user.getId(), PhotoStatus.COMPLETED, pageRequest); + + List photoIds = photos.getContent().stream() + .map(Photo::getId) + .toList(); + + if (photoIds.isEmpty()) { + return PhotoMapper.toPhotoLikedPageResponse(photos, List.of()); + } + + Set liked = findUserLikedPhotoIds(user.getId(), photoIds); + Set downloaded = findUserDownloadedPhotoIds(user.getId(), photoIds); + Set recent = findUserRecentlyDownloadedPhotoIds(user.getId(), photoIds); + + List responses = buildPhotoLikedResponses(photos.getContent(), liked, downloaded, recent); + + return PhotoMapper.toPhotoLikedPageResponse(photos, responses); + } + + public PhotoDetailResponse getPhotoDetail(User user, String code, Long photoId) { + Photo photo = photoReader.getPhotoInAlbum(photoId, code); + + String profileImage = ProfileImageUtil.resolveProfileImage(photo.getUser(), cdnUrlResolver); + String resolveOriginalUrl = cdnUrlResolver.resolveOriginal(photo.getImageUrl()); + String resolveThumbnailUrl = cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl()); + + boolean isLiked = photoLikesRepository.existsByUserIdAndPhotoId(user.getId(), photo.getId()); + boolean isDownloaded = photoHistoryRepository.existsByUserIdAndPhotoId(user.getId(), photo.getId()); + boolean isRecentlyDownloaded = photoHistoryRepository.existsByUserIdAndPhotoIdAndUpdatedAtAfter( + user.getId(), photo.getId(), LocalDateTime.now().minusHours(1) + ); + boolean canDelete = photo.getUser().getId().equals(user.getId()) + || photo.getAlbum().getMakerId().equals(user.getId()); + + return PhotoMapper.toPhotoDetailResponse( + photo, profileImage, resolveOriginalUrl, resolveThumbnailUrl, isLiked, isDownloaded, isRecentlyDownloaded, canDelete + ); + } + + private PhotoPageResponse getPhotoPageFromDB(String code, int page, int size, AlbumSorting albumSorting) { + PageRequest pageRequest = PageRequest.of(page, size, getPhotoSortingOption(albumSorting)); + Slice photos = photoRepository.findAllByAlbumCodeAndStatus(code, PhotoStatus.COMPLETED, pageRequest); + + List responses = photos.getContent().stream() + .map(photo -> { + String profileImage = ProfileImageUtil.resolveProfileImage(photo.getUser(), cdnUrlResolver); + String imageUrl = cdnUrlResolver.resolveOriginal(photo.getImageUrl()); + String thumbnailUrl = cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl()); + return PhotoMapper.toPhotoListResponse(photo, profileImage, imageUrl, thumbnailUrl, false, false); + }) + .toList(); + + return PhotoMapper.toPhotoPageResponse(photos, responses); + } + + private PhotoPageResponse attachUserStatus(User user, PhotoPageResponse response) { + List photoIds = extractPhotoIds(response); + Set likedIds = findUserLikedPhotoIds(user.getId(), photoIds); + Set downloadedIds = findUserDownloadedPhotoIds(user.getId(), photoIds); + Set recentlyDownloadedIds = findUserRecentlyDownloadedPhotoIds(user.getId(), photoIds); + List updatedResponses = updateUserStatus(response.responses(), likedIds, downloadedIds, recentlyDownloadedIds); + return PhotoMapper.toRebuildPhotoPageResponse(response, updatedResponses); + } + + private List extractPhotoIds(PhotoPageResponse response) { + return response.responses().stream() + .map(PhotoListResponse::photoId) + .toList(); + } + + private Set findUserLikedPhotoIds(Long userId, List photoIds) { + return photoLikesRepository.findAllLikedPhotoIds(userId, photoIds); + } + + private Set findUserDownloadedPhotoIds(Long userId, List photoIds) { + return photoHistoryRepository.findDownloadedPhotoIds(userId, photoIds); + } + + private Set findUserRecentlyDownloadedPhotoIds(Long userId, List photoIds) { + return photoHistoryRepository.findRecentlyDownloadedPhotoIds(userId, photoIds, LocalDateTime.now().minusHours(1)); + } + + private List updateUserStatus( + List responses, + Set likeIds, + Set downloadedIds, + Set recentlyDownloadedIds + ) { + return responses.stream() + .map(response -> response.withUserStatus( + likeIds.contains(response.photoId()), + downloadedIds.contains(response.photoId()), + recentlyDownloadedIds.contains(response.photoId()) + )).toList(); + } + + private Sort getPhotoSortingOption(AlbumSorting albumSorting) { + return switch (albumSorting) { + case POPULAR -> Sort.by(Sort.Order.desc("likesCnt"), Sort.Order.desc("createdAt")); + case CAPTURED_AT -> Sort.by(Sort.Direction.DESC, "captureTime"); + case CREATED_AT -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } + + private List buildPhotoLikedResponses(List photos, Set liked, Set downloaded, Set recent) { + return photos.stream() + .map(photo -> { + Long id = photo.getId(); + String imageUrl = cdnUrlResolver.resolveOriginal(photo.getImageUrl()); + String thumbnailUrl = cdnUrlResolver.resolveThumbnail(photo.getThumbnailUrl()); + boolean isLiked = liked.contains(id); + boolean isDownloaded = downloaded.contains(id); + boolean isRecentlyDownloaded = recent.contains(id); + return PhotoMapper.toPhotoLikedResponse(photo, imageUrl, thumbnailUrl, isLiked, isDownloaded, isRecentlyDownloaded); + }) + .toList(); + } +} diff --git a/src/main/java/com/cheeeese/photo/application/PhotoService.java b/src/main/java/com/cheeeese/photo/application/PhotoService.java new file mode 100644 index 0000000..bebee74 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/PhotoService.java @@ -0,0 +1,313 @@ +package com.cheeeese.photo.application; + +import com.cheeeese.album.application.support.AlbumReader; +import com.cheeeese.album.application.validator.AlbumValidator; +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.global.util.S3Util; +import com.cheeeese.photo.application.logger.PhotoLogger; +import com.cheeeese.photo.application.support.PhotoReader; +import com.cheeeese.photo.application.validator.PhotoValidator; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoHistory; +import com.cheeeese.photo.domain.PhotoLikes; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.dto.request.PhotoDownloadRequest; +import com.cheeeese.photo.dto.request.PhotoPresignedUrlRequest; +import com.cheeeese.photo.dto.request.PhotoUploadReportRequest; +import com.cheeeese.photo.dto.response.PhotoDownloadResponse; +import com.cheeeese.photo.dto.response.PhotoPresignedUrlResponse; +import com.cheeeese.photo.exception.PhotoException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import com.cheeeese.photo.infrastructure.mapper.PhotoHistoryMapper; +import com.cheeeese.photo.infrastructure.mapper.PhotoLikesMapper; +import com.cheeeese.photo.infrastructure.mapper.PhotoMapper; +import com.cheeeese.photo.infrastructure.persistence.PhotoHistoryRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.application.UserService; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PhotoService { + + private final UserService userService; + private final UserRepository userRepository; + private final PhotoRepository photoRepository; + private final AlbumRepository albumRepository; + private final PhotoLikesRepository photoLikesRepository; + private final PhotoHistoryRepository photoHistoryRepository; + private final PhotoReader photoReader; + private final PhotoValidator photoValidator; + private final AlbumValidator albumValidator; + private final AlbumReader albumReader; + private final PresignedUrlService presignedUrlService; + private final PhotoQueryService photoQueryService; + + private final PhotoLogger photoLogger; + + @Value("${ncp.object-storage.bucket}") + private String bucket; + + private static final String ORIGINAL_PHOTO_PATH_FORMAT = "album/%s/original/%d_%s"; + + public List getRecentPhotosForNewEnter(Long albumId) { + return photoRepository.findRecentPhotosByAlbumIdAndStatus( + albumId, + PhotoStatus.COMPLETED, + PageRequest.of(0, 5) + ); + } + + @Transactional + public PhotoPresignedUrlResponse createPresignedUrls(User user, PhotoPresignedUrlRequest request) { + Album album = albumRepository.findByIdForUpdate( + albumValidator.validateAlbumCode(request.albumCode()).getId() + ); + albumValidator.validateAlbumParticipant(album, user); + + long currentActiveCount = photoRepository.countActivePhotosByAlbumId( + album.getId(), + Arrays.asList(PhotoStatus.UPLOADING, PhotoStatus.COMPLETED) + ); + + validateUploadRequest(album, request, currentActiveCount); + + List presignedUrls = generatePresignedUrls(user, album, request.fileInfos()); + + log.info("[Photo] Photo Upload Request | user_id={} album_code={} file_count={}", + user.getId(), request.albumCode(), request.fileInfos().size()); + + return PhotoMapper.toPresignedUrlResponse(presignedUrls); + } + + @Transactional + public PhotoDownloadResponse getDownloadPresignedUrls(User user, PhotoDownloadRequest request) { + Album album = validateAlbumAndPermission(user, request.code()); + + List photos = photoRepository.findAllByIdIn(request.photoIds()); + + albumValidator.validateDownloadPermission(album, user, photos); + + Set recentDownloadIds = photoHistoryRepository.findRecentlyDownloadedPhotoIds( + user.getId(), + request.photoIds(), + LocalDateTime.now().minusHours(1) + ); + + List presignedUrls = generateDownloadPresignedUrls( + photos, recentDownloadIds + ); + + photos.stream() + .filter(photo -> !recentDownloadIds.contains(photo.getId())) + .forEach(photo -> photoHistoryRepository.findByUserIdAndPhotoId(user.getId(), photo.getId()) + .ifPresentOrElse( + PhotoHistory::touch, + () -> photoHistoryRepository.save(PhotoHistoryMapper.toEntity(user, photo)) + ) + ); + + photoLogger.logDownload(user.getId(), request.code(), request.photoIds()); + + return PhotoMapper.toPhotoDownloadResponse(presignedUrls); + } + + @Transactional + public void reportUploadResult(User user, PhotoUploadReportRequest request) { + List failurePhotoIds = request.failurePhotoIds().stream() + .distinct() + .toList(); + + PhotoValidator.ValidatedPhotos validated = photoValidator.validatePhotos(user.getId(), failurePhotoIds); + Long albumId = validated.albumId(); + + handleFailedUploads(user, albumId, failurePhotoIds); + } + + @Transactional + public void createPhotoLikes(User user, Long photoId) { + Photo photo = photoReader.getPhoto(photoId); + + PhotoLikes photoLikes = PhotoLikesMapper.toEntity(user, photo); + + photoRepository.incrementLikeCnt(photo.getId()); + photoLikesRepository.save(photoLikes); + + userRepository.incrementLikeCnt(photo.getUser().getId()); + + photoQueryService.invalidatePhotoCache(photo.getAlbum().getCode()); + } + + @Transactional + public void deletePhotoLikes(User user, Long photoId) { + Photo photo = photoReader.getPhoto(photoId); + + PhotoLikes photoLikes = photoLikesRepository.findByUserIdAndPhotoId(user.getId(), photo.getId()) + .orElseThrow(() -> new PhotoException(PhotoErrorCode.PHOTO_LIKES_NOT_FOUND)); + + photoRepository.decrementLikeCnt(photo.getId()); + photoLikesRepository.delete(photoLikes); + + userRepository.decrementLikeCnt(photo.getUser().getId()); + + photoQueryService.invalidatePhotoCache(photo.getAlbum().getCode()); + } + + @Transactional + public void cleanupOldUploadingPhotos() { + LocalDateTime threshold = LocalDateTime.now().minusMinutes(30); + int updatedCount = photoRepository.updateOldUploadingPhotosStatus( + PhotoStatus.FAILED, + PhotoStatus.UPLOADING, + threshold + ); + } + + @Transactional + public void deletePhoto(User user, String code, Long photoId) { + Album album = albumValidator.validateAlbumCode(code); + albumValidator.validateAlbumEntry(album, user); + + UserAlbum userAlbum = albumReader.getAlbumParticipant(album, user); + + Photo photo = photoReader.getPhotoInAlbum(photoId, code); + + photoValidator.validateDeletePermission(user, userAlbum, album, photo); + + int updatedRows = albumRepository.decrementPhotoCount(album.getId(), 1); + if (updatedRows == 0) { + throw new PhotoException(PhotoErrorCode.PHOTO_COUNT_DECREMENT_FAILED); + } + + userService.decrementPhotoCount(photo.getUser().getId(), 1); + + userRepository.decrementLikeCntBy(photo.getUser().getId(), photo.getLikesCnt()); + + photoLikesRepository.deleteAllByPhotoId(photo.getId()); + + photo.softDelete(); + + photoQueryService.invalidatePhotoCache(album.getCode()); + } + + private Album validateAlbumAndPermission(User user, String albumCode) { + Album album = albumValidator.validateAlbumCode(albumCode); + albumValidator.validateAlbumParticipant(album, user); + return album; + } + + private void validateUploadRequest(Album album, PhotoPresignedUrlRequest request, long currentActiveCount) { + int maxCount = album.getMaxPhotoCount(); + int requestedCount = request.fileInfos().size(); + + photoValidator.validatePhotoCount(currentActiveCount, requestedCount, maxCount); + photoValidator.validateFileInfos(request.fileInfos()); + } + + private List generatePresignedUrls( + User user, + Album album, + List fileInfos + ) { + return fileInfos.stream() + .map(file -> createPresignedUrlForFile(user, album, file)) + .collect(Collectors.toList()); + } + + private List generateDownloadPresignedUrls( + List photos, + Set recentDownloadedIds + ) { + return photos.stream() + .map(photo -> createPresignedUrlForDownload(photo, recentDownloadedIds)) + .toList(); + } + + private PhotoPresignedUrlResponse.PresignedUrlInfo createPresignedUrlForFile( + User user, + Album album, + PhotoPresignedUrlRequest.FileInfo file + ) { + Photo photo = PhotoMapper.toEntity(user, album, file.captureTime()); + photoRepository.save(photo); + + String safeFileName = sanitizeFileName(file.fileName()); + String objectKey = String.format( + ORIGINAL_PHOTO_PATH_FORMAT, + album.getCode(), + photo.getId(), + safeFileName + ); + String imageUrl = bucket + "/" + objectKey; + photo.updateImageUrl(imageUrl); + + String uploadUrl = presignedUrlService.generatePresignedPutUrl(objectKey, file.contentType()); + + return PhotoMapper.toPresignedUrlInfo(photo.getId(), uploadUrl); + } + + private PhotoDownloadResponse.DownloadFileInfo createPresignedUrlForDownload(Photo photo, Set recentDownloadedIds) { + String fileName = S3Util.extractFileName(photo.getImageUrl()); + + // 1์‹œ๊ฐ„ ์ด๋‚ด ๋‹ค์šด๋กœ๋“œ O -> null ๋ฐ˜ํ™˜ + if (recentDownloadedIds.contains(photo.getId())) { + return PhotoMapper.toDownloadPresignedUrlInfo(photo, fileName, null); + } + + String objectKey = S3Util.extractObjectKey(photo.getImageUrl()); + String url = presignedUrlService.generatePresignedGetUrl(objectKey, fileName); + + return PhotoMapper.toDownloadPresignedUrlInfo(photo, fileName, url); + } + + private String sanitizeFileName(String raw) { + String name = raw == null ? "unnamed" : raw; + name = name.replace('\\', '/'); // ๊ตฌ๋ถ„์ž ํ†ต์ผ + int idx = name.lastIndexOf('/'); + if (idx >= 0) name = name.substring(idx + 1); // ๊ฒฝ๋กœ ์ œ๊ฑฐ + name = name.replaceAll("[^a-zA-Z0-9._-]", "_"); // ํ—ˆ์šฉ ๋ฌธ์ž ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ + if (name.isBlank()) name = "unnamed"; + return name; + } + + private void handleFailedUploads(User user, Long albumId, List failurePhotoIds) { + int updatedRows = photoRepository.updateStatusByIdsAndUserIdAndExpectedStatus( + failurePhotoIds, + user.getId(), + PhotoStatus.FAILED, + PhotoStatus.UPLOADING + ); + + if (updatedRows != failurePhotoIds.size()) { + throw new PhotoException(PhotoErrorCode.PHOTO_STATUS_UPDATE_FAILED); + } + + // ์—…๋กœ๋“œ ๋กœ์ง ์žฌ์„ค๊ณ„ (์„ ์ œ์ ์œผ๋กœ count๋ฅผ ์˜ฌ๋ ค๋†“์ง€ ์•Š์Œ -> count ๊ฐ์†Œ ๋กœ์ง ์ œ๊ฑฐ) + // ํ˜น์‹œ ๋ชฐ๋ผ์„œ ๋‚˜๋‘  +// if (updatedRows > 0) { +// int decremented = albumRepository.decrementPhotoCount(albumId, updatedRows); +// if (decremented == 0) { +// throw new PhotoException(PhotoErrorCode.PHOTO_COUNT_DECREMENT_FAILED); +// } +// userService.decrementPhotoCount(user.getId(), updatedRows); +// } + } +} diff --git a/src/main/java/com/cheeeese/photo/application/PresignedUrlService.java b/src/main/java/com/cheeeese/photo/application/PresignedUrlService.java new file mode 100644 index 0000000..d1c4a49 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/PresignedUrlService.java @@ -0,0 +1,54 @@ +package com.cheeeese.photo.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class PresignedUrlService { + + private final S3Presigner s3Presigner; + + @Value("${ncp.object-storage.bucket}") + private String bucket; + + public String generatePresignedPutUrl(String uniqueKey, String contentType) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(uniqueKey) + .contentType(contentType) + .build(); + + PresignedPutObjectRequest presignedRequest = + s3Presigner.presignPutObject(p -> p + .signatureDuration(Duration.ofMinutes(10)) + .putObjectRequest(putObjectRequest) + ); + + return presignedRequest.url().toString(); + } + + public String generatePresignedGetUrl(String uniqueKey, String fileName) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(uniqueKey) + .responseContentDisposition("attachment; filename=\"" + fileName + "\"") + .build(); + + PresignedGetObjectRequest presignedRequest = + s3Presigner.presignGetObject(r -> r + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + ); + + return presignedRequest.url().toString(); + } +} diff --git a/src/main/java/com/cheeeese/photo/application/logger/PhotoLogger.java b/src/main/java/com/cheeeese/photo/application/logger/PhotoLogger.java new file mode 100644 index 0000000..2e55fa6 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/logger/PhotoLogger.java @@ -0,0 +1,43 @@ +package com.cheeeese.photo.application.logger; + +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +public class PhotoLogger { + + private static final String STAT_PREFIX = "[STAT]"; + + /** + * [์ง€ํ‘œ 2, 3] ์‚ฌ์ง„ ์—…๋กœ๋“œ ์™„๋ฃŒ + * photo_upload_completed(user_id, album_code, photo_id, created_at) + */ + public void logUploadCompleted(Long userId, String albumCode, Long photoId) { + try { + MDC.put("type", "photo"); + log.info("{} photo_upload_completed | user_id={} album_code={} photo_id={} created_at={}", + STAT_PREFIX, userId, albumCode, photoId, LocalDateTime.now()); + } finally { + MDC.remove("type"); + } + } + + /** + * [์ง€ํ‘œ 3, 5] ์‚ฌ์ง„ ๋‹ค์šด๋กœ๋“œ + * photo_download_log(user_id, album_code, photo_ids, requested_at) + */ + public void logDownload(Long userId, String albumCode, List photoIds) { + try { + MDC.put("type", "photo"); + log.info("{} photo_download_log | user_id={} album_code={} photo_ids={} requested_at={}", + STAT_PREFIX, userId, albumCode, photoIds, LocalDateTime.now()); + } finally { + MDC.remove("type"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/photo/application/support/PhotoReader.java b/src/main/java/com/cheeeese/photo/application/support/PhotoReader.java new file mode 100644 index 0000000..0208049 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/support/PhotoReader.java @@ -0,0 +1,37 @@ +package com.cheeeese.photo.application.support; + +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.exception.PhotoException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PhotoReader { + + private final PhotoRepository photoRepository; + + public Photo getPhoto(Long photoId) { // TODO: ์ถ”ํ›„ ์‚ญ์ œ + Photo photo = photoRepository.findById(photoId) + .orElseThrow(() -> new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND)); + + if (photo.isDeleted()) { + throw new PhotoException(PhotoErrorCode.PHOTO_ALREADY_DELETED); + } + + return photo; + } + + public Photo getPhotoInAlbum(Long photoId, String albumCode) { + Photo photo = photoRepository.findByIdAndAlbum_Code(photoId, albumCode) + .orElseThrow(() -> new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND)); + + if (photo.isDeleted()) { + throw new PhotoException(PhotoErrorCode.PHOTO_ALREADY_DELETED); + } + + return photo; + } +} diff --git a/src/main/java/com/cheeeese/photo/application/validator/PhotoValidator.java b/src/main/java/com/cheeeese/photo/application/validator/PhotoValidator.java new file mode 100644 index 0000000..d35c104 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/application/validator/PhotoValidator.java @@ -0,0 +1,156 @@ +package com.cheeeese.photo.application.validator; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.dto.request.PhotoPresignedUrlRequest; +import com.cheeeese.photo.exception.PhotoException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PhotoValidator { + + private final PhotoRepository photoRepository; + + private static final long MAX_FILE_SIZE = 6 * 1024 * 1024; // 6MB + private static final List ALLOWED_TYPES = List.of("image/jpeg", "image/png", "image/jpg"); + + /** + * presigned URL ๋ฐœ๊ธ‰ ์ „, ํŒŒ์ผ ์ •๋ณด ํ˜•์‹/์šฉ๋Ÿ‰ ๊ฒ€์ฆ + */ + public void validateFileInfos(List fileInfos) { + if (fileInfos == null || fileInfos.isEmpty()) { + throw new PhotoException(PhotoErrorCode.PHOTO_FILE_LIST_EMPTY); + } + + for (PhotoPresignedUrlRequest.FileInfo file : fileInfos) { + if (file.fileName() == null || file.fileName().isBlank()) { + throw new PhotoException(PhotoErrorCode.PHOTO_FILE_NAME_REQUIRED); + } + + if (file.captureTime() == null) { + throw new PhotoException(PhotoErrorCode.PHOTO_CAPTURE_TIME_REQUIRED); + } + + if (file.fileSize() > MAX_FILE_SIZE) { + throw new PhotoException(PhotoErrorCode.PHOTO_FILE_SIZE_EXCEEDED); + } + + if (!ALLOWED_TYPES.contains(file.contentType())) { + throw new PhotoException(PhotoErrorCode.PHOTO_INVALID_CONTENT_TYPE); + } + } + } + + /** + * ์•จ๋ฒ” ๋‚ด ์—…๋กœ๋“œ ์ œํ•œ ๊ฒ€์ฆ + */ + public void validatePhotoCount(long currentCount, int uploadCount, int maxPhotoCount) { + if (currentCount + uploadCount > maxPhotoCount) { + throw new PhotoException(PhotoErrorCode.PHOTO_MAX_COUNT_EXCEEDED); + } + } + + /** + * ์กด์žฌ, ์†Œ์œ , ๋™์ผ ์•จ๋ฒ” ๊ฒ€์ฆ์„ ํ†ตํ•ฉ ์ˆ˜ํ–‰ + */ + public ValidatedPhotos validatePhotos(Long userId, List photoIds) { + validatePhotoIdsNotEmpty(photoIds); + List photos = findAndValidateExistence(photoIds); + validateOwnership(photos, userId); + Long albumId = validateSingleAlbum(photos); + return new ValidatedPhotos(photos, albumId); + } + + /** + * photoIds๊ฐ€ ๋น„์–ด์žˆ์ง€ ์•Š์€์ง€ ๊ฒ€์ฆ + */ + private void validatePhotoIdsNotEmpty(List photoIds) { + if (photoIds == null || photoIds.isEmpty()) { + throw new PhotoException(PhotoErrorCode.PHOTO_ID_LIST_EMPTY); + } + } + + /** + * photoId ๋ฆฌ์ŠคํŠธ ๊ธฐ๋ฐ˜์œผ๋กœ ์กด์žฌํ•˜๋Š” ์‚ฌ์ง„ ์กฐํšŒ ๋ฐ ์กด์žฌ ๊ฒ€์ฆ + */ + private List findAndValidateExistence(List photoIds) { + List photos = photoRepository.findAllById(photoIds); + + Set uniqueRequestedIds = new HashSet<>(photoIds); + + Set foundIds = photos.stream() + .map(Photo::getId) + .collect(Collectors.toSet()); + + Set missingIds = uniqueRequestedIds.stream() + .filter(id -> !foundIds.contains(id)) + .collect(Collectors.toSet()); + + if (!missingIds.isEmpty()) { + log.error("์กด์žฌํ•˜์ง€ ์•Š๋Š” photoIds: {}", missingIds); + throw new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND); + } + return photos; + } + + /** + * ์†Œ์œ ์ž ์ผ์น˜ ๊ฒ€์ฆ + */ + private void validateOwnership(List photos, Long userId) { + boolean invalidOwner = photos.stream() + .anyMatch(photo -> !photo.getUser().getId().equals(userId)); + + if (invalidOwner) { + throw new PhotoException(PhotoErrorCode.PHOTO_OWNER_MISMATCH); + } + } + + /** + * ๋™์ผ ์•จ๋ฒ” ๊ฒ€์ฆ + */ + private Long validateSingleAlbum(List photos) { + Set albumIds = photos.stream() + .map(photo -> photo.getAlbum().getId()) + .collect(Collectors.toSet()); + + if (albumIds.size() != 1) { + throw new PhotoException(PhotoErrorCode.PHOTO_REPORT_INVALID_ALBUM); + } + + return albumIds.iterator().next(); + } + + /** + * ๊ฒ€์ฆ ๊ฒฐ๊ณผ ๊ฐ์ฒด: ์•จ๋ฒ” ID + ์‚ฌ์ง„ ๋ฆฌ์ŠคํŠธ ๋ณด๊ด€ + */ + public record ValidatedPhotos(List photos, Long albumId) {} + + /** + * ์‚ฌ์ง„ ์‚ญ์ œ ๊ถŒํ•œ ๊ฒ€์ฆ + */ + public void validateDeletePermission(User user, UserAlbum userAlbum, Album album, Photo photo) { + if (userAlbum.getRole() == Role.MAKER) return; + + if (!photo.getUser().getId().equals(user.getId())) { + throw new PhotoException(PhotoErrorCode.PHOTO_OWNER_MISMATCH); + } + + if (!photo.getAlbum().getId().equals(album.getId())) { + throw new PhotoException(PhotoErrorCode.PHOTO_NOT_FOUND_IN_ALBUM); + } + } +} diff --git a/src/main/java/com/cheeeese/photo/domain/Photo.java b/src/main/java/com/cheeeese/photo/domain/Photo.java new file mode 100644 index 0000000..ae2f7a5 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/domain/Photo.java @@ -0,0 +1,78 @@ +package com.cheeeese.photo.domain; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.global.domain.BaseEntity; +import com.cheeeese.user.domain.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "photo") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Photo extends BaseEntity { + + @Id + @Column(name = "photo_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "album_id", nullable = false) + private Album album; + + @Column(name = "image_url", columnDefinition = "TEXT") + private String imageUrl; + + @Column(name = "thumbnail_url", columnDefinition = "TEXT") + private String thumbnailUrl; + + @Column(name = "likes_cnt", nullable = false) + private int likesCnt; + + @Column(name = "capture_time", nullable = false) + private LocalDateTime captureTime; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PhotoStatus status; + + @Builder + private Photo( + User user, + Album album, + String imageUrl, + String thumbnailUrl, + LocalDateTime captureTime, + PhotoStatus status + ) { + this.user = user; + this.album = album; + this.imageUrl = imageUrl; + this.thumbnailUrl = thumbnailUrl; + this.captureTime = captureTime; + this.likesCnt = 0; + this.isDeleted = false; + this.status = status; + } + + public void updateImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public void softDelete() { + this.isDeleted = true; + } +} diff --git a/src/main/java/com/cheeeese/photo/domain/PhotoHistory.java b/src/main/java/com/cheeeese/photo/domain/PhotoHistory.java new file mode 100644 index 0000000..7d90aec --- /dev/null +++ b/src/main/java/com/cheeeese/photo/domain/PhotoHistory.java @@ -0,0 +1,44 @@ +package com.cheeeese.photo.domain; + +import com.cheeeese.global.domain.BaseEntity; +import com.cheeeese.user.domain.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "photo_history", + uniqueConstraints = @UniqueConstraint( + columnNames = {"user_id", "photo_id"} + ) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhotoHistory extends BaseEntity { + + @Id + @Column(name = "photo_history_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "photo_id", nullable = false) + private Photo photo; + + @Builder + private PhotoHistory(User user, Photo photo) { + this.user = user; + this.photo = photo; + } + + public void touch() { + this.markUpdated(); + } +} diff --git a/src/main/java/com/cheeeese/photo/domain/PhotoLikes.java b/src/main/java/com/cheeeese/photo/domain/PhotoLikes.java new file mode 100644 index 0000000..24ecacf --- /dev/null +++ b/src/main/java/com/cheeeese/photo/domain/PhotoLikes.java @@ -0,0 +1,40 @@ +package com.cheeeese.photo.domain; + +import com.cheeeese.global.domain.BaseEntity; +import com.cheeeese.user.domain.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "photo_likes", + uniqueConstraints = @UniqueConstraint( + columnNames = {"user_id", "photo_id"} + ) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhotoLikes extends BaseEntity { + + @Id + @Column(name = "photo_likes_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "photo_id", nullable = false) + private Photo photo; + + @Builder + private PhotoLikes(User user, Photo photo) { + this.user = user; + this.photo = photo; + } +} diff --git a/src/main/java/com/cheeeese/photo/domain/PhotoStatus.java b/src/main/java/com/cheeeese/photo/domain/PhotoStatus.java new file mode 100644 index 0000000..ac4b3f8 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/domain/PhotoStatus.java @@ -0,0 +1,7 @@ +package com.cheeeese.photo.domain; + +public enum PhotoStatus { + UPLOADING, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/cheeeese/photo/dto/request/PhotoCompleteRequest.java b/src/main/java/com/cheeeese/photo/dto/request/PhotoCompleteRequest.java new file mode 100644 index 0000000..e2c1e9c --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/request/PhotoCompleteRequest.java @@ -0,0 +1,12 @@ +package com.cheeeese.photo.dto.request; + + +import jakarta.validation.constraints.NotNull; + +public record PhotoCompleteRequest( + @NotNull(message = "photoId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long photoId, + + @NotNull(message = "thumbnailUrl์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String thumbnailUrl +) {} diff --git a/src/main/java/com/cheeeese/photo/dto/request/PhotoDownloadRequest.java b/src/main/java/com/cheeeese/photo/dto/request/PhotoDownloadRequest.java new file mode 100644 index 0000000..018e232 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/request/PhotoDownloadRequest.java @@ -0,0 +1,21 @@ +package com.cheeeese.photo.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "์‚ฌ์ง„ ๋‹ค์šด๋กœ๋“œ presigned url ๋ฐœ๊ธ‰ API") +public record PhotoDownloadRequest( + @Schema(description = "์•จ๋ฒ” ์ฝ”๋“œ", example = "1f0b7ea8-fab6-6581-95e3-0720bc07603e") + @NotBlank(message = "์•จ๋ฒ” ์ฝ”๋“œ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String code, + + @Schema(description = "์‚ฌ์ง„ ๊ณ ์œ  ID", example = "[1, 2, 3]") + @NotEmpty(message = "์‚ฌ์ง„ ID ๋ชฉ๋ก์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + List photoIds +) { +} diff --git a/src/main/java/com/cheeeese/photo/dto/request/PhotoPresignedUrlRequest.java b/src/main/java/com/cheeeese/photo/dto/request/PhotoPresignedUrlRequest.java new file mode 100644 index 0000000..6d0d67b --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/request/PhotoPresignedUrlRequest.java @@ -0,0 +1,40 @@ +package com.cheeeese.photo.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +@Schema(description = "Presigned URL ๋ฐœ๊ธ‰ ์š”์ฒญ") +public record PhotoPresignedUrlRequest( + @NotNull + @Schema(description = "์•จ๋ฒ” ์ฝ”๋“œ", example = "786ccd09-5f22-4aa9-a32b-f62dd2e94cc8") + String albumCode, + + @NotNull + @Schema(description = "์—…๋กœ๋“œํ•  ํŒŒ์ผ ์ •๋ณด ๋ชฉ๋ก") + List fileInfos +) { + @Builder + @Schema(description = "๊ฐœ๋ณ„ ํŒŒ์ผ ์ •๋ณด") + public record FileInfo( + @NotBlank + @Schema(description = "์›๋ณธ ํŒŒ์ผ๋ช…", example = "my_holiday_pic.jpg") + String fileName, + + @NotNull + @Schema(description = "์ดฌ์˜ ์‹œ๊ฐ„ (์—†์„ ๊ฒฝ์šฐ ํ˜„์žฌ ์‹œ๊ฐ„)", example = "2025-02-01T14:30:00") + LocalDateTime captureTime, + + @Schema(description = "ํŒŒ์ผ ํฌ๊ธฐ (Byte)", example = "3000000") + long fileSize, + + @NotBlank + @Schema(description = "ํŒŒ์ผ Content-Type", example = "image/jpeg") + String contentType + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/photo/dto/request/PhotoUploadReportRequest.java b/src/main/java/com/cheeeese/photo/dto/request/PhotoUploadReportRequest.java new file mode 100644 index 0000000..5fbc186 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/request/PhotoUploadReportRequest.java @@ -0,0 +1,16 @@ +package com.cheeeese.photo.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "์‚ฌ์ง„ ์—…๋กœ๋“œ ๊ฒฐ๊ณผ ๋ณด๊ณ  ์š”์ฒญ (์‹คํŒจ ์ฒ˜๋ฆฌ)") +public record PhotoUploadReportRequest( + @NotNull + @Schema(description = "์—…๋กœ๋“œ ์ค‘ ์‹คํŒจํ•˜๊ฑฐ๋‚˜ ์ทจ์†Œ๋œ ์‚ฌ์ง„ ID ๋ชฉ๋ก (UPLOADING -> FAILED & ๋กค๋ฐฑ)", example = "[1, 3]") + List failurePhotoIds +) { +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/photo/dto/response/PhotoDetailResponse.java b/src/main/java/com/cheeeese/photo/dto/response/PhotoDetailResponse.java new file mode 100644 index 0000000..2a0d030 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/response/PhotoDetailResponse.java @@ -0,0 +1,61 @@ +package com.cheeeese.photo.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@Schema( + description = "์‚ฌ์ง„ ์ƒ์„ธ ์กฐํšŒ API", + requiredProperties = { + "name", + "profileImage", + "photoId", + "imageUrl", + "thumbnailUrl", + "likesCnt", + "isLiked", + "isDownloaded", + "isRecentlyDownloaded", + "canDeleted" + } +) +public record PhotoDetailResponse( + @Schema(description = "์—…๋กœ๋” ์ด๋ฆ„", example = "์ฃผ์ •๋นˆ") + String name, + + @Schema(description = "์—…๋กœ๋” ํ”„๋กœํ•„ ์ด๋ฏธ์ง€", example = "https://say-cheese...") + String profileImage, + + @Schema(description = "์‚ฌ์ง„ ID", example = "1") + Long photoId, + + @Schema(description = "์‚ฌ์ง„ ์›๋ณธ url", example = "example.jpg") + String imageUrl, + + @Schema(description = "์‚ฌ์ง„ ์ธ๋„ค์ผ url", example = "example.jpg") + String thumbnailUrl, + + @Schema(description = "์ข‹์•„์š” ์ˆ˜", example = "1") + int likesCnt, + + @Schema(description = "์ข‹์•„์š” ์—ฌ๋ถ€", example = "true") + boolean isLiked, + + @Schema(description = "๋‹ค์šด๋กœ๋“œ ์—ฌ๋ถ€", example = "false") + boolean isDownloaded, + + @Schema(description = "1์‹œ๊ฐ„ ์ด๋‚ด ๋‹ค์šด๋กœ๋“œ ์—ฌ๋ถ€", example = "false") + boolean isRecentlyDownloaded, + + @Schema(description = "์‚ญ์ œ ๊ฐ€๋Šฅ ์—ฌ๋ถ€", example = "false") + boolean canDelete, + + @Schema(description = "์ดฌ์˜ ์‹œ๊ฐ", example = "2025-01-13T14:23:45") + LocalDateTime captureTime, + + @Schema(description = "์—…๋กœ๋“œ ์‹œ๊ฐ", example = "2025-01-13T14:23:45") + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/cheeeese/photo/dto/response/PhotoDownloadResponse.java b/src/main/java/com/cheeeese/photo/dto/response/PhotoDownloadResponse.java new file mode 100644 index 0000000..6ba5252 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/response/PhotoDownloadResponse.java @@ -0,0 +1,67 @@ +package com.cheeeese.photo.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +@Schema( + description = "์‚ฌ์ง„ ๋‹ค์šด๋กœ๋“œ ์‘๋‹ต DTO", + requiredProperties = { + "downloadFiles" + } +) +public record PhotoDownloadResponse( + @Schema( + description = "๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅํ•œ ์‚ฌ์ง„ ํŒŒ์ผ ์ •๋ณด ๋ชฉ๋ก", + example = """ + [ + { + "photoId": 1, + "downloadUrl": "https://kr.objectstorage...", + "fileName": "IMG_001.jpg", + "captureTime": "2025-11-10T13:45:12", + "createdAt": "2025-11-10T14:00:00" + }, + { + "photoId": 2, + "downloadUrl": null, + "fileName": "IMG_002.jpg", + "captureTime": "2025-11-10T13:50:30", + "createdAt": "2025-11-10T14:10:00" + } + ] + """ + ) + List downloadFiles +) { + @Builder + @Schema( + description = "๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅํ•œ ์‚ฌ์ง„ ํŒŒ์ผ ์ •๋ณด", + requiredProperties = { + "photoId", + "downloadUrl", + "fileName", + "captureTime", + "createdAt" + } + ) + public record DownloadFileInfo( + @Schema(description = "์‚ฌ์ง„ ๊ณ ์œ  ID", example = "1") + Long photoId, + + @Schema(description = "ํด๋ผ์šฐ๋“œ ์Šคํ† ๋ฆฌ์ง€์—์„œ ๋‹ค์šด๋กœ๋“œ ๋ฐ›์„ URL", example = "https://kr.objectstorage...") + String downloadUrl, + + @Schema(description = "ํŒŒ์ผ ์›๋ณธ ์ด๋ฆ„", example = "IMG_002.jpg") + String fileName, + + @Schema(description = "์‚ฌ์ง„ ์ดฌ์˜ ์‹œ๊ฐ", example = "2025-11-10T13:50:30") + LocalDateTime captureTime, + + @Schema(description = "์‚ฌ์ง„ ์—…๋กœ๋“œ ์‹œ๊ฐ", example = "2025-11-10T14:10:00") + LocalDateTime createdAt + ) {} +} diff --git a/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedPageResponse.java b/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedPageResponse.java new file mode 100644 index 0000000..8ef4156 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedPageResponse.java @@ -0,0 +1,49 @@ +package com.cheeeese.photo.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema( + description = "๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์‚ฌ์ง„ ์กฐํšŒ ํŽ˜์ด์ง€๋„ค์ด์…˜", + requiredProperties = { + "responses", + "listSize", + "isFirst", + "isLast", + "hasNext" + } +) +public record PhotoLikedPageResponse( + @Schema( + description = "์‚ฌ์ง„ ๋ชฉ๋ก", + example = """ + [ + { + "name": "์ฃผ์ •๋นˆ", + "photoId": 1, + "imageUrl": "https://cdn.cheeeese.me/original.jpg", + "thumbnailUrl": "https://cdn.cheeeese.me/thumb1.jpg", + "isDownloaded": false, + "isRecentlyDownloaded": false + } + ] + """ + ) + List responses, + + @Schema(description = "ํ˜„์žฌ ํŽ˜์ด์ง€์˜ ์‚ฌ์ง„ ๊ฐœ์ˆ˜", example = "10") + int listSize, + + @Schema(description = "์ฒซ ๋ฒˆ์งธ ํŽ˜์ด์ง€ ์—ฌ๋ถ€", example = "true") + boolean isFirst, + + @Schema(description = "๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€ ์—ฌ๋ถ€", example = "false") + boolean isLast, + + @Schema(description = "๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€", example = "true") + boolean hasNext +) { +} diff --git a/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedResponse.java b/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedResponse.java new file mode 100644 index 0000000..56aa985 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedResponse.java @@ -0,0 +1,41 @@ +package com.cheeeese.photo.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema( + description = "๋‚ด๊ฐ€ ๋ฑํ•œ ์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ API", + requiredProperties = { + "photoId", + "thumbnailUrl", + "isDownloaded", + "isRecentlyDownloaded" + } +) +public record PhotoLikedResponse( + @Schema(description = "์‚ฌ์ง„ ์—…๋กœ๋” ์ด๋ฆ„", example = "์ฃผ์ •๋นˆ") + String name, + + @Schema(description = "์‚ฌ์ง„ ID", example = "1") + Long photoId, + + @Schema(description = "์‚ฌ์ง„ ์›๋ณธ url", example = "example.jpg") + String imageUrl, + + @Schema(description = "์‚ฌ์ง„ ์ธ๋„ค์ผ url", example = "example.jpg") + String thumbnailUrl, + + @Schema(description = "์ข‹์•„์š” ์ˆ˜", example = "1") + int likeCnt, + + @Schema(description = "์ข‹์•„์š” ์—ฌ๋ถ€", example = "true") + boolean isLiked, + + @Schema(description = "๋‹ค์šด๋กœ๋“œ ์—ฌ๋ถ€", example = "false") + boolean isDownloaded, + + @Schema(description = "1์‹œ๊ฐ„ ์ด๋‚ด ๋‹ค์šด๋กœ๋“œ ์—ฌ๋ถ€", example = "false") + boolean isRecentlyDownloaded +) { +} diff --git a/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedUserResponse.java b/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedUserResponse.java new file mode 100644 index 0000000..9b5f35a --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/response/PhotoLikedUserResponse.java @@ -0,0 +1,58 @@ +package com.cheeeese.photo.dto.response; + +import com.cheeeese.album.domain.type.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "๋ฑํ•œ ์‚ฌ๋žŒ ๋ชฉ๋ก API") +public record PhotoLikedUserResponse( + @Schema(description = "์‚ฌ์ง„ ์ข‹์•„์š” ์ด ๊ฐœ์ˆ˜", example = "12") + int likeCnt, + + @Schema( + description = "์‚ฌ์ง„์„ ์ข‹์•„ํ•œ ์‚ฌ์šฉ์ž ๋ชฉ๋ก", + example = """ + [ + { + "name": "ํ™๊ธธ๋™", + "profileImageUrl": "https://cdn.cheeeese.me/profile/hong.jpg", + "isMe": false, + "role": "GUEST" + }, + { + "name": "์ •๋นˆ", + "profileImageUrl": "https://cdn.cheeeese.me/profile/jb.jpg", + "isMe": true, + "role": "MAKER" + } + ] + """ + ) + List photoLikers +) { + + @Builder + @Schema(description = "๋ฑํ•œ ์‚ฌ์šฉ์ž ์ •๋ณด") + public record PhotoLiker( + @Schema(description = "์‚ฌ์šฉ์ž ์ด๋ฆ„", example = "ํ™๊ธธ๋™") + String name, + + @Schema( + description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL", + example = "https://cdn.cheeeese.me/profile/hong.jpg" + ) + String profileImageUrl, + + @Schema(description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž ์—ฌ๋ถ€", example = "false") + boolean isMe, + + @Schema( + description = "ํ•ด๋‹น ์‚ฌ์ง„์ด ์†ํ•œ ์•จ๋ฒ”์—์„œ์˜ ์—ญํ• ", + example = "GUEST" + ) + Role role + ) {} +} diff --git a/src/main/java/com/cheeeese/photo/dto/response/PhotoListResponse.java b/src/main/java/com/cheeeese/photo/dto/response/PhotoListResponse.java new file mode 100644 index 0000000..680a55c --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/response/PhotoListResponse.java @@ -0,0 +1,54 @@ +package com.cheeeese.photo.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder(toBuilder = true) +@Schema( + description = "์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ API", + requiredProperties = { + "photoId", + "profileImage", + "thumbnailUrl", + "likeCnt", + "isLiked", + "isDownloaded", + "isRecentlyDownloaded" + } +) +public record PhotoListResponse( + @Schema(description = "์‚ฌ์ง„ ์—…๋กœ๋” ์ด๋ฆ„", example = "์ฃผ์ •๋นˆ") + String name, + + @Schema(description = "์‚ฌ์ง„ ID", example = "1") + Long photoId, + + @Schema(description = "์—…๋กœ๋” ํ”„๋กœํ•„ ์ด๋ฏธ์ง€", example = "example.jpg") + String profileImage, + + @Schema(description = "์‚ฌ์ง„ ์›๋ณธ url", example = "example.jpg") + String imageUrl, + + @Schema(description = "์‚ฌ์ง„ ์ธ๋„ค์ผ url", example = "example.jpg") + String thumbnailUrl, + + @Schema(description = "์ข‹์•„์š” ์ˆ˜", example = "1") + int likeCnt, + + @Schema(description = "์ข‹์•„์š” ์—ฌ๋ถ€", example = "true") + boolean isLiked, + + @Schema(description = "๋‹ค์šด๋กœ๋“œ ์—ฌ๋ถ€", example = "false") + boolean isDownloaded, + + @Schema(description = "1์‹œ๊ฐ„ ์ด๋‚ด ๋‹ค์šด๋กœ๋“œ ์—ฌ๋ถ€", example = "false") + boolean isRecentlyDownloaded +) { + public PhotoListResponse withUserStatus(boolean isLiked, boolean isDownloaded, boolean isRecentlyDownloaded) { + return this.toBuilder() + .isLiked(isLiked) + .isDownloaded(isDownloaded) + .isRecentlyDownloaded(isRecentlyDownloaded) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/photo/dto/response/PhotoPageResponse.java b/src/main/java/com/cheeeese/photo/dto/response/PhotoPageResponse.java new file mode 100644 index 0000000..f62aa19 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/response/PhotoPageResponse.java @@ -0,0 +1,52 @@ +package com.cheeeese.photo.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema( + description = "์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ ํŽ˜์ด์ง€๋„ค์ด์…˜", + requiredProperties = { + "responses", + "listSize", + "isFirst", + "isLast", + "hasNext" + } +) +public record PhotoPageResponse( + @Schema( + description = "์‚ฌ์ง„ ๋ชฉ๋ก", + example = """ + [ + { + "name": "์ฃผ์ •๋นˆ", + "photoId": 1, + "profileImage": "https://say-cheese-profile.edge...", + "imageUrl": "https://cdn.cheeeese.me/original.jpg", + "thumbnailUrl": "https://cdn.cheeeese.me/thumb1.jpg", + "likeCnt": 1, + "isLiked": true, + "isDownloaded": false, + "isRecentlyDownloaded": false + } + ] + """ + ) + List responses, + + @Schema(description = "ํ˜„์žฌ ํŽ˜์ด์ง€์˜ ์‚ฌ์ง„ ๊ฐœ์ˆ˜", example = "20") + int listSize, + + @Schema(description = "์ฒซ ๋ฒˆ์งธ ํŽ˜์ด์ง€ ์—ฌ๋ถ€", example = "true") + boolean isFirst, + + @Schema(description = "๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€ ์—ฌ๋ถ€", example = "false") + boolean isLast, + + @Schema(description = "๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€", example = "true") + boolean hasNext +) { +} diff --git a/src/main/java/com/cheeeese/photo/dto/response/PhotoPresignedUrlResponse.java b/src/main/java/com/cheeeese/photo/dto/response/PhotoPresignedUrlResponse.java new file mode 100644 index 0000000..411f203 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/dto/response/PhotoPresignedUrlResponse.java @@ -0,0 +1,34 @@ +package com.cheeeese.photo.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema( + description = "Presigned URL ๋ฐœ๊ธ‰ ์‘๋‹ต", + requiredProperties = { + "presignedUrlInfos" + } +) +public record PhotoPresignedUrlResponse( + @Schema(description = "๋ฐœ๊ธ‰๋œ Presigned URL ๋ชฉ๋ก") + List presignedUrlInfos +) { + @Builder + @Schema( + description = "Presigned URL ์ •๋ณด", + requiredProperties = { + "photoId", + "uploadUrl" + } + ) + public record PresignedUrlInfo( + @Schema(description = "์ €์žฅ๋œ ์‚ฌ์ง„ ID", example = "100") + Long photoId, + + @Schema(description = "ํด๋ผ์šฐ๋“œ ์Šคํ† ๋ฆฌ์ง€์— ์—…๋กœ๋“œํ•  URL", example = "https://bucket.s3.ap-northeast-2.amazonaws.com/...") + String uploadUrl + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/photo/exception/PhotoException.java b/src/main/java/com/cheeeese/photo/exception/PhotoException.java new file mode 100644 index 0000000..3b7ec96 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/exception/PhotoException.java @@ -0,0 +1,13 @@ +package com.cheeeese.photo.exception; + +import com.cheeeese.global.exception.BusinessException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import lombok.Getter; + +@Getter +public class PhotoException extends BusinessException { + public PhotoException(PhotoErrorCode errorCode) { + super(errorCode); + } +} + diff --git a/src/main/java/com/cheeeese/photo/exception/code/PhotoErrorCode.java b/src/main/java/com/cheeeese/photo/exception/code/PhotoErrorCode.java new file mode 100644 index 0000000..51df56a --- /dev/null +++ b/src/main/java/com/cheeeese/photo/exception/code/PhotoErrorCode.java @@ -0,0 +1,40 @@ +package com.cheeeese.photo.exception.code; + +import com.cheeeese.global.common.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum PhotoErrorCode implements BaseCode { + + // Presigned URL ๋ฐœ๊ธ‰ ๊ด€๋ จ ์˜ค๋ฅ˜ + PHOTO_MAX_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "์•จ๋ฒ”์˜ ์ตœ๋Œ€ ์‚ฌ์ง„ ๊ฐœ์ˆ˜๋ฅผ ์ดˆ๊ณผํ•ฉ๋‹ˆ๋‹ค."), + PHOTO_FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "ํŒŒ์ผ ํฌ๊ธฐ๋Š” 6MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + PHOTO_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "์œ ํšจํ•œ ์ด๋ฏธ์ง€ ํŒŒ์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค."), + PHOTO_FILE_LIST_EMPTY(HttpStatus.BAD_REQUEST, "์—…๋กœ๋“œํ•  ํŒŒ์ผ ๋ชฉ๋ก์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_FILE_NAME_REQUIRED(HttpStatus.BAD_REQUEST, "ํŒŒ์ผ๋ช…์ด ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_COUNT_INCREMENT_FAILED(HttpStatus.CONFLICT, "์•จ๋ฒ” ์‚ฌ์ง„ ๊ฐœ์ˆ˜ ์ฆ๊ฐ€์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + + // ์—…๋กœ๋“œ ๋ณด๊ณ  ๊ด€๋ จ ์˜ค๋ฅ˜ + PHOTO_ID_LIST_EMPTY(HttpStatus.BAD_REQUEST, "์š”์ฒญ์— photoId ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_REPORT_INVALID_ALBUM(HttpStatus.BAD_REQUEST, "๋ณด๊ณ ๋œ ์‚ฌ์ง„๋“ค์€ ๋ฐ˜๋“œ์‹œ ๋™์ผํ•œ ์•จ๋ฒ”์— ์†ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + PHOTO_OWNER_MISMATCH(HttpStatus.FORBIDDEN, "์‚ฌ์šฉ์ž์™€ ์‚ฌ์ง„์˜ ์†Œ์œ ์ž๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + PHOTO_REPORT_CONFLICTING_IDS(HttpStatus.BAD_REQUEST, "์—…๋กœ๋“œ ๊ฒฐ๊ณผ(success/failure) ๋ชฉ๋ก์— ์ค‘๋ณต๋œ ์‚ฌ์ง„ ID๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_STATUS_UPDATE_FAILED(HttpStatus.CONFLICT, "์‚ฌ์ง„ ์ƒํƒœ ์—…๋ฐ์ดํŠธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_COUNT_DECREMENT_FAILED(HttpStatus.CONFLICT, "์•จ๋ฒ” ์‚ฌ์ง„ ๊ฐœ์ˆ˜ ๊ฐ์†Œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + THUMBNAIL_UPDATE_FAILED(HttpStatus.CONFLICT, "์ธ๋„ค์ผ ์ƒํƒœ ์—…๋ฐ์ดํŠธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + + // ์‚ฌ์ง„ ๋„๋ฉ”์ธ ๊ด€๋ จ ์˜ค๋ฅ˜ + PHOTO_NOT_FOUND(HttpStatus.NOT_FOUND, "ํ•ด๋‹น ์‚ฌ์ง„์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + PHOTO_CAPTURE_TIME_REQUIRED(HttpStatus.BAD_REQUEST, "์ดฌ์˜ ์‹œ๊ฐ„์ด ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."), + PHOTO_LIKES_NOT_FOUND(HttpStatus.NOT_FOUND, "์‚ฌ์ง„์— ๋Œ€ํ•œ ์ข‹์•„์š” ๋‚ด์—ญ์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + PHOTO_NOT_FOUND_IN_ALBUM(HttpStatus.NOT_FOUND, "ํ•ด๋‹น ์‚ฌ์ง„์€ ์ด ์•จ๋ฒ”์— ํฌํ•จ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + PHOTO_ALREADY_DELETED(HttpStatus.CONFLICT, "์ด๋ฏธ ์‚ญ์ œ๋œ ์‚ฌ์ง„์ž…๋‹ˆ๋‹ค."), + ; + + private final HttpStatus httpStatus; + private final String message; +} + diff --git a/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoHistoryMapper.java b/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoHistoryMapper.java new file mode 100644 index 0000000..7f4e665 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoHistoryMapper.java @@ -0,0 +1,15 @@ +package com.cheeeese.photo.infrastructure.mapper; + +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoHistory; +import com.cheeeese.user.domain.User; + +public class PhotoHistoryMapper { + + public static PhotoHistory toEntity(User user, Photo photo) { + return PhotoHistory.builder() + .user(user) + .photo(photo) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoLikesMapper.java b/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoLikesMapper.java new file mode 100644 index 0000000..d7979cf --- /dev/null +++ b/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoLikesMapper.java @@ -0,0 +1,15 @@ +package com.cheeeese.photo.infrastructure.mapper; + +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoLikes; +import com.cheeeese.user.domain.User; + +public class PhotoLikesMapper { + + public static PhotoLikes toEntity(User user, Photo photo) { + return PhotoLikes.builder() + .user(user) + .photo(photo) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoMapper.java b/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoMapper.java new file mode 100644 index 0000000..29c8a0e --- /dev/null +++ b/src/main/java/com/cheeeese/photo/infrastructure/mapper/PhotoMapper.java @@ -0,0 +1,179 @@ +package com.cheeeese.photo.infrastructure.mapper; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.dto.response.*; +import com.cheeeese.user.domain.User; +import org.springframework.data.domain.Slice; + +import java.time.LocalDateTime; +import java.util.List; + +public class PhotoMapper { + + public static Photo toEntity(User user, Album album, LocalDateTime captureTime) { + return Photo.builder() + .user(user) + .album(album) + .imageUrl(null) // presigned URL ์ƒ์„ฑ ํ›„ updateImageUrl()๋กœ ์„ธํŒ…๋จ + .thumbnailUrl(null) + .captureTime(captureTime != null ? captureTime : LocalDateTime.now()) + .status(PhotoStatus.UPLOADING) + .build(); + } + + public static PhotoPresignedUrlResponse.PresignedUrlInfo toPresignedUrlInfo( + Long photoId, + String uploadUrl + ) { + return PhotoPresignedUrlResponse.PresignedUrlInfo.builder() + .photoId(photoId) + .uploadUrl(uploadUrl) + .build(); + } + + public static PhotoPresignedUrlResponse toPresignedUrlResponse( + List presignedUrlInfos + ) { + return PhotoPresignedUrlResponse.builder() + .presignedUrlInfos(presignedUrlInfos) + .build(); + } + + public static PhotoDownloadResponse.DownloadFileInfo toDownloadPresignedUrlInfo( + Photo photo, + String fileName, + String downloadUrl + ) { + return PhotoDownloadResponse.DownloadFileInfo.builder() + .photoId(photo.getId()) + .downloadUrl(downloadUrl) + .fileName(fileName) + .captureTime(photo.getCaptureTime()) + .createdAt(photo.getCreatedAt()) + .build(); + } + + public static PhotoDownloadResponse toPhotoDownloadResponse(List downloadFileInfos) { + return PhotoDownloadResponse.builder() + .downloadFiles(downloadFileInfos) + .build(); + } + + public static PhotoListResponse toPhotoListResponse( + Photo photo, + String profileImage, + String imageUrl, + String thumbnailUrl, + boolean isLiked, + boolean isDownloaded + ) { + return PhotoListResponse.builder() + .name(photo.getUser().getName()) + .photoId(photo.getId()) + .profileImage(profileImage) + .imageUrl(imageUrl) + .thumbnailUrl(thumbnailUrl) + .likeCnt(photo.getLikesCnt()) + .isLiked(isLiked) + .isDownloaded(isDownloaded) + .build(); + } + + public static PhotoLikedResponse toPhotoLikedResponse( + Photo photo, + String imageUrl, + String thumbnailUrl, + boolean isLiked, + boolean isDownloaded, + boolean isRecentlyDownloaded + ) { + return PhotoLikedResponse.builder() + .name(photo.getUser().getName()) + .photoId(photo.getId()) + .imageUrl(imageUrl) + .thumbnailUrl(thumbnailUrl) + .likeCnt(photo.getLikesCnt()) + .isLiked(isLiked) + .isDownloaded(isDownloaded) + .isRecentlyDownloaded(isRecentlyDownloaded) + .build(); + } + + public static PhotoPageResponse toPhotoPageResponse(Slice photos, List responses) { + return PhotoPageResponse.builder() + .responses(responses) + .listSize(responses.size()) + .isFirst(photos.isFirst()) + .isLast(photos.isLast()) + .hasNext(photos.hasNext()) + .build(); + } + + public static PhotoLikedPageResponse toPhotoLikedPageResponse(Slice photos, List responses) { + return PhotoLikedPageResponse.builder() + .responses(responses) + .listSize(responses.size()) + .isFirst(photos.isFirst()) + .isLast(photos.isLast()) + .hasNext(photos.hasNext()) + .build(); + } + + public static PhotoPageResponse toRebuildPhotoPageResponse(PhotoPageResponse response, List updated) { + return PhotoPageResponse.builder() + .responses(updated) + .listSize(updated.size()) + .isFirst(response.isFirst()) + .isLast(response.isLast()) + .hasNext(response.hasNext()) + .build(); + } + + public static PhotoDetailResponse toPhotoDetailResponse( + Photo photo, + String profileImage, + String imageUrl, + String thumbnailUrl, + boolean isLiked, + boolean isDownloaded, + boolean isRecentlyDownloaded, + boolean canDelete + ) { + return PhotoDetailResponse.builder() + .name(photo.getUser().getName()) + .photoId(photo.getId()) + .profileImage(profileImage) + .imageUrl(imageUrl) + .thumbnailUrl(thumbnailUrl) + .likesCnt(photo.getLikesCnt()) + .isLiked(isLiked) + .isDownloaded(isDownloaded) + .isRecentlyDownloaded(isRecentlyDownloaded) + .canDelete(canDelete) + .captureTime(photo.getCaptureTime()) + .createdAt(photo.getCreatedAt()) + .build(); + } + + public static PhotoLikedUserResponse.PhotoLiker toPhotoLiker(User user, String profileImageUrl, boolean isMe, Role role) { + return PhotoLikedUserResponse.PhotoLiker.builder() + .name(user.getName()) + .profileImageUrl(profileImageUrl) + .isMe(isMe) + .role(role) + .build(); + } + + public static PhotoLikedUserResponse toPhotoLikerResponse( + Photo photo, + List likers + ) { + return PhotoLikedUserResponse.builder() + .likeCnt(photo.getLikesCnt()) + .photoLikers(likers) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoHistoryRepository.java b/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoHistoryRepository.java new file mode 100644 index 0000000..37ae07c --- /dev/null +++ b/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoHistoryRepository.java @@ -0,0 +1,40 @@ +package com.cheeeese.photo.infrastructure.persistence; + +import com.cheeeese.photo.domain.PhotoHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface PhotoHistoryRepository extends JpaRepository { + boolean existsByUserIdAndPhotoId(Long userId, Long photoId); + + boolean existsByUserIdAndPhotoIdAndUpdatedAtAfter(Long userId, Long photoId, LocalDateTime updatedAt); + + @Query(""" + SELECT ph.photo.id + FROM PhotoHistory ph + WHERE ph.user.id = :userId + AND ph.photo.id IN :photoIds + """) + Set findDownloadedPhotoIds(@Param("userId") Long userId, @Param("photoIds") List photoIds); + + @Query(""" + SELECT ph.photo.id + FROM PhotoHistory ph + WHERE ph.user.id = :userId + AND ph.photo.id IN :photoIds + AND ph.updatedAt >= :threshold + """) + Set findRecentlyDownloadedPhotoIds( + @Param("userId") Long userId, + @Param("photoIds") List photoIds, + @Param("threshold") LocalDateTime threshold + ); + + Optional findByUserIdAndPhotoId(Long userId, Long photoId); +} diff --git a/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoLikesRepository.java b/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoLikesRepository.java new file mode 100644 index 0000000..c8fc233 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoLikesRepository.java @@ -0,0 +1,41 @@ +package com.cheeeese.photo.infrastructure.persistence; + +import com.cheeeese.photo.domain.PhotoLikes; +import com.cheeeese.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface PhotoLikesRepository extends JpaRepository { + @Query(""" + SELECT pl.photo.id + FROM PhotoLikes pl + WHERE pl.user.id = :userId + AND pl.photo.id IN :photoIds + """) + Set findAllLikedPhotoIds(@Param("userId") Long userId, @Param("photoIds") List photoIds); + + boolean existsByUserIdAndPhotoId(Long userId, Long photoId); + + Optional findByUserIdAndPhotoId(Long userId, Long photoId); + + @Query(""" + SELECT COUNT(DISTINCT pl.user.id) + FROM PhotoLikes pl + WHERE pl.photo.id IN :photoIds + """) + long countDistinctUserIdsByPhotoIds(@Param("photoIds") List photoIds); + + @Query(""" + SELECT pl.user + FROM PhotoLikes pl + WHERE pl.photo.id = :photoId + """) + List findLikersByPhotoId(@Param("photoId") Long photoId); + + void deleteAllByPhotoId(Long photoId); +} diff --git a/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoRepository.java b/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoRepository.java new file mode 100644 index 0000000..48321a7 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/infrastructure/persistence/PhotoRepository.java @@ -0,0 +1,192 @@ +package com.cheeeese.photo.infrastructure.persistence; + +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface PhotoRepository extends JpaRepository { + + Optional findByIdAndAlbum_Code(Long photoId, String albumCode); + + @Query(""" + SELECT p + FROM Photo p + JOIN p.album a + WHERE a.code = :code + AND p.isDeleted = FALSE + AND p.status = :status + """) + Slice findAllByAlbumCodeAndStatus( + @Param("code") String code, + @Param("status") PhotoStatus status, + Pageable pageable + ); + + @Query(""" + SELECT p + FROM Photo p + JOIN p.album a + JOIN PhotoLikes pl ON pl.photo = p + WHERE a.code = :albumCode + AND p.isDeleted = FALSE + AND p.status = :status + AND pl.user.id = :userId + """) + Slice findLikedPhotosByAlbumAndUser( + @Param("albumCode") String albumCode, + @Param("userId") Long userId, + @Param("status") PhotoStatus status, + Pageable pageable + ); + + @Modifying + @Query(""" + UPDATE Photo p + SET p.likesCnt = p.likesCnt + 1 + WHERE p.id = :photoId + """) + void incrementLikeCnt(@Param("photoId") Long photoId); + + @Modifying + @Query(""" + UPDATE Photo p + SET p.likesCnt = p.likesCnt - 1 + WHERE p.id = :photoId + AND p.likesCnt > 0 + """) + void decrementLikeCnt(@Param("photoId") Long photoId); + + @Query(""" + SELECT p + FROM Photo p + JOIN FETCH p.user + WHERE p.album.id = :albumId + AND p.isDeleted = FALSE + AND p.status = :status + ORDER BY p.createdAt DESC + """) + List findRecentPhotosByAlbumIdAndStatus( + @Param("albumId") Long albumId, + @Param("status") PhotoStatus status, + Pageable pageable + ); + + @Query(value = """ + SELECT * + FROM ( + SELECT p.*, + ROW_NUMBER() OVER ( + PARTITION BY p.album_id + ORDER BY p.created_at DESC, p.photo_id DESC + ) AS rn + FROM photo p + WHERE p.album_id IN (:albumIds) + AND p.is_deleted = FALSE + AND p.status = :status + ) t + WHERE t.rn <= 3 + ORDER BY t.album_id ASC, t.rn ASC + """, + nativeQuery = true) + List findTop3RecentPhotosInEachAlbum( + @Param("albumIds") List albumIds, + @Param("status") PhotoStatus status + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE Photo p + SET p.status = :newStatus + WHERE p.id IN :photoIds + AND p.user.id = :userId + AND p.status = :expectedStatus + """) + int updateStatusByIdsAndUserIdAndExpectedStatus( + @Param("photoIds") List photoIds, + @Param("userId") Long userId, + @Param("newStatus") PhotoStatus newStatus, + @Param("expectedStatus") PhotoStatus expectedStatus + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Photo p set p.status = :newStatus, p.thumbnailUrl = :thumbnailUrl " + + "where p.id = :photoId and p.status = :expectedStatus") + int updateStatusAndUrl(Long photoId, PhotoStatus expectedStatus, PhotoStatus newStatus, String thumbnailUrl); + + @Query(""" + SELECT p.id + FROM Photo p + WHERE p.album.id = :albumId + AND p.isDeleted = FALSE + AND p.status = :status + ORDER BY p.likesCnt DESC, p.createdAt DESC + """) + List findTop4CompletedPhotoIdsByLikes( + @Param("albumId") Long albumId, + @Param("status") PhotoStatus status, + Pageable pageable + ); + + @Query(""" + SELECT p + FROM Photo p + WHERE p.album.id = :albumId + AND p.isDeleted = FALSE + AND p.status = :status + ORDER BY p.likesCnt DESC, p.createdAt DESC + """) + List findTop4CompletedPhotosByLikes( + @Param("albumId") Long albumId, + @Param("status") PhotoStatus status, + Pageable pageable + ); + + List findAllByIdIn(List photoIds); + + @Query(""" + SELECT p + FROM Photo p + WHERE p.id IN :photoIds + AND p.isDeleted = FALSE + ORDER BY p.likesCnt DESC, p.createdAt DESC, p.id DESC + """) + List findAllByIdInOrderByLikesDescCreatedDesc(@Param("photoIds") List photoIds); + + @Query(""" + SELECT p.album.code + FROM Photo p + WHERE p.id = :photoId + """) + String findAlbumCodeByPhotoId(@Param("photoId") Long photoId); + + @Query(""" + SELECT COUNT(p) + FROM Photo p + WHERE p.album.id = :albumId + AND p.isDeleted = FALSE + AND p.status IN :statuses + """) + long countActivePhotosByAlbumId(@Param("albumId") Long albumId, @Param("statuses") List statuses); + + @Modifying + @Query(""" + UPDATE Photo p + SET p.status = :newStatus + WHERE p.status = :expectedStatus + AND p.createdAt < :threshold + """) + int updateOldUploadingPhotosStatus( + @Param("newStatus") PhotoStatus newStatus, + @Param("expectedStatus") PhotoStatus expectedStatus, + @Param("threshold") LocalDateTime threshold + ); +} diff --git a/src/main/java/com/cheeeese/photo/presentation/PhotoCallbackController.java b/src/main/java/com/cheeeese/photo/presentation/PhotoCallbackController.java new file mode 100644 index 0000000..01d7186 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/presentation/PhotoCallbackController.java @@ -0,0 +1,24 @@ +package com.cheeeese.photo.presentation; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.photo.dto.request.PhotoCompleteRequest; +import com.cheeeese.photo.application.PhotoCallbackService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import static com.cheeeese.global.common.code.SuccessCode.THUMBNAIL_PRODUCE_COMPLETE; + +@RestController +@RequestMapping("/internal/thumbnail") +@RequiredArgsConstructor +public class PhotoCallbackController { + + private final PhotoCallbackService photoCallbackService; + + @PostMapping("/complete") + public CommonResponse completeUpload(@Valid @RequestBody PhotoCompleteRequest request) { + photoCallbackService.markUploadCompleted(request); + return CommonResponse.success(THUMBNAIL_PRODUCE_COMPLETE); + } +} diff --git a/src/main/java/com/cheeeese/photo/presentation/PhotoCommandController.java b/src/main/java/com/cheeeese/photo/presentation/PhotoCommandController.java new file mode 100644 index 0000000..320576a --- /dev/null +++ b/src/main/java/com/cheeeese/photo/presentation/PhotoCommandController.java @@ -0,0 +1,33 @@ +package com.cheeeese.photo.presentation; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.photo.application.PhotoService; +import com.cheeeese.photo.presentation.swagger.PhotoCommandSwagger; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.cheeeese.global.common.code.SuccessCode.PHOTO_DELETE_SUCCESS; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/album/{code}/photo") +public class PhotoCommandController implements PhotoCommandSwagger { + + private final PhotoService photoService; + + @Override + @DeleteMapping("/{photoId}") + public CommonResponse deletePhoto( + @CurrentUser User user, + @PathVariable String code, + @PathVariable Long photoId + ) { + photoService.deletePhoto(user, code, photoId); + return CommonResponse.success(PHOTO_DELETE_SUCCESS); + } +} diff --git a/src/main/java/com/cheeeese/photo/presentation/PhotoController.java b/src/main/java/com/cheeeese/photo/presentation/PhotoController.java new file mode 100644 index 0000000..5cbad22 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/presentation/PhotoController.java @@ -0,0 +1,68 @@ +package com.cheeeese.photo.presentation; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.photo.application.PhotoService; +import com.cheeeese.photo.dto.request.PhotoDownloadRequest; +import com.cheeeese.photo.dto.request.PhotoPresignedUrlRequest; +import com.cheeeese.photo.dto.request.PhotoUploadReportRequest; +import com.cheeeese.photo.dto.response.PhotoDownloadResponse; +import com.cheeeese.photo.dto.response.PhotoPresignedUrlResponse; +import com.cheeeese.photo.presentation.swagger.PhotoSwagger; +import com.cheeeese.user.domain.User; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import static com.cheeeese.global.common.code.SuccessCode.*; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/photo") +public class PhotoController implements PhotoSwagger { + + private final PhotoService photoService; + + @Override + @PostMapping("/presigned-url") + public CommonResponse createPresignedUrls( + @CurrentUser User user, + @RequestBody @Valid PhotoPresignedUrlRequest request + ) { + return CommonResponse.success(PRESIGNED_URL_ISSUE_SUCCESS, photoService.createPresignedUrls(user, request)); + } + + @Override + @PostMapping("/report") + public CommonResponse reportUploadResult( + @CurrentUser User user, + @RequestBody @Valid PhotoUploadReportRequest request + ) { + photoService.reportUploadResult(user, request); + return CommonResponse.success(PHOTO_UPLOAD_REPORT_SUCCESS); + } + + @Override + @PostMapping("/download-url") + public CommonResponse getDownloadPresignedUrls( + @CurrentUser User user, + @RequestBody @Valid PhotoDownloadRequest request + ) { + return CommonResponse.success(PRESIGNED_URL_ISSUE_SUCCESS, photoService.getDownloadPresignedUrls(user, request)); + } + + @Override + @PostMapping("/{photoId}/liked") + public CommonResponse createPhotoLikes(@CurrentUser User user, @PathVariable Long photoId) { + photoService.createPhotoLikes(user, photoId); + return CommonResponse.success(PHOTO_LIKES_CREATE_SUCCESS); + } + + @Override + @DeleteMapping("/{photoId}/unliked") + public CommonResponse deletePhotoLikes(@CurrentUser User user, @PathVariable Long photoId) { + photoService.deletePhotoLikes(user, photoId); + return CommonResponse.success(PHOTO_LIKES_DELETE_SUCCESS); + } +} \ No newline at end of file diff --git a/src/main/java/com/cheeeese/photo/presentation/PhotoQueryController.java b/src/main/java/com/cheeeese/photo/presentation/PhotoQueryController.java new file mode 100644 index 0000000..2b2e829 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/presentation/PhotoQueryController.java @@ -0,0 +1,78 @@ +package com.cheeeese.photo.presentation; + +import com.cheeeese.album.domain.type.AlbumSorting; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.photo.application.PhotoInfoService; +import com.cheeeese.photo.application.PhotoQueryService; +import com.cheeeese.photo.dto.response.*; +import com.cheeeese.photo.presentation.swagger.PhotoQuerySwagger; +import com.cheeeese.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import static com.cheeeese.global.common.code.SuccessCode.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/album") +public class PhotoQueryController implements PhotoQuerySwagger { + + private final PhotoQueryService photoQueryService; + private final PhotoInfoService photoInfoService; + + @Override + @GetMapping("/{code}/photos") + public CommonResponse getAlbumPhotoPage( + @CurrentUser User user, + @PathVariable String code, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "CREATED_AT") AlbumSorting sorting + ) { + return CommonResponse.success( + PHOTO_LIST_GET_SUCCESS, + photoQueryService.getPhotoPage(user, code, page, size, sorting) + ); + } + + @Override + @GetMapping("/{code}/photos/liked") + public CommonResponse getAlbumLikedPhotoPage( + @CurrentUser User user, + @PathVariable String code, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + return CommonResponse.success( + PHOTO_LIKES_LIST_GET_SUCCESS, + photoQueryService.getPhotoLiked(user, code, page, size) + ); + } + + @Override + @GetMapping("/{code}/photos/{photoId}") + public CommonResponse getPhotoDetail( + @CurrentUser User user, + @PathVariable String code, + @PathVariable Long photoId + ) { + return CommonResponse.success( + PHOTO_DETAIL_GET_SUCCESS, + photoQueryService.getPhotoDetail(user, code, photoId) + ); + } + + @Override + @GetMapping("/{code}/photos/{photoId}/likers") + public CommonResponse getPhotoLikedUsers( + @CurrentUser User user, + @PathVariable String code, + @PathVariable Long photoId + ) { + return CommonResponse.success( + PHOTO_LIKERS_GET_SUCCESS, + photoInfoService.getPhotoLikedUsers(user, code, photoId) + ); + } +} diff --git a/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoCommandSwagger.java b/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoCommandSwagger.java new file mode 100644 index 0000000..8105098 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoCommandSwagger.java @@ -0,0 +1,34 @@ +package com.cheeeese.photo.presentation.swagger; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.user.domain.User; +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 org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "[์‚ฌ์ง„ - ๊ด€๋ฆฌ]", description = "์‚ฌ์ง„ ๊ด€๋ฆฌ (์‚ญ์ œ ๋“ฑ)์— ๋Œ€ํ•œ API") +public interface PhotoCommandSwagger { + @Operation( + summary = "์‚ฌ์ง„ ์‚ญ์ œ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ (String) + `photoId`: ์‚ฌ์ง„ ID (Long) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์ง„ ์‚ญ์ œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse deletePhoto( + @CurrentUser User user, + @PathVariable String code, + @PathVariable Long photoId + ); +} diff --git a/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoQuerySwagger.java b/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoQuerySwagger.java new file mode 100644 index 0000000..94f168d --- /dev/null +++ b/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoQuerySwagger.java @@ -0,0 +1,112 @@ +package com.cheeeese.photo.presentation.swagger; + +import com.cheeeese.album.domain.type.AlbumSorting; +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.photo.dto.response.*; +import com.cheeeese.user.domain.User; +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 org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[์‚ฌ์ง„ ์กฐํšŒ]", description = "์‚ฌ์ง„ ์กฐํšŒ ๊ด€๋ จ API") +public interface PhotoQuerySwagger { + @Operation( + summary = "์•จ๋ฒ” ๋‚ด ์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ \n + + ### RequestParam + --- + `page`: ์กฐํšŒํ•  ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (๊ธฐ๋ณธ๊ฐ’: 0) \n + `size`: ํŽ˜์ด์ง€๋‹น ์‚ฌ์ง„ ๊ฐœ์ˆ˜ (๊ธฐ๋ณธ๊ฐ’: 20) \n + `sorting`: ์ •๋ ฌ ๊ธฐ์ค€ (`CREATED_AT`: ์—…๋กœ๋“œ ์‹œ๊ฐ„์ˆœ, `POPULAR`: ๋ฑ ๋งŽ์€์ˆœ, `CAPTURED_AT`: ์ตœ๊ทผ ์ดฌ์˜ํ•œ ์‹œ๊ฐ„์ˆœ) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์•จ๋ฒ” ๋‚ด ์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse getAlbumPhotoPage( + @CurrentUser User user, + @PathVariable String code, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "CREATED_AT") AlbumSorting sorting + ); + + @Operation( + summary = "๋‚ด๊ฐ€ ๋ฑํ•œ ์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ \n + + ### RequestParam + --- + `page`: ์กฐํšŒํ•  ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (๊ธฐ๋ณธ๊ฐ’: 0) \n + `size`: ํŽ˜์ด์ง€๋‹น ์‚ฌ์ง„ ๊ฐœ์ˆ˜ (๊ธฐ๋ณธ๊ฐ’: 10) \n + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "๋‚ด๊ฐ€ ๋ฑํ•œ ์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse getAlbumLikedPhotoPage( + @CurrentUser User user, + @PathVariable String code, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ); + + @Operation( + summary = "์•จ๋ฒ” ๋‚ด ์‚ฌ์ง„ ์ƒ์„ธ ์กฐํšŒ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ \n + `photoId`: ์‚ฌ์ง„ ID + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์•จ๋ฒ” ๋‚ด ์‚ฌ์ง„ ์ƒ์„ธ ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse getPhotoDetail( + @CurrentUser User user, + @PathVariable String code, + @PathVariable Long photoId + ); + + @Operation( + summary = "๋ฑํ•œ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ API", + description = """ + ### PathVariable + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ (String) \n + `photoId`: ์‚ฌ์ง„ ๊ณ ์œ  ID (Long) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "๋ฑํ•œ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse getPhotoLikedUsers( + @CurrentUser User user, + @PathVariable String code, + @PathVariable Long photoId + ); +} diff --git a/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoSwagger.java b/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoSwagger.java new file mode 100644 index 0000000..09498a2 --- /dev/null +++ b/src/main/java/com/cheeeese/photo/presentation/swagger/PhotoSwagger.java @@ -0,0 +1,215 @@ +package com.cheeeese.photo.presentation.swagger; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.photo.dto.request.PhotoDownloadRequest; +import com.cheeeese.photo.dto.request.PhotoPresignedUrlRequest; +import com.cheeeese.photo.dto.request.PhotoUploadReportRequest; +import com.cheeeese.photo.dto.response.PhotoDownloadResponse; +import com.cheeeese.photo.dto.response.PhotoPresignedUrlResponse; +import com.cheeeese.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +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.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[์‚ฌ์ง„]", description = "์‚ฌ์ง„ ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ์— ๋Œ€ํ•œ API") +public interface PhotoSwagger { + @Operation( + summary = "์‚ฌ์ง„ ์—…๋กœ๋“œ Presigned URL ๋ฐœ๊ธ‰ API", + description = """ + ### RequestBody + --- + `albumCode`: ์‚ฌ์ง„์„ ์—…๋กœ๋“œํ•  ์•จ๋ฒ”์˜ ์ฝ”๋“œ \n + `fileInfos`: ์—…๋กœ๋“œํ•  ํŒŒ์ผ ์ •๋ณด ๋ชฉ๋ก (ํŒŒ์ผ๋ช…, captureTime, ํฌ๊ธฐ, Content-Type) \n + + ### ๋กœ์ง ์ƒ์„ธ + --- + 1. ์•จ๋ฒ”์˜ ์กด์žฌ ๋ฐ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ + 2. ์•จ๋ฒ”์˜ ์ตœ๋Œ€ ์‚ฌ์ง„ ๊ฐœ์ˆ˜ (`maxPhotoCount`) ์ดˆ๊ณผ ์—ฌ๋ถ€ ํ™•์ธ + 3. ํŒŒ์ผ๋ณ„ ํฌ๊ธฐ(6MB), Content-Type(image/jpeg ยท image/png ยท image/jpg) ์œ ํšจ์„ฑ ๊ฒ€์ฆ + 4. ๊ฒ€์ฆ ํ†ต๊ณผ ์‹œ, DB์— `Photo` ๋ ˆ์ฝ”๋“œ๋ฅผ `UPLOADING` ์ƒํƒœ๋กœ ์ƒ์„ฑ + 5. ํด๋ผ์šฐ๋“œ ์Šคํ† ๋ฆฌ์ง€ Presigned URL์„ ๋ฐœ๊ธ‰ํ•˜์—ฌ ๋ฐ˜ํ™˜ + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Presigned URL ๋ฐœ๊ธ‰์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ), + @ApiResponse( + responseCode = "400", + description = "์œ ํšจ์„ฑ ๊ฒ€์ฆ ์‹คํŒจ (์ตœ๋Œ€ ๊ฐœ์ˆ˜ ์ดˆ๊ณผ, ํŒŒ์ผ ํฌ๊ธฐ/ํ˜•์‹ ๋ถˆ์ผ์น˜ ๋“ฑ)", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 400, + "message": "์•จ๋ฒ”์˜ ์ตœ๋Œ€ ์‚ฌ์ง„ ๊ฐœ์ˆ˜๋ฅผ ์ดˆ๊ณผํ•ฉ๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "์‚ฌ์šฉ์ž๋Š” ํ•ด๋‹น ์•จ๋ฒ”์˜ ์ฐธ๊ฐ€์ž๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 403, + "message": "์‚ฌ์šฉ์ž๋Š” ํ•ด๋‹น ์•จ๋ฒ”์˜ ์ฐธ๊ฐ€์ž๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค." + } + """) + ) + ) + }) + CommonResponse createPresignedUrls( + @CurrentUser User user, + @RequestBody @Valid PhotoPresignedUrlRequest request + ); + + @Operation( + summary = "์‚ฌ์ง„ ์—…๋กœ๋“œ ๊ฒฐ๊ณผ ๋ณด๊ณ  API (์‹คํŒจ ์ฒ˜๋ฆฌ)", // [์ถ”๊ฐ€] + description = """ + ### RequestBody + --- + `failurePhotoIds`: Object Storage ์—…๋กœ๋“œ ์‹คํŒจ ID ๋ชฉ๋ก \n + + ### ๋กœ์ง ์ƒ์„ธ + --- + 1. **Failure IDs ์ฒ˜๋ฆฌ**: `Photo` ์ƒํƒœ๋ฅผ `UPLOADING`์—์„œ `FAILED`์œผ๋กœ ๋ณ€๊ฒฝ, ์•จ๋ฒ”์˜ `currentPhotoCount`๋ฅผ **๋กค๋ฐฑ** (๊ฐ์†Œ)ํ•ฉ๋‹ˆ๋‹ค. + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์ง„ ์—…๋กœ๋“œ ๊ฒฐ๊ณผ ๋ณด๊ณ ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ), + @ApiResponse( + responseCode = "400", + description = "๋ณด๊ณ ๋œ ์‚ฌ์ง„๋“ค์€ ๋ฐ˜๋“œ์‹œ ๋™์ผํ•œ ์•จ๋ฒ”์— ์†ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 400, + "message": "๋ณด๊ณ ๋œ ์‚ฌ์ง„๋“ค์€ ๋ฐ˜๋“œ์‹œ ๋™์ผํ•œ ์•จ๋ฒ”์— ์†ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "์‚ฌ์šฉ์ž์™€ ์‚ฌ์ง„์˜ ์†Œ์œ ์ž๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 403, + "message": "์‚ฌ์šฉ์ž์™€ ์‚ฌ์ง„์˜ ์†Œ์œ ์ž๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "๋ณด๊ณ ๋œ ์‚ฌ์ง„ ID ์ค‘ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 404, + "message": "๋ณด๊ณ ๋œ ์‚ฌ์ง„ ID ์ค‘ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค." + } + """) + ) + ), + @ApiResponse( + responseCode = "409", + description = "์‚ฌ์ง„ ์ƒํƒœ ์—…๋ฐ์ดํŠธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": 409, + "message": "์‚ฌ์ง„ ์ƒํƒœ ์—…๋ฐ์ดํŠธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค." + } + """) + ) + ) + }) + CommonResponse reportUploadResult( + @CurrentUser User user, + @RequestBody @Valid PhotoUploadReportRequest request + ); + + @Operation( + summary = "์‚ฌ์ง„ ๋‹ค์šด๋กœ๋“œ Presigned Url ๋ฐœ๊ธ‰ API", + description = """ + ### RequestBody + --- + `code`: ์•จ๋ฒ” ์ฝ”๋“œ (String) \n + `photoIds`: ๋‹ค์šด๋กœ๋“œ ๋ฐ›์„ ์‚ฌ์ง„ ID (List) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์ง„ ๋‹ค์šด๋กœ๋“œ presigned url ๋ฐœ๊ธ‰์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse getDownloadPresignedUrls( + @CurrentUser User user, + @RequestBody @Valid PhotoDownloadRequest request + ); + + @Operation( + summary = "์‚ฌ์ง„ ์ข‹์•„์š” ์ƒ์„ฑ API - ์ถ”ํ›„ ์ˆ˜์ • ํ•„์š” (Command๋กœ ์ด๋™ ์˜ˆ์ •)", + description = """ + ### PathVariable + --- + `photoId`: ์‚ฌ์ง„ ID + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์ง„์— ๋Œ€ํ•œ ์ข‹์•„์š” ์ƒ์„ฑ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse createPhotoLikes( + @CurrentUser User user, + @PathVariable Long photoId + ); + + @Operation( + summary = "์‚ฌ์ง„ ์ข‹์•„์š” ์‚ญ์ œ API - ์ถ”ํ›„ ์ˆ˜์ • ํ•„์š” (Command๋กœ ์ด๋™ ์˜ˆ์ •)", + description = """ + ### PathVariable + --- + `photoId`: ์‚ฌ์ง„ ID + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์ง„์— ๋Œ€ํ•œ ์ข‹์•„์š” ์‚ญ์ œ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse deletePhotoLikes( + @CurrentUser User user, + @PathVariable Long photoId + ); +} diff --git a/src/main/java/com/cheeeese/user/application/UserService.java b/src/main/java/com/cheeeese/user/application/UserService.java new file mode 100644 index 0000000..b6da68e --- /dev/null +++ b/src/main/java/com/cheeeese/user/application/UserService.java @@ -0,0 +1,92 @@ +package com.cheeeese.user.application; + +import com.cheeeese.global.util.ProfileImageUtil; +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.user.application.validator.UserValidator; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.domain.type.ProfileImageType; +import com.cheeeese.user.dto.request.UserOnboardingRequest; +import com.cheeeese.user.dto.request.UserProfileImageRequest; +import com.cheeeese.user.dto.request.UserProfileRequest; +import com.cheeeese.user.dto.response.UserInfoResponse; +import com.cheeeese.user.dto.response.UserProfileImageResponse; +import com.cheeeese.user.exception.UserException; +import com.cheeeese.user.exception.code.UserErrorCode; +import com.cheeeese.user.infrastructure.mapper.UserMapper; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserValidator userValidator; + private final UserRepository userRepository; + private final CdnUrlResolver cdnUrlResolver; + + @Transactional + public void updateUserName(User user, UserProfileRequest request) { + user.updateUserName(request.name()); + } + + public UserProfileImageResponse getUserProfileImageOpt() { + List opts = + Arrays.stream(ProfileImageType.values()) + .map(type -> { + String resolvedUrl = cdnUrlResolver.resolveProfile(type.getPath()); + return UserMapper.toProfileImageOpt(type, resolvedUrl); + }) + .toList(); + + return UserMapper.toProfileImageResponse(opts); + } + + @Transactional + public void updateUserProfileImage(User user, UserProfileImageRequest request) { + ProfileImageType type = ProfileImageType.fromName(request.imageCode()); + user.updateUserProfileImage(type.name()); + } + + @Transactional + public void saveUserOnboarding(User user, UserOnboardingRequest request) { + userValidator.validateUserOnboarding(request); + ProfileImageType type = ProfileImageType.fromName(request.imageCode()); + + user.saveUserOnboarding( + request.name(), + type.name(), + true, + request.isServiceAgreement(), + request.isUserInfoAgreement(), + request.isMarketingAgreement(), + request.isThirdPartyAgreement() + ); + } + + public UserInfoResponse getUserInfo(User user) { + String profileImage = ProfileImageUtil.resolveProfileImage(user, cdnUrlResolver); + return UserMapper.toUserInfoResponse(user, profileImage); + } + + @Transactional + public void incrementPhotoCount(Long userId, int count) { + int updated = userRepository.incrementPhotoCount(userId, count); + if (updated != 1) { + throw new UserException(UserErrorCode.USER_PHOTO_COUNT_INCREMENT_FAILED); + } + } + + @Transactional + public void decrementPhotoCount(Long userId, int count) { + int updated = userRepository.decrementPhotoCount(userId, count); + if (updated != 1) { + throw new UserException(UserErrorCode.USER_PHOTO_COUNT_DECREMENT_FAILED); + } + } +} diff --git a/src/main/java/com/cheeeese/user/application/validator/UserValidator.java b/src/main/java/com/cheeeese/user/application/validator/UserValidator.java new file mode 100644 index 0000000..090b100 --- /dev/null +++ b/src/main/java/com/cheeeese/user/application/validator/UserValidator.java @@ -0,0 +1,24 @@ +package com.cheeeese.user.application.validator; + +import com.cheeeese.user.dto.request.UserOnboardingRequest; +import com.cheeeese.user.exception.UserException; +import com.cheeeese.user.exception.code.UserErrorCode; +import org.springframework.stereotype.Component; + +@Component +public class UserValidator { + + public void validateUserOnboarding(UserOnboardingRequest request) { + if (request.name().isBlank()) { + throw new UserException(UserErrorCode.USER_NAME_REQUIRED); + } + + if (request.imageCode().isBlank()) { + throw new UserException(UserErrorCode.USER_PROFILE_IMAGE_CODE_REQUIRED); + } + + if (!request.isServiceAgreement() || !request.isUserInfoAgreement() || !request.isThirdPartyAgreement()) { + throw new UserException(UserErrorCode.REQUIRED_TERMS_NOT_AGREED); + } + } +} diff --git a/src/main/java/com/cheeeese/user/domain/User.java b/src/main/java/com/cheeeese/user/domain/User.java new file mode 100644 index 0000000..47c403b --- /dev/null +++ b/src/main/java/com/cheeeese/user/domain/User.java @@ -0,0 +1,110 @@ +package com.cheeeese.user.domain; + +import com.cheeeese.global.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @Column(name = "user_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Column(name = "profile_image", nullable = false) + private String profileImage; + + @Column(name = "provider_id", nullable = false) + private String providerId; + + @Column(name = "is_service_agreement", nullable = false) + private boolean isServiceAgreement; + + @Column(name = "is_user_info_agreement", nullable = false) + private boolean isUserInfoAgreement; + + @Column(name = "is_marketing_agreement", nullable = false) + private boolean isMarketingAgreement; + + @Column(name = "is_third_party_agreement", nullable = false) + private boolean isThirdPartyAgreement; + + @Column(name = "is_onboarded", nullable = false) + private boolean isOnboarded; + + @Column(name = "album_cnt", nullable = false) + private int albumCnt; + + @Column(name = "photo_cnt", nullable = false) + private int photoCnt; + + @Column(name = "likes_cnt", nullable = false) + private int likesCnt; + + @Builder + private User( + String name, + String email, + String profileImage, + String providerId, + boolean isServiceAgreement, + boolean isUserInfoAgreement, + boolean isMarketingAgreement, + boolean isThirdPartyAgreement, + boolean isOnboarded, + int albumCnt, + int photoCnt, + int likesCnt + ) { + this.name = name; + this.email = email; + this.profileImage = profileImage; + this.providerId = providerId; + this.isServiceAgreement = isServiceAgreement; + this.isUserInfoAgreement = isUserInfoAgreement; + this.isMarketingAgreement = isMarketingAgreement; + this.isThirdPartyAgreement = isThirdPartyAgreement; + this.isOnboarded = isOnboarded; + this.albumCnt = albumCnt; + this.photoCnt = photoCnt; + this.likesCnt = likesCnt; + } + + public void updateUserName(String name) { + this.name = name; + } + + public void updateUserProfileImage(String profileImage) { + this.profileImage = profileImage; + } + + public void saveUserOnboarding( + String name, + String profileImage, + boolean isOnboarded, + boolean isServiceAgreement, + boolean isUserInfoAgreement, + boolean isMarketingAgreement, + boolean isThirdPartyAgreement + ) { + this.name = name; + this.profileImage = profileImage; + this.isOnboarded = isOnboarded; + this.isServiceAgreement = isServiceAgreement; + this.isUserInfoAgreement = isUserInfoAgreement; + this.isMarketingAgreement = isMarketingAgreement; + this.isThirdPartyAgreement = isThirdPartyAgreement; + } +} diff --git a/src/main/java/com/cheeeese/user/domain/type/ProfileImageType.java b/src/main/java/com/cheeeese/user/domain/type/ProfileImageType.java new file mode 100644 index 0000000..d6d6499 --- /dev/null +++ b/src/main/java/com/cheeeese/user/domain/type/ProfileImageType.java @@ -0,0 +1,33 @@ +package com.cheeeese.user.domain.type; + +import lombok.Getter; + +@Getter +public enum ProfileImageType { + P1("profile/sign_up_profile_1.jpg"), + P2("profile/sign_up_profile_2.jpg"), + P3("profile/sign_up_profile_3.jpg"), + P4("profile/sign_up_profile_4.jpg"), + P5("profile/sign_up_profile_5.jpg"), + P6("profile/sign_up_profile_6.jpg"), + P7("profile/sign_up_profile_7.jpg"), + P8("profile/sign_up_profile_8.jpg"), + P9("profile/sign_up_profile_9.jpg"), + P10("profile/sign_up_profile_10.jpg"); + + private final String path; + + ProfileImageType(String path) { + this.path = path; + } + + public static ProfileImageType fromName(String name) { + if (name == null || name.isBlank()) return null; + + try { + return ProfileImageType.valueOf(name); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/com/cheeeese/user/dto/request/UserOnboardingRequest.java b/src/main/java/com/cheeeese/user/dto/request/UserOnboardingRequest.java new file mode 100644 index 0000000..7d56e16 --- /dev/null +++ b/src/main/java/com/cheeeese/user/dto/request/UserOnboardingRequest.java @@ -0,0 +1,46 @@ +package com.cheeeese.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +@Schema(description = "์‚ฌ์šฉ์ž ์˜จ๋ณด๋”ฉ API") +public record UserOnboardingRequest( + @NotBlank + @Schema(description = "์‚ฌ์šฉ์ž ์ด๋ฆ„", example = "์ฃผ") + String name, + + @NotBlank + @Schema(description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ฝ”๋“œ", example = "P2") + String imageCode, + + @NotNull + @Schema( + description = "์„œ๋น„์Šค ์ด์šฉ ์•ฝ๊ด€ ๋™์˜", + example = "true" + ) + boolean isServiceAgreement, + + @NotNull + @Schema( + description = "์‚ฌ์šฉ์ž ์ •๋ณด ์ˆ˜์ง‘ ๋™์˜", + example = "true" + ) + boolean isUserInfoAgreement, + + @Schema( + description = "๋งˆ์ผ€ํŒ… ์ˆ˜์‹  ๋™์˜", + example = "false" + ) + boolean isMarketingAgreement, + + @NotNull + @Schema( + description = "์ œ3์ž ์ œ๊ณต ๋™์˜", + example = "true" + ) + boolean isThirdPartyAgreement +) { +} diff --git a/src/main/java/com/cheeeese/user/dto/request/UserProfileImageRequest.java b/src/main/java/com/cheeeese/user/dto/request/UserProfileImageRequest.java new file mode 100644 index 0000000..3742013 --- /dev/null +++ b/src/main/java/com/cheeeese/user/dto/request/UserProfileImageRequest.java @@ -0,0 +1,12 @@ +package com.cheeeese.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ˆ˜์ • API") +public record UserProfileImageRequest( + @Schema(description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ฝ”๋“œ", example = "P1") + String imageCode +) { +} diff --git a/src/main/java/com/cheeeese/user/dto/request/UserProfileRequest.java b/src/main/java/com/cheeeese/user/dto/request/UserProfileRequest.java new file mode 100644 index 0000000..d656d02 --- /dev/null +++ b/src/main/java/com/cheeeese/user/dto/request/UserProfileRequest.java @@ -0,0 +1,13 @@ +package com.cheeeese.user.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ˆ˜์ • API") +public record UserProfileRequest( + @Schema(description = "์‚ฌ์šฉ์ž ์ด๋ฆ„", example = "์ฃผ") + String name + // TODO: ์ด๋ฏธ์ง€ ์ˆ˜์ • ์ถ”ํ›„ ์ถ”๊ฐ€ +) { +} diff --git a/src/main/java/com/cheeeese/user/dto/response/UserInfoResponse.java b/src/main/java/com/cheeeese/user/dto/response/UserInfoResponse.java new file mode 100644 index 0000000..5d30d9c --- /dev/null +++ b/src/main/java/com/cheeeese/user/dto/response/UserInfoResponse.java @@ -0,0 +1,36 @@ +package com.cheeeese.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema( + description = "์‚ฌ์šฉ์ž ๊ธฐ๋ณธ ์ •๋ณด ์‘๋‹ต", + requiredProperties = { + "profileImage", + "email", + "name", + "albumCount", + "photoCount", + "likesCount" + } +)public record UserInfoResponse( + @Schema(description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL", example = "https://cdn.cheeeese.me/profile.png") + String profileImage, + + @Schema(description = "์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ", example = "say.cheese@gmail.com") + String email, + + @Schema(description = "์‚ฌ์šฉ์ž ์ด๋ฆ„", example = "์น˜์ฆˆ๋Ÿฌ๋ฒ„") + String name, + + @Schema(description = "์‚ฌ์šฉ์ž๊ฐ€ ์†ํ•œ ์•จ๋ฒ” ์ˆ˜", example = "5") + long albumCount, + + @Schema(description = "์‚ฌ์šฉ์ž๊ฐ€ ์—…๋กœ๋“œํ•œ ์‚ฌ์ง„ ์ˆ˜", example = "42") + long photoCount, + + @Schema(description = "์‚ฌ์šฉ์ž๊ฐ€ ๋ฐ›์€ ์ด ์ข‹์•„์š” ์ˆ˜", example = "128") + long likesCount +) { +} diff --git a/src/main/java/com/cheeeese/user/dto/response/UserProfileImageResponse.java b/src/main/java/com/cheeeese/user/dto/response/UserProfileImageResponse.java new file mode 100644 index 0000000..12519de --- /dev/null +++ b/src/main/java/com/cheeeese/user/dto/response/UserProfileImageResponse.java @@ -0,0 +1,43 @@ +package com.cheeeese.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒํ•  ์ˆ˜ ์žˆ๋Š” ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ๋ชฉ๋ก API") +public record UserProfileImageResponse( + @Schema( + description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์˜ต์…˜ ๋ชฉ๋ก", + example = """ + [ + { + "imageCode": "P5", + "profileImageUrl": "https://say-cheese-profile.edge.naverncp.com/profile/sign_up_profile_5.jpg" + }, + { + "imageCode": "P6", + "profileImageUrl": "https://say-cheese-profile.edge.naverncp.com/profile/sign_up_profile_6.jpg" + } + ] + """ + ) + List opts +) { + + @Builder + public record ProfileImageOpt( + @Schema( + description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ฝ”๋“œ", + example = "P5" + ) + String imageCode, + + @Schema( + description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€์˜ CDN URL", + example = "https://say-cheese-profile.edge.naverncp.com/profile/sign_up_profile_5.jpg" + ) + String profileImageUrl + ) {} +} diff --git a/src/main/java/com/cheeeese/user/exception/UserException.java b/src/main/java/com/cheeeese/user/exception/UserException.java new file mode 100644 index 0000000..e95431d --- /dev/null +++ b/src/main/java/com/cheeeese/user/exception/UserException.java @@ -0,0 +1,12 @@ +package com.cheeeese.user.exception; + +import com.cheeeese.global.exception.BusinessException; +import com.cheeeese.user.exception.code.UserErrorCode; +import lombok.Getter; + +@Getter +public class UserException extends BusinessException { + public UserException(UserErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/cheeeese/user/exception/code/UserErrorCode.java b/src/main/java/com/cheeeese/user/exception/code/UserErrorCode.java new file mode 100644 index 0000000..493ebe0 --- /dev/null +++ b/src/main/java/com/cheeeese/user/exception/code/UserErrorCode.java @@ -0,0 +1,22 @@ +package com.cheeeese.user.exception.code; + +import com.cheeeese.global.common.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements BaseCode { + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), + USER_NAME_REQUIRED(HttpStatus.BAD_REQUEST, "์‚ฌ์šฉ์ž ์ด๋ฆ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."), + USER_PROFILE_IMAGE_CODE_REQUIRED(HttpStatus.BAD_REQUEST, "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ฝ”๋“œ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ๊ฐ’์ž…๋‹ˆ๋‹ค."), + REQUIRED_TERMS_NOT_AGREED(HttpStatus.BAD_REQUEST, "ํ•„์ˆ˜ ์•ฝ๊ด€์— ๋™์˜ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."), + USER_PHOTO_COUNT_INCREMENT_FAILED(HttpStatus.CONFLICT, "์œ ์ €์˜ ์•จ๋ฒ” ์‚ฌ์ง„ ๊ฐœ์ˆ˜ ์ฆ๊ฐ€์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + USER_PHOTO_COUNT_DECREMENT_FAILED(HttpStatus.CONFLICT, "์œ ์ €์˜ ์•จ๋ฒ” ์‚ฌ์ง„ ๊ฐœ์ˆ˜ ๊ฐ์†Œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cheeeese/user/infrastructure/mapper/UserMapper.java b/src/main/java/com/cheeeese/user/infrastructure/mapper/UserMapper.java new file mode 100644 index 0000000..cdc2230 --- /dev/null +++ b/src/main/java/com/cheeeese/user/infrastructure/mapper/UserMapper.java @@ -0,0 +1,47 @@ +package com.cheeeese.user.infrastructure.mapper; + +import com.cheeeese.oauth2.domain.OAuth2UserInfo; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.dto.response.UserInfoResponse; +import com.cheeeese.user.domain.type.ProfileImageType; +import com.cheeeese.user.dto.response.UserProfileImageResponse; + +import java.util.List; + +public class UserMapper { + + private static final String DEFAULT_PROFILE_IMAGE = "P1"; + + public static User toEntity(OAuth2UserInfo oAuth2UserInfo) { + return User.builder() + .email(oAuth2UserInfo.getEmail()) + .name(oAuth2UserInfo.getName()) + .profileImage(DEFAULT_PROFILE_IMAGE) + .providerId(oAuth2UserInfo.getProviderId()) + .build(); + } + + public static UserInfoResponse toUserInfoResponse(User user, String profileImage) { + return UserInfoResponse.builder() + .profileImage(profileImage) + .name(user.getName()) + .email(user.getEmail()) + .albumCount(user.getAlbumCnt()) + .photoCount(user.getPhotoCnt()) + .likesCount(user.getLikesCnt()) + .build(); + } + + public static UserProfileImageResponse.ProfileImageOpt toProfileImageOpt(ProfileImageType type, String imageUrl) { + return UserProfileImageResponse.ProfileImageOpt.builder() + .imageCode(type.name()) + .profileImageUrl(imageUrl) + .build(); + } + + public static UserProfileImageResponse toProfileImageResponse(List opts) { + return UserProfileImageResponse.builder() + .opts(opts) + .build(); + } +} diff --git a/src/main/java/com/cheeeese/user/infrastructure/persistence/UserRepository.java b/src/main/java/com/cheeeese/user/infrastructure/persistence/UserRepository.java new file mode 100644 index 0000000..f8a29e3 --- /dev/null +++ b/src/main/java/com/cheeeese/user/infrastructure/persistence/UserRepository.java @@ -0,0 +1,55 @@ +package com.cheeeese.user.infrastructure.persistence; + +import com.cheeeese.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByProviderId(String providerId); + + @Modifying(flushAutomatically = true) + @Query("UPDATE User u SET u.photoCnt = u.photoCnt + :count WHERE u.id = :userId") + int incrementPhotoCount(@Param("userId") Long userId, @Param("count") int count); + + @Modifying(flushAutomatically = true) + @Query("UPDATE User u SET u.photoCnt = u.photoCnt - :count WHERE u.id = :userId AND u.photoCnt >= :count") + int decrementPhotoCount(@Param("userId") Long userId, @Param("count") int count); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE User u + SET u.albumCnt = u.albumCnt + 1 + WHERE u.id = :userId + """) + void incrementAlbumCnt(@Param("userId") Long userId); + + @Modifying(flushAutomatically = true) + @Query(""" + UPDATE User u + SET u.likesCnt = u.likesCnt + 1 + WHERE u.id = :userId + """) + void incrementLikeCnt(@Param("userId") Long userId); + + @Modifying(flushAutomatically = true) + @Query(""" + UPDATE User u + SET u.likesCnt = u.likesCnt - 1 + WHERE u.id = :userId + AND u.likesCnt > 0 + """) + void decrementLikeCnt(@Param("userId") Long userId); + + @Modifying + @Query(""" + UPDATE User u + SET u.likesCnt = u.likesCnt - :count + WHERE u.id = :userId + AND u.likesCnt >= :count + """) + void decrementLikeCntBy(@Param("userId") Long userId, @Param("count") int count); +} diff --git a/src/main/java/com/cheeeese/user/presentation/UserController.java b/src/main/java/com/cheeeese/user/presentation/UserController.java new file mode 100644 index 0000000..93e365a --- /dev/null +++ b/src/main/java/com/cheeeese/user/presentation/UserController.java @@ -0,0 +1,67 @@ +package com.cheeeese.user.presentation; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.user.application.UserService; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.dto.request.UserOnboardingRequest; +import com.cheeeese.user.dto.request.UserProfileImageRequest; +import com.cheeeese.user.dto.request.UserProfileRequest; +import com.cheeeese.user.dto.response.UserInfoResponse; +import com.cheeeese.user.dto.response.UserProfileImageResponse; +import com.cheeeese.user.presentation.swagger.UserSwagger; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import static com.cheeeese.global.common.code.SuccessCode.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/user") +public class UserController implements UserSwagger { + + private final UserService userService; + + @Override + @GetMapping("/me") + public CommonResponse getUserInfo(@CurrentUser User user) { + return CommonResponse.success(USER_INFO_FETCH_SUCCESS, userService.getUserInfo(user)); + } + + @Override + @PatchMapping("/me/name") + public CommonResponse updateUserName( + @CurrentUser User user, + @RequestBody @Valid UserProfileRequest request + ) { + userService.updateUserName(user, request); + return CommonResponse.success(USER_NAME_UPDATE_SUCCESS); + } + + @Override + @GetMapping("/profile-images") + public CommonResponse getUserProfileImage() { + return CommonResponse.success(USER_PROFILE_IMAGE_OPT_GET_SUCCESS, userService.getUserProfileImageOpt()); + } + + @Override + @PatchMapping("/me/profile-image") + public CommonResponse updateUserProfileImage( + @CurrentUser User user, + @RequestBody @Valid UserProfileImageRequest request + ) { + userService.updateUserProfileImage(user, request); + return CommonResponse.success(USER_PROFILE_IMAGE_UPDATE_SUCCESS); + } + + @Override + @PostMapping("/onboarding") + public CommonResponse saveUserOnboarding( + @CurrentUser User user, + @RequestBody @Valid UserOnboardingRequest request + ) { + userService.saveUserOnboarding(user, request); + return CommonResponse.success(USER_ONBOARDING_SUCCESS); + } +} diff --git a/src/main/java/com/cheeeese/user/presentation/swagger/UserSwagger.java b/src/main/java/com/cheeeese/user/presentation/swagger/UserSwagger.java new file mode 100644 index 0000000..51b21ae --- /dev/null +++ b/src/main/java/com/cheeeese/user/presentation/swagger/UserSwagger.java @@ -0,0 +1,106 @@ +package com.cheeeese.user.presentation.swagger; + +import com.cheeeese.global.common.CommonResponse; +import com.cheeeese.global.util.CurrentUser; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.dto.request.UserOnboardingRequest; +import com.cheeeese.user.dto.request.UserProfileImageRequest; +import com.cheeeese.user.dto.request.UserProfileRequest; +import com.cheeeese.user.dto.response.UserInfoResponse; +import com.cheeeese.user.dto.response.UserProfileImageResponse; +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.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[์‚ฌ์šฉ์ž]", description = "์‚ฌ์šฉ์ž ๊ด€๋ จ API") +public interface UserSwagger { + @Operation( + summary = "์‚ฌ์šฉ์ž ๊ธฐ๋ณธ ์ •๋ณด ์กฐํšŒ API", + description = "์‚ฌ์šฉ์ž์˜ ํ”„๋กœํ•„ ์ด๋ฏธ์ง€, ์ด๋ฆ„, ์ฐธ์—ฌ ์•จ๋ฒ” ์ˆ˜, ์—…๋กœ๋“œํ•œ ์‚ฌ์ง„ ์ˆ˜, ๋ฐ›์€ ์ข‹์•„์š” ์ˆ˜๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse getUserInfo(@CurrentUser User user); + + + @Operation( + summary = "์‚ฌ์šฉ์ž ์ด๋ฆ„ ์ˆ˜์ • API", + description = """ + ### RequestBody + --- + `name`: ์‚ฌ์šฉ์ž ์ด๋ฆ„ (String) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์šฉ์ž ์ด๋ฆ„์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse updateUserName( + @CurrentUser User user, + @RequestBody @Valid UserProfileRequest request + ); + + @Operation( + summary = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์กฐํšŒ API", + description = "์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒํ•  ์ˆ˜ ์žˆ๋Š” ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜ํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse getUserProfileImage(); + + @Operation( + summary = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ˆ˜์ • API", + description = """ + ### RequestBody + --- + `imageCode`: ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ฝ”๋“œ (String) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ˆ˜์ •์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜ํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse updateUserProfileImage( + @CurrentUser User user, + @RequestBody @Valid UserProfileImageRequest request + ); + + @Operation( + summary = "์‚ฌ์šฉ์ž ์˜จ๋ณด๋”ฉ API", + description = """ + ### RequestBody + --- + `name`: ์‚ฌ์šฉ์ž ์ด๋ฆ„ (String) \n + `imageCode`: ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ฝ”๋“œ (String) \n + `isServiceAgreement`: ์„œ๋น„์Šค ์ด์šฉ ์•ฝ๊ด€ ๋™์˜ (boolean) \n + `isUserInfoAgreement`: ์‚ฌ์šฉ์ž ์ •๋ณด ์ˆ˜์ง‘ ๋™์˜ (boolean) \n + `isMarketingAgreement`: ๋งˆ์ผ€ํŒ… ์ˆ˜์‹  ๋™์˜ (boolean) \n + `isThirdPartyAgreement`: ์ œ3์ž ์ œ๊ณต ๋™์˜ (boolean) + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์šฉ์ž ์ด์šฉ ์•ฝ๊ด€ ๋™์˜๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‹คํ–‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + ) + }) + CommonResponse saveUserOnboarding( + @CurrentUser User user, + @RequestBody @Valid UserOnboardingRequest request + ); +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..ccb9307 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + {"type":"%X{type:-application}"} + + + + timestamp + KST + + + + level + + + + logger + + + + + + + + + stackTrace + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/cheeeese/CheeeeseApplicationTests.java b/src/test/java/com/cheeeese/CheeeeseApplicationTests.java new file mode 100644 index 0000000..3c28ce9 --- /dev/null +++ b/src/test/java/com/cheeeese/CheeeeseApplicationTests.java @@ -0,0 +1,13 @@ +package com.cheeeese; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CheeeeseApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/cheeeese/album/AlbumServiceTest.java b/src/test/java/com/cheeeese/album/AlbumServiceTest.java new file mode 100644 index 0000000..c47ba1c --- /dev/null +++ b/src/test/java/com/cheeeese/album/AlbumServiceTest.java @@ -0,0 +1,202 @@ +package com.cheeeese.album; + +import com.cheeeese.album.application.AlbumService; +import com.cheeeese.album.application.logger.AlbumLogger; +import com.cheeeese.album.application.support.AlbumReader; +import com.cheeeese.album.application.validator.AlbumValidator; +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.domain.type.AlbumJoinStatus; +import com.cheeeese.album.dto.response.AlbumEnterResponse; +import com.cheeeese.album.dto.response.ExistingEnterResponse; +import com.cheeeese.album.dto.response.NewEnterResponse; +import com.cheeeese.album.exception.AlbumException; +import com.cheeeese.album.exception.code.AlbumErrorCode; +import com.cheeeese.album.infrastructure.persistence.AlbumExpirationRedisRepository; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.photo.application.PhotoService; +import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@Tag("benchmark") +class AlbumServiceTest { + + private AlbumService albumService; + + // ๋ชจ๋“  ์˜์กด์„ฑ Mock ์„ ์–ธ (PhotoService ํฌํ•จ) + @Mock private AlbumValidator albumValidator; + @Mock private AlbumRepository albumRepository; + @Mock private UserAlbumRepository userAlbumRepository; + @Mock private UserRepository userRepository; + @Mock private PhotoRepository photoRepository; + @Mock private PhotoLikesRepository photoLikesRepository; + @Mock private PhotoService photoService; + @Mock private AlbumExpirationRedisRepository albumExpirationRedisRepository; + @Mock private CdnUrlResolver cdnUrlResolver; + @Mock private AlbumReader albumReader; + @Mock private AlbumLogger albumLogger; + + @BeforeEach + void setUp() { + // ์ƒ์„ฑ์ž๋ฅผ ํ†ตํ•ด ์ง์ ‘ ์ฃผ์ž… + albumService = new AlbumService( + albumValidator, + albumRepository, + userAlbumRepository, + userRepository, + photoRepository, + photoLikesRepository, + photoService, + albumExpirationRedisRepository, + cdnUrlResolver, + albumReader, + albumLogger + ); + } + + @Test + @DisplayName("์‹ ๊ทœ ์ฐธ์—ฌ์ž๋Š” ์•จ๋ฒ” ์ž…์žฅ ์‹œ GUEST ๊ถŒํ•œ์„ ์–ป๊ณ  ์ฐธ์—ฌ์ž ์ˆ˜๊ฐ€ ์ฆ๊ฐ€ํ•œ๋‹ค.") + void enterAlbum_NewUser() { + // given + String code = "album-code-123"; + + User user = mock(User.class); + given(user.getId()).willReturn(10L); + + User maker = mock(User.class); + given(maker.getId()).willReturn(100L); + + Album album = Album.builder() + .makerId(maker.getId()) + .title("Test Album") + .themeEmoji("๐Ÿง€") + .eventDate(LocalDate.now()) + .expiredAt(LocalDateTime.now().plusDays(7)) + .build(); + + // 1. Validator ํ†ต๊ณผ ์„ค์ • + given(albumValidator.validateAlbumCode(code)).willReturn(album); + doNothing().when(albumValidator).validateAlbumEntry(album, user); + doNothing().when(albumValidator).validateAlbumCapacity(album); + + // 2. ๊ธฐ์กด ์ฐธ์—ฌ ์ด๋ ฅ ์—†์Œ (์‹ ๊ทœ) + given(userAlbumRepository.findByUserIdAndAlbumId(user.getId(), album.getId())) + .willReturn(Optional.empty()); + + // 3. Maker ์ •๋ณด ์กฐํšŒ + given(userRepository.findById(album.getMakerId())).willReturn(Optional.of(maker)); + + // 4. ์ฐธ์—ฌ์ž ์ˆ˜ ์ฆ๊ฐ€ ์„ฑ๊ณต + given(albumRepository.incrementParticipantCountAtomically(album.getId())).willReturn(1); + + // 5. [์ค‘์š”] PhotoService ํ˜ธ์ถœ ์‹œ ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ (NPE ๋ฐฉ์ง€) + given(photoService.getRecentPhotosForNewEnter(album.getId())).willReturn(List.of()); + + // when + AlbumEnterResponse response = albumService.enterAlbum(code, user); + + // then + assertThat(response).isInstanceOf(NewEnterResponse.class); + assertThat(response.joinStatus()).isEqualTo(AlbumJoinStatus.NEW); + + verify(userAlbumRepository).save(any(UserAlbum.class)); + verify(albumRepository).incrementParticipantCountAtomically(album.getId()); + } + + @Test + @DisplayName("๊ธฐ์กด์— ๋‚˜๊ฐ”๋˜ ์ฐธ์—ฌ์ž๊ฐ€ ์žฌ์ž…์žฅ(REJOINED)ํ•˜๋ฉด isVisible์ด true๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค.") + void enterAlbum_RejoinedUser() { + // given + String code = "album-code-rejoin"; + User user = mock(User.class); + given(user.getId()).willReturn(20L); + + User maker = mock(User.class); + given(maker.getId()).willReturn(100L); + + Album album = Album.builder() + .makerId(maker.getId()) + .title("Rejoin Album") + .themeEmoji("๐Ÿ“ธ") + .eventDate(LocalDate.now()) + .expiredAt(LocalDateTime.now().plusDays(7)) + .build(); + + given(albumValidator.validateAlbumCode(code)).willReturn(album); + doNothing().when(albumValidator).validateAlbumEntry(album, user); + + // ๊ธฐ์กด ์ฐธ์—ฌ ์ •๋ณด ์กด์žฌ (isVisible=false) -> ์žฌ์ž…์žฅ ์ผ€์ด์Šค + UserAlbum userAlbum = mock(UserAlbum.class); + given(userAlbum.isVisible()).willReturn(false); + + given(userAlbumRepository.findByUserIdAndAlbumId(user.getId(), album.getId())) + .willReturn(Optional.of(userAlbum)); + + given(userRepository.findById(album.getMakerId())).willReturn(Optional.of(maker)); + + // when + AlbumEnterResponse response = albumService.enterAlbum(code, user); + + // then + assertThat(response).isInstanceOf(ExistingEnterResponse.class); + assertThat(response.joinStatus()).isEqualTo(AlbumJoinStatus.REJOINED); + + verify(userAlbum).show(); + } + + @Test + @DisplayName("์•จ๋ฒ” ์ •์›์ด ์ดˆ๊ณผ๋œ ๊ฒฝ์šฐ ์ž…์žฅ์ด ๊ฑฐ๋ถ€๋˜๊ณ  ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + void enterAlbum_Fail_MaxParticipantReached() { + // given + String code = "full-album-code"; + User user = mock(User.class); + Album album = mock(Album.class); + + Long makerId = 999L; + given(album.getMakerId()).willReturn(makerId); + + given(albumValidator.validateAlbumCode(code)).willReturn(album); + doNothing().when(albumValidator).validateAlbumEntry(album, user); + + // ๊ธฐ์กด ์ฐธ์—ฌ์ž๊ฐ€ ์•„๋‹˜ + given(userAlbumRepository.findByUserIdAndAlbumId(user.getId(), album.getId())) + .willReturn(Optional.empty()); + + // Maker ์ •๋ณด ์กฐํšŒ Mocking ์ถ”๊ฐ€ + given(userRepository.findById(makerId)).willReturn(Optional.of(mock(User.class))); + + // ์ •์› ์ฒดํฌ ํ†ต๊ณผ ๊ฐ€์ • + doNothing().when(albumValidator).validateAlbumCapacity(album); + + // ํ•ต์‹ฌ: ์—…๋ฐ์ดํŠธ ๋œ ํ–‰์˜ ๊ฐœ์ˆ˜๊ฐ€ 0 (์—…๋ฐ์ดํŠธ ์‹คํŒจ = ์ •์› ์ดˆ๊ณผ) + given(albumRepository.incrementParticipantCountAtomically(album.getId())).willReturn(0); + + // when & then + assertThatThrownBy(() -> albumService.enterAlbum(code, user)) + .isInstanceOf(AlbumException.class) + .hasFieldOrPropertyWithValue("errorCode", AlbumErrorCode.ALBUM_MAX_PARTICIPANT_REACHED); + } +} \ No newline at end of file diff --git a/src/test/java/com/cheeeese/album/benchmark/AlbumConcurrencyTest.java b/src/test/java/com/cheeeese/album/benchmark/AlbumConcurrencyTest.java new file mode 100644 index 0000000..1fa39eb --- /dev/null +++ b/src/test/java/com/cheeeese/album/benchmark/AlbumConcurrencyTest.java @@ -0,0 +1,92 @@ +package com.cheeeese.album.benchmark; + +import com.cheeeese.album.application.AlbumService; +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.fixture.FixtureFactory; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Tag("benchmark") +public class AlbumConcurrencyTest { + + @Autowired private AlbumService albumService; + @Autowired private AlbumRepository albumRepository; + @Autowired private UserRepository userRepository; + + @Test + @DisplayName("100๋ช…์˜ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์‹œ์— ์•จ๋ฒ”์— ์ž…์žฅํ•  ๋•Œ ์„ฑ๋Šฅ ๋ฐ ์ •ํ•ฉ์„ฑ ํ…Œ์ŠคํŠธ") + void enterAlbumConcurrencyTest() throws InterruptedException { + // Given + int threadCount = 100; + User maker = userRepository.save(FixtureFactory.createKakaoUser()); + // ์ •์›์ด 100๋ช… ์ด์ƒ์ธ ์•จ๋ฒ” ์ƒ์„ฑ (๊ทธ๋ž˜์•ผ ์—๋Ÿฌ ์—†์ด ์„ฑ๊ณต ์ผ€์ด์Šค ์ธก์ • ๊ฐ€๋Šฅ) + Album album = FixtureFactory.createAlbum(maker.getId()); + // โ€ป FixtureFactory์˜ createAlbum์ด participant๋ฅผ 4๋กœ ์„ค์ •ํ•œ๋‹ค๋ฉด ์ด ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด + // FixtureFactory๋ฅผ ์ˆ˜์ •ํ•˜๊ฑฐ๋‚˜ ๋นŒ๋”๋ฅผ ํ†ตํ•ด 100๋ช… ์ด์ƒ์œผ๋กœ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + // ์—ฌ๊ธฐ์„œ๋Š” ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด Repository์—์„œ ๊ฐ•์ œ๋กœ ์—…๋ฐ์ดํŠธํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ฑฐ๋‚˜ + // Album ๊ฐ์ฒด ์ƒ์„ฑ ์‹œ participant๋ฅผ ๋„‰๋„‰ํ•˜๊ฒŒ ์žก์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค. + + // ์ž„์‹œ๋กœ ์ฐธ์—ฌ ๊ฐ€๋Šฅ ์ธ์› ๋Š˜๋ฆฌ๊ธฐ (FixtureFactory ์ˆ˜์ • ์—†์ด ์ง„ํ–‰ ์‹œ) + // ์‹ค์ œ ์ฝ”๋“œ์—์„œ๋Š” ์—”ํ‹ฐํ‹ฐ ์ˆ˜์ • ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ Fixture๋ฅผ ์กฐ์ •ํ•˜์„ธ์š”. + // ์˜ˆ: album.updateParticipantCount(200); + albumRepository.save(album); + + ExecutorService executorService = Executors.newFixedThreadPool(32); // ์Šค๋ ˆ๋“œ ํ’€ + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + + long start = System.currentTimeMillis(); + + // When + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + // ๊ฐ๊ธฐ ๋‹ค๋ฅธ ์œ ์ € ์ƒ์„ฑ + User guest = User.builder() + .name("Guest" + index) + .email("guest" + index + "@test.com") + .profileImage("P1") + .providerId("kakao_" + index) + .build(); + userRepository.save(guest); + + // ์ž…์žฅ ์‹œ๋„ + albumService.enterAlbum(album.getCode(), guest); + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ž…์žฅ ์‹คํŒจ: " + e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); // ๋ชจ๋“  ์Šค๋ ˆ๋“œ๊ฐ€ ๋๋‚  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ + long end = System.currentTimeMillis(); + + // Then + Album updatedAlbum = albumRepository.findById(album.getId()).orElseThrow(); + System.out.printf("๋™์‹œ ์ž…์žฅ ์š”์ฒญ %d๊ฑด ์ฒ˜๋ฆฌ ์‹œ๊ฐ„: %d ms%n", threadCount, (end - start)); + System.out.printf("์„ฑ๊ณตํ•œ ์š”์ฒญ ์ˆ˜: %d%n", successCount.get()); + System.out.printf("DB ๋ฐ˜์˜๋œ ์ฐธ์—ฌ์ž ์ˆ˜: %d%n", updatedAlbum.getCurrentParticipant()); + + // ๊ธฐ์กด 1๋ช…(๋ฉ”์ด์ปค) + 100๋ช…(๊ฒŒ์ŠคํŠธ) = 101๋ช…์ด์–ด์•ผ ํ•จ (๋˜๋Š” ๋กœ์ง์— ๋”ฐ๋ผ ๋‹ค๋ฆ„) + // enterAlbum ๋กœ์ง์— ๋”ฐ๋ผ 100๋ช…์ด ์ •์ƒ์ ์œผ๋กœ ๋“ค์–ด๊ฐ”๋Š”์ง€ ๊ฒ€์ฆ + assertThat(updatedAlbum.getCurrentParticipant()).isEqualTo(1 + successCount.get()); + } +} diff --git a/src/test/java/com/cheeeese/album/benchmark/AlbumServiceBenchmarkTest.java b/src/test/java/com/cheeeese/album/benchmark/AlbumServiceBenchmarkTest.java new file mode 100644 index 0000000..6894473 --- /dev/null +++ b/src/test/java/com/cheeeese/album/benchmark/AlbumServiceBenchmarkTest.java @@ -0,0 +1,107 @@ +package com.cheeeese.album.benchmark; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.fixture.FixtureFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +@SpringBootTest +@Transactional +@Tag("benchmark") +public class AlbumServiceBenchmarkTest { + + @Autowired + private AlbumRepository albumRepository; + + @PersistenceContext + private EntityManager em; + + private static final int DATA_SIZE = 20000; + private final Random random = new Random(); + + @BeforeEach + void setUp() { + System.out.println("UUID v4Codes VS UUID v7Codes ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ์‹œ์ž‘"); + + List v4Albums = new ArrayList<>(); + List v7Albums = new ArrayList<>(); + + for (int i = 0; i < DATA_SIZE; i++) { + v4Albums.add(FixtureFactory.createAlbumV4(i)); + v7Albums.add(FixtureFactory.createAlbumV7(i)); + } + + long startV4 = System.currentTimeMillis(); + albumRepository.saveAll(v4Albums); + albumRepository.flush(); + long v4InsertTime = System.currentTimeMillis() - startV4; + + long startV7 = System.currentTimeMillis(); + albumRepository.saveAll(v7Albums); + albumRepository.flush(); + long v7InsertTime = System.currentTimeMillis() - startV7; + + System.out.printf("Insert ์™„๋ฃŒ (v4: %d ms, v7: %d ms)%n", v4InsertTime, v7InsertTime); + } + + @Test + @DisplayName("UUID v4 vs v7 ์ „์ฒด DB ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ") + void compareFullPerformance() { + List allAlbums = albumRepository.findAll(); + + List v4Codes = allAlbums.stream() + .filter(a -> a.getTitle().startsWith("v4")) + .map(Album::getCode) + .toList(); + + List v7Codes = allAlbums.stream() + .filter(a -> a.getTitle().startsWith("v7")) + .map(Album::getCode) + .toList(); + + // ์ „์ฒด ์กฐํšŒ ์†๋„ ํ…Œ์ŠคํŠธ + measureSelectingTest("v4", v4Codes); + measureSelectingTest("v7", v7Codes); + + // ์ „์ฒด ์ •๋ ฌ ์‹œ๊ฐ„ ํ…Œ์ŠคํŠธ + measureSortingTest("v4"); + measureSortingTest("v7"); + } + + private void measureSelectingTest(String label, List codes) { + long total = 0; + for (int i = 0; i < 100; i++) { + String code = codes.get(random.nextInt(codes.size())); + long start = System.nanoTime(); + albumRepository.findByCode(code); + total += System.nanoTime() - start; + } + System.out.printf("[%s] ์ „์ฒด ์กฐํšŒ ์‹œ๊ฐ„: %.2f ms%n", label, total / 1000000.0); + } + + private void measureSortingTest(String label) { + long start = System.currentTimeMillis(); + em.createQuery(""" + SELECT a + FROM Album a + WHERE a.title LIKE :prefix + ORDER BY a.code DESC + """, Album.class) + .setParameter("prefix", label + "%") + .getResultList(); + long elapsed = System.currentTimeMillis() - start; + System.out.printf("[%s] Order By ์ •๋ ฌ ์‹œ๊ฐ„: %d ms%n", label, elapsed); + } +} diff --git a/src/test/java/com/cheeeese/album/integration/UserAlbumServiceIntegrationTest.java b/src/test/java/com/cheeeese/album/integration/UserAlbumServiceIntegrationTest.java new file mode 100644 index 0000000..e704b45 --- /dev/null +++ b/src/test/java/com/cheeeese/album/integration/UserAlbumServiceIntegrationTest.java @@ -0,0 +1,73 @@ +package com.cheeeese.album.integration; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.fixture.FixtureFactory; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class UserAlbumServiceIntegrationTest { + + @Autowired + private AlbumRepository albumRepository; + + @Autowired + private UserAlbumRepository userAlbumRepository; + + private static final int ITERATIONS = 1000; + + private static User testUser; + private static Album testAlbum; + + @BeforeAll + static void setUp( + @Autowired UserRepository userRepository, + @Autowired AlbumRepository albumRepository, + @Autowired UserAlbumRepository userAlbumRepository + ) { + testUser = FixtureFactory.createKakaoUser(); + userRepository.save(testUser); + + testAlbum = FixtureFactory.createAlbum(testUser.getId()); + albumRepository.save(testAlbum); + + UserAlbum userAlbum = FixtureFactory.createHostUserAlbum(testUser, testAlbum); + userAlbumRepository.save(userAlbum); + + System.out.println("[ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ ์™„๋ฃŒ]"); + } + + @Test + @DisplayName("JOIN ์กฐํšŒ vs ์ง์ ‘ ์กฐํšŒ ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ") + void compareJoinQueryAndDirectQueryPerformance() { + for (int i = 0; i < 5; i++) { + albumRepository.findByMakerId(testAlbum.getMakerId()); + userAlbumRepository.findByAlbumIdAndUserIdAndRole(testAlbum.getId(), testUser.getId(), Role.MAKER); + } + + long total1 = 0; + long total2 = 0; + + for (int i = 0; i < ITERATIONS; i++) { + long start = System.nanoTime(); + albumRepository.findByMakerId(testAlbum.getMakerId()); + total1 += System.nanoTime() - start; + + start = System.nanoTime(); + userAlbumRepository.findByAlbumIdAndUserIdAndRole(testAlbum.getId(), testUser.getId(), Role.MAKER); + total2 += System.nanoTime() - start; + } + + System.out.printf("[1] Album.hostId ์ง์ ‘ ์กฐํšŒ ํ‰๊ท : %.2f ms%n", (total1 / 1_000_000.0 / ITERATIONS)); + System.out.printf("[2] Participant JOIN ์กฐํšŒ ํ‰๊ท : %.2f ms%n", (total2 / 1_000_000.0 / ITERATIONS)); + } +} diff --git a/src/test/java/com/cheeeese/cheese4cut/Cheese4cutServiceTest.java b/src/test/java/com/cheeeese/cheese4cut/Cheese4cutServiceTest.java new file mode 100644 index 0000000..ac8f5e4 --- /dev/null +++ b/src/test/java/com/cheeeese/cheese4cut/Cheese4cutServiceTest.java @@ -0,0 +1,176 @@ +package com.cheeeese.cheese4cut; + +import com.cheeeese.album.application.validator.AlbumValidator; +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.cheese4cut.application.Cheese4cutService; +import com.cheeeese.cheese4cut.application.validator.Cheese4cutValidator; +import com.cheeeese.cheese4cut.domain.Cheese4cut; +import com.cheeeese.cheese4cut.dto.request.Cheese4cutFixedRequest; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutPreviewResponse; +import com.cheeeese.cheese4cut.dto.response.Cheese4cutResponse; +import com.cheeeese.cheese4cut.exception.Cheese4cutException; +import com.cheeeese.cheese4cut.exception.code.Cheese4cutErrorCode; +import com.cheeeese.cheese4cut.infrastructure.persistence.Cheese4cutRepository; +import com.cheeeese.global.util.resolver.CdnUrlResolver; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@Tag("benchmark") +class Cheese4cutServiceTest { + + @InjectMocks + private Cheese4cutService cheese4cutService; + + @Mock private Cheese4cutRepository cheese4cutRepository; + @Mock private AlbumValidator albumValidator; + @Mock private Cheese4cutValidator cheese4cutValidator; + @Mock private PhotoRepository photoRepository; + @Mock private PhotoLikesRepository photoLikesRepository; + @Mock private AlbumRepository albumRepository; + @Mock private CdnUrlResolver cdnUrlResolver; + + @Test + @DisplayName("์น˜์ฆˆ๋„ค์ปท ์ˆ˜๋™ ํ™•์ • ์‹œ ์ •์ƒ์ ์œผ๋กœ ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์ €์žฅ๋œ๋‹ค.") + void finalizeCheese4cut_Success() { + // given + String code = "valid-code"; + User maker = mock(User.class); + Album album = mock(Album.class); + List photoIds = List.of(1L, 2L, 3L, 4L); + Cheese4cutFixedRequest request = new Cheese4cutFixedRequest(photoIds); + + // Mocking + given(albumValidator.validateAlbumCode(code)).willReturn(album); + given(album.isExpired()).willReturn(false); + // ์ด๋ฏธ ํ™•์ •๋œ ๋‚ด์—ญ์ด ์—†์–ด์•ผ ํ•จ + given(cheese4cutRepository.findByAlbumId(album.getId())).willReturn(Optional.empty()); + + // ์‚ฌ์ง„ ์กฐํšŒ ๊ฒฐ๊ณผ Mocking (์ˆœ์„œ๋Œ€๋กœ ์กฐํšŒ๋˜์—ˆ๋‹ค๊ณ  ๊ฐ€์ •) + Photo p1 = mock(Photo.class); given(p1.getId()).willReturn(1L); + Photo p2 = mock(Photo.class); given(p2.getId()).willReturn(2L); + Photo p3 = mock(Photo.class); given(p3.getId()).willReturn(3L); + Photo p4 = mock(Photo.class); given(p4.getId()).willReturn(4L); + + given(photoRepository.findAllByIdInOrderByLikesDescCreatedDesc(photoIds)) + .willReturn(List.of(p1, p2, p3, p4)); + + // when + cheese4cutService.finalizeCheese4cut(maker, code, request); + + // then + // 1. ๊ถŒํ•œ ๊ฒ€์ฆ ํ˜ธ์ถœ ์—ฌ๋ถ€ + verify(cheese4cutValidator).validateUserIsMaker(album, maker); + // 2. ์‚ฌ์ง„ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ํ˜ธ์ถœ ์—ฌ๋ถ€ + verify(cheese4cutValidator).validateFinalizePhotos(album, photoIds); + // 3. ์ตœ์ข… ์ €์žฅ ํ˜ธ์ถœ ์—ฌ๋ถ€ + verify(cheese4cutRepository).save(any(Cheese4cut.class)); + } + + @Test + @DisplayName("์น˜์ฆˆ๋„ค์ปท ํ™•์ • ์ „, ์ข‹์•„์š” ์ƒ์œ„ 4๊ฐœ ์‚ฌ์ง„์„ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void getCheese4cut_Preview_Success() { + // given + String code = "preview-code"; + User user = mock(User.class); + Album album = mock(Album.class); + given(album.getId()).willReturn(1L); + given(album.getParticipant()).willReturn(10); + + given(albumRepository.findByCode(code)).willReturn(Optional.of(album)); + + // ํ™•์ • ๋‚ด์—ญ ์—†์Œ + given(cheese4cutRepository.findByAlbumId(album.getId())).willReturn(Optional.empty()); + + // ์ข‹์•„์š” ์ƒ์œ„ 4๊ฐœ ์‚ฌ์ง„ ID ์กฐํšŒ Mock + List topIds = List.of(10L, 20L, 30L, 40L); + given(photoRepository.findTop4CompletedPhotoIdsByLikes(eq(1L), eq(PhotoStatus.COMPLETED), any())) + .willReturn(topIds); + + // ์‚ฌ์ง„ ๊ฐ์ฒด ์กฐํšŒ Mock (์ˆœ์„œ ๋ณด์žฅ์„ ์œ„ํ•ด ์‹ค์ œ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜) + Photo p1 = mock(Photo.class); given(p1.getId()).willReturn(10L); + Photo p2 = mock(Photo.class); given(p2.getId()).willReturn(20L); + Photo p3 = mock(Photo.class); given(p3.getId()).willReturn(30L); + Photo p4 = mock(Photo.class); given(p4.getId()).willReturn(40L); + + given(photoRepository.findAllByIdIn(topIds)).willReturn(List.of(p1, p2, p3, p4)); + given(cdnUrlResolver.resolveOriginal(any())).willReturn("http://cdn.url/image.jpg"); + given(photoLikesRepository.countDistinctUserIdsByPhotoIds(topIds)).willReturn(5L); + // when + Cheese4cutResponse response = cheese4cutService.getCheese4cutByAlbumCode(null, code); + + // then + assertThat(response).isInstanceOf(Cheese4cutPreviewResponse.class); + Cheese4cutPreviewResponse preview = (Cheese4cutPreviewResponse) response; + + assertThat(preview.isFinalized()).isFalse(); + assertThat(preview.previewPhotos()).hasSize(4); + assertThat(preview.uniqueLikesCount()).isEqualTo(5); + } + + @Test + @DisplayName("์ด๋ฏธ ์น˜์ฆˆ๋„ค์ปท์ด ํ™•์ •๋œ ์•จ๋ฒ”์— ๋Œ€ํ•ด ๋‹ค์‹œ ํ™•์ •์„ ์‹œ๋„ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + void finalizeCheese4cut_Fail_AlreadyFinalized() { + // given + String code = "code"; + User user = mock(User.class); + Album album = mock(Album.class); + given(album.getId()).willReturn(1L); + + given(albumValidator.validateAlbumCode(code)).willReturn(album); + given(album.isExpired()).willReturn(false); + + // ์ด๋ฏธ ํ™•์ •๋œ ๋‚ด์—ญ์ด ์กด์žฌํ•จ (Optional.of) + given(cheese4cutRepository.findByAlbumId(album.getId())) + .willReturn(Optional.of(mock(Cheese4cut.class))); + + Cheese4cutFixedRequest request = new Cheese4cutFixedRequest(List.of(1L, 2L, 3L, 4L)); + + // when & then + assertThatThrownBy(() -> cheese4cutService.finalizeCheese4cut(user, code, request)) + .isInstanceOf(Cheese4cutException.class) + .hasFieldOrPropertyWithValue("errorCode", Cheese4cutErrorCode.CHEESE4CUT_ALREADY_FINALIZED); + } + + @Test + @DisplayName("ํ™•์ • ์ „ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์กฐํšŒ ์‹œ, ์™„๋ฃŒ๋œ ์‚ฌ์ง„์ด 4์žฅ ๋ฏธ๋งŒ์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + void getCheese4cut_Preview_Fail_InsufficientPhotos() { + // given + String code = "code"; + Album album = mock(Album.class); + given(album.getId()).willReturn(1L); + given(albumRepository.findByCode(code)).willReturn(Optional.of(album)); + + // ํ™•์ • ๋‚ด์—ญ ์—†์Œ + given(cheese4cutRepository.findByAlbumId(1L)).willReturn(Optional.empty()); + + // ์‚ฌ์ง„ ์กฐํšŒ ๊ฒฐ๊ณผ๊ฐ€ 3์žฅ๋ฟ์ž„ + given(photoRepository.findTop4CompletedPhotoIdsByLikes(eq(1L), eq(PhotoStatus.COMPLETED), any())) + .willReturn(List.of(1L, 2L, 3L)); + + // when & then + assertThatThrownBy(() -> cheese4cutService.getCheese4cutByAlbumCode(null, code)) + .isInstanceOf(Cheese4cutException.class) + .hasFieldOrPropertyWithValue("errorCode", Cheese4cutErrorCode.INSUFFICIENT_COUNT_FOR_CHEESE4CUT); + } +} \ No newline at end of file diff --git a/src/test/java/com/cheeeese/fixture/FixtureFactory.java b/src/test/java/com/cheeeese/fixture/FixtureFactory.java new file mode 100644 index 0000000..0564488 --- /dev/null +++ b/src/test/java/com/cheeeese/fixture/FixtureFactory.java @@ -0,0 +1,129 @@ +package com.cheeeese.fixture; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.domain.type.Role; +import com.cheeeese.album.infrastructure.mapper.AlbumMapper; +import com.cheeeese.album.infrastructure.mapper.UserAlbumMapper; +import com.cheeeese.oauth2.domain.OAuth2UserInfo; +import com.cheeeese.oauth2.infrastructure.userinfo.KakaoUserInfo; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoHistory; +import com.cheeeese.photo.domain.PhotoLikes; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.infrastructure.mapper.PhotoHistoryMapper; +import com.cheeeese.photo.infrastructure.mapper.PhotoLikesMapper; +import com.cheeeese.photo.infrastructure.mapper.PhotoMapper; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.infrastructure.mapper.UserMapper; +import com.github.f4b6a3.uuid.UuidCreator; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class FixtureFactory { + + public static KakaoUserInfo createKakaoUserInfo() { + Map profile = new HashMap<>(); + profile.put("nickname", "์นด์นด์˜ค์œ ์ €"); + profile.put("profile_image_url", "https://example.com/kakao-profile.png"); + + Map kakaoAccount = new HashMap<>(); + kakaoAccount.put("email", "kakao_user@test.com"); + kakaoAccount.put("profile", profile); + + Map attributes = new HashMap<>(); + attributes.put("id", 1234567890L); + attributes.put("kakao_account", kakaoAccount); + + return new KakaoUserInfo(attributes); + } + + public static User createKakaoUser() { + KakaoUserInfo kakaoInfo = createKakaoUserInfo(); + return UserMapper.toEntity(kakaoInfo); + } + + public static Album createAlbum(Long userId) { + return AlbumMapper.toEntity( + userId, + "ํ…Œ์ŠคํŠธ ์•จ๋ฒ”", + "ํ…Œ์ŠคํŠธ ์ฝ”๋“œ", + "ํ…Œ์ŠคํŠธ ์ด๋ฏธ์ง€", + 4, + LocalDate.of(2025, 1, 1), + true, + LocalDateTime.now().plusDays(7) + ); + } + + public static Album createAlbumV4(int i) { + return AlbumMapper.toEntity( + 1L, + "v4-" + i, + UUID.randomUUID().toString(), + "ํ…Œ์ŠคํŠธ ์ด๋ฏธ์ง€", + 4, + LocalDate.of(2025, 1, 1), + true, + LocalDateTime.now().plusDays(7) + ); + } + + public static Album createAlbumV7(int i) { + return AlbumMapper.toEntity( + 1L, + "v7-" + i, + UuidCreator.getTimeOrderedEpoch().toString(), + "ํ…Œ์ŠคํŠธ ์ด๋ฏธ์ง€", + 4, + LocalDate.of(2025, 1, 1), + true, + LocalDateTime.now().plusDays(7) + ); + } + + public static UserAlbum createHostUserAlbum(User user, Album album) { + return UserAlbumMapper.toEntity( + user, + album, + Role.MAKER + ); + } + + public static Photo createPhoto(User user, Album album, LocalDateTime captureTime) { + return PhotoMapper.toEntity( + user, + album, + captureTime + ); + } + + public static Photo createCompletedPhoto(User user, Album album, LocalDateTime now) { + return Photo.builder() + .user(user) + .album(album) + .imageUrl(null) + .thumbnailUrl(null) + .captureTime(now) + .status(PhotoStatus.COMPLETED) + .build(); + } + + public static PhotoHistory createPhotoHistory(User user, Photo photo) { + return PhotoHistoryMapper.toEntity( + user, + photo + ); + } + + public static PhotoLikes createPhotoLikes(User user, Photo photo) { + return PhotoLikesMapper.toEntity( + user, + photo + ); + } +} diff --git a/src/test/java/com/cheeeese/photo/PhotoServiceTest.java b/src/test/java/com/cheeeese/photo/PhotoServiceTest.java new file mode 100644 index 0000000..3d4d6a5 --- /dev/null +++ b/src/test/java/com/cheeeese/photo/PhotoServiceTest.java @@ -0,0 +1,145 @@ +package com.cheeeese.photo; + +import com.cheeeese.album.application.validator.AlbumValidator; +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.photo.application.PhotoService; +import com.cheeeese.photo.application.PresignedUrlService; +import com.cheeeese.photo.application.validator.PhotoValidator; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoStatus; +import com.cheeeese.photo.dto.request.PhotoPresignedUrlRequest; +import com.cheeeese.photo.dto.request.PhotoUploadReportRequest; +import com.cheeeese.photo.dto.response.PhotoPresignedUrlResponse; +import com.cheeeese.photo.exception.PhotoException; +import com.cheeeese.photo.exception.code.PhotoErrorCode; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@Tag("benchmark") +class PhotoServiceTest { + + @InjectMocks + private PhotoService photoService; + + @Mock private AlbumValidator albumValidator; + @Mock private AlbumRepository albumRepository; + @Mock private PhotoRepository photoRepository; + @Mock private PhotoValidator photoValidator; + @Mock private PresignedUrlService presignedUrlService; + + @Test + @DisplayName("์œ ํšจํ•œ ์š”์ฒญ์ผ ๊ฒฝ์šฐ Presigned URL ๋ชฉ๋ก์„ ์ •์ƒ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void createPresignedUrls_Success() { + // given + User user = mock(User.class); + ReflectionTestUtils.setField(photoService, "bucket", "test-bucket"); // @Value ์ฃผ์ž… ์ฒ˜๋ฆฌ + + String code = "album-code"; + Album album = Album.builder().makerId(1L).maxPhotoCount(100).build(); + + PhotoPresignedUrlRequest.FileInfo fileInfo = + new PhotoPresignedUrlRequest.FileInfo("1.jpg", LocalDateTime.now(), 3000000, "image/jpeg"); + PhotoPresignedUrlRequest request = + new PhotoPresignedUrlRequest(code, List.of(fileInfo)); + + given(albumValidator.validateAlbumCode(code)).willReturn(album); + given(albumRepository.findByIdForUpdate(any())).willReturn(album); // Lock ํš๋“ ๋ชจํ‚น + + // Presigned URL ์ƒ์„ฑ ๋ชจํ‚น + given(presignedUrlService.generatePresignedPutUrl(anyString(), anyString())) + .willReturn("https://s3.url/upload?sig=..."); + + // when + PhotoPresignedUrlResponse response = photoService.createPresignedUrls(user, request); + + // then + assertThat(response.presignedUrlInfos()).hasSize(1); + assertThat(response.presignedUrlInfos().get(0).uploadUrl()).contains("https://s3.url"); + + // DB์— UPLOADING ์ƒํƒœ๋กœ ์ €์žฅ๋˜์—ˆ๋Š”์ง€ ๊ฒ€์ฆ + verify(photoRepository).save(any(Photo.class)); + // ์šฉ๋Ÿ‰/๊ฐœ์ˆ˜ ๊ฒ€์ฆ ๋กœ์ง ํ˜ธ์ถœ ํ™•์ธ + verify(photoValidator).validatePhotoCount(anyLong(), anyInt(), anyInt()); + verify(photoValidator).validateFileInfos(any()); + } + + @Test + @DisplayName("์—…๋กœ๋“œ ์š”์ฒญํ•œ ์‚ฌ์ง„ ์ˆ˜๊ฐ€ ์•จ๋ฒ”์˜ ๋‚จ์€ ์šฉ๋Ÿ‰์„ ์ดˆ๊ณผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + void createPresignedUrls_Fail_MaxCountExceeded() { + // given + User user = mock(User.class); + String code = "album-code"; + Album album = mock(Album.class); + given(album.getId()).willReturn(1L); + given(album.getMaxPhotoCount()).willReturn(100); + + given(albumValidator.validateAlbumCode(code)).willReturn(album); + given(albumRepository.findByIdForUpdate(1L)).willReturn(album); + + // ํ˜„์žฌ 99์žฅ ์žˆ๊ณ , 2์žฅ ์—…๋กœ๋“œ ์‹œ๋„ + given(photoRepository.countActivePhotosByAlbumId(eq(1L), anyList())).willReturn(99L); + + PhotoPresignedUrlRequest request = new PhotoPresignedUrlRequest(code, List.of( + new PhotoPresignedUrlRequest.FileInfo("1.jpg", LocalDateTime.now(), 3000000, "image/jpeg"), + new PhotoPresignedUrlRequest.FileInfo("2.jpg", LocalDateTime.now(), 3000000, "image/jpeg") + )); + + // Validator๊ฐ€ ์˜ˆ์™ธ๋ฅผ ๋˜์ง€๋„๋ก ์„ค์ • (Spy๋‚˜ ์‹ค์ œ ๊ฐ์ฒด ์‚ฌ์šฉ ์‹œ์—๋Š” ๋กœ์ง์— ๋”ฐ๋ผ ๋ฐœ์ƒ, ์—ฌ๊ธฐ์„  Mock์˜ ํ–‰๋™ ์ •์˜) + doThrow(new PhotoException(PhotoErrorCode.PHOTO_MAX_COUNT_EXCEEDED)) + .when(photoValidator).validatePhotoCount(99L, 2, 100); + + // when & then + assertThatThrownBy(() -> photoService.createPresignedUrls(user, request)) + .isInstanceOf(PhotoException.class) + .hasFieldOrPropertyWithValue("errorCode", PhotoErrorCode.PHOTO_MAX_COUNT_EXCEEDED); + } + + @Test + @DisplayName("์—…๋กœ๋“œ ์‹คํŒจ ๋ณด๊ณ  ์‹œ, ํ•ด๋‹น ์‚ฌ์ง„๋“ค์˜ ์ƒํƒœ๊ฐ€ FAILED๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค.") + void reportUploadResult_Success() { + // given + User user = mock(User.class); + Long userId = 1L; + given(user.getId()).willReturn(userId); + + List failIds = List.of(101L, 102L); + PhotoUploadReportRequest request = new PhotoUploadReportRequest(failIds); + + // Validator ํ†ต๊ณผ ์„ค์ • + PhotoValidator.ValidatedPhotos validated = new PhotoValidator.ValidatedPhotos(List.of(), 1L); // ๋นˆ ๋ฆฌ์ŠคํŠธ๋ผ๋„ ํ๋ฆ„๋งŒ ๊ฒ€์ฆ + given(photoValidator.validatePhotos(userId, failIds)).willReturn(validated); + + // Update ๋กœ์ง ์‹คํ–‰ ๊ฒฐ๊ณผ ์„ค์ • (2๊ฐœ ์ˆ˜์ •๋จ) + given(photoRepository.updateStatusByIdsAndUserIdAndExpectedStatus( + failIds, userId, PhotoStatus.FAILED, PhotoStatus.UPLOADING + )).willReturn(2); + + // when + photoService.reportUploadResult(user, request); + + // then + // ์ƒํƒœ ์—…๋ฐ์ดํŠธ ๋ฉ”์„œ๋“œ๊ฐ€ ์ •ํ™•ํ•œ ์ธ์ž๋กœ ํ˜ธ์ถœ๋˜์—ˆ๋Š”์ง€ ๊ฒ€์ฆ + verify(photoRepository).updateStatusByIdsAndUserIdAndExpectedStatus( + failIds, userId, PhotoStatus.FAILED, PhotoStatus.UPLOADING + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/cheeeese/photo/integration/PhotoQueryServiceIntegrationTest.java b/src/test/java/com/cheeeese/photo/integration/PhotoQueryServiceIntegrationTest.java new file mode 100644 index 0000000..e3ba9a3 --- /dev/null +++ b/src/test/java/com/cheeeese/photo/integration/PhotoQueryServiceIntegrationTest.java @@ -0,0 +1,134 @@ +package com.cheeeese.photo.integration; + +import com.cheeeese.album.domain.Album; +import com.cheeeese.album.domain.UserAlbum; +import com.cheeeese.album.domain.type.AlbumSorting; +import com.cheeeese.album.infrastructure.persistence.AlbumRepository; +import com.cheeeese.album.infrastructure.persistence.UserAlbumRepository; +import com.cheeeese.fixture.FixtureFactory; +import com.cheeeese.photo.application.PhotoInfoService; +import com.cheeeese.photo.application.PhotoQueryService; +import com.cheeeese.photo.domain.Photo; +import com.cheeeese.photo.domain.PhotoHistory; +import com.cheeeese.photo.domain.PhotoLikes; +import com.cheeeese.photo.dto.response.*; +import com.cheeeese.photo.infrastructure.persistence.PhotoHistoryRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoLikesRepository; +import com.cheeeese.photo.infrastructure.persistence.PhotoRepository; +import com.cheeeese.user.domain.User; +import com.cheeeese.user.infrastructure.persistence.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +public class PhotoQueryServiceIntegrationTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private AlbumRepository albumRepository; + + @Autowired + private UserAlbumRepository userAlbumRepository; + + @Autowired + private PhotoRepository photoRepository; + + @Autowired + private PhotoHistoryRepository photoHistoryRepository; + + @Autowired + private PhotoLikesRepository photoLikesRepository; + + @Autowired + private PhotoQueryService photoQueryService; + + @Autowired + private PhotoInfoService photoInfoService; + + private User testUser; + private Album testAlbum; + private UserAlbum testUserAlbum; + private Photo testPhoto; + private PhotoHistory testPhotoHistory; + private PhotoLikes testPhotoLikes; + + @BeforeEach + void setUp() { + testUser = userRepository.save(FixtureFactory.createKakaoUser()); + testAlbum = albumRepository.save(FixtureFactory.createAlbum(testUser.getId())); + testUserAlbum = userAlbumRepository.save(FixtureFactory.createHostUserAlbum(testUser, testAlbum)); + for (int i = 1; i <= 3; i++) { + testPhoto = FixtureFactory.createCompletedPhoto(testUser, testAlbum, LocalDateTime.now()); + testPhoto.updateImageUrl("album/" + testAlbum.getId() + "/original/photo_" + i + ".jpg"); + photoRepository.save(testPhoto); + } + photoRepository.flush(); + testPhotoHistory = photoHistoryRepository.save(FixtureFactory.createPhotoHistory(testUser, testPhoto)); + testPhotoLikes = photoLikesRepository.save(FixtureFactory.createPhotoLikes(testUser, testPhoto)); + } + + @Test + @DisplayName("์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ - ํŽ˜์ด์ง• ๋ฐ CDN URL ๋ณ€ํ™˜ ํ™•์ธ") + void getPhotoList() { + // when + PhotoPageResponse page = photoQueryService.getPhotoPage( + testUser, testAlbum.getCode(), 0, 20, AlbumSorting.CREATED_AT + ); + + // then + assertThat(page.responses()).hasSize(3); + assertThat(page.responses().getFirst().imageUrl()).contains("say-cheese.edge.naverncp.com"); + } + + @Test + @DisplayName("์‚ฌ์ง„ ์ƒ์„ธ ์กฐํšŒ ํ…Œ์ŠคํŠธ - CDN URL, ์ตœ๊ทผ ๋‹ค์šด๋กœ๋“œ ์—ฌ๋ถ€ ํ™•์ธ") + void getPhotoDetail() { + // given + testPhoto.updateImageUrl("album/" + testAlbum.getId() + "/original/test.jpg"); + photoHistoryRepository.save(testPhotoHistory); + + // when + PhotoDetailResponse response = photoQueryService.getPhotoDetail(testUser, testAlbum.getCode(), testPhoto.getId()); + + // then + assertThat(response.imageUrl()).contains("edge.naverncp.com"); + assertThat(response.isRecentlyDownloaded()).isTrue(); + } + + @Test + @DisplayName("๋ฑํ•œ ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ") + void getLikedUserList() { + // when + PhotoLikedUserResponse likedUsers = photoInfoService.getPhotoLikedUsers( + testUser, testAlbum.getCode(), testPhoto.getId() + ); + + // then + assertThat(likedUsers.photoLikers()).hasSize(1); + assertThat(likedUsers.photoLikers().getFirst().name()).isNotBlank(); + } + + @Test + @DisplayName("๋ฑํ•œ ์‚ฌ์ง„ ๋ชฉ๋ก ์กฐํšŒ") + void getUserLikedPhotoList() { + // when + PhotoLikedPageResponse page = photoQueryService.getPhotoLiked( + testUser, testAlbum.getCode(), 0, 10 + ); + + // then + assertThat(page.responses()).hasSize(1); + assertThat(page.responses().getFirst().imageUrl()).contains("say-cheese.edge.naverncp.com"); + } +}