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 ๋ฌธ์žฅ๊ณผ ๊ฐ์ •์„ ํ•จ๊ป˜ ๋‹ด๋Š” ๋…์„œ ๊ธฐ๋ก + + +reed_graphic + + + + + +

+ + + +

+

+ + +

+ +## Features +| ํ™ˆ | ๋„์„œ ๊ฒ€์ƒ‰ ๋ฐ ๋“ฑ๋ก | ๋‚ด์„œ์žฌ | +|:---:|:---:|:---:| +| ํ™ˆ | ๋„์„œ ๊ฒ€์ƒ‰ ๋ฐ ๋“ฑ๋ก | ๋‚ด์„œ์žฌ | + +| OCR | ๊ธฐ๋ก ๋“ฑ๋ก | ๋„์„œ & ๊ธฐ๋ก ์ƒ์„ธ | +|:---:|:---:|:---:| +| 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" )