From 4a7158b072696072a1dd879edb1a2ecd16bf9c3c Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 24 Dec 2025 11:47:58 +0900 Subject: [PATCH 01/17] =?UTF-8?q?refactor:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EB=B0=8F=20Dockerfile=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy_v2_admin_api.yml | 89 ++----- .github/workflows/deploy_v2_development.yml | 235 ++++++++++++++++++ .../deploy_v2_development_product_api.yml | 129 ---------- .../deploy_v2_release_product_api.yml | 20 ++ Dockerfile | 35 +-- Dockerfile-admin | 34 +-- 6 files changed, 284 insertions(+), 258 deletions(-) create mode 100644 .github/workflows/deploy_v2_development.yml delete mode 100644 .github/workflows/deploy_v2_development_product_api.yml diff --git a/.github/workflows/deploy_v2_admin_api.yml b/.github/workflows/deploy_v2_admin_api.yml index 0f1f504a8..a117e219d 100644 --- a/.github/workflows/deploy_v2_admin_api.yml +++ b/.github/workflows/deploy_v2_admin_api.yml @@ -1,4 +1,4 @@ -name: deploy v2 admin api +name: deploy v2 admin api production on: workflow_dispatch: @@ -8,7 +8,7 @@ on: - 'release/admin' concurrency: - group: "deploy-admin" + group: "deploy-admin-production" cancel-in-progress: true env: @@ -31,6 +31,22 @@ jobs: submodules: true token: ${{ secrets.GIT_ACCESS_TOKEN }} + - name: setup jdk + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: setup gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: read admin version id: version run: | @@ -39,6 +55,10 @@ jobs: echo "image-tag=admin_${VERSION}" >> $GITHUB_OUTPUT echo "Admin version: $VERSION" + - name: build admin module + run: | + ./gradlew :bottlenote-admin-api:build -x test -x asciidoctor --build-cache --parallel + - name: build and push to registry id: build uses: ./.github/actions/docker-build-push @@ -52,7 +72,7 @@ jobs: dockerfile: Dockerfile-admin context: . platforms: linux/arm64 - cache-scope: admin + cache-scope: admin-production image-title: "BottleNote Admin API" image-description: "BottleNote Admin API Server" image-vendor: "BottleNote" @@ -60,65 +80,6 @@ jobs: sign-image: 'true' cosign-key-path: 'git.environment-variables/storage/docker-registry/cosign.key' cosign-password: ${{ secrets.COSIGN_PASSWORD }} - update-development: - needs: build-and-push - runs-on: ubuntu-latest - steps: - - name: checkout code with submodules - uses: actions/checkout@v4 - with: - submodules: true - token: ${{ secrets.GIT_ACCESS_TOKEN }} - - name: setup kustomize - uses: imranismail/setup-kustomize@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - name: update development image tag - run: | - IMAGE_TAG="${{ needs.build-and-push.outputs.image-tag }}" - REGISTRY="${{ secrets.REGISTRY_ADDRESS }}" - - echo "Updating development image tag: $IMAGE_TAG" - - cd git.environment-variables - git checkout main - - cd deploy/overlays/development - kustomize edit set image \ - bottlenote-admin-api=${REGISTRY}/${{ env.IMAGE_NAME }}:${IMAGE_TAG} - - echo "development kustomization.yaml updated" - - - name: commit and push with retry - run: | - cd git.environment-variables - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add deploy/overlays/development/kustomization.yaml - - # 변경사항 있을 때만 커밋 - if git diff --staged --quiet; then - echo "No changes to commit" - exit 0 - fi - - git commit -m "chore(admin): update development image tag to ${{ needs.build-and-push.outputs.image-tag }} - - Updated by GitHub Actions - Commit: ${{ github.sha }} - Branch: ${{ github.ref_name }} - Workflow: ${{ github.workflow }}" - - # 재시도 로직 (최대 5회) - for i in {1..5}; do - git pull --rebase origin main && git push origin main && break - echo "Push failed, retry $i/5..." - sleep $((i * 2)) - done - - echo "Development deployment triggered" update-production: needs: build-and-push @@ -129,10 +90,12 @@ jobs: with: submodules: true token: ${{ secrets.GIT_ACCESS_TOKEN }} + - name: setup kustomize uses: imranismail/setup-kustomize@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} + - name: update production image tag run: | IMAGE_TAG="${{ needs.build-and-push.outputs.image-tag }}" @@ -158,7 +121,6 @@ jobs: git add deploy/overlays/production/kustomization.yaml - # 변경사항 있을 때만 커밋 if git diff --staged --quiet; then echo "No changes to commit" exit 0 @@ -171,7 +133,6 @@ jobs: Branch: ${{ github.ref_name }} Workflow: ${{ github.workflow }}" - # 재시도 로직 (최대 5회) for i in {1..5}; do git pull --rebase origin main && git push origin main && break echo "Push failed, retry $i/5..." diff --git a/.github/workflows/deploy_v2_development.yml b/.github/workflows/deploy_v2_development.yml new file mode 100644 index 000000000..99f55b316 --- /dev/null +++ b/.github/workflows/deploy_v2_development.yml @@ -0,0 +1,235 @@ +name: deploy v2 development + +on: + workflow_dispatch: + workflow_run: + workflows: [ "product ci pipeline" ] + types: + - completed + branches: + - main + +concurrency: + group: "deploy-development" + cancel-in-progress: true + +jobs: + # ============================================ + # 1단계: Gradle 빌드 및 JAR Artifact 업로드 + # ============================================ + prepare-build: + runs-on: ubuntu-24.04-arm + outputs: + product-version: ${{ steps.versions.outputs.product-version }} + product-image-tag: ${{ steps.versions.outputs.product-image-tag }} + admin-version: ${{ steps.versions.outputs.admin-version }} + admin-image-tag: ${{ steps.versions.outputs.admin-image-tag }} + steps: + - name: checkout code with submodules + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.GIT_ACCESS_TOKEN }} + + - name: setup jdk + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: setup gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: read versions + id: versions + run: | + PRODUCT_VERSION=$(cat bottlenote-product-api/VERSION | tr -d '[:space:]') + ADMIN_VERSION=$(cat bottlenote-admin-api/VERSION | tr -d '[:space:]') + echo "product-version=$PRODUCT_VERSION" >> $GITHUB_OUTPUT + echo "product-image-tag=product_${PRODUCT_VERSION}_development" >> $GITHUB_OUTPUT + echo "admin-version=$ADMIN_VERSION" >> $GITHUB_OUTPUT + echo "admin-image-tag=admin_${ADMIN_VERSION}_development" >> $GITHUB_OUTPUT + echo "Product version: $PRODUCT_VERSION" + echo "Admin version: $ADMIN_VERSION" + + - name: build both modules + run: | + ./gradlew :bottlenote-product-api:build :bottlenote-admin-api:build \ + -x test -x asciidoctor --build-cache --parallel + + - name: upload product jar + uses: actions/upload-artifact@v4 + with: + name: product-jar + path: bottlenote-product-api/build/libs/bottlenote-product-api.jar + retention-days: 1 + + - name: upload admin jar + uses: actions/upload-artifact@v4 + with: + name: admin-jar + path: bottlenote-admin-api/build/libs/bottlenote-admin-api.jar + retention-days: 1 + + # ============================================ + # 2단계: Product Docker 이미지 빌드 (병렬) + # ============================================ + build-product-image: + needs: prepare-build + runs-on: ubuntu-24.04-arm + outputs: + image-digest: ${{ steps.build.outputs.image-digest }} + steps: + - name: checkout code with submodules + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.GIT_ACCESS_TOKEN }} + + - name: download product jar + uses: actions/download-artifact@v4 + with: + name: product-jar + path: bottlenote-product-api/build/libs/ + + - name: build and push to registry + id: build + uses: ./.github/actions/docker-build-push + with: + registry-url: ${{ secrets.REGISTRY_ADDRESS }} + registry-username: ${{ secrets.REGISTRY_USERNAME }} + registry-password: ${{ secrets.REGISTRY_PASSWORD }} + image-name: bottlenote-product-api + image-tag: ${{ needs.prepare-build.outputs.product-image-tag }} + additional-tags: product_latest_development + dockerfile: Dockerfile + context: . + platforms: linux/arm64 + cache-scope: product-development + image-title: "BottleNote Product API" + image-description: "BottleNote Product API Server (Development)" + image-vendor: "BottleNote" + image-authors: "BottleNote Team" + sign-image: 'true' + cosign-key-path: 'git.environment-variables/storage/docker-registry/cosign.key' + cosign-password: ${{ secrets.COSIGN_PASSWORD }} + + # ============================================ + # 2단계: Admin Docker 이미지 빌드 (병렬) + # ============================================ + build-admin-image: + needs: prepare-build + runs-on: ubuntu-24.04-arm + outputs: + image-digest: ${{ steps.build.outputs.image-digest }} + steps: + - name: checkout code with submodules + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.GIT_ACCESS_TOKEN }} + + - name: download admin jar + uses: actions/download-artifact@v4 + with: + name: admin-jar + path: bottlenote-admin-api/build/libs/ + + - name: build and push to registry + id: build + uses: ./.github/actions/docker-build-push + with: + registry-url: ${{ secrets.REGISTRY_ADDRESS }} + registry-username: ${{ secrets.REGISTRY_USERNAME }} + registry-password: ${{ secrets.REGISTRY_PASSWORD }} + image-name: bottlenote-admin-api + image-tag: ${{ needs.prepare-build.outputs.admin-image-tag }} + additional-tags: admin_latest_development + dockerfile: Dockerfile-admin + context: . + platforms: linux/arm64 + cache-scope: admin-development + image-title: "BottleNote Admin API" + image-description: "BottleNote Admin API Server (Development)" + image-vendor: "BottleNote" + image-authors: "BottleNote Team" + sign-image: 'true' + cosign-key-path: 'git.environment-variables/storage/docker-registry/cosign.key' + cosign-password: ${{ secrets.COSIGN_PASSWORD }} + + # ============================================ + # 3단계: Development 환경 업데이트 + # ============================================ + update-development: + needs: [prepare-build, build-product-image, build-admin-image] + runs-on: ubuntu-latest + steps: + - name: checkout code with submodules + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.GIT_ACCESS_TOKEN }} + + - name: setup kustomize + uses: imranismail/setup-kustomize@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: update development image tags + run: | + PRODUCT_TAG="${{ needs.prepare-build.outputs.product-image-tag }}" + ADMIN_TAG="${{ needs.prepare-build.outputs.admin-image-tag }}" + REGISTRY="${{ secrets.REGISTRY_ADDRESS }}" + + echo "Updating development image tags:" + echo " Product: $PRODUCT_TAG" + echo " Admin: $ADMIN_TAG" + + cd git.environment-variables + git checkout main + + cd deploy/overlays/development + kustomize edit set image \ + bottlenote-product-api=${REGISTRY}/bottlenote-product-api:${PRODUCT_TAG} \ + bottlenote-admin-api=${REGISTRY}/bottlenote-admin-api:${ADMIN_TAG} + + echo "development kustomization.yaml updated" + + - name: commit and push with retry + run: | + cd git.environment-variables + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add deploy/overlays/development/kustomization.yaml + + if git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + + git commit -m "chore(dev): update development image tags + + Product: ${{ needs.prepare-build.outputs.product-image-tag }} + Admin: ${{ needs.prepare-build.outputs.admin-image-tag }} + + Updated by GitHub Actions + Commit: ${{ github.sha }} + Branch: ${{ github.ref_name }} + Workflow: ${{ github.workflow }}" + + for i in {1..5}; do + git pull --rebase origin main && git push origin main && break + echo "Push failed, retry $i/5..." + sleep $((i * 2)) + done + + echo "Development deployment triggered" diff --git a/.github/workflows/deploy_v2_development_product_api.yml b/.github/workflows/deploy_v2_development_product_api.yml deleted file mode 100644 index 6d5c99614..000000000 --- a/.github/workflows/deploy_v2_development_product_api.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: deploy v2 development product api - -on: - workflow_dispatch: - workflow_run: - workflows: [ "product ci pipeline" ] - types: - - completed - branches: - - main - #push: - # branches: - # - main - # paths: - # - 'bottlenote-batch/**' - # - 'bottlenote-mono/**' - # - 'bottlenote-observability/**' - # - 'bottlenote-product-api/**' - -concurrency: - group: "deploy-product" - cancel-in-progress: true - -env: - IMAGE_NAME: bottlenote-product-api - -jobs: - build-and-push: - runs-on: ubuntu-24.04-arm - outputs: - version: ${{ steps.version.outputs.version }} - image-tag: ${{ steps.version.outputs.image-tag }} - image-digest: ${{ steps.build.outputs.image-digest }} - steps: - - name: checkout code with submodules - uses: actions/checkout@v4 - with: - submodules: true - token: ${{ secrets.GIT_ACCESS_TOKEN }} - - - name: read product version - id: version - run: | - VERSION=$(cat bottlenote-product-api/VERSION | tr -d '[:space:]') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "image-tag=product_${VERSION}_development" >> $GITHUB_OUTPUT - echo "Product version: $VERSION" - - - name: build and push to registry - id: build - uses: ./.github/actions/docker-build-push - with: - registry-url: ${{ secrets.REGISTRY_ADDRESS }} - registry-username: ${{ secrets.REGISTRY_USERNAME }} - registry-password: ${{ secrets.REGISTRY_PASSWORD }} - image-name: ${{ env.IMAGE_NAME }} - image-tag: product_${{ steps.version.outputs.version }}_development - additional-tags: product_latest - dockerfile: Dockerfile - context: . - platforms: linux/arm64 - cache-scope: product - image-title: "BottleNote Product API" - image-description: "BottleNote Product API Server" - image-vendor: "BottleNote" - image-authors: "BottleNote Team" - sign-image: 'true' - cosign-key-path: 'git.environment-variables/storage/docker-registry/cosign.key' - cosign-password: ${{ secrets.COSIGN_PASSWORD }} - - update-development: - needs: build-and-push - runs-on: ubuntu-latest - steps: - - name: checkout code with submodules - uses: actions/checkout@v4 - with: - submodules: true - token: ${{ secrets.GIT_ACCESS_TOKEN }} - - - name: setup kustomize - uses: imranismail/setup-kustomize@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: update development image tag - run: | - IMAGE_TAG="${{ needs.build-and-push.outputs.image-tag }}" - REGISTRY="${{ secrets.REGISTRY_ADDRESS }}" - - echo "Updating development image tag: $IMAGE_TAG" - - cd git.environment-variables - git checkout main - - cd deploy/overlays/development - kustomize edit set image \ - bottlenote-product-api=${REGISTRY}/${{ env.IMAGE_NAME }}:${IMAGE_TAG} - - echo "development kustomization.yaml updated" - - - name: commit and push with retry - run: | - cd git.environment-variables - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add deploy/overlays/development/kustomization.yaml - - if git diff --staged --quiet; then - echo "No changes to commit" - exit 0 - fi - - git commit -m "chore(product): update development image tag to ${{ needs.build-and-push.outputs.image-tag }} - - Updated by GitHub Actions - Commit: ${{ github.sha }} - Branch: ${{ github.ref_name }} - Workflow: ${{ github.workflow }}" - - for i in {1..5}; do - git pull --rebase origin main && git push origin main && break - echo "Push failed, retry $i/5..." - sleep $((i * 2)) - done - - echo "Development deployment triggered" diff --git a/.github/workflows/deploy_v2_release_product_api.yml b/.github/workflows/deploy_v2_release_product_api.yml index ab6f7c663..c2ace22f7 100644 --- a/.github/workflows/deploy_v2_release_product_api.yml +++ b/.github/workflows/deploy_v2_release_product_api.yml @@ -31,6 +31,22 @@ jobs: submodules: true token: ${{ secrets.GIT_ACCESS_TOKEN }} + - name: setup jdk + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: setup gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: read product version id: version run: | @@ -39,6 +55,10 @@ jobs: echo "image-tag=product_${VERSION}" >> $GITHUB_OUTPUT echo "Product version: $VERSION" + - name: build product module + run: | + ./gradlew :bottlenote-product-api:build -x test -x asciidoctor --build-cache --parallel + - name: build and push to registry id: build uses: ./.github/actions/docker-build-push diff --git a/Dockerfile b/Dockerfile index aebc76d86..4d8f7e2e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,6 @@ -# 빌드 스테이지 -FROM eclipse-temurin:21-jdk AS builder -WORKDIR /app - -# Gradle wrapper 및 설정 파일 복사 (의존성 캐싱 최적화) -COPY gradlew gradlew.bat gradle.properties settings.gradle build.gradle ./ -COPY gradle ./gradle - -# 각 모듈의 build.gradle 복사 -COPY bottlenote-mono/build.gradle bottlenote-mono/ -COPY bottlenote-product-api/build.gradle bottlenote-product-api/ -COPY bottlenote-admin-api/build.gradle.kts bottlenote-admin-api/ -COPY bottlenote-batch/build.gradle bottlenote-batch/ - -# 의존성만 다운로드 (이 레이어가 캐시됨!) -RUN ./gradlew dependencies --no-daemon || true - -# 소스 코드 복사 -COPY . . - -# 애플리케이션 빌드 -RUN ./gradlew build -x test -x asciidoctor --build-cache --parallel - -# 실행 스테이지 FROM eclipse-temurin:21-jre WORKDIR /app -# 빌드 메타데이터 (GitHub Actions에서 주입) ARG GIT_COMMIT=unknown ARG GIT_BRANCH=unknown ARG BUILD_TIME=unknown @@ -33,19 +8,13 @@ ARG BUILD_TIME=unknown ENV GIT_COMMIT=${GIT_COMMIT} ENV GIT_BRANCH=${GIT_BRANCH} ENV BUILD_TIME=${BUILD_TIME} - -# 시간대 설정 ENV TZ=Asia/Seoul -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -# 설정 파일 디렉토리 생성 +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN mkdir -p config -# 빌드 스테이지에서 생성된 JAR 파일만 복사 (product-api 모듈의 실행 가능한 JAR) -COPY --from=builder /app/bottlenote-product-api/build/libs/bottlenote-product-api.jar /app.jar +COPY bottlenote-product-api/build/libs/bottlenote-product-api.jar /app.jar -# 환경 변수로 프로필 지정 가능하도록 설정 ENV SPRING_PROFILES_ACTIVE=default -# 실행 시 .env.dev 파일 로드 ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/Dockerfile-admin b/Dockerfile-admin index 7a4e991b8..5383f95b7 100644 --- a/Dockerfile-admin +++ b/Dockerfile-admin @@ -1,31 +1,6 @@ -# 빌드 스테이지 -FROM eclipse-temurin:21-jdk AS builder -WORKDIR /app - -# Gradle wrapper 및 설정 파일 복사 (의존성 캐싱 최적화) -COPY gradlew gradlew.bat gradle.properties settings.gradle build.gradle ./ -COPY gradle ./gradle - -# 각 모듈의 build.gradle 복사 -COPY bottlenote-mono/build.gradle bottlenote-mono/ -COPY bottlenote-product-api/build.gradle bottlenote-product-api/ -COPY bottlenote-admin-api/build.gradle.kts bottlenote-admin-api/ -COPY bottlenote-batch/build.gradle bottlenote-batch/ - -# 의존성만 다운로드 (이 레이어가 캐시됨) -RUN ./gradlew dependencies --no-daemon || true - -# 소스 코드 복사 -COPY . . - -# 애플리케이션 빌드 -RUN ./gradlew :bottlenote-admin-api:build -x test -x asciidoctor --build-cache --parallel - -# 실행 스테이지 FROM eclipse-temurin:21-jre WORKDIR /app -# 빌드 메타데이터 (GitHub Actions에서 주입) ARG GIT_COMMIT=unknown ARG GIT_BRANCH=unknown ARG BUILD_TIME=unknown @@ -33,18 +8,13 @@ ARG BUILD_TIME=unknown ENV GIT_COMMIT=${GIT_COMMIT} ENV GIT_BRANCH=${GIT_BRANCH} ENV BUILD_TIME=${BUILD_TIME} - -# 시간대 설정 ENV TZ=Asia/Seoul -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -# 설정 파일 디렉토리 생성 +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN mkdir -p config -# 빌드 스테이지에서 생성된 JAR 파일만 복사 (admin-api 모듈의 실행 가능한 JAR) -COPY --from=builder /app/bottlenote-admin-api/build/libs/bottlenote-admin-api.jar /app.jar +COPY bottlenote-admin-api/build/libs/bottlenote-admin-api.jar /app.jar -# 환경 변수로 프로필 지정 가능하도록 설정 ENV SPRING_PROFILES_ACTIVE=default ENTRYPOINT ["java", "-jar", "/app.jar"] From 37e0af87dc4bfc6ee20beddf29293d72b43670de Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 24 Dec 2025 12:07:48 +0900 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=ED=83=9C=EA=B7=B8=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=8B=A8=EC=88=9C=ED=99=94=20=EB=B0=8F=20short-sha=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy_v2_development.yml | 33 +++++---------------- git.environment-variables | 2 +- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/.github/workflows/deploy_v2_development.yml b/.github/workflows/deploy_v2_development.yml index 99f55b316..2c8e1c3d2 100644 --- a/.github/workflows/deploy_v2_development.yml +++ b/.github/workflows/deploy_v2_development.yml @@ -14,15 +14,11 @@ concurrency: cancel-in-progress: true jobs: - # ============================================ - # 1단계: Gradle 빌드 및 JAR Artifact 업로드 - # ============================================ prepare-build: runs-on: ubuntu-24.04-arm outputs: - product-version: ${{ steps.versions.outputs.product-version }} + short-sha: ${{ steps.versions.outputs.short-sha }} product-image-tag: ${{ steps.versions.outputs.product-image-tag }} - admin-version: ${{ steps.versions.outputs.admin-version }} admin-image-tag: ${{ steps.versions.outputs.admin-image-tag }} steps: - name: checkout code with submodules @@ -47,17 +43,14 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: read versions + - name: generate image tags id: versions run: | - PRODUCT_VERSION=$(cat bottlenote-product-api/VERSION | tr -d '[:space:]') - ADMIN_VERSION=$(cat bottlenote-admin-api/VERSION | tr -d '[:space:]') - echo "product-version=$PRODUCT_VERSION" >> $GITHUB_OUTPUT - echo "product-image-tag=product_${PRODUCT_VERSION}_development" >> $GITHUB_OUTPUT - echo "admin-version=$ADMIN_VERSION" >> $GITHUB_OUTPUT - echo "admin-image-tag=admin_${ADMIN_VERSION}_development" >> $GITHUB_OUTPUT - echo "Product version: $PRODUCT_VERSION" - echo "Admin version: $ADMIN_VERSION" + SHORT_SHA="${GITHUB_SHA::7}" + echo "short-sha=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "product-image-tag=product_${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "admin-image-tag=admin_${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "Commit SHA: $SHORT_SHA" - name: build both modules run: | @@ -78,9 +71,6 @@ jobs: path: bottlenote-admin-api/build/libs/bottlenote-admin-api.jar retention-days: 1 - # ============================================ - # 2단계: Product Docker 이미지 빌드 (병렬) - # ============================================ build-product-image: needs: prepare-build runs-on: ubuntu-24.04-arm @@ -121,9 +111,6 @@ jobs: cosign-key-path: 'git.environment-variables/storage/docker-registry/cosign.key' cosign-password: ${{ secrets.COSIGN_PASSWORD }} - # ============================================ - # 2단계: Admin Docker 이미지 빌드 (병렬) - # ============================================ build-admin-image: needs: prepare-build runs-on: ubuntu-24.04-arm @@ -164,9 +151,6 @@ jobs: cosign-key-path: 'git.environment-variables/storage/docker-registry/cosign.key' cosign-password: ${{ secrets.COSIGN_PASSWORD }} - # ============================================ - # 3단계: Development 환경 업데이트 - # ============================================ update-development: needs: [prepare-build, build-product-image, build-admin-image] runs-on: ubuntu-latest @@ -216,14 +200,13 @@ jobs: exit 0 fi - git commit -m "chore(dev): update development image tags + git commit -m "chore(dev): update development image tags (${{ needs.prepare-build.outputs.short-sha }}) Product: ${{ needs.prepare-build.outputs.product-image-tag }} Admin: ${{ needs.prepare-build.outputs.admin-image-tag }} Updated by GitHub Actions Commit: ${{ github.sha }} - Branch: ${{ github.ref_name }} Workflow: ${{ github.workflow }}" for i in {1..5}; do diff --git a/git.environment-variables b/git.environment-variables index b980a67ea..177f6ba67 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit b980a67ea47a960cdf9f7bf91ae104c7c5c1ffa9 +Subproject commit 177f6ba6745a2261008b288e30b87d171e440067 From aa9e4d67fe8d0bf154a75e1556b2c6b2c339f18a Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 24 Dec 2025 12:27:50 +0900 Subject: [PATCH 03/17] =?UTF-8?q?docs:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20API=20RestDocs=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/asciidoc/api/admin-auth/auth.adoc | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc index ab0a57270..f542373fb 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc @@ -48,6 +48,39 @@ include::{snippets}/admin/auth/refresh/http-response.adoc[] ''' +=== 회원가입 === + +- 인증된 관리자가 새로운 관리자 계정을 생성합니다. +- 모든 인증된 어드민이 호출 가능합니다. +- ROOT_ADMIN 역할은 ROOT_ADMIN만 부여할 수 있습니다. + +[source] +---- +POST /admin/api/v1/auth/signup +---- + +[discrete] +==== 요청 헤더 ==== + +[discrete] +include::{snippets}/admin/auth/signup/request-headers.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/auth/signup/request-fields.adoc[] +include::{snippets}/admin/auth/signup/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/auth/signup/response-fields.adoc[] +include::{snippets}/admin/auth/signup/http-response.adoc[] + +''' + === 탈퇴 === - 인증된 관리자 계정을 탈퇴 처리합니다. From 0b183794a2122fe6aebf066b07164790401f87f8 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 24 Dec 2025 12:28:25 +0900 Subject: [PATCH 04/17] =?UTF-8?q?chore:=20CI=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=97=90=EC=84=9C=20release/admin=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EC=A0=9C=EC=99=B8=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/product_ci_pipeline.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/product_ci_pipeline.yml b/.github/workflows/product_ci_pipeline.yml index 02a834ddf..16215a122 100644 --- a/.github/workflows/product_ci_pipeline.yml +++ b/.github/workflows/product_ci_pipeline.yml @@ -3,8 +3,6 @@ name: product ci pipeline on: workflow_dispatch: pull_request: - branches-ignore: - - 'release/admin' concurrency: group: "ci-${{ github.head_ref || github.ref }}" From 78024edf3d4defcad456d1d1f39581aa91815bcc Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 24 Dec 2025 16:19:37 +0900 Subject: [PATCH 05/17] =?UTF-8?q?refactor:=20ROOT=5FADMIN=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=88=ED=87=B4=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ROOT_ADMIN 탈퇴 시 ACCESS_DENIED 예외 발생하도록 수정 - ROOT_ADMIN 초기화 로직을 이메일 기준으로 변경 (existsActiveAdmin -> existsByEmail) - http 폴더를 product/admin으로 분리하여 구조화 - admin 인증 API HTTP 샘플 추가 (로그인, 토큰갱신, 회원가입, 탈퇴) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../auth/persentaton/AuthController.kt | 14 +++---- .../user/service/AdminAuthService.java | 4 ++ git.environment-variables | 2 +- .../\354\235\270\354\246\235.http" | 42 +++++++++++++++++++ http/admin/http-client.env.json | 16 +++++++ ...\353\240\210\352\261\260\354\213\234.http" | 0 ...\355\201\260\353\260\234\352\270\211.http" | 0 ...\354\233\220\352\260\200\354\236\205.http" | 0 .../\354\235\270\354\246\235v2.http" | 0 ...\355\201\260\353\260\234\352\270\211.http" | 0 ...\354\212\244\355\206\240\355\201\260.http" | 0 ...\353\246\254\355\217\254\355\212\270.http" | 0 ...\355\201\260\352\262\200\354\246\235.http" | 0 ...\354\233\220\355\203\210\355\207\264.http" | 0 ...\354\235\264\353\263\264\355\213\200.http" | 0 ...\353\266\200\354\241\260\355\232\214.http" | 0 ...\355\216\230\354\235\264\354\247\200.http" | 0 ...\355\225\204\354\210\230\354\240\225.http" | 0 ...\355\232\214\352\270\260\353\241\235.http" | 0 ...\354\260\234\355\225\230\352\270\260.http" | 0 ...\353\237\254\353\263\264\352\270\260.http" | 0 ...\354\204\270\353\263\264\352\270\260.http" | 0 ...353\246\254_\354\247\200\354\227\255.http" | 0 ...\354\234\204\354\212\244\355\202\244.http" | 0 ...\354\205\230\352\262\200\354\203\211.http" | 0 ...\355\225\251\352\262\200\354\203\211.http" | 0 ...\352\270\200\352\264\200\353\246\254.http" | 0 ...\353\267\260\353\263\264\352\270\260.http" | 0 ...354\240\225_\354\202\255\354\240\234.http" | 0 ...\354\242\213\354\225\204\354\232\224.http" | 0 ...\354\240\220\352\264\200\353\246\254.http" | 0 ...\352\263\240\352\264\200\353\246\254.http" | 0 ...\353\213\250\352\264\200\353\246\254.http" | 0 ...\354\232\260\352\264\200\353\246\254.http" | 0 ...\353\246\254\354\241\260\355\232\214.http" | 0 ...\354\235\230\352\264\200\353\246\254.http" | 0 ...\354\212\244\354\247\200\354\233\220.http" | 0 ...\354\225\261\354\240\225\353\263\264.http" | 0 ...\354\227\205\353\241\234\353\223\234.http" | 0 ...\354\213\234\354\225\214\353\246\274.http" | 0 http/{ => product}/README.md | 0 .../product/_\354\235\270\354\246\235.http" | 0 http/{ => product}/http-client.env.json | 0 43 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 "http/admin/01_\354\235\270\354\246\235/\354\235\270\354\246\235.http" create mode 100644 http/admin/http-client.env.json rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\206\214\354\205\234\355\232\214\354\233\220\352\260\200\354\236\205.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\206\214\354\205\234\355\232\214\354\233\220\352\260\200\354\236\205.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\270\354\246\235v2.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\270\354\246\235v2.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\274\353\260\230\355\206\240\355\201\260\353\260\234\352\270\211.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\274\353\260\230\355\206\240\355\201\260\353\260\234\352\270\211.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\353\224\224\353\260\224\354\235\264\354\212\244\355\206\240\355\201\260.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\353\224\224\353\260\224\354\235\264\354\212\244\355\206\240\355\201\260.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\354\234\240\354\240\200\353\246\254\355\217\254\355\212\270.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\354\234\240\354\240\200\353\246\254\355\217\254\355\212\270.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\206\240\355\201\260\352\262\200\354\246\235.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\206\240\355\201\260\352\262\200\354\246\235.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\232\214\354\233\220\355\203\210\355\207\264.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\232\214\354\233\220\355\203\210\355\207\264.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200\354\204\270\353\266\200\354\241\260\355\232\214.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200\354\204\270\353\266\200\354\241\260\355\232\214.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\355\216\230\354\235\264\354\247\200.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\355\216\230\354\235\264\354\247\200.http" (100%) rename "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\355\224\204\353\241\234\355\225\204\354\210\230\354\240\225.http" => "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\355\224\204\353\241\234\355\225\204\354\210\230\354\240\225.http" (100%) rename "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\241\260\355\232\214\352\270\260\353\241\235.http" => "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\241\260\355\232\214\352\270\260\353\241\235.http" (100%) rename "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\260\234\355\225\230\352\270\260.http" => "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\260\234\355\225\230\352\270\260.http" (100%) rename "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" => "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" (100%) rename "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\203\201\354\204\270\353\263\264\352\270\260.http" => "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\203\201\354\204\270\353\263\264\352\270\260.http" (100%) rename "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\271\264\355\205\214\352\263\240\353\246\254_\354\247\200\354\227\255.http" => "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\271\264\355\205\214\352\263\240\353\246\254_\354\247\200\354\227\255.http" (100%) rename "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\354\235\270\352\270\260\354\234\204\354\212\244\355\202\244.http" => "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\354\235\270\352\270\260\354\234\204\354\212\244\355\202\244.http" (100%) rename "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230\352\262\200\354\203\211.http" => "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230\352\262\200\354\203\211.http" (100%) rename "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\206\265\355\225\251\352\262\200\354\203\211.http" => "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\206\265\355\225\251\352\262\200\354\203\211.http" (100%) rename "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\214\223\352\270\200/\353\214\223\352\270\200\352\264\200\353\246\254.http" => "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\214\223\352\270\200/\353\214\223\352\270\200\352\264\200\353\246\254.http" (100%) rename "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\353\263\264\352\270\260.http" => "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\353\263\264\352\270\260.http" (100%) rename "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\236\221\354\204\261_\354\210\230\354\240\225_\354\202\255\354\240\234.http" => "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\236\221\354\204\261_\354\210\230\354\240\225_\354\202\255\354\240\234.http" (100%) rename "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\242\213\354\225\204\354\232\224.http" => "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\242\213\354\225\204\354\232\224.http" (100%) rename "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\263\204\354\240\220/\353\263\204\354\240\220\352\264\200\353\246\254.http" => "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\263\204\354\240\220/\353\263\204\354\240\220\352\264\200\353\246\254.http" (100%) rename "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\354\213\240\352\263\240/\354\213\240\352\263\240\352\264\200\353\246\254.http" => "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\354\213\240\352\263\240/\354\213\240\352\263\240\352\264\200\353\246\254.http" (100%) rename "http/04_\354\206\214\354\205\234/\354\260\250\353\213\250\352\264\200\353\246\254.http" => "http/product/04_\354\206\214\354\205\234/\354\260\250\353\213\250\352\264\200\353\246\254.http" (100%) rename "http/04_\354\206\214\354\205\234/\355\214\224\353\241\234\354\232\260\352\264\200\353\246\254.http" => "http/product/04_\354\206\214\354\205\234/\355\214\224\353\241\234\354\232\260\352\264\200\353\246\254.http" (100%) rename "http/04_\354\206\214\354\205\234/\355\236\210\354\212\244\355\206\240\353\246\254/\355\236\210\354\212\244\355\206\240\353\246\254\354\241\260\355\232\214.http" => "http/product/04_\354\206\214\354\205\234/\355\236\210\354\212\244\355\206\240\353\246\254/\355\236\210\354\212\244\355\206\240\353\246\254\354\241\260\355\232\214.http" (100%) rename "http/05_\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254/\353\254\270\354\235\230\352\264\200\353\246\254.http" => "http/product/05_\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254/\353\254\270\354\235\230\352\264\200\353\246\254.http" (100%) rename "http/05_\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220.http" => "http/product/05_\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220.http" (100%) rename "http/99_\352\263\265\355\206\265/\354\225\261\354\240\225\353\263\264.http" => "http/product/99_\352\263\265\355\206\265/\354\225\261\354\240\225\353\263\264.http" (100%) rename "http/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" => "http/product/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" (100%) rename "http/99_\352\263\265\355\206\265/\355\221\270\354\213\234\354\225\214\353\246\274.http" => "http/product/99_\352\263\265\355\206\265/\355\221\270\354\213\234\354\225\214\353\246\274.http" (100%) rename http/{ => product}/README.md (100%) rename "http/_\354\235\270\354\246\235.http" => "http/product/_\354\235\270\354\246\235.http" (100%) rename http/{ => product}/http-client.env.json (100%) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt index f3514f05d..c9824ba59 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt @@ -27,13 +27,13 @@ class AuthController( @EventListener(ApplicationReadyEvent::class) fun onApplicationReady() { - val rootAdminIsAlive = authService.rootAdminIsAlive() - log.info("루트 어드민 존재 여부: {}", rootAdminIsAlive) - if (!rootAdminIsAlive) { - log.info("루트 어드민 초기 생성 로직 호출") - val email = rootAdminProperties.email - val encodedPassword = rootAdminProperties.getEncodedPassword(encoder) - authService.initRootAdmin(email, encodedPassword) + val email = rootAdminProperties.email + val encodedPassword = rootAdminProperties.getEncodedPassword(encoder) + val created = authService.initRootAdmin(email, encodedPassword) + if (created) { + log.info("루트 어드민 초기 생성 완료: {}", email) + } else { + log.info("루트 어드민 이미 존재: {}", email) } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminAuthService.java b/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminAuthService.java index 7423ff2f7..fab088871 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminAuthService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminAuthService.java @@ -132,6 +132,10 @@ public void withdraw(Long adminId) { .findById(adminId) .orElseThrow(() -> new UserException(UserExceptionCode.USER_NOT_FOUND)); + if (admin.hasRole(AdminRole.ROOT_ADMIN)) { + throw new UserException(UserExceptionCode.ACCESS_DENIED); + } + admin.deactivate(); log.info("어드민 탈퇴: adminId={}", adminId); } diff --git a/git.environment-variables b/git.environment-variables index 177f6ba67..136a9579b 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 177f6ba6745a2261008b288e30b87d171e440067 +Subproject commit 136a9579b38a50f1f6b96091046533347efd2e61 diff --git "a/http/admin/01_\354\235\270\354\246\235/\354\235\270\354\246\235.http" "b/http/admin/01_\354\235\270\354\246\235/\354\235\270\354\246\235.http" new file mode 100644 index 000000000..6164b8992 --- /dev/null +++ "b/http/admin/01_\354\235\270\354\246\235/\354\235\270\354\246\235.http" @@ -0,0 +1,42 @@ +### 로그인 +POST {{host}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); + client.global.set("refreshToken", response.body.data.refreshToken); +%} + +### 토큰 갱신 +POST {{host}}/auth/refresh +Content-Type: application/json + +{ + "refreshToken": "{{refreshToken}}" +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); + client.global.set("refreshToken", response.body.data.refreshToken); +%} + +### 회원가입 +POST {{host}}/auth/signup +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "email": "new@bottlenote.com", + "password": "password123", + "name": "새 어드민", + "roles": ["PARTNER", "COMMUNITY_MANAGER"] +} + +### 탈퇴 +DELETE {{host}}/auth/withdraw +Authorization: Bearer {{accessToken}} diff --git a/http/admin/http-client.env.json b/http/admin/http-client.env.json new file mode 100644 index 000000000..77a8faeae --- /dev/null +++ b/http/admin/http-client.env.json @@ -0,0 +1,16 @@ +{ + "local": { + "host": "http://localhost:8080/admin/api/v1", + "email": "admin@bottlenote.com", + "password": "password123", + "accessToken": "", + "refreshToken": "" + }, + "dev": { + "host": "https://admin-api.development.bottle-note.com/admin/api/v1", + "email": "bottlenote.official@gmail.com", + "password": "whisky1615!", + "accessToken": "", + "refreshToken": "" + } +} diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\206\214\354\205\234\355\232\214\354\233\220\352\260\200\354\236\205.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\206\214\354\205\234\355\232\214\354\233\220\352\260\200\354\236\205.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\206\214\354\205\234\355\232\214\354\233\220\352\260\200\354\236\205.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\206\214\354\205\234\355\232\214\354\233\220\352\260\200\354\236\205.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\270\354\246\235v2.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\270\354\246\235v2.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\270\354\246\235v2.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\270\354\246\235v2.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\274\353\260\230\355\206\240\355\201\260\353\260\234\352\270\211.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\274\353\260\230\355\206\240\355\201\260\353\260\234\352\270\211.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\274\353\260\230\355\206\240\355\201\260\353\260\234\352\270\211.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\354\235\274\353\260\230\355\206\240\355\201\260\353\260\234\352\270\211.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\353\224\224\353\260\224\354\235\264\354\212\244\355\206\240\355\201\260.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\353\224\224\353\260\224\354\235\264\354\212\244\355\206\240\355\201\260.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\353\224\224\353\260\224\354\235\264\354\212\244\355\206\240\355\201\260.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\353\224\224\353\260\224\354\235\264\354\212\244\355\206\240\355\201\260.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\354\234\240\354\240\200\353\246\254\355\217\254\355\212\270.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\354\234\240\354\240\200\353\246\254\355\217\254\355\212\270.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\354\234\240\354\240\200\353\246\254\355\217\254\355\212\270.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\354\234\240\354\240\200\353\246\254\355\217\254\355\212\270.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\206\240\355\201\260\352\262\200\354\246\235.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\206\240\355\201\260\352\262\200\354\246\235.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\206\240\355\201\260\352\262\200\354\246\235.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\206\240\355\201\260\352\262\200\354\246\235.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\232\214\354\233\220\355\203\210\355\207\264.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\232\214\354\233\220\355\203\210\355\207\264.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\232\214\354\233\220\355\203\210\355\207\264.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\263\204\354\240\225\352\264\200\353\246\254/\355\232\214\354\233\220\355\203\210\355\207\264.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200\354\204\270\353\266\200\354\241\260\355\232\214.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200\354\204\270\353\266\200\354\241\260\355\232\214.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200\354\204\270\353\266\200\354\241\260\355\232\214.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\353\263\264\355\213\200\354\204\270\353\266\200\354\241\260\355\232\214.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\355\216\230\354\235\264\354\247\200.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\355\216\230\354\235\264\354\247\200.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\355\216\230\354\235\264\354\247\200.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\353\247\210\354\235\264\355\216\230\354\235\264\354\247\200.http" diff --git "a/http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\355\224\204\353\241\234\355\225\204\354\210\230\354\240\225.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\355\224\204\353\241\234\355\225\204\354\210\230\354\240\225.http" similarity index 100% rename from "http/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\355\224\204\353\241\234\355\225\204\354\210\230\354\240\225.http" rename to "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\353\202\264\354\240\225\353\263\264/\355\224\204\353\241\234\355\225\204\354\210\230\354\240\225.http" diff --git "a/http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\241\260\355\232\214\352\270\260\353\241\235.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\241\260\355\232\214\352\270\260\353\241\235.http" similarity index 100% rename from "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\241\260\355\232\214\352\270\260\353\241\235.http" rename to "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\241\260\355\232\214\352\270\260\353\241\235.http" diff --git "a/http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\260\234\355\225\230\352\270\260.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\260\234\355\225\230\352\270\260.http" similarity index 100% rename from "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\260\234\355\225\230\352\270\260.http" rename to "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\353\202\230\353\247\214\354\235\230\354\234\204\354\212\244\355\202\244/\354\260\234\355\225\230\352\270\260.http" diff --git "a/http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" similarity index 100% rename from "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" rename to "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" diff --git "a/http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\203\201\354\204\270\353\263\264\352\270\260.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\203\201\354\204\270\353\263\264\352\270\260.http" similarity index 100% rename from "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\203\201\354\204\270\353\263\264\352\270\260.http" rename to "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\203\201\354\204\270\353\263\264\352\270\260.http" diff --git "a/http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\271\264\355\205\214\352\263\240\353\246\254_\354\247\200\354\227\255.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\271\264\355\205\214\352\263\240\353\246\254_\354\247\200\354\227\255.http" similarity index 100% rename from "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\271\264\355\205\214\352\263\240\353\246\254_\354\247\200\354\227\255.http" rename to "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\354\271\264\355\205\214\352\263\240\353\246\254_\354\247\200\354\227\255.http" diff --git "a/http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\354\235\270\352\270\260\354\234\204\354\212\244\355\202\244.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\354\235\270\352\270\260\354\234\204\354\212\244\355\202\244.http" similarity index 100% rename from "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\354\235\270\352\270\260\354\234\204\354\212\244\355\202\244.http" rename to "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\354\235\270\352\270\260\354\234\204\354\212\244\355\202\244.http" diff --git "a/http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230\352\262\200\354\203\211.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230\352\262\200\354\203\211.http" similarity index 100% rename from "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230\352\262\200\354\203\211.http" rename to "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\201\220\353\240\210\354\235\264\354\205\230\352\262\200\354\203\211.http" diff --git "a/http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\206\265\355\225\251\352\262\200\354\203\211.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\206\265\355\225\251\352\262\200\354\203\211.http" similarity index 100% rename from "http/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\206\265\355\225\251\352\262\200\354\203\211.http" rename to "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\260\276\352\270\260/\355\206\265\355\225\251\352\262\200\354\203\211.http" diff --git "a/http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\214\223\352\270\200/\353\214\223\352\270\200\352\264\200\353\246\254.http" "b/http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\214\223\352\270\200/\353\214\223\352\270\200\352\264\200\353\246\254.http" similarity index 100% rename from "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\214\223\352\270\200/\353\214\223\352\270\200\352\264\200\353\246\254.http" rename to "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\214\223\352\270\200/\353\214\223\352\270\200\352\264\200\353\246\254.http" diff --git "a/http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\353\263\264\352\270\260.http" "b/http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\353\263\264\352\270\260.http" similarity index 100% rename from "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\353\263\264\352\270\260.http" rename to "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\353\263\264\352\270\260.http" diff --git "a/http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\236\221\354\204\261_\354\210\230\354\240\225_\354\202\255\354\240\234.http" "b/http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\236\221\354\204\261_\354\210\230\354\240\225_\354\202\255\354\240\234.http" similarity index 100% rename from "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\236\221\354\204\261_\354\210\230\354\240\225_\354\202\255\354\240\234.http" rename to "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\236\221\354\204\261_\354\210\230\354\240\225_\354\202\255\354\240\234.http" diff --git "a/http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\242\213\354\225\204\354\232\224.http" "b/http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\242\213\354\225\204\354\232\224.http" similarity index 100% rename from "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\242\213\354\225\204\354\232\224.http" rename to "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\246\254\353\267\260/\353\246\254\353\267\260\354\242\213\354\225\204\354\232\224.http" diff --git "a/http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\263\204\354\240\220/\353\263\204\354\240\220\352\264\200\353\246\254.http" "b/http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\263\204\354\240\220/\353\263\204\354\240\220\352\264\200\353\246\254.http" similarity index 100% rename from "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\263\204\354\240\220/\353\263\204\354\240\220\352\264\200\353\246\254.http" rename to "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\353\263\204\354\240\220/\353\263\204\354\240\220\352\264\200\353\246\254.http" diff --git "a/http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\354\213\240\352\263\240/\354\213\240\352\263\240\352\264\200\353\246\254.http" "b/http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\354\213\240\352\263\240/\354\213\240\352\263\240\352\264\200\353\246\254.http" similarity index 100% rename from "http/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\354\213\240\352\263\240/\354\213\240\352\263\240\352\264\200\353\246\254.http" rename to "http/product/03_\353\246\254\353\267\260_\355\217\211\352\260\200/\354\213\240\352\263\240/\354\213\240\352\263\240\352\264\200\353\246\254.http" diff --git "a/http/04_\354\206\214\354\205\234/\354\260\250\353\213\250\352\264\200\353\246\254.http" "b/http/product/04_\354\206\214\354\205\234/\354\260\250\353\213\250\352\264\200\353\246\254.http" similarity index 100% rename from "http/04_\354\206\214\354\205\234/\354\260\250\353\213\250\352\264\200\353\246\254.http" rename to "http/product/04_\354\206\214\354\205\234/\354\260\250\353\213\250\352\264\200\353\246\254.http" diff --git "a/http/04_\354\206\214\354\205\234/\355\214\224\353\241\234\354\232\260\352\264\200\353\246\254.http" "b/http/product/04_\354\206\214\354\205\234/\355\214\224\353\241\234\354\232\260\352\264\200\353\246\254.http" similarity index 100% rename from "http/04_\354\206\214\354\205\234/\355\214\224\353\241\234\354\232\260\352\264\200\353\246\254.http" rename to "http/product/04_\354\206\214\354\205\234/\355\214\224\353\241\234\354\232\260\352\264\200\353\246\254.http" diff --git "a/http/04_\354\206\214\354\205\234/\355\236\210\354\212\244\355\206\240\353\246\254/\355\236\210\354\212\244\355\206\240\353\246\254\354\241\260\355\232\214.http" "b/http/product/04_\354\206\214\354\205\234/\355\236\210\354\212\244\355\206\240\353\246\254/\355\236\210\354\212\244\355\206\240\353\246\254\354\241\260\355\232\214.http" similarity index 100% rename from "http/04_\354\206\214\354\205\234/\355\236\210\354\212\244\355\206\240\353\246\254/\355\236\210\354\212\244\355\206\240\353\246\254\354\241\260\355\232\214.http" rename to "http/product/04_\354\206\214\354\205\234/\355\236\210\354\212\244\355\206\240\353\246\254/\355\236\210\354\212\244\355\206\240\353\246\254\354\241\260\355\232\214.http" diff --git "a/http/05_\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254/\353\254\270\354\235\230\352\264\200\353\246\254.http" "b/http/product/05_\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254/\353\254\270\354\235\230\352\264\200\353\246\254.http" similarity index 100% rename from "http/05_\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254/\353\254\270\354\235\230\352\264\200\353\246\254.http" rename to "http/product/05_\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254/\353\254\270\354\235\230\352\264\200\353\246\254.http" diff --git "a/http/05_\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220.http" "b/http/product/05_\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220.http" similarity index 100% rename from "http/05_\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220.http" rename to "http/product/05_\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220/\353\271\204\354\246\210\353\213\210\354\212\244\354\247\200\354\233\220.http" diff --git "a/http/99_\352\263\265\355\206\265/\354\225\261\354\240\225\353\263\264.http" "b/http/product/99_\352\263\265\355\206\265/\354\225\261\354\240\225\353\263\264.http" similarity index 100% rename from "http/99_\352\263\265\355\206\265/\354\225\261\354\240\225\353\263\264.http" rename to "http/product/99_\352\263\265\355\206\265/\354\225\261\354\240\225\353\263\264.http" diff --git "a/http/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" "b/http/product/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" similarity index 100% rename from "http/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" rename to "http/product/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" diff --git "a/http/99_\352\263\265\355\206\265/\355\221\270\354\213\234\354\225\214\353\246\274.http" "b/http/product/99_\352\263\265\355\206\265/\355\221\270\354\213\234\354\225\214\353\246\274.http" similarity index 100% rename from "http/99_\352\263\265\355\206\265/\355\221\270\354\213\234\354\225\214\353\246\274.http" rename to "http/product/99_\352\263\265\355\206\265/\355\221\270\354\213\234\354\225\214\353\246\274.http" diff --git a/http/README.md b/http/product/README.md similarity index 100% rename from http/README.md rename to http/product/README.md diff --git "a/http/_\354\235\270\354\246\235.http" "b/http/product/_\354\235\270\354\246\235.http" similarity index 100% rename from "http/_\354\235\270\354\246\235.http" rename to "http/product/_\354\235\270\354\246\235.http" diff --git a/http/http-client.env.json b/http/product/http-client.env.json similarity index 100% rename from http/http-client.env.json rename to http/product/http-client.env.json From 5f12328f2c0a826aec42e680cdc208c871151ff7 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 24 Dec 2025 16:42:06 +0900 Subject: [PATCH 06/17] =?UTF-8?q?fix:=20Quartz=20=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A7=81=20=EC=84=A4=EC=A0=95=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quartz JDBC JobStore 클러스터링이 제대로 동작하지 않아 동일 Job이 여러 인스턴스에서 중복 실행되는 문제 수정. - jobStore.class: JobStoreTX 명시적 지정 - driverDelegateClass: StdJDBCDelegate 추가 - clusterCheckinInterval: 20000ms 설정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- bottlenote-batch/src/main/resources/application-batch.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bottlenote-batch/src/main/resources/application-batch.yml b/bottlenote-batch/src/main/resources/application-batch.yml index efebf85af..872c04cad 100644 --- a/bottlenote-batch/src/main/resources/application-batch.yml +++ b/bottlenote-batch/src/main/resources/application-batch.yml @@ -20,4 +20,7 @@ spring: threadCount: 5 threadPriority: 5 jobStore: + class: org.quartz.impl.jdbcjobstore.JobStoreTX + driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate isClustered: true + clusterCheckinInterval: 20000 From 971804ef7303e73175026f9c861d7ed2c0ac427b Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 24 Dec 2025 16:55:06 +0900 Subject: [PATCH 07/17] =?UTF-8?q?fix:=20Quartz=20jobStore.class=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EC=84=A4=EC=A0=95=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bottlenote-batch/src/main/resources/application-batch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bottlenote-batch/src/main/resources/application-batch.yml b/bottlenote-batch/src/main/resources/application-batch.yml index 872c04cad..58528498a 100644 --- a/bottlenote-batch/src/main/resources/application-batch.yml +++ b/bottlenote-batch/src/main/resources/application-batch.yml @@ -20,7 +20,7 @@ spring: threadCount: 5 threadPriority: 5 jobStore: - class: org.quartz.impl.jdbcjobstore.JobStoreTX + #class: org.quartz.impl.jdbcjobstore.JobStoreTX driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate isClustered: true clusterCheckinInterval: 20000 From 4bb5280a67532777d7f7d6442a1938f510b76a12 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 26 Dec 2025 10:23:38 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20DailyDataReportIntegrationTest=20C?= =?UTF-8?q?ontext=20=EC=B6=A9=EB=8F=8C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FakeWebhookRestTemplate 클래스 추가 (테스트용 RestTemplate Fake 구현체) - TestContainersConfig에 FakeWebhookRestTemplate 빈 등록 - DailyDataReportIntegrationTest에서 내부 TestConfig 제거하여 Context 공유 이전에 TestConfig 내부 클래스로 인해 다른 통합 테스트와 다른 Spring Context가 생성되어 TestContainers 충돌이 발생했음. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../utils/FakeWebhookRestTemplate.java | 77 +++++++++++ .../operation/utils/TestContainersConfig.java | 8 ++ .../DailyDataReportIntegrationTest.java | 124 ++++-------------- 3 files changed, 114 insertions(+), 95 deletions(-) create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java new file mode 100644 index 000000000..cca808234 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java @@ -0,0 +1,77 @@ +package app.bottlenote.operation.utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +/** 테스트용 Fake RestTemplate 구현체. 실제 HTTP 호출 없이 요청을 캡처하여 검증에 사용합니다. */ +public class FakeWebhookRestTemplate extends RestTemplate { + + private final List capturedRequests = + Collections.synchronizedList(new ArrayList<>()); + private ResponseEntity defaultResponse = ResponseEntity.ok("Success"); + + @Override + @SuppressWarnings("unchecked") + public ResponseEntity postForEntity( + String url, Object request, Class responseType, Object... uriVariables) { + capturedRequests.add(new CapturedRequest(url, request)); + return (ResponseEntity) defaultResponse; + } + + @Override + @SuppressWarnings("unchecked") + public ResponseEntity postForEntity( + String url, Object request, Class responseType, java.util.Map uriVariables) { + capturedRequests.add(new CapturedRequest(url, request)); + return (ResponseEntity) defaultResponse; + } + + public void setDefaultResponse(ResponseEntity response) { + this.defaultResponse = response; + } + + public int getCallCount() { + return capturedRequests.size(); + } + + public boolean wasCalled() { + return !capturedRequests.isEmpty(); + } + + public boolean wasNotCalled() { + return capturedRequests.isEmpty(); + } + + public CapturedRequest getLastRequest() { + if (capturedRequests.isEmpty()) { + return null; + } + return capturedRequests.get(capturedRequests.size() - 1); + } + + public String getLastRequestBody() { + CapturedRequest last = getLastRequest(); + if (last == null || last.request() == null) { + return null; + } + if (last.request() instanceof HttpEntity entity) { + return entity.getBody() != null ? entity.getBody().toString() : null; + } + return last.request().toString(); + } + + public List getAllRequests() { + return Collections.unmodifiableList(capturedRequests); + } + + public void clear() { + capturedRequests.clear(); + defaultResponse = ResponseEntity.ok("Success"); + } + + public record CapturedRequest(String url, Object request) {} +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java index 0369dabf8..7320fcdee 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java @@ -4,6 +4,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; @@ -36,4 +37,11 @@ MySQLContainer mysqlContainer() { RedisContainer redisContainer() { return new RedisContainer(DockerImageName.parse("redis:7.0.12")).withReuse(true); } + + /** 테스트용 Fake RestTemplate 빈. webhookRestTemplate을 대체합니다. */ + @Bean + @Primary + FakeWebhookRestTemplate webhookRestTemplate() { + return new FakeWebhookRestTemplate(); + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java index ae0054b5e..dae8fe2e3 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java @@ -3,14 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.operation.utils.FakeWebhookRestTemplate; import app.bottlenote.support.report.service.DailyDataReportService; import java.time.LocalDate; import java.time.LocalDateTime; @@ -18,51 +13,30 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.http.HttpEntity; -import org.springframework.http.ResponseEntity; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.web.client.RestTemplate; @Tag("integration") @DisplayName("[integration] [service] DailyDataReportService - TestContainers 실제 데이터 통합 테스트") class DailyDataReportIntegrationTest extends IntegrationTestSupport { - @TestConfiguration - static class TestConfig { - @Bean - @Primary - RestTemplate webhookRestTemplate() { - return Mockito.mock(RestTemplate.class); - } - } - @Autowired private DailyDataReportService dailyDataReportService; @Autowired private JdbcTemplate jdbcTemplate; - @Autowired private RestTemplate webhookRestTemplate; + @Autowired private FakeWebhookRestTemplate fakeWebhookRestTemplate; private LocalDate testDate; @BeforeEach void setUp() { testDate = LocalDate.now(); - Mockito.reset(webhookRestTemplate); + fakeWebhookRestTemplate.clear(); } @DisplayName("시나리오1: 실제 데이터로 일일 리포트를 생성하고 집계가 정확한지 검증") @Test void 실제_데이터를_사용하여_일일_리포트가_정확하게_집계된다() { - // given - Mock 응답 설정 - doReturn(ResponseEntity.ok("Success")) - .when(webhookRestTemplate) - .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); - // given - 오늘과 어제 데이터를 구분하여 생성 LocalDateTime today = testDate.atStartOfDay(); LocalDateTime yesterday = today.minusDays(1); @@ -100,23 +74,17 @@ void setUp() { dailyDataReportService.collectAndSendDailyReport(testDate, webhookUrl); // then - 웹훅이 정확히 1번 호출됨 - verify(webhookRestTemplate, times(1)) - .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); + assertThat(fakeWebhookRestTemplate.getCallCount()).isEqualTo(1); // 전송된 메시지 내용 검증 - org.mockito.ArgumentCaptor entityCaptor = - org.mockito.ArgumentCaptor.forClass(HttpEntity.class); - verify(webhookRestTemplate) - .postForEntity(anyString(), entityCaptor.capture(), eq(String.class)); - - String body = entityCaptor.getValue().getBody().toString(); + String body = fakeWebhookRestTemplate.getLastRequestBody(); assertNotNull(body); // 오늘 데이터만 집계되었는지 검증 - assertThat(body).contains("👥 **신규 유저**: 3명"); - assertThat(body).contains("✍️ **신규 리뷰**: 2개"); - assertThat(body).contains("💬 **신규 댓글**: 4개"); - assertThat(body).contains("❤️ **신규 좋아요**: 5개"); + assertThat(body).contains("신규 유저**: 3명"); + assertThat(body).contains("신규 리뷰**: 2개"); + assertThat(body).contains("신규 댓글**: 4개"); + assertThat(body).contains("신규 좋아요**: 5개"); } @DisplayName("시나리오2: 데이터가 없는 날은 웹훅을 전송하지 않는다") @@ -130,18 +98,12 @@ void setUp() { dailyDataReportService.collectAndSendDailyReport(emptyDate, webhookUrl); // then - 신규 데이터가 없으므로 웹훅이 호출되지 않음 - verify(webhookRestTemplate, times(0)) - .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); + assertThat(fakeWebhookRestTemplate.wasNotCalled()).isTrue(); } @DisplayName("시나리오3: 시간 경계값 - 자정 직전과 직후 데이터 구분") @Test void 자정을_기준으로_데이터가_정확하게_구분된다() { - // given - Mock 응답 설정 - doReturn(ResponseEntity.ok("Success")) - .when(webhookRestTemplate) - .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); - // given - 자정 기준으로 경계값 테스트 LocalDateTime todayMidnight = testDate.atStartOfDay(); LocalDateTime beforeMidnight = todayMidnight.minusSeconds(1); // 23:59:59 (어제) @@ -160,16 +122,13 @@ void setUp() { testDate, "https://discord.com/api/webhooks/test"); // then - 자정 이후 데이터만 집계됨 - org.mockito.ArgumentCaptor entityCaptor = - org.mockito.ArgumentCaptor.forClass(HttpEntity.class); - verify(webhookRestTemplate) - .postForEntity(anyString(), entityCaptor.capture(), eq(String.class)); + assertThat(fakeWebhookRestTemplate.wasCalled()).isTrue(); - String body = entityCaptor.getValue().getBody().toString(); + String body = fakeWebhookRestTemplate.getLastRequestBody(); // 자정 직후(00:00:01) 데이터만 포함 - assertThat(body).contains("👥 **신규 유저**: 1명"); - assertThat(body).contains("✍️ **신규 리뷰**: 1개"); + assertThat(body).contains("신규 유저**: 1명"); + assertThat(body).contains("신규 리뷰**: 1개"); } @DisplayName("시나리오4: 웹훅 URL이 없으면 데이터 수집만 하고 전송하지 않는다") @@ -183,18 +142,12 @@ void setUp() { assertDoesNotThrow(() -> dailyDataReportService.collectAndSendDailyReport(testDate, null)); // then - 웹훅 전송이 호출되지 않음 - verify(webhookRestTemplate, times(0)) - .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); + assertThat(fakeWebhookRestTemplate.wasNotCalled()).isTrue(); } @DisplayName("시나리오5: 대량 데이터 집계 성능 테스트") @Test void 대량의_데이터도_정상적으로_집계된다() { - // given - Mock 응답 설정 - doReturn(ResponseEntity.ok("Success")) - .when(webhookRestTemplate) - .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); - // given - 대량 데이터 생성 (유저 10명, 리뷰 20개, 댓글 30개, 좋아요 40개) LocalDateTime today = testDate.atStartOfDay(); @@ -223,27 +176,19 @@ void setUp() { dailyDataReportService.collectAndSendDailyReport(testDate, webhookUrl); // then - 정확한 집계 결과 확인 - org.mockito.ArgumentCaptor entityCaptor = - org.mockito.ArgumentCaptor.forClass(HttpEntity.class); - verify(webhookRestTemplate) - .postForEntity(anyString(), entityCaptor.capture(), eq(String.class)); + assertThat(fakeWebhookRestTemplate.wasCalled()).isTrue(); - String body = entityCaptor.getValue().getBody().toString(); + String body = fakeWebhookRestTemplate.getLastRequestBody(); - assertThat(body).contains("👥 **신규 유저**: 10명"); - assertThat(body).contains("✍️ **신규 리뷰**: 20개"); - assertThat(body).contains("💬 **신규 댓글**: 30개"); - assertThat(body).contains("❤️ **신규 좋아요**: 40개"); + assertThat(body).contains("신규 유저**: 10명"); + assertThat(body).contains("신규 리뷰**: 20개"); + assertThat(body).contains("신규 댓글**: 30개"); + assertThat(body).contains("신규 좋아요**: 40개"); } @DisplayName("시나리오6: 신고와 문의 데이터가 포함된 리포트 생성") @Test void 신고와_문의_데이터가_포함된_리포트가_생성된다() { - // given - Mock 응답 설정 - doReturn(ResponseEntity.ok("Success")) - .when(webhookRestTemplate) - .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); - // given - 신고 및 문의 데이터 생성 createReviewReport(1L, 1L, "WAITING"); createReviewReport(1L, 2L, "PENDING"); @@ -259,28 +204,20 @@ void setUp() { dailyDataReportService.collectAndSendDailyReport(testDate, webhookUrl); // then - 웹훅 호출 검증 - org.mockito.ArgumentCaptor entityCaptor = - org.mockito.ArgumentCaptor.forClass(HttpEntity.class); - verify(webhookRestTemplate) - .postForEntity(anyString(), entityCaptor.capture(), eq(String.class)); + assertThat(fakeWebhookRestTemplate.wasCalled()).isTrue(); - String body = entityCaptor.getValue().getBody().toString(); + String body = fakeWebhookRestTemplate.getLastRequestBody(); assertNotNull(body); // 신고 및 문의 데이터 검증 (리뷰 신고 2건 + 유저 신고 1건 = 3건) - assertThat(body).contains("🚨 **미처리 신고**: 3건"); - assertThat(body).contains("📧 **미처리 문의**: 2건"); - assertThat(body).contains("👥 **신규 유저**: 1명"); + assertThat(body).contains("미처리 신고**: 3건"); + assertThat(body).contains("미처리 문의**: 2건"); + assertThat(body).contains("신규 유저**: 1명"); } @DisplayName("시나리오7: 0건인 항목은 리포트에서 제외된다") @Test void 값이_0인_항목은_메시지에_포함되지_않는다() { - // given - Mock 응답 설정 - doReturn(ResponseEntity.ok("Success")) - .when(webhookRestTemplate) - .postForEntity(anyString(), any(HttpEntity.class), eq(String.class)); - // given - 신규 유저 1명만 생성 (다른 데이터는 0) LocalDateTime today = testDate.atStartOfDay(); createUser(today, "onlyuser@test.com"); @@ -290,16 +227,13 @@ void setUp() { dailyDataReportService.collectAndSendDailyReport(testDate, webhookUrl); // then - 웹훅 호출 검증 - org.mockito.ArgumentCaptor entityCaptor = - org.mockito.ArgumentCaptor.forClass(HttpEntity.class); - verify(webhookRestTemplate) - .postForEntity(anyString(), entityCaptor.capture(), eq(String.class)); + assertThat(fakeWebhookRestTemplate.wasCalled()).isTrue(); - String body = entityCaptor.getValue().getBody().toString(); + String body = fakeWebhookRestTemplate.getLastRequestBody(); assertNotNull(body); // 신규 유저만 포함되고 나머지는 제외 - assertThat(body).contains("👥 **신규 유저**: 1명"); + assertThat(body).contains("신규 유저**: 1명"); assertThat(body).doesNotContain("**신규 리뷰**"); assertThat(body).doesNotContain("**신규 댓글**"); assertThat(body).doesNotContain("**신규 좋아요**"); From 1833f83dd2437784071cdfc2051434b6cf99bd25 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 26 Dec 2025 11:06:53 +0900 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20WebhookRestTemplate=20?= =?UTF-8?q?=EB=B9=88=20=EC=83=9D=EC=84=B1=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/app/external/webhook/config/WebhookConfig.java | 2 ++ .../report/integration/DailyDataReportIntegrationTest.java | 3 --- git.environment-variables | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/external/webhook/config/WebhookConfig.java b/bottlenote-mono/src/main/java/app/external/webhook/config/WebhookConfig.java index 656ec4b8a..f3d223aa4 100644 --- a/bottlenote-mono/src/main/java/app/external/webhook/config/WebhookConfig.java +++ b/bottlenote-mono/src/main/java/app/external/webhook/config/WebhookConfig.java @@ -5,6 +5,7 @@ import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.web.client.RestTemplate; @Configuration @@ -12,6 +13,7 @@ public class WebhookConfig { @Bean + @Profile("!test") public RestTemplate webhookRestTemplate(RestTemplateBuilder builder) { return builder .connectTimeout(Duration.ofSeconds(5)) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java index dae8fe2e3..5b861786c 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java @@ -19,11 +19,8 @@ @Tag("integration") @DisplayName("[integration] [service] DailyDataReportService - TestContainers 실제 데이터 통합 테스트") class DailyDataReportIntegrationTest extends IntegrationTestSupport { - @Autowired private DailyDataReportService dailyDataReportService; - @Autowired private JdbcTemplate jdbcTemplate; - @Autowired private FakeWebhookRestTemplate fakeWebhookRestTemplate; private LocalDate testDate; diff --git a/git.environment-variables b/git.environment-variables index 136a9579b..378cbb86e 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 136a9579b38a50f1f6b96091046533347efd2e61 +Subproject commit 378cbb86ef9581a8043c8f874d9a61c1b17aaa82 From 47e5f4c8fb52957b5b3561486b4049cf5cab9ccb Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 26 Dec 2025 11:49:34 +0900 Subject: [PATCH 10/17] =?UTF-8?q?refactor:=20CapturedRequest=EB=A5=BC=20Ca?= =?UTF-8?q?pturedCall=EB=A1=9C=20=EB=A6=AC=EB=84=A4=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../operation/utils/FakeWebhookRestTemplate.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java index cca808234..31cb7e063 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java @@ -10,7 +10,7 @@ /** 테스트용 Fake RestTemplate 구현체. 실제 HTTP 호출 없이 요청을 캡처하여 검증에 사용합니다. */ public class FakeWebhookRestTemplate extends RestTemplate { - private final List capturedRequests = + private final List capturedRequests = Collections.synchronizedList(new ArrayList<>()); private ResponseEntity defaultResponse = ResponseEntity.ok("Success"); @@ -18,7 +18,7 @@ public class FakeWebhookRestTemplate extends RestTemplate { @SuppressWarnings("unchecked") public ResponseEntity postForEntity( String url, Object request, Class responseType, Object... uriVariables) { - capturedRequests.add(new CapturedRequest(url, request)); + capturedRequests.add(new CapturedCall(url, request)); return (ResponseEntity) defaultResponse; } @@ -26,7 +26,7 @@ public ResponseEntity postForEntity( @SuppressWarnings("unchecked") public ResponseEntity postForEntity( String url, Object request, Class responseType, java.util.Map uriVariables) { - capturedRequests.add(new CapturedRequest(url, request)); + capturedRequests.add(new CapturedCall(url, request)); return (ResponseEntity) defaultResponse; } @@ -46,7 +46,7 @@ public boolean wasNotCalled() { return capturedRequests.isEmpty(); } - public CapturedRequest getLastRequest() { + public CapturedCall getLastRequest() { if (capturedRequests.isEmpty()) { return null; } @@ -54,7 +54,7 @@ public CapturedRequest getLastRequest() { } public String getLastRequestBody() { - CapturedRequest last = getLastRequest(); + CapturedCall last = getLastRequest(); if (last == null || last.request() == null) { return null; } @@ -64,7 +64,7 @@ public String getLastRequestBody() { return last.request().toString(); } - public List getAllRequests() { + public List getAllRequests() { return Collections.unmodifiableList(capturedRequests); } @@ -73,5 +73,5 @@ public void clear() { defaultResponse = ResponseEntity.ok("Success"); } - public record CapturedRequest(String url, Object request) {} + public record CapturedCall(String url, Object request) {} } From 6f51986c82a531974b99597c1cac4806dca839b2 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 26 Dec 2025 11:52:13 +0900 Subject: [PATCH 11/17] =?UTF-8?q?refactor:=20FakeWebhookRestTemplate=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/FakeWebhookRestTemplate.java | 58 +++++++++---------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java index 31cb7e063..712d6ab0e 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/FakeWebhookRestTemplate.java @@ -3,75 +3,71 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import org.springframework.http.HttpEntity; import org.springframework.http.ResponseEntity; +import org.springframework.lang.NonNull; import org.springframework.web.client.RestTemplate; /** 테스트용 Fake RestTemplate 구현체. 실제 HTTP 호출 없이 요청을 캡처하여 검증에 사용합니다. */ public class FakeWebhookRestTemplate extends RestTemplate { - private final List capturedRequests = - Collections.synchronizedList(new ArrayList<>()); - private ResponseEntity defaultResponse = ResponseEntity.ok("Success"); + private final List capturedCalls = Collections.synchronizedList(new ArrayList<>()); + private final ResponseEntity defaultResponse = ResponseEntity.ok("Success"); + @NonNull @Override @SuppressWarnings("unchecked") public ResponseEntity postForEntity( - String url, Object request, Class responseType, Object... uriVariables) { - capturedRequests.add(new CapturedCall(url, request)); + @NonNull String url, + Object request, + @NonNull Class responseType, + @NonNull Object... uriVariables) { + capturedCalls.add(new CapturedCall(url, request)); return (ResponseEntity) defaultResponse; } + @NonNull @Override @SuppressWarnings("unchecked") public ResponseEntity postForEntity( - String url, Object request, Class responseType, java.util.Map uriVariables) { - capturedRequests.add(new CapturedCall(url, request)); + @NonNull String url, + Object request, + @NonNull Class responseType, + @NonNull Map uriVariables) { + capturedCalls.add(new CapturedCall(url, request)); return (ResponseEntity) defaultResponse; } - public void setDefaultResponse(ResponseEntity response) { - this.defaultResponse = response; - } - public int getCallCount() { - return capturedRequests.size(); + return capturedCalls.size(); } public boolean wasCalled() { - return !capturedRequests.isEmpty(); + return !capturedCalls.isEmpty(); } public boolean wasNotCalled() { - return capturedRequests.isEmpty(); + return capturedCalls.isEmpty(); } - public CapturedCall getLastRequest() { - if (capturedRequests.isEmpty()) { + public String getLastRequestBody() { + if (capturedCalls.isEmpty()) { return null; } - return capturedRequests.get(capturedRequests.size() - 1); - } - - public String getLastRequestBody() { - CapturedCall last = getLastRequest(); - if (last == null || last.request() == null) { + CapturedCall last = capturedCalls.getLast(); + if (last.payload() == null) { return null; } - if (last.request() instanceof HttpEntity entity) { + if (last.payload() instanceof HttpEntity entity) { return entity.getBody() != null ? entity.getBody().toString() : null; } - return last.request().toString(); - } - - public List getAllRequests() { - return Collections.unmodifiableList(capturedRequests); + return last.payload().toString(); } public void clear() { - capturedRequests.clear(); - defaultResponse = ResponseEntity.ok("Success"); + capturedCalls.clear(); } - public record CapturedCall(String url, Object request) {} + public record CapturedCall(String url, Object payload) {} } From 589d6b7e6eafb77f40dcdb2cd3bff1eaf3f63ac3 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 26 Dec 2025 12:14:46 +0900 Subject: [PATCH 12/17] =?UTF-8?q?test:=20AdminAuthIntegrationTest=EC=97=90?= =?UTF-8?q?=20ROOT=5FADMIN=20=ED=83=88=ED=87=B4=20=EB=B6=88=EA=B0=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/AdminAuthIntegrationTest.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt index 74b73382a..b49a9a080 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt @@ -194,10 +194,10 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { inner class WithdrawTest { @Test - @DisplayName("인증된 어드민이 탈퇴에 성공한다") + @DisplayName("일반 어드민이 탈퇴에 성공한다") fun withdrawSuccess() { // given - val admin = adminUserTestFactory.persistRootAdmin() + val admin = adminUserTestFactory.persistPartnerAdmin() val accessToken = getAccessToken(admin) // when & then @@ -210,6 +210,24 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { .bodyJson() .extractingPath("$.data.message").isEqualTo("탈퇴 처리되었습니다.") } + + @Test + @DisplayName("ROOT_ADMIN은 탈퇴할 수 없다") + fun rootAdminCannotWithdraw() { + // given + val admin = adminUserTestFactory.persistRootAdmin() + val accessToken = getAccessToken(admin) + + // when & then + assertThat( + mockMvcTester.delete() + .uri("/auth/withdraw") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } } @Nested From 1206f88220dd1d81da2ad629bc8afad37fc137ba Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 27 Dec 2025 01:05:57 +0900 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20password=20=EC=B6=94=EA=B0=80=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ http/admin/http-client.env.json | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2cb06f857..f584c9b2b 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ settings.local.json # Cosign keys cosign.key cosign.pub + +*.env.* diff --git a/http/admin/http-client.env.json b/http/admin/http-client.env.json index 77a8faeae..d55a9180e 100644 --- a/http/admin/http-client.env.json +++ b/http/admin/http-client.env.json @@ -8,8 +8,8 @@ }, "dev": { "host": "https://admin-api.development.bottle-note.com/admin/api/v1", - "email": "bottlenote.official@gmail.com", - "password": "whisky1615!", + "email": "bottlenote.official@email.com", + "password": "password1234", "accessToken": "", "refreshToken": "" } From a264b0a7008606f2a5c829f1e5a206292fb017f4 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sun, 28 Dec 2025 12:22:44 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20API=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=82=B4=20=EC=9E=98=EB=AA=BB=EB=90=9C=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/docs/asciidoc/api/overview/overview.adoc | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/overview/overview.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/overview/overview.adoc index 2345bd8dd..2f7ae7e20 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/overview/overview.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/overview/overview.adoc @@ -11,6 +11,7 @@ [cols="1,3"] |==== |환경 |DNS -|개발 (dev) | link:[https://api.admin.development.bottle-note.com/] -|운영(prod) | link:[https://api.admin.product.bottle-note.com/] -|==== \ No newline at end of file +|개발 (dev) | link:[https://admin-api.development.bottle-note.com/] +|운영(prod) | link:[https://admin-api.bottle-note.com/] +|==== + From c06e9facf60f9fd18a497d41f86b0370bb7b808c Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 30 Dec 2025 15:30:28 +0900 Subject: [PATCH 15/17] =?UTF-8?q?feat:=20Admin=20=EB=AC=B8=EC=9D=98?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EA=B4=80=EB=A6=AC=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(=EB=AA=A9=EB=A1=9D/=EC=83=81=EC=84=B8/=EB=8B=B5?= =?UTF-8?q?=EB=B3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminHelpController 추가 (목록 조회, 상세 조회, 답변 등록) - AdminHelpService 및 관련 DTO 생성 - Help 엔티티에 answer() 도메인 메서드 추가 - 관리자용 QueryDSL 쿼리 구현 (상태/유형 필터링 지원) - 통합 테스트 및 RestDocs 테스트 작성 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../help/presentation/AdminHelpController.kt | 48 ++++ .../docs/help/AdminHelpControllerDocsTest.kt | 252 ++++++++++++++++++ .../help/AdminHelpIntegrationTest.kt | 252 ++++++++++++++++++ .../bottlenote/support/help/domain/Help.java | 9 + .../support/help/domain/HelpRepository.java | 4 + .../dto/request/AdminHelpAnswerRequest.java | 9 + .../dto/request/AdminHelpPageableRequest.java | 15 ++ .../dto/response/AdminHelpAnswerResponse.java | 10 + .../dto/response/AdminHelpDetailResponse.java | 23 ++ .../dto/response/AdminHelpListResponse.java | 24 ++ .../help/repository/HelpQuerySupporter.java | 23 +- .../custom/CustomHelpQueryRepository.java | 18 +- .../custom/CustomHelpQueryRepositoryImpl.java | 51 ++++ .../help/service/AdminHelpService.java | 76 ++++++ 14 files changed, 808 insertions(+), 6 deletions(-) create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/support/help/presentation/AdminHelpController.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/request/AdminHelpAnswerRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/request/AdminHelpPageableRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpAnswerResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpDetailResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpListResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/support/help/service/AdminHelpService.java diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/support/help/presentation/AdminHelpController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/support/help/presentation/AdminHelpController.kt new file mode 100644 index 000000000..62c354c2d --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/support/help/presentation/AdminHelpController.kt @@ -0,0 +1,48 @@ +package app.bottlenote.support.help.presentation + +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.security.SecurityContextUtil +import app.bottlenote.support.help.dto.request.AdminHelpAnswerRequest +import app.bottlenote.support.help.dto.request.AdminHelpPageableRequest +import app.bottlenote.support.help.service.AdminHelpService +import app.bottlenote.user.exception.UserException +import app.bottlenote.user.exception.UserExceptionCode +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/helps") +class AdminHelpController( + private val adminHelpService: AdminHelpService +) { + + @GetMapping + fun getHelpList(@ModelAttribute request: AdminHelpPageableRequest): ResponseEntity<*> { + val response = adminHelpService.getHelpList(request) + return GlobalResponse.ok(response) + } + + @GetMapping("/{helpId}") + fun getHelpDetail(@PathVariable helpId: Long): ResponseEntity<*> { + val response = adminHelpService.getHelpDetail(helpId) + return GlobalResponse.ok(response) + } + + @PostMapping("/{helpId}/answer") + fun answerHelp( + @PathVariable helpId: Long, + @RequestBody @Valid request: AdminHelpAnswerRequest + ): ResponseEntity<*> { + val adminId = SecurityContextUtil.getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } + val response = adminHelpService.answerHelp(helpId, adminId, request) + return GlobalResponse.ok(response) + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt new file mode 100644 index 000000000..3fe323c41 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt @@ -0,0 +1,252 @@ +package app.docs.help + +import app.bottlenote.global.security.SecurityContextUtil +import app.bottlenote.global.service.cursor.CursorPageable +import app.bottlenote.global.service.cursor.PageResponse +import app.bottlenote.support.constant.StatusType +import app.bottlenote.support.help.constant.HelpType +import app.bottlenote.support.help.dto.request.HelpImageItem +import app.bottlenote.support.help.dto.response.AdminHelpAnswerResponse +import app.bottlenote.support.help.dto.response.AdminHelpDetailResponse +import app.bottlenote.support.help.dto.response.AdminHelpListResponse +import app.bottlenote.support.help.presentation.AdminHelpController +import app.bottlenote.support.help.service.AdminHelpService +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.BDDMockito.given +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName +import org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.operation.preprocess.Preprocessors.* +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.* +import org.springframework.restdocs.request.RequestDocumentation.* +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester +import java.time.LocalDateTime +import java.util.* + +@WebMvcTest( + controllers = [AdminHelpController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin Help 컨트롤러 RestDocs 테스트") +class AdminHelpControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @MockitoBean + private lateinit var adminHelpService: AdminHelpService + + @Test + @DisplayName("문의 목록 조회") + fun getHelpList() { + // given + val helpList = listOf( + AdminHelpListResponse.AdminHelpInfo.builder() + .helpId(1L) + .userId(100L) + .userNickname("테스트유저") + .title("위스키 관련 문의") + .type(HelpType.WHISKEY) + .status(StatusType.WAITING) + .createAt(LocalDateTime.now()) + .build() + ) + val response = AdminHelpListResponse.of(1L, helpList) + val cursorPageable = CursorPageable.builder() + .cursor(20L) + .pageSize(20L) + .hasNext(false) + .currentCursor(0L) + .build() + val pageResponse = PageResponse.of(response, cursorPageable) + + given(adminHelpService.getHelpList(any())).willReturn(pageResponse) + + // when & then + assertThat( + mvc.get().uri("/helps") + .header("Authorization", "Bearer test_access_token") + .param("status", StatusType.WAITING.name) + .param("type", HelpType.WHISKEY.name) + .param("cursor", "0") + .param("pageSize", "20") + ) + .hasStatusOk() + .apply( + document( + "admin/help/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰") + ), + queryParameters( + parameterWithName("status").optional().description("상태 필터 (WAITING, SUCCESS, REJECT, DELETED)"), + parameterWithName("type").optional().description("문의 유형 필터 (WHISKEY, REVIEW, USER, ETC)"), + parameterWithName("cursor").optional().description("페이징 커서 (기본값: 0)"), + parameterWithName("pageSize").optional().description("페이지 크기 (기본값: 20)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data.content.totalCount").type(JsonFieldType.NUMBER).description("전체 문의 수"), + fieldWithPath("data.content.helpList[].helpId").type(JsonFieldType.NUMBER).description("문의 ID"), + fieldWithPath("data.content.helpList[].userId").type(JsonFieldType.NUMBER).description("문의자 ID"), + fieldWithPath("data.content.helpList[].userNickname").type(JsonFieldType.STRING).description("문의자 닉네임"), + fieldWithPath("data.content.helpList[].title").type(JsonFieldType.STRING).description("문의 제목"), + fieldWithPath("data.content.helpList[].type").type(JsonFieldType.STRING).description("문의 유형"), + fieldWithPath("data.content.helpList[].status").type(JsonFieldType.STRING).description("처리 상태"), + fieldWithPath("data.content.helpList[].createAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.cursorPageable.cursor").type(JsonFieldType.NUMBER).description("다음 커서"), + fieldWithPath("data.cursorPageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.cursorPageable.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 여부"), + fieldWithPath("data.cursorPageable.currentCursor").type(JsonFieldType.NUMBER).description("현재 커서"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + + @Test + @DisplayName("문의 상세 조회") + fun getHelpDetail() { + // given + val response = AdminHelpDetailResponse.builder() + .helpId(1L) + .userId(100L) + .userNickname("테스트유저") + .title("위스키 관련 문의") + .content("위스키에 대해 문의드립니다.") + .type(HelpType.WHISKEY) + .imageUrlList(listOf(HelpImageItem.create(1, "https://example.com/image.jpg"))) + .status(StatusType.WAITING) + .adminId(null) + .responseContent(null) + .createAt(LocalDateTime.now()) + .lastModifyAt(LocalDateTime.now()) + .build() + + given(adminHelpService.getHelpDetail(anyLong())).willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/helps/1") + .header("Authorization", "Bearer test_access_token") + ) + .hasStatusOk() + .apply( + document( + "admin/help/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰") + ), + pathParameters( + parameterWithName("helpId").description("문의 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data.helpId").type(JsonFieldType.NUMBER).description("문의 ID"), + fieldWithPath("data.userId").type(JsonFieldType.NUMBER).description("문의자 ID"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("문의자 닉네임"), + fieldWithPath("data.title").type(JsonFieldType.STRING).description("문의 제목"), + fieldWithPath("data.content").type(JsonFieldType.STRING).description("문의 내용"), + fieldWithPath("data.type").type(JsonFieldType.STRING).description("문의 유형"), + fieldWithPath("data.imageUrlList[].order").type(JsonFieldType.NUMBER).description("이미지 순서"), + fieldWithPath("data.imageUrlList[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("data.status").type(JsonFieldType.STRING).description("처리 상태"), + fieldWithPath("data.adminId").type(JsonFieldType.NULL).description("담당 관리자 ID"), + fieldWithPath("data.responseContent").type(JsonFieldType.NULL).description("답변 내용"), + fieldWithPath("data.createAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.lastModifyAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + + @Test + @DisplayName("문의 답변 등록") + fun answerHelp() { + // given + val response = AdminHelpAnswerResponse.of(1L, StatusType.SUCCESS) + + given(adminHelpService.answerHelp(anyLong(), anyLong(), any())).willReturn(response) + + Mockito.mockStatic(SecurityContextUtil::class.java).use { mockedStatic: MockedStatic -> + mockedStatic.`when`> { SecurityContextUtil.getAdminUserIdByContext() } + .thenReturn(Optional.of(1L)) + + val request = mapOf( + "responseContent" to "답변 내용입니다.", + "status" to StatusType.SUCCESS.name + ) + + // when & then + assertThat( + mvc.post().uri("/helps/1/answer") + .header("Authorization", "Bearer test_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/help/answer", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰") + ), + pathParameters( + parameterWithName("helpId").description("문의 ID") + ), + requestFields( + fieldWithPath("responseContent").type(JsonFieldType.STRING).description("답변 내용"), + fieldWithPath("status").type(JsonFieldType.STRING).description("처리 상태 (SUCCESS, REJECT)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data.helpId").type(JsonFieldType.NUMBER).description("문의 ID"), + fieldWithPath("data.status").type(JsonFieldType.STRING).description("처리 상태"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt new file mode 100644 index 000000000..53f6b34d1 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt @@ -0,0 +1,252 @@ +package app.integration.help + +import app.IntegrationTestSupport +import app.bottlenote.support.constant.StatusType +import app.bottlenote.support.help.constant.HelpType +import app.bottlenote.support.help.fixture.HelpTestFactory +import app.bottlenote.user.fixture.UserTestFactory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType + +@Tag("admin_integration") +@DisplayName("[integration] Admin Help API 통합 테스트") +class AdminHelpIntegrationTest : IntegrationTestSupport() { + + @Autowired + private lateinit var helpTestFactory: HelpTestFactory + + @Autowired + private lateinit var userTestFactory: UserTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("문의 목록 조회 API") + inner class GetHelpListTest { + + @Test + @DisplayName("문의 목록을 조회할 수 있다") + fun getHelpList() { + // given + val user = userTestFactory.persistUser() + helpTestFactory.persistMultipleHelpsByUser(user.id, HelpType.WHISKEY, 5) + + // when & then + assertThat( + mockMvcTester.get().uri("/helps") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("상태로 필터링하여 조회할 수 있다") + fun getHelpListFilterByStatus() { + // given + val user = userTestFactory.persistUser() + helpTestFactory.persistHelp(user.id, HelpType.WHISKEY) + + // when & then + assertThat( + mockMvcTester.get().uri("/helps") + .header("Authorization", "Bearer $accessToken") + .param("status", StatusType.WAITING.name) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("문의 유형으로 필터링하여 조회할 수 있다") + fun getHelpListFilterByType() { + // given + val user = userTestFactory.persistUser() + helpTestFactory.persistHelp(user.id, HelpType.WHISKEY) + helpTestFactory.persistHelp(user.id, HelpType.REVIEW) + + // when & then + assertThat( + mockMvcTester.get().uri("/helps") + .header("Authorization", "Bearer $accessToken") + .param("type", HelpType.WHISKEY.name) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("인증 없이 조회하면 실패한다") + fun getHelpListWithoutAuth() { + // when & then + assertThat( + mockMvcTester.get().uri("/helps") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("문의 상세 조회 API") + inner class GetHelpDetailTest { + + @Test + @DisplayName("문의 상세를 조회할 수 있다") + fun getHelpDetail() { + // given + val user = userTestFactory.persistUser() + val help = helpTestFactory.persistHelp(user.id, HelpType.WHISKEY, "테스트 제목", "테스트 내용") + + // when & then + assertThat( + mockMvcTester.get().uri("/helps/${help.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + + assertThat( + mockMvcTester.get().uri("/helps/${help.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.title").isEqualTo("테스트 제목") + } + + @Test + @DisplayName("존재하지 않는 문의를 조회하면 실패한다") + fun getHelpDetailNotFound() { + // when & then + assertThat( + mockMvcTester.get().uri("/helps/99999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } + } + + @Nested + @DisplayName("문의 답변 등록 API") + inner class AnswerHelpTest { + + @Test + @DisplayName("문의에 답변을 등록할 수 있다") + fun answerHelp() { + // given + val user = userTestFactory.persistUser() + val help = helpTestFactory.persistHelp(user.id, HelpType.WHISKEY) + + val request = mapOf( + "responseContent" to "답변 내용입니다.", + "status" to StatusType.SUCCESS.name + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/helps/${help.id}/answer") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + + assertThat( + mockMvcTester.post().uri("/helps/${help.id}/answer") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.status").isEqualTo(StatusType.SUCCESS.name) + } + + @Test + @DisplayName("반려 상태로 답변을 등록할 수 있다") + fun answerHelpWithReject() { + // given + val user = userTestFactory.persistUser() + val help = helpTestFactory.persistHelp(user.id, HelpType.WHISKEY) + + val request = mapOf( + "responseContent" to "반려 사유입니다.", + "status" to StatusType.REJECT.name + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/helps/${help.id}/answer") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.status").isEqualTo(StatusType.REJECT.name) + } + + @Test + @DisplayName("존재하지 않는 문의에 답변하면 실패한다") + fun answerHelpNotFound() { + // given + val request = mapOf( + "responseContent" to "답변 내용입니다.", + "status" to StatusType.SUCCESS.name + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/helps/99999/answer") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + .bodyJson() + .extractingPath("$.success").isEqualTo(false) + } + + @Test + @DisplayName("답변 내용 없이 요청하면 실패한다") + fun answerHelpWithoutContent() { + // given + val user = userTestFactory.persistUser() + val help = helpTestFactory.persistHelp(user.id, HelpType.WHISKEY) + + val request = mapOf( + "responseContent" to "", + "status" to StatusType.SUCCESS.name + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/helps/${help.id}/answer") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/domain/Help.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/domain/Help.java index 3d93fe89d..1099e1786 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/help/domain/Help.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/domain/Help.java @@ -113,4 +113,13 @@ public void deleteHelp() { public boolean isMyHelpPost(Long userId) { return this.userId.equals(userId); } + + public void answer(Long adminId, String responseContent, StatusType status) { + Objects.requireNonNull(adminId, "adminId는 필수입니다"); + Objects.requireNonNull(responseContent, "responseContent는 필수입니다"); + Objects.requireNonNull(status, "status는 필수입니다"); + this.adminId = adminId; + this.responseContent = responseContent; + this.status = status; + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/domain/HelpRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/domain/HelpRepository.java index e73391dca..51b9f63fe 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/help/domain/HelpRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/domain/HelpRepository.java @@ -2,7 +2,9 @@ import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.support.help.constant.HelpType; +import app.bottlenote.support.help.dto.request.AdminHelpPageableRequest; import app.bottlenote.support.help.dto.request.HelpPageableRequest; +import app.bottlenote.support.help.dto.response.AdminHelpListResponse; import app.bottlenote.support.help.dto.response.HelpListResponse; import java.util.List; import java.util.Optional; @@ -23,4 +25,6 @@ public interface HelpRepository { PageResponse getHelpList( HelpPageableRequest helpPageableRequest, Long currentUserId); + + PageResponse getAdminHelpList(AdminHelpPageableRequest request); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/request/AdminHelpAnswerRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/request/AdminHelpAnswerRequest.java new file mode 100644 index 000000000..79015858c --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/request/AdminHelpAnswerRequest.java @@ -0,0 +1,9 @@ +package app.bottlenote.support.help.dto.request; + +import app.bottlenote.support.constant.StatusType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AdminHelpAnswerRequest( + @NotBlank(message = "답변 내용은 필수입니다.") String responseContent, + @NotNull(message = "처리 상태는 필수입니다.") StatusType status) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/request/AdminHelpPageableRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/request/AdminHelpPageableRequest.java new file mode 100644 index 000000000..bdb30a1fe --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/request/AdminHelpPageableRequest.java @@ -0,0 +1,15 @@ +package app.bottlenote.support.help.dto.request; + +import app.bottlenote.support.constant.StatusType; +import app.bottlenote.support.help.constant.HelpType; +import lombok.Builder; + +public record AdminHelpPageableRequest( + StatusType status, HelpType type, Long cursor, Long pageSize) { + + @Builder + public AdminHelpPageableRequest { + cursor = cursor != null ? cursor : 0L; + pageSize = pageSize != null ? pageSize : 20L; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpAnswerResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpAnswerResponse.java new file mode 100644 index 000000000..ec750fb41 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpAnswerResponse.java @@ -0,0 +1,10 @@ +package app.bottlenote.support.help.dto.response; + +import app.bottlenote.support.constant.StatusType; + +public record AdminHelpAnswerResponse(Long helpId, StatusType status, String message) { + + public static AdminHelpAnswerResponse of(Long helpId, StatusType status) { + return new AdminHelpAnswerResponse(helpId, status, "답변이 등록되었습니다."); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpDetailResponse.java new file mode 100644 index 000000000..95b81412d --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpDetailResponse.java @@ -0,0 +1,23 @@ +package app.bottlenote.support.help.dto.response; + +import app.bottlenote.support.constant.StatusType; +import app.bottlenote.support.help.constant.HelpType; +import app.bottlenote.support.help.dto.request.HelpImageItem; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record AdminHelpDetailResponse( + Long helpId, + Long userId, + String userNickname, + String title, + String content, + HelpType type, + List imageUrlList, + StatusType status, + Long adminId, + String responseContent, + LocalDateTime createAt, + LocalDateTime lastModifyAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpListResponse.java new file mode 100644 index 000000000..61f40088c --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/dto/response/AdminHelpListResponse.java @@ -0,0 +1,24 @@ +package app.bottlenote.support.help.dto.response; + +import app.bottlenote.support.constant.StatusType; +import app.bottlenote.support.help.constant.HelpType; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +public record AdminHelpListResponse(Long totalCount, List helpList) { + + public static AdminHelpListResponse of(Long totalCount, List helpList) { + return new AdminHelpListResponse(totalCount, helpList); + } + + @Builder + public record AdminHelpInfo( + Long helpId, + Long userId, + String userNickname, + String title, + HelpType type, + StatusType status, + LocalDateTime createAt) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/HelpQuerySupporter.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/HelpQuerySupporter.java index 30190e9fc..566bd004d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/HelpQuerySupporter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/HelpQuerySupporter.java @@ -1,7 +1,9 @@ package app.bottlenote.support.help.repository; import static app.bottlenote.support.help.domain.QHelp.help; +import static app.bottlenote.user.domain.QUser.user; +import app.bottlenote.support.help.dto.response.AdminHelpListResponse; import app.bottlenote.support.help.dto.response.HelpListResponse; import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.Projections; @@ -11,9 +13,9 @@ public class HelpQuerySupporter { /** - * 문의글 목록 조회 API에 사용되는 생성자 Projection 메서드입니다. + * 문의글 목록 조회 API에 사용되는 생성자 Projection 메서드입니다. (사용자용) * - * @return + * @return HelpInfo Projection */ public ConstructorExpression helpResponseConstructor() { return Projections.constructor( @@ -24,4 +26,21 @@ public ConstructorExpression helpResponseConstructor( help.createAt.as("createdAt"), help.status.as("helpStatus")); } + + /** + * 문의글 목록 조회 API에 사용되는 생성자 Projection 메서드입니다. (관리자용) + * + * @return AdminHelpInfo Projection + */ + public ConstructorExpression adminHelpResponseConstructor() { + return Projections.constructor( + AdminHelpListResponse.AdminHelpInfo.class, + help.id.as("helpId"), + help.userId.as("userId"), + user.nickName.as("userNickname"), + help.title.as("title"), + help.type.as("type"), + help.status.as("status"), + help.createAt.as("createAt")); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/custom/CustomHelpQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/custom/CustomHelpQueryRepository.java index d4bac34fb..d63973fff 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/custom/CustomHelpQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/custom/CustomHelpQueryRepository.java @@ -1,18 +1,28 @@ package app.bottlenote.support.help.repository.custom; import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.support.help.dto.request.AdminHelpPageableRequest; import app.bottlenote.support.help.dto.request.HelpPageableRequest; +import app.bottlenote.support.help.dto.response.AdminHelpListResponse; import app.bottlenote.support.help.dto.response.HelpListResponse; public interface CustomHelpQueryRepository { /** - * 문의글 목록을 조회하는 메서드입니다. + * 문의글 목록을 조회하는 메서드입니다. (사용자용) * - * @param helpPageableRequest - * @param currentUserId - * @return + * @param helpPageableRequest 페이징 요청 + * @param currentUserId 현재 사용자 ID + * @return 문의글 목록 */ PageResponse getHelpList( HelpPageableRequest helpPageableRequest, Long currentUserId); + + /** + * 문의글 목록을 조회하는 메서드입니다. (관리자용) + * + * @param request 페이징 및 필터링 요청 + * @return 전체 문의글 목록 + */ + PageResponse getAdminHelpList(AdminHelpPageableRequest request); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/custom/CustomHelpQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/custom/CustomHelpQueryRepositoryImpl.java index cc82c86fc..1e74a1252 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/custom/CustomHelpQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/repository/custom/CustomHelpQueryRepositoryImpl.java @@ -1,12 +1,16 @@ package app.bottlenote.support.help.repository.custom; import static app.bottlenote.support.help.domain.QHelp.help; +import static app.bottlenote.user.domain.QUser.user; import app.bottlenote.global.service.cursor.CursorPageable; import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.support.help.dto.request.AdminHelpPageableRequest; import app.bottlenote.support.help.dto.request.HelpPageableRequest; +import app.bottlenote.support.help.dto.response.AdminHelpListResponse; import app.bottlenote.support.help.dto.response.HelpListResponse; import app.bottlenote.support.help.repository.HelpQuerySupporter; +import com.querydsl.core.BooleanBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -67,4 +71,51 @@ private boolean isHasNext( } return hasNext; } + + @Override + public PageResponse getAdminHelpList(AdminHelpPageableRequest request) { + BooleanBuilder whereClause = new BooleanBuilder(); + + if (request.status() != null) { + whereClause.and(help.status.eq(request.status())); + } + if (request.type() != null) { + whereClause.and(help.type.eq(request.type())); + } + + List fetch = + queryFactory + .select(supporter.adminHelpResponseConstructor()) + .from(help) + .leftJoin(user) + .on(help.userId.eq(user.id)) + .where(whereClause) + .orderBy(help.createAt.desc()) + .offset(request.cursor()) + .limit(request.pageSize() + 1) + .fetch(); + + Long totalCount = queryFactory.select(help.id.count()).from(help).where(whereClause).fetchOne(); + + CursorPageable cursorPageable = getAdminCursorPageable(request, fetch); + log.info("Admin CURSOR Pageable info: {}", cursorPageable.toString()); + + return PageResponse.of(AdminHelpListResponse.of(totalCount, fetch), cursorPageable); + } + + private CursorPageable getAdminCursorPageable( + AdminHelpPageableRequest request, List fetch) { + + boolean hasNext = fetch.size() > request.pageSize(); + if (hasNext) { + fetch.remove(fetch.size() - 1); + } + + return CursorPageable.builder() + .cursor(request.cursor() + request.pageSize()) + .pageSize(request.pageSize()) + .hasNext(hasNext) + .currentCursor(request.cursor()) + .build(); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/AdminHelpService.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/AdminHelpService.java new file mode 100644 index 000000000..1a5d706c7 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/AdminHelpService.java @@ -0,0 +1,76 @@ +package app.bottlenote.support.help.service; + +import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_FOUND; + +import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.support.help.domain.Help; +import app.bottlenote.support.help.domain.HelpRepository; +import app.bottlenote.support.help.dto.request.AdminHelpAnswerRequest; +import app.bottlenote.support.help.dto.request.AdminHelpPageableRequest; +import app.bottlenote.support.help.dto.request.HelpImageItem; +import app.bottlenote.support.help.dto.response.AdminHelpAnswerResponse; +import app.bottlenote.support.help.dto.response.AdminHelpDetailResponse; +import app.bottlenote.support.help.dto.response.AdminHelpListResponse; +import app.bottlenote.support.help.exception.HelpException; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AdminHelpService { + + private final HelpRepository helpRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public PageResponse getHelpList(AdminHelpPageableRequest request) { + return helpRepository.getAdminHelpList(request); + } + + @Transactional(readOnly = true) + public AdminHelpDetailResponse getHelpDetail(Long helpId) { + Help help = + helpRepository.findById(helpId).orElseThrow(() -> new HelpException(HELP_NOT_FOUND)); + + String userNickname = + userRepository.findById(help.getUserId()).map(User::getNickName).orElse("탈퇴한 사용자"); + + return AdminHelpDetailResponse.builder() + .helpId(help.getId()) + .userId(help.getUserId()) + .userNickname(userNickname) + .title(help.getTitle()) + .content(help.getContent()) + .type(help.getType()) + .imageUrlList( + help.getHelpImageList().getHelpImages().stream() + .map( + image -> + HelpImageItem.create( + image.getHelpimageInfo().getOrder(), + image.getHelpimageInfo().getImageUrl())) + .toList()) + .status(help.getStatus()) + .adminId(help.getAdminId()) + .responseContent(help.getResponseContent()) + .createAt(help.getCreateAt()) + .lastModifyAt(help.getLastModifyAt()) + .build(); + } + + @Transactional + public AdminHelpAnswerResponse answerHelp( + Long helpId, Long adminId, AdminHelpAnswerRequest request) { + Help help = + helpRepository.findById(helpId).orElseThrow(() -> new HelpException(HELP_NOT_FOUND)); + + help.answer(adminId, request.responseContent(), request.status()); + + return AdminHelpAnswerResponse.of(help.getId(), help.getStatus()); + } +} From 8f3a84ff282db65fd0da889b5d60dcbc7927f52f Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 30 Dec 2025 15:34:55 +0900 Subject: [PATCH 16/17] =?UTF-8?q?docs:=20Admin=20Help=20API=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/docs/asciidoc/admin-api.adoc | 6 ++ .../docs/asciidoc/api/admin-help/help.adoc | 100 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/admin-help/help.adoc diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index a85ea389c..d855eb49f 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -35,3 +35,9 @@ include::api/admin-auth/auth.adoc[] == Alcohol API include::api/admin-alcohols/alcohols.adoc[] + +''' + +== Help API + +include::api/admin-help/help.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-help/help.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-help/help.adoc new file mode 100644 index 000000000..a6cd1072e --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-help/help.adoc @@ -0,0 +1,100 @@ +=== 문의 목록 조회 === + +- 전체 문의 목록을 조회합니다. +- 상태(status)와 유형(type)으로 필터링할 수 있습니다. +- 커서 기반 페이징을 지원합니다. + +[source] +---- +GET /admin/api/v1/helps +---- + +[discrete] +==== 요청 헤더 ==== + +[discrete] +include::{snippets}/admin/help/list/request-headers.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/help/list/query-parameters.adoc[] +include::{snippets}/admin/help/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/help/list/response-fields.adoc[] +include::{snippets}/admin/help/list/http-response.adoc[] + +''' + +=== 문의 상세 조회 === + +- 특정 문의의 상세 정보를 조회합니다. +- 문의 내용, 이미지, 답변 내용 등을 확인할 수 있습니다. + +[source] +---- +GET /admin/api/v1/helps/{helpId} +---- + +[discrete] +==== 요청 헤더 ==== + +[discrete] +include::{snippets}/admin/help/detail/request-headers.adoc[] + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/help/detail/path-parameters.adoc[] +include::{snippets}/admin/help/detail/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/help/detail/response-fields.adoc[] +include::{snippets}/admin/help/detail/http-response.adoc[] + +''' + +=== 문의 답변 등록 === + +- 문의에 대한 답변을 등록합니다. +- 처리 상태를 SUCCESS(처리 완료) 또는 REJECT(반려)로 설정할 수 있습니다. + +[source] +---- +POST /admin/api/v1/helps/{helpId}/answer +---- + +[discrete] +==== 요청 헤더 ==== + +[discrete] +include::{snippets}/admin/help/answer/request-headers.adoc[] + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/help/answer/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/help/answer/request-fields.adoc[] +include::{snippets}/admin/help/answer/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/help/answer/response-fields.adoc[] +include::{snippets}/admin/help/answer/http-response.adoc[] From 8569276651bcd8b279d7f01778b5f043a6529e1b Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 30 Dec 2025 18:44:46 +0900 Subject: [PATCH 17/17] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=201.0.6?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bottlenote-admin-api/VERSION | 2 +- git.environment-variables | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index 90a27f9ce..af0b7ddbf 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.5 +1.0.6 diff --git a/git.environment-variables b/git.environment-variables index 378cbb86e..375ea7433 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 378cbb86ef9581a8043c8f874d9a61c1b17aaa82 +Subproject commit 375ea74335f68cff7b94062fd5918faec11673c0