diff --git a/.github/actions/deploy-module/action.yml b/.github/actions/deploy-module/action.yml
new file mode 100644
index 00000000..a1227e54
--- /dev/null
+++ b/.github/actions/deploy-module/action.yml
@@ -0,0 +1,177 @@
+name: 'Deploy Module'
+description: 'Build, push and deploy a module to server (dev/prod)'
+
+inputs:
+ environment:
+ description: 'Environment (dev or prod)'
+ required: true
+ module:
+ description: 'Module name (apis, admin, batch)'
+ required: true
+ port:
+ description: 'Server port (for logging only - actual port defined in docker-compose.yml)'
+ required: false
+ default: 'N/A'
+ dockerhub-username:
+ description: 'Docker Hub username'
+ required: true
+ dockerhub-token:
+ description: 'Docker Hub token'
+ required: true
+ secret-properties:
+ description: 'Secret properties (dev or prod)'
+ required: true
+ apple-auth-key:
+ description: 'Apple Auth Key'
+ required: true
+ host:
+ description: 'Server host'
+ required: true
+ username:
+ description: 'Server username'
+ required: true
+ ssh-key:
+ description: 'Server SSH key'
+ required: true
+ ssh-port:
+ description: 'Server SSH port'
+ required: true
+ discord-webhook-url:
+ description: 'Discord webhook URL'
+ required: true
+ image-prefix:
+ description: 'Docker image prefix'
+ required: true
+ image-tag-type:
+ description: 'Image tag type (development-latest or semver)'
+ required: true
+ deploy-script:
+ description: 'Deploy script name (deploy-dev.sh or deploy-prod.sh)'
+ required: true
+ default: 'deploy-dev.sh'
+ release-version:
+ description: 'Release version tag (e.g., v1.2.3). Only used in production for metadata tracking.'
+ required: false
+ default: 'unknown'
+
+runs:
+ using: 'composite'
+ steps:
+ - name: Inject application-secret.properties from Secrets
+ shell: bash
+ run: |
+ mkdir -p ./secret
+ echo "$SECRET_CONTENT" > ./secret/application-${{ inputs.environment }}-secret.properties
+ echo "$APPLE_KEY_CONTENT" > ./secret/AuthKey.p8
+ chmod 600 ./secret/*
+ env:
+ SECRET_CONTENT: ${{ inputs.secret-properties }}
+ APPLE_KEY_CONTENT: ${{ inputs.apple-auth-key }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ inputs.dockerhub-username }}
+ password: ${{ inputs.dockerhub-token }}
+
+ - name: Extract metadata for Docker
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: docker.io/${{ inputs.image-prefix }}-${{ inputs.module }}
+ tags: ${{ inputs.image-tag-type }}
+
+ - name: Build and push Docker image
+ id: build-and-push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: linux/amd64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ cache-from: type=gha,scope=${{ inputs.module }}
+ cache-to: type=gha,mode=max,scope=${{ inputs.module }}
+ build-args: |
+ MODULE=${{ inputs.module }}
+
+ - name: Deploy to Server
+ uses: appleboy/ssh-action@v1.2.2
+ with:
+ host: ${{ inputs.host }}
+ username: ${{ inputs.username }}
+ key: ${{ inputs.ssh-key }}
+ port: ${{ inputs.ssh-port }}
+ script: |
+ export DOCKERHUB_USERNAME="${{ inputs.dockerhub-username }}"
+ export DOCKERHUB_TOKEN="${{ inputs.dockerhub-token }}"
+ export MODULE="${{ inputs.module }}"
+ export SPRING_PROFILE="${{ inputs.environment }}"
+ export IMAGE_TAG="$(echo "${{ steps.meta.outputs.tags }}" | head -n1)"
+ export RELEASE_VERSION="${{ inputs.release-version }}"
+ cd ~/deploy
+ chmod +x ./${{ inputs.deploy-script }}
+ ./${{ inputs.deploy-script }}
+
+ - name: Send Discord notification on success (Development)
+ uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645
+ if: success() && inputs.environment == 'dev'
+ continue-on-error: true
+ with:
+ webhook-url: ${{ inputs.discord-webhook-url }}
+ embed-title: "โ
[${{ github.repository }}] Development Deploy Succeeded - ${{ inputs.module }}"
+ embed-description: |
+ **Module**: `${{ inputs.module }}`
+ **Commit**: `${{ github.sha }}`
+ **Author**: `${{ github.actor }}`
+ **Message**: `${{ github.event.head_commit.message }}`
+ [View Committed Changes](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
+ embed-color: 65280
+
+ - name: Send Discord notification on success (Production)
+ uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645
+ if: success() && inputs.environment == 'prod'
+ continue-on-error: true
+ with:
+ webhook-url: ${{ inputs.discord-webhook-url }}
+ content: "๐ **Production Deploy Succeeded!**"
+ embed-title: "โ
[${{ github.repository }}] Production Deploy Succeeded - ${{ inputs.module }}"
+ embed-description: |
+ **Module**: `${{ inputs.module }}`
+ **Deployed by**: `${{ github.actor }}`
+ The new version has been successfully deployed to production.
+ [View Workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
+ embed-color: 65280
+
+ - name: Send Discord notification on failure (Development)
+ uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645
+ if: failure() && inputs.environment == 'dev'
+ continue-on-error: true
+ with:
+ webhook-url: ${{ inputs.discord-webhook-url }}
+ embed-title: "โ [${{ github.repository }}] Development Deploy Failed - ${{ inputs.module }}"
+ embed-description: |
+ **Module**: `${{ inputs.module }}`
+ **Commit**: `${{ github.sha }}`
+ **Author**: `${{ github.actor }}`
+ An error occurred during the workflow execution.
+ [View Failed Workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
+ embed-color: 16711680
+
+ - name: Send Discord notification on failure (Production)
+ uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645
+ if: failure() && inputs.environment == 'prod'
+ continue-on-error: true
+ with:
+ webhook-url: ${{ inputs.discord-webhook-url }}
+ content: "๐จ **Production Deploy Failed!**"
+ embed-title: "โ [${{ github.repository }}] Production Deploy Failed - ${{ inputs.module }}"
+ embed-description: |
+ **Module**: `${{ inputs.module }}`
+ **Deployed by**: `${{ github.actor }}`
+ An error occurred during the production deployment workflow.
+ [View Failed Workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
+ embed-color: 16711680
diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml
index eeceb7a5..5975ab1b 100644
--- a/.github/workflows/ci-pr.yml
+++ b/.github/workflows/ci-pr.yml
@@ -58,4 +58,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- run: ./gradlew fullCheck --parallel --build-cache --info --stacktrace
+ run: |
+ # fullCheck: ๋ชจ๋ ๋ชจ๋ (apis, admin, batch, gateway ๋ฑ)์ ๋น๋, ํ
์คํธ, ์ ์ ๋ถ์ ์ํ
+ # --parallel: ๋ชจ๋๋ณ ๋ณ๋ ฌ ๋น๋๋ก ์๊ฐ ๋จ์ถ
+ # --build-cache: Gradle ๋น๋ ์บ์ ์ฌ์ฉ
+ ./gradlew fullCheck --parallel --build-cache --info --stacktrace
diff --git a/.github/workflows/close-jira-issue.yml b/.github/workflows/close-jira-issue.yml
index f41ec72d..a874a665 100644
--- a/.github/workflows/close-jira-issue.yml
+++ b/.github/workflows/close-jira-issue.yml
@@ -29,4 +29,4 @@ jobs:
uses: atlassian/gajira-transition@v3
with:
issue: ${{ env.JIRA_KEY }}
- transition: "31"
+ transition: ๊ฐ๋ฐ ์๋ฃ
diff --git a/.github/workflows/create-jira-issue.yml b/.github/workflows/create-jira-issue.yml
index 60eb1b8f..e8398668 100644
--- a/.github/workflows/create-jira-issue.yml
+++ b/.github/workflows/create-jira-issue.yml
@@ -85,7 +85,7 @@ jobs:
uses: atlassian/gajira-create@v3
with:
project: BOOK
- issuetype: Task
+ issuetype: ํ์ ์์
summary: '${{ github.event.issue.title }}'
description: '${{ steps.md2jira.outputs.output-text }}'
fields: |
diff --git a/.github/workflows/dev-ci-cd.yml b/.github/workflows/dev-ci-cd.yml
index 68e85ef2..8cef3a29 100644
--- a/.github/workflows/dev-ci-cd.yml
+++ b/.github/workflows/dev-ci-cd.yml
@@ -11,96 +11,93 @@ concurrency:
env:
REGISTRY: docker.io
- IMAGE_NAME: ninecraft0523/ninecraft-server
- MODULE: apis
+ IMAGE_PREFIX: ninecraft0523/ninecraft
jobs:
- build-push-and-deploy:
+ detect-changes:
runs-on: ubuntu-24.04
- timeout-minutes: 20
- environment: development
-
+ outputs:
+ apis: ${{ steps.filter.outputs.apis }}
+ # admin: ${{ steps.filter.outputs.admin }} # TODO: Uncomment when admin module is ready
+ batch: ${{ steps.filter.outputs.batch }}
+ any: ${{ steps.filter.outputs.any }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- - name: Inject application-secret.properties from Secrets
- run: |
- mkdir ./secret
- echo "${{ secrets.DEV_SECRET_PROPERTIES }}" > ./secret/application-dev-secret.properties
- echo "${{ secrets.APPLE_AUTH_KEY }}" > ./secret/AuthKey.p8
- chmod 600 ./secret/*
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to Docker Hub
- uses: docker/login-action@v3
+ - name: Check changed files
+ uses: dorny/paths-filter@v3
+ id: filter
with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
+ filters: |
+ apis:
+ - 'apis/**'
+ - 'domain/**'
+ - 'infra/**'
+ - 'global-utils/**'
+ - 'observability/**'
+ - '.github/**'
+ # admin: # TODO: Uncomment when admin module is ready
+ # - 'admin/**'
+ # - 'domain/**'
+ # - 'infra/**'
+ # - 'global-utils/**'
+ # - 'observability/**'
+ # - '.github/**'
+ batch:
+ - 'batch/**'
+ - 'domain/**'
+ - 'infra/**'
+ - 'global-utils/**'
+ - 'observability/**'
+ - '.github/**'
+ any:
+ - 'apis/**'
+ # - 'admin/**' # TODO: Uncomment when admin module is ready
+ - 'batch/**'
+ - 'domain/**'
+ - 'infra/**'
+ - 'global-utils/**'
+ - 'observability/**'
+ - '.github/**'
- - name: Extract metadata for Docker
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- tags: |
- type=raw,value=development-latest
+ build-push-and-deploy:
+ needs: detect-changes
+ if: needs.detect-changes.outputs.any == 'true'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ environment: development
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - module: apis
+ changed: ${{ needs.detect-changes.outputs.apis }}
+ # - module: admin # TODO: Uncomment when admin module is ready
+ # changed: ${{ needs.detect-changes.outputs.admin }}
+ - module: batch
+ changed: ${{ needs.detect-changes.outputs.batch }}
- - name: Build and push Docker image
- id: build-and-push
- uses: docker/build-push-action@v6
- with:
- context: .
- file: ./Dockerfile
- platforms: linux/amd64
- push: true
- tags: ${{ steps.meta.outputs.tags }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
- build-args: |
- MODULE=${{ env.MODULE }}
+ steps:
+ - name: Checkout code
+ if: matrix.changed == 'true'
+ uses: actions/checkout@v4
- - name: Deploy to Development Server
- uses: appleboy/ssh-action@v1.2.2
+ - name: Deploy module
+ if: matrix.changed == 'true'
+ uses: ./.github/actions/deploy-module
with:
+ environment: dev
+ module: ${{ matrix.module }}
+ dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
+ dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
+ secret-properties: ${{ secrets.DEV_SECRET_PROPERTIES }}
+ apple-auth-key: ${{ secrets.APPLE_AUTH_KEY }}
host: ${{ secrets.DEV_HOST }}
username: ${{ secrets.DEV_USERNAME }}
- key: ${{ secrets.DEV_SSH_KEY }}
- port: ${{ secrets.DEV_PORT }}
- script: |
- export DOCKERHUB_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}"
- export DOCKERHUB_TOKEN="${{ secrets.DOCKERHUB_TOKEN }}"
- export IMAGE_TAG="$(echo "${{ steps.meta.outputs.tags }}" | head -n1)"
- cd ~/deploy
- chmod +x ./deploy.sh
- ./deploy.sh
-
- - name: Send Discord notification on success
- uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645
- if: success()
- continue-on-error: true
- with:
- webhook-url: ${{ secrets.DEV_DEPLOY_DISCORD_WEBHOOK_URL }}
- embed-title: "โ
[${{ github.repository }}] Development Deploy Succeeded"
- embed-description: |
- **Commit**: `${{ github.sha }}`
- **Author**: `${{ github.actor }}`
- **Message**: `${{ github.event.head_commit.message }}`
- [View Committed Changes](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
- embed-color: 65280
-
- - name: Send Discord notification on failure
- uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645
- if: failure()
- continue-on-error: true
- with:
- webhook-url: ${{ secrets.DEV_DEPLOY_DISCORD_WEBHOOK_URL }}
- embed-title: "โ [${{ github.repository }}] Development Deploy Failed"
- embed-description: |
- **Commit**: `${{ github.sha }}`
- **Author**: `${{ github.actor }}`
- An error occurred during the workflow execution.
- [View Failed Workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
- embed-color: 16711680
+ ssh-key: ${{ secrets.DEV_SSH_KEY }}
+ ssh-port: ${{ secrets.DEV_PORT }}
+ discord-webhook-url: ${{ secrets.DEV_DEPLOY_DISCORD_WEBHOOK_URL }}
+ image-prefix: ${{ env.IMAGE_PREFIX }}
+ image-tag-type: type=raw,value=development-latest
+ deploy-script: deploy-dev.sh
diff --git a/.github/workflows/prod-ci-cd.yml b/.github/workflows/prod-ci-cd.yml
index 9c89683e..252c7a6d 100644
--- a/.github/workflows/prod-ci-cd.yml
+++ b/.github/workflows/prod-ci-cd.yml
@@ -1,4 +1,4 @@
-name: Prod CI/CD - Build, Push and Deploy
+name: Prod CI/CD - Build, Push and Deploy (Sequential with Matrix)
on:
release:
@@ -11,97 +11,62 @@ concurrency:
env:
REGISTRY: docker.io
- IMAGE_NAME: ninecraft0523/ninecraft-server
- MODULE: apis
+ IMAGE_PREFIX: ninecraft0523/ninecraft
jobs:
- build-push-and-deploy:
+ deploy-modules:
runs-on: ubuntu-24.04
timeout-minutes: 25
environment: production
+ strategy:
+ max-parallel: 1
+ fail-fast: true
+ matrix:
+ module: [apis, batch] # ๋ฐฐํฌ ์์: apis โ batch (๋ฐฐ์ด ์์๋๋ก ์คํ)
steps:
- name: Checkout code
uses: actions/checkout@v4
- - name: Inject application-secret.properties from Secrets
- run: |
- mkdir ./secret
- echo "${{ secrets.PROD_SECRET_PROPERTIES }}" > ./secret/application-prod-secret.properties
- echo "${{ secrets.APPLE_AUTH_KEY }}" > ./secret/AuthKey.p8
- chmod 600 ./secret/*
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to Docker Hub
- uses: docker/login-action@v3
+ - name: Deploy ${{ matrix.module }} module
+ uses: ./.github/actions/deploy-module
with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Extract metadata for Docker
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- tags: |
+ environment: prod
+ module: ${{ matrix.module }}
+ dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
+ dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
+ secret-properties: ${{ secrets.PROD_SECRET_PROPERTIES }}
+ apple-auth-key: ${{ secrets.APPLE_AUTH_KEY }}
+ host: ${{ secrets.PROD_HOST }}
+ username: ${{ secrets.PROD_USERNAME }}
+ ssh-key: ${{ secrets.PROD_SSH_KEY }}
+ ssh-port: ${{ secrets.PROD_PORT }}
+ discord-webhook-url: ${{ secrets.PROD_DEPLOY_DISCORD_WEBHOOK_URL }}
+ image-prefix: ${{ env.IMAGE_PREFIX }}
+ image-tag-type: |
type=semver,pattern={{version}}
type=raw,value=production-latest
+ deploy-script: deploy-prod.sh
+ release-version: ${{ github.event.release.tag_name }}
- - name: Build and push Docker image
- id: build-and-push
- uses: docker/build-push-action@v6
- with:
- context: .
- file: ./Dockerfile
- platforms: linux/amd64
- push: true
- tags: ${{ steps.meta.outputs.tags }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
- build-args: |
- MODULE=${{ env.MODULE }}
-
- - name: Deploy to Production Server
- uses: appleboy/ssh-action@v1.2.2
- with:
- host: ${{ secrets.PROD_HOST }}
- username: ${{ secrets.PROD_USERNAME }}
- key: ${{ secrets.PROD_SSH_KEY }}
- port: ${{ secrets.PROD_PORT }}
- script: |
- export DOCKERHUB_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}"
- export DOCKERHUB_TOKEN="${{ secrets.DOCKERHUB_TOKEN }}"
- export IMAGE_TAG="$(echo "${{ steps.meta.outputs.tags }}" | head -n1)"
- export VERSION_TAG="${{ steps.meta.outputs.version }}"
- export RELEASE_VERSION="${{ github.event.release.tag_name }}"
- cd ~/deploy
- chmod +x ./deploy.sh
- ./deploy.sh
+ deployment-summary:
+ runs-on: ubuntu-24.04
+ needs: deploy-modules
+ if: always()
- - name: Send Discord notification on success
- if: success()
+ steps:
+ - name: Send deployment summary to Discord
uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645
+ continue-on-error: true
with:
webhook-url: ${{ secrets.PROD_DEPLOY_DISCORD_WEBHOOK_URL }}
- content: "๐ **Production Deploy Succeeded!**"
- embed-title: "โ
[${{ github.repository }}] Release **${{ github.event.release.tag_name }}**"
+ content: "๐ **Production Deployment Summary**"
+ embed-title: "[${{ github.repository }}] Production Deployment Completed"
embed-description: |
- **Released by**: `${{ github.actor }}`
- The new version has been successfully deployed to production.
- [View Release Notes](https://github.com/${{ github.repository }}/releases/tag/${{ github.event.release.tag_name }})
- embed-color: 65280 # Green
+ **Release**: `${{ github.event.release.tag_name }}`
+ **Status**: ${{ needs.deploy-modules.result == 'success' && 'โ
All modules deployed successfully' || 'โ Deployment failed' }}
+ **Deployed by**: `${{ github.actor }}`
- - name: Send Discord notification on failure
- if: failure()
- uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645
- with:
- webhook-url: ${{ secrets.PROD_DEPLOY_DISCORD_WEBHOOK_URL }}
- content: "๐จ **Production Deploy Failed!**"
- embed-title: "โ [${{ github.repository }}] Release **${{ github.event.release.tag_name }}**"
- embed-description: |
- **Released by**: `${{ github.actor }}`
- An error occurred during the production deployment workflow.
- [View Failed Workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
- embed-color: 16711680 # Red
+ [View Release](${{ github.event.release.html_url }})
+ [View Workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
+ embed-color: ${{ needs.deploy-modules.result == 'success' && '65280' || '16711680' }}
diff --git a/.gitignore b/.gitignore
index 9c7598b7..e8b6d83c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -175,5 +175,6 @@ node_modules/
# secret
application-*-secret.properties
secret/
+**/reed-firebase-adminsdk.json
# End of https://www.toptal.com/developers/gitignore/api/intellij,kotlin,gradle
diff --git a/Dockerfile b/Dockerfile
index b2f69cf6..a7c7cf13 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,7 @@
# Build stage
FROM gradle:8.7-jdk21 AS build
-ARG MODULE=apis
+# MODULE: ๋น๋ํ ๋ชจ๋ (apis, admin, batch) - ๋น๋ ์ --build-arg MODULE=xxx ํ์
+ARG MODULE
WORKDIR /app
# ์์กด์ฑ ์บ์ฑ ์ต์ ํ๋ฅผ ์ํ ๋จ๊ณ๋ณ ๋ณต์ฌ
@@ -23,8 +24,9 @@ COPY . .
RUN ./gradlew :${MODULE}:bootJar --parallel --no-daemon
# Run stage
-FROM openjdk:21-slim
-ARG MODULE=apis
+FROM eclipse-temurin:21-jdk
+# MODULE: ๋น๋ํ ๋ชจ๋ (apis, admin, batch) - ๋น๋ ์ --build-arg MODULE=xxx ํ์
+ARG MODULE
WORKDIR /app
# ๋ฉํฐ์คํ
์ด์ง ๋น๋๋ก ์ต์ข
์ด๋ฏธ์ง ํฌ๊ธฐ ์ต์ํ
@@ -39,4 +41,6 @@ ENV TZ=Asia/Seoul
# JVM ์คํ ์ค์
# - Xms512m: ์ด๊ธฐ ํ ๋ฉ๋ชจ๋ฆฌ 512MB
# - Xmx1g: ์ต๋ ํ ๋ฉ๋ชจ๋ฆฌ 1GB
-ENTRYPOINT ["java", "-Xms512m", "-Xmx1g", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"]
+# - server.port: Spring Boot ์๋ฒ ํฌํธ (์ปจํ
์ด๋ ์คํ ์ -e SERVER_PORT=xxxx๋ก ์ฃผ์
, ๊ธฐ๋ณธ๊ฐ: 8080)
+# - exec: shell ํ๋ก์ธ์ค๋ฅผ java ํ๋ก์ธ์ค๋ก ๋์ฒดํ์ฌ graceful shutdown ์ง์
+ENTRYPOINT ["sh", "-c", "exec java -Xms512m -Xmx1g -Duser.timezone=Asia/Seoul -Dserver.port=${SERVER_PORT:-8080} -jar app.jar"]
diff --git a/Dockerfile-dev b/Dockerfile-dev
deleted file mode 100644
index b2f69cf6..00000000
--- a/Dockerfile-dev
+++ /dev/null
@@ -1,42 +0,0 @@
-# Build stage
-FROM gradle:8.7-jdk21 AS build
-ARG MODULE=apis
-WORKDIR /app
-
-# ์์กด์ฑ ์บ์ฑ ์ต์ ํ๋ฅผ ์ํ ๋จ๊ณ๋ณ ๋ณต์ฌ
-# 1. ์์กด์ฑ ์บ์ฑ ์ต์ ํ๋ฅผ ์ํด Gradle Wrapper ๋ฐ ์์กด์ฑ ๊ด๋ จ ํ์ผ๋ง ๋จผ์ ๋ณต์ฌ
-COPY build.gradle.kts settings.gradle.kts gradlew gradlew.bat ./
-COPY gradle/wrapper/ ./gradle/wrapper/
-COPY buildSrc/ ./buildSrc/
-COPY ${MODULE}/build.gradle.kts ./${MODULE}/
-
-# 2. Gradle Wrapper ์คํ ๊ถํ ๋ถ์ฌ
-RUN chmod +x gradlew
-
-# 3. ์์ค์ฝ๋ ์์ด ์์กด์ฑ๋ง ๋ค์ด๋ก๋
-RUN ./gradlew :${MODULE}:dependencies --no-daemon
-
-# 4. ์์ค์ฝ๋ ์ ์ฒด ๋ณต์ฌ
-COPY . .
-
-# 5. ์ค์ ์ ํ๋ฆฌ์ผ์ด์
๋น๋
-RUN ./gradlew :${MODULE}:bootJar --parallel --no-daemon
-
-# Run stage
-FROM openjdk:21-slim
-ARG MODULE=apis
-WORKDIR /app
-
-# ๋ฉํฐ์คํ
์ด์ง ๋น๋๋ก ์ต์ข
์ด๋ฏธ์ง ํฌ๊ธฐ ์ต์ํ
-COPY --from=build /app/${MODULE}/build/libs/${MODULE}-*.jar app.jar
-
-# ๋ฐํ์์ ํ์ํ secret ํด๋ ๋ณต์ฌ
-COPY --from=build /app/secret ./secret/
-
-# TimeZone KST ์ค์
-ENV TZ=Asia/Seoul
-
-# JVM ์คํ ์ค์
-# - Xms512m: ์ด๊ธฐ ํ ๋ฉ๋ชจ๋ฆฌ 512MB
-# - Xmx1g: ์ต๋ ํ ๋ฉ๋ชจ๋ฆฌ 1GB
-ENTRYPOINT ["java", "-Xms512m", "-Xmx1g", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"]
diff --git a/README.md b/README.md
index ccb6bf35..5635ca04 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,35 @@
-# 26th-APP-TEAM-1-BE
+# 26th-APP-TEAM-1-BE : - Reed ๋ฌธ์ฅ๊ณผ ๊ฐ์ ์ ํจ๊ป ๋ด๋ ๋
์ ๊ธฐ๋ก
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Features
+| ํ | ๋์ ๊ฒ์ ๋ฐ ๋ฑ๋ก | ๋ด์์ฌ |
+|:---:|:---:|:---:|
+|
|
|
|
+
+| OCR | ๊ธฐ๋ก ๋ฑ๋ก | ๋์ & ๊ธฐ๋ก ์์ธ |
+|:---:|:---:|:---:|
+|
|
|
|
+
+| ๊ธฐ๋ก ์นด๋ ๊ณต์ |
+|:---:|
+|
|
+
## ๐ ๋ชจ๋๋ณ ์์ธ ์ค๋ช
๋ฐ๋ก๊ฐ๊ธฐ
diff --git a/admin/build.gradle.kts b/admin/build.gradle.kts
index 9b84b1db..05319495 100644
--- a/admin/build.gradle.kts
+++ b/admin/build.gradle.kts
@@ -4,6 +4,7 @@ dependencies {
implementation(project(Dependencies.Projects.INFRA))
implementation(project(Dependencies.Projects.DOMAIN))
implementation(project(Dependencies.Projects.GLOBAL_UTILS))
+ implementation(project(Dependencies.Projects.OBSERVABILITY))
implementation(Dependencies.Spring.BOOT_STARTER_WEB)
implementation(Dependencies.Spring.BOOT_STARTER_SECURITY)
diff --git a/admin/src/main/kotlin/org/yapp/admin/AdminApplication.kt b/admin/src/main/kotlin/org/yapp/admin/AdminApplication.kt
index 513706a8..6c029fc8 100644
--- a/admin/src/main/kotlin/org/yapp/admin/AdminApplication.kt
+++ b/admin/src/main/kotlin/org/yapp/admin/AdminApplication.kt
@@ -1,11 +1,16 @@
package org.yapp.admin
import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
import org.springframework.boot.runApplication
+import org.springframework.context.annotation.ComponentScan
-@SpringBootApplication
+@SpringBootApplication(
+ exclude = [JpaRepositoriesAutoConfiguration::class]
+)
+@ComponentScan(basePackages = ["org.yapp"])
class AdminApplication
fun main(args: Array) {
- runApplication(*args)
+ runApplication(*args)
}
diff --git a/admin/src/main/resources/application.yml b/admin/src/main/resources/application.yml
index b0b7ec80..a9371496 100644
--- a/admin/src/main/resources/application.yml
+++ b/admin/src/main/resources/application.yml
@@ -9,17 +9,25 @@ spring:
group:
dev:
- persistence
+ - crosscutting
- jwt
- redis
- external
+ - observability
prod:
- persistence
+ - crosscutting
- jwt
- redis
- external
+ - observability
test:
- persistence
+ - crosscutting
- jwt
+ - redis
+ - external
+ - observability
servlet:
multipart:
max-file-size: 10MB
diff --git a/apis/build.gradle.kts b/apis/build.gradle.kts
index 4d04c486..c6ab8ff1 100644
--- a/apis/build.gradle.kts
+++ b/apis/build.gradle.kts
@@ -5,6 +5,7 @@ dependencies {
implementation(project(Dependencies.Projects.DOMAIN))
implementation(project(Dependencies.Projects.GLOBAL_UTILS))
implementation(project(Dependencies.Projects.GATEWAY))
+ implementation(project(Dependencies.Projects.OBSERVABILITY))
implementation(Dependencies.Spring.BOOT_STARTER_WEB)
implementation(Dependencies.Spring.BOOT_STARTER_DATA_JPA)
diff --git a/apis/src/main/kotlin/org/yapp/apis/ApisApplication.kt b/apis/src/main/kotlin/org/yapp/apis/ApisApplication.kt
index fb5c2683..45714bbe 100644
--- a/apis/src/main/kotlin/org/yapp/apis/ApisApplication.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/ApisApplication.kt
@@ -3,20 +3,12 @@ package org.yapp.apis
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
import org.springframework.boot.runApplication
+import org.springframework.context.annotation.ComponentScan
-/**
- * Main application class for the apis module.
- */
@SpringBootApplication(
- scanBasePackages = [
- "org.yapp.apis",
- "org.yapp.infra",
- "org.yapp.domain",
- "org.yapp.gateway",
- "org.yapp.globalutils"
- ],
- exclude = [JpaRepositoriesAutoConfiguration::class]
+ exclude = [JpaRepositoriesAutoConfiguration::class] // infra ๋ชจ๋์์ @EnableJpaRepositories๋ก ๋ช
์์ ์ผ๋ก ์ค์ ํ์ฌ ์๋ ๊ด๋ฆฌ
)
+@ComponentScan(basePackages = ["org.yapp"])
class ApisApplication
fun main(args: Array) {
diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/UserIdResponse.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/UserIdResponse.kt
index 2432fe93..358bcd4d 100644
--- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/UserIdResponse.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/UserIdResponse.kt
@@ -1,7 +1,7 @@
package org.yapp.apis.auth.dto.response
import io.swagger.v3.oas.annotations.media.Schema
-import org.yapp.domain.token.RefreshToken.UserId
+import org.yapp.domain.user.User
import java.util.*
@Schema(
@@ -13,7 +13,7 @@ data class UserIdResponse(
val userId: UUID
) {
companion object {
- fun from(userId: UserId): UserIdResponse {
+ fun from(userId: User.Id): UserIdResponse {
return UserIdResponse(userId.value)
}
}
diff --git a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt
index bf07e47b..3096fa9a 100644
--- a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt
@@ -71,7 +71,7 @@ class BookController(
@RequestParam(required = false) status: BookStatus?,
@RequestParam(required = false) sort: UserBookSortType?,
@RequestParam(required = false) title: String?,
- @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC)
+ @PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC)
pageable: Pageable
): ResponseEntity {
val response = bookUseCase.getUserLibraryBooks(userId, status, sort, title, pageable)
diff --git a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt
index a578d2e5..075dc317 100644
--- a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt
@@ -158,7 +158,11 @@ interface BookControllerApi {
@RequestParam(required = false) @Parameter(description = "์ฑ
์ํ ํํฐ") status: BookStatus?,
@RequestParam(required = false) @Parameter(description = "์ ๋ ฌ ๋ฐฉ์") sort: UserBookSortType?,
@RequestParam(required = false) @Parameter(description = "์ฑ
์ ๋ชฉ ๊ฒ์") title: String?,
- @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC)
+ @Parameter(
+ description = "ํ์ด์ง ์ ๋ณด (๊ธฐ๋ณธ๊ฐ: 10๊ฐ, ์ต์ ์์ ์ผ ์)",
+ example = "?page=0&size=10&sort=updatedAt,desc"
+ )
+ @PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC)
pageable: Pageable
): ResponseEntity
diff --git a/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt b/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt
index 4cf813e3..742ad5cc 100644
--- a/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt
@@ -12,16 +12,19 @@ import org.yapp.apis.book.exception.UserBookException
import org.yapp.domain.userbook.BookStatus
import org.yapp.domain.userbook.UserBookDomainService
import org.yapp.domain.userbook.UserBookSortType
+import org.yapp.domain.user.UserDomainService
import org.yapp.globalutils.annotation.ApplicationService
import java.util.*
@ApplicationService
class UserBookService(
- private val userBookDomainService: UserBookDomainService
+ private val userBookDomainService: UserBookDomainService,
+ private val userDomainService: UserDomainService
) {
fun upsertUserBook(@Valid upsertUserBookRequest: UpsertUserBookRequest): UserBookResponse {
+ val userId = upsertUserBookRequest.validUserId()
val userBookInfoVO = userBookDomainService.upsertUserBook(
- upsertUserBookRequest.validUserId(),
+ userId,
upsertUserBookRequest.validBookId(),
upsertUserBookRequest.validBookIsbn13(),
upsertUserBookRequest.validBookTitle(),
@@ -30,6 +33,10 @@ class UserBookService(
upsertUserBookRequest.validBookCoverImageUrl(),
upsertUserBookRequest.validStatus()
)
+
+ // Update user's lastActivity when a book is registered
+ userDomainService.updateLastActivity(userId)
+
return UserBookResponse.from(userBookInfoVO)
}
diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordController.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordController.kt
index 784dde6c..50ee9837 100644
--- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordController.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordController.kt
@@ -63,7 +63,7 @@ class ReadingRecordController(
@AuthenticationPrincipal userId: UUID,
@PathVariable userBookId: UUID,
@RequestParam(required = false) sort: ReadingRecordSortType?,
- @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC)
+ @PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC)
pageable: Pageable
): ResponseEntity {
val response = readingRecordUseCase.getReadingRecordsByUserBookId(
diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApi.kt
index 94f8802b..c768fcca 100644
--- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApi.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApi.kt
@@ -105,8 +105,8 @@ interface ReadingRecordControllerApi {
@AuthenticationPrincipal @Parameter(description = "์ธ์ฆ๋ ์ฌ์ฉ์ ID") userId: UUID,
@PathVariable @Parameter(description = "๋
์ ๊ธฐ๋ก์ ์กฐํํ ์ฌ์ฉ์ ์ฑ
ID") userBookId: UUID,
@RequestParam(required = false) @Parameter(description = "์ ๋ ฌ ๋ฐฉ์ (PAGE_NUMBER_ASC, PAGE_NUMBER_DESC, CREATED_DATE_ASC, CREATED_DATE_DESC)") sort: ReadingRecordSortType?,
- @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC)
- @Parameter(description = "ํ์ด์ง๋ค์ด์
์ ๋ณด (ํ์ด์ง ๋ฒํธ, ํ์ด์ง ํฌ๊ธฐ, ์ ๋ ฌ ๋ฐฉ์)") pageable: Pageable
+ @PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC)
+ @Parameter(description = "ํ์ด์ง๋ค์ด์
์ ๋ณด (๊ธฐ๋ณธ๊ฐ: 10๊ฐ, ์ต์ ์์ ์ผ ์)") pageable: Pageable
): ResponseEntity
@Operation(
diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt
index fe999867..9f58eaf6 100644
--- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt
@@ -30,17 +30,19 @@ data class CreateReadingRecordRequest private constructor(
@field:Schema(description = "๊ธฐ์ต์ ๋จ๋ ๋ฌธ์ฅ", example = "์ด๊ฒ์ ๊ธฐ์ต์ ๋จ๋ ๋ฌธ์ฅ์
๋๋ค.", required = true)
val quote: String? = null,
- @field:NotBlank(message = "๊ฐ์ํ์ ํ์์
๋๋ค.")
@field:Size(max = 1000, message = "๊ฐ์ํ์ 1000์๋ฅผ ์ด๊ณผํ ์ ์์ต๋๋ค.")
- @field:Schema(description = "๊ฐ์ํ", example = "์ด ์ฑ
์ ๋งค์ฐ ์ธ์์ ์ด์์ต๋๋ค.", required = true)
+ @field:Schema(description = "๊ฐ์ํ", example = "์ด ์ฑ
์ ๋งค์ฐ ์ธ์์ ์ด์์ต๋๋ค.", required = false)
val review: String? = null,
@field:Size(max = 1, message = "๊ฐ์ ํ๊ทธ๋ ์ต๋ 1๊ฐ๊น์ง ๊ฐ๋ฅํฉ๋๋ค. (๋จ์ผ ๊ฐ์ ๋ง ๋ฐ์ง๋ง, ํ์ฅ์ฑ์ ์ํด ๋ฆฌ์คํธ ํํ๋ก ๊ด๋ฆฌ๋ฉ๋๋ค.)")
@field:Schema(description = "๊ฐ์ ํ๊ทธ ๋ชฉ๋ก (ํ์ฌ๋ ์ต๋ 1๊ฐ, ํ์ฅ ๊ฐ๋ฅ)", example = "[\"๊ฐ๋์ \"]")
val emotionTags: List<@Size(max = 10, message = "๊ฐ์ ํ๊ทธ๋ 10์๋ฅผ ์ด๊ณผํ ์ ์์ต๋๋ค.") String> = emptyList()
) {
- fun validPageNumber(): Int = pageNumber!!
- fun validQuote(): String = quote!!
- fun validReview(): String = review!!
+ fun validPageNumber(): Int =
+ requireNotNull(pageNumber) { "pageNumber๋ null์ผ ์ ์์ต๋๋ค." }
+
+ fun validQuote(): String =
+ requireNotNull(quote) { "quote๋ null์ผ ์ ์์ต๋๋ค." }
+
fun validEmotionTags(): List = emotionTags
}
diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequest.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequest.kt
index fc87bb69..f480204f 100644
--- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequest.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequest.kt
@@ -34,9 +34,4 @@ data class UpdateReadingRecordRequest private constructor(
@field:Size(max = 3, message = "๊ฐ์ ํ๊ทธ๋ ์ต๋ 3๊ฐ๊น์ง ๊ฐ๋ฅํฉ๋๋ค.")
@field:Schema(description = "์์ ํ ๊ฐ์ ํ๊ทธ ๋ชฉ๋ก", example = """["๋ฐ๋ปํจ","์ฆ๊ฑฐ์","์ฌํ","๊นจ๋ฌ์"]""")
val emotionTags: List<@Size(max = 10, message = "๊ฐ์ ํ๊ทธ๋ 10์๋ฅผ ์ด๊ณผํ ์ ์์ต๋๋ค.") String>?
-) {
- fun validPageNumber(): Int = pageNumber!!
- fun validQuote(): String = quote!!
- fun validReview(): String = review!!
- fun validEmotionTags(): List = emotionTags!!
-}
+)
diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt
index a075da1b..6107182a 100644
--- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt
@@ -24,7 +24,7 @@ data class ReadingRecordResponse private constructor(
val quote: String,
@field:Schema(description = "๊ฐ์ํ", example = "์ด ์ฑ
์ ๋งค์ฐ ์ธ์์ ์ด์์ต๋๋ค.")
- val review: String,
+ val review: String?,
@field:Schema(description = "๊ฐ์ ํ๊ทธ ๋ชฉ๋ก", example = "[\"๊ฐ๋์ \", \"์ฌํ\", \"ํฌ๋ง\"]")
val emotionTags: List,
@@ -56,7 +56,7 @@ data class ReadingRecordResponse private constructor(
userBookId = readingRecordInfoVO.userBookId.value,
pageNumber = readingRecordInfoVO.pageNumber.value,
quote = readingRecordInfoVO.quote.value,
- review = readingRecordInfoVO.review.value,
+ review = readingRecordInfoVO.review?.value,
emotionTags = readingRecordInfoVO.emotionTags,
createdAt = readingRecordInfoVO.createdAt.format(dateTimeFormatter),
updatedAt = readingRecordInfoVO.updatedAt.format(dateTimeFormatter),
diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt
index 6bee0161..06c37a84 100644
--- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt
@@ -7,12 +7,14 @@ import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequest
import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponse
import org.yapp.domain.readingrecord.ReadingRecordDomainService
import org.yapp.domain.readingrecord.ReadingRecordSortType
+import org.yapp.domain.user.UserDomainService
import org.yapp.globalutils.annotation.ApplicationService
import java.util.*
@ApplicationService
class ReadingRecordService(
private val readingRecordDomainService: ReadingRecordDomainService,
+ private val userDomainService: UserDomainService
) {
fun createReadingRecord(
userId: UUID,
@@ -23,10 +25,13 @@ class ReadingRecordService(
userBookId = userBookId,
pageNumber = request.validPageNumber(),
quote = request.validQuote(),
- review = request.validReview(),
+ review = request.review,
emotionTags = request.validEmotionTags()
)
+ // Update user's lastActivity when a reading record is created
+ userDomainService.updateLastActivity(userId)
+
return ReadingRecordResponse.from(readingRecordInfoVO)
}
@@ -51,16 +56,21 @@ class ReadingRecordService(
readingRecordDomainService.deleteAllByUserBookId(userBookId)
}
fun updateReadingRecord(
+ userId: UUID,
readingRecordId: UUID,
request: UpdateReadingRecordRequest
): ReadingRecordResponse {
val readingRecordInfoVO = readingRecordDomainService.modifyReadingRecord(
readingRecordId = readingRecordId,
- pageNumber = request.validPageNumber(),
- quote = request.validQuote(),
- review = request.validReview(),
- emotionTags = request.validEmotionTags()
+ pageNumber = request.pageNumber,
+ quote = request.quote,
+ review = request.review,
+ emotionTags = request.emotionTags
)
+
+ // Update user's lastActivity when a reading record is updated
+ userDomainService.updateLastActivity(userId)
+
return ReadingRecordResponse.from(readingRecordInfoVO)
}
diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt
index 64f5012c..7ab0bce5 100644
--- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt
@@ -86,6 +86,7 @@ class ReadingRecordUseCase(
userService.validateUserExists(userId)
return readingRecordService.updateReadingRecord(
+ userId = userId,
readingRecordId = readingRecordId,
request = request
)
diff --git a/apis/src/main/kotlin/org/yapp/apis/test/controller/TestController.kt b/apis/src/main/kotlin/org/yapp/apis/test/controller/TestController.kt
new file mode 100644
index 00000000..cc74fe2b
--- /dev/null
+++ b/apis/src/main/kotlin/org/yapp/apis/test/controller/TestController.kt
@@ -0,0 +1,25 @@
+package org.yapp.apis.test.controller
+
+import org.springframework.security.core.annotation.AuthenticationPrincipal
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PutMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.RestController
+import org.yapp.apis.test.service.TestService
+import java.util.UUID
+
+@RestController
+@RequestMapping("/api/v1/test")
+class TestController(
+ private val testService: TestService
+) {
+ // Endpoints for testing with authenticated user (userId from @AuthenticationPrincipal)
+ @PutMapping("/authenticated-user/last-activity/{days}-days-ago")
+ fun setLastActivityForAuthenticatedUser(
+ @AuthenticationPrincipal userId: UUID,
+ @PathVariable days: Int
+ ) {
+ testService.updateLastActivityByUserId(userId, days)
+ }
+}
diff --git a/apis/src/main/kotlin/org/yapp/apis/test/service/TestService.kt b/apis/src/main/kotlin/org/yapp/apis/test/service/TestService.kt
new file mode 100644
index 00000000..66054d39
--- /dev/null
+++ b/apis/src/main/kotlin/org/yapp/apis/test/service/TestService.kt
@@ -0,0 +1,17 @@
+package org.yapp.apis.test.service
+
+import org.springframework.stereotype.Service
+import org.yapp.domain.user.UserDomainService
+import java.time.LocalDateTime
+import java.util.UUID
+
+@Service
+class TestService(
+ private val userDomainService: UserDomainService,
+) {
+
+ fun updateLastActivityByUserId(userId: UUID, days: Int) {
+ val newLastActivity = LocalDateTime.now().minusDays(days.toLong())
+ userDomainService.forceUpdateLastActivity(userId, newLastActivity)
+ }
+}
diff --git a/apis/src/main/kotlin/org/yapp/apis/user/controller/UserController.kt b/apis/src/main/kotlin/org/yapp/apis/user/controller/UserController.kt
index e20fc536..b4235a0f 100644
--- a/apis/src/main/kotlin/org/yapp/apis/user/controller/UserController.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/user/controller/UserController.kt
@@ -4,6 +4,8 @@ import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.*
+import org.yapp.apis.user.dto.request.DeviceRequest
+import org.yapp.apis.user.dto.request.NotificationSettingsRequest
import org.yapp.apis.user.dto.request.TermsAgreementRequest
import org.yapp.apis.user.dto.response.UserProfileResponse
import org.yapp.apis.user.usecase.UserUseCase
@@ -30,4 +32,22 @@ class UserController(
val userProfile = userUseCase.updateTermsAgreement(userId, request)
return ResponseEntity.ok(userProfile)
}
+
+ @PutMapping("/me/notification-settings")
+ override fun updateNotificationSettings(
+ @AuthenticationPrincipal userId: UUID,
+ @Valid @RequestBody request: NotificationSettingsRequest
+ ): ResponseEntity {
+ val userProfile = userUseCase.updateNotificationSettings(userId, request)
+ return ResponseEntity.ok(userProfile)
+ }
+
+ @PutMapping("/me/devices")
+ override fun registerDevice(
+ @AuthenticationPrincipal userId: UUID,
+ @Valid @RequestBody request: DeviceRequest
+ ): ResponseEntity {
+ val userProfile = userUseCase.registerDevice(userId, request)
+ return ResponseEntity.ok(userProfile)
+ }
}
diff --git a/apis/src/main/kotlin/org/yapp/apis/user/controller/UserControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/user/controller/UserControllerApi.kt
index 870b8909..e9849d01 100644
--- a/apis/src/main/kotlin/org/yapp/apis/user/controller/UserControllerApi.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/user/controller/UserControllerApi.kt
@@ -14,6 +14,8 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
+import org.yapp.apis.user.dto.request.DeviceRequest
+import org.yapp.apis.user.dto.request.NotificationSettingsRequest
import org.yapp.apis.user.dto.request.TermsAgreementRequest
import org.yapp.apis.user.dto.response.UserProfileResponse
import org.yapp.globalutils.exception.ErrorResponse
@@ -84,4 +86,72 @@ interface UserControllerApi {
@Parameter(hidden = true) @AuthenticationPrincipal userId: UUID,
@Valid @RequestBody @Parameter(description = "์ฝ๊ด ๋์ ์์ฒญ ๊ฐ์ฒด") request: TermsAgreementRequest
): ResponseEntity
+
+ @Operation(
+ summary = "์๋ฆผ ์ค์ ์
๋ฐ์ดํธ",
+ description = "์ฌ์ฉ์์ ์๋ฆผ ์ค์ ์ ์
๋ฐ์ดํธํฉ๋๋ค."
+ )
+ @ApiResponses(
+ value = [
+ ApiResponse(
+ responseCode = "200",
+ description = "์๋ฆผ ์ค์ ์
๋ฐ์ดํธ ์ฑ๊ณต",
+ content = [Content(schema = Schema(implementation = UserProfileResponse::class))]
+ ),
+ ApiResponse(
+ responseCode = "400",
+ description = "์๋ชป๋ ์์ฒญ ํ๋ผ๋ฏธํฐ",
+ content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+ ),
+ ApiResponse(
+ responseCode = "401",
+ description = "์ธ์ฆ๋์ง ์์ ์ฌ์ฉ์",
+ content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+ ),
+ ApiResponse(
+ responseCode = "404",
+ description = "์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.",
+ content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+ )
+ ]
+ )
+ @PutMapping("/me/notification-settings")
+ fun updateNotificationSettings(
+ @Parameter(hidden = true) @AuthenticationPrincipal userId: UUID,
+ @Valid @RequestBody @Parameter(description = "์๋ฆผ ์ค์ ์์ฒญ ๊ฐ์ฒด") request: NotificationSettingsRequest
+ ): ResponseEntity
+
+ @Operation(
+ summary = "๋๋ฐ์ด์ค ๋ฑ๋ก",
+ description = "์ฌ์ฉ์์ ๋๋ฐ์ด์ค๋ฅผ ๋ฑ๋กํฉ๋๋ค. ์ด๋ฏธ ๋ฑ๋ก๋ ๋๋ฐ์ด์ค์ธ ๊ฒฝ์ฐ FCM ํ ํฐ์ ์
๋ฐ์ดํธํฉ๋๋ค."
+ )
+ @ApiResponses(
+ value = [
+ ApiResponse(
+ responseCode = "200",
+ description = "๋๋ฐ์ด์ค ๋ฑ๋ก ๋๋ ์
๋ฐ์ดํธ ์ฑ๊ณต",
+ content = [Content(schema = Schema(implementation = UserProfileResponse::class))]
+ ),
+ ApiResponse(
+ responseCode = "400",
+ description = "์๋ชป๋ ์์ฒญ ํ๋ผ๋ฏธํฐ",
+ content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+ ),
+ ApiResponse(
+ responseCode = "401",
+ description = "์ธ์ฆ๋์ง ์์ ์ฌ์ฉ์",
+ content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+ ),
+ ApiResponse(
+ responseCode = "404",
+ description = "์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.",
+ content = [Content(schema = Schema(implementation = ErrorResponse::class))]
+ )
+ ]
+ )
+ @PutMapping("/me/devices")
+ fun registerDevice(
+ @Parameter(hidden = true) @AuthenticationPrincipal userId: UUID,
+ @Valid @RequestBody @Parameter(description = "๋๋ฐ์ด์ค ์ ๋ณด ์์ฒญ ๊ฐ์ฒด") request: DeviceRequest
+ ): ResponseEntity
}
diff --git a/apis/src/main/kotlin/org/yapp/apis/user/dto/request/DeviceRequest.kt b/apis/src/main/kotlin/org/yapp/apis/user/dto/request/DeviceRequest.kt
new file mode 100644
index 00000000..90b5d36f
--- /dev/null
+++ b/apis/src/main/kotlin/org/yapp/apis/user/dto/request/DeviceRequest.kt
@@ -0,0 +1,29 @@
+package org.yapp.apis.user.dto.request
+
+import io.swagger.v3.oas.annotations.media.Schema
+import jakarta.validation.constraints.NotBlank
+
+@Schema(
+ name = "DeviceRequest",
+ description = "DTO for device update requests"
+)
+data class DeviceRequest private constructor(
+ @field:Schema(
+ description = "๋๋ฐ์ด์ค ์์ด๋",
+ example = "c8a9d7d0-4f6a-4b1a-8f0a-9d8e7f6a4b1a",
+ required = true
+ )
+ @field:NotBlank(message = "๋๋ฐ์ด์ค ์์ด๋๋ ํ์์
๋๋ค.")
+ val deviceId: String? = null,
+
+ @field:Schema(
+ description = "FCM ํ ํฐ",
+ example = "epGzIKlHScicTBrbt26pFG:APA91bG-ZPD-KMJyS-JOiflEPUIVvrp8l9DFBN2dlNG8IHw8mFlkAPok7dVPFJR4phc9061KPztkAIjBJaryZLnv6vIJXNGQsykzDcok3wFC9LrsC-F_aKY",
+ required = true
+ )
+ @field:NotBlank(message = "FCM ํ ํฐ์ ํ์์
๋๋ค.")
+ val fcmToken: String? = null
+) {
+ fun validDeviceId(): String = deviceId!!.trim()
+ fun validFcmToken(): String = fcmToken!!.trim()
+}
\ No newline at end of file
diff --git a/apis/src/main/kotlin/org/yapp/apis/user/dto/request/NotificationSettingsRequest.kt b/apis/src/main/kotlin/org/yapp/apis/user/dto/request/NotificationSettingsRequest.kt
new file mode 100644
index 00000000..2765fbab
--- /dev/null
+++ b/apis/src/main/kotlin/org/yapp/apis/user/dto/request/NotificationSettingsRequest.kt
@@ -0,0 +1,20 @@
+package org.yapp.apis.user.dto.request
+
+import io.swagger.v3.oas.annotations.media.Schema
+import jakarta.validation.constraints.NotNull
+
+@Schema(
+ name = "NotificationSettingsRequest",
+ description = "DTO for notification settings update requests"
+)
+data class NotificationSettingsRequest private constructor(
+ @field:Schema(
+ description = "์๋ฆผ ์ค์ ์ฌ๋ถ",
+ example = "true",
+ required = true
+ )
+ @field:NotNull(message = "์๋ฆผ ์ค์ ์ฌ๋ถ๋ ํ์์
๋๋ค.")
+ val notificationEnabled: Boolean? = null
+) {
+ fun validNotificationEnabled(): Boolean = notificationEnabled!!
+}
\ No newline at end of file
diff --git a/apis/src/main/kotlin/org/yapp/apis/user/dto/response/UserProfileResponse.kt b/apis/src/main/kotlin/org/yapp/apis/user/dto/response/UserProfileResponse.kt
index 3dcf4186..92ed5d6e 100644
--- a/apis/src/main/kotlin/org/yapp/apis/user/dto/response/UserProfileResponse.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/user/dto/response/UserProfileResponse.kt
@@ -39,7 +39,13 @@ data class UserProfileResponse(
description = "Whether the user has agreed to the terms of service",
example = "false"
)
- val termsAgreed: Boolean
+ val termsAgreed: Boolean,
+
+ @field:Schema(
+ description = "Whether notifications are enabled for the user",
+ example = "true"
+ )
+ val notificationEnabled: Boolean
) {
companion object {
fun from(userProfileVO: UserProfileVO): UserProfileResponse {
@@ -48,7 +54,8 @@ data class UserProfileResponse(
email = userProfileVO.email.value,
nickname = userProfileVO.nickname,
provider = userProfileVO.provider,
- termsAgreed = userProfileVO.termsAgreed
+ termsAgreed = userProfileVO.termsAgreed,
+ notificationEnabled = userProfileVO.notificationEnabled
)
}
}
diff --git a/apis/src/main/kotlin/org/yapp/apis/user/service/UserService.kt b/apis/src/main/kotlin/org/yapp/apis/user/service/UserService.kt
index 4a1acd2b..43c8eff8 100644
--- a/apis/src/main/kotlin/org/yapp/apis/user/service/UserService.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/user/service/UserService.kt
@@ -3,17 +3,21 @@ package org.yapp.apis.user.service
import jakarta.validation.Valid
import org.yapp.apis.auth.exception.AuthErrorCode
import org.yapp.apis.auth.exception.AuthException
+import org.yapp.apis.user.dto.request.DeviceRequest
import org.yapp.apis.user.dto.request.FindUserIdentityRequest
+import org.yapp.apis.user.dto.request.NotificationSettingsRequest
import org.yapp.apis.user.dto.request.TermsAgreementRequest
import org.yapp.apis.user.dto.response.UserAuthInfoResponse
import org.yapp.apis.user.dto.response.UserProfileResponse
+import org.yapp.domain.device.DeviceDomainService
import org.yapp.domain.user.UserDomainService
import org.yapp.globalutils.annotation.ApplicationService
import java.util.*
@ApplicationService
class UserService(
- private val userDomainService: UserDomainService
+ private val userDomainService: UserDomainService,
+ private val deviceDomainService: DeviceDomainService
) {
fun findUserProfileByUserId(userId: UUID): UserProfileResponse {
val userProfile = userDomainService.findUserProfileById(userId)
@@ -36,4 +40,17 @@ class UserService(
val userIdentity = userDomainService.findUserIdentityById(findUserIdentityRequest.validUserId())
return UserAuthInfoResponse.from(userIdentity)
}
+
+ fun updateNotificationSettings(userId: UUID, @Valid request: NotificationSettingsRequest): UserProfileResponse {
+ validateUserExists(userId)
+ val updatedUserProfile = userDomainService.updateNotificationSettings(userId, request.validNotificationEnabled())
+ return UserProfileResponse.from(updatedUserProfile)
+ }
+
+ fun registerDevice(userId: UUID, @Valid request: DeviceRequest): UserProfileResponse {
+ validateUserExists(userId)
+ deviceDomainService.findOrCreateDevice(userId, request.validDeviceId(), request.validFcmToken())
+ val userProfile = userDomainService.findUserProfileById(userId)
+ return UserProfileResponse.from(userProfile)
+ }
}
diff --git a/apis/src/main/kotlin/org/yapp/apis/user/usecase/UserUseCase.kt b/apis/src/main/kotlin/org/yapp/apis/user/usecase/UserUseCase.kt
index 412823eb..b4e65cdf 100644
--- a/apis/src/main/kotlin/org/yapp/apis/user/usecase/UserUseCase.kt
+++ b/apis/src/main/kotlin/org/yapp/apis/user/usecase/UserUseCase.kt
@@ -1,6 +1,8 @@
package org.yapp.apis.user.usecase
import org.springframework.transaction.annotation.Transactional
+import org.yapp.apis.user.dto.request.DeviceRequest
+import org.yapp.apis.user.dto.request.NotificationSettingsRequest
import org.yapp.apis.user.dto.request.TermsAgreementRequest
import org.yapp.apis.user.dto.response.UserProfileResponse
import org.yapp.apis.user.service.UserService
@@ -20,4 +22,14 @@ class UserUseCase(
fun updateTermsAgreement(userId: UUID, request: TermsAgreementRequest): UserProfileResponse {
return userService.updateTermsAgreement(userId, request)
}
+
+ @Transactional
+ fun updateNotificationSettings(userId: UUID, request: NotificationSettingsRequest): UserProfileResponse {
+ return userService.updateNotificationSettings(userId, request)
+ }
+
+ @Transactional
+ fun registerDevice(userId: UUID, request: DeviceRequest): UserProfileResponse {
+ return userService.registerDevice(userId, request)
+ }
}
diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml
index 235eeb20..dcd73de6 100644
--- a/apis/src/main/resources/application.yml
+++ b/apis/src/main/resources/application.yml
@@ -11,23 +11,23 @@ spring:
- persistence
- crosscutting
- jwt
- - web
- redis
- external
+ - observability
prod:
- persistence
- crosscutting
- jwt
- - web
- redis
- external
+ - observability
test:
- persistence
- crosscutting
- jwt
- - web
- redis
- external
+ - observability
servlet:
multipart:
max-file-size: 10MB
diff --git a/batch/build.gradle.kts b/batch/build.gradle.kts
index 51836ce9..67c2432b 100644
--- a/batch/build.gradle.kts
+++ b/batch/build.gradle.kts
@@ -4,14 +4,18 @@ dependencies {
implementation(project(Dependencies.Projects.DOMAIN))
implementation(project(Dependencies.Projects.GLOBAL_UTILS))
implementation(project(Dependencies.Projects.INFRA))
+ implementation(project(Dependencies.Projects.OBSERVABILITY))
implementation(Dependencies.Spring.BOOT_STARTER_WEB)
implementation(Dependencies.Spring.BOOT_STARTER_SECURITY)
implementation(Dependencies.Spring.BOOT_STARTER_VALIDATION)
+ implementation(Dependencies.Spring.BOOT_STARTER_DATA_JPA)
testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)
implementation(Dependencies.Database.MYSQL_CONNECTOR)
+ implementation(Dependencies.Firebase.FIREBASE_ADMIN)
+
testImplementation(Dependencies.TestContainers.MYSQL)
testImplementation(Dependencies.TestContainers.JUNIT_JUPITER)
}
diff --git a/batch/src/main/kotlin/org/yapp/batch/BatchApplication.kt b/batch/src/main/kotlin/org/yapp/batch/BatchApplication.kt
index 554d731a..31c38d6a 100644
--- a/batch/src/main/kotlin/org/yapp/batch/BatchApplication.kt
+++ b/batch/src/main/kotlin/org/yapp/batch/BatchApplication.kt
@@ -1,9 +1,14 @@
package org.yapp.batch
import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
import org.springframework.boot.runApplication
+import org.springframework.context.annotation.ComponentScan
-@SpringBootApplication
+@SpringBootApplication(
+ exclude = [JpaRepositoriesAutoConfiguration::class] // infra ๋ชจ๋์์ @EnableJpaRepositories๋ก ๋ช
์์ ์ผ๋ก ์ค์ ํ์ฌ ์๋ ๊ด๋ฆฌ
+)
+@ComponentScan(basePackages = ["org.yapp"])
class BatchApplication
fun main(args: Array) {
diff --git a/batch/src/main/kotlin/org/yapp/batch/config/FcmConfig.kt b/batch/src/main/kotlin/org/yapp/batch/config/FcmConfig.kt
new file mode 100644
index 00000000..4e61d59a
--- /dev/null
+++ b/batch/src/main/kotlin/org/yapp/batch/config/FcmConfig.kt
@@ -0,0 +1,88 @@
+package org.yapp.batch.config
+
+import com.google.auth.oauth2.GoogleCredentials
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.context.annotation.Profile
+import java.io.ByteArrayInputStream
+import java.io.IOException
+
+@Configuration
+@Profile("!test") // ์์ ์กฐ์น
+class FcmConfig {
+
+ @Value("\${FIREBASE_TYPE:service_account}")
+ private lateinit var type: String
+
+ @Value("\${FIREBASE_PROJECT_ID:reed-1f3ce}")
+ private lateinit var projectId: String
+
+ @Value("\${FIREBASE_PRIVATE_KEY_ID:1d0ad75134b39680e0e0b4b477475cf4266f076d}")
+ private lateinit var privateKeyId: String
+
+ @Value("\${FIREBASE_PRIVATE_KEY}")
+ private lateinit var privateKey: String
+
+ @Value("\${FIREBASE_CLIENT_EMAIL:firebase-adminsdk-fbsvc@reed-1f3ce.iam.gserviceaccount.com}")
+ private lateinit var clientEmail: String
+
+ @Value("\${FIREBASE_CLIENT_ID:113454566071768455640}")
+ private lateinit var clientId: String
+
+ @Value("\${FIREBASE_AUTH_URI:https://accounts.google.com/o/oauth2/auth}")
+ private lateinit var authUri: String
+
+ @Value("\${FIREBASE_TOKEN_URI:https://oauth2.googleapis.com/token}")
+ private lateinit var tokenUri: String
+
+ @Value("\${FIREBASE_AUTH_PROVIDER_X509_CERT_URL:https://www.googleapis.com/oauth2/v1/certs}")
+ private lateinit var authProviderX509CertUrl: String
+
+ @Value("\${FIREBASE_CLIENT_X509_CERT_URL:https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40reed-1f3ce.iam.gserviceaccount.com}")
+ private lateinit var clientX509CertUrl: String
+
+ @Value("\${FIREBASE_UNIVERSE_DOMAIN:googleapis.com}")
+ private lateinit var universeDomain: String
+
+ @Bean
+ fun firebaseApp(): FirebaseApp {
+ try {
+ // Create a JSON string with the Firebase credentials
+ val firebaseCredentialsJson = """
+ {
+ "type": "$type",
+ "project_id": "$projectId",
+ "private_key_id": "$privateKeyId",
+ "private_key": "$privateKey",
+ "client_email": "$clientEmail",
+ "client_id": "$clientId",
+ "auth_uri": "$authUri",
+ "token_uri": "$tokenUri",
+ "auth_provider_x509_cert_url": "$authProviderX509CertUrl",
+ "client_x509_cert_url": "$clientX509CertUrl",
+ "universe_domain": "$universeDomain"
+ }
+ """.trimIndent()
+
+ // Create GoogleCredentials from the JSON string
+ val googleCredentials = GoogleCredentials.fromStream(
+ ByteArrayInputStream(firebaseCredentialsJson.toByteArray())
+ )
+
+ val options = FirebaseOptions.builder()
+ .setCredentials(googleCredentials)
+ .build()
+
+ return if (FirebaseApp.getApps().isEmpty()) {
+ FirebaseApp.initializeApp(options)
+ } else {
+ FirebaseApp.getInstance()
+ }
+ } catch (e: IOException) {
+ throw RuntimeException("Failed to initialize Firebase", e)
+ }
+ }
+}
diff --git a/batch/src/main/kotlin/org/yapp/batch/config/InfraConfig.kt b/batch/src/main/kotlin/org/yapp/batch/config/InfraConfig.kt
index 9ab37203..d521da40 100644
--- a/batch/src/main/kotlin/org/yapp/batch/config/InfraConfig.kt
+++ b/batch/src/main/kotlin/org/yapp/batch/config/InfraConfig.kt
@@ -1,11 +1,15 @@
package org.yapp.batch.config
import org.springframework.context.annotation.Configuration
+import org.yapp.infra.EnableInfraBaseConfig
+import org.yapp.infra.InfraBaseConfigGroup
@Configuration(proxyBeanMethods = false)
-//@EnableInfraBaseConfig([InfraBaseConfigGroup.FCM])
-class InfraConfig {
- /*
- Batch ๋ชจ๋์๋ ์ถํ ํด๋น ๋ฐฉ์์ผ๋ก FCM Config ์ถ๊ฐ
- */
-}
+@EnableInfraBaseConfig(
+ [
+ InfraBaseConfigGroup.JPA,
+ InfraBaseConfigGroup.AOP,
+ InfraBaseConfigGroup.SENTRY
+ ]
+)
+class InfraConfig
diff --git a/batch/src/main/kotlin/org/yapp/batch/dto/FcmSendResult.kt b/batch/src/main/kotlin/org/yapp/batch/dto/FcmSendResult.kt
new file mode 100644
index 00000000..9cfb733d
--- /dev/null
+++ b/batch/src/main/kotlin/org/yapp/batch/dto/FcmSendResult.kt
@@ -0,0 +1,35 @@
+package org.yapp.batch.dto
+
+data class FcmSendResult private constructor(
+ val successCount: Int,
+ val failureCount: Int,
+ val invalidTokens: List
+) {
+ companion object {
+ private const val ZERO_COUNT = 0
+
+ fun of(
+ successCount: Int,
+ failureCount: Int,
+ invalidTokens: List
+ ): FcmSendResult {
+ return FcmSendResult(successCount, failureCount, invalidTokens)
+ }
+
+ fun empty(): FcmSendResult {
+ return FcmSendResult(
+ successCount = ZERO_COUNT,
+ failureCount = ZERO_COUNT,
+ invalidTokens = emptyList()
+ )
+ }
+
+ fun allFailed(failureCount: Int): FcmSendResult {
+ return FcmSendResult(
+ successCount = ZERO_COUNT,
+ failureCount = failureCount,
+ invalidTokens = emptyList()
+ )
+ }
+ }
+}
diff --git a/batch/src/main/kotlin/org/yapp/batch/job/fcm/FcmNotificationJobConfig.kt b/batch/src/main/kotlin/org/yapp/batch/job/fcm/FcmNotificationJobConfig.kt
new file mode 100644
index 00000000..cdea77a5
--- /dev/null
+++ b/batch/src/main/kotlin/org/yapp/batch/job/fcm/FcmNotificationJobConfig.kt
@@ -0,0 +1,36 @@
+package org.yapp.batch.job.fcm
+
+import org.slf4j.LoggerFactory
+import org.springframework.context.annotation.Configuration
+import org.springframework.scheduling.annotation.EnableScheduling
+import org.springframework.scheduling.annotation.Scheduled
+import org.yapp.batch.service.NotificationService
+
+@Configuration
+@EnableScheduling
+class FcmNotificationJobConfig(
+ private val notificationService: NotificationService
+) {
+ private val logger = LoggerFactory.getLogger(FcmNotificationJobConfig::class.java)
+
+ companion object {
+ private const val UNRECORDED_DAYS_THRESHOLD = 7
+ private const val DORMANT_DAYS_THRESHOLD = 30
+ }
+
+ @Scheduled(fixedRate = 60000)
+ fun checkAndSendNotifications() {
+ logger.info("========== Starting FCM notification job ==========")
+
+ val (unrecordedUserCount, unrecordedDeviceCount) = notificationService.sendUnrecordedNotifications(UNRECORDED_DAYS_THRESHOLD)
+ val (dormantUserCount, dormantDeviceCount) = notificationService.sendDormantNotifications(DORMANT_DAYS_THRESHOLD)
+ notificationService.resetNotificationsForActiveUsers()
+
+ logger.info(
+ "========== Completed FCM notification job ========== \n" +
+ "Summary:\n" +
+ " - Unrecorded: $unrecordedUserCount users, $unrecordedDeviceCount devices\n" +
+ " - Dormant: $dormantUserCount users, $dormantDeviceCount devices"
+ )
+ }
+}
diff --git a/batch/src/main/kotlin/org/yapp/batch/service/FcmService.kt b/batch/src/main/kotlin/org/yapp/batch/service/FcmService.kt
new file mode 100644
index 00000000..a4f65a39
--- /dev/null
+++ b/batch/src/main/kotlin/org/yapp/batch/service/FcmService.kt
@@ -0,0 +1,87 @@
+package org.yapp.batch.service
+
+import com.google.firebase.messaging.*
+import org.slf4j.LoggerFactory
+import org.springframework.stereotype.Service
+import org.yapp.batch.dto.FcmSendResult
+
+@Service
+class FcmService {
+ private val logger = LoggerFactory.getLogger(FcmService::class.java)
+
+ fun sendMulticastNotification(tokens: List, title: String, body: String): FcmSendResult {
+ if (tokens.isEmpty()) {
+ logger.warn("FCM token list is empty. Skipping notification.")
+ return FcmSendResult.empty()
+ }
+
+ val notification = buildNotification(title, body)
+ val messages = tokens.map { token ->
+ Message.builder()
+ .setNotification(notification)
+ .setToken(token)
+ .build()
+ }
+
+ try {
+ val response = FirebaseMessaging.getInstance().sendEach(messages)
+ return processFcmResponse(response, tokens)
+ } catch (e: FirebaseMessagingException) {
+ logger.error("Failed to send FCM notification to ${tokens.size} tokens", e)
+ return FcmSendResult.allFailed(tokens.size)
+ }
+ }
+
+ private fun buildNotification(title: String, body: String): Notification {
+ return Notification.builder()
+ .setTitle(title)
+ .setBody(body)
+ .build()
+ }
+
+ private fun processFcmResponse(response: BatchResponse, tokens: List): FcmSendResult {
+ val invalidTokens = mutableListOf()
+ val noFailures = 0
+
+ if (response.failureCount > noFailures) {
+ response.responses.forEachIndexed { index, sendResponse ->
+ if (sendResponse.isSuccessful) {
+ return@forEachIndexed
+ }
+
+ val failedToken = tokens[index]
+ val errorCode = sendResponse.exception?.messagingErrorCode
+
+ if (errorCode == MessagingErrorCode.UNREGISTERED) {
+ invalidTokens.add(failedToken)
+ logger.warn("Unregistered FCM token: {}. Error: {}", failedToken, errorCode)
+ return@forEachIndexed
+ }
+
+ if (errorCode == MessagingErrorCode.INVALID_ARGUMENT) {
+ val errorMessage = sendResponse.exception?.message ?: ""
+ if (errorMessage.contains("invalid registration token", ignoreCase = true)) {
+ invalidTokens.add(failedToken)
+ logger.warn("Invalid FCM token format: {}. Error: {}", failedToken, errorMessage)
+ return@forEachIndexed
+ }
+ }
+
+ logger.error("Failed to send to token: {}. Error: {}", failedToken, errorCode, sendResponse.exception)
+ }
+ }
+
+ logger.info(
+ "FCM multicast message sent. Success: {}, Failure: {}, Invalid Tokens: {}",
+ response.successCount,
+ response.failureCount,
+ invalidTokens.size
+ )
+
+ return FcmSendResult.of(
+ successCount = response.successCount,
+ failureCount = response.failureCount,
+ invalidTokens = invalidTokens
+ )
+ }
+}
diff --git a/batch/src/main/kotlin/org/yapp/batch/service/NotificationService.kt b/batch/src/main/kotlin/org/yapp/batch/service/NotificationService.kt
new file mode 100644
index 00000000..8d380d43
--- /dev/null
+++ b/batch/src/main/kotlin/org/yapp/batch/service/NotificationService.kt
@@ -0,0 +1,165 @@
+package org.yapp.batch.service
+
+import org.slf4j.LoggerFactory
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import org.yapp.domain.device.DeviceDomainService
+import org.yapp.domain.device.vo.DeviceVO
+import org.yapp.domain.notification.NotificationDomainService
+import org.yapp.domain.notification.NotificationType
+import org.yapp.domain.user.User
+import org.yapp.domain.user.UserDomainService
+import org.yapp.domain.user.vo.NotificationTargetUserVO
+
+@Service
+class NotificationService(
+ private val userDomainService: UserDomainService,
+ private val notificationDomainService: NotificationDomainService,
+ private val deviceDomainService: DeviceDomainService,
+ private val fcmService: FcmService
+) {
+ private val logger = LoggerFactory.getLogger(NotificationService::class.java)
+
+ companion object {
+ private const val UNRECORDED_NOTIFICATION_TITLE = "๐ ์ ์ ๋ฉ์ถ ๊ธฐ๋ก.. ๋ค์ ์ด์ด๊ฐ ๋ณผ๊น์?"
+ private const val UNRECORDED_NOTIFICATION_MESSAGE = "์ด๋ฒ์ฃผ์ ์ฝ์ ์ฑ
, ์๊ธฐ ์ ์ ๊ธฐ๋กํด ๋ณด์ธ์!"
+ private const val DORMANT_NOTIFICATION_TITLE = "๐ Reed์ ํจ๊ป ๋
์ ๊ธฐ๋ก ์์"
+ private const val DORMANT_NOTIFICATION_MESSAGE = "๊ทธ๋์ ์ฝ์ ์ฑ
์ ๋ชจ์ ๊ธฐ๋กํด ๋ณด์ธ์!"
+ private const val NO_SUCCESSFUL_DEVICES = 0
+ private const val NO_DEVICES_SENT = 0
+ }
+
+ @Transactional
+ fun sendUnrecordedNotifications(daysThreshold: Int): Pair {
+ return sendNotificationsByType(
+ daysThreshold = daysThreshold,
+ notificationType = NotificationType.UNRECORDED,
+ title = UNRECORDED_NOTIFICATION_TITLE,
+ message = UNRECORDED_NOTIFICATION_MESSAGE,
+ findUsers = { userDomainService.findUnrecordedUsers(it) }
+ )
+ }
+
+ @Transactional
+ fun sendDormantNotifications(daysThreshold: Int): Pair {
+ return sendNotificationsByType(
+ daysThreshold = daysThreshold,
+ notificationType = NotificationType.DORMANT,
+ title = DORMANT_NOTIFICATION_TITLE,
+ message = DORMANT_NOTIFICATION_MESSAGE,
+ findUsers = { userDomainService.findDormantUsers(it) }
+ )
+ }
+
+ private fun sendNotificationsByType(
+ daysThreshold: Int,
+ notificationType: NotificationType,
+ title: String,
+ message: String,
+ findUsers: (Int) -> List
+ ): Pair {
+ logger.info("Starting $notificationType notifications (threshold: $daysThreshold days)")
+
+ val users = findUsers(daysThreshold)
+ logger.info("Found ${users.size} $notificationType users")
+
+ var successUserCount = 0
+ var successDeviceCount = 0
+
+ users.forEach { user ->
+ val (success, deviceCount) = sendNotificationsToUser(
+ user = user,
+ title = title,
+ message = message,
+ notificationType = notificationType
+ )
+
+ if (success) {
+ successUserCount++
+ successDeviceCount += deviceCount
+ }
+ }
+
+ logger.info("Completed $notificationType notifications: $successUserCount users, $successDeviceCount devices")
+ return Pair(successUserCount, successDeviceCount)
+ }
+
+ private fun sendNotificationsToUser(
+ user: NotificationTargetUserVO,
+ title: String,
+ message: String,
+ notificationType: NotificationType
+ ): Pair {
+ val userId = User.Id.newInstance(user.id)
+ if (notificationDomainService.hasActiveNotification(userId, notificationType)) {
+ logger.info("User ${user.id} already has active $notificationType notification, skipping")
+ return Pair(false, NO_DEVICES_SENT)
+ }
+
+ val devices = deviceDomainService.findDevicesByUserId(user.id)
+ if (devices.isEmpty()) {
+ logger.info("No devices found for user ${user.id}")
+ return Pair(false, NO_DEVICES_SENT)
+ }
+
+ val successDeviceCount = sendToDevices(devices, title, message)
+ if (successDeviceCount > NO_SUCCESSFUL_DEVICES) {
+ notificationDomainService.createAndSaveNotification(
+ userId = userId,
+ title = title,
+ message = message,
+ notificationType = notificationType
+ )
+ return Pair(true, successDeviceCount)
+ }
+
+ logger.info("Failed to send notification to any device for user ${user.id}")
+ return Pair(false, NO_DEVICES_SENT)
+ }
+
+ private fun sendToDevices(
+ devices: List,
+ title: String,
+ message: String
+ ): Int {
+ val validTokens = devices
+ .map { it.fcmToken }
+ .filter { it.isNotBlank() }
+
+ if (validTokens.isEmpty()) {
+ logger.warn("No valid FCM tokens found for devices: {}", devices.map { it.id })
+ return NO_DEVICES_SENT
+ }
+
+ val result = fcmService.sendMulticastNotification(validTokens, title, message)
+
+ if (result.invalidTokens.isNotEmpty()) {
+ logger.info("Found ${result.invalidTokens.size} invalid tokens to remove.")
+ deviceDomainService.removeDevicesByTokens(result.invalidTokens)
+ }
+
+ return result.successCount
+ }
+
+ @Transactional
+ fun resetNotificationsForActiveUsers() {
+ val sentNotifications = notificationDomainService.findSentNotifications()
+
+ sentNotifications.forEach { notification ->
+ val sentAt = notification.sentAt
+ if (sentAt != null) {
+ try {
+ val user = userDomainService.findNotificationTargetUserById(notification.userId.value)
+ val lastActivity = user.lastActivity
+
+ if (lastActivity != null && lastActivity.isAfter(sentAt)) {
+ val resetNotification = notification.reset()
+ notificationDomainService.save(resetNotification)
+ }
+ } catch (e: Exception) {
+ logger.warn("Failed to reset notification for user ${notification.userId.value}", e)
+ }
+ }
+ }
+ }
+}
diff --git a/batch/src/main/resources/application.yml b/batch/src/main/resources/application.yml
index 35307531..1e8a85e4 100644
--- a/batch/src/main/resources/application.yml
+++ b/batch/src/main/resources/application.yml
@@ -1,5 +1,5 @@
server:
- port: 8080
+ port: 8082
shutdown: graceful
spring:
@@ -9,15 +9,22 @@ spring:
group:
dev:
- persistence
- - jwt
+ - crosscutting
- redis
+ - external
+ - observability
prod:
- persistence
- - jwt
+ - crosscutting
- redis
+ - external
+ - observability
test:
- persistence
- - jwt
+ - crosscutting
+ - redis
+ - external
+ - observability
servlet:
multipart:
max-file-size: 10MB
@@ -43,6 +50,5 @@ spring:
---
spring:
config:
- import: optional:file:../secret/application-test-secret.properties
activate:
on-profile: test
diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt
index d5e946b6..e214e3bc 100644
--- a/buildSrc/src/main/kotlin/Dependencies.kt
+++ b/buildSrc/src/main/kotlin/Dependencies.kt
@@ -30,6 +30,7 @@ object Dependencies {
const val DOMAIN = ":domain"
const val GLOBAL_UTILS = ":global-utils"
const val GATEWAY = ":gateway"
+ const val OBSERVABILITY = ":observability"
}
object Logging {
@@ -75,4 +76,8 @@ object Dependencies {
const val SPRING_BOOT_STARTER = "io.sentry:sentry-spring-boot-starter-jakarta:8.22.0"
const val LOG4J2 = "io.sentry:sentry-log4j2:8.22.0"
}
+
+ object Firebase {
+ const val FIREBASE_ADMIN = "com.google.firebase:firebase-admin:9.2.0"
+ }
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/device/Device.kt b/domain/src/main/kotlin/org/yapp/domain/device/Device.kt
new file mode 100644
index 00000000..c5f935f6
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/device/Device.kt
@@ -0,0 +1,55 @@
+package org.yapp.domain.device
+
+import org.yapp.domain.user.User
+import org.yapp.globalutils.util.UuidGenerator
+import java.time.LocalDateTime
+import java.util.UUID
+
+data class Device private constructor(
+ val id: Id,
+ val userId: User.Id,
+ val deviceId: String,
+ val fcmToken: String,
+ val createdAt: LocalDateTime? = null,
+ val updatedAt: LocalDateTime? = null
+) {
+ companion object {
+ fun create(
+ userId: UUID,
+ deviceId: String,
+ fcmToken: String
+ ): Device {
+ return Device(
+ id = Id.newInstance(UuidGenerator.create()),
+ userId = User.Id.newInstance(userId),
+ deviceId = deviceId,
+ fcmToken = fcmToken
+ )
+ }
+
+ fun reconstruct(
+ id: Id,
+ userId: User.Id,
+ deviceId: String,
+ fcmToken: String,
+ createdAt: LocalDateTime?,
+ updatedAt: LocalDateTime?
+ ): Device {
+ return Device(
+ id = id,
+ userId = userId,
+ deviceId = deviceId,
+ fcmToken = fcmToken,
+ createdAt = createdAt,
+ updatedAt = updatedAt
+ )
+ }
+ }
+
+ @JvmInline
+ value class Id(val value: UUID) {
+ companion object {
+ fun newInstance(value: UUID) = Id(value)
+ }
+ }
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/device/DeviceDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/device/DeviceDomainService.kt
new file mode 100644
index 00000000..8cbed241
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/device/DeviceDomainService.kt
@@ -0,0 +1,34 @@
+package org.yapp.domain.device
+
+import org.yapp.domain.device.vo.DeviceVO
+import org.yapp.globalutils.annotation.DomainService
+import java.util.UUID
+
+@DomainService
+class DeviceDomainService(
+ private val deviceRepository: DeviceRepository
+) {
+ fun findOrCreateDevice(userId: UUID, deviceId: String, fcmToken: String) {
+ val device = deviceRepository.findByDeviceId(deviceId)
+ if (device == null) {
+ val newDevice = Device.create(userId, deviceId, fcmToken)
+ deviceRepository.save(newDevice)
+ }
+ }
+
+ fun findDevicesByUserId(userId: UUID): List {
+ return deviceRepository.findByUserId(userId)
+ .map { DeviceVO.from(it) }
+ }
+
+ fun findDeviceByFcmToken(fcmToken: String): DeviceVO? {
+ return deviceRepository.findByFcmToken(fcmToken)
+ ?.let { DeviceVO.from(it) }
+ }
+
+ fun removeDevicesByTokens(tokens: List) {
+ if (tokens.isNotEmpty()) {
+ deviceRepository.deleteByTokens(tokens)
+ }
+ }
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/device/DeviceRepository.kt b/domain/src/main/kotlin/org/yapp/domain/device/DeviceRepository.kt
new file mode 100644
index 00000000..c1a64355
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/device/DeviceRepository.kt
@@ -0,0 +1,11 @@
+package org.yapp.domain.device
+
+import java.util.UUID
+
+interface DeviceRepository {
+ fun findByDeviceId(deviceId: String): Device?
+ fun findByFcmToken(fcmToken: String): Device?
+ fun save(device: Device): Device
+ fun findByUserId(userId: UUID): List
+ fun deleteByTokens(tokens: List)
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/device/vo/DeviceVO.kt b/domain/src/main/kotlin/org/yapp/domain/device/vo/DeviceVO.kt
new file mode 100644
index 00000000..b4b16629
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/device/vo/DeviceVO.kt
@@ -0,0 +1,20 @@
+package org.yapp.domain.device.vo
+
+import org.yapp.domain.device.Device
+import java.util.UUID
+
+data class DeviceVO private constructor(
+ val id: UUID,
+ val userId: UUID,
+ val fcmToken: String
+) {
+ companion object {
+ fun from(device: Device): DeviceVO {
+ return DeviceVO(
+ id = device.id.value,
+ userId = device.userId.value,
+ fcmToken = device.fcmToken
+ )
+ }
+ }
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/notification/Notification.kt b/domain/src/main/kotlin/org/yapp/domain/notification/Notification.kt
new file mode 100644
index 00000000..9326e485
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/notification/Notification.kt
@@ -0,0 +1,80 @@
+package org.yapp.domain.notification
+
+import org.yapp.domain.user.User
+import org.yapp.globalutils.util.UuidGenerator
+import java.time.LocalDateTime
+import java.util.UUID
+
+data class Notification private constructor(
+ val id: Id,
+ val userId: User.Id,
+ val title: String,
+ val message: String,
+ val notificationType: NotificationType,
+ val isRead: Boolean = false,
+ val isSent: Boolean = false,
+ val sentAt: LocalDateTime? = null,
+ val createdAt: LocalDateTime? = null,
+ val updatedAt: LocalDateTime? = null
+) {
+ fun reset(): Notification {
+ return this.copy(
+ isSent = false,
+ sentAt = null
+ )
+ }
+
+ companion object {
+ fun create(
+ userId: UUID,
+ title: String,
+ message: String,
+ notificationType: NotificationType,
+ isSent: Boolean = false,
+ sentAt: LocalDateTime? = null
+ ): Notification {
+ return Notification(
+ id = Id.newInstance(UuidGenerator.create()),
+ userId = User.Id.newInstance(userId),
+ title = title,
+ message = message,
+ notificationType = notificationType,
+ isSent = isSent,
+ sentAt = sentAt
+ )
+ }
+
+ fun reconstruct(
+ id: Id,
+ userId: User.Id,
+ title: String,
+ message: String,
+ notificationType: NotificationType,
+ isRead: Boolean,
+ isSent: Boolean = false,
+ sentAt: LocalDateTime? = null,
+ createdAt: LocalDateTime?,
+ updatedAt: LocalDateTime?
+ ): Notification {
+ return Notification(
+ id = id,
+ userId = userId,
+ title = title,
+ message = message,
+ notificationType = notificationType,
+ isRead = isRead,
+ isSent = isSent,
+ sentAt = sentAt,
+ createdAt = createdAt,
+ updatedAt = updatedAt
+ )
+ }
+ }
+
+ @JvmInline
+ value class Id(val value: UUID) {
+ companion object {
+ fun newInstance(value: UUID) = Id(value)
+ }
+ }
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/notification/NotificationDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/notification/NotificationDomainService.kt
new file mode 100644
index 00000000..809fd683
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/notification/NotificationDomainService.kt
@@ -0,0 +1,42 @@
+package org.yapp.domain.notification
+
+import org.yapp.domain.user.User
+import org.yapp.globalutils.annotation.DomainService
+import java.time.LocalDateTime
+
+@DomainService
+class NotificationDomainService(
+ private val notificationRepository: NotificationRepository
+) {
+ fun hasActiveNotification(userId: User.Id, notificationType: NotificationType): Boolean {
+ val userNotifications = notificationRepository.findByUserId(userId.value)
+ return userNotifications.any {
+ it.notificationType == notificationType && it.isSent
+ }
+ }
+
+ fun createAndSaveNotification(
+ userId: User.Id,
+ title: String,
+ message: String,
+ notificationType: NotificationType
+ ) {
+ val notification = Notification.create(
+ userId = userId.value,
+ title = title,
+ message = message,
+ notificationType = notificationType,
+ isSent = true,
+ sentAt = LocalDateTime.now()
+ )
+ notificationRepository.save(notification)
+ }
+
+ fun findSentNotifications(): List {
+ return notificationRepository.findBySent(true)
+ }
+
+ fun save(notification: Notification) {
+ notificationRepository.save(notification)
+ }
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/notification/NotificationRepository.kt b/domain/src/main/kotlin/org/yapp/domain/notification/NotificationRepository.kt
new file mode 100644
index 00000000..f90fabc9
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/notification/NotificationRepository.kt
@@ -0,0 +1,12 @@
+package org.yapp.domain.notification
+
+import org.yapp.domain.user.User
+import java.util.UUID
+
+interface NotificationRepository {
+ fun save(notification: Notification): Notification
+ fun findByUser(user: User): Notification?
+ fun findByUserId(userId: UUID): List
+ fun findAll(): List
+ fun findBySent(isSent: Boolean): List
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/notification/NotificationType.kt b/domain/src/main/kotlin/org/yapp/domain/notification/NotificationType.kt
new file mode 100644
index 00000000..6c246d77
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/notification/NotificationType.kt
@@ -0,0 +1,6 @@
+package org.yapp.domain.notification
+
+enum class NotificationType {
+ UNRECORDED,
+ DORMANT
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt
index 39bcfcd1..f7cf1cab 100644
--- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt
@@ -1,15 +1,16 @@
package org.yapp.domain.readingrecord
+import org.yapp.domain.userbook.UserBook
import org.yapp.globalutils.util.UuidGenerator
import java.time.LocalDateTime
import java.util.*
data class ReadingRecord private constructor(
val id: Id,
- val userBookId: UserBookId,
+ val userBookId: UserBook.Id,
val pageNumber: PageNumber,
val quote: Quote,
- val review: Review,
+ val review: Review?,
val emotionTags: List = emptyList(),
val createdAt: LocalDateTime? = null,
val updatedAt: LocalDateTime? = null,
@@ -20,12 +21,12 @@ data class ReadingRecord private constructor(
userBookId: UUID,
pageNumber: Int,
quote: String,
- review: String,
+ review: String?,
emotionTags: List = emptyList()
): ReadingRecord {
return ReadingRecord(
id = Id.newInstance(UuidGenerator.create()),
- userBookId = UserBookId.newInstance(userBookId),
+ userBookId = UserBook.Id.newInstance(userBookId),
pageNumber = PageNumber.newInstance(pageNumber),
quote = Quote.newInstance(quote),
review = Review.newInstance(review),
@@ -35,10 +36,10 @@ data class ReadingRecord private constructor(
fun reconstruct(
id: Id,
- userBookId: UserBookId,
+ userBookId: UserBook.Id,
pageNumber: PageNumber,
quote: Quote,
- review: Review,
+ review: Review?,
emotionTags: List = emptyList(),
createdAt: LocalDateTime? = null,
updatedAt: LocalDateTime? = null,
@@ -67,7 +68,7 @@ data class ReadingRecord private constructor(
return this.copy(
pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber,
quote = quote?.let { Quote.newInstance(it) } ?: this.quote,
- review = review?.let { Review.newInstance(it) } ?: this.review,
+ review = if (review != null) Review.newInstance(review) else this.review,
emotionTags = emotionTags?.map { EmotionTag.newInstance(it) } ?: this.emotionTags,
updatedAt = LocalDateTime.now()
)
@@ -80,13 +81,6 @@ data class ReadingRecord private constructor(
}
}
- @JvmInline
- value class UserBookId(val value: UUID) {
- companion object {
- fun newInstance(value: UUID) = UserBookId(value)
- }
- }
-
@JvmInline
value class PageNumber(val value: Int) {
companion object {
@@ -111,8 +105,10 @@ data class ReadingRecord private constructor(
@JvmInline
value class Review(val value: String) {
companion object {
- fun newInstance(value: String): Review {
- require(value.isNotBlank()) { "Review cannot be blank" }
+ fun newInstance(value: String?): Review? {
+ if (value.isNullOrBlank()) {
+ return null
+ }
require(value.length <= 1000) { "Review cannot exceed 1000 characters" }
return Review(value)
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt
index ad510b61..ae1e2a80 100644
--- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt
@@ -17,7 +17,7 @@ import org.yapp.domain.userbook.exception.UserBookNotFoundException
import org.yapp.domain.userbook.exception.UserBookErrorCode
@DomainService
-class ReadingRecordDomainService(
+class ReadingRecordDomainService( // TODO: readingRecordRepository๋ง ๋จ๊ธฐ๊ณ ์ ๊ฑฐ
private val readingRecordRepository: ReadingRecordRepository,
private val tagRepository: TagRepository,
private val readingRecordTagRepository: ReadingRecordTagRepository,
@@ -28,7 +28,7 @@ class ReadingRecordDomainService(
userBookId: UUID,
pageNumber: Int,
quote: String,
- review: String,
+ review: String?,
emotionTags: List
): ReadingRecordInfoVO {
val userBook = userBookRepository.findById(userBookId)
diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt
index ace1edbb..543d713c 100644
--- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt
@@ -2,6 +2,7 @@ package org.yapp.domain.readingrecord
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
+import java.time.LocalDateTime
import java.util.UUID
@@ -33,4 +34,13 @@ interface ReadingRecordRepository {
): Page
fun deleteById(id: UUID)
+
+ /**
+ * Find reading records created after the specified time for books owned by the user
+ *
+ * @param userBookIds List of user book IDs to search in
+ * @param after Find records created after this time
+ * @return List of reading records matching the criteria
+ */
+ fun findByUserBookIdInAndCreatedAtAfter(userBookIds: List, after: LocalDateTime): List
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordSortType.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordSortType.kt
index e2a700af..1d2fa8f5 100644
--- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordSortType.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordSortType.kt
@@ -3,6 +3,8 @@ package org.yapp.domain.readingrecord
enum class ReadingRecordSortType {
CREATED_DATE_ASC,
CREATED_DATE_DESC,
+ UPDATED_DATE_ASC,
+ UPDATED_DATE_DESC,
PAGE_NUMBER_ASC,
PAGE_NUMBER_DESC;
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt
index 34943b90..95126d28 100644
--- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt
@@ -1,14 +1,15 @@
package org.yapp.domain.readingrecord.vo
import org.yapp.domain.readingrecord.ReadingRecord
+import org.yapp.domain.userbook.UserBook
import java.time.LocalDateTime
data class ReadingRecordInfoVO private constructor(
val id: ReadingRecord.Id,
- val userBookId: ReadingRecord.UserBookId,
+ val userBookId: UserBook.Id,
val pageNumber: ReadingRecord.PageNumber,
val quote: ReadingRecord.Quote,
- val review: ReadingRecord.Review,
+ val review: ReadingRecord.Review?,
val emotionTags: List,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
diff --git a/domain/src/main/kotlin/org/yapp/domain/token/RefreshToken.kt b/domain/src/main/kotlin/org/yapp/domain/token/RefreshToken.kt
index 87802a0b..82b5a591 100644
--- a/domain/src/main/kotlin/org/yapp/domain/token/RefreshToken.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/token/RefreshToken.kt
@@ -1,5 +1,6 @@
package org.yapp.domain.token
+import org.yapp.domain.user.User
import org.yapp.globalutils.util.UuidGenerator
import java.time.LocalDateTime
import java.util.*
@@ -7,7 +8,7 @@ import java.util.*
data class RefreshToken private constructor(
val id: Id?,
val token: Token,
- val userId: UserId,
+ val userId: User.Id,
val expiresAt: LocalDateTime,
val createdAt: LocalDateTime
) {
@@ -25,7 +26,7 @@ data class RefreshToken private constructor(
return RefreshToken(
id = Id.newInstance(UuidGenerator.create()),
token = Token.newInstance(token),
- userId = UserId.newInstance(userId),
+ userId = User.Id.newInstance(userId),
expiresAt = expiresAt,
createdAt = createdAt
)
@@ -34,7 +35,7 @@ data class RefreshToken private constructor(
fun reconstruct(
id: Id,
token: Token,
- userId: UserId,
+ userId: User.Id,
expiresAt: LocalDateTime,
createdAt: LocalDateTime
): RefreshToken {
@@ -70,15 +71,4 @@ data class RefreshToken private constructor(
}
}
}
-
- @JvmInline
- value class UserId(val value: UUID) {
- override fun toString(): String = value.toString()
-
- companion object {
- fun newInstance(value: UUID): UserId {
- return UserId(value)
- }
- }
- }
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/token/RefreshTokenDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/token/RefreshTokenDomainService.kt
index 75b4bd35..6f7c7c9c 100644
--- a/domain/src/main/kotlin/org/yapp/domain/token/RefreshTokenDomainService.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/token/RefreshTokenDomainService.kt
@@ -1,9 +1,9 @@
package org.yapp.domain.token
import org.yapp.domain.token.RefreshToken.Token
-import org.yapp.domain.token.RefreshToken.UserId
import org.yapp.domain.token.exception.TokenErrorCode
import org.yapp.domain.token.exception.TokenNotFoundException
+import org.yapp.domain.user.User
import org.yapp.globalutils.annotation.DomainService
import java.time.LocalDateTime
import java.util.*
@@ -37,7 +37,7 @@ class RefreshTokenDomainService(
}
}
- fun getUserIdByToken(refreshToken: String): UserId {
+ fun getUserIdByToken(refreshToken: String): User.Id {
val storedToken = refreshTokenRepository.findByToken(refreshToken)
?: throw TokenNotFoundException(TokenErrorCode.TOKEN_NOT_FOUND)
return storedToken.userId
diff --git a/domain/src/main/kotlin/org/yapp/domain/user/User.kt b/domain/src/main/kotlin/org/yapp/domain/user/User.kt
index 47c67e58..d9b22605 100644
--- a/domain/src/main/kotlin/org/yapp/domain/user/User.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/user/User.kt
@@ -16,6 +16,8 @@ data class User private constructor(
val role: Role,
val termsAgreed: Boolean = false,
val appleRefreshToken: String? = null,
+ val notificationEnabled: Boolean = true,
+ val lastActivity: LocalDateTime? = null,
val createdAt: LocalDateTime? = null,
val updatedAt: LocalDateTime? = null,
val deletedAt: LocalDateTime? = null
@@ -40,6 +42,24 @@ data class User private constructor(
)
}
+ fun updateNotificationEnabled(enabled: Boolean): User {
+ return this.copy(
+ notificationEnabled = enabled
+ )
+ }
+
+ fun updateLastActivity(): User {
+ return this.copy(
+ lastActivity = LocalDateTime.now()
+ )
+ }
+
+ fun forceUpdateLastActivity(newLastActivity: LocalDateTime): User {
+ return this.copy(
+ lastActivity = newLastActivity
+ )
+ }
+
companion object {
fun create(
email: String,
@@ -47,7 +67,8 @@ data class User private constructor(
profileImageUrl: String?,
providerType: ProviderType,
providerId: String,
- termsAgreed: Boolean = false
+ termsAgreed: Boolean = false,
+ notificationEnabled: Boolean = true
): User {
return User(
id = Id.newInstance(UuidGenerator.create()),
@@ -58,7 +79,9 @@ data class User private constructor(
providerId = ProviderId.newInstance(providerId),
role = Role.USER,
termsAgreed = termsAgreed,
- appleRefreshToken = null
+ appleRefreshToken = null,
+ notificationEnabled = notificationEnabled,
+ lastActivity = LocalDateTime.now()
)
}
@@ -70,7 +93,8 @@ data class User private constructor(
providerType: ProviderType,
providerId: String,
role: Role,
- termsAgreed: Boolean = false
+ termsAgreed: Boolean = false,
+ notificationEnabled: Boolean = true
): User {
return User(
id = Id.newInstance(UuidGenerator.create()),
@@ -81,7 +105,9 @@ data class User private constructor(
providerId = ProviderId.newInstance(providerId),
role = role,
termsAgreed = termsAgreed,
- appleRefreshToken = null
+ appleRefreshToken = null,
+ notificationEnabled = notificationEnabled,
+ lastActivity = LocalDateTime.now()
)
}
@@ -95,6 +121,8 @@ data class User private constructor(
role: Role,
termsAgreed: Boolean = false,
appleRefreshToken: String? = null,
+ notificationEnabled: Boolean = true,
+ lastActivity: LocalDateTime? = null,
createdAt: LocalDateTime? = null,
updatedAt: LocalDateTime? = null,
deletedAt: LocalDateTime? = null
@@ -109,6 +137,8 @@ data class User private constructor(
role = role,
termsAgreed = termsAgreed,
appleRefreshToken = appleRefreshToken,
+ notificationEnabled = notificationEnabled,
+ lastActivity = lastActivity,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt
diff --git a/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt
index d8151c52..6b0a42fe 100644
--- a/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt
@@ -1,24 +1,30 @@
package org.yapp.domain.user
+import org.yapp.domain.readingrecord.ReadingRecordRepository
import org.yapp.domain.user.exception.UserErrorCode
import org.yapp.domain.user.exception.UserNotFoundException
-import org.yapp.domain.user.vo.UserAuthVO
-import org.yapp.domain.user.vo.UserIdentityVO
-import org.yapp.domain.user.vo.UserProfileVO
-import org.yapp.domain.user.vo.WithdrawTargetUserVO
+import org.yapp.domain.user.vo.*
+import org.yapp.domain.userbook.UserBookRepository
import org.yapp.globalutils.annotation.DomainService
-
+import java.time.LocalDateTime
import java.util.*
@DomainService
class UserDomainService(
private val userRepository: UserRepository,
+ private val userBookRepository: UserBookRepository,
+ private val readingRecordRepository: ReadingRecordRepository
) {
fun findUserProfileById(id: UUID): UserProfileVO {
val user = userRepository.findById(id) ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND)
return UserProfileVO.newInstance(user)
}
+ fun findNotificationTargetUserById(id: UUID): NotificationTargetUserVO {
+ val user = userRepository.findById(id) ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND)
+ return NotificationTargetUserVO.from(user)
+ }
+
fun findUserIdentityById(id: UUID): UserIdentityVO {
val user = userRepository.findById(id) ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND)
return UserIdentityVO.newInstance(user)
@@ -99,4 +105,75 @@ class UserDomainService(
userRepository.deleteById(user.id.value)
}
+
+ fun updateLastActivity(userId: UUID) {
+ val user = userRepository.findById(userId)
+ ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND)
+
+ val sevenDaysAgo = LocalDateTime.now().minusDays(7)
+
+ val recentBooks = userBookRepository.findByUserIdAndCreatedAtAfter(userId, sevenDaysAgo)
+
+ val userBooks = userBookRepository.findAllByUserId(userId)
+ val userBookIds = userBooks.map { it.id.value }
+ val recentRecords = if (userBookIds.isNotEmpty()) {
+ readingRecordRepository.findByUserBookIdInAndCreatedAtAfter(userBookIds, sevenDaysAgo)
+ } else {
+ emptyList()
+ }
+
+ if (recentBooks.isNotEmpty() || recentRecords.isNotEmpty()) {
+ userRepository.save(user.updateLastActivity())
+ }
+ }
+
+ fun forceUpdateLastActivity(userId: UUID, newLastActivity: LocalDateTime) {
+ val user = userRepository.findById(userId)
+ ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND)
+
+ userRepository.save(user.forceUpdateLastActivity(newLastActivity))
+ }
+
+ fun updateNotificationSettings(userId: UUID, notificationEnabled: Boolean): UserProfileVO {
+ val user = userRepository.findById(userId)
+ ?: throw UserNotFoundException(UserErrorCode.USER_NOT_FOUND)
+
+ val updatedUser = userRepository.save(user.updateNotificationEnabled(notificationEnabled))
+ return UserProfileVO.newInstance(updatedUser)
+ }
+
+ fun findUnrecordedUsers(daysThreshold: Int): List {
+ val targetDate = LocalDateTime.now().minusDays(daysThreshold.toLong())
+
+ val allUsers = userRepository.findByLastActivityBeforeAndNotificationEnabled(
+ LocalDateTime.now().plusDays(1),
+ true
+ )
+
+ return allUsers.filter { user ->
+ val userId = user.id.value
+ val recentBooks = userBookRepository.findByUserIdAndCreatedAtAfter(userId, targetDate)
+
+ if (recentBooks.isEmpty()) {
+ false
+ } else {
+ val userBooks = userBookRepository.findAllByUserId(userId)
+ val userBookIds = userBooks.map { it.id.value }
+
+ val recentRecords = if (userBookIds.isNotEmpty()) {
+ readingRecordRepository.findByUserBookIdInAndCreatedAtAfter(userBookIds, targetDate)
+ } else {
+ emptyList()
+ }
+
+ recentRecords.isEmpty()
+ }
+ }.map { NotificationTargetUserVO.from(it) }
+ }
+
+ fun findDormantUsers(daysThreshold: Int): List {
+ val targetDate = LocalDateTime.now().minusDays(daysThreshold.toLong())
+ return userRepository.findByLastActivityBeforeAndNotificationEnabled(targetDate, true)
+ .map { NotificationTargetUserVO.from(it) }
+ }
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/user/UserRepository.kt b/domain/src/main/kotlin/org/yapp/domain/user/UserRepository.kt
index ac9b7f29..cb75e8f6 100644
--- a/domain/src/main/kotlin/org/yapp/domain/user/UserRepository.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/user/UserRepository.kt
@@ -1,5 +1,6 @@
package org.yapp.domain.user
+import java.time.LocalDateTime
import java.util.*
interface UserRepository {
@@ -19,4 +20,13 @@ interface UserRepository {
fun existsByEmail(email: String): Boolean
fun deleteById(userId: UUID): Unit
+
+ /**
+ * Find users who haven't been active since the specified time and have notifications enabled
+ *
+ * @param lastActivityBefore Find users whose last activity is before this time
+ * @param notificationEnabled Find users with notifications enabled if true
+ * @return List of users matching the criteria
+ */
+ fun findByLastActivityBeforeAndNotificationEnabled(lastActivityBefore: LocalDateTime, notificationEnabled: Boolean): List
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/user/vo/NotificationTargetUserVO.kt b/domain/src/main/kotlin/org/yapp/domain/user/vo/NotificationTargetUserVO.kt
new file mode 100644
index 00000000..5b7f17fd
--- /dev/null
+++ b/domain/src/main/kotlin/org/yapp/domain/user/vo/NotificationTargetUserVO.kt
@@ -0,0 +1,23 @@
+package org.yapp.domain.user.vo
+
+import org.yapp.domain.user.User
+import java.time.LocalDateTime
+import java.util.UUID
+
+data class NotificationTargetUserVO private constructor(
+ val id: UUID,
+ val email: String,
+ val nickname: String,
+ val lastActivity: LocalDateTime?
+) {
+ companion object {
+ fun from(user: User): NotificationTargetUserVO {
+ return NotificationTargetUserVO(
+ id = user.id.value,
+ email = user.email.value,
+ nickname = user.nickname,
+ lastActivity = user.lastActivity
+ )
+ }
+ }
+}
diff --git a/domain/src/main/kotlin/org/yapp/domain/user/vo/UserProfileVO.kt b/domain/src/main/kotlin/org/yapp/domain/user/vo/UserProfileVO.kt
index 0bd89f85..1d9f827a 100644
--- a/domain/src/main/kotlin/org/yapp/domain/user/vo/UserProfileVO.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/user/vo/UserProfileVO.kt
@@ -8,7 +8,8 @@ data class UserProfileVO private constructor(
val email: User.Email,
val nickname: String,
val provider: ProviderType,
- val termsAgreed: Boolean
+ val termsAgreed: Boolean,
+ val notificationEnabled: Boolean
) {
init {
require(nickname.isNotBlank()) {"nickname์ ๋น์ด ์์ ์ ์์ต๋๋ค."}
@@ -24,7 +25,8 @@ data class UserProfileVO private constructor(
email = user.email,
nickname = user.nickname,
provider = user.providerType,
- termsAgreed = user.termsAgreed
+ termsAgreed = user.termsAgreed,
+ notificationEnabled = user.notificationEnabled
)
}
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt
index aa6dc0a1..d107264b 100644
--- a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt
@@ -1,15 +1,16 @@
package org.yapp.domain.userbook
+import org.yapp.domain.book.Book
+import org.yapp.domain.user.User
import org.yapp.globalutils.util.UuidGenerator
-import org.yapp.globalutils.validator.IsbnValidator
import java.time.LocalDateTime
import java.util.*
data class UserBook private constructor(
val id: Id,
- val userId: UserId,
- val bookId: BookId,
- val bookIsbn13: BookIsbn13,
+ val userId: User.Id,
+ val bookId: Book.Id,
+ val bookIsbn13: Book.Isbn13,
val coverImageUrl: String,
val publisher: String,
val title: String,
@@ -45,9 +46,9 @@ data class UserBook private constructor(
): UserBook {
return UserBook(
id = Id.newInstance(UuidGenerator.create()),
- userId = UserId.newInstance(userId),
- bookId = BookId.newInstance(bookId),
- bookIsbn13 = BookIsbn13.newInstance(bookIsbn13),
+ userId = User.Id.newInstance(userId),
+ bookId = Book.Id.newInstance(bookId),
+ bookIsbn13 = Book.Isbn13.newInstance(bookIsbn13),
coverImageUrl = coverImageUrl,
publisher = publisher,
title = title,
@@ -58,9 +59,9 @@ data class UserBook private constructor(
fun reconstruct(
id: Id,
- userId: UserId,
- bookId: BookId,
- bookIsbn13: BookIsbn13,
+ userId: User.Id,
+ bookId: Book.Id,
+ bookIsbn13: Book.Isbn13,
coverImageUrl: String,
publisher: String,
title: String,
@@ -95,28 +96,4 @@ data class UserBook private constructor(
fun newInstance(value: UUID) = Id(value)
}
}
-
- @JvmInline
- value class UserId(val value: UUID) {
- companion object {
- fun newInstance(value: UUID) = UserId(value)
- }
- }
-
- @JvmInline
- value class BookId(val value: UUID) {
- companion object {
- fun newInstance(value: UUID) = BookId(value)
- }
- }
-
- @JvmInline
- value class BookIsbn13(val value: String) {
- companion object {
- fun newInstance(value: String): BookIsbn13 {
- require(IsbnValidator.isValidIsbn13(value)) { "ISBN13 must be a 13-digit number." }
- return BookIsbn13(value)
- }
- }
- }
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt
index 58f08862..4afad4db 100644
--- a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt
@@ -32,4 +32,13 @@ interface UserBookRepository {
limit: Int,
excludeIds: Set
): List
+
+ /**
+ * Find books registered by a user after the specified time
+ *
+ * @param userId The user's ID
+ * @param after Find books registered after this time
+ * @return List of books matching the criteria
+ */
+ fun findByUserIdAndCreatedAtAfter(userId: UUID, after: LocalDateTime): List
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookSortType.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookSortType.kt
index 93b290d4..800d8bff 100644
--- a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookSortType.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookSortType.kt
@@ -5,5 +5,7 @@ enum class UserBookSortType {
TITLE_ASC,
TITLE_DESC,
CREATED_DATE_ASC,
- CREATED_DATE_DESC;
+ CREATED_DATE_DESC,
+ UPDATED_DATE_ASC,
+ UPDATED_DATE_DESC;
}
diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/vo/HomeBookVO.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/vo/HomeBookVO.kt
index 84ba184a..dd9ace90 100644
--- a/domain/src/main/kotlin/org/yapp/domain/userbook/vo/HomeBookVO.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/userbook/vo/HomeBookVO.kt
@@ -1,14 +1,16 @@
package org.yapp.domain.userbook.vo
+import org.yapp.domain.book.Book
+import org.yapp.domain.user.User
import org.yapp.domain.userbook.BookStatus
import org.yapp.domain.userbook.UserBook
import java.time.LocalDateTime
data class HomeBookVO private constructor(
val id: UserBook.Id,
- val userId: UserBook.UserId,
- val bookId: UserBook.BookId,
- val bookIsbn13: UserBook.BookIsbn13,
+ val userId: User.Id,
+ val bookId: Book.Id,
+ val bookIsbn13: Book.Isbn13,
val coverImageUrl: String,
val publisher: String,
val title: String,
diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookInfoVO.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookInfoVO.kt
index c5e7647b..415714d8 100644
--- a/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookInfoVO.kt
+++ b/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookInfoVO.kt
@@ -1,14 +1,16 @@
package org.yapp.domain.userbook.vo
+import org.yapp.domain.book.Book
+import org.yapp.domain.user.User
import org.yapp.domain.userbook.BookStatus
import org.yapp.domain.userbook.UserBook
import java.time.LocalDateTime
data class UserBookInfoVO private constructor(
val id: UserBook.Id,
- val userId: UserBook.UserId,
- val bookId: UserBook.BookId,
- val bookIsbn13: UserBook.BookIsbn13,
+ val userId: User.Id,
+ val bookId: Book.Id,
+ val bookIsbn13: Book.Isbn13,
val coverImageUrl: String,
val publisher: String,
val title: String,
diff --git a/gateway/build.gradle.kts b/gateway/build.gradle.kts
index 52c2c904..c280b284 100644
--- a/gateway/build.gradle.kts
+++ b/gateway/build.gradle.kts
@@ -2,12 +2,11 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar
dependencies {
implementation(project(Dependencies.Projects.GLOBAL_UTILS))
+ implementation(project(Dependencies.Projects.OBSERVABILITY))
+
implementation(Dependencies.Spring.BOOT_STARTER_WEB)
implementation(Dependencies.Spring.BOOT_STARTER_SECURITY)
implementation(Dependencies.Spring.BOOT_STARTER_OAUTH2_RESOURCE_SERVER)
- implementation(Dependencies.Spring.BOOT_STARTER_ACTUATOR)
-
- implementation(Dependencies.Prometheus.MICROMETER_PROMETHEUS_REGISTRY)
testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)
}
diff --git a/gateway/src/main/kotlin/org/yapp/gateway/filter/MdcLoggingFilter.kt b/gateway/src/main/kotlin/org/yapp/gateway/filter/MdcLoggingFilter.kt
deleted file mode 100644
index b2277191..00000000
--- a/gateway/src/main/kotlin/org/yapp/gateway/filter/MdcLoggingFilter.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package org.yapp.gateway.filter
-
-import jakarta.servlet.FilterChain
-import jakarta.servlet.http.HttpServletRequest
-import jakarta.servlet.http.HttpServletResponse
-import org.slf4j.MDC
-import org.springframework.security.core.context.SecurityContextHolder
-import org.springframework.security.oauth2.jwt.Jwt
-import org.springframework.stereotype.Component
-import org.springframework.web.filter.OncePerRequestFilter
-import java.util.*
-
-@Component
-class MdcLoggingFilter : OncePerRequestFilter() {
- companion object {
- private const val TRACE_ID_HEADER = "X-Request-ID"
- private const val XFF_HEADER = "X-Forwarded-For"
- private const val X_REAL_IP_HEADER = "X-Real-IP"
- private const val TRACE_ID_KEY = "traceId"
- private const val USER_ID_KEY = "userId"
- private const val CLIENT_IP_KEY = "clientIp"
- private const val REQUEST_INFO_KEY = "requestInfo"
- private const val DEFAULT_GUEST_USER = "GUEST"
- }
-
- override fun doFilterInternal(
- request: HttpServletRequest,
- response: HttpServletResponse,
- filterChain: FilterChain
- ) {
- val traceId = resolveTraceId(request)
- populateMdc(request, traceId)
-
- try {
- filterChain.doFilter(request, response)
- } finally {
- MDC.clear()
- }
- }
-
- private fun resolveTraceId(request: HttpServletRequest): String {
- val incomingTraceId = request.getHeader(TRACE_ID_HEADER)
- return incomingTraceId?.takeIf { it.isNotBlank() }
- ?: UUID.randomUUID().toString().replace("-", "")
- }
-
- private fun populateMdc(request: HttpServletRequest, traceId: String) {
- MDC.put(TRACE_ID_KEY, traceId)
- MDC.put(CLIENT_IP_KEY, extractClientIp(request))
- MDC.put(REQUEST_INFO_KEY, "${request.method} ${request.requestURI}")
-
- val userId = resolveUserId()
- MDC.put(USER_ID_KEY, userId ?: DEFAULT_GUEST_USER)
- }
-
- private fun extractClientIp(request: HttpServletRequest): String {
- val xffHeader = request.getHeader(XFF_HEADER)
- if (!xffHeader.isNullOrBlank()) {
- return xffHeader.split(",").first().trim()
- }
-
- val xRealIp = request.getHeader(X_REAL_IP_HEADER)
- if (!xRealIp.isNullOrBlank()) {
- return xRealIp.trim()
- }
-
- return request.remoteAddr
- }
-
- private fun resolveUserId(): String? {
- val authentication = SecurityContextHolder.getContext().authentication ?: return null
-
- return when (val principal = authentication.principal) {
- is Jwt -> principal.subject
- else -> principal?.toString()
- }
- }
-}
-
diff --git a/gateway/src/main/kotlin/org/yapp/gateway/filter/SecurityMdcLoggingFilter.kt b/gateway/src/main/kotlin/org/yapp/gateway/filter/SecurityMdcLoggingFilter.kt
new file mode 100644
index 00000000..b86c8552
--- /dev/null
+++ b/gateway/src/main/kotlin/org/yapp/gateway/filter/SecurityMdcLoggingFilter.kt
@@ -0,0 +1,30 @@
+package org.yapp.gateway.filter
+
+import org.springframework.security.core.context.SecurityContextHolder
+import org.springframework.security.oauth2.jwt.Jwt
+import org.springframework.stereotype.Component
+import org.yapp.observability.logging.filter.BaseMdcLoggingFilter
+
+/**
+ * Spring Security์ JWT ์ธ์ฆ์ด ์๋ ํ๊ฒฝ์์ ์ฌ์ฉํ๋ MDC ๋ก๊น
ํํฐ
+ *
+ * SecurityContext์์ JWT ํ ํฐ์ ์ฝ์ด ์ฌ์ฉ์ ID๋ฅผ MDC์ ์ถ๊ฐํฉ๋๋ค.
+ * API ์๋ฒ(apis), ๊ด๋ฆฌ์ ์๋ฒ(admin) ๋ฑ ์ธ์ฆ์ด ํ์ํ ์๋น์ค์ ์ฌ์ฉ๋ฉ๋๋ค.
+ */
+@Component
+class SecurityMdcLoggingFilter : BaseMdcLoggingFilter() {
+ /**
+ * SecurityContext์์ JWT principal์ ์ฝ์ด ์ฌ์ฉ์ ID๋ฅผ ์ถ์ถํฉ๋๋ค.
+ *
+ * @return JWT subject (์ฌ์ฉ์ ID) ๋๋ null
+ */
+ override fun resolveUserId(): String? {
+ val authentication = SecurityContextHolder.getContext().authentication ?: return null
+
+ return when (val principal = authentication.principal) {
+ is Jwt -> principal.subject
+ else -> principal?.toString()
+ }
+ }
+}
+
diff --git a/gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt b/gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt
index 2fa33de4..81b179df 100644
--- a/gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt
+++ b/gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt
@@ -11,8 +11,8 @@ import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
import org.springframework.security.web.SecurityFilterChain
-import org.yapp.gateway.config.ActuatorProperties
-import org.yapp.gateway.filter.MdcLoggingFilter
+import org.yapp.observability.metrics.config.ActuatorProperties
+import org.yapp.gateway.filter.SecurityMdcLoggingFilter
@Configuration
@EnableWebSecurity
@@ -21,7 +21,7 @@ class SecurityConfig(
private val jwtAuthenticationConverter: Converter,
private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint,
private val customAccessDeniedHandler: CustomAccessDeniedHandler,
- private val mdcLoggingFilter: MdcLoggingFilter,
+ private val securityMdcLoggingFilter: SecurityMdcLoggingFilter,
actuatorProperties: ActuatorProperties
) {
companion object {
@@ -61,7 +61,7 @@ class SecurityConfig(
it.requestMatchers(ADMIN_PATTERN).hasRole("ADMIN")
it.anyRequest().authenticated()
}
- .addFilterAfter(mdcLoggingFilter, BearerTokenAuthenticationFilter::class.java)
+ .addFilterAfter(securityMdcLoggingFilter, BearerTokenAuthenticationFilter::class.java)
.build()
}
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/device/DeviceJpaRepository.kt b/infra/src/main/kotlin/org/yapp/infra/device/DeviceJpaRepository.kt
new file mode 100644
index 00000000..74a095e5
--- /dev/null
+++ b/infra/src/main/kotlin/org/yapp/infra/device/DeviceJpaRepository.kt
@@ -0,0 +1,17 @@
+package org.yapp.infra.device
+
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.Modifying
+import org.springframework.data.jpa.repository.Query
+import org.yapp.infra.device.entity.DeviceEntity
+import java.util.UUID
+
+interface DeviceJpaRepository : JpaRepository {
+ fun findByDeviceId(deviceId: String): DeviceEntity?
+ fun findByFcmToken(fcmToken: String): DeviceEntity?
+ fun findByUserId(userId: UUID): List
+
+ @Modifying(clearAutomatically = true)
+ @Query("DELETE FROM DeviceEntity d WHERE d.fcmToken IN :tokens")
+ fun deleteByFcmTokenIn(tokens: List)
+}
diff --git a/infra/src/main/kotlin/org/yapp/infra/device/DeviceRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/device/DeviceRepositoryImpl.kt
new file mode 100644
index 00000000..b244930f
--- /dev/null
+++ b/infra/src/main/kotlin/org/yapp/infra/device/DeviceRepositoryImpl.kt
@@ -0,0 +1,32 @@
+package org.yapp.infra.device
+
+import org.springframework.stereotype.Repository
+import org.yapp.domain.device.Device
+import org.yapp.domain.device.DeviceRepository
+import org.yapp.infra.device.entity.DeviceEntity
+import java.util.UUID
+
+@Repository
+class DeviceRepositoryImpl(
+ private val deviceJpaRepository: DeviceJpaRepository
+) : DeviceRepository {
+ override fun findByDeviceId(deviceId: String): Device? {
+ return deviceJpaRepository.findByDeviceId(deviceId)?.toDomain()
+ }
+
+ override fun findByFcmToken(fcmToken: String): Device? {
+ return deviceJpaRepository.findByFcmToken(fcmToken)?.toDomain()
+ }
+
+ override fun save(device: Device): Device {
+ return deviceJpaRepository.save(DeviceEntity.fromDomain(device)).toDomain()
+ }
+
+ override fun findByUserId(userId: UUID): List {
+ return deviceJpaRepository.findByUserId(userId).map { it.toDomain() }
+ }
+
+ override fun deleteByTokens(tokens: List) {
+ deviceJpaRepository.deleteByFcmTokenIn(tokens)
+ }
+}
diff --git a/infra/src/main/kotlin/org/yapp/infra/device/entity/DeviceEntity.kt b/infra/src/main/kotlin/org/yapp/infra/device/entity/DeviceEntity.kt
new file mode 100644
index 00000000..91426181
--- /dev/null
+++ b/infra/src/main/kotlin/org/yapp/infra/device/entity/DeviceEntity.kt
@@ -0,0 +1,51 @@
+package org.yapp.infra.device.entity
+
+import jakarta.persistence.*
+import org.hibernate.annotations.JdbcTypeCode
+import org.yapp.domain.device.Device
+import org.yapp.domain.user.User
+import org.yapp.infra.common.BaseTimeEntity
+import java.sql.Types
+import java.util.UUID
+
+@Entity
+@Table(name = "device")
+class DeviceEntity(
+ @Id
+ @JdbcTypeCode(Types.VARCHAR)
+ @Column(length = 36, updatable = false, nullable = false)
+ val id: UUID,
+
+ @JdbcTypeCode(Types.VARCHAR)
+ @Column(name = "user_id", length = 36, nullable = false)
+ val userId: UUID,
+
+ @Column(name = "device_id", nullable = false)
+ var deviceId: String,
+
+ @Column(name = "fcm_token", nullable = false)
+ var fcmToken: String,
+) : BaseTimeEntity() {
+
+ fun toDomain(): Device {
+ return Device.reconstruct(
+ id = Device.Id.newInstance(this.id),
+ userId = User.Id.newInstance(this.userId),
+ deviceId = this.deviceId,
+ fcmToken = this.fcmToken,
+ createdAt = this.createdAt,
+ updatedAt = this.updatedAt
+ )
+ }
+
+ companion object {
+ fun fromDomain(device: Device): DeviceEntity {
+ return DeviceEntity(
+ id = device.id.value,
+ userId = device.userId.value,
+ deviceId = device.deviceId,
+ fcmToken = device.fcmToken
+ )
+ }
+ }
+}
diff --git a/infra/src/main/kotlin/org/yapp/infra/external/redis/entity/RefreshTokenEntity.kt b/infra/src/main/kotlin/org/yapp/infra/external/redis/entity/RefreshTokenEntity.kt
index 3fea9c13..f9bb0000 100644
--- a/infra/src/main/kotlin/org/yapp/infra/external/redis/entity/RefreshTokenEntity.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/external/redis/entity/RefreshTokenEntity.kt
@@ -4,6 +4,7 @@ import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash
import org.springframework.data.redis.core.index.Indexed
import org.yapp.domain.token.RefreshToken
+import org.yapp.domain.user.User
import org.yapp.globalutils.util.UuidGenerator
import java.time.LocalDateTime
import java.util.*
@@ -25,7 +26,7 @@ class RefreshTokenEntity private constructor(
fun toDomain(): RefreshToken = RefreshToken.reconstruct(
id = RefreshToken.Id.newInstance(this.id),
token = RefreshToken.Token.newInstance(this.token),
- userId = RefreshToken.UserId.newInstance(this.userId),
+ userId = User.Id.newInstance(this.userId),
expiresAt = expiresAt,
createdAt = createdAt
)
diff --git a/infra/src/main/kotlin/org/yapp/infra/notification/entity/NotificationEntity.kt b/infra/src/main/kotlin/org/yapp/infra/notification/entity/NotificationEntity.kt
new file mode 100644
index 00000000..6953c0b3
--- /dev/null
+++ b/infra/src/main/kotlin/org/yapp/infra/notification/entity/NotificationEntity.kt
@@ -0,0 +1,74 @@
+package org.yapp.infra.notification.entity
+
+import jakarta.persistence.*
+import org.hibernate.annotations.JdbcTypeCode
+import org.yapp.domain.notification.Notification
+import org.yapp.domain.notification.NotificationType
+import org.yapp.domain.user.User
+import org.yapp.infra.common.BaseTimeEntity
+import java.sql.Types
+import java.time.LocalDateTime
+import java.util.UUID
+
+@Entity
+@Table(name = "notification")
+class NotificationEntity(
+ @Id
+ @JdbcTypeCode(Types.VARCHAR)
+ @Column(length = 36, updatable = false, nullable = false)
+ val id: UUID,
+
+ @JdbcTypeCode(Types.VARCHAR)
+ @Column(name = "user_id", length = 36, nullable = false)
+ val userId: UUID,
+
+ @Column(nullable = false)
+ var title: String,
+
+ @Column(nullable = false)
+ var message: String,
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "notification_type", nullable = false)
+ var notificationType: NotificationType,
+
+ @Column(name = "is_read", nullable = false)
+ var isRead: Boolean = false,
+
+ @Column(name = "is_sent", nullable = false)
+ var isSent: Boolean = false,
+
+ @Column(name = "sent_at")
+ var sentAt: LocalDateTime? = null
+) : BaseTimeEntity() {
+
+ companion object {
+ fun fromDomain(notification: Notification): NotificationEntity {
+ return NotificationEntity(
+ id = notification.id.value,
+ userId = notification.userId.value,
+ title = notification.title,
+ message = notification.message,
+ notificationType = notification.notificationType,
+ isRead = notification.isRead,
+ isSent = notification.isSent,
+ sentAt = notification.sentAt
+ )
+ }
+ }
+
+ fun toDomain(): Notification {
+ return Notification.reconstruct(
+ id = Notification.Id.newInstance(this.id),
+ userId = User.Id.newInstance(this.userId),
+ title = this.title,
+ message = this.message,
+ notificationType = this.notificationType,
+ isRead = this.isRead,
+ isSent = this.isSent,
+ sentAt = this.sentAt,
+ createdAt = this.createdAt,
+ updatedAt = this.updatedAt
+ )
+ }
+}
diff --git a/infra/src/main/kotlin/org/yapp/infra/notification/repository/JpaNotificationRepository.kt b/infra/src/main/kotlin/org/yapp/infra/notification/repository/JpaNotificationRepository.kt
new file mode 100644
index 00000000..f8b538c3
--- /dev/null
+++ b/infra/src/main/kotlin/org/yapp/infra/notification/repository/JpaNotificationRepository.kt
@@ -0,0 +1,11 @@
+package org.yapp.infra.notification.repository
+
+import org.yapp.infra.notification.entity.NotificationEntity
+import org.yapp.infra.user.entity.UserEntity
+import org.springframework.data.jpa.repository.JpaRepository
+import java.util.UUID
+
+interface JpaNotificationRepository : JpaRepository {
+ fun findByUserId(userId: UUID): List
+ fun findByIsSent(isSent: Boolean): List
+}
diff --git a/infra/src/main/kotlin/org/yapp/infra/notification/repository/impl/NotificationRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/notification/repository/impl/NotificationRepositoryImpl.kt
new file mode 100644
index 00000000..6cac407a
--- /dev/null
+++ b/infra/src/main/kotlin/org/yapp/infra/notification/repository/impl/NotificationRepositoryImpl.kt
@@ -0,0 +1,38 @@
+package org.yapp.infra.notification.repository.impl
+
+import org.springframework.stereotype.Repository
+import org.yapp.domain.notification.Notification
+import org.yapp.domain.notification.NotificationRepository
+import org.yapp.domain.user.User
+import org.yapp.infra.notification.entity.NotificationEntity
+import org.yapp.infra.notification.repository.JpaNotificationRepository
+import org.yapp.infra.user.entity.UserEntity
+import java.util.UUID
+
+@Repository
+class NotificationRepositoryImpl(
+ private val jpaNotificationRepository: JpaNotificationRepository
+) : NotificationRepository {
+ override fun save(notification: Notification): Notification {
+ val notificationEntity = jpaNotificationRepository.save(
+ NotificationEntity.fromDomain(notification)
+ )
+ return notificationEntity.toDomain()
+ }
+
+ override fun findByUser(user: User): Notification? {
+ return jpaNotificationRepository.findByUserId(user.id.value).firstOrNull()?.toDomain()
+ }
+
+ override fun findByUserId(userId: UUID): List {
+ return jpaNotificationRepository.findByUserId(userId).map { it.toDomain() }
+ }
+
+ override fun findAll(): List {
+ return jpaNotificationRepository.findAll().map { it.toDomain() }
+ }
+
+ override fun findBySent(isSent: Boolean): List {
+ return jpaNotificationRepository.findByIsSent(isSent).map { it.toDomain() }
+ }
+}
diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt
index fd18192e..1b754912 100644
--- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt
@@ -5,6 +5,7 @@ import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.annotations.SQLDelete
import org.hibernate.annotations.SQLRestriction
import org.yapp.domain.readingrecord.ReadingRecord
+import org.yapp.domain.userbook.UserBook
import org.yapp.infra.common.BaseTimeEntity
import java.sql.Types
import java.util.*
@@ -25,7 +26,7 @@ class ReadingRecordEntity(
pageNumber: Int,
quote: String,
- review: String,
+ review: String?,
) : BaseTimeEntity() {
@@ -38,14 +39,14 @@ class ReadingRecordEntity(
var quote: String = quote
protected set
- @Column(name = "review", nullable = false, length = 1000)
- var review: String = review
+ @Column(name = "review", nullable = true, length = 1000)
+ var review: String? = review
protected set
fun toDomain(): ReadingRecord {
return ReadingRecord.reconstruct(
id = ReadingRecord.Id.newInstance(this.id),
- userBookId = ReadingRecord.UserBookId.newInstance(this.userBookId),
+ userBookId = UserBook.Id.newInstance(this.userBookId),
pageNumber = ReadingRecord.PageNumber.newInstance(this.pageNumber),
quote = ReadingRecord.Quote.newInstance(this.quote),
review = ReadingRecord.Review.newInstance(this.review),
@@ -63,7 +64,7 @@ class ReadingRecordEntity(
userBookId = readingRecord.userBookId.value,
pageNumber = readingRecord.pageNumber.value,
quote = readingRecord.quote.value,
- review = readingRecord.review.value
+ review = readingRecord.review?.value
)
}
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt
index 926fca08..d36d3926 100644
--- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt
@@ -4,6 +4,7 @@ import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.yapp.infra.readingrecord.entity.ReadingRecordEntity
+import java.time.LocalDateTime
import java.util.UUID
@@ -16,6 +17,8 @@ interface JpaReadingRecordRepository : JpaRepository,
fun findAllByUserBookId(userBookId: UUID, pageable: Pageable): Page
fun findAllByUserBookIdIn(userBookIds: List): List
-
+
fun countByUserBookId(userBookId: UUID): Long
+
+ fun findByUserBookIdInAndCreatedAtAfter(userBookIds: List, createdAt: LocalDateTime): List
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt
index 026386cb..b710f712 100644
--- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt
@@ -59,7 +59,9 @@ class JpaReadingRecordQuerydslRepositoryImpl(
ReadingRecordSortType.CREATED_DATE_ASC -> arrayOf(readingRecord.createdAt.asc())
ReadingRecordSortType.CREATED_DATE_DESC -> arrayOf(readingRecord.createdAt.desc())
- null -> arrayOf(readingRecord.createdAt.desc())
+ ReadingRecordSortType.UPDATED_DATE_ASC -> arrayOf(readingRecord.updatedAt.asc())
+ ReadingRecordSortType.UPDATED_DATE_DESC -> arrayOf(readingRecord.updatedAt.desc())
+ null -> arrayOf(readingRecord.updatedAt.desc())
}
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt
index ba5b0fa2..d4ca7559 100644
--- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt
@@ -9,6 +9,7 @@ import org.yapp.domain.readingrecord.ReadingRecordRepository
import org.yapp.domain.readingrecord.ReadingRecordSortType
import org.yapp.infra.readingrecord.entity.ReadingRecordEntity
import org.yapp.infra.readingrecord.repository.JpaReadingRecordRepository
+import java.time.LocalDateTime
import java.util.*
@Repository
@@ -60,4 +61,9 @@ class ReadingRecordRepositoryImpl(
override fun deleteById(id: UUID) {
jpaReadingRecordRepository.deleteById(id)
}
+
+ override fun findByUserBookIdInAndCreatedAtAfter(userBookIds: List, after: LocalDateTime): List {
+ val entities = jpaReadingRecordRepository.findByUserBookIdInAndCreatedAtAfter(userBookIds, after)
+ return entities.map { it.toDomain() }
+ }
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt b/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt
index 34f86f90..d3868f99 100644
--- a/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt
@@ -9,6 +9,7 @@ import org.yapp.domain.user.User
import org.yapp.globalutils.auth.Role
import org.yapp.infra.common.BaseTimeEntity
import java.sql.Types
+import java.time.LocalDateTime
import java.util.*
@Entity
@@ -39,7 +40,13 @@ class UserEntity private constructor(
termsAgreed: Boolean = false,
- appleRefreshToken: String? = null
+ appleRefreshToken: String? = null,
+
+ @Column(name = "notification_enabled", nullable = false)
+ var notificationEnabled: Boolean = true,
+
+ @Column(name = "last_activity")
+ var lastActivity: LocalDateTime? = null
) : BaseTimeEntity() {
@Column(nullable = false, length = 100)
@@ -73,6 +80,8 @@ class UserEntity private constructor(
role = role,
termsAgreed = termsAgreed,
appleRefreshToken = appleRefreshToken,
+ notificationEnabled = notificationEnabled,
+ lastActivity = lastActivity,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt
@@ -88,7 +97,9 @@ class UserEntity private constructor(
providerId = user.providerId.value,
role = user.role,
termsAgreed = user.termsAgreed,
- appleRefreshToken = user.appleRefreshToken
+ appleRefreshToken = user.appleRefreshToken,
+ notificationEnabled = user.notificationEnabled,
+ lastActivity = user.lastActivity
)
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserRepository.kt b/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserRepository.kt
index 407bc46a..9d64f079 100644
--- a/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserRepository.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserRepository.kt
@@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.yapp.domain.user.ProviderType
import org.yapp.infra.user.entity.UserEntity
+import java.time.LocalDateTime
import java.util.*
/**
@@ -29,4 +30,9 @@ interface JpaUserRepository : JpaRepository {
nativeQuery = true
)
fun findByIdIncludingDeleted(id: UUID): UserEntity?
+
+ fun findByLastActivityBeforeAndNotificationEnabledAndDeletedAtIsNull(
+ lastActivityBefore: LocalDateTime,
+ notificationEnabled: Boolean
+ ): List
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt
index 583b0de6..5aa50bf6 100644
--- a/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt
@@ -7,6 +7,7 @@ import org.yapp.domain.user.User
import org.yapp.domain.user.UserRepository
import org.yapp.infra.user.entity.UserEntity
import org.yapp.infra.user.repository.JpaUserRepository
+import java.time.LocalDateTime
import java.util.*
@Repository
@@ -49,4 +50,14 @@ class UserRepositoryImpl(
override fun deleteById(userId: UUID) {
return jpaUserRepository.deleteById(userId)
}
+
+ override fun findByLastActivityBeforeAndNotificationEnabled(
+ lastActivityBefore: LocalDateTime,
+ notificationEnabled: Boolean
+ ): List {
+ return jpaUserRepository.findByLastActivityBeforeAndNotificationEnabledAndDeletedAtIsNull(
+ lastActivityBefore,
+ notificationEnabled
+ ).map { it.toDomain() }
+ }
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt
index 91b6b492..25db5f35 100644
--- a/infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt
@@ -4,6 +4,8 @@ import jakarta.persistence.*
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.annotations.SQLDelete
import org.hibernate.annotations.SQLRestriction
+import org.yapp.domain.book.Book
+import org.yapp.domain.user.User
import org.yapp.domain.userbook.BookStatus
import org.yapp.domain.userbook.UserBook
import org.yapp.infra.common.BaseTimeEntity
@@ -71,9 +73,9 @@ class UserBookEntity(
fun toDomain(): UserBook = UserBook.reconstruct(
id = UserBook.Id.newInstance(this.id),
- userId = UserBook.UserId.newInstance(this.userId),
- bookId = UserBook.BookId.newInstance(this.bookId),
- bookIsbn13 = UserBook.BookIsbn13.newInstance(this.bookIsbn13),
+ userId = User.Id.newInstance(this.userId),
+ bookId = Book.Id.newInstance(this.bookId),
+ bookIsbn13 = Book.Isbn13.newInstance(this.bookIsbn13),
coverImageUrl = this.coverImageUrl,
publisher = this.publisher,
title = this.title,
diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookRepository.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookRepository.kt
index 05c8fec9..abacb2b6 100644
--- a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookRepository.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookRepository.kt
@@ -2,6 +2,7 @@ package org.yapp.infra.userbook.repository
import org.springframework.data.jpa.repository.JpaRepository
import org.yapp.infra.userbook.entity.UserBookEntity
+import java.time.LocalDateTime
import java.util.*
interface JpaUserBookRepository : JpaRepository, JpaUserBookQuerydslRepository {
@@ -10,4 +11,5 @@ interface JpaUserBookRepository : JpaRepository, JpaUserBo
fun existsByIdAndUserId(id: UUID, userId: UUID): Boolean
fun findAllByUserId(userId: UUID): List
fun findAllByUserIdAndBookIsbn13In(userId: UUID, bookIsbn13s: List): List
+ fun findByUserIdAndCreatedAtAfter(userId: UUID, createdAt: LocalDateTime): List
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/JpaUserBookQuerydslRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/JpaUserBookQuerydslRepositoryImpl.kt
index ce487e6c..c2634766 100644
--- a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/JpaUserBookQuerydslRepositoryImpl.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/JpaUserBookQuerydslRepositoryImpl.kt
@@ -164,7 +164,9 @@ class JpaUserBookQuerydslRepositoryImpl(
UserBookSortType.TITLE_DESC -> userBook.title.desc()
UserBookSortType.CREATED_DATE_ASC -> userBook.createdAt.asc()
UserBookSortType.CREATED_DATE_DESC -> userBook.createdAt.desc()
- null -> userBook.createdAt.desc()
+ UserBookSortType.UPDATED_DATE_ASC -> userBook.updatedAt.asc()
+ UserBookSortType.UPDATED_DATE_DESC -> userBook.updatedAt.desc()
+ null -> userBook.updatedAt.desc()
}
}
}
diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt
index 262a6966..14fbae12 100644
--- a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt
+++ b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt
@@ -86,5 +86,7 @@ class UserBookRepositoryImpl(
return entities.map { it.toDomain() }
}
-
+ override fun findByUserIdAndCreatedAtAfter(userId: UUID, after: LocalDateTime): List {
+ return jpaUserBookRepository.findByUserIdAndCreatedAtAfter(userId, after).map { it.toDomain() }
+ }
}
diff --git a/infra/src/main/resources/application-persistence.yml b/infra/src/main/resources/application-persistence.yml
index e4a3398b..941a12b2 100644
--- a/infra/src/main/resources/application-persistence.yml
+++ b/infra/src/main/resources/application-persistence.yml
@@ -8,7 +8,7 @@ spring:
jpa:
database-platform: org.hibernate.dialect.MySQL8Dialect
hibernate:
- ddl-auto: none
+ ddl-auto: validate
show-sql: true
open-in-view: false
properties:
@@ -31,10 +31,10 @@ spring:
jpa:
hibernate:
- ddl-auto: validate
+ ddl-auto: update
flyway:
- enabled: true
+ enabled: false
baseline-on-migrate: false
locations:
- classpath:db/migration/mysql
diff --git a/infra/src/main/resources/db/migration/mysql/V20251025_001__make_review_column_nullable.sql b/infra/src/main/resources/db/migration/mysql/V20251025_001__make_review_column_nullable.sql
new file mode 100644
index 00000000..da45215c
--- /dev/null
+++ b/infra/src/main/resources/db/migration/mysql/V20251025_001__make_review_column_nullable.sql
@@ -0,0 +1 @@
+ALTER TABLE reading_records MODIFY COLUMN review VARCHAR(1000) NULL;
\ No newline at end of file
diff --git a/infra/src/main/resources/db/migration/mysql/V20251115_001__add_notification_and_device_tables.sql b/infra/src/main/resources/db/migration/mysql/V20251115_001__add_notification_and_device_tables.sql
new file mode 100644
index 00000000..52ff3612
--- /dev/null
+++ b/infra/src/main/resources/db/migration/mysql/V20251115_001__add_notification_and_device_tables.sql
@@ -0,0 +1,43 @@
+-- Add notification_enabled and last_activity columns to users table
+ALTER TABLE users
+ ADD COLUMN notification_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '์๋ฆผ ์์ ๋์ ์ฌ๋ถ',
+ ADD COLUMN last_activity DATETIME(6) NULL COMMENT '๋ง์ง๋ง ํ๋ ์๊ฐ';
+
+-- Create device table for multi-device push notification support
+CREATE TABLE device
+(
+ id VARCHAR(36) NOT NULL COMMENT '๋๋ฐ์ด์ค ID',
+ created_at DATETIME(6) NOT NULL COMMENT '์์ฑ ์๊ฐ',
+ updated_at DATETIME(6) NOT NULL COMMENT '์์ ์๊ฐ',
+ user_id VARCHAR(36) NOT NULL COMMENT '์ฌ์ฉ์ ID',
+ device_id VARCHAR(255) NOT NULL COMMENT '๋๋ฐ์ด์ค ๊ณ ์ ID',
+ fcm_token VARCHAR(255) NOT NULL COMMENT 'FCM ํ ํฐ',
+ CONSTRAINT pk_device PRIMARY KEY (id)
+) COMMENT '์ฌ์ฉ์ ๋๋ฐ์ด์ค ์ ๋ณด';
+
+-- Create notification table
+CREATE TABLE notification
+(
+ id VARCHAR(36) NOT NULL COMMENT '์๋ฆผ ID',
+ created_at DATETIME(6) NOT NULL COMMENT '์์ฑ ์๊ฐ',
+ updated_at DATETIME(6) NOT NULL COMMENT '์์ ์๊ฐ',
+ user_id VARCHAR(36) NOT NULL COMMENT '์ฌ์ฉ์ ID',
+ title VARCHAR(255) NOT NULL COMMENT '์๋ฆผ ์ ๋ชฉ',
+ message VARCHAR(1000) NOT NULL COMMENT '์๋ฆผ ๋ฉ์์ง',
+ notification_type ENUM ('UNRECORDED', 'DORMANT') NOT NULL COMMENT '์๋ฆผ ํ์
',
+ is_read BOOLEAN NOT NULL DEFAULT FALSE COMMENT '์ฝ์ ์ฌ๋ถ',
+ is_sent BOOLEAN NOT NULL DEFAULT FALSE COMMENT '์ ์ก ์ฌ๋ถ',
+ sent_at DATETIME(6) NULL COMMENT '์ ์ก ์๊ฐ',
+ CONSTRAINT pk_notification PRIMARY KEY (id)
+) COMMENT '์ฌ์ฉ์ ์๋ฆผ ์ ๋ณด';
+
+-- Create indexes for actual query usage only
+CREATE INDEX idx_device_user_id ON device (user_id);
+CREATE INDEX idx_device_device_id ON device (device_id);
+CREATE INDEX idx_device_fcm_token ON device (fcm_token);
+
+CREATE INDEX idx_notification_user_id ON notification (user_id);
+CREATE INDEX idx_notification_is_sent ON notification (is_sent);
+
+-- Composite index for: WHERE last_activity < ? AND notification_enabled = true
+CREATE INDEX idx_users_last_activity_notification ON users (last_activity, notification_enabled);
diff --git a/observability/build.gradle.kts b/observability/build.gradle.kts
new file mode 100644
index 00000000..3437d36a
--- /dev/null
+++ b/observability/build.gradle.kts
@@ -0,0 +1,21 @@
+import org.springframework.boot.gradle.tasks.bundling.BootJar
+
+dependencies {
+ // Web & Filter
+ implementation(Dependencies.Spring.BOOT_STARTER_WEB)
+
+ // Metrics & Monitoring
+ implementation(Dependencies.Spring.BOOT_STARTER_ACTUATOR)
+ implementation(Dependencies.Prometheus.MICROMETER_PROMETHEUS_REGISTRY)
+
+ // Logging
+ implementation(Dependencies.Logging.KOTLIN_LOGGING)
+
+ // Test
+ testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)
+}
+
+tasks {
+ withType { enabled = true }
+ withType { enabled = false }
+}
diff --git a/observability/src/main/kotlin/org/yapp/observability/logging/filter/BaseMdcLoggingFilter.kt b/observability/src/main/kotlin/org/yapp/observability/logging/filter/BaseMdcLoggingFilter.kt
new file mode 100644
index 00000000..d0469581
--- /dev/null
+++ b/observability/src/main/kotlin/org/yapp/observability/logging/filter/BaseMdcLoggingFilter.kt
@@ -0,0 +1,95 @@
+package org.yapp.observability.logging.filter
+
+import jakarta.servlet.FilterChain
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.slf4j.MDC
+import org.springframework.web.filter.OncePerRequestFilter
+import java.util.*
+
+/**
+ * MDC (Mapped Diagnostic Context) ๊ธฐ๋ฐ ๋ก๊น
ํํฐ์ ๊ธฐ๋ณธ ๊ตฌํ
+ *
+ * ์ด ํํฐ๋ ๋ชจ๋ HTTP ์์ฒญ์ ๋ํด ๋ค์ ์ ๋ณด๋ฅผ MDC์ ์ถ๊ฐํฉ๋๋ค:
+ * - traceId: ์์ฒญ ์ถ์ ID (X-Request-ID ํค๋์์ ๊ฐ์ ธ์ค๊ฑฐ๋ ์๋ ์์ฑ)
+ * - clientIp: ํด๋ผ์ด์ธํธ IP (X-Forwarded-For, X-Real-IP ํค๋ ๊ณ ๋ ค)
+ * - requestInfo: HTTP ๋ฉ์๋์ URI
+ * - userId: ์ฌ์ฉ์ ID (์๋ธํด๋์ค์์ ๊ตฌํ)
+ *
+ * ์๋ธํด๋์ค๋ resolveUserId()๋ฅผ ์ค๋ฒ๋ผ์ด๋ํ์ฌ ์ฌ์ฉ์ ID ์ถ์ถ ๋ก์ง์ ์ ๊ณตํ ์ ์์ต๋๋ค.
+ */
+abstract class BaseMdcLoggingFilter : OncePerRequestFilter() {
+ companion object {
+ const val TRACE_ID_HEADER = "X-Request-ID"
+ const val XFF_HEADER = "X-Forwarded-For"
+ const val X_REAL_IP_HEADER = "X-Real-IP"
+ const val TRACE_ID_KEY = "traceId"
+ const val USER_ID_KEY = "userId"
+ const val CLIENT_IP_KEY = "clientIp"
+ const val REQUEST_INFO_KEY = "requestInfo"
+ const val DEFAULT_GUEST_USER = "GUEST"
+ }
+
+ override fun doFilterInternal(
+ request: HttpServletRequest,
+ response: HttpServletResponse,
+ filterChain: FilterChain
+ ) {
+ val traceId = resolveTraceId(request)
+ populateMdc(request, traceId)
+
+ try {
+ filterChain.doFilter(request, response)
+ } finally {
+ MDC.clear()
+ }
+ }
+
+ /**
+ * ์์ฒญ์์ TraceId๋ฅผ ์ถ์ถํ๊ฑฐ๋ ์์ฑํฉ๋๋ค.
+ * X-Request-ID ํค๋๊ฐ ์์ผ๋ฉด ์ฌ์ฉํ๊ณ , ์์ผ๋ฉด ์๋ก ์์ฑํฉ๋๋ค.
+ */
+ private fun resolveTraceId(request: HttpServletRequest): String {
+ val incomingTraceId = request.getHeader(TRACE_ID_HEADER)
+ return incomingTraceId?.takeIf { it.isNotBlank() }
+ ?: UUID.randomUUID().toString().replace("-", "")
+ }
+
+ /**
+ * MDC์ ๋ก๊น
์ปจํ
์คํธ ์ ๋ณด๋ฅผ ์ถ๊ฐํฉ๋๋ค.
+ */
+ private fun populateMdc(request: HttpServletRequest, traceId: String) {
+ MDC.put(TRACE_ID_KEY, traceId)
+ MDC.put(CLIENT_IP_KEY, extractClientIp(request))
+ MDC.put(REQUEST_INFO_KEY, "${request.method} ${request.requestURI}")
+
+ val userId = resolveUserId()
+ MDC.put(USER_ID_KEY, userId ?: DEFAULT_GUEST_USER)
+ }
+
+ /**
+ * ํด๋ผ์ด์ธํธ์ ์ค์ IP ์ฃผ์๋ฅผ ์ถ์ถํฉ๋๋ค.
+ * X-Forwarded-For, X-Real-IP ํค๋๋ฅผ ์ฐ์ ํ์ธํ๊ณ , ์์ผ๋ฉด remoteAddr ์ฌ์ฉํฉ๋๋ค.
+ */
+ private fun extractClientIp(request: HttpServletRequest): String {
+ val xffHeader = request.getHeader(XFF_HEADER)
+ if (!xffHeader.isNullOrBlank()) {
+ return xffHeader.split(",").first().trim()
+ }
+
+ val xRealIp = request.getHeader(X_REAL_IP_HEADER)
+ if (!xRealIp.isNullOrBlank()) {
+ return xRealIp.trim()
+ }
+
+ return request.remoteAddr
+ }
+
+ /**
+ * ์ฌ์ฉ์ ID๋ฅผ ์ถ์ถํฉ๋๋ค.
+ * ์๋ธํด๋์ค์์ ์ค๋ฒ๋ผ์ด๋ํ์ฌ Security Context, JWT ๋ฑ์์ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์ถ์ถํ ์ ์์ต๋๋ค.
+ *
+ * @return ์ฌ์ฉ์ ID (null์ธ ๊ฒฝ์ฐ GUEST๋ก ์ฒ๋ฆฌ๋จ)
+ */
+ protected abstract fun resolveUserId(): String?
+}
diff --git a/observability/src/main/kotlin/org/yapp/observability/logging/filter/SimpleMdcLoggingFilter.kt b/observability/src/main/kotlin/org/yapp/observability/logging/filter/SimpleMdcLoggingFilter.kt
new file mode 100644
index 00000000..c984fa95
--- /dev/null
+++ b/observability/src/main/kotlin/org/yapp/observability/logging/filter/SimpleMdcLoggingFilter.kt
@@ -0,0 +1,18 @@
+package org.yapp.observability.logging.filter
+
+import org.springframework.stereotype.Component
+
+/**
+ * ์ธ์ฆ์ด ํ์ ์๋ ํ๊ฒฝ์์ ์ฌ์ฉํ๋ ๊ธฐ๋ณธ MDC ๋ก๊น
ํํฐ
+ *
+ * ์ด ํํฐ๋ ์ฌ์ฉ์ ID๋ฅผ ์ถ์ถํ์ง ์๊ณ ๋ชจ๋ ์์ฒญ์ GUEST๋ก ์ฒ๋ฆฌํฉ๋๋ค.
+ * Batch ์ ํ๋ฆฌ์ผ์ด์
์ด๋ ์ธ์ฆ์ด ์๋ ๋ด๋ถ ์๋น์ค์ ์ฌ์ฉ๋ฉ๋๋ค.
+ */
+@Component
+class SimpleMdcLoggingFilter : BaseMdcLoggingFilter() {
+ /**
+ * ์ธ์ฆ ์ ๋ณด๊ฐ ์์ผ๋ฏ๋ก null์ ๋ฐํํฉ๋๋ค.
+ * MDC์๋ GUEST๋ก ๊ธฐ๋ก๋ฉ๋๋ค.
+ */
+ override fun resolveUserId(): String? = null
+}
diff --git a/gateway/src/main/kotlin/org/yapp/gateway/config/ActuatorProperties.kt b/observability/src/main/kotlin/org/yapp/observability/metrics/config/ActuatorProperties.kt
similarity index 81%
rename from gateway/src/main/kotlin/org/yapp/gateway/config/ActuatorProperties.kt
rename to observability/src/main/kotlin/org/yapp/observability/metrics/config/ActuatorProperties.kt
index 0a381e73..41728f80 100644
--- a/gateway/src/main/kotlin/org/yapp/gateway/config/ActuatorProperties.kt
+++ b/observability/src/main/kotlin/org/yapp/observability/metrics/config/ActuatorProperties.kt
@@ -1,4 +1,4 @@
-package org.yapp.gateway.config
+package org.yapp.observability.metrics.config
import org.springframework.boot.context.properties.ConfigurationProperties
diff --git a/gateway/src/main/resources/application-web.yml b/observability/src/main/resources/application-observability.yml
similarity index 96%
rename from gateway/src/main/resources/application-web.yml
rename to observability/src/main/resources/application-observability.yml
index 3923ec3b..7e659010 100644
--- a/gateway/src/main/resources/application-web.yml
+++ b/observability/src/main/resources/application-observability.yml
@@ -18,7 +18,7 @@ spring:
management:
server:
- port: 8081
+ port: 1234
endpoints:
jmx:
exposure:
diff --git a/settings.gradle.kts b/settings.gradle.kts
index c7847254..95b6b7ab 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -7,5 +7,6 @@ include(
"batch",
"domain",
"infra",
- "global-utils"
+ "global-utils",
+ "observability"
)