From 1440f00ad531a1e92c2fd358b35395c265c8f695 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 23 Jan 2026 13:34:15 +0900 Subject: [PATCH 01/29] feat : add batch deployment scripts and bump version --- .claude/skills/deploy-batch/SKILL.md | 112 +++++++++++++ .../deploy-batch/scripts/build-image.sh | 88 ++++++++++ .../deploy-batch/scripts/bump-version.sh | 88 ++++++++++ .../scripts/check-prerequisites.sh | 102 ++++++++++++ .../deploy-batch/scripts/check-version.sh | 95 +++++++++++ .../deploy-batch/scripts/decrypt-registry.sh | 69 ++++++++ .../skills/deploy-batch/scripts/push-image.sh | 122 ++++++++++++++ .../deploy-batch/scripts/update-kustomize.sh | 156 ++++++++++++++++++ bottlenote-batch/VERSION | 2 +- git.environment-variables | 2 +- 10 files changed, 834 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/deploy-batch/SKILL.md create mode 100755 .claude/skills/deploy-batch/scripts/build-image.sh create mode 100755 .claude/skills/deploy-batch/scripts/bump-version.sh create mode 100755 .claude/skills/deploy-batch/scripts/check-prerequisites.sh create mode 100755 .claude/skills/deploy-batch/scripts/check-version.sh create mode 100755 .claude/skills/deploy-batch/scripts/decrypt-registry.sh create mode 100755 .claude/skills/deploy-batch/scripts/push-image.sh create mode 100755 .claude/skills/deploy-batch/scripts/update-kustomize.sh diff --git a/.claude/skills/deploy-batch/SKILL.md b/.claude/skills/deploy-batch/SKILL.md new file mode 100644 index 000000000..ef836fb9a --- /dev/null +++ b/.claude/skills/deploy-batch/SKILL.md @@ -0,0 +1,112 @@ +--- +name: deploy-batch +description: | + 배치 모듈 배포. "배치 배포", "batch deploy", "배치 이미지 올려줘" 요청 시 사용. + 단계별 스크립트로 구성되어 개별 테스트 가능. +disable-model-invocation: true +allowed-tools: Bash, Read, Edit, Write, AskUserQuestion +--- + +# Batch 모듈 배포 + +## 현재 상태 + +### 사전 조건 +!`.claude/skills/deploy-batch/scripts/check-prerequisites.sh 2>&1 || true` + +### 버전 정보 +!`.claude/skills/deploy-batch/scripts/check-version.sh both 2>&1 || true` + +## 배포 플로우 + +``` +1. 사전 조건 확인 (check-prerequisites.sh) +2. 레지스트리 인증 복호화 (decrypt-registry.sh) +3. 버전 확인 (check-version.sh) + → 충돌 시 버전 증가 (bump-version.sh) +4. [사용자 질문] 배포 환경 선택 +5. [사용자 질문] cosign 서명 여부 +6. 이미지 빌드 (build-image.sh) +7. 이미지 푸시 (push-image.sh) +8. kustomize 업데이트 (update-kustomize.sh) +``` + +## 실행 가이드 + +### 1단계: 사전 조건 확인 +```bash +.claude/skills/deploy-batch/scripts/check-prerequisites.sh +``` +- 실패 시 안내된 설치 명령어 실행 후 재시도 + +### 2단계: 버전 확인 및 조정 +```bash +.claude/skills/deploy-batch/scripts/check-version.sh both +``` +- STATUS=CONFLICT 시 버전 증가 필요: +```bash +.claude/skills/deploy-batch/scripts/bump-version.sh --patch +``` + +### 3단계: 사용자에게 질문 (AskUserQuestion) + +**배포 환경 선택:** +- Production만 +- Development만 +- 둘 다 (권장) + +**cosign 서명 여부:** +- 서명 포함 (권장) +- 서명 제외 + +### 4단계: 이미지 빌드 +```bash +# VERSION 생략 시 VERSION 파일에서 자동 읽음 +.claude/skills/deploy-batch/scripts/build-image.sh +.claude/skills/deploy-batch/scripts/build-image.sh 1.0.0 # 명시적 버전 +``` + +### 5단계: 이미지 푸시 +```bash +# 서명 없이 +.claude/skills/deploy-batch/scripts/push-image.sh + +# 서명 포함 +.claude/skills/deploy-batch/scripts/push-image.sh --sign +``` + +### 6단계: kustomize 업데이트 +```bash +# 환경: production | development | both (기본값: both) +.claude/skills/deploy-batch/scripts/update-kustomize.sh +.claude/skills/deploy-batch/scripts/update-kustomize.sh production +.claude/skills/deploy-batch/scripts/update-kustomize.sh 1.0.0 both # 명시적 버전 +``` + +## 스크립트 목록 + +| 스크립트 | 용도 | +|---------|------| +| `check-prerequisites.sh` | 필수 도구/키 확인, 미충족 시 즉시 중단 | +| `decrypt-registry.sh` | sops로 레지스트리 인증 정보 복호화 | +| `check-version.sh` | VERSION 파일과 배포 태그 비교 | +| `bump-version.sh` | semver 패치/마이너/메이저 증가 | +| `build-image.sh` | Gradle + Docker 빌드 | +| `push-image.sh` | Docker 푸시 + cosign 서명 (선택) | +| `update-kustomize.sh` | kustomize 태그 업데이트 + 서브모듈 푸시 | + +## 테스트 (dry-run) + +모든 스크립트는 `--dry-run` 옵션 지원: +```bash +.claude/skills/deploy-batch/scripts/bump-version.sh --dry-run +.claude/skills/deploy-batch/scripts/build-image.sh --dry-run +.claude/skills/deploy-batch/scripts/push-image.sh --dry-run --sign +.claude/skills/deploy-batch/scripts/update-kustomize.sh both --dry-run +``` + +## 주의사항 + +- VERSION에 `+` 문자 사용 금지 (Docker 태그 제한) +- 서브모듈 푸시 전 자동으로 `git pull --rebase` 실행 +- cosign 서명 시 cosign.key 파일 필요 diff --git a/.claude/skills/deploy-batch/scripts/build-image.sh b/.claude/skills/deploy-batch/scripts/build-image.sh new file mode 100755 index 000000000..a5d900727 --- /dev/null +++ b/.claude/skills/deploy-batch/scripts/build-image.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +# build-image.sh +# Gradle 빌드 + Docker 이미지 빌드 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +# 파라미터 파싱 +VERSION="" +DRY_RUN=false +SKIP_GRADLE=false + +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + --skip-gradle) + SKIP_GRADLE=true + shift + ;; + -*) + echo "Usage: $0 [version] [--dry-run] [--skip-gradle]" >&2 + exit 1 + ;; + *) + VERSION="$1" + shift + ;; + esac +done + +# 버전 파라미터 검증 +if [[ -z "$VERSION" ]]; then + VERSION_FILE="$PROJECT_ROOT/bottlenote-batch/VERSION" + if [[ -f "$VERSION_FILE" ]]; then + VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') + echo -e "${YELLOW}[INFO]${NC} VERSION 파일에서 읽음: $VERSION" + else + echo -e "${RED}[ERROR]${NC} 버전을 지정하거나 VERSION 파일이 필요합니다" >&2 + exit 1 + fi +fi + +REGISTRY="docker-registry.bottle-note.com" +IMAGE_NAME="bottlenote-batch" +FULL_TAG="${REGISTRY}/${IMAGE_NAME}:batch_${VERSION}" + +echo "=== 빌드 시작 ===" +echo "버전: $VERSION" +echo "이미지: $FULL_TAG" +echo "" + +# 1. Gradle 빌드 +if [[ "$SKIP_GRADLE" == "false" ]]; then + echo -e "${GREEN}[1/2]${NC} Gradle 빌드..." + if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} ./gradlew :bottlenote-batch:build -x test --build-cache --parallel" + else + cd "$PROJECT_ROOT" + ./gradlew :bottlenote-batch:build -x test --build-cache --parallel + echo -e "${GREEN}[OK]${NC} Gradle 빌드 완료" + fi +else + echo -e "${YELLOW}[SKIP]${NC} Gradle 빌드 건너뜀" +fi + +# 2. Docker 빌드 +echo -e "${GREEN}[2/2]${NC} Docker 이미지 빌드..." +if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} docker build --platform linux/arm64 -f Dockerfile-batch -t $FULL_TAG ." +else + cd "$PROJECT_ROOT" + docker build --platform linux/arm64 -f Dockerfile-batch -t "$FULL_TAG" . + echo -e "${GREEN}[OK]${NC} Docker 빌드 완료: $FULL_TAG" +fi + +echo "" +echo "---" +echo "IMAGE=$FULL_TAG" diff --git a/.claude/skills/deploy-batch/scripts/bump-version.sh b/.claude/skills/deploy-batch/scripts/bump-version.sh new file mode 100755 index 000000000..f1452aaa2 --- /dev/null +++ b/.claude/skills/deploy-batch/scripts/bump-version.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +# bump-version.sh +# 패치 버전 자동 증가 (semver) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +# 옵션 파싱 +DRY_RUN=false +BUMP_TYPE="patch" + +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + --minor) + BUMP_TYPE="minor" + shift + ;; + --major) + BUMP_TYPE="major" + shift + ;; + --patch) + BUMP_TYPE="patch" + shift + ;; + *) + echo "Usage: $0 [--dry-run] [--patch|--minor|--major]" >&2 + exit 1 + ;; + esac +done + +VERSION_FILE="$PROJECT_ROOT/bottlenote-batch/VERSION" + +if [[ ! -f "$VERSION_FILE" ]]; then + echo -e "${RED}[ERROR]${NC} VERSION 파일 없음: $VERSION_FILE" >&2 + exit 1 +fi + +CURRENT_VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') + +# semver 파싱 +if [[ ! "$CURRENT_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo -e "${RED}[ERROR]${NC} 잘못된 버전 형식: $CURRENT_VERSION (expected: X.Y.Z)" >&2 + exit 1 +fi + +MAJOR="${BASH_REMATCH[1]}" +MINOR="${BASH_REMATCH[2]}" +PATCH="${BASH_REMATCH[3]}" + +# 버전 증가 +case $BUMP_TYPE in + major) + NEW_MAJOR=$((MAJOR + 1)) + NEW_VERSION="${NEW_MAJOR}.0.0" + ;; + minor) + NEW_MINOR=$((MINOR + 1)) + NEW_VERSION="${MAJOR}.${NEW_MINOR}.0" + ;; + patch) + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" + ;; +esac + +echo "=== 버전 증가 ($BUMP_TYPE) ===" +echo "OLD=$CURRENT_VERSION" +echo "NEW=$NEW_VERSION" + +if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} 실제 파일 수정 없음" +else + echo "$NEW_VERSION" > "$VERSION_FILE" + echo -e "${GREEN}[OK]${NC} VERSION 파일 업데이트 완료" +fi diff --git a/.claude/skills/deploy-batch/scripts/check-prerequisites.sh b/.claude/skills/deploy-batch/scripts/check-prerequisites.sh new file mode 100755 index 000000000..d4f9bbff4 --- /dev/null +++ b/.claude/skills/deploy-batch/scripts/check-prerequisites.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# check-prerequisites.sh +# 배포에 필요한 도구와 키 존재 확인 +# 필수 조건 미충족 시 즉시 중단 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +panic() { + echo -e "${RED}[FATAL]${NC} $1" >&2 + echo -e "${YELLOW}[해결방법]${NC} $2" >&2 + exit 1 +} + +ok() { + echo -e "${GREEN}[OK]${NC} $1" +} + +echo "=== 사전 조건 확인 ===" + +# 1. age 키 확인 +AGE_KEY_PATH="${HOME}/.config/sops/age/keys.txt" +if [[ -f "$AGE_KEY_PATH" ]]; then + ok "age 키: $AGE_KEY_PATH" +else + panic "age 키 없음: $AGE_KEY_PATH" \ + "age-keygen -o ~/.config/sops/age/keys.txt" +fi + +# 2. sops 설치 확인 +if command -v sops &> /dev/null; then + SOPS_VERSION=$(sops --version 2>&1 | head -1) + ok "sops: $SOPS_VERSION" +else + panic "sops 미설치" \ + "brew install sops" +fi + +# 3. docker 설치 확인 +if command -v docker &> /dev/null; then + DOCKER_VERSION=$(docker --version) + ok "docker: $DOCKER_VERSION" +else + panic "docker 미설치" \ + "brew install --cask docker" +fi + +# 4. kustomize 설치 확인 +if command -v kustomize &> /dev/null; then + KUSTOMIZE_VERSION=$(kustomize version 2>&1 | head -1) + ok "kustomize: $KUSTOMIZE_VERSION" +else + panic "kustomize 미설치" \ + "brew install kustomize" +fi + +# 5. cosign 설치 확인 (선택사항 - 경고만) +if command -v cosign &> /dev/null; then + COSIGN_VERSION=$(cosign version 2>&1 | grep -i version | head -1 || echo "installed") + ok "cosign: $COSIGN_VERSION" +else + echo -e "${YELLOW}[WARN]${NC} cosign 미설치 (서명 기능 제한) - brew install cosign" +fi + +# 6. VERSION 파일 확인 +VERSION_FILE="$PROJECT_ROOT/bottlenote-batch/VERSION" +if [[ -f "$VERSION_FILE" ]]; then + VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') + ok "VERSION 파일: $VERSION" +else + panic "VERSION 파일 없음: $VERSION_FILE" \ + "echo '1.0.0' > $VERSION_FILE" +fi + +# 7. registry.sops.env 파일 확인 +SOPS_ENV_FILE="$PROJECT_ROOT/git.environment-variables/storage/docker-registry/registry.sops.env" +if [[ -f "$SOPS_ENV_FILE" ]]; then + ok "registry.sops.env 존재" +else + panic "registry.sops.env 없음" \ + "git submodule update --init --recursive" +fi + +# 8. Dockerfile-batch 확인 +DOCKERFILE="$PROJECT_ROOT/Dockerfile-batch" +if [[ -f "$DOCKERFILE" ]]; then + ok "Dockerfile-batch 존재" +else + panic "Dockerfile-batch 없음: $DOCKERFILE" \ + "프로젝트 루트에 Dockerfile-batch 파일 필요" +fi + +echo "" +echo -e "${GREEN}=== 모든 사전 조건 충족 ===${NC}" +exit 0 diff --git a/.claude/skills/deploy-batch/scripts/check-version.sh b/.claude/skills/deploy-batch/scripts/check-version.sh new file mode 100755 index 000000000..b73e307d9 --- /dev/null +++ b/.claude/skills/deploy-batch/scripts/check-version.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# check-version.sh +# 현재 버전과 배포된 태그 비교 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +# 환경 파라미터 +ENV="${1:-both}" + +if [[ "$ENV" != "production" ]] && [[ "$ENV" != "development" ]] && [[ "$ENV" != "both" ]]; then + echo "Usage: $0 [production|development|both]" >&2 + exit 1 +fi + +# VERSION 파일 읽기 +VERSION_FILE="$PROJECT_ROOT/bottlenote-batch/VERSION" +if [[ ! -f "$VERSION_FILE" ]]; then + echo -e "${RED}[ERROR]${NC} VERSION 파일 없음: $VERSION_FILE" >&2 + exit 1 +fi +CURRENT_VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') + +# kustomization.yaml에서 batch 태그 추출 +get_deployed_tag() { + local env=$1 + local kustomize_file="$PROJECT_ROOT/git.environment-variables/deploy/overlays/$env/kustomization.yaml" + + if [[ ! -f "$kustomize_file" ]]; then + echo "NOT_FOUND" + return + fi + + # newTag: batch_X.X.X 형식에서 버전 추출 + local tag=$(grep -A2 "name: bottlenote-batch" "$kustomize_file" | grep "newTag:" | sed 's/.*newTag: batch_//' | tr -d '[:space:]') + + if [[ -z "$tag" ]]; then + echo "NOT_FOUND" + else + echo "$tag" + fi +} + +echo "=== 버전 확인 ===" +echo "VERSION 파일: $CURRENT_VERSION" +echo "" + +CONFLICT=false + +check_env() { + local env=$1 + local deployed=$(get_deployed_tag "$env") + + if [[ "$deployed" == "NOT_FOUND" ]]; then + echo -e "${YELLOW}[$env]${NC} 태그 없음" + elif [[ "$deployed" == "$CURRENT_VERSION" ]]; then + echo -e "${RED}[$env]${NC} 태그: batch_$deployed (버전 충돌!)" + CONFLICT=true + else + echo -e "${GREEN}[$env]${NC} 태그: batch_$deployed" + fi +} + +if [[ "$ENV" == "both" ]] || [[ "$ENV" == "production" ]]; then + check_env "production" +fi + +if [[ "$ENV" == "both" ]] || [[ "$ENV" == "development" ]]; then + check_env "development" +fi + +echo "" + +# 결과 출력 (스크립트에서 파싱 가능한 형식) +echo "---" +echo "VERSION=$CURRENT_VERSION" +PROD_TAG=$(get_deployed_tag "production") +DEV_TAG=$(get_deployed_tag "development") +echo "PRODUCTION_TAG=$PROD_TAG" +echo "DEVELOPMENT_TAG=$DEV_TAG" + +if [[ "$CONFLICT" == "true" ]]; then + echo "STATUS=CONFLICT" + echo -e "${YELLOW}[권장]${NC} bump-version.sh 실행하여 버전 증가 필요" + exit 1 +else + echo "STATUS=OK" +fi diff --git a/.claude/skills/deploy-batch/scripts/decrypt-registry.sh b/.claude/skills/deploy-batch/scripts/decrypt-registry.sh new file mode 100755 index 000000000..32ecf3157 --- /dev/null +++ b/.claude/skills/deploy-batch/scripts/decrypt-registry.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +# decrypt-registry.sh +# sops로 레지스트리 인증 정보 복호화 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +SOPS_ENV_FILE="$PROJECT_ROOT/git.environment-variables/storage/docker-registry/registry.sops.env" + +# 옵션 파싱 +EXPORT_MODE=false +while [[ $# -gt 0 ]]; do + case $1 in + --export) + EXPORT_MODE=true + shift + ;; + *) + echo "Usage: $0 [--export]" + echo " --export: export 문 형태로 출력 (source로 사용 가능)" + exit 1 + ;; + esac +done + +# age 키 존재 확인 +AGE_KEY_PATH="${HOME}/.config/sops/age/keys.txt" +if [[ ! -f "$AGE_KEY_PATH" ]]; then + echo "ERROR: age 키 없음: $AGE_KEY_PATH" >&2 + exit 1 +fi + +# sops.env 파일 존재 확인 +if [[ ! -f "$SOPS_ENV_FILE" ]]; then + echo "ERROR: registry.sops.env 없음: $SOPS_ENV_FILE" >&2 + exit 1 +fi + +# sops로 복호화 +DECRYPTED=$(sops -d "$SOPS_ENV_FILE" 2>/dev/null) + +# 파싱 +REGISTRY_ADDRESS=$(echo "$DECRYPTED" | grep "^REGISTRY_ADDRESS=" | cut -d'=' -f2-) +REGISTRY_USERNAME=$(echo "$DECRYPTED" | grep "^REGISTRY_USERNAME=" | cut -d'=' -f2-) +REGISTRY_PASSWORD=$(echo "$DECRYPTED" | grep "^REGISTRY_PASSWORD=" | cut -d'=' -f2-) +COSIGN_PASSWORD=$(echo "$DECRYPTED" | grep "^COSIGN_PASSWORD=" | cut -d'=' -f2-) + +if [[ -z "$REGISTRY_ADDRESS" ]] || [[ -z "$REGISTRY_USERNAME" ]] || [[ -z "$REGISTRY_PASSWORD" ]]; then + echo "ERROR: 복호화 실패 또는 필수 필드 누락" >&2 + exit 1 +fi + +if [[ "$EXPORT_MODE" == "true" ]]; then + # source로 사용 가능한 형태로 출력 + echo "export REGISTRY_ADDRESS=\"$REGISTRY_ADDRESS\"" + echo "export REGISTRY_USERNAME=\"$REGISTRY_USERNAME\"" + echo "export REGISTRY_PASSWORD=\"$REGISTRY_PASSWORD\"" + [[ -n "$COSIGN_PASSWORD" ]] && echo "export COSIGN_PASSWORD=\"$COSIGN_PASSWORD\"" +else + # 일반 출력 + echo "REGISTRY_ADDRESS=$REGISTRY_ADDRESS" + echo "REGISTRY_USERNAME=$REGISTRY_USERNAME" + echo "REGISTRY_PASSWORD=***" + echo "COSIGN_PASSWORD=${COSIGN_PASSWORD:+***}" + echo "" + echo "복호화 성공. 전체 값을 보려면 --export 옵션 사용" +fi diff --git a/.claude/skills/deploy-batch/scripts/push-image.sh b/.claude/skills/deploy-batch/scripts/push-image.sh new file mode 100755 index 000000000..f6a2db91f --- /dev/null +++ b/.claude/skills/deploy-batch/scripts/push-image.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +# push-image.sh +# Docker 푸시 및 선택적 cosign 서명 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +# 파라미터 파싱 +VERSION="" +DRY_RUN=false +SIGN=false + +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + --sign) + SIGN=true + shift + ;; + -*) + echo "Usage: $0 [version] [--dry-run] [--sign]" >&2 + exit 1 + ;; + *) + VERSION="$1" + shift + ;; + esac +done + +# 버전 파라미터 검증 +if [[ -z "$VERSION" ]]; then + VERSION_FILE="$PROJECT_ROOT/bottlenote-batch/VERSION" + if [[ -f "$VERSION_FILE" ]]; then + VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') + echo -e "${YELLOW}[INFO]${NC} VERSION 파일에서 읽음: $VERSION" + else + echo -e "${RED}[ERROR]${NC} 버전을 지정하거나 VERSION 파일이 필요합니다" >&2 + exit 1 + fi +fi + +REGISTRY="docker-registry.bottle-note.com" +IMAGE_NAME="bottlenote-batch" +FULL_TAG="${REGISTRY}/${IMAGE_NAME}:batch_${VERSION}" + +echo "=== Docker 푸시 ===" +echo "이미지: $FULL_TAG" +echo "서명: $SIGN" +echo "" + +# 레지스트리 인증 정보 복호화 +echo -e "${GREEN}[1/3]${NC} 레지스트리 인증..." +if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} decrypt-registry.sh --export" + REGISTRY_ADDRESS="docker-registry.bottle-note.com" + REGISTRY_USERNAME="test" + REGISTRY_PASSWORD="test" +else + DECRYPT_OUTPUT=$("$SCRIPT_DIR/decrypt-registry.sh" --export) + eval "$DECRYPT_OUTPUT" + + # Docker 로그인 + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_ADDRESS" -u "$REGISTRY_USERNAME" --password-stdin + echo -e "${GREEN}[OK]${NC} Docker 로그인 완료" +fi + +# Docker 푸시 +echo -e "${GREEN}[2/3]${NC} Docker 이미지 푸시..." +if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} docker push $FULL_TAG" +else + docker push "$FULL_TAG" + echo -e "${GREEN}[OK]${NC} 푸시 완료: $FULL_TAG" +fi + +# cosign 서명 (선택) +if [[ "$SIGN" == "true" ]]; then + echo -e "${GREEN}[3/3]${NC} cosign 서명..." + + COSIGN_KEY="$PROJECT_ROOT/git.environment-variables/storage/docker-registry/cosign.key" + + if [[ ! -f "$COSIGN_KEY" ]]; then + echo -e "${RED}[ERROR]${NC} cosign 키 없음: $COSIGN_KEY" >&2 + exit 1 + fi + + if ! command -v cosign &> /dev/null; then + echo -e "${RED}[ERROR]${NC} cosign 미설치" >&2 + exit 1 + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} COSIGN_PASSWORD=*** cosign sign --key $COSIGN_KEY $FULL_TAG" + else + # COSIGN_PASSWORD는 decrypt-registry.sh에서 export됨 + if [[ -z "${COSIGN_PASSWORD:-}" ]]; then + echo -e "${RED}[ERROR]${NC} COSIGN_PASSWORD 없음 (registry.sops.env에 추가 필요)" >&2 + exit 1 + fi + export COSIGN_PASSWORD + cosign sign --key "$COSIGN_KEY" "$FULL_TAG" + echo -e "${GREEN}[OK]${NC} cosign 서명 완료" + fi +else + echo -e "${YELLOW}[SKIP]${NC} cosign 서명 건너뜀 (--sign 옵션 없음)" +fi + +echo "" +echo "---" +echo "PUSHED=$FULL_TAG" +echo "SIGNED=$SIGN" diff --git a/.claude/skills/deploy-batch/scripts/update-kustomize.sh b/.claude/skills/deploy-batch/scripts/update-kustomize.sh new file mode 100755 index 000000000..3472062a5 --- /dev/null +++ b/.claude/skills/deploy-batch/scripts/update-kustomize.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +# update-kustomize.sh +# kustomization.yaml 업데이트 및 서브모듈 푸시 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +# 파라미터 파싱 +VERSION="" +ENV="both" +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + production|development|both) + ENV="$1" + shift + ;; + -*) + echo "Usage: $0 [version] [production|development|both] [--dry-run]" >&2 + exit 1 + ;; + *) + VERSION="$1" + shift + ;; + esac +done + +# 버전 파라미터 검증 +if [[ -z "$VERSION" ]]; then + VERSION_FILE="$PROJECT_ROOT/bottlenote-batch/VERSION" + if [[ -f "$VERSION_FILE" ]]; then + VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') + echo -e "${YELLOW}[INFO]${NC} VERSION 파일에서 읽음: $VERSION" + else + echo -e "${RED}[ERROR]${NC} 버전을 지정하거나 VERSION 파일이 필요합니다" >&2 + exit 1 + fi +fi + +# 환경 파라미터 검증 +if [[ "$ENV" != "production" ]] && [[ "$ENV" != "development" ]] && [[ "$ENV" != "both" ]]; then + echo -e "${RED}[ERROR]${NC} 잘못된 환경: $ENV (production|development|both)" >&2 + exit 1 +fi + +REGISTRY="docker-registry.bottle-note.com" +IMAGE_NAME="bottlenote-batch" +FULL_TAG="batch_${VERSION}" +SUBMODULE_DIR="$PROJECT_ROOT/git.environment-variables" + +echo "=== Kustomize 업데이트 ===" +echo "버전: $VERSION" +echo "태그: $FULL_TAG" +echo "환경: $ENV" +echo "" + +# 서브모듈 동기화 (최우선) +echo -e "${GREEN}[SYNC]${NC} 서브모듈 원격 동기화..." +if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} cd $SUBMODULE_DIR && git fetch origin && git reset --hard origin/main" +else + cd "$SUBMODULE_DIR" + + # 현재 브랜치 확인 + CURRENT_BRANCH=$(git branch --show-current) + if [[ "$CURRENT_BRANCH" != "main" ]]; then + echo -e "${YELLOW}[WARN]${NC} 현재 브랜치: $CURRENT_BRANCH (main이 아님)" + git checkout main + fi + + # 원격에서 최신 상태 가져오기 + git fetch origin + + # 로컬 변경사항 확인 및 처리 + if ! git diff --quiet || ! git diff --cached --quiet; then + echo -e "${YELLOW}[WARN]${NC} 로컬 변경사항 있음 - 리셋합니다" + git reset --hard origin/main + else + # 변경사항 없으면 fast-forward + git reset --hard origin/main + fi + + echo -e "${GREEN}[OK]${NC} 서브모듈 동기화 완료 (origin/main)" +fi +echo "" + +update_kustomize() { + local env=$1 + local overlay_dir="$SUBMODULE_DIR/deploy/overlays/$env" + + if [[ ! -d "$overlay_dir" ]]; then + echo -e "${RED}[ERROR]${NC} 디렉토리 없음: $overlay_dir" >&2 + return 1 + fi + + echo -e "${GREEN}[$env]${NC} kustomize 업데이트..." + + if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} cd $overlay_dir && kustomize edit set image bottlenote-batch=${REGISTRY}/${IMAGE_NAME}:${FULL_TAG}" + else + cd "$overlay_dir" + kustomize edit set image "bottlenote-batch=${REGISTRY}/${IMAGE_NAME}:${FULL_TAG}" + echo -e "${GREEN}[OK]${NC} $env 업데이트 완료" + fi +} + +# 환경별 업데이트 +if [[ "$ENV" == "both" ]] || [[ "$ENV" == "production" ]]; then + update_kustomize "production" +fi + +if [[ "$ENV" == "both" ]] || [[ "$ENV" == "development" ]]; then + update_kustomize "development" +fi + +# 서브모듈 커밋 및 푸시 +echo "" +echo -e "${GREEN}[COMMIT]${NC} 서브모듈 커밋 및 푸시..." + +if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}[DRY-RUN]${NC} cd $SUBMODULE_DIR" + echo -e "${YELLOW}[DRY-RUN]${NC} git add -A" + echo -e "${YELLOW}[DRY-RUN]${NC} git commit -m 'chore(batch): update image tag to ${FULL_TAG}'" + echo -e "${YELLOW}[DRY-RUN]${NC} git push origin main" +else + cd "$SUBMODULE_DIR" + + # 변경사항 확인 + if git diff --quiet && git diff --cached --quiet; then + echo -e "${YELLOW}[SKIP]${NC} 변경사항 없음" + else + git add -A + git commit -m "chore(batch): update image tag to ${FULL_TAG}" + git push origin main + echo -e "${GREEN}[OK]${NC} 서브모듈 푸시 완료" + fi +fi + +echo "" +echo "---" +echo "VERSION=$VERSION" +echo "TAG=$FULL_TAG" +echo "ENVIRONMENT=$ENV" diff --git a/bottlenote-batch/VERSION b/bottlenote-batch/VERSION index 3eefcb9dd..7dea76edb 100644 --- a/bottlenote-batch/VERSION +++ b/bottlenote-batch/VERSION @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/git.environment-variables b/git.environment-variables index b9910dc0e..cf01d4f68 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit b9910dc0e8fed666e5f4a4a7bec41fb1c3439fd8 +Subproject commit cf01d4f685649652a92c43ea8dcbe61daf357b84 From 6aba04e7906aacd198a57bceda0f32b0f0d1201d Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 27 Jan 2026 22:03:46 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat(admin):=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=ED=8C=85=20=ED=83=9C=EA=B7=B8=20CRUD=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이스팅 태그 상세 조회 (조상/자식/연결된 위스키 포함) - 테이스팅 태그 생성/수정/삭제 - 위스키 벌크 연결/해제 기능 - 트리 구조 지원 (단일 부모, 최대 3depth) - 중복 이름 검증, 자식/연결 위스키 존재 시 삭제 방지 - DTO-엔티티 분리 아키텍처 규칙 준수 Co-Authored-By: Claude Opus 4.5 --- .../presentation/AdminTastingTagController.kt | 51 ++- .../app/helper/alcohols/AlcoholsHelper.kt | 1 + .../AdminTastingTagIntegrationTest.kt | 420 ++++++++++++++++++ .../domain/AlcoholsTastingTagsRepository.java | 14 + .../alcohols/domain/TastingTag.java | 24 +- .../alcohols/domain/TastingTagRepository.java | 15 + .../AdminTastingTagAlcoholRequest.java | 7 + .../request/AdminTastingTagUpsertRequest.java | 10 + .../AdminTastingTagDetailResponse.java | 38 ++ .../dto/response/AdminTastingTagItem.java | 1 + .../exception/AlcoholExceptionCode.java | 8 +- .../JpaAlcoholsTastingTagsRepository.java | 34 ++ .../repository/JpaTastingTagRepository.java | 19 +- .../alcohols/service/TastingTagService.java | 224 ++++++++++ .../dto/response/AdminResultResponse.java | 5 + .../fixture/InMemoryTastingTagRepository.java | 62 ++- .../fixture/TastingTagTestFactory.java | 131 ++++++ .../service/TastingTagServiceTest.java | 11 +- git.environment-variables | 2 +- 19 files changed, 1057 insertions(+), 20 deletions(-) create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagAlcoholRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt index a6077d061..e49d029a4 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt @@ -1,11 +1,20 @@ package app.bottlenote.alcohols.presentation import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.dto.request.AdminTastingTagAlcoholRequest +import app.bottlenote.alcohols.dto.request.AdminTastingTagUpsertRequest import app.bottlenote.alcohols.service.AlcoholReferenceService +import app.bottlenote.alcohols.service.TastingTagService import app.bottlenote.global.data.response.GlobalResponse +import jakarta.validation.Valid import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping 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.PutMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -13,11 +22,51 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/tasting-tags") class AdminTastingTagController( - private val alcoholReferenceService: AlcoholReferenceService + private val alcoholReferenceService: AlcoholReferenceService, + private val tastingTagService: TastingTagService ) { @GetMapping fun getAllTastingTags(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { return ResponseEntity.ok(alcoholReferenceService.findAllTastingTags(request)) } + + @GetMapping("/{tagId}") + fun getTagDetail(@PathVariable tagId: Long): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.getTagDetail(tagId)) + } + + @PostMapping + fun createTag(@RequestBody @Valid request: AdminTastingTagUpsertRequest): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.createTag(request)) + } + + @PutMapping("/{tagId}") + fun updateTag( + @PathVariable tagId: Long, + @RequestBody @Valid request: AdminTastingTagUpsertRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.updateTag(tagId, request)) + } + + @DeleteMapping("/{tagId}") + fun deleteTag(@PathVariable tagId: Long): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.deleteTag(tagId)) + } + + @PostMapping("/{tagId}/alcohols") + fun addAlcoholsToTag( + @PathVariable tagId: Long, + @RequestBody @Valid request: AdminTastingTagAlcoholRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.addAlcoholsToTag(tagId, request.alcoholIds())) + } + + @DeleteMapping("/{tagId}/alcohols") + fun removeAlcoholsFromTag( + @PathVariable tagId: Long, + @RequestBody @Valid request: AdminTastingTagAlcoholRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.removeAlcoholsFromTag(tagId, request.alcoholIds())) + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 1ec30f963..b51c932ae 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -112,6 +112,7 @@ object AlcoholsHelper { "Tag$i", "icon$i.png", "테이스팅 태그 설명 $i", + null, LocalDateTime.of(2024, 1, i, 0, 0), LocalDateTime.of(2024, 6, i, 0, 0) ) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt new file mode 100644 index 000000000..ddb1e39fc --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt @@ -0,0 +1,420 @@ +package app.integration.alcohols + +import app.IntegrationTestSupport +import app.bottlenote.alcohols.fixture.AlcoholTestFactory +import app.bottlenote.alcohols.fixture.TastingTagTestFactory +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 TastingTag API 통합 테스트") +class AdminTastingTagIntegrationTest : IntegrationTestSupport() { + + @Autowired + private lateinit var tastingTagTestFactory: TastingTagTestFactory + + @Autowired + private lateinit var alcoholTestFactory: AlcoholTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("테이스팅 태그 단건 조회 API") + inner class GetTagDetail { + + @Test + @DisplayName("테이스팅 태그 상세 정보를 조회할 수 있다") + fun getTagDetailSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag("허니", "Honey") + + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + + assertThat( + mockMvcTester.get().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.korName").isEqualTo("허니") + } + + @Test + @DisplayName("부모 태그가 있는 경우 조상 정보가 포함된다") + fun getTagDetailWithAncestors() { + // given + val tree = tastingTagTestFactory.persistTastingTagTree() + val leafTag = tree[2] + + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags/${leafTag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.ancestors.length()").isEqualTo(2) + } + + @Test + @DisplayName("연결된 위스키 목록이 포함된다") + fun getTagDetailWithAlcohols() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val alcohol = alcoholTestFactory.persistAlcohol() + tastingTagTestFactory.linkAlcoholToTag(alcohol, tag) + + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.alcohols.length()").isEqualTo(1) + } + + @Test + @DisplayName("존재하지 않는 태그 조회 시 실패한다") + fun getTagDetailNotFound() { + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("테이스팅 태그 생성 API") + inner class CreateTag { + + @Test + @DisplayName("테이스팅 태그를 생성할 수 있다") + fun createTagSuccess() { + // given + val request = mapOf( + "korName" to "새로운 태그", + "engName" to "New Tag", + "description" to "테스트 설명" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_CREATED") + } + + @Test + @DisplayName("부모 태그를 지정하여 생성할 수 있다") + fun createTagWithParent() { + // given + val parent = tastingTagTestFactory.persistTastingTag("부모 태그", "Parent Tag") + val request = mapOf( + "korName" to "자식 태그", + "engName" to "Child Tag", + "parentId" to parent.id + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_CREATED") + } + + @Test + @DisplayName("중복된 한글 이름으로 생성 시 실패한다") + fun createTagDuplicateName() { + // given + tastingTagTestFactory.persistTastingTag("중복 태그", "Duplicate Tag") + val request = mapOf( + "korName" to "중복 태그", + "engName" to "Another Tag" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("최대 깊이를 초과하는 태그 생성 시 실패한다") + fun createTagExceedMaxDepth() { + // given - 3depth 트리 생성 (root -> middle -> leaf) + val tree = tastingTagTestFactory.persistTastingTagTree() + val leafTag = tree[2] + + val request = mapOf( + "korName" to "4depth 태그", + "engName" to "4depth Tag", + "parentId" to leafTag.id + ) + + // when & then - 4depth 생성 시도 시 실패 + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("테이스팅 태그 수정 API") + inner class UpdateTag { + + @Test + @DisplayName("테이스팅 태그를 수정할 수 있다") + fun updateTagSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val request = mapOf( + "korName" to "수정된 태그", + "engName" to "Updated Tag", + "description" to "수정된 설명" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_UPDATED") + } + + @Test + @DisplayName("다른 태그와 중복되는 이름으로 수정 시 실패한다") + fun updateTagDuplicateName() { + // given + tastingTagTestFactory.persistTastingTag("기존 태그", "Existing Tag") + val targetTag = tastingTagTestFactory.persistTastingTag("수정 대상", "Target Tag") + + val request = mapOf( + "korName" to "기존 태그", + "engName" to "Updated Tag" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/tasting-tags/${targetTag.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("존재하지 않는 태그 수정 시 실패한다") + fun updateTagNotFound() { + // given + val request = mapOf( + "korName" to "수정된 태그", + "engName" to "Updated Tag" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/tasting-tags/999999") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("테이스팅 태그 삭제 API") + inner class DeleteTag { + + @Test + @DisplayName("테이스팅 태그를 삭제할 수 있다") + fun deleteTagSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_DELETED") + } + + @Test + @DisplayName("자식 태그가 존재하는 경우 삭제할 수 없다") + fun deleteTagWithChildren() { + // given + val parent = tastingTagTestFactory.persistTastingTag("부모", "Parent") + tastingTagTestFactory.persistTastingTagWithParent(parent) + + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/${parent.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("연결된 위스키가 존재하는 경우 삭제할 수 없다") + fun deleteTagWithAlcohols() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val alcohol = alcoholTestFactory.persistAlcohol() + tastingTagTestFactory.linkAlcoholToTag(alcohol, tag) + + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("존재하지 않는 태그 삭제 시 실패한다") + fun deleteTagNotFound() { + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("테이스팅 태그 위스키 연결 API") + inner class ManageAlcohols { + + @Test + @DisplayName("위스키를 태그에 벌크로 연결할 수 있다") + fun addAlcoholsToTagSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val alcohol1 = alcoholTestFactory.persistAlcohol() + val alcohol2 = alcoholTestFactory.persistAlcohol() + + val request = mapOf("alcoholIds" to listOf(alcohol1.id, alcohol2.id)) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags/${tag.id}/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_ALCOHOL_ADDED") + } + + @Test + @DisplayName("위스키 연결을 벌크로 해제할 수 있다") + fun removeAlcoholsFromTagSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val alcohol1 = alcoholTestFactory.persistAlcohol() + val alcohol2 = alcoholTestFactory.persistAlcohol() + tastingTagTestFactory.linkAlcoholToTag(alcohol1, tag) + tastingTagTestFactory.linkAlcoholToTag(alcohol2, tag) + + val request = mapOf("alcoholIds" to listOf(alcohol1.id)) + + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/${tag.id}/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_ALCOHOL_REMOVED") + } + + @Test + @DisplayName("존재하지 않는 위스키 연결 시 실패한다") + fun addAlcoholsNotFound() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val request = mapOf("alcoholIds" to listOf(999999L)) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags/${tag.id}/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("인증 테스트") + inner class AuthenticationTest { + + @Test + @DisplayName("인증 없이 요청 시 실패한다") + fun requestWithoutAuth() { + // when & then + assertThat(mockMvcTester.get().uri("/tasting-tags/1")) + .hasStatus4xxClientError() + + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(mapOf("korName" to "테스트", "engName" to "Test"))) + ) + .hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java new file mode 100644 index 000000000..97383ad96 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java @@ -0,0 +1,14 @@ +package app.bottlenote.alcohols.domain; + +import java.util.List; + +public interface AlcoholsTastingTagsRepository { + + List findByTastingTagId(Long tastingTagId); + + List saveAll(Iterable alcoholsTastingTags); + + void deleteByTastingTagIdAndAlcoholIdIn(Long tastingTagId, List alcoholIds); + + boolean existsByTastingTagId(Long tastingTagId); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTag.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTag.java index b836dcd3a..2f01db402 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTag.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTag.java @@ -6,6 +6,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Lob; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.ArrayList; @@ -40,19 +41,36 @@ public class TastingTag extends BaseEntity { @Column(name = "kor_name", nullable = false) private String korName; - // base64 이미지로 변환해도 될듯 - @Comment("아이콘") - @Column(name = "icon") + @Lob + @Comment("아이콘 (Base64 이미지)") + @Column(name = "icon", columnDefinition = "MEDIUMTEXT") private String icon; @Comment("태그 설명") @Column(name = "description") private String description; + @Comment("부모 태그 ID (null이면 root)") + @Column(name = "parent_id") + private Long parentId; + @Builder.Default @OneToMany(mappedBy = "tastingTag") private List alcoholsTastingTags = new ArrayList<>(); + public void update( + String korName, String engName, String icon, String description, Long parentId) { + this.korName = korName; + this.engName = engName; + this.icon = icon; + this.description = description; + this.parentId = parentId; + } + + public boolean isRoot() { + return this.parentId == null; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java index 34953cfa4..343d8a28f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java @@ -2,6 +2,7 @@ import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -10,4 +11,18 @@ public interface TastingTagRepository { List findAll(); Page findAllTastingTags(String keyword, Pageable pageable); + + Optional findById(Long id); + + Optional findByKorName(String korName); + + List findByParentId(Long parentId); + + TastingTag save(TastingTag tastingTag); + + void delete(TastingTag tastingTag); + + boolean existsByKorNameAndIdNot(String korName, Long id); + + boolean existsByParentId(Long parentId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagAlcoholRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagAlcoholRequest.java new file mode 100644 index 000000000..307a3da95 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagAlcoholRequest.java @@ -0,0 +1,7 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record AdminTastingTagAlcoholRequest( + @NotEmpty(message = "위스키 ID 목록은 필수입니다.") List alcoholIds) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java new file mode 100644 index 000000000..f173c2b7a --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java @@ -0,0 +1,10 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record AdminTastingTagUpsertRequest( + @NotBlank(message = "한글 이름은 필수입니다.") String korName, + @NotBlank(message = "영문 이름은 필수입니다.") String engName, + String icon, + String description, + Long parentId) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java new file mode 100644 index 000000000..53b61f9db --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java @@ -0,0 +1,38 @@ +package app.bottlenote.alcohols.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +public record AdminTastingTagDetailResponse( + Long id, + String korName, + String engName, + String icon, + String description, + AdminTastingTagItem parent, + List ancestors, + List children, + List alcohols, + LocalDateTime createdAt, + LocalDateTime modifiedAt) { + + public static AdminTastingTagDetailResponse of( + AdminTastingTagItem tagItem, + AdminTastingTagItem parent, + List ancestors, + List children, + List alcohols) { + return new AdminTastingTagDetailResponse( + tagItem.id(), + tagItem.korName(), + tagItem.engName(), + tagItem.icon(), + tagItem.description(), + parent, + ancestors, + children, + alcohols, + tagItem.createdAt(), + tagItem.modifiedAt()); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java index c7fa78074..93b4c8bf1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java @@ -8,5 +8,6 @@ public record AdminTastingTagItem( String engName, String icon, String description, + Long parentId, LocalDateTime createdAt, LocalDateTime modifiedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java index 5101c39ab..27d626e79 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java @@ -9,7 +9,13 @@ public enum AlcoholExceptionCode implements ExceptionCode { DISTILLERY_NOT_FOUND(HttpStatus.NOT_FOUND, "증류소를 찾을 수 없습니다."), ALCOHOL_HAS_REVIEWS(HttpStatus.CONFLICT, "리뷰가 존재하는 위스키는 삭제할 수 없습니다."), ALCOHOL_HAS_RATINGS(HttpStatus.CONFLICT, "평점이 존재하는 위스키는 삭제할 수 없습니다."), - ALCOHOL_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 위스키입니다."); + ALCOHOL_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 위스키입니다."), + TASTING_TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "테이스팅 태그를 찾을 수 없습니다."), + TASTING_TAG_DUPLICATE_NAME(HttpStatus.CONFLICT, "동일한 한글 이름의 태그가 이미 존재합니다."), + TASTING_TAG_HAS_CHILDREN(HttpStatus.CONFLICT, "자식 태그가 존재하는 태그는 삭제할 수 없습니다."), + TASTING_TAG_HAS_ALCOHOLS(HttpStatus.CONFLICT, "연결된 위스키가 존재하는 태그는 삭제할 수 없습니다."), + TASTING_TAG_PARENT_NOT_FOUND(HttpStatus.NOT_FOUND, "부모 태그를 찾을 수 없습니다."), + TASTING_TAG_MAX_DEPTH_EXCEEDED(HttpStatus.BAD_REQUEST, "태그 계층 구조는 최대 3단계까지 가능합니다."); private final HttpStatus httpStatus; private final String message; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java new file mode 100644 index 000000000..3489a32e4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java @@ -0,0 +1,34 @@ +package app.bottlenote.alcohols.repository; + +import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.AlcoholsTastingTagsRepository; +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +@JpaRepositoryImpl +public interface JpaAlcoholsTastingTagsRepository + extends AlcoholsTastingTagsRepository, JpaRepository { + + @Override + @Query("select att from alcohol_tasting_tags att where att.tastingTag.id = :tastingTagId") + List findByTastingTagId(@Param("tastingTagId") Long tastingTagId); + + @Override + @Modifying + @Query( + """ + delete from alcohol_tasting_tags att + where att.tastingTag.id = :tastingTagId and att.alcohol.id in :alcoholIds + """) + void deleteByTastingTagIdAndAlcoholIdIn( + @Param("tastingTagId") Long tastingTagId, @Param("alcoholIds") List alcoholIds); + + @Override + @Query( + "select case when count(att) > 0 then true else false end from alcohol_tasting_tags att where att.tastingTag.id = :tastingTagId") + boolean existsByTastingTagId(@Param("tastingTagId") Long tastingTagId); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java index f1c343aed..d36a422ae 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java @@ -4,6 +4,8 @@ import app.bottlenote.alcohols.domain.TastingTagRepository; import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; +import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; @@ -18,7 +20,7 @@ public interface JpaTastingTagRepository @Query( """ select new app.bottlenote.alcohols.dto.response.AdminTastingTagItem( - t.id, t.korName, t.engName, t.icon, t.description, t.createAt, t.lastModifyAt + t.id, t.korName, t.engName, t.icon, t.description, t.parentId, t.createAt, t.lastModifyAt ) from tasting_tag t where (:keyword is null or :keyword = '' @@ -26,4 +28,19 @@ or t.korName like concat('%', :keyword, '%') or t.engName like concat('%', :keyword, '%')) """) Page findAllTastingTags(@Param("keyword") String keyword, Pageable pageable); + + @Override + Optional findById(Long id); + + @Override + Optional findByKorName(String korName); + + @Override + List findByParentId(Long parentId); + + @Override + boolean existsByKorNameAndIdNot(String korName, Long id); + + @Override + boolean existsByParentId(Long parentId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java index f5331dac8..eaec13272 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java @@ -1,7 +1,31 @@ package app.bottlenote.alcohols.service; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_DUPLICATE_NAME; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_HAS_ALCOHOLS; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_HAS_CHILDREN; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_MAX_DEPTH_EXCEEDED; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_PARENT_NOT_FOUND; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_ALCOHOL_ADDED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_ALCOHOL_REMOVED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_CREATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_DELETED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_UPDATED; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.AlcoholsTastingTagsRepository; import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.domain.TastingTagRepository; +import app.bottlenote.alcohols.dto.request.AdminTastingTagUpsertRequest; +import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; +import app.bottlenote.alcohols.dto.response.AdminTastingTagDetailResponse; +import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; +import app.bottlenote.alcohols.exception.AlcoholException; +import app.bottlenote.global.dto.response.AdminResultResponse; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,7 +42,11 @@ @RequiredArgsConstructor public class TastingTagService { + private static final int MAX_DEPTH = 3; + private final TastingTagRepository tastingTagRepository; + private final AlcoholsTastingTagsRepository alcoholsTastingTagsRepository; + private final AlcoholQueryRepository alcoholQueryRepository; private volatile Trie trie; @@ -52,4 +80,200 @@ public List extractTagNames(String text) { return trie.parseText(text).stream().map(Emit::getKeyword).distinct().toList(); } + + @Transactional(readOnly = true) + public AdminTastingTagDetailResponse getTagDetail(Long tagId) { + TastingTag tag = + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); + + AdminTastingTagItem parent = null; + if (tag.getParentId() != null) { + parent = + tastingTagRepository + .findById(tag.getParentId()) + .map(this::toAdminTastingTagItem) + .orElse(null); + } + + List ancestors = findAncestors(tag.getParentId(), MAX_DEPTH); + List children = + tastingTagRepository.findByParentId(tagId).stream() + .map(this::toAdminTastingTagItem) + .toList(); + + List alcohols = + alcoholsTastingTagsRepository.findByTastingTagId(tagId).stream() + .map(att -> toAdminAlcoholItem(att.getAlcohol())) + .toList(); + + AdminTastingTagItem tagItem = toAdminTastingTagItem(tag); + return AdminTastingTagDetailResponse.of(tagItem, parent, ancestors, children, alcohols); + } + + @Transactional + public AdminResultResponse createTag(AdminTastingTagUpsertRequest request) { + if (tastingTagRepository.findByKorName(request.korName()).isPresent()) { + throw new AlcoholException(TASTING_TAG_DUPLICATE_NAME); + } + + validateParentAndDepth(request.parentId()); + + TastingTag tag = + TastingTag.builder() + .korName(request.korName()) + .engName(request.engName()) + .icon(request.icon()) + .description(request.description()) + .parentId(request.parentId()) + .build(); + + TastingTag saved = tastingTagRepository.save(tag); + return AdminResultResponse.of(TASTING_TAG_CREATED, saved.getId()); + } + + @Transactional + public AdminResultResponse updateTag(Long tagId, AdminTastingTagUpsertRequest request) { + TastingTag tag = + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); + + if (tastingTagRepository.existsByKorNameAndIdNot(request.korName(), tagId)) { + throw new AlcoholException(TASTING_TAG_DUPLICATE_NAME); + } + + if (request.parentId() != null && !request.parentId().equals(tag.getParentId())) { + validateParentAndDepth(request.parentId()); + } + + tag.update( + request.korName(), + request.engName(), + request.icon(), + request.description(), + request.parentId()); + + return AdminResultResponse.of(TASTING_TAG_UPDATED, tagId); + } + + @Transactional + public AdminResultResponse deleteTag(Long tagId) { + TastingTag tag = + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); + + if (tastingTagRepository.existsByParentId(tagId)) { + throw new AlcoholException(TASTING_TAG_HAS_CHILDREN); + } + + if (alcoholsTastingTagsRepository.existsByTastingTagId(tagId)) { + throw new AlcoholException(TASTING_TAG_HAS_ALCOHOLS); + } + + tastingTagRepository.delete(tag); + return AdminResultResponse.of(TASTING_TAG_DELETED, tagId); + } + + @Transactional + public AdminResultResponse addAlcoholsToTag(Long tagId, List alcoholIds) { + TastingTag tag = + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); + + List newMappings = new ArrayList<>(); + for (Long alcoholId : alcoholIds) { + Alcohol alcohol = + alcoholQueryRepository + .findById(alcoholId) + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); + newMappings.add(AlcoholsTastingTags.of(alcohol, tag)); + } + + alcoholsTastingTagsRepository.saveAll(newMappings); + return AdminResultResponse.of(TASTING_TAG_ALCOHOL_ADDED, tagId); + } + + @Transactional + public AdminResultResponse removeAlcoholsFromTag(Long tagId, List alcoholIds) { + if (!tastingTagRepository.findById(tagId).isPresent()) { + throw new AlcoholException(TASTING_TAG_NOT_FOUND); + } + + alcoholsTastingTagsRepository.deleteByTastingTagIdAndAlcoholIdIn(tagId, alcoholIds); + return AdminResultResponse.of(TASTING_TAG_ALCOHOL_REMOVED, tagId); + } + + private List findAncestors(Long parentId, int maxDepth) { + List ancestors = new ArrayList<>(); + Long currentParentId = parentId; + int depth = 0; + + while (currentParentId != null && depth < maxDepth) { + TastingTag parent = tastingTagRepository.findById(currentParentId).orElse(null); + if (parent == null) break; + + ancestors.add(toAdminTastingTagItem(parent)); + currentParentId = parent.getParentId(); + depth++; + } + + return ancestors; + } + + private void validateParentAndDepth(Long parentId) { + if (parentId == null) return; + + TastingTag parent = + tastingTagRepository + .findById(parentId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_PARENT_NOT_FOUND)); + + int parentDepth = calculateDepth(parent); + if (parentDepth >= MAX_DEPTH) { + throw new AlcoholException(TASTING_TAG_MAX_DEPTH_EXCEEDED); + } + } + + private int calculateDepth(TastingTag tag) { + int depth = 1; + Long currentParentId = tag.getParentId(); + + while (currentParentId != null && depth < MAX_DEPTH) { + TastingTag parent = tastingTagRepository.findById(currentParentId).orElse(null); + if (parent == null) break; + + currentParentId = parent.getParentId(); + depth++; + } + + return depth; + } + + private AdminAlcoholItem toAdminAlcoholItem(Alcohol alcohol) { + return new AdminAlcoholItem( + alcohol.getId(), + alcohol.getKorName(), + alcohol.getEngName(), + alcohol.getKorCategory(), + alcohol.getEngCategory(), + alcohol.getImageUrl(), + alcohol.getCreateAt(), + alcohol.getLastModifyAt()); + } + + private AdminTastingTagItem toAdminTastingTagItem(TastingTag tag) { + return new AdminTastingTagItem( + tag.getId(), + tag.getKorName(), + tag.getEngName(), + tag.getIcon(), + tag.getDescription(), + tag.getParentId(), + tag.getCreateAt(), + tag.getLastModifyAt()); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java index 398bebcbe..912da055f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java @@ -20,6 +20,11 @@ public enum ResultCode { ALCOHOL_CREATED("위스키가 등록되었습니다."), ALCOHOL_UPDATED("위스키가 수정되었습니다."), ALCOHOL_DELETED("위스키가 삭제되었습니다."), + TASTING_TAG_CREATED("테이스팅 태그가 등록되었습니다."), + TASTING_TAG_UPDATED("테이스팅 태그가 수정되었습니다."), + TASTING_TAG_DELETED("테이스팅 태그가 삭제되었습니다."), + TASTING_TAG_ALCOHOL_ADDED("위스키가 연결되었습니다."), + TASTING_TAG_ALCOHOL_REMOVED("위스키 연결이 해제되었습니다."), ; private final String message; diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java index 2f8b44f4c..3a30b7936 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -30,16 +31,7 @@ public Page findAllTastingTags(String keyword, Pageable pag || keyword.isEmpty() || t.getKorName().contains(keyword) || t.getEngName().contains(keyword)) - .map( - t -> - new AdminTastingTagItem( - t.getId(), - t.getKorName(), - t.getEngName(), - t.getIcon(), - t.getDescription(), - t.getCreateAt(), - t.getLastModifyAt())) + .map(this::toAdminTastingTagItem) .toList(); int start = (int) pageable.getOffset(); @@ -50,17 +42,63 @@ public Page findAllTastingTags(String keyword, Pageable pag return new PageImpl<>(pageContent, pageable, filtered.size()); } + @Override + public Optional findById(Long id) { + return tags.stream().filter(t -> Objects.equals(t.getId(), id)).findFirst(); + } + + @Override + public Optional findByKorName(String korName) { + return tags.stream().filter(t -> Objects.equals(t.getKorName(), korName)).findFirst(); + } + + @Override + public List findByParentId(Long parentId) { + return tags.stream().filter(t -> Objects.equals(t.getParentId(), parentId)).toList(); + } + + @Override public TastingTag save(TastingTag tag) { Long id = tag.getId(); if (Objects.isNull(id)) { - id = (long) (tags.size() + 1); - ReflectionTestUtils.setField(tag, "id", id); + Long newId = (long) (tags.size() + 1); + ReflectionTestUtils.setField(tag, "id", newId); } + final Long tagId = tag.getId(); + tags.removeIf(t -> Objects.equals(t.getId(), tagId)); tags.add(tag); return tag; } + @Override + public void delete(TastingTag tag) { + tags.removeIf(t -> Objects.equals(t.getId(), tag.getId())); + } + + @Override + public boolean existsByKorNameAndIdNot(String korName, Long id) { + return tags.stream() + .anyMatch(t -> Objects.equals(t.getKorName(), korName) && !Objects.equals(t.getId(), id)); + } + + @Override + public boolean existsByParentId(Long parentId) { + return tags.stream().anyMatch(t -> Objects.equals(t.getParentId(), parentId)); + } + public void clear() { tags.clear(); } + + private AdminTastingTagItem toAdminTastingTagItem(TastingTag tag) { + return new AdminTastingTagItem( + tag.getId(), + tag.getKorName(), + tag.getEngName(), + tag.getIcon(), + tag.getDescription(), + tag.getParentId(), + tag.getCreateAt(), + tag.getLastModifyAt()); + } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java new file mode 100644 index 000000000..150cd3f90 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java @@ -0,0 +1,131 @@ +package app.bottlenote.alcohols.fixture; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.TastingTag; +import jakarta.persistence.EntityManager; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class TastingTagTestFactory { + + private final Random random = new SecureRandom(); + + @Autowired private EntityManager em; + + @Transactional + @NotNull + public TastingTag persistTastingTag() { + TastingTag tag = + TastingTag.builder() + .korName("테스트 태그-" + generateRandomSuffix()) + .engName("test-tag-" + generateRandomSuffix()) + .description("테스트용 태그입니다") + .build(); + em.persist(tag); + em.flush(); + return tag; + } + + @Transactional + @NotNull + public TastingTag persistTastingTag(@NotNull String korName, @NotNull String engName) { + TastingTag tag = + TastingTag.builder().korName(korName).engName(engName).description("테스트용 태그입니다").build(); + em.persist(tag); + em.flush(); + return tag; + } + + @Transactional + @NotNull + public TastingTag persistTastingTagWithParent(@NotNull TastingTag parent) { + TastingTag tag = + TastingTag.builder() + .korName("자식 태그-" + generateRandomSuffix()) + .engName("child-tag-" + generateRandomSuffix()) + .description("부모가 있는 태그입니다") + .parentId(parent.getId()) + .build(); + em.persist(tag); + em.flush(); + return tag; + } + + @Transactional + @NotNull + public TastingTag persistTastingTagWithParent( + @NotNull String korName, @NotNull String engName, @NotNull TastingTag parent) { + TastingTag tag = + TastingTag.builder() + .korName(korName) + .engName(engName) + .description("부모가 있는 태그입니다") + .parentId(parent.getId()) + .build(); + em.persist(tag); + em.flush(); + return tag; + } + + /** 3depth 트리 구조 생성 (대분류 -> 중분류 -> 소분류) */ + @Transactional + @NotNull + public List persistTastingTagTree() { + List tags = new ArrayList<>(); + + TastingTag root = + TastingTag.builder() + .korName("향-" + generateRandomSuffix()) + .engName("Aroma-" + generateRandomSuffix()) + .description("대분류 태그") + .build(); + em.persist(root); + tags.add(root); + + TastingTag middle = + TastingTag.builder() + .korName("달콤한-" + generateRandomSuffix()) + .engName("Sweet-" + generateRandomSuffix()) + .description("중분류 태그") + .parentId(root.getId()) + .build(); + em.persist(middle); + tags.add(middle); + + TastingTag leaf = + TastingTag.builder() + .korName("허니-" + generateRandomSuffix()) + .engName("Honey-" + generateRandomSuffix()) + .description("소분류 태그") + .parentId(middle.getId()) + .build(); + em.persist(leaf); + tags.add(leaf); + + em.flush(); + return tags; + } + + @Transactional + @NotNull + public AlcoholsTastingTags linkAlcoholToTag(@NotNull Alcohol alcohol, @NotNull TastingTag tag) { + AlcoholsTastingTags mapping = AlcoholsTastingTags.of(alcohol, tag); + em.persist(mapping); + em.flush(); + return mapping; + } + + private String generateRandomSuffix() { + return String.valueOf(random.nextInt(10000)); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java index 34a3fe352..672ad36eb 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java @@ -1,7 +1,10 @@ package app.bottlenote.alcohols.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.domain.AlcoholsTastingTagsRepository; import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.fixture.InMemoryTastingTagRepository; import java.util.List; @@ -21,12 +24,18 @@ class TastingTagServiceTest { InMemoryTastingTagRepository tastingTagRepository; + AlcoholsTastingTagsRepository alcoholsTastingTagsRepository; + AlcoholQueryRepository alcoholQueryRepository; TastingTagService tastingTagService; @BeforeEach void setUp() { tastingTagRepository = new InMemoryTastingTagRepository(); - tastingTagService = new TastingTagService(tastingTagRepository); + alcoholsTastingTagsRepository = mock(AlcoholsTastingTagsRepository.class); + alcoholQueryRepository = mock(AlcoholQueryRepository.class); + tastingTagService = + new TastingTagService( + tastingTagRepository, alcoholsTastingTagsRepository, alcoholQueryRepository); tastingTagRepository.save(createTag("바닐라", "vanilla")); tastingTagRepository.save(createTag("꿀", "honey")); diff --git a/git.environment-variables b/git.environment-variables index cf01d4f68..625ccd3d4 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit cf01d4f685649652a92c43ea8dcbe61daf357b84 +Subproject commit 625ccd3d4b26ce14b19a14cf24b04cbc823bf80a From 27dbef744e5d401476c94b88525a376e9f498ba4 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 27 Jan 2026 23:49:43 +0900 Subject: [PATCH 03/29] =?UTF-8?q?docs(admin):=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=ED=8C=85=20=ED=83=9C=EA=B7=B8=20RestDocs=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminTastingTagControllerDocsTest에 7개 API 테스트 추가 - 목록 조회, 상세 조회, 생성, 수정, 삭제 - 위스키 연결/해제 벌크 API - tasting-tags.adoc 문서 신규 생성 - admin-api.adoc에 Tasting Tag API 섹션 추가 - reference.adoc에서 테이스팅 태그 목록 조회 분리 Co-Authored-By: Claude Opus 4.5 --- .../src/docs/asciidoc/admin-api.adoc | 6 + .../api/admin-reference/reference.adoc | 26 - .../api/admin-tasting-tags/tasting-tags.adoc | 201 ++++++++ .../AdminTastingTagControllerDocsTest.kt | 470 ++++++++++++++++-- 4 files changed, 624 insertions(+), 79 deletions(-) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 39adcc475..5c60d05c2 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -52,6 +52,12 @@ include::api/admin-file/file.adoc[] ''' +== Tasting Tag API + +include::api/admin-tasting-tags/tasting-tags.adoc[] + +''' + == Reference API include::api/admin-reference/reference.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc index 6e3f972e3..2d1072a96 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc @@ -1,29 +1,3 @@ -=== 테이스팅 태그 목록 조회 === - -- 전체 테이스팅 태그 목록을 조회합니다. -- 술의 향미를 표현하는 태그 정보를 제공합니다. - -[source] ----- -GET /admin/api/v1/tasting-tags ----- - -[discrete] -==== 요청 ==== - -[discrete] -include::{snippets}/admin/tasting-tags/list/curl-request.adoc[] -include::{snippets}/admin/tasting-tags/list/http-request.adoc[] - -[discrete] -==== 응답 파라미터 ==== - -[discrete] -include::{snippets}/admin/tasting-tags/list/response-fields.adoc[] -include::{snippets}/admin/tasting-tags/list/http-response.adoc[] - -''' - === 지역 목록 조회 === - 전체 지역(국가) 목록을 조회합니다. diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc new file mode 100644 index 000000000..4fea6d795 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc @@ -0,0 +1,201 @@ +=== 테이스팅 태그 목록 조회 === + +- 테이스팅 태그 목록을 페이지네이션으로 조회합니다. +- 키워드로 한글명/영문명 검색이 가능합니다. + +[source] +---- +GET /admin/api/v1/tasting-tags +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/list/query-parameters.adoc[] +include::{snippets}/admin/tasting-tags/list/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/list/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/list/http-response.adoc[] + +''' + +=== 테이스팅 태그 상세 조회 === + +- 특정 테이스팅 태그의 상세 정보를 조회합니다. +- 부모/자식 태그 정보 및 연결된 위스키 목록을 포함합니다. + +[source] +---- +GET /admin/api/v1/tasting-tags/{tagId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/detail/path-parameters.adoc[] +include::{snippets}/admin/tasting-tags/detail/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/detail/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/detail/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/detail/http-response.adoc[] + +''' + +=== 테이스팅 태그 생성 === + +- 새로운 테이스팅 태그를 생성합니다. +- parentId를 지정하여 계층 구조를 만들 수 있습니다. + +[source] +---- +POST /admin/api/v1/tasting-tags +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/create/request-fields.adoc[] +include::{snippets}/admin/tasting-tags/create/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/create/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/create/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/create/http-response.adoc[] + +''' + +=== 테이스팅 태그 수정 === + +- 기존 테이스팅 태그 정보를 수정합니다. + +[source] +---- +PUT /admin/api/v1/tasting-tags/{tagId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/update/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/update/request-fields.adoc[] +include::{snippets}/admin/tasting-tags/update/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/update/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/update/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/update/http-response.adoc[] + +''' + +=== 테이스팅 태그 삭제 === + +- 테이스팅 태그를 삭제합니다. +- 자식 태그가 있는 경우 삭제할 수 없습니다. + +[source] +---- +DELETE /admin/api/v1/tasting-tags/{tagId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/delete/path-parameters.adoc[] +include::{snippets}/admin/tasting-tags/delete/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/delete/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/delete/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/delete/http-response.adoc[] + +''' + +=== 테이스팅 태그 위스키 연결 === + +- 테이스팅 태그에 위스키를 벌크로 연결합니다. + +[source] +---- +POST /admin/api/v1/tasting-tags/{tagId}/alcohols +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/add-alcohols/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/add-alcohols/request-fields.adoc[] +include::{snippets}/admin/tasting-tags/add-alcohols/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/add-alcohols/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/add-alcohols/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/add-alcohols/http-response.adoc[] + +''' + +=== 테이스팅 태그 위스키 연결 해제 === + +- 테이스팅 태그에서 위스키 연결을 벌크로 해제합니다. + +[source] +---- +DELETE /admin/api/v1/tasting-tags/{tagId}/alcohols +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/remove-alcohols/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/remove-alcohols/request-fields.adoc[] +include::{snippets}/admin/tasting-tags/remove-alcohols/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/remove-alcohols/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/remove-alcohols/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/remove-alcohols/http-response.adoc[] diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index 351dbff82..56d5d18bb 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -1,29 +1,38 @@ package app.docs.alcohols import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.dto.request.AdminTastingTagUpsertRequest +import app.bottlenote.alcohols.dto.response.AdminAlcoholItem +import app.bottlenote.alcohols.dto.response.AdminTastingTagDetailResponse +import app.bottlenote.alcohols.dto.response.AdminTastingTagItem import app.bottlenote.alcohols.presentation.AdminTastingTagController import app.bottlenote.alcohols.service.AlcoholReferenceService +import app.bottlenote.alcohols.service.TastingTagService import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.dto.response.AdminResultResponse import app.helper.alcohols.AlcoholsHelper +import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong import org.mockito.BDDMockito.given 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.data.domain.PageImpl +import org.springframework.http.MediaType 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.fieldWithPath -import org.springframework.restdocs.payload.PayloadDocumentation.responseFields -import org.springframework.restdocs.request.RequestDocumentation.parameterWithName -import org.springframework.restdocs.request.RequestDocumentation.queryParameters +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 @WebMvcTest( controllers = [AdminTastingTagController::class], @@ -36,60 +45,415 @@ class AdminTastingTagControllerDocsTest { @Autowired private lateinit var mvc: MockMvcTester + @Autowired + private lateinit var mapper: ObjectMapper + @MockitoBean private lateinit var alcoholReferenceService: AlcoholReferenceService - @Test - @DisplayName("테이스팅 태그 목록을 조회할 수 있다") - fun getAllTastingTags() { - // given - val items = AlcoholsHelper.createAdminTastingTagItems(3) - val page = PageImpl(items) - val response = GlobalResponse.fromPage(page) - - given(alcoholReferenceService.findAllTastingTags(any(AdminReferenceSearchRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.get().uri("/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC") - ) - .hasStatusOk() - .apply( - document( - "admin/tasting-tags/list", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - queryParameters( - parameterWithName("keyword").description("검색어 (한글명/영문명)").optional(), - parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), - parameterWithName("size").description("페이지 크기 (기본값: 20)").optional(), - parameterWithName("sortOrder").description("정렬 방향 (ASC/DESC, 기본값: ASC)").optional() - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.ARRAY).description("테이스팅 태그 목록"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("태그 ID"), - fieldWithPath("data[].korName").type(JsonFieldType.STRING).description("태그 한글명"), - fieldWithPath("data[].engName").type(JsonFieldType.STRING).description("태그 영문명"), - fieldWithPath("data[].icon").type(JsonFieldType.STRING).description("아이콘"), - fieldWithPath("data[].description").type(JsonFieldType.STRING).description("설명"), - fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), - fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + @MockitoBean + private lateinit var tastingTagService: TastingTagService + + @Nested + @DisplayName("테이스팅 태그 목록 조회") + inner class GetTastingTagList { + + @Test + @DisplayName("테이스팅 태그 목록을 조회할 수 있다") + fun getAllTastingTags() { + // given + val items = AlcoholsHelper.createAdminTastingTagItems(3) + val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) + + given(alcoholReferenceService.findAllTastingTags(any(AdminReferenceSearchRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC") + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("keyword").description("검색어 (한글명/영문명)").optional(), + parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 20)").optional(), + parameterWithName("sortOrder").description("정렬 방향 (ASC/DESC, 기본값: ASC)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("테이스팅 태그 목록"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data[].korName").type(JsonFieldType.STRING).description("태그 한글명"), + fieldWithPath("data[].engName").type(JsonFieldType.STRING).description("태그 영문명"), + fieldWithPath("data[].icon").type(JsonFieldType.STRING).description("아이콘 (Base64)"), + fieldWithPath("data[].description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("data[].parentId").type(JsonFieldType.NUMBER).description("부모 태그 ID").optional(), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("테이스팅 태그 상세 조회") + inner class GetTastingTagDetail { + + @Test + @DisplayName("테이스팅 태그 상세 정보를 조회할 수 있다") + fun getTagDetail() { + // given + val tagItem = AdminTastingTagItem( + 1L, "바닐라", "Vanilla", "base64icon", "바닐라 향", null, + LocalDateTime.of(2024, 1, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + ) + val childItem = AdminTastingTagItem( + 2L, "바닐라 크림", "Vanilla Cream", null, "바닐라 크림 향", 1L, + LocalDateTime.of(2024, 2, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + ) + val alcoholItem = AdminAlcoholItem( + 1L, "글렌피딕 12년", "Glenfiddich 12", "싱글몰트", "Single Malt", + "https://example.com/image.jpg", + LocalDateTime.of(2024, 1, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + ) + + val response = AdminTastingTagDetailResponse.of( + tagItem, null, emptyList(), listOf(childItem), listOf(alcoholItem) + ) + + given(tastingTagService.getTagDetail(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.get().uri("/tasting-tags/{tagId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("태그 상세 정보"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data.korName").type(JsonFieldType.STRING).description("태그 한글명"), + fieldWithPath("data.engName").type(JsonFieldType.STRING).description("태그 영문명"), + fieldWithPath("data.icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("data.parent").type(JsonFieldType.OBJECT).description("부모 태그 정보").optional(), + fieldWithPath("data.ancestors").type(JsonFieldType.ARRAY).description("조상 태그 목록 (가까운 순)"), + fieldWithPath("data.children").type(JsonFieldType.ARRAY).description("자식 태그 목록"), + fieldWithPath("data.children[].id").type(JsonFieldType.NUMBER).description("자식 태그 ID"), + fieldWithPath("data.children[].korName").type(JsonFieldType.STRING).description("자식 태그 한글명"), + fieldWithPath("data.children[].engName").type(JsonFieldType.STRING).description("자식 태그 영문명"), + fieldWithPath("data.children[].icon").type(JsonFieldType.STRING).description("자식 태그 아이콘").optional(), + fieldWithPath("data.children[].description").type(JsonFieldType.STRING).description("자식 태그 설명").optional(), + fieldWithPath("data.children[].parentId").type(JsonFieldType.NUMBER).description("부모 태그 ID").optional(), + fieldWithPath("data.children[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.children[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.alcohols").type(JsonFieldType.ARRAY).description("연결된 위스키 목록"), + fieldWithPath("data.alcohols[].alcoholId").type(JsonFieldType.NUMBER).description("위스키 ID"), + fieldWithPath("data.alcohols[].korName").type(JsonFieldType.STRING).description("위스키 한글명"), + fieldWithPath("data.alcohols[].engName").type(JsonFieldType.STRING).description("위스키 영문명"), + fieldWithPath("data.alcohols[].korCategoryName").type(JsonFieldType.STRING).description("카테고리 한글명"), + fieldWithPath("data.alcohols[].engCategoryName").type(JsonFieldType.STRING).description("카테고리 영문명"), + fieldWithPath("data.alcohols[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL").optional(), + fieldWithPath("data.alcohols[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.alcohols[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("테이스팅 태그 생성") + inner class CreateTastingTag { + + @Test + @DisplayName("테이스팅 태그를 생성할 수 있다") + fun createTag() { + // given + val request = mapOf( + "korName" to "바닐라", + "engName" to "Vanilla", + "icon" to "base64EncodedIcon", + "description" to "바닐라 향", + "parentId" to null + ) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_CREATED, 1L) + + given(tastingTagService.createTag(any(AdminTastingTagUpsertRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/tasting-tags") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("korName").type(JsonFieldType.STRING).description("태그 한글명 (필수)"), + fieldWithPath("engName").type(JsonFieldType.STRING).description("태그 영문명 (필수)"), + fieldWithPath("icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("parentId").type(JsonFieldType.NULL).description("부모 태그 ID (null이면 루트)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("생성된 태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("테이스팅 태그 수정") + inner class UpdateTastingTag { + + @Test + @DisplayName("테이스팅 태그를 수정할 수 있다") + fun updateTag() { + // given + val request = mapOf( + "korName" to "바닐라 수정", + "engName" to "Vanilla Updated", + "description" to "수정된 설명" + ) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_UPDATED, 1L) + + given(tastingTagService.updateTag(anyLong(), any(AdminTastingTagUpsertRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.put().uri("/tasting-tags/{tagId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + requestFields( + fieldWithPath("korName").type(JsonFieldType.STRING).description("태그 한글명 (필수)"), + fieldWithPath("engName").type(JsonFieldType.STRING).description("태그 영문명 (필수)"), + fieldWithPath("icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("parentId").type(JsonFieldType.NUMBER).description("부모 태그 ID").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("수정된 태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) ) ) + } + } + + @Nested + @DisplayName("테이스팅 태그 삭제") + inner class DeleteTastingTag { + + @Test + @DisplayName("테이스팅 태그를 삭제할 수 있다") + fun deleteTag() { + // given + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_DELETED, 1L) + + given(tastingTagService.deleteTag(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.delete().uri("/tasting-tags/{tagId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("삭제된 태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("테이스팅 태그 위스키 연결 관리") + inner class ManageAlcohols { + + @Test + @DisplayName("위스키를 태그에 벌크로 연결할 수 있다") + fun addAlcoholsToTag() { + // given + val request = mapOf("alcoholIds" to listOf(1L, 2L, 3L)) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_ALCOHOL_ADDED, 1L) + + given(tastingTagService.addAlcoholsToTag(anyLong(), any())) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/tasting-tags/{tagId}/alcohols", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/add-alcohols", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + requestFields( + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("연결할 위스키 ID 목록") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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 removeAlcoholsFromTag() { + // given + val request = mapOf("alcoholIds" to listOf(1L, 2L)) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_ALCOHOL_REMOVED, 1L) + + given(tastingTagService.removeAlcoholsFromTag(anyLong(), any())) + .willReturn(response) + + // when & then + assertThat( + mvc.delete().uri("/tasting-tags/{tagId}/alcohols", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/remove-alcohols", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + requestFields( + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("해제할 위스키 ID 목록") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } } } From 6f5a368fb3a3b22414d4fd70a0865017105dfea0 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 27 Jan 2026 23:52:32 +0900 Subject: [PATCH 04/29] =?UTF-8?q?docs(admin):=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=ED=8C=85=20=ED=83=9C=EA=B7=B8=20HTTP=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이스팅태그.http 신규 생성 (7개 API) - 제조사_지역_태그.http → 제조사_지역.http 파일명 변경 - 테이스팅 태그 목록 조회를 별도 파일로 분리 Co-Authored-By: Claude Opus 4.5 --- ...354\202\254_\354\247\200\354\227\255.http" | 4 -- ...\355\214\205\355\203\234\352\267\270.http" | 55 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) rename "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255_\355\203\234\352\267\270.http" => "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255.http" (64%) create mode 100644 "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270.http" diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255_\355\203\234\352\267\270.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255.http" similarity index 64% rename from "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255_\355\203\234\352\267\270.http" rename to "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255.http" index 8852a55cb..c97424722 100644 --- "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255_\355\203\234\352\267\270.http" +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255.http" @@ -5,7 +5,3 @@ Authorization: Bearer {{accessToken}} ### 지역 목록 조회 GET {{host}}/regions?keyword=&cursor=0&pageSize=10 Authorization: Bearer {{accessToken}} - -### 테이스팅 태그 목록 조회 -GET {{host}}/tasting-tags?keyword=&cursor=0&pageSize=10 -Authorization: Bearer {{accessToken}} diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270.http" new file mode 100644 index 000000000..ec71c9b51 --- /dev/null +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270.http" @@ -0,0 +1,55 @@ +### 테이스팅 태그 목록 조회 +GET {{host}}/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그 상세 조회 +GET {{host}}/tasting-tags/1 +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그 생성 +POST {{host}}/tasting-tags +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "korName": "바닐라", + "engName": "Vanilla", + "icon": "base64EncodedIcon", + "description": "바닐라 향", + "parentId": null +} + +### 테이스팅 태그 수정 +PUT {{host}}/tasting-tags/1 +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "korName": "바닐라 수정", + "engName": "Vanilla Updated", + "icon": "base64EncodedIcon", + "description": "수정된 바닐라 향", + "parentId": null +} + +### 테이스팅 태그 삭제 +DELETE {{host}}/tasting-tags/1 +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그에 위스키 벌크 연결 +POST {{host}}/tasting-tags/1/alcohols +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "alcoholIds": [1, 2, 3] +} + +### 테이스팅 태그에서 위스키 벌크 연결 해제 +DELETE {{host}}/tasting-tags/1/alcohols +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "alcoholIds": [1, 2] +} From afd997f842f021abafc29528c307c07e41e87b9c Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 28 Jan 2026 10:23:17 +0900 Subject: [PATCH 05/29] feat: add Base64 image validation annotation and validator - Added `@Base64Image` annotation for validating Base64-encoded images. - Implemented `Base64ImageValidator` with size and MIME type checks. - Integrated validation for `AdminTastingTagUpsertRequest.icon` field. - Included helper constant `VALID_BASE64_PNG` for testing purposes. --- .github/workflows/github-pages.yml | 3 +- .gitignore | 2 + .../api/admin-tasting-tags/tasting-tags.adoc | 8 +- .../AdminTastingTagControllerDocsTest.kt | 3 +- .../app/helper/alcohols/AlcoholsHelper.kt | 3 + .../request/AdminTastingTagUpsertRequest.java | 3 +- .../global/validation/Base64Image.java | 30 +++++ .../validation/Base64ImageValidator.java | 120 ++++++++++++++++++ .../alcohol-search-performance-improvement.md | 0 9 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/global/validation/Base64Image.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/global/validation/Base64ImageValidator.java rename {docs/plans => plan}/alcohol-search-performance-improvement.md (100%) diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index a497e8110..e063f9263 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -5,7 +5,8 @@ on: branches: [ "main" ] paths: - 'bottlenote-*/src/docs/**' - - 'bottlenote-*/src/test/**/docs/**' + - 'bottlenote-*/src/test/java/**/docs/**' + - 'bottlenote-*/src/test/kotlin/**/docs/**' - 'bottlenote-*/build.gradle*' - 'gradle/libs.versions.toml' - 'docs/**' diff --git a/.gitignore b/.gitignore index 60934fbee..6e7f7f917 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ cosign.pub # Log files spy.log +/.omc/state/subagent-tracking.json +/.omc/continuation-count.json diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc index 4fea6d795..43aaf37bc 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc @@ -20,7 +20,7 @@ include::{snippets}/admin/tasting-tags/list/http-request.adoc[] ==== 응답 파라미터 ==== [discrete] -include::{snippets}/admin/tasting-tags/list/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/list/response-fields.adoc[]룰 include::{snippets}/admin/tasting-tags/list/http-response.adoc[] ''' @@ -28,7 +28,7 @@ include::{snippets}/admin/tasting-tags/list/http-response.adoc[] === 테이스팅 태그 상세 조회 === - 특정 테이스팅 태그의 상세 정보를 조회합니다. -- 부모/자식 태그 정보 및 연결된 위스키 목록을 포함합니다. +- 부모/자식 태그 정보 및 연결된 술 목록을 포함합니다. [source] ---- @@ -140,7 +140,7 @@ include::{snippets}/admin/tasting-tags/delete/http-response.adoc[] === 테이스팅 태그 위스키 연결 === -- 테이스팅 태그에 위스키를 벌크로 연결합니다. +- 테이스팅 태그에 술을 벌크로 연결합니다. [source] ---- @@ -172,7 +172,7 @@ include::{snippets}/admin/tasting-tags/add-alcohols/http-response.adoc[] === 테이스팅 태그 위스키 연결 해제 === -- 테이스팅 태그에서 위스키 연결을 벌크로 해제합니다. +- 테이스팅 태그에서 술 연결을 벌크로 해제합니다. [source] ---- diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index 56d5d18bb..b8ea8a695 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -207,7 +207,7 @@ class AdminTastingTagControllerDocsTest { val request = mapOf( "korName" to "바닐라", "engName" to "Vanilla", - "icon" to "base64EncodedIcon", + "icon" to AlcoholsHelper.VALID_BASE64_PNG, "description" to "바닐라 향", "parentId" to null ) @@ -266,6 +266,7 @@ class AdminTastingTagControllerDocsTest { val request = mapOf( "korName" to "바닐라 수정", "engName" to "Vanilla Updated", + "icon" to AlcoholsHelper.VALID_BASE64_PNG, "description" to "수정된 설명" ) val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_UPDATED, 1L) diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index b51c932ae..95d6d5ca1 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -14,6 +14,9 @@ import java.time.LocalDateTime object AlcoholsHelper { + /** 1x1 투명 PNG 이미지 (테스트용) */ + const val VALID_BASE64_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + fun createAdminAlcoholItem( id: Long = 1L, korName: String = "테스트 위스키", diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java index f173c2b7a..1d4cd8c22 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java @@ -1,10 +1,11 @@ package app.bottlenote.alcohols.dto.request; +import app.bottlenote.global.validation.Base64Image; import jakarta.validation.constraints.NotBlank; public record AdminTastingTagUpsertRequest( @NotBlank(message = "한글 이름은 필수입니다.") String korName, @NotBlank(message = "영문 이름은 필수입니다.") String engName, - String icon, + @Base64Image String icon, String description, Long parentId) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/validation/Base64Image.java b/bottlenote-mono/src/main/java/app/bottlenote/global/validation/Base64Image.java new file mode 100644 index 000000000..defd40ff0 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/validation/Base64Image.java @@ -0,0 +1,30 @@ +package app.bottlenote.global.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = Base64ImageValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Base64Image { + + String message() default "유효한 Base64 이미지 형식이 아닙니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + /** 허용할 MIME 타입 목록 (기본: png, jpg, jpeg, gif, webp, svg) */ + String[] allowedTypes() default { + "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml" + }; + + /** 최대 허용 크기 (bytes, 기본: 5MB) */ + long maxSize() default 5 * 1024 * 1024; +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/validation/Base64ImageValidator.java b/bottlenote-mono/src/main/java/app/bottlenote/global/validation/Base64ImageValidator.java new file mode 100644 index 000000000..3183b244f --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/validation/Base64ImageValidator.java @@ -0,0 +1,120 @@ +package app.bottlenote.global.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Base64; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Base64ImageValidator implements ConstraintValidator { + + private static final Pattern DATA_URI_PATTERN = + Pattern.compile("^data:([a-zA-Z0-9]+/[a-zA-Z0-9+.-]+)?;base64,(.*)$"); + + private Set allowedTypes; + private long maxSize; + + @Override + public void initialize(Base64Image annotation) { + this.allowedTypes = Set.copyOf(Arrays.asList(annotation.allowedTypes())); + this.maxSize = annotation.maxSize(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isBlank()) { + return true; // null/blank는 허용 (@NotBlank로 별도 검증) + } + + String base64Data; + String declaredMimeType = null; + + Matcher matcher = DATA_URI_PATTERN.matcher(value); + if (matcher.matches()) { + declaredMimeType = matcher.group(1); + base64Data = matcher.group(2); + } else { + base64Data = value; + } + + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(base64Data); + } catch (IllegalArgumentException e) { + setMessage(context, "유효한 Base64 인코딩이 아닙니다."); + return false; + } + + if (decoded.length > maxSize) { + setMessage(context, "이미지 크기가 최대 허용 크기를 초과합니다. (최대: " + (maxSize / 1024 / 1024) + "MB)"); + return false; + } + + String detectedMimeType = detectMimeType(decoded); + if (detectedMimeType == null) { + setMessage(context, "이미지 형식을 감지할 수 없습니다."); + return false; + } + + if (!allowedTypes.contains(detectedMimeType)) { + setMessage(context, "허용되지 않는 이미지 형식입니다. (허용: " + String.join(", ", allowedTypes) + ")"); + return false; + } + + if (declaredMimeType != null && !declaredMimeType.equals(detectedMimeType)) { + setMessage(context, "선언된 MIME 타입과 실제 이미지 형식이 일치하지 않습니다."); + return false; + } + + return true; + } + + private String detectMimeType(byte[] data) { + if (data.length < 8) { + return null; + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (data[0] == (byte) 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47) { + return "image/png"; + } + + // JPEG: FF D8 FF + if (data[0] == (byte) 0xFF && data[1] == (byte) 0xD8 && data[2] == (byte) 0xFF) { + return "image/jpeg"; + } + + // GIF: 47 49 46 38 + if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38) { + return "image/gif"; + } + + // WEBP: 52 49 46 46 ... 57 45 42 50 + if (data.length >= 12 + && data[0] == 0x52 + && data[1] == 0x49 + && data[2] == 0x46 + && data[3] == 0x46 + && data[8] == 0x57 + && data[9] == 0x45 + && data[10] == 0x42 + && data[11] == 0x50) { + return "image/webp"; + } + + // SVG: Date: Wed, 28 Jan 2026 11:25:39 +0900 Subject: [PATCH 06/29] chore: git ignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 6e7f7f917..385b309b0 100644 --- a/.gitignore +++ b/.gitignore @@ -73,5 +73,4 @@ cosign.pub # Log files spy.log -/.omc/state/subagent-tracking.json -/.omc/continuation-count.json +/.omc/ From 2be01df063132c3110cf442a1ee0c15ddbb76f51 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 28 Jan 2026 11:28:50 +0900 Subject: [PATCH 07/29] refactor: TastingTag DTO and related logic to hierarchical structure Replaced `AdminTastingTagItem` with `TastingTagNodeItem` for a matryoshka-style hierarchical tag structure. Updated APIs, services, repositories, and tests to support the new structure, enabling parent-child relationships directly in the DTO for a more intuitive representation. --- ...roduct_ci_pipeline.yml => ci_pipeline.yml} | 5 +- .github/workflows/deploy_v2_development.yml | 2 +- .../AdminTastingTagControllerDocsTest.kt | 60 +++++------- .../app/helper/alcohols/AlcoholsHelper.kt | 23 +++-- .../AdminTastingTagIntegrationTest.kt | 20 ++-- .../alcohols/domain/TastingTagRepository.java | 4 +- .../AdminTastingTagDetailResponse.java | 33 +------ .../dto/response/AdminTastingTagItem.java | 13 --- .../dto/response/TastingTagNodeItem.java | 31 ++++++ .../repository/JpaTastingTagRepository.java | 8 +- .../alcohols/service/TastingTagService.java | 94 ++++++++++--------- .../fixture/InMemoryTastingTagRepository.java | 23 ++--- git.environment-variables | 2 +- 13 files changed, 160 insertions(+), 158 deletions(-) rename .github/workflows/{product_ci_pipeline.yml => ci_pipeline.yml} (99%) delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/TastingTagNodeItem.java diff --git a/.github/workflows/product_ci_pipeline.yml b/.github/workflows/ci_pipeline.yml similarity index 99% rename from .github/workflows/product_ci_pipeline.yml rename to .github/workflows/ci_pipeline.yml index 16215a122..782afbd10 100644 --- a/.github/workflows/product_ci_pipeline.yml +++ b/.github/workflows/ci_pipeline.yml @@ -1,8 +1,11 @@ -name: product ci pipeline +name: ci pipeline on: workflow_dispatch: pull_request: + push: + branches: + - main concurrency: group: "ci-${{ github.head_ref || github.ref }}" diff --git a/.github/workflows/deploy_v2_development.yml b/.github/workflows/deploy_v2_development.yml index 2c8e1c3d2..105522e00 100644 --- a/.github/workflows/deploy_v2_development.yml +++ b/.github/workflows/deploy_v2_development.yml @@ -3,7 +3,7 @@ name: deploy v2 development on: workflow_dispatch: workflow_run: - workflows: [ "product ci pipeline" ] + workflows: [ "ci pipeline" ] types: - completed branches: diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index b8ea8a695..642367bc7 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -4,7 +4,7 @@ import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.alcohols.dto.request.AdminTastingTagUpsertRequest import app.bottlenote.alcohols.dto.response.AdminAlcoholItem import app.bottlenote.alcohols.dto.response.AdminTastingTagDetailResponse -import app.bottlenote.alcohols.dto.response.AdminTastingTagItem +import app.bottlenote.alcohols.dto.response.TastingTagNodeItem import app.bottlenote.alcohols.presentation.AdminTastingTagController import app.bottlenote.alcohols.service.AlcoholReferenceService import app.bottlenote.alcohols.service.TastingTagService @@ -62,7 +62,7 @@ class AdminTastingTagControllerDocsTest { @DisplayName("테이스팅 태그 목록을 조회할 수 있다") fun getAllTastingTags() { // given - val items = AlcoholsHelper.createAdminTastingTagItems(3) + val items = AlcoholsHelper.createTastingTagNodeItems(3) val page = PageImpl(items) val response = GlobalResponse.fromPage(page) @@ -92,11 +92,10 @@ class AdminTastingTagControllerDocsTest { fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("태그 ID"), fieldWithPath("data[].korName").type(JsonFieldType.STRING).description("태그 한글명"), fieldWithPath("data[].engName").type(JsonFieldType.STRING).description("태그 영문명"), - fieldWithPath("data[].icon").type(JsonFieldType.STRING).description("아이콘 (Base64)"), - fieldWithPath("data[].description").type(JsonFieldType.STRING).description("설명"), - fieldWithPath("data[].parentId").type(JsonFieldType.NUMBER).description("부모 태그 ID").optional(), - fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data[].icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), + fieldWithPath("data[].description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("data[].parent").type(JsonFieldType.OBJECT).description("부모 태그 (목록에서는 null)").optional(), + fieldWithPath("data[].children").type(JsonFieldType.ARRAY).description("자식 태그 목록 (목록에서는 null)").optional(), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), @@ -122,13 +121,11 @@ class AdminTastingTagControllerDocsTest { @DisplayName("테이스팅 태그 상세 정보를 조회할 수 있다") fun getTagDetail() { // given - val tagItem = AdminTastingTagItem( - 1L, "바닐라", "Vanilla", "base64icon", "바닐라 향", null, - LocalDateTime.of(2024, 1, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + val childNode = TastingTagNodeItem.of( + 2L, "바닐라 크림", "Vanilla Cream", null, "바닐라 크림 향", null, emptyList() ) - val childItem = AdminTastingTagItem( - 2L, "바닐라 크림", "Vanilla Cream", null, "바닐라 크림 향", 1L, - LocalDateTime.of(2024, 2, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + val tagNode = TastingTagNodeItem.of( + 1L, "바닐라", "Vanilla", "base64icon", "바닐라 향", null, listOf(childNode) ) val alcoholItem = AdminAlcoholItem( 1L, "글렌피딕 12년", "Glenfiddich 12", "싱글몰트", "Single Malt", @@ -136,9 +133,7 @@ class AdminTastingTagControllerDocsTest { LocalDateTime.of(2024, 1, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) ) - val response = AdminTastingTagDetailResponse.of( - tagItem, null, emptyList(), listOf(childItem), listOf(alcoholItem) - ) + val response = AdminTastingTagDetailResponse.of(tagNode, listOf(alcoholItem)) given(tastingTagService.getTagDetail(anyLong())).willReturn(response) @@ -157,22 +152,21 @@ class AdminTastingTagControllerDocsTest { fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), fieldWithPath("data").type(JsonFieldType.OBJECT).description("태그 상세 정보"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("태그 ID"), - fieldWithPath("data.korName").type(JsonFieldType.STRING).description("태그 한글명"), - fieldWithPath("data.engName").type(JsonFieldType.STRING).description("태그 영문명"), - fieldWithPath("data.icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("설명").optional(), - fieldWithPath("data.parent").type(JsonFieldType.OBJECT).description("부모 태그 정보").optional(), - fieldWithPath("data.ancestors").type(JsonFieldType.ARRAY).description("조상 태그 목록 (가까운 순)"), - fieldWithPath("data.children").type(JsonFieldType.ARRAY).description("자식 태그 목록"), - fieldWithPath("data.children[].id").type(JsonFieldType.NUMBER).description("자식 태그 ID"), - fieldWithPath("data.children[].korName").type(JsonFieldType.STRING).description("자식 태그 한글명"), - fieldWithPath("data.children[].engName").type(JsonFieldType.STRING).description("자식 태그 영문명"), - fieldWithPath("data.children[].icon").type(JsonFieldType.STRING).description("자식 태그 아이콘").optional(), - fieldWithPath("data.children[].description").type(JsonFieldType.STRING).description("자식 태그 설명").optional(), - fieldWithPath("data.children[].parentId").type(JsonFieldType.NUMBER).description("부모 태그 ID").optional(), - fieldWithPath("data.children[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.children[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.tag").type(JsonFieldType.OBJECT).description("태그 트리 정보"), + fieldWithPath("data.tag.id").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data.tag.korName").type(JsonFieldType.STRING).description("태그 한글명"), + fieldWithPath("data.tag.engName").type(JsonFieldType.STRING).description("태그 영문명"), + fieldWithPath("data.tag.icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), + fieldWithPath("data.tag.description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("data.tag.parent").type(JsonFieldType.OBJECT).description("부모 태그 (마트료시카 구조)").optional(), + fieldWithPath("data.tag.children").type(JsonFieldType.ARRAY).description("자식 태그 목록 (마트료시카 구조)"), + fieldWithPath("data.tag.children[].id").type(JsonFieldType.NUMBER).description("자식 태그 ID"), + fieldWithPath("data.tag.children[].korName").type(JsonFieldType.STRING).description("자식 태그 한글명"), + fieldWithPath("data.tag.children[].engName").type(JsonFieldType.STRING).description("자식 태그 영문명"), + fieldWithPath("data.tag.children[].icon").type(JsonFieldType.STRING).description("자식 태그 아이콘").optional(), + fieldWithPath("data.tag.children[].description").type(JsonFieldType.STRING).description("자식 태그 설명").optional(), + fieldWithPath("data.tag.children[].parent").type(JsonFieldType.OBJECT).description("손자의 부모 (null)").optional(), + fieldWithPath("data.tag.children[].children").type(JsonFieldType.ARRAY).description("손자 태그 목록"), fieldWithPath("data.alcohols").type(JsonFieldType.ARRAY).description("연결된 위스키 목록"), fieldWithPath("data.alcohols[].alcoholId").type(JsonFieldType.NUMBER).description("위스키 ID"), fieldWithPath("data.alcohols[].korName").type(JsonFieldType.STRING).description("위스키 한글명"), @@ -182,8 +176,6 @@ class AdminTastingTagControllerDocsTest { fieldWithPath("data.alcohols[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL").optional(), fieldWithPath("data.alcohols[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data.alcohols[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 95d6d5ca1..6a6bcc994 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -5,7 +5,7 @@ import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse.TastingTa import app.bottlenote.alcohols.dto.response.AdminAlcoholItem import app.bottlenote.alcohols.dto.response.AdminDistilleryItem import app.bottlenote.alcohols.dto.response.AdminRegionItem -import app.bottlenote.alcohols.dto.response.AdminTastingTagItem +import app.bottlenote.alcohols.dto.response.TastingTagNodeItem import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.global.data.response.GlobalResponse @@ -107,17 +107,26 @@ object AlcoholsHelper { LocalDateTime.of(2024, 6, 1, 0, 0) ) - fun createAdminTastingTagItems(count: Int = 3): List = + fun createTastingTagNodeItem( + id: Long = 1L, + korName: String = "테스트 태그", + engName: String = "Test Tag", + icon: String? = null, + description: String? = null, + parent: TastingTagNodeItem? = null, + children: List? = null + ): TastingTagNodeItem = TastingTagNodeItem.of( + id, korName, engName, icon, description, parent, children + ) + + fun createTastingTagNodeItems(count: Int = 3): List = (1..count).map { i -> - AdminTastingTagItem( + TastingTagNodeItem.forList( i.toLong(), "태그$i", "Tag$i", "icon$i.png", - "테이스팅 태그 설명 $i", - null, - LocalDateTime.of(2024, 1, i, 0, 0), - LocalDateTime.of(2024, 6, i, 0, 0) + "테이스팅 태그 설명 $i" ) } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt index ddb1e39fc..456bfe04b 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt @@ -55,24 +55,32 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { ) .hasStatusOk() .bodyJson() - .extractingPath("$.data.korName").isEqualTo("허니") + .extractingPath("$.data.tag.korName").isEqualTo("허니") } @Test - @DisplayName("부모 태그가 있는 경우 조상 정보가 포함된다") - fun getTagDetailWithAncestors() { - // given + @DisplayName("부모 태그가 있는 경우 마트료시카 구조로 조상 정보가 포함된다") + fun getTagDetailWithParentChain() { + // given - 3depth 트리 생성 (root -> middle -> leaf) val tree = tastingTagTestFactory.persistTastingTagTree() val leafTag = tree[2] - // when & then + // when & then - leaf 태그 조회 시 parent.parent 존재 확인 + assertThat( + mockMvcTester.get().uri("/tasting-tags/${leafTag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.tag.parent").isNotNull() + assertThat( mockMvcTester.get().uri("/tasting-tags/${leafTag.id}") .header("Authorization", "Bearer $accessToken") ) .hasStatusOk() .bodyJson() - .extractingPath("$.data.ancestors.length()").isEqualTo(2) + .extractingPath("$.data.tag.parent.parent").isNotNull() } @Test diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java index 343d8a28f..575f47b43 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java @@ -1,6 +1,6 @@ package app.bottlenote.alcohols.domain; -import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; +import app.bottlenote.alcohols.dto.response.TastingTagNodeItem; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -10,7 +10,7 @@ public interface TastingTagRepository { List findAll(); - Page findAllTastingTags(String keyword, Pageable pageable); + Page findAllTastingTags(String keyword, Pageable pageable); Optional findById(Long id); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java index 53b61f9db..dff8f266d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java @@ -1,38 +1,13 @@ package app.bottlenote.alcohols.dto.response; -import java.time.LocalDateTime; import java.util.List; +/** 테이스팅 태그 상세 조회 응답. 마트료시카 스타일 트리 구조 + 연결된 위스키 목록. */ public record AdminTastingTagDetailResponse( - Long id, - String korName, - String engName, - String icon, - String description, - AdminTastingTagItem parent, - List ancestors, - List children, - List alcohols, - LocalDateTime createdAt, - LocalDateTime modifiedAt) { + TastingTagNodeItem tag, List alcohols) { public static AdminTastingTagDetailResponse of( - AdminTastingTagItem tagItem, - AdminTastingTagItem parent, - List ancestors, - List children, - List alcohols) { - return new AdminTastingTagDetailResponse( - tagItem.id(), - tagItem.korName(), - tagItem.engName(), - tagItem.icon(), - tagItem.description(), - parent, - ancestors, - children, - alcohols, - tagItem.createdAt(), - tagItem.modifiedAt()); + TastingTagNodeItem tag, List alcohols) { + return new AdminTastingTagDetailResponse(tag, alcohols); } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java deleted file mode 100644 index 93b4c8bf1..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java +++ /dev/null @@ -1,13 +0,0 @@ -package app.bottlenote.alcohols.dto.response; - -import java.time.LocalDateTime; - -public record AdminTastingTagItem( - Long id, - String korName, - String engName, - String icon, - String description, - Long parentId, - LocalDateTime createdAt, - LocalDateTime modifiedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/TastingTagNodeItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/TastingTagNodeItem.java new file mode 100644 index 000000000..d55e18b57 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/TastingTagNodeItem.java @@ -0,0 +1,31 @@ +package app.bottlenote.alcohols.dto.response; + +import java.util.List; + +/** 마트료시카 스타일 테이스팅 태그 트리 노드. depth 3 고정, 반대방향 필드는 null로 설정. */ +public record TastingTagNodeItem( + Long id, + String korName, + String engName, + String icon, + String description, + TastingTagNodeItem parent, + List children) { + + public static TastingTagNodeItem of( + Long id, + String korName, + String engName, + String icon, + String description, + TastingTagNodeItem parent, + List children) { + return new TastingTagNodeItem(id, korName, engName, icon, description, parent, children); + } + + /** 목록 조회용 (parent/children = null) */ + public static TastingTagNodeItem forList( + Long id, String korName, String engName, String icon, String description) { + return new TastingTagNodeItem(id, korName, engName, icon, description, null, null); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java index d36a422ae..a91ca4d0d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java @@ -2,7 +2,7 @@ import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.domain.TastingTagRepository; -import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; +import app.bottlenote.alcohols.dto.response.TastingTagNodeItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; import java.util.List; import java.util.Optional; @@ -19,15 +19,15 @@ public interface JpaTastingTagRepository @Override @Query( """ - select new app.bottlenote.alcohols.dto.response.AdminTastingTagItem( - t.id, t.korName, t.engName, t.icon, t.description, t.parentId, t.createAt, t.lastModifyAt + select new app.bottlenote.alcohols.dto.response.TastingTagNodeItem( + t.id, t.korName, t.engName, t.icon, t.description, null, null ) from tasting_tag t where (:keyword is null or :keyword = '' or t.korName like concat('%', :keyword, '%') or t.engName like concat('%', :keyword, '%')) """) - Page findAllTastingTags(@Param("keyword") String keyword, Pageable pageable); + Page findAllTastingTags(@Param("keyword") String keyword, Pageable pageable); @Override Optional findById(Long id); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java index eaec13272..b69386fa8 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java @@ -22,7 +22,7 @@ import app.bottlenote.alcohols.dto.request.AdminTastingTagUpsertRequest; import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; import app.bottlenote.alcohols.dto.response.AdminTastingTagDetailResponse; -import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; +import app.bottlenote.alcohols.dto.response.TastingTagNodeItem; import app.bottlenote.alcohols.exception.AlcoholException; import app.bottlenote.global.dto.response.AdminResultResponse; import java.util.ArrayList; @@ -66,12 +66,6 @@ public void initializeTrie() { log.info("TastingTag Trie 초기화 완료: {}개 태그 등록", tags.size()); } - /** - * 문장에서 태그 이름 목록을 추출한다. (부분 매칭 허용) - * - * @param text 분석할 문장 - * @return 매칭된 태그 이름 목록 - */ @Transactional(readOnly = true) public List extractTagNames(String text) { if (trie == null || text == null || text.isBlank()) { @@ -88,28 +82,25 @@ public AdminTastingTagDetailResponse getTagDetail(Long tagId) { .findById(tagId) .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); - AdminTastingTagItem parent = null; - if (tag.getParentId() != null) { - parent = - tastingTagRepository - .findById(tag.getParentId()) - .map(this::toAdminTastingTagItem) - .orElse(null); - } + TastingTagNodeItem parentChain = buildParentChain(tag.getParentId()); + List childrenTree = buildChildrenTree(tagId); - List ancestors = findAncestors(tag.getParentId(), MAX_DEPTH); - List children = - tastingTagRepository.findByParentId(tagId).stream() - .map(this::toAdminTastingTagItem) - .toList(); + TastingTagNodeItem tagNode = + TastingTagNodeItem.of( + tag.getId(), + tag.getKorName(), + tag.getEngName(), + tag.getIcon(), + tag.getDescription(), + parentChain, + childrenTree); List alcohols = alcoholsTastingTagsRepository.findByTastingTagId(tagId).stream() .map(att -> toAdminAlcoholItem(att.getAlcohol())) .toList(); - AdminTastingTagItem tagItem = toAdminTastingTagItem(tag); - return AdminTastingTagDetailResponse.of(tagItem, parent, ancestors, children, alcohols); + return AdminTastingTagDetailResponse.of(tagNode, alcohols); } @Transactional @@ -207,21 +198,46 @@ public AdminResultResponse removeAlcoholsFromTag(Long tagId, List alcoholI return AdminResultResponse.of(TASTING_TAG_ALCOHOL_REMOVED, tagId); } - private List findAncestors(Long parentId, int maxDepth) { - List ancestors = new ArrayList<>(); - Long currentParentId = parentId; - int depth = 0; + /** 부모 체인을 마트료시카 구조로 빌드 (parent.parent.parent...) */ + private TastingTagNodeItem buildParentChain(Long parentId) { + if (parentId == null) { + return null; + } - while (currentParentId != null && depth < maxDepth) { - TastingTag parent = tastingTagRepository.findById(currentParentId).orElse(null); - if (parent == null) break; + TastingTag parent = tastingTagRepository.findById(parentId).orElse(null); + if (parent == null) { + return null; + } - ancestors.add(toAdminTastingTagItem(parent)); - currentParentId = parent.getParentId(); - depth++; + return TastingTagNodeItem.of( + parent.getId(), + parent.getKorName(), + parent.getEngName(), + parent.getIcon(), + parent.getDescription(), + buildParentChain(parent.getParentId()), + null); + } + + /** 자식 트리를 마트료시카 구조로 빌드 (children[].children[]...) */ + private List buildChildrenTree(Long tagId) { + List children = tastingTagRepository.findByParentId(tagId); + if (children.isEmpty()) { + return List.of(); } - return ancestors; + return children.stream() + .map( + child -> + TastingTagNodeItem.of( + child.getId(), + child.getKorName(), + child.getEngName(), + child.getIcon(), + child.getDescription(), + null, + buildChildrenTree(child.getId()))) + .toList(); } private void validateParentAndDepth(Long parentId) { @@ -264,16 +280,4 @@ private AdminAlcoholItem toAdminAlcoholItem(Alcohol alcohol) { alcohol.getCreateAt(), alcohol.getLastModifyAt()); } - - private AdminTastingTagItem toAdminTastingTagItem(TastingTag tag) { - return new AdminTastingTagItem( - tag.getId(), - tag.getKorName(), - tag.getEngName(), - tag.getIcon(), - tag.getDescription(), - tag.getParentId(), - tag.getCreateAt(), - tag.getLastModifyAt()); - } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java index 3a30b7936..e87b657e2 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java @@ -2,7 +2,7 @@ import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.domain.TastingTagRepository; -import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; +import app.bottlenote.alcohols.dto.response.TastingTagNodeItem; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -22,8 +22,8 @@ public List findAll() { } @Override - public Page findAllTastingTags(String keyword, Pageable pageable) { - List filtered = + public Page findAllTastingTags(String keyword, Pageable pageable) { + List filtered = tags.stream() .filter( t -> @@ -31,12 +31,12 @@ public Page findAllTastingTags(String keyword, Pageable pag || keyword.isEmpty() || t.getKorName().contains(keyword) || t.getEngName().contains(keyword)) - .map(this::toAdminTastingTagItem) + .map(this::toTastingTagNodeItem) .toList(); int start = (int) pageable.getOffset(); int end = Math.min(start + pageable.getPageSize(), filtered.size()); - List pageContent = + List pageContent = start < filtered.size() ? filtered.subList(start, end) : List.of(); return new PageImpl<>(pageContent, pageable, filtered.size()); @@ -90,15 +90,8 @@ public void clear() { tags.clear(); } - private AdminTastingTagItem toAdminTastingTagItem(TastingTag tag) { - return new AdminTastingTagItem( - tag.getId(), - tag.getKorName(), - tag.getEngName(), - tag.getIcon(), - tag.getDescription(), - tag.getParentId(), - tag.getCreateAt(), - tag.getLastModifyAt()); + private TastingTagNodeItem toTastingTagNodeItem(TastingTag tag) { + return TastingTagNodeItem.forList( + tag.getId(), tag.getKorName(), tag.getEngName(), tag.getIcon(), tag.getDescription()); } } diff --git a/git.environment-variables b/git.environment-variables index 625ccd3d4..6cc75e4de 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 625ccd3d4b26ce14b19a14cf24b04cbc823bf80a +Subproject commit 6cc75e4de1ce3e9489af01dd202e9550160d5049 From 21eea975341b63b9dd5ddc0d108e86ee35b2695c Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 28 Jan 2026 11:38:29 +0900 Subject: [PATCH 08/29] docs: simplify titles in tasting tag API documentation --- .../docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc index 43aaf37bc..705d8699b 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc @@ -138,7 +138,7 @@ include::{snippets}/admin/tasting-tags/delete/http-response.adoc[] ''' -=== 테이스팅 태그 위스키 연결 === +=== 테이스팅 태그 연결 === - 테이스팅 태그에 술을 벌크로 연결합니다. @@ -170,7 +170,7 @@ include::{snippets}/admin/tasting-tags/add-alcohols/http-response.adoc[] ''' -=== 테이스팅 태그 위스키 연결 해제 === +=== 테이스팅 태그 연결 해제 === - 테이스팅 태그에서 술 연결을 벌크로 해제합니다. From e64cdd7f0df6d4d56bbfecc316586c3c8d388ab2 Mon Sep 17 00:00:00 2001 From: rlagu Date: Mon, 2 Feb 2026 22:37:25 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat(admin):=20=ED=81=90=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B4=80=EB=A6=AC=20Admin=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 큐레이션 CRUD API (목록, 상세, 생성, 수정, 삭제) - 활성화 상태 토글 API (PATCH /status) - 노출 순서 변경 API (PATCH /display-order) - 위스키 추가/제거 API (POST/DELETE /alcohols) 구현 내용: - AdminCurationController (Kotlin) - AdminCurationService (Java) - Request/Response DTO 8개 - CurationKeyword 엔티티 업데이트 메서드 추가 - QueryDSL 기반 Admin 검색 쿼리 구현 - 통합 테스트 및 RestDocs 문서화 - Admin API 구현 가이드 문서 작성 Co-Authored-By: Claude Opus 4.5 --- .claude/docs/ADMIN-API-GUIDE.md | 568 ++++++++++++++++++ .../src/docs/asciidoc/admin-api.adoc | 6 + .../api/admin-curations/curations.adoc | 260 ++++++++ .../presentation/AdminCurationController.kt | 89 +++ .../AdminCurationControllerDocsTest.kt | 526 ++++++++++++++++ .../app/helper/curation/CurationHelper.kt | 89 +++ .../curation/AdminCurationIntegrationTest.kt | 435 ++++++++++++++ .../alcohols/domain/CurationKeyword.java | 31 + .../domain/CurationKeywordRepository.java | 14 + .../request/AdminCurationAlcoholRequest.java | 12 + .../request/AdminCurationCreateRequest.java | 28 + .../AdminCurationDisplayOrderRequest.java | 13 + .../request/AdminCurationSearchRequest.java | 21 + .../request/AdminCurationStatusRequest.java | 10 + .../request/AdminCurationUpdateRequest.java | 28 + .../response/AdminCurationDetailResponse.java | 51 ++ .../response/AdminCurationListResponse.java | 21 + .../exception/AlcoholExceptionCode.java | 5 +- .../CustomCurationKeywordRepository.java | 7 + .../CustomCurationKeywordRepositoryImpl.java | 41 ++ .../JpaCurationKeywordRepository.java | 5 +- .../service/AdminCurationService.java | 160 +++++ .../dto/response/AdminResultResponse.java | 7 + 23 files changed, 2425 insertions(+), 2 deletions(-) create mode 100644 .claude/docs/ADMIN-API-GUIDE.md create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/curations.adoc create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminCurationController.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationAlcoholRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationCreateRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationDisplayOrderRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationSearchRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationStatusRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationUpdateRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationDetailResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationListResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminCurationService.java diff --git a/.claude/docs/ADMIN-API-GUIDE.md b/.claude/docs/ADMIN-API-GUIDE.md new file mode 100644 index 000000000..7f82d827e --- /dev/null +++ b/.claude/docs/ADMIN-API-GUIDE.md @@ -0,0 +1,568 @@ +# Admin API 구현 가이드 + +> 이 문서는 Admin API를 구현할 때 따라야 할 표준 패턴과 컨벤션을 정의합니다. + +## 목차 +- [아키텍처 개요](#아키텍처-개요) +- [구현 단계](#구현-단계) +- [DTO 작성 규칙](#dto-작성-규칙) +- [Service 작성 규칙](#service-작성-규칙) +- [Controller 작성 규칙](#controller-작성-규칙) +- [테스트 작성 규칙](#테스트-작성-규칙) +- [문서화 규칙](#문서화-규칙) + +--- + +## 아키텍처 개요 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ admin-api (Kotlin) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Controller (presentation) │ │ +│ │ - REST 엔드포인트 정의 │ │ +│ │ - 요청/응답 처리 │ │ +│ │ - GlobalResponse 래핑 │ │ +│ └─────────────────────────────────────────────────────┘ │ +└────────────────────────────┬────────────────────────────────┘ + │ 의존 +┌────────────────────────────▼────────────────────────────────┐ +│ mono (Java) │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Service │ │ +│ │ - 비즈니스 로직 처리 │ │ +│ │ - 트랜잭션 관리 │ │ +│ │ - AdminResultResponse 반환 │ │ +│ └────────────────────────────┬────────────────────────┘ │ +│ ┌────────────────────────────▼────────────────────────┐ │ +│ │ Repository │ │ +│ │ - JPA + QueryDSL │ │ +│ │ - 도메인 레포지토리 인터페이스 구현 │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ DTO │ │ +│ │ - Request: Java record + validation │ │ +│ │ - Response: Java record │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 구현 단계 + +### Phase 1: 구현 + +| 순서 | 작업 | 모듈 | 언어 | +|------|------|------|------| +| 1 | Request/Response DTO 작성 | mono | Java | +| 2 | Service 작성 | mono | Java | +| 3 | Repository 확장 (필요 시) | mono | Java | +| 4 | Controller 작성 | admin-api | Kotlin | + +### Phase 2: 테스트 + +| 순서 | 작업 | 위치 | +|------|------|------| +| 1 | Test Helper 작성 | `app/helper/{domain}/` | +| 2 | Integration Test 작성 | `app/integration/{domain}/` | + +### Phase 3: 문서화 + +| 순서 | 작업 | 위치 | +|------|------|------| +| 1 | RestDocs Test 작성 | `app/docs/{domain}/` | +| 2 | AsciiDoc 작성 | `src/docs/asciidoc/{domain}.adoc` | + +--- + +## DTO 작성 규칙 + +### 위치 +``` +bottlenote-mono/src/main/java/app/bottlenote/{domain}/dto/ +├── request/ +│ ├── Admin{Domain}SearchRequest.java # 목록 조회 +│ ├── Admin{Domain}CreateRequest.java # 생성 +│ ├── Admin{Domain}UpdateRequest.java # 수정 +│ └── Admin{Domain}{Action}Request.java # 특수 액션 +└── response/ + ├── Admin{Domain}ListResponse.java # 목록 항목 + └── Admin{Domain}DetailResponse.java # 상세 조회 +``` + +### Request DTO 패턴 + +```java +// 검색 요청 (GET 파라미터) +public record Admin{Domain}SearchRequest( + String keyword, + {FilterType} filter, + Integer page, + Integer size) { + + @Builder + public Admin{Domain}SearchRequest { + page = page != null ? page : 0; + size = size != null ? size : 20; + } +} + +// 생성/수정 요청 (POST/PUT body) +public record Admin{Domain}CreateRequest( + @NotBlank(message = "이름은 필수입니다.") String name, + String description, + @NotNull(message = "타입은 필수입니다.") {Type} type) { + + @Builder + public Admin{Domain}CreateRequest { + // 기본값 설정 (선택) + } +} +``` + +### Response DTO 패턴 + +```java +// 목록 응답 +public record Admin{Domain}ListResponse( + Long id, + String name, + {Type} type, + Boolean isActive, + LocalDateTime createdAt) {} + +// 상세 응답 +public record Admin{Domain}DetailResponse( + Long id, + String name, + String description, + // ... 상세 필드 + LocalDateTime createdAt, + LocalDateTime modifiedAt) { + + public static Admin{Domain}DetailResponse from({Domain} entity) { + return new Admin{Domain}DetailResponse( + entity.getId(), + entity.getName(), + // ... + ); + } +} +``` + +### 페이징 방식 + +| API 유형 | 방식 | 파라미터 | +|----------|------|----------| +| Admin (관리자) | 오프셋 페이징 | `page`, `size` | +| Product (클라이언트) | 커서 페이징 | `cursor`, `pageSize` | + +--- + +## Service 작성 규칙 + +### 위치 +``` +bottlenote-mono/src/main/java/app/bottlenote/{domain}/service/Admin{Domain}Service.java +``` + +### 기본 구조 + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class Admin{Domain}Service { + + private final {Domain}Repository repository; + + // 목록 조회 + @Transactional(readOnly = true) + public Page search(Admin{Domain}SearchRequest request) { + PageRequest pageable = PageRequest.of(request.page(), request.size()); + return repository.searchForAdmin(request, pageable); + } + + // 상세 조회 + @Transactional(readOnly = true) + public Admin{Domain}DetailResponse getDetail(Long id) { + {Domain} entity = repository.findById(id) + .orElseThrow(() -> new {Domain}Exception({DOMAIN}_NOT_FOUND)); + return Admin{Domain}DetailResponse.from(entity); + } + + // 생성 + @Transactional + public AdminResultResponse create(Admin{Domain}CreateRequest request) { + // 중복 검사 (필요 시) + if (repository.existsByName(request.name())) { + throw new {Domain}Exception({DOMAIN}_DUPLICATE_NAME); + } + + {Domain} entity = {Domain}.create(request.name(), ...); + {Domain} saved = repository.save(entity); + return AdminResultResponse.of({DOMAIN}_CREATED, saved.getId()); + } + + // 수정 + @Transactional + public AdminResultResponse update(Long id, Admin{Domain}UpdateRequest request) { + {Domain} entity = repository.findById(id) + .orElseThrow(() -> new {Domain}Exception({DOMAIN}_NOT_FOUND)); + + entity.update(request.name(), ...); + return AdminResultResponse.of({DOMAIN}_UPDATED, id); + } + + // 삭제 + @Transactional + public AdminResultResponse delete(Long id) { + {Domain} entity = repository.findById(id) + .orElseThrow(() -> new {Domain}Exception({DOMAIN}_NOT_FOUND)); + + repository.delete(entity); + return AdminResultResponse.of({DOMAIN}_DELETED, id); + } +} +``` + +### AdminResultResponse 사용 + +CUD 작업은 `AdminResultResponse`로 통일: + +```java +// global/dto/response/AdminResultResponse.java +public record AdminResultResponse(String code, String message, Long targetId, String responseAt) { + public static AdminResultResponse of(ResultCode code, Long targetId) { ... } + + public enum ResultCode { + {DOMAIN}_CREATED("{도메인}이(가) 등록되었습니다."), + {DOMAIN}_UPDATED("{도메인}이(가) 수정되었습니다."), + {DOMAIN}_DELETED("{도메인}이(가) 삭제되었습니다."), + // 커스텀 액션 + {DOMAIN}_{ACTION}("{도메인} {액션}이(가) 완료되었습니다."); + } +} +``` + +--- + +## Controller 작성 규칙 + +### 위치 +``` +bottlenote-admin-api/src/main/kotlin/app/bottlenote/{domain}/presentation/Admin{Domain}Controller.kt +``` + +### 기본 구조 + +```kotlin +@RestController +@RequestMapping("/{resources}") // 복수형 리소스명 (kebab-case) +class Admin{Domain}Controller( + private val admin{Domain}Service: Admin{Domain}Service +) { + + // 목록 조회 + @GetMapping + fun list(@ModelAttribute request: Admin{Domain}SearchRequest): ResponseEntity<*> { + return GlobalResponse.ok(admin{Domain}Service.search(request)) + } + + // 상세 조회 + @GetMapping("/{id}") + fun detail(@PathVariable id: Long): ResponseEntity<*> { + return GlobalResponse.ok(admin{Domain}Service.getDetail(id)) + } + + // 생성 + @PostMapping + fun create(@RequestBody @Valid request: Admin{Domain}CreateRequest): ResponseEntity<*> { + return GlobalResponse.ok(admin{Domain}Service.create(request)) + } + + // 수정 + @PutMapping("/{id}") + fun update( + @PathVariable id: Long, + @RequestBody @Valid request: Admin{Domain}UpdateRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(admin{Domain}Service.update(id, request)) + } + + // 삭제 + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): ResponseEntity<*> { + return GlobalResponse.ok(admin{Domain}Service.delete(id)) + } +} +``` + +### HTTP 메서드 규칙 + +| 작업 | Method | URL 패턴 | 예시 | +|------|--------|----------|------| +| 목록 조회 | GET | `/{resources}` | `GET /curations` | +| 상세 조회 | GET | `/{resources}/{id}` | `GET /curations/1` | +| 생성 | POST | `/{resources}` | `POST /curations` | +| 전체 수정 | PUT | `/{resources}/{id}` | `PUT /curations/1` | +| 부분 수정 | PATCH | `/{resources}/{id}/{field}` | `PATCH /curations/1/status` | +| 삭제 | DELETE | `/{resources}/{id}` | `DELETE /curations/1` | +| 하위 리소스 추가 | POST | `/{resources}/{id}/{sub}` | `POST /curations/1/alcohols` | +| 하위 리소스 삭제 | DELETE | `/{resources}/{id}/{sub}/{subId}` | `DELETE /curations/1/alcohols/5` | + +### 인증이 필요한 경우 + +```kotlin +@PostMapping("/{id}/action") +fun action( + @PathVariable id: Long, + @RequestBody @Valid request: ActionRequest +): ResponseEntity<*> { + val adminId = SecurityContextUtil.getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } + return GlobalResponse.ok(service.action(id, adminId, request)) +} +``` + +--- + +## 테스트 작성 규칙 + +### Test Helper + +**위치**: `app/helper/{domain}/{Domain}Helper.kt` + +```kotlin +object {Domain}Helper { + + fun createAdmin{Domain}ListResponse( + id: Long = 1L, + name: String = "테스트 {도메인}", + isActive: Boolean = true + ): Admin{Domain}ListResponse = Admin{Domain}ListResponse( + id, name, /* ... */, isActive, LocalDateTime.now() + ) + + fun createAdmin{Domain}DetailResponse( + id: Long = 1L, + name: String = "테스트 {도메인}" + ): Admin{Domain}DetailResponse = Admin{Domain}DetailResponse( + id, name, /* ... */ + ) + + fun create{Domain}Request( + name: String = "새 {도메인}" + ): Map = mapOf( + "name" to name, + // ... + ) +} +``` + +### Integration Test + +**위치**: `app/integration/{domain}/Admin{Domain}IntegrationTest.kt` + +```kotlin +@Tag("admin_integration") +@DisplayName("[integration] Admin {도메인} API 통합 테스트") +class Admin{Domain}IntegrationTest : IntegrationTestSupport() { + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("{도메인} 목록 조회 API") + inner class List{Domain}s { + + @Test + @DisplayName("{도메인} 목록을 조회할 수 있다") + fun listSuccess() { + // given - 테스트 데이터 준비 + + // when & then + assertThat( + mockMvcTester.get().uri("/{resources}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.content").isNotNull() + } + + @Test + @DisplayName("인증 없이 요청하면 401을 반환한다") + fun listUnauthorized() { + assertThat( + mockMvcTester.get().uri("/{resources}") + ) + .hasStatus(HttpStatus.UNAUTHORIZED) + } + } + + @Nested + @DisplayName("{도메인} 생성 API") + inner class Create{Domain} { + + @Test + @DisplayName("{도메인}을 생성할 수 있다") + fun createSuccess() { + val request = {Domain}Helper.create{Domain}Request() + + assertThat( + mockMvcTester.post().uri("/{resources}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("{DOMAIN}_CREATED") + } + + @Test + @DisplayName("필수 필드 누락 시 400을 반환한다") + fun createValidationFail() { + val request = mapOf("name" to "") + + assertThat( + mockMvcTester.post().uri("/{resources}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus(HttpStatus.BAD_REQUEST) + } + } + + // @Nested inner class Update{Domain} { ... } + // @Nested inner class Delete{Domain} { ... } +} +``` + +### 테스트 케이스 체크리스트 + +| API | 필수 테스트 | +|-----|-----------| +| 목록 조회 | 성공, 인증 실패, 필터링 | +| 상세 조회 | 성공, 인증 실패, 존재하지 않는 ID | +| 생성 | 성공, 인증 실패, 필수 필드 누락, 중복 검사 | +| 수정 | 성공, 인증 실패, 존재하지 않는 ID | +| 삭제 | 성공, 인증 실패, 존재하지 않는 ID | + +--- + +## 문서화 규칙 + +### RestDocs Test + +**위치**: `app/docs/{domain}/Admin{Domain}ControllerDocsTest.kt` + +```kotlin +@WebMvcTest( + controllers = [Admin{Domain}Controller::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("[docs] Admin {도메인} API 문서화") +class Admin{Domain}ControllerDocsTest { + + @Autowired private lateinit var mvc: MockMvc + @Autowired private lateinit var mapper: ObjectMapper + @MockitoBean private lateinit var admin{Domain}Service: Admin{Domain}Service + + @Test + @DisplayName("{도메인} 목록 조회") + fun list{Domain}s() { + // given + val response = PageImpl(listOf({Domain}Helper.createAdmin{Domain}ListResponse())) + given(admin{Domain}Service.search(any())).willReturn(response) + + // when & then + mvc.get("/{resources}") { + param("page", "0") + param("size", "20") + }.andExpect { + status { isOk() } + }.andDo { + document( + "admin/{resources}/list", + queryParameters( + parameterWithName("page").description("페이지 번호").optional(), + parameterWithName("size").description("페이지 크기").optional() + ), + responseFields( + fieldWithPath("success").description("성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("data.content[]").description("목록"), + // ... + ) + ) + } + } +} +``` + +### AsciiDoc + +**위치**: `src/docs/asciidoc/{domain}.adoc` + +```asciidoc += {도메인} 관리 API + +== {도메인} 목록 조회 +operation::admin/{resources}/list[snippets='http-request,query-parameters,http-response,response-fields'] + +== {도메인} 상세 조회 +operation::admin/{resources}/detail[snippets='http-request,path-parameters,http-response,response-fields'] + +== {도메인} 생성 +operation::admin/{resources}/create[snippets='http-request,request-fields,http-response,response-fields'] + +== {도메인} 수정 +operation::admin/{resources}/update[snippets='http-request,path-parameters,request-fields,http-response,response-fields'] + +== {도메인} 삭제 +operation::admin/{resources}/delete[snippets='http-request,path-parameters,http-response,response-fields'] +``` + +--- + +## 예외 처리 + +### ExceptionCode 추가 + +```java +// {domain}/exception/{Domain}ExceptionCode.java +public enum {Domain}ExceptionCode implements ExceptionCode { + {DOMAIN}_NOT_FOUND(HttpStatus.NOT_FOUND, "{도메인}을(를) 찾을 수 없습니다."), + {DOMAIN}_DUPLICATE_NAME(HttpStatus.CONFLICT, "동일한 이름의 {도메인}이(가) 이미 존재합니다."), + {DOMAIN}_INVALID_STATE(HttpStatus.BAD_REQUEST, "잘못된 상태입니다."); + // ... +} +``` + +--- + +## 빠른 시작 체크리스트 + +새로운 Admin API 구현 시 다음 순서로 진행: + +- [ ] **DTO**: Request/Response record 작성 (mono) +- [ ] **Exception**: ExceptionCode enum 추가 (mono) +- [ ] **ResultCode**: AdminResultResponse.ResultCode 추가 (mono) +- [ ] **Repository**: 필요 시 Admin 전용 쿼리 메서드 추가 (mono) +- [ ] **Service**: Admin{Domain}Service 작성 (mono) +- [ ] **Controller**: Admin{Domain}Controller 작성 (admin-api, Kotlin) +- [ ] **Helper**: {Domain}Helper object 작성 (test) +- [ ] **Integration Test**: Admin{Domain}IntegrationTest 작성 (test) +- [ ] **RestDocs Test**: Admin{Domain}ControllerDocsTest 작성 (선택) +- [ ] **AsciiDoc**: {domain}.adoc 작성 (선택) +- [ ] **빌드 검증**: `./gradlew :bottlenote-admin-api:build` diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 5c60d05c2..624fd8d2a 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -61,3 +61,9 @@ include::api/admin-tasting-tags/tasting-tags.adoc[] == Reference API include::api/admin-reference/reference.adoc[] + +''' + +== Curation API + +include::api/admin-curations/curations.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/curations.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/curations.adoc new file mode 100644 index 000000000..83d8c18b4 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/curations.adoc @@ -0,0 +1,260 @@ +=== 큐레이션 목록 조회 === + +- 큐레이션 목록을 페이지네이션으로 조회합니다. +- 키워드 및 활성화 상태로 필터링이 가능합니다. + +[source] +---- +GET /admin/api/v1/curations +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/list/query-parameters.adoc[] +include::{snippets}/admin/curations/list/curl-request.adoc[] +include::{snippets}/admin/curations/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/list/response-fields.adoc[] +include::{snippets}/admin/curations/list/http-response.adoc[] + +''' + +=== 큐레이션 상세 조회 === + +- 특정 큐레이션의 상세 정보를 조회합니다. +- 포함된 위스키 ID 목록을 포함합니다. + +[source] +---- +GET /admin/api/v1/curations/{curationId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/detail/path-parameters.adoc[] +include::{snippets}/admin/curations/detail/curl-request.adoc[] +include::{snippets}/admin/curations/detail/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/detail/response-fields.adoc[] +include::{snippets}/admin/curations/detail/http-response.adoc[] + +''' + +=== 큐레이션 생성 === + +- 새로운 큐레이션을 생성합니다. +- 위스키 ID 목록을 함께 지정할 수 있습니다. + +[source] +---- +POST /admin/api/v1/curations +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/create/request-fields.adoc[] +include::{snippets}/admin/curations/create/curl-request.adoc[] +include::{snippets}/admin/curations/create/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/create/response-fields.adoc[] +include::{snippets}/admin/curations/create/http-response.adoc[] + +''' + +=== 큐레이션 수정 === + +- 기존 큐레이션 정보를 수정합니다. + +[source] +---- +PUT /admin/api/v1/curations/{curationId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update/request-fields.adoc[] +include::{snippets}/admin/curations/update/curl-request.adoc[] +include::{snippets}/admin/curations/update/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update/response-fields.adoc[] +include::{snippets}/admin/curations/update/http-response.adoc[] + +''' + +=== 큐레이션 삭제 === + +- 큐레이션을 삭제합니다. + +[source] +---- +DELETE /admin/api/v1/curations/{curationId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/delete/path-parameters.adoc[] +include::{snippets}/admin/curations/delete/curl-request.adoc[] +include::{snippets}/admin/curations/delete/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/delete/response-fields.adoc[] +include::{snippets}/admin/curations/delete/http-response.adoc[] + +''' + +=== 큐레이션 활성화 상태 변경 === + +- 큐레이션의 활성화 상태를 변경합니다. +- 비활성화된 큐레이션은 클라이언트에 노출되지 않습니다. + +[source] +---- +PATCH /admin/api/v1/curations/{curationId}/status +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update-status/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update-status/request-fields.adoc[] +include::{snippets}/admin/curations/update-status/curl-request.adoc[] +include::{snippets}/admin/curations/update-status/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update-status/response-fields.adoc[] +include::{snippets}/admin/curations/update-status/http-response.adoc[] + +''' + +=== 큐레이션 노출 순서 변경 === + +- 큐레이션의 노출 순서를 변경합니다. +- 숫자가 작을수록 먼저 노출됩니다. + +[source] +---- +PATCH /admin/api/v1/curations/{curationId}/display-order +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update-display-order/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update-display-order/request-fields.adoc[] +include::{snippets}/admin/curations/update-display-order/curl-request.adoc[] +include::{snippets}/admin/curations/update-display-order/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/update-display-order/response-fields.adoc[] +include::{snippets}/admin/curations/update-display-order/http-response.adoc[] + +''' + +=== 큐레이션에 위스키 추가 === + +- 큐레이션에 위스키를 벌크로 추가합니다. + +[source] +---- +POST /admin/api/v1/curations/{curationId}/alcohols +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/add-alcohols/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/add-alcohols/request-fields.adoc[] +include::{snippets}/admin/curations/add-alcohols/curl-request.adoc[] +include::{snippets}/admin/curations/add-alcohols/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/add-alcohols/response-fields.adoc[] +include::{snippets}/admin/curations/add-alcohols/http-response.adoc[] + +''' + +=== 큐레이션에서 위스키 제거 === + +- 큐레이션에서 특정 위스키를 제거합니다. + +[source] +---- +DELETE /admin/api/v1/curations/{curationId}/alcohols/{alcoholId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/remove-alcohol/path-parameters.adoc[] +include::{snippets}/admin/curations/remove-alcohol/curl-request.adoc[] +include::{snippets}/admin/curations/remove-alcohol/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/curations/remove-alcohol/response-fields.adoc[] +include::{snippets}/admin/curations/remove-alcohol/http-response.adoc[] diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminCurationController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminCurationController.kt new file mode 100644 index 000000000..4b975a972 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminCurationController.kt @@ -0,0 +1,89 @@ +package app.bottlenote.alcohols.presentation + +import app.bottlenote.alcohols.dto.request.AdminCurationAlcoholRequest +import app.bottlenote.alcohols.dto.request.AdminCurationCreateRequest +import app.bottlenote.alcohols.dto.request.AdminCurationDisplayOrderRequest +import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest +import app.bottlenote.alcohols.dto.request.AdminCurationStatusRequest +import app.bottlenote.alcohols.dto.request.AdminCurationUpdateRequest +import app.bottlenote.alcohols.service.AdminCurationService +import app.bottlenote.global.data.response.GlobalResponse +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/curations") +class AdminCurationController( + private val adminCurationService: AdminCurationService +) { + + @GetMapping + fun list(@ModelAttribute request: AdminCurationSearchRequest): ResponseEntity { + return ResponseEntity.ok(adminCurationService.search(request)) + } + + @GetMapping("/{curationId}") + fun detail(@PathVariable curationId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminCurationService.getDetail(curationId)) + } + + @PostMapping + fun create(@RequestBody @Valid request: AdminCurationCreateRequest): ResponseEntity<*> { + return GlobalResponse.ok(adminCurationService.create(request)) + } + + @PutMapping("/{curationId}") + fun update( + @PathVariable curationId: Long, + @RequestBody @Valid request: AdminCurationUpdateRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminCurationService.update(curationId, request)) + } + + @DeleteMapping("/{curationId}") + fun delete(@PathVariable curationId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminCurationService.delete(curationId)) + } + + @PatchMapping("/{curationId}/status") + fun updateStatus( + @PathVariable curationId: Long, + @RequestBody @Valid request: AdminCurationStatusRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminCurationService.updateStatus(curationId, request)) + } + + @PatchMapping("/{curationId}/display-order") + fun updateDisplayOrder( + @PathVariable curationId: Long, + @RequestBody @Valid request: AdminCurationDisplayOrderRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminCurationService.updateDisplayOrder(curationId, request)) + } + + @PostMapping("/{curationId}/alcohols") + fun addAlcohols( + @PathVariable curationId: Long, + @RequestBody @Valid request: AdminCurationAlcoholRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminCurationService.addAlcohols(curationId, request)) + } + + @DeleteMapping("/{curationId}/alcohols/{alcoholId}") + fun removeAlcohol( + @PathVariable curationId: Long, + @PathVariable alcoholId: Long + ): ResponseEntity<*> { + return GlobalResponse.ok(adminCurationService.removeAlcohol(curationId, alcoholId)) + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt new file mode 100644 index 000000000..c25430aa7 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt @@ -0,0 +1,526 @@ +package app.docs.curation + +import app.bottlenote.alcohols.dto.request.AdminCurationAlcoholRequest +import app.bottlenote.alcohols.dto.request.AdminCurationCreateRequest +import app.bottlenote.alcohols.dto.request.AdminCurationDisplayOrderRequest +import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest +import app.bottlenote.alcohols.dto.request.AdminCurationStatusRequest +import app.bottlenote.alcohols.dto.request.AdminCurationUpdateRequest +import app.bottlenote.alcohols.presentation.AdminCurationController +import app.bottlenote.alcohols.service.AdminCurationService +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.dto.response.AdminResultResponse +import app.helper.curation.CurationHelper +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.BDDMockito.given +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.data.domain.PageImpl +import org.springframework.http.MediaType +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 + +@WebMvcTest( + controllers = [AdminCurationController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin Curation 컨트롤러 RestDocs 테스트") +class AdminCurationControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @Autowired + private lateinit var mapper: ObjectMapper + + @MockitoBean + private lateinit var adminCurationService: AdminCurationService + + @Nested + @DisplayName("큐레이션 목록 조회") + inner class ListCurations { + + @Test + @DisplayName("큐레이션 목록을 조회할 수 있다") + fun listCurations() { + // given + val items = CurationHelper.createAdminCurationListResponses(3) + val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) + + given(adminCurationService.search(any(AdminCurationSearchRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/curations?keyword=&isActive=true&page=0&size=20") + ) + .hasStatusOk() + .apply( + document( + "admin/curations/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("keyword").description("검색어 (큐레이션명)").optional(), + parameterWithName("isActive").description("활성화 상태 필터 (true/false/null)").optional(), + parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 20)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("페이징 데이터"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("큐레이션 목록"), + fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.content[].name").type(JsonFieldType.STRING).description("큐레이션명"), + fieldWithPath("data.content[].alcoholCount").type(JsonFieldType.NUMBER).description("포함된 위스키 수"), + fieldWithPath("data.content[].displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), + fieldWithPath("data.content[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.pageable").type(JsonFieldType.OBJECT).description("페이징 정보").ignored(), + fieldWithPath("data.pageable.pageNumber").type(JsonFieldType.NUMBER).description("페이지 번호").ignored(), + fieldWithPath("data.pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기").ignored(), + fieldWithPath("data.pageable.sort").type(JsonFieldType.OBJECT).description("정렬 정보").ignored(), + fieldWithPath("data.pageable.sort.empty").type(JsonFieldType.BOOLEAN).ignored(), + fieldWithPath("data.pageable.sort.sorted").type(JsonFieldType.BOOLEAN).ignored(), + fieldWithPath("data.pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).ignored(), + fieldWithPath("data.pageable.offset").type(JsonFieldType.NUMBER).ignored(), + fieldWithPath("data.pageable.paged").type(JsonFieldType.BOOLEAN).ignored(), + fieldWithPath("data.pageable.unpaged").type(JsonFieldType.BOOLEAN).ignored(), + fieldWithPath("data.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("data.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.sort").type(JsonFieldType.OBJECT).description("정렬 정보").ignored(), + fieldWithPath("data.sort.empty").type(JsonFieldType.BOOLEAN).ignored(), + fieldWithPath("data.sort.sorted").type(JsonFieldType.BOOLEAN).ignored(), + fieldWithPath("data.sort.unsorted").type(JsonFieldType.BOOLEAN).ignored(), + fieldWithPath("data.numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), + fieldWithPath("data.first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), + fieldWithPath("data.empty").type(JsonFieldType.BOOLEAN).description("빈 페이지 여부"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 상세 조회") + inner class GetCurationDetail { + + @Test + @DisplayName("큐레이션 상세 정보를 조회할 수 있다") + fun getCurationDetail() { + // given + val response = CurationHelper.createAdminCurationDetailResponse() + + given(adminCurationService.getDetail(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.get().uri("/curations/{curationId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/curations/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("큐레이션 상세 정보"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("큐레이션명"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("data.coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), + fieldWithPath("data.displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), + fieldWithPath("data.isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data.alcoholIds").type(JsonFieldType.ARRAY).description("포함된 위스키 ID 목록"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 생성") + inner class CreateCuration { + + @Test + @DisplayName("큐레이션을 생성할 수 있다") + fun createCuration() { + // given + val request = CurationHelper.createCurationCreateRequest() + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_CREATED, 1L) + + given(adminCurationService.create(any(AdminCurationCreateRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/curations") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("큐레이션명 (필수)"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), + fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (기본값: 0)").optional(), + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("포함할 위스키 ID 목록").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("생성된 큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 수정") + inner class UpdateCuration { + + @Test + @DisplayName("큐레이션을 수정할 수 있다") + fun updateCuration() { + // given + val request = CurationHelper.createCurationUpdateRequest() + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_UPDATED, 1L) + + given(adminCurationService.update(anyLong(), any(AdminCurationUpdateRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.put().uri("/curations/{curationId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("큐레이션명 (필수)"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), + fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (필수)"), + fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)"), + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("포함할 위스키 ID 목록") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("수정된 큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 삭제") + inner class DeleteCuration { + + @Test + @DisplayName("큐레이션을 삭제할 수 있다") + fun deleteCuration() { + // given + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_DELETED, 1L) + + given(adminCurationService.delete(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.delete().uri("/curations/{curationId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/curations/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("삭제된 큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 활성화 상태 변경") + inner class UpdateCurationStatus { + + @Test + @DisplayName("큐레이션 활성화 상태를 변경할 수 있다") + fun updateStatus() { + // given + val request = CurationHelper.createCurationStatusRequest(isActive = false) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_STATUS_UPDATED, 1L) + + given(adminCurationService.updateStatus(anyLong(), any(AdminCurationStatusRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.patch().uri("/curations/{curationId}/status", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/update-status", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + requestFields( + fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 노출 순서 변경") + inner class UpdateCurationDisplayOrder { + + @Test + @DisplayName("큐레이션 노출 순서를 변경할 수 있다") + fun updateDisplayOrder() { + // given + val request = CurationHelper.createCurationDisplayOrderRequest(displayOrder = 5) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_DISPLAY_ORDER_UPDATED, 1L) + + given(adminCurationService.updateDisplayOrder(anyLong(), any(AdminCurationDisplayOrderRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.patch().uri("/curations/{curationId}/display-order", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/update-display-order", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + requestFields( + fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (0 이상, 필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 위스키 관리") + inner class ManageCurationAlcohols { + + @Test + @DisplayName("큐레이션에 위스키를 추가할 수 있다") + fun addAlcohols() { + // given + val request = CurationHelper.createCurationAlcoholRequest(alcoholIds = setOf(1L, 2L, 3L)) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_ALCOHOL_ADDED, 1L) + + given(adminCurationService.addAlcohols(anyLong(), any(AdminCurationAlcoholRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/curations/{curationId}/alcohols", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/add-alcohols", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + requestFields( + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("추가할 위스키 ID 목록 (필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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 removeAlcohol() { + // given + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_ALCOHOL_REMOVED, 1L) + + given(adminCurationService.removeAlcohol(anyLong(), anyLong())).willReturn(response) + + // when & then + assertThat( + mvc.delete().uri("/curations/{curationId}/alcohols/{alcoholId}", 1L, 5L) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/remove-alcohol", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID"), + parameterWithName("alcoholId").description("제거할 위스키 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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/helper/curation/CurationHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt new file mode 100644 index 000000000..243b021ec --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt @@ -0,0 +1,89 @@ +package app.helper.curation + +import app.bottlenote.alcohols.dto.response.AdminCurationDetailResponse +import app.bottlenote.alcohols.dto.response.AdminCurationListResponse +import app.bottlenote.global.dto.response.AdminResultResponse +import java.time.LocalDateTime + +object CurationHelper { + + fun createAdminCurationListResponse( + id: Long = 1L, + name: String = "테스트 큐레이션", + alcoholCount: Int = 5, + displayOrder: Int = 1, + isActive: Boolean = true, + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0) + ): AdminCurationListResponse = AdminCurationListResponse( + id, name, alcoholCount, displayOrder, isActive, createdAt + ) + + fun createAdminCurationListResponses(count: Int = 3): List = + (1..count).map { i -> + createAdminCurationListResponse( + id = i.toLong(), + name = "큐레이션 $i", + alcoholCount = i * 2, + displayOrder = i, + createdAt = LocalDateTime.of(2024, i, 1, 0, 0) + ) + } + + fun createAdminCurationDetailResponse( + id: Long = 1L, + name: String = "테스트 큐레이션", + description: String = "큐레이션 설명입니다.", + coverImageUrl: String = "https://example.com/cover.jpg", + displayOrder: Int = 1, + isActive: Boolean = true, + alcoholIds: Set = setOf(1L, 2L, 3L), + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), + modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) + ): AdminCurationDetailResponse = AdminCurationDetailResponse( + id, name, description, coverImageUrl, displayOrder, isActive, alcoholIds, createdAt, modifiedAt + ) + + fun createCurationCreateRequest( + name: String = "새 큐레이션", + description: String? = "큐레이션 설명", + coverImageUrl: String? = "https://example.com/cover.jpg", + displayOrder: Int = 0, + alcoholIds: Set = emptySet() + ): Map = mapOf( + "name" to name, + "description" to description, + "coverImageUrl" to coverImageUrl, + "displayOrder" to displayOrder, + "alcoholIds" to alcoholIds + ) + + fun createCurationUpdateRequest( + name: String = "수정된 큐레이션", + description: String? = "수정된 설명", + coverImageUrl: String? = "https://example.com/updated.jpg", + displayOrder: Int = 1, + isActive: Boolean = true, + alcoholIds: Set = setOf(1L, 2L) + ): Map = mapOf( + "name" to name, + "description" to description, + "coverImageUrl" to coverImageUrl, + "displayOrder" to displayOrder, + "isActive" to isActive, + "alcoholIds" to alcoholIds + ) + + fun createCurationStatusRequest(isActive: Boolean = true): Map = + mapOf("isActive" to isActive) + + fun createCurationDisplayOrderRequest(displayOrder: Int = 1): Map = + mapOf("displayOrder" to displayOrder) + + fun createCurationAlcoholRequest(alcoholIds: Set = setOf(1L, 2L)): Map = + mapOf("alcoholIds" to alcoholIds) + + fun createAdminResultResponse( + code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.CURATION_CREATED, + targetId: Long = 1L + ): AdminResultResponse = AdminResultResponse.of(code, targetId) +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt new file mode 100644 index 000000000..e9f2fc378 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt @@ -0,0 +1,435 @@ +package app.integration.curation + +import app.IntegrationTestSupport +import app.bottlenote.alcohols.fixture.AlcoholTestFactory +import app.helper.curation.CurationHelper +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 Curation API 통합 테스트") +class AdminCurationIntegrationTest : IntegrationTestSupport() { + + @Autowired + private lateinit var alcoholTestFactory: AlcoholTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("큐레이션 목록 조회 API") + inner class ListCurations { + + @Test + @DisplayName("큐레이션 목록을 조회할 수 있다") + fun listSuccess() { + // given + alcoholTestFactory.persistCurationKeyword() + alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester.get().uri("/curations") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("키워드로 필터링하여 조회할 수 있다") + fun listWithKeywordFilter() { + // given + alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester.get().uri("/curations") + .param("keyword", "테스트") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("활성화 상태로 필터링하여 조회할 수 있다") + fun listWithIsActiveFilter() { + // given + alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester.get().uri("/curations") + .param("isActive", "true") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("인증 없이 요청하면 401을 반환한다") + fun listUnauthorized() { + assertThat( + mockMvcTester.get().uri("/curations") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 상세 조회 API") + inner class GetCurationDetail { + + @Test + @DisplayName("큐레이션 상세 정보를 조회할 수 있다") + fun getDetailSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester.get().uri("/curations/${curation.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("존재하지 않는 큐레이션 조회 시 404를 반환한다") + fun getDetailNotFound() { + // when & then + assertThat( + mockMvcTester.get().uri("/curations/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 생성 API") + inner class CreateCuration { + + @Test + @DisplayName("큐레이션을 생성할 수 있다") + fun createSuccess() { + // given + val request = CurationHelper.createCurationCreateRequest( + name = "새로운 큐레이션" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("CURATION_CREATED") + } + + @Test + @DisplayName("위스키를 포함하여 큐레이션을 생성할 수 있다") + fun createWithAlcohols() { + // given + val alcohol1 = alcoholTestFactory.persistAlcohol() + val alcohol2 = alcoholTestFactory.persistAlcohol() + + val request = CurationHelper.createCurationCreateRequest( + name = "위스키 큐레이션", + alcoholIds = setOf(alcohol1.id, alcohol2.id) + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("CURATION_CREATED") + } + + @Test + @DisplayName("이름이 중복되면 409를 반환한다") + fun createDuplicateName() { + // given + alcoholTestFactory.persistCurationKeyword() + val request = CurationHelper.createCurationCreateRequest( + name = "테스트 큐레이션" // persistCurationKeyword 기본 이름과 유사 + ) + + // when & then - 실제 중복 체크는 이름이 정확히 일치해야 함 + assertThat( + mockMvcTester.post().uri("/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() // 다른 이름이므로 성공 + } + + @Test + @DisplayName("필수 필드 누락 시 400을 반환한다") + fun createValidationFail() { + // given + val request = mapOf( + "name" to "", // 빈 문자열 + "description" to "설명" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 수정 API") + inner class UpdateCuration { + + @Test + @DisplayName("큐레이션을 수정할 수 있다") + fun updateSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val request = CurationHelper.createCurationUpdateRequest( + name = "수정된 큐레이션", + description = "수정된 설명" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/curations/${curation.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("CURATION_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 큐레이션 수정 시 404를 반환한다") + fun updateNotFound() { + // given + val request = CurationHelper.createCurationUpdateRequest() + + // when & then + assertThat( + mockMvcTester.put().uri("/curations/999999") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 삭제 API") + inner class DeleteCuration { + + @Test + @DisplayName("큐레이션을 삭제할 수 있다") + fun deleteSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester.delete().uri("/curations/${curation.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("CURATION_DELETED") + } + + @Test + @DisplayName("존재하지 않는 큐레이션 삭제 시 404를 반환한다") + fun deleteNotFound() { + // when & then + assertThat( + mockMvcTester.delete().uri("/curations/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 활성화 상태 변경 API") + inner class UpdateCurationStatus { + + @Test + @DisplayName("큐레이션 활성화 상태를 변경할 수 있다") + fun updateStatusSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val request = CurationHelper.createCurationStatusRequest(isActive = false) + + // when & then + assertThat( + mockMvcTester.patch().uri("/curations/${curation.id}/status") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("CURATION_STATUS_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 큐레이션의 상태 변경 시 404를 반환한다") + fun updateStatusNotFound() { + // given + val request = CurationHelper.createCurationStatusRequest(isActive = false) + + // when & then + assertThat( + mockMvcTester.patch().uri("/curations/999999/status") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 노출 순서 변경 API") + inner class UpdateCurationDisplayOrder { + + @Test + @DisplayName("큐레이션 노출 순서를 변경할 수 있다") + fun updateDisplayOrderSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val request = CurationHelper.createCurationDisplayOrderRequest(displayOrder = 5) + + // when & then + assertThat( + mockMvcTester.patch().uri("/curations/${curation.id}/display-order") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("CURATION_DISPLAY_ORDER_UPDATED") + } + } + + @Nested + @DisplayName("큐레이션 위스키 관리 API") + inner class ManageCurationAlcohols { + + @Test + @DisplayName("큐레이션에 위스키를 추가할 수 있다") + fun addAlcoholsSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val alcohol1 = alcoholTestFactory.persistAlcohol() + val alcohol2 = alcoholTestFactory.persistAlcohol() + val request = CurationHelper.createCurationAlcoholRequest( + alcoholIds = setOf(alcohol1.id, alcohol2.id) + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/curations/${curation.id}/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("CURATION_ALCOHOL_ADDED") + } + + @Test + @DisplayName("큐레이션에서 위스키를 제거할 수 있다") + fun removeAlcoholSuccess() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + val curation = alcoholTestFactory.persistCurationKeyword("테스트 큐레이션", listOf(alcohol)) + + // when & then + assertThat( + mockMvcTester.delete().uri("/curations/${curation.id}/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("CURATION_ALCOHOL_REMOVED") + } + + @Test + @DisplayName("큐레이션에 포함되지 않은 위스키 제거 시 400을 반환한다") + fun removeAlcoholNotIncluded() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val alcohol = alcoholTestFactory.persistAlcohol() + + // when & then + assertThat( + mockMvcTester.delete().uri("/curations/${curation.id}/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("인증 테스트") + inner class AuthenticationTest { + + @Test + @DisplayName("인증 없이 요청 시 실패한다") + fun requestWithoutAuth() { + // when & then + assertThat(mockMvcTester.get().uri("/curations")) + .hasStatus4xxClientError() + + assertThat(mockMvcTester.get().uri("/curations/1")) + .hasStatus4xxClientError() + + assertThat( + mockMvcTester.post().uri("/curations") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(CurationHelper.createCurationCreateRequest())) + ) + .hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeyword.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeyword.java index dfed72fee..b80e02c43 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeyword.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeyword.java @@ -69,4 +69,35 @@ public static CurationKeyword create( .alcoholIds(alcoholIds != null ? new HashSet<>(alcoholIds) : new HashSet<>()) .build(); } + + public void update( + String name, + String description, + String coverImageUrl, + Integer displayOrder, + Boolean isActive, + Set alcoholIds) { + this.name = name; + this.description = description; + this.coverImageUrl = coverImageUrl; + this.displayOrder = displayOrder; + this.isActive = isActive; + this.alcoholIds = alcoholIds != null ? new HashSet<>(alcoholIds) : new HashSet<>(); + } + + public void updateStatus(Boolean isActive) { + this.isActive = isActive; + } + + public void updateDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } + + public void addAlcohols(Set alcoholIds) { + this.alcoholIds.addAll(alcoholIds); + } + + public void removeAlcohol(Long alcoholId) { + this.alcoholIds.remove(alcoholId); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeywordRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeywordRepository.java index e85d3858f..467e57b4a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeywordRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/CurationKeywordRepository.java @@ -1,10 +1,14 @@ package app.bottlenote.alcohols.domain; +import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; +import app.bottlenote.alcohols.dto.response.AdminCurationListResponse; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; import app.bottlenote.global.service.cursor.CursorResponse; import java.util.Optional; import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; /** 큐레이션 키워드 조회 질의에 관한 애그리거트를 정의합니다. */ public interface CurationKeywordRepository { @@ -20,4 +24,14 @@ CursorResponse getCurationAlcohols( Long curationId, Long cursor, Integer pageSize); Optional> findAlcoholIdsByKeyword(String keyword); + + // Admin용 메서드 + CurationKeyword save(CurationKeyword curationKeyword); + + void delete(CurationKeyword curationKeyword); + + boolean existsByName(String name); + + Page searchForAdmin( + AdminCurationSearchRequest request, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationAlcoholRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationAlcoholRequest.java new file mode 100644 index 000000000..a26be56f2 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationAlcoholRequest.java @@ -0,0 +1,12 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import java.util.Set; + +/** + * Admin 큐레이션 위스키 추가 요청 + * + * @param alcoholIds 추가할 위스키 ID 목록 + */ +public record AdminCurationAlcoholRequest( + @NotEmpty(message = "추가할 위스키 ID는 최소 1개 이상이어야 합니다.") Set alcoholIds) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationCreateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationCreateRequest.java new file mode 100644 index 000000000..c53734cc3 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationCreateRequest.java @@ -0,0 +1,28 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.NotBlank; +import java.util.Set; +import lombok.Builder; + +/** + * Admin 큐레이션 생성 요청 + * + * @param name 큐레이션 이름 + * @param description 설명 + * @param coverImageUrl 커버 이미지 URL + * @param displayOrder 노출 순서 + * @param alcoholIds 포함할 위스키 ID 목록 + */ +public record AdminCurationCreateRequest( + @NotBlank(message = "큐레이션 이름은 필수입니다.") String name, + String description, + String coverImageUrl, + Integer displayOrder, + Set alcoholIds) { + + @Builder + public AdminCurationCreateRequest { + displayOrder = displayOrder != null ? displayOrder : 0; + alcoholIds = alcoholIds != null ? alcoholIds : Set.of(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationDisplayOrderRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationDisplayOrderRequest.java new file mode 100644 index 000000000..e351d6440 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationDisplayOrderRequest.java @@ -0,0 +1,13 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +/** + * Admin 큐레이션 노출 순서 변경 요청 + * + * @param displayOrder 노출 순서 (0 이상) + */ +public record AdminCurationDisplayOrderRequest( + @NotNull(message = "노출 순서는 필수입니다.") @Min(value = 0, message = "노출 순서는 0 이상이어야 합니다.") + Integer displayOrder) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationSearchRequest.java new file mode 100644 index 000000000..ecde116a7 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationSearchRequest.java @@ -0,0 +1,21 @@ +package app.bottlenote.alcohols.dto.request; + +import lombok.Builder; + +/** + * Admin 큐레이션 목록 검색 요청 + * + * @param keyword 큐레이션 이름 검색어 + * @param isActive 활성화 상태 필터 (null: 전체, true: 활성, false: 비활성) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지 크기 + */ +public record AdminCurationSearchRequest( + String keyword, Boolean isActive, Integer page, Integer size) { + + @Builder + public AdminCurationSearchRequest { + page = page != null ? page : 0; + size = size != null ? size : 20; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationStatusRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationStatusRequest.java new file mode 100644 index 000000000..982a094dc --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationStatusRequest.java @@ -0,0 +1,10 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.NotNull; + +/** + * Admin 큐레이션 활성화 상태 변경 요청 + * + * @param isActive 활성화 상태 + */ +public record AdminCurationStatusRequest(@NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationUpdateRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationUpdateRequest.java new file mode 100644 index 000000000..bdc070b35 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminCurationUpdateRequest.java @@ -0,0 +1,28 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.Set; + +/** + * Admin 큐레이션 수정 요청 + * + * @param name 큐레이션 이름 + * @param description 설명 + * @param coverImageUrl 커버 이미지 URL + * @param displayOrder 노출 순서 + * @param isActive 활성화 상태 + * @param alcoholIds 포함할 위스키 ID 목록 + */ +public record AdminCurationUpdateRequest( + @NotBlank(message = "큐레이션 이름은 필수입니다.") String name, + String description, + String coverImageUrl, + @NotNull(message = "노출 순서는 필수입니다.") Integer displayOrder, + @NotNull(message = "활성화 상태는 필수입니다.") Boolean isActive, + Set alcoholIds) { + + public AdminCurationUpdateRequest { + alcoholIds = alcoholIds != null ? alcoholIds : Set.of(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationDetailResponse.java new file mode 100644 index 000000000..9d016276e --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationDetailResponse.java @@ -0,0 +1,51 @@ +package app.bottlenote.alcohols.dto.response; + +import java.time.LocalDateTime; +import java.util.Set; + +/** + * Admin 큐레이션 상세 조회 응답 + * + * @param id 큐레이션 ID + * @param name 큐레이션 이름 + * @param description 설명 + * @param coverImageUrl 커버 이미지 URL + * @param displayOrder 노출 순서 + * @param isActive 활성화 상태 + * @param alcoholIds 포함된 위스키 ID 목록 + * @param createdAt 생성일시 + * @param modifiedAt 수정일시 + */ +public record AdminCurationDetailResponse( + Long id, + String name, + String description, + String coverImageUrl, + Integer displayOrder, + Boolean isActive, + Set alcoholIds, + LocalDateTime createdAt, + LocalDateTime modifiedAt) { + + public static AdminCurationDetailResponse of( + Long id, + String name, + String description, + String coverImageUrl, + Integer displayOrder, + Boolean isActive, + Set alcoholIds, + LocalDateTime createdAt, + LocalDateTime modifiedAt) { + return new AdminCurationDetailResponse( + id, + name, + description, + coverImageUrl, + displayOrder, + isActive, + alcoholIds, + createdAt, + modifiedAt); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationListResponse.java new file mode 100644 index 000000000..7d0da0263 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminCurationListResponse.java @@ -0,0 +1,21 @@ +package app.bottlenote.alcohols.dto.response; + +import java.time.LocalDateTime; + +/** + * Admin 큐레이션 목록 응답 항목 + * + * @param id 큐레이션 ID + * @param name 큐레이션 이름 + * @param alcoholCount 포함된 위스키 수 + * @param displayOrder 노출 순서 + * @param isActive 활성화 상태 + * @param createdAt 생성일시 + */ +public record AdminCurationListResponse( + Long id, + String name, + Integer alcoholCount, + Integer displayOrder, + Boolean isActive, + LocalDateTime createdAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java index 27d626e79..58efb5375 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java @@ -15,7 +15,10 @@ public enum AlcoholExceptionCode implements ExceptionCode { TASTING_TAG_HAS_CHILDREN(HttpStatus.CONFLICT, "자식 태그가 존재하는 태그는 삭제할 수 없습니다."), TASTING_TAG_HAS_ALCOHOLS(HttpStatus.CONFLICT, "연결된 위스키가 존재하는 태그는 삭제할 수 없습니다."), TASTING_TAG_PARENT_NOT_FOUND(HttpStatus.NOT_FOUND, "부모 태그를 찾을 수 없습니다."), - TASTING_TAG_MAX_DEPTH_EXCEEDED(HttpStatus.BAD_REQUEST, "태그 계층 구조는 최대 3단계까지 가능합니다."); + TASTING_TAG_MAX_DEPTH_EXCEEDED(HttpStatus.BAD_REQUEST, "태그 계층 구조는 최대 3단계까지 가능합니다."), + CURATION_NOT_FOUND(HttpStatus.NOT_FOUND, "큐레이션을 찾을 수 없습니다."), + CURATION_DUPLICATE_NAME(HttpStatus.CONFLICT, "동일한 이름의 큐레이션이 이미 존재합니다."), + CURATION_ALCOHOL_NOT_INCLUDED(HttpStatus.BAD_REQUEST, "해당 위스키가 큐레이션에 포함되어 있지 않습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepository.java index e971112ee..6023b1623 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepository.java @@ -1,10 +1,14 @@ package app.bottlenote.alcohols.repository; +import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; +import app.bottlenote.alcohols.dto.response.AdminCurationListResponse; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; import app.bottlenote.global.service.cursor.CursorResponse; import java.util.Optional; import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface CustomCurationKeywordRepository { @@ -15,4 +19,7 @@ CursorResponse getCurationAlcohols( Long curationId, Long cursor, Integer pageSize); Optional> findAlcoholIdsByKeyword(String keyword); + + Page searchForAdmin( + AdminCurationSearchRequest request, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepositoryImpl.java index a00280ec3..785ef3e75 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomCurationKeywordRepositoryImpl.java @@ -7,6 +7,8 @@ import static app.bottlenote.review.domain.QReview.review; import app.bottlenote.alcohols.domain.CurationKeyword; +import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; +import app.bottlenote.alcohols.dto.response.AdminCurationListResponse; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; import app.bottlenote.global.service.cursor.CursorPageable; @@ -18,6 +20,9 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; @Slf4j @RequiredArgsConstructor @@ -163,4 +168,40 @@ private BooleanExpression alcoholIdIn(Long alcoholId) { curationKeyword.alcoholIds) .eq(1L); } + + @Override + public Page searchForAdmin( + AdminCurationSearchRequest request, Pageable pageable) { + + List content = + queryFactory + .select( + Projections.constructor( + AdminCurationListResponse.class, + curationKeyword.id, + curationKeyword.name, + curationKeyword.alcoholIds.size(), + curationKeyword.displayOrder, + curationKeyword.isActive, + curationKeyword.createAt)) + .from(curationKeyword) + .where(keywordContains(request.keyword()), isActiveEq(request.isActive())) + .orderBy(curationKeyword.displayOrder.asc(), curationKeyword.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = + queryFactory + .select(curationKeyword.count()) + .from(curationKeyword) + .where(keywordContains(request.keyword()), isActiveEq(request.isActive())) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + private BooleanExpression isActiveEq(Boolean isActive) { + return isActive != null ? curationKeyword.isActive.eq(isActive) : null; + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaCurationKeywordRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaCurationKeywordRepository.java index 8284aef0c..086f17d75 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaCurationKeywordRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaCurationKeywordRepository.java @@ -9,4 +9,7 @@ public interface JpaCurationKeywordRepository extends CurationKeywordRepository, JpaRepository, - CustomCurationKeywordRepository {} + CustomCurationKeywordRepository { + + boolean existsByName(String name); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminCurationService.java new file mode 100644 index 000000000..e6e93f9f9 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminCurationService.java @@ -0,0 +1,160 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.CURATION_ALCOHOL_NOT_INCLUDED; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.CURATION_DUPLICATE_NAME; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.CURATION_NOT_FOUND; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_ALCOHOL_ADDED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_ALCOHOL_REMOVED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_CREATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_DELETED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_DISPLAY_ORDER_UPDATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_STATUS_UPDATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.CURATION_UPDATED; + +import app.bottlenote.alcohols.domain.CurationKeyword; +import app.bottlenote.alcohols.domain.CurationKeywordRepository; +import app.bottlenote.alcohols.dto.request.AdminCurationAlcoholRequest; +import app.bottlenote.alcohols.dto.request.AdminCurationCreateRequest; +import app.bottlenote.alcohols.dto.request.AdminCurationDisplayOrderRequest; +import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest; +import app.bottlenote.alcohols.dto.request.AdminCurationStatusRequest; +import app.bottlenote.alcohols.dto.request.AdminCurationUpdateRequest; +import app.bottlenote.alcohols.dto.response.AdminCurationDetailResponse; +import app.bottlenote.alcohols.exception.AlcoholException; +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.global.dto.response.AdminResultResponse; +import java.util.HashSet; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminCurationService { + + private final CurationKeywordRepository curationKeywordRepository; + + @Transactional(readOnly = true) + public GlobalResponse search(AdminCurationSearchRequest request) { + PageRequest pageable = PageRequest.of(request.page(), request.size()); + return GlobalResponse.fromPage(curationKeywordRepository.searchForAdmin(request, pageable)); + } + + @Transactional(readOnly = true) + public AdminCurationDetailResponse getDetail(Long curationId) { + CurationKeyword curation = + curationKeywordRepository + .findById(curationId) + .orElseThrow(() -> new AlcoholException(CURATION_NOT_FOUND)); + + return AdminCurationDetailResponse.of( + curation.getId(), + curation.getName(), + curation.getDescription(), + curation.getCoverImageUrl(), + curation.getDisplayOrder(), + curation.getIsActive(), + curation.getAlcoholIds(), + curation.getCreateAt(), + curation.getLastModifyAt()); + } + + @Transactional + public AdminResultResponse create(AdminCurationCreateRequest request) { + if (curationKeywordRepository.existsByName(request.name())) { + throw new AlcoholException(CURATION_DUPLICATE_NAME); + } + + CurationKeyword curation = + CurationKeyword.create( + request.name(), + request.description(), + request.coverImageUrl(), + request.displayOrder(), + new HashSet<>(request.alcoholIds())); + + CurationKeyword saved = curationKeywordRepository.save(curation); + return AdminResultResponse.of(CURATION_CREATED, saved.getId()); + } + + @Transactional + public AdminResultResponse update(Long curationId, AdminCurationUpdateRequest request) { + CurationKeyword curation = + curationKeywordRepository + .findById(curationId) + .orElseThrow(() -> new AlcoholException(CURATION_NOT_FOUND)); + + curation.update( + request.name(), + request.description(), + request.coverImageUrl(), + request.displayOrder(), + request.isActive(), + new HashSet<>(request.alcoholIds())); + + return AdminResultResponse.of(CURATION_UPDATED, curationId); + } + + @Transactional + public AdminResultResponse delete(Long curationId) { + CurationKeyword curation = + curationKeywordRepository + .findById(curationId) + .orElseThrow(() -> new AlcoholException(CURATION_NOT_FOUND)); + + curationKeywordRepository.delete(curation); + return AdminResultResponse.of(CURATION_DELETED, curationId); + } + + @Transactional + public AdminResultResponse updateStatus(Long curationId, AdminCurationStatusRequest request) { + CurationKeyword curation = + curationKeywordRepository + .findById(curationId) + .orElseThrow(() -> new AlcoholException(CURATION_NOT_FOUND)); + + curation.updateStatus(request.isActive()); + return AdminResultResponse.of(CURATION_STATUS_UPDATED, curationId); + } + + @Transactional + public AdminResultResponse updateDisplayOrder( + Long curationId, AdminCurationDisplayOrderRequest request) { + CurationKeyword curation = + curationKeywordRepository + .findById(curationId) + .orElseThrow(() -> new AlcoholException(CURATION_NOT_FOUND)); + + curation.updateDisplayOrder(request.displayOrder()); + return AdminResultResponse.of(CURATION_DISPLAY_ORDER_UPDATED, curationId); + } + + @Transactional + public AdminResultResponse addAlcohols(Long curationId, AdminCurationAlcoholRequest request) { + CurationKeyword curation = + curationKeywordRepository + .findById(curationId) + .orElseThrow(() -> new AlcoholException(CURATION_NOT_FOUND)); + + curation.addAlcohols(request.alcoholIds()); + return AdminResultResponse.of(CURATION_ALCOHOL_ADDED, curationId); + } + + @Transactional + public AdminResultResponse removeAlcohol(Long curationId, Long alcoholId) { + CurationKeyword curation = + curationKeywordRepository + .findById(curationId) + .orElseThrow(() -> new AlcoholException(CURATION_NOT_FOUND)); + + if (!curation.getAlcoholIds().contains(alcoholId)) { + throw new AlcoholException(CURATION_ALCOHOL_NOT_INCLUDED); + } + + curation.removeAlcohol(alcoholId); + return AdminResultResponse.of(CURATION_ALCOHOL_REMOVED, curationId); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java index 912da055f..39cb7bc22 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java @@ -25,6 +25,13 @@ public enum ResultCode { TASTING_TAG_DELETED("테이스팅 태그가 삭제되었습니다."), TASTING_TAG_ALCOHOL_ADDED("위스키가 연결되었습니다."), TASTING_TAG_ALCOHOL_REMOVED("위스키 연결이 해제되었습니다."), + CURATION_CREATED("큐레이션이 등록되었습니다."), + CURATION_UPDATED("큐레이션이 수정되었습니다."), + CURATION_DELETED("큐레이션이 삭제되었습니다."), + CURATION_STATUS_UPDATED("큐레이션 활성화 상태가 변경되었습니다."), + CURATION_DISPLAY_ORDER_UPDATED("큐레이션 노출 순서가 변경되었습니다."), + CURATION_ALCOHOL_ADDED("큐레이션에 위스키가 추가되었습니다."), + CURATION_ALCOHOL_REMOVED("큐레이션에서 위스키가 제거되었습니다."), ; private final String message; From 17ce1851ae409d3a63ef6dd0597ae143b98b38d8 Mon Sep 17 00:00:00 2001 From: rlagu Date: Mon, 2 Feb 2026 22:51:46 +0900 Subject: [PATCH 10/29] docs: simplify ADMIN-API-GUIDE to rules and checklists - Remove verbose code examples, keep rules/tables only - Add DTO-Entity separation rule (use of() not from()) - Add GlobalResponse.fromPage() pattern for list endpoints - Add verification procedure (6 steps) - Add reference implementation file paths Co-Authored-By: Claude Opus 4.5 --- .claude/docs/ADMIN-API-GUIDE.md | 581 +++++--------------------------- 1 file changed, 91 insertions(+), 490 deletions(-) diff --git a/.claude/docs/ADMIN-API-GUIDE.md b/.claude/docs/ADMIN-API-GUIDE.md index 7f82d827e..cd02d4be0 100644 --- a/.claude/docs/ADMIN-API-GUIDE.md +++ b/.claude/docs/ADMIN-API-GUIDE.md @@ -1,155 +1,84 @@ # Admin API 구현 가이드 -> 이 문서는 Admin API를 구현할 때 따라야 할 표준 패턴과 컨벤션을 정의합니다. - -## 목차 -- [아키텍처 개요](#아키텍처-개요) -- [구현 단계](#구현-단계) -- [DTO 작성 규칙](#dto-작성-규칙) -- [Service 작성 규칙](#service-작성-규칙) -- [Controller 작성 규칙](#controller-작성-규칙) -- [테스트 작성 규칙](#테스트-작성-규칙) -- [문서화 규칙](#문서화-규칙) +> Admin API 구현 시 따라야 할 규칙과 체크리스트를 정의합니다. +> 코드 예시는 기존 구현 파일을 참고하세요. --- ## 아키텍처 개요 ``` -┌─────────────────────────────────────────────────────────────┐ -│ admin-api (Kotlin) │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Controller (presentation) │ │ -│ │ - REST 엔드포인트 정의 │ │ -│ │ - 요청/응답 처리 │ │ -│ │ - GlobalResponse 래핑 │ │ -│ └─────────────────────────────────────────────────────┘ │ -└────────────────────────────┬────────────────────────────────┘ - │ 의존 -┌────────────────────────────▼────────────────────────────────┐ -│ mono (Java) │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Service │ │ -│ │ - 비즈니스 로직 처리 │ │ -│ │ - 트랜잭션 관리 │ │ -│ │ - AdminResultResponse 반환 │ │ -│ └────────────────────────────┬────────────────────────┘ │ -│ ┌────────────────────────────▼────────────────────────┐ │ -│ │ Repository │ │ -│ │ - JPA + QueryDSL │ │ -│ │ - 도메인 레포지토리 인터페이스 구현 │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ DTO │ │ -│ │ - Request: Java record + validation │ │ -│ │ - Response: Java record │ │ -│ └─────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +admin-api (Kotlin) → mono (Java) +├── Controller (presentation) ─┬→ Service (비즈니스 로직) +│ ├→ Repository (JPA + QueryDSL) +│ └→ DTO (Request/Response) ``` +**핵심 원칙:** +- admin-api는 프레젠테이션 계층만 담당 (Kotlin) +- 비즈니스 로직과 DTO는 mono 모듈에 작성 (Java) +- admin-api는 `spring-data-jpa`를 `testImplementation`으로만 의존 + --- -## 구현 단계 +## 구현 체크리스트 -### Phase 1: 구현 +### Phase 1: 구현 (mono → admin-api 순서) -| 순서 | 작업 | 모듈 | 언어 | +| 순서 | 작업 | 모듈 | 위치 | |------|------|------|------| -| 1 | Request/Response DTO 작성 | mono | Java | -| 2 | Service 작성 | mono | Java | -| 3 | Repository 확장 (필요 시) | mono | Java | -| 4 | Controller 작성 | admin-api | Kotlin | +| 1 | Request/Response DTO | mono | `{domain}/dto/request/`, `{domain}/dto/response/` | +| 2 | ExceptionCode 추가 | mono | `{domain}/exception/{Domain}ExceptionCode.java` | +| 3 | ResultCode 추가 | mono | `global/dto/response/AdminResultResponse.java` | +| 4 | Repository 확장 (필요 시) | mono | `{domain}/repository/` | +| 5 | Service 작성 | mono | `{domain}/service/Admin{Domain}Service.java` | +| 6 | Controller 작성 | admin-api | `{domain}/presentation/Admin{Domain}Controller.kt` | ### Phase 2: 테스트 | 순서 | 작업 | 위치 | |------|------|------| -| 1 | Test Helper 작성 | `app/helper/{domain}/` | -| 2 | Integration Test 작성 | `app/integration/{domain}/` | +| 1 | Test Helper | `app/helper/{domain}/{Domain}Helper.kt` (object 싱글톤) | +| 2 | Integration Test | `app/integration/{domain}/Admin{Domain}IntegrationTest.kt` | -### Phase 3: 문서화 +### Phase 3: 문서화 (선택) | 순서 | 작업 | 위치 | |------|------|------| -| 1 | RestDocs Test 작성 | `app/docs/{domain}/` | -| 2 | AsciiDoc 작성 | `src/docs/asciidoc/{domain}.adoc` | +| 1 | RestDocs Test | `app/docs/{domain}/Admin{Domain}ControllerDocsTest.kt` | +| 2 | AsciiDoc | `src/docs/asciidoc/api/{domain}/` | --- -## DTO 작성 규칙 +## 핵심 규칙 -### 위치 -``` -bottlenote-mono/src/main/java/app/bottlenote/{domain}/dto/ -├── request/ -│ ├── Admin{Domain}SearchRequest.java # 목록 조회 -│ ├── Admin{Domain}CreateRequest.java # 생성 -│ ├── Admin{Domain}UpdateRequest.java # 수정 -│ └── Admin{Domain}{Action}Request.java # 특수 액션 -└── response/ - ├── Admin{Domain}ListResponse.java # 목록 항목 - └── Admin{Domain}DetailResponse.java # 상세 조회 -``` +### DTO 규칙 -### Request DTO 패턴 - -```java -// 검색 요청 (GET 파라미터) -public record Admin{Domain}SearchRequest( - String keyword, - {FilterType} filter, - Integer page, - Integer size) { - - @Builder - public Admin{Domain}SearchRequest { - page = page != null ? page : 0; - size = size != null ? size : 20; - } -} - -// 생성/수정 요청 (POST/PUT body) -public record Admin{Domain}CreateRequest( - @NotBlank(message = "이름은 필수입니다.") String name, - String description, - @NotNull(message = "타입은 필수입니다.") {Type} type) { - - @Builder - public Admin{Domain}CreateRequest { - // 기본값 설정 (선택) - } -} -``` +| 규칙 | 설명 | +|------|------| +| **DTO-Entity 분리** | Response DTO는 Entity를 직접 참조하면 안 됨 (아키텍처 규칙 위반) | +| **팩토리 메서드** | `from(Entity)` 금지 → `of(...)` 사용, 변환 로직은 Service에서 처리 | +| **record 사용** | Java record로 작성, `@Builder` 생성자에서 기본값 설정 | +| **Validation** | `@NotBlank`, `@NotNull` 등 Bean Validation 사용 | -### Response DTO 패턴 - -```java -// 목록 응답 -public record Admin{Domain}ListResponse( - Long id, - String name, - {Type} type, - Boolean isActive, - LocalDateTime createdAt) {} - -// 상세 응답 -public record Admin{Domain}DetailResponse( - Long id, - String name, - String description, - // ... 상세 필드 - LocalDateTime createdAt, - LocalDateTime modifiedAt) { - - public static Admin{Domain}DetailResponse from({Domain} entity) { - return new Admin{Domain}DetailResponse( - entity.getId(), - entity.getName(), - // ... - ); - } -} -``` +### Service 규칙 + +| 규칙 | 설명 | +|------|------| +| **목록 조회 반환** | `Page` 직접 반환 금지 → `GlobalResponse.fromPage()` 사용 | +| **상세 조회 반환** | Response DTO 반환, `of()` 팩토리로 변환 | +| **CUD 반환** | `AdminResultResponse.of(ResultCode, targetId)` 통일 | +| **트랜잭션** | 조회는 `@Transactional(readOnly = true)`, CUD는 `@Transactional` | + +### Controller 규칙 + +| 규칙 | 설명 | +|------|------| +| **목록 조회** | `ResponseEntity.ok(service.search(request))` (Service가 GlobalResponse 반환) | +| **그 외 API** | `GlobalResponse.ok(service.xxx())` | +| **매핑** | `@RequestMapping("/{복수형}")` (kebab-case) | +| **검색 파라미터** | `@ModelAttribute` 사용 | +| **Body 파라미터** | `@RequestBody @Valid` 사용 | ### 페이징 방식 @@ -160,146 +89,7 @@ public record Admin{Domain}DetailResponse( --- -## Service 작성 규칙 - -### 위치 -``` -bottlenote-mono/src/main/java/app/bottlenote/{domain}/service/Admin{Domain}Service.java -``` - -### 기본 구조 - -```java -@Slf4j -@Service -@RequiredArgsConstructor -public class Admin{Domain}Service { - - private final {Domain}Repository repository; - - // 목록 조회 - @Transactional(readOnly = true) - public Page search(Admin{Domain}SearchRequest request) { - PageRequest pageable = PageRequest.of(request.page(), request.size()); - return repository.searchForAdmin(request, pageable); - } - - // 상세 조회 - @Transactional(readOnly = true) - public Admin{Domain}DetailResponse getDetail(Long id) { - {Domain} entity = repository.findById(id) - .orElseThrow(() -> new {Domain}Exception({DOMAIN}_NOT_FOUND)); - return Admin{Domain}DetailResponse.from(entity); - } - - // 생성 - @Transactional - public AdminResultResponse create(Admin{Domain}CreateRequest request) { - // 중복 검사 (필요 시) - if (repository.existsByName(request.name())) { - throw new {Domain}Exception({DOMAIN}_DUPLICATE_NAME); - } - - {Domain} entity = {Domain}.create(request.name(), ...); - {Domain} saved = repository.save(entity); - return AdminResultResponse.of({DOMAIN}_CREATED, saved.getId()); - } - - // 수정 - @Transactional - public AdminResultResponse update(Long id, Admin{Domain}UpdateRequest request) { - {Domain} entity = repository.findById(id) - .orElseThrow(() -> new {Domain}Exception({DOMAIN}_NOT_FOUND)); - - entity.update(request.name(), ...); - return AdminResultResponse.of({DOMAIN}_UPDATED, id); - } - - // 삭제 - @Transactional - public AdminResultResponse delete(Long id) { - {Domain} entity = repository.findById(id) - .orElseThrow(() -> new {Domain}Exception({DOMAIN}_NOT_FOUND)); - - repository.delete(entity); - return AdminResultResponse.of({DOMAIN}_DELETED, id); - } -} -``` - -### AdminResultResponse 사용 - -CUD 작업은 `AdminResultResponse`로 통일: - -```java -// global/dto/response/AdminResultResponse.java -public record AdminResultResponse(String code, String message, Long targetId, String responseAt) { - public static AdminResultResponse of(ResultCode code, Long targetId) { ... } - - public enum ResultCode { - {DOMAIN}_CREATED("{도메인}이(가) 등록되었습니다."), - {DOMAIN}_UPDATED("{도메인}이(가) 수정되었습니다."), - {DOMAIN}_DELETED("{도메인}이(가) 삭제되었습니다."), - // 커스텀 액션 - {DOMAIN}_{ACTION}("{도메인} {액션}이(가) 완료되었습니다."); - } -} -``` - ---- - -## Controller 작성 규칙 - -### 위치 -``` -bottlenote-admin-api/src/main/kotlin/app/bottlenote/{domain}/presentation/Admin{Domain}Controller.kt -``` - -### 기본 구조 - -```kotlin -@RestController -@RequestMapping("/{resources}") // 복수형 리소스명 (kebab-case) -class Admin{Domain}Controller( - private val admin{Domain}Service: Admin{Domain}Service -) { - - // 목록 조회 - @GetMapping - fun list(@ModelAttribute request: Admin{Domain}SearchRequest): ResponseEntity<*> { - return GlobalResponse.ok(admin{Domain}Service.search(request)) - } - - // 상세 조회 - @GetMapping("/{id}") - fun detail(@PathVariable id: Long): ResponseEntity<*> { - return GlobalResponse.ok(admin{Domain}Service.getDetail(id)) - } - - // 생성 - @PostMapping - fun create(@RequestBody @Valid request: Admin{Domain}CreateRequest): ResponseEntity<*> { - return GlobalResponse.ok(admin{Domain}Service.create(request)) - } - - // 수정 - @PutMapping("/{id}") - fun update( - @PathVariable id: Long, - @RequestBody @Valid request: Admin{Domain}UpdateRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(admin{Domain}Service.update(id, request)) - } - - // 삭제 - @DeleteMapping("/{id}") - fun delete(@PathVariable id: Long): ResponseEntity<*> { - return GlobalResponse.ok(admin{Domain}Service.delete(id)) - } -} -``` - -### HTTP 메서드 규칙 +## HTTP 메서드 규칙 | 작업 | Method | URL 패턴 | 예시 | |------|--------|----------|------| @@ -312,142 +102,20 @@ class Admin{Domain}Controller( | 하위 리소스 추가 | POST | `/{resources}/{id}/{sub}` | `POST /curations/1/alcohols` | | 하위 리소스 삭제 | DELETE | `/{resources}/{id}/{sub}/{subId}` | `DELETE /curations/1/alcohols/5` | -### 인증이 필요한 경우 - -```kotlin -@PostMapping("/{id}/action") -fun action( - @PathVariable id: Long, - @RequestBody @Valid request: ActionRequest -): ResponseEntity<*> { - val adminId = SecurityContextUtil.getAdminUserIdByContext() - .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } - return GlobalResponse.ok(service.action(id, adminId, request)) -} -``` - --- -## 테스트 작성 규칙 - -### Test Helper - -**위치**: `app/helper/{domain}/{Domain}Helper.kt` - -```kotlin -object {Domain}Helper { - - fun createAdmin{Domain}ListResponse( - id: Long = 1L, - name: String = "테스트 {도메인}", - isActive: Boolean = true - ): Admin{Domain}ListResponse = Admin{Domain}ListResponse( - id, name, /* ... */, isActive, LocalDateTime.now() - ) - - fun createAdmin{Domain}DetailResponse( - id: Long = 1L, - name: String = "테스트 {도메인}" - ): Admin{Domain}DetailResponse = Admin{Domain}DetailResponse( - id, name, /* ... */ - ) - - fun create{Domain}Request( - name: String = "새 {도메인}" - ): Map = mapOf( - "name" to name, - // ... - ) -} -``` +## 테스트 규칙 ### Integration Test -**위치**: `app/integration/{domain}/Admin{Domain}IntegrationTest.kt` - -```kotlin -@Tag("admin_integration") -@DisplayName("[integration] Admin {도메인} API 통합 테스트") -class Admin{Domain}IntegrationTest : IntegrationTestSupport() { - - private lateinit var accessToken: String - - @BeforeEach - fun setUp() { - val admin = adminUserTestFactory.persistRootAdmin() - accessToken = getAccessToken(admin) - } - - @Nested - @DisplayName("{도메인} 목록 조회 API") - inner class List{Domain}s { - - @Test - @DisplayName("{도메인} 목록을 조회할 수 있다") - fun listSuccess() { - // given - 테스트 데이터 준비 - - // when & then - assertThat( - mockMvcTester.get().uri("/{resources}") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.content").isNotNull() - } - - @Test - @DisplayName("인증 없이 요청하면 401을 반환한다") - fun listUnauthorized() { - assertThat( - mockMvcTester.get().uri("/{resources}") - ) - .hasStatus(HttpStatus.UNAUTHORIZED) - } - } - - @Nested - @DisplayName("{도메인} 생성 API") - inner class Create{Domain} { - - @Test - @DisplayName("{도메인}을 생성할 수 있다") - fun createSuccess() { - val request = {Domain}Helper.create{Domain}Request() - - assertThat( - mockMvcTester.post().uri("/{resources}") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("{DOMAIN}_CREATED") - } - - @Test - @DisplayName("필수 필드 누락 시 400을 반환한다") - fun createValidationFail() { - val request = mapOf("name" to "") - - assertThat( - mockMvcTester.post().uri("/{resources}") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus(HttpStatus.BAD_REQUEST) - } - } - - // @Nested inner class Update{Domain} { ... } - // @Nested inner class Delete{Domain} { ... } -} -``` +| 항목 | 규칙 | +|------|------| +| 상속 | `IntegrationTestSupport` | +| 태그 | `@Tag("admin_integration")` | +| 인증 | `getAccessToken(admin)` → `Authorization: Bearer $token` | +| 검증 | `mockMvcTester.get/post/put/delete()` + AssertJ | -### 테스트 케이스 체크리스트 +### 필수 테스트 케이스 | API | 필수 테스트 | |-----|-----------| @@ -457,112 +125,45 @@ class Admin{Domain}IntegrationTest : IntegrationTestSupport() { | 수정 | 성공, 인증 실패, 존재하지 않는 ID | | 삭제 | 성공, 인증 실패, 존재하지 않는 ID | ---- - -## 문서화 규칙 - ### RestDocs Test -**위치**: `app/docs/{domain}/Admin{Domain}ControllerDocsTest.kt` - -```kotlin -@WebMvcTest( - controllers = [Admin{Domain}Controller::class], - excludeAutoConfiguration = [SecurityAutoConfiguration::class] -) -@AutoConfigureRestDocs -@DisplayName("[docs] Admin {도메인} API 문서화") -class Admin{Domain}ControllerDocsTest { - - @Autowired private lateinit var mvc: MockMvc - @Autowired private lateinit var mapper: ObjectMapper - @MockitoBean private lateinit var admin{Domain}Service: Admin{Domain}Service - - @Test - @DisplayName("{도메인} 목록 조회") - fun list{Domain}s() { - // given - val response = PageImpl(listOf({Domain}Helper.createAdmin{Domain}ListResponse())) - given(admin{Domain}Service.search(any())).willReturn(response) - - // when & then - mvc.get("/{resources}") { - param("page", "0") - param("size", "20") - }.andExpect { - status { isOk() } - }.andDo { - document( - "admin/{resources}/list", - queryParameters( - parameterWithName("page").description("페이지 번호").optional(), - parameterWithName("size").description("페이지 크기").optional() - ), - responseFields( - fieldWithPath("success").description("성공 여부"), - fieldWithPath("code").description("응답 코드"), - fieldWithPath("data.content[]").description("목록"), - // ... - ) - ) - } - } -} -``` +| 항목 | 규칙 | +|------|------| +| 어노테이션 | `@WebMvcTest(excludeAutoConfiguration = [SecurityAutoConfiguration::class])` | +| Mock | `@MockitoBean`으로 Service 목킹 | +| 목록 조회 Mock | `GlobalResponse.fromPage(page)` 반환 | +| 그 외 Mock | Response DTO 또는 `AdminResultResponse` 반환 | -### AsciiDoc - -**위치**: `src/docs/asciidoc/{domain}.adoc` - -```asciidoc -= {도메인} 관리 API - -== {도메인} 목록 조회 -operation::admin/{resources}/list[snippets='http-request,query-parameters,http-response,response-fields'] - -== {도메인} 상세 조회 -operation::admin/{resources}/detail[snippets='http-request,path-parameters,http-response,response-fields'] +--- -== {도메인} 생성 -operation::admin/{resources}/create[snippets='http-request,request-fields,http-response,response-fields'] +## 참고 구현 파일 -== {도메인} 수정 -operation::admin/{resources}/update[snippets='http-request,path-parameters,request-fields,http-response,response-fields'] - -== {도메인} 삭제 -operation::admin/{resources}/delete[snippets='http-request,path-parameters,http-response,response-fields'] -``` +| 항목 | 파일 경로 | +|------|----------| +| Controller | `admin-api/.../alcohols/presentation/AdminCurationController.kt` | +| Service | `mono/.../alcohols/service/AdminCurationService.java` | +| Response DTO | `mono/.../alcohols/dto/response/AdminCurationDetailResponse.java` | +| Request DTO | `mono/.../alcohols/dto/request/AdminCuration*Request.java` | +| Integration Test | `admin-api/.../integration/curation/AdminCurationIntegrationTest.kt` | +| RestDocs Test | `admin-api/.../docs/curation/AdminCurationControllerDocsTest.kt` | +| Helper | `admin-api/.../helper/curation/CurationHelper.kt` | --- -## 예외 처리 +## 검증 절차 -### ExceptionCode 추가 +Admin API 구현 완료 후 아래 순서대로 검증: -```java -// {domain}/exception/{Domain}ExceptionCode.java -public enum {Domain}ExceptionCode implements ExceptionCode { - {DOMAIN}_NOT_FOUND(HttpStatus.NOT_FOUND, "{도메인}을(를) 찾을 수 없습니다."), - {DOMAIN}_DUPLICATE_NAME(HttpStatus.CONFLICT, "동일한 이름의 {도메인}이(가) 이미 존재합니다."), - {DOMAIN}_INVALID_STATE(HttpStatus.BAD_REQUEST, "잘못된 상태입니다."); - // ... -} -``` +| 순서 | 검증 항목 | 명령어 | 태그/범위 | +|------|----------|--------|-----------| +| 1 | 컴파일 | `./gradlew :bottlenote-admin-api:compileKotlin` | - | +| 2 | 코드 포맷팅 | `./gradlew :bottlenote-mono:spotlessCheck` | mono만 적용 | +| 3 | 아키텍처 규칙 | `./gradlew :bottlenote-mono:check_rule_test` | `@Tag("rule")` | +| 4 | 단위 테스트 | `./gradlew unit_test` | `@Tag("unit")` | +| 5 | 어드민 통합 테스트 | `./gradlew admin_integration_test` | `@Tag("admin_integration")` | +| 6 | REST Docs 생성 | `./gradlew :bottlenote-admin-api:restDocsTest` | `app.docs.*` | ---- - -## 빠른 시작 체크리스트 - -새로운 Admin API 구현 시 다음 순서로 진행: - -- [ ] **DTO**: Request/Response record 작성 (mono) -- [ ] **Exception**: ExceptionCode enum 추가 (mono) -- [ ] **ResultCode**: AdminResultResponse.ResultCode 추가 (mono) -- [ ] **Repository**: 필요 시 Admin 전용 쿼리 메서드 추가 (mono) -- [ ] **Service**: Admin{Domain}Service 작성 (mono) -- [ ] **Controller**: Admin{Domain}Controller 작성 (admin-api, Kotlin) -- [ ] **Helper**: {Domain}Helper object 작성 (test) -- [ ] **Integration Test**: Admin{Domain}IntegrationTest 작성 (test) -- [ ] **RestDocs Test**: Admin{Domain}ControllerDocsTest 작성 (선택) -- [ ] **AsciiDoc**: {domain}.adoc 작성 (선택) -- [ ] **빌드 검증**: `./gradlew :bottlenote-admin-api:build` +**전체 검증 (위 1-5 포함):** +```bash +./gradlew :bottlenote-admin-api:build +``` From 9cf5b5b65e1d5d27e0b81a7c5d275618b9c5de54 Mon Sep 17 00:00:00 2001 From: rlagu Date: Mon, 2 Feb 2026 22:54:22 +0900 Subject: [PATCH 11/29] docs: add GitHub Copilot repository instructions - Response in Korean - Focus on high severity issues only in code review - Add project context (tech stack, architecture) Co-Authored-By: Claude Opus 4.5 --- .github/copilot-instructions.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..77a79fa65 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,23 @@ +# GitHub Copilot 저장소 지침 + +## 응답 언어 + +- 모든 응답은 **한국어**로 작성하세요. +- 코드 주석도 한국어로 간략하게 작성하세요. + +## 코드 리뷰 지침 + +- **중요도 높음(High severity)** 이슈만 리뷰하세요. +- 다음 항목에 집중하세요: + - 보안 취약점 (SQL Injection, XSS 등) + - 잠재적 버그 (NPE, 무한 루프 등) + - 성능 문제 (N+1, 메모리 누수 등) + - 아키텍처 규칙 위반 +- 코드 스타일, 네이밍, 포맷팅 등 낮은 중요도 이슈는 무시하세요. + +## 프로젝트 컨텍스트 + +- **기술 스택**: Spring Boot 3.x, Java 21, Kotlin, MySQL, Redis, QueryDSL +- **아키텍처**: 멀티모듈 (mono, admin-api, product-api) +- **테스트**: JUnit 5, TestContainers, MockMvc +- 자세한 프로젝트 규칙은 `CLAUDE.md` 참고 From 07fe5159c33b2e7351af96290335718c90c15afd Mon Sep 17 00:00:00 2001 From: rlagu Date: Mon, 2 Feb 2026 23:11:08 +0900 Subject: [PATCH 12/29] docs: add Antora documentation migration plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Antora 아키텍처 및 구조 분석 - 현재 시스템(Asciidoctor + Jekyll) vs Antora 비교 - 마이그레이션 작업 항목 및 체크리스트 - GitHub Actions 워크플로우 수정 가이드 - 롤백 계획 포함 Co-Authored-By: Claude Opus 4.5 --- plan/antora-documentation-migration.md | 489 +++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 plan/antora-documentation-migration.md diff --git a/plan/antora-documentation-migration.md b/plan/antora-documentation-migration.md new file mode 100644 index 000000000..5203b381d --- /dev/null +++ b/plan/antora-documentation-migration.md @@ -0,0 +1,489 @@ +# Antora 기반 API 문서 시스템 마이그레이션 계획 + +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **PLANNING** +Start Date: 2026-02-02 +Last Updated: 2026-02-02 + +** Completed Work ** +- 현행 문서 시스템 분석 완료 +- Antora 아키텍처 조사 완료 +- 기존 GitHub Actions 워크플로우 분석 완료 + +** Remaining Work ** +- Antora 디렉토리 구조 설계 +- antora-playbook.yml 작성 +- GitHub Actions 워크플로우 수정 +- 기존 ADOC 문서 include 경로 수정 +================================================================================ +``` + +--- + +## 1. Antora란? + +### 1.1 개요 + +Antora는 **멀티 리포지토리, 멀티 버전 문서 사이트 생성기**입니다. + +| 특징 | 설명 | +|------|------| +| **AsciiDoc 네이티브** | AsciiDoc 마크업 언어를 기본 지원 | +| **멀티 버전** | 동일 문서의 여러 버전을 동시에 관리 | +| **멀티 컴포넌트** | 여러 프로젝트/모듈 문서를 하나의 사이트로 통합 | +| **Git 기반** | Git 저장소에서 직접 콘텐츠 수집 | +| **정적 사이트** | HTML 정적 파일 생성 → 어디서든 호스팅 가능 | + +### 1.2 현재 시스템 vs Antora + +| 항목 | 현재 (Asciidoctor + Jekyll) | Antora | +|------|------------------------------|--------| +| **문서 형식** | AsciiDoc (.adoc) | AsciiDoc (.adoc) | +| **빌드 도구** | Asciidoctor → HTML | Antora (Asciidoctor 내장) | +| **배포** | Jekyll → GitHub Pages | Antora → GitHub Pages | +| **버전 관리** | 단일 버전 | 멀티 버전 지원 | +| **검색** | 커스텀 JavaScript | 내장 검색 또는 Algolia | +| **네비게이션** | 커스텀 탭 UI | 자동 생성 사이드바 | +| **테마** | 직접 CSS 작성 | UI Bundle 시스템 | + +--- + +## 2. Antora 디렉토리 구조 + +### 2.1 표준 구조 + +``` +docs/ # 문서 루트 +├── antora-playbook.yml # Antora 설정 파일 (필수) +├── modules/ +│ ├── ROOT/ # 기본 모듈 (홈페이지) +│ │ ├── pages/ +│ │ │ └── index.adoc +│ │ └── nav.adoc # 네비게이션 정의 +│ │ +│ ├── product-api/ # Product API 모듈 +│ │ ├── pages/ # 페이지 파일 +│ │ │ ├── index.adoc +│ │ │ └── api/ +│ │ │ ├── overview/ +│ │ │ ├── alcohols/ +│ │ │ ├── review/ +│ │ │ └── ... +│ │ ├── partials/ # 재사용 콘텐츠 +│ │ ├── examples/ # 코드 예제 (스니펫) +│ │ │ └── generated-snippets/ ← REST Docs 스니펫 +│ │ └── nav.adoc +│ │ +│ └── admin-api/ # Admin API 모듈 +│ ├── pages/ +│ │ ├── index.adoc +│ │ └── api/ +│ │ ├── overview/ +│ │ ├── admin-auth/ +│ │ ├── admin-alcohols/ +│ │ └── ... +│ ├── examples/ +│ │ └── generated-snippets/ ← REST Docs 스니펫 +│ └── nav.adoc +│ +└── antora.yml # 컴포넌트 버전 설명자 +``` + +### 2.2 핵심 파일 설명 + +| 파일 | 역할 | +|------|------| +| `antora-playbook.yml` | 사이트 전체 설정 (소스 위치, 출력 경로, UI 번들) | +| `antora.yml` | 컴포넌트/버전 정보 (name, version, title) | +| `nav.adoc` | 사이드바 네비게이션 구조 정의 | +| `pages/` | 게시될 페이지 파일 | +| `partials/` | include용 재사용 콘텐츠 조각 | +| `examples/` | 코드 예제 파일 (REST Docs 스니펫 포함) | + +--- + +## 3. 현재 시스템 분석 + +### 3.1 현재 GitHub Actions 워크플로우 + +**파일**: `.github/workflows/github-pages.yml` + +```yaml +# 현재 동작 흐름 +1. REST Docs 테스트 실행 + ./gradlew restDocsTest + +2. Asciidoctor로 HTML 생성 + ./gradlew :bottlenote-product-api:asciidoctor + ./gradlew :bottlenote-admin-api:asciidoctor + +3. HTML 파일을 docs/ 폴더로 복사 + cp bottlenote-product-api/build/docs/asciidoc/product-api.html docs/ + cp bottlenote-admin-api/build/docs/asciidoc/admin-api.html docs/ + +4. Jekyll로 GitHub Pages 빌드/배포 +``` + +### 3.2 현재 문서 구조 + +``` +bottlenote-product-api/ +└── src/docs/asciidoc/ + ├── product-api.adoc # 메인 문서 + └── api/ + ├── overview/ + ├── alcohols/ + ├── review/ + └── ... + +bottlenote-admin-api/ +└── src/docs/asciidoc/ + ├── admin-api.adoc # 메인 문서 + └── api/ + ├── overview/ + ├── admin-auth/ + ├── admin-alcohols/ + └── ... + +docs/ +├── index.html # 탭 전환 UI +├── product-api.html # 빌드된 Product API 문서 +└── admin-api.html # 빌드된 Admin API 문서 +``` + +### 3.3 현재 Include 방식 + +```asciidoc +ifndef::snippets[] +:snippets: ../../build/generated-snippets +endif::[] + +include::{snippets}/admin/help/list/query-parameters.adoc[] +``` + +- `{snippets}` 변수로 빌드 시 생성된 스니펫 경로 참조 +- Gradle 빌드 시 `generated-snippets/` 폴더에 REST Docs 스니펫 생성 + +--- + +## 4. 마이그레이션 방안 + +### 4.1 선택지 비교 + +| 방안 | 설명 | 장점 | 단점 | +|------|------|------|------| +| **A. 완전 Antora 전환** | Antora가 AsciiDoc을 직접 빌드 | 풀 기능 활용 | include 경로 전체 수정 필요 | +| **B. 하이브리드** | Asciidoctor로 빌드 후 Antora가 HTML 수집 | 기존 구조 유지 | Antora 기능 제한적 | +| **C. 현재 구조 유지** | 기존 방식 계속 사용 | 변경 없음 | Antora 도입 불가 | + +### 4.2 권장 방안: A. 완전 Antora 전환 + +**이유**: +1. Antora의 멀티 버전, 검색, 네비게이션 기능 활용 +2. 장기적으로 유지보수 용이 +3. Spring 공식 문서도 Antora 사용 중 + +--- + +## 5. 마이그레이션 작업 항목 + +### 5.1 Phase 1: Antora 구조 생성 + +#### 5.1.1 antora.yml 생성 + +**파일**: `docs/antora.yml` + +```yaml +name: bottle-note +title: Bottle Note API +version: '1.0' +start_page: ROOT:index.adoc + +nav: + - modules/ROOT/nav.adoc + - modules/product-api/nav.adoc + - modules/admin-api/nav.adoc +``` + +#### 5.1.2 antora-playbook.yml 생성 + +**파일**: `docs/antora-playbook.yml` + +```yaml +site: + title: Bottle Note API Documentation + url: https://bottle-note.github.io/bottle-note-api-server + start_page: bottle-note::index.adoc + +content: + sources: + - url: . + start_path: docs + branches: HEAD + +ui: + bundle: + url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable + snapshot: true + +output: + dir: ./_site + +asciidoc: + attributes: + page-pagination: true +``` + +#### 5.1.3 네비게이션 파일 생성 + +**파일**: `docs/modules/ROOT/nav.adoc` + +```asciidoc +* xref:index.adoc[홈] +* xref:product-api:index.adoc[Product API] +* xref:admin-api:index.adoc[Admin API] +``` + +**파일**: `docs/modules/product-api/nav.adoc` + +```asciidoc +* 개요 +** xref:api/overview/overview.adoc[API 서버 경로] +** xref:api/overview/global-response.adoc[공통 응답] +** xref:api/overview/global-exception.adoc[예외 처리] + +* 술(Alcohol) API +** xref:api/alcohols/search.adoc[검색] +** xref:api/alcohols/detail.adoc[상세 조회] +// ... 추가 항목 +``` + +### 5.2 Phase 2: 기존 ADOC 파일 이동 + +#### 5.2.1 파일 복사 스크립트 + +```bash +#!/bin/bash + +# Product API 문서 복사 +mkdir -p docs/modules/product-api/pages/api +cp -r bottlenote-product-api/src/docs/asciidoc/api/* docs/modules/product-api/pages/api/ + +# Admin API 문서 복사 +mkdir -p docs/modules/admin-api/pages/api +cp -r bottlenote-admin-api/src/docs/asciidoc/api/* docs/modules/admin-api/pages/api/ +``` + +#### 5.2.2 Include 경로 수정 + +**변경 전**: +```asciidoc +include::{snippets}/admin/help/list/query-parameters.adoc[] +``` + +**변경 후**: +```asciidoc +include::example$generated-snippets/admin/help/list/query-parameters.adoc[] +``` + +### 5.3 Phase 3: GitHub Actions 수정 + +#### 5.3.1 새 워크플로우 + +**파일**: `.github/workflows/github-pages.yml` (수정) + +```yaml +name: Deploy Antora Documentation + +on: + push: + branches: [ "main" ] + paths: + - 'bottlenote-*/src/docs/**' + - 'bottlenote-*/src/test/java/**/docs/**' + - 'bottlenote-*/src/test/kotlin/**/docs/**' + - 'docs/**' + - '.github/workflows/github-pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Generate REST Docs snippets + run: ./gradlew restDocsTest + + - name: Copy snippets to Antora structure + run: | + # Product API 스니펫 복사 + mkdir -p docs/modules/product-api/examples/generated-snippets + cp -r bottlenote-product-api/build/generated-snippets/* \ + docs/modules/product-api/examples/generated-snippets/ + + # Admin API 스니펫 복사 + mkdir -p docs/modules/admin-api/examples/generated-snippets + cp -r bottlenote-admin-api/build/generated-snippets/* \ + docs/modules/admin-api/examples/generated-snippets/ + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install Antora + run: npm install -g @antora/cli @antora/site-generator + + - name: Build Antora site + run: | + cd docs + antora antora-playbook.yml + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 +``` + +--- + +## 6. 체크리스트 + +### Phase 1: 구조 생성 +- [ ] `docs/antora.yml` 생성 +- [ ] `docs/antora-playbook.yml` 생성 +- [ ] `docs/modules/ROOT/` 디렉토리 생성 +- [ ] `docs/modules/product-api/` 디렉토리 생성 +- [ ] `docs/modules/admin-api/` 디렉토리 생성 +- [ ] 각 모듈 `nav.adoc` 작성 + +### Phase 2: 문서 이동 +- [ ] Product API ADOC 파일 복사 +- [ ] Admin API ADOC 파일 복사 +- [ ] Include 경로 수정 (`{snippets}` → `example$generated-snippets`) +- [ ] `tasting-tags.adoc:23` 오타 수정 ("룰" 제거) + +### Phase 3: 빌드 설정 +- [ ] `.github/workflows/github-pages.yml` 수정 +- [ ] 로컬 Antora 빌드 테스트 +- [ ] GitHub Actions 테스트 + +### Phase 4: 검증 +- [ ] 모든 페이지 정상 렌더링 확인 +- [ ] 모든 include 스니펫 정상 로드 확인 +- [ ] 네비게이션 동작 확인 +- [ ] 검색 기능 확인 (추가 설정 필요 시) + +--- + +## 7. 롤백 계획 + +### 문제 발생 시 + +```bash +# 1. 현재 index.html 기반 구조로 즉시 복귀 + +# GitHub Actions 워크플로우를 이전 버전으로 복원 +git checkout HEAD~1 -- .github/workflows/github-pages.yml + +# 커밋 및 푸시 +git add .github/workflows/github-pages.yml +git commit -m "revert: rollback to Jekyll-based documentation" +git push origin main +``` + +### 백업 항목 + +| 파일 | 백업 경로 | +|------|----------| +| `github-pages.yml` | `github-pages.yml.bak` | +| `docs/index.html` | 그대로 유지 (삭제하지 않음) | + +--- + +## 8. 추가 고려사항 + +### 8.1 Spring Antora Extensions (선택) + +Spring 공식 문서에서 사용하는 확장 기능: + +```bash +npm install @springio/antora-extensions +``` + +**기능**: +- Partial Build (단일 버전만 빌드) +- Latest Version 매핑 +- Tabs 마이그레이션 + +### 8.2 커스텀 UI Bundle (선택) + +기본 Antora UI 대신 Spring 스타일 UI 사용 가능: + +```yaml +ui: + bundle: + url: https://github.com/spring-io/antora-ui-spring/releases/download/latest/ui-bundle.zip +``` + +### 8.3 Algolia 검색 통합 (선택) + +Antora에 Algolia DocSearch 통합 가능: + +```yaml +site: + keys: + algolia-api-key: 'YOUR_API_KEY' + algolia-index-name: 'bottle-note-docs' +``` + +--- + +## 9. 참고 자료 + +| 자료 | URL | +|------|-----| +| Antora 공식 문서 | https://docs.antora.org/ | +| Antora Collector Extension | https://gitlab.com/antora/antora-collector-extension | +| Spring Antora Extensions | https://github.com/spring-io/antora-extensions | +| Spring Boot Antora Wiki | https://github.com/spring-projects/spring-boot/wiki/Antora | +| AsciiDoc 언어 문서 | https://docs.asciidoctor.org/asciidoc/latest/ | + +--- + +**작성일**: 2026-02-02 +**버전**: 1.0 +**담당자**: Development Team From e8339510bf5782a2151f53d79e2b2f6fc2828a39 Mon Sep 17 00:00:00 2001 From: rlagu Date: Mon, 2 Feb 2026 23:17:55 +0900 Subject: [PATCH 13/29] docs: add file management strategy and deployment sections to Antora plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Git에 포함할 파일 vs CI에서 생성할 파일 전략 추가 - GitHub Pages 배포 방식 비교 (현재 vs Antora) - GitHub Actions 변경점 상세 비교 Co-Authored-By: Claude Opus 4.5 --- plan/antora-documentation-migration.md | 117 +++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/plan/antora-documentation-migration.md b/plan/antora-documentation-migration.md index 5203b381d..cace2c13e 100644 --- a/plan/antora-documentation-migration.md +++ b/plan/antora-documentation-migration.md @@ -434,9 +434,116 @@ git push origin main --- -## 8. 추가 고려사항 +## 8. 파일 관리 전략: Git에 포함할 파일 vs CI에서 생성할 파일 -### 8.1 Spring Antora Extensions (선택) +### 8.1 권장 전략: CI에서 조립 + +원본 ADOC 파일은 각 모듈에서 계속 관리하고, **빌드 시에만 Antora 구조로 조립**합니다. + +| 항목 | Git에 포함 | CI에서 생성 | +|------|:----------:|:-----------:| +| `antora.yml` | ✅ | | +| `antora-playbook.yml` | ✅ | | +| `nav.adoc` | ✅ | | +| `modules/ROOT/pages/index.adoc` | ✅ | | +| `modules/{api}/pages/*.adoc` | | ✅ (복사) | +| `modules/{api}/examples/snippets/` | | ✅ (복사) | +| `_site/` (빌드 결과) | | ✅ (생성) | + +### 8.2 이유 + +1. **중복 방지**: `src/docs/asciidoc/`에 있는 원본과 `docs/modules/`에 복사본이 생기면 동기화 문제 발생 +2. **단일 진실 소스(Single Source of Truth)**: 원본은 각 모듈의 `src/docs/`에만 유지 +3. **저장소 용량**: 스니펫은 빌드마다 생성되므로 Git에 불필요 + +### 8.3 Git에 커밋할 파일 (설정만) + +``` +docs/ +├── antora.yml +├── antora-playbook.yml +└── modules/ + ├── ROOT/ + │ ├── nav.adoc + │ └── pages/ + │ └── index.adoc # 홈페이지만 + ├── product-api/ + │ └── nav.adoc # 네비게이션만 + └── admin-api/ + └── nav.adoc # 네비게이션만 +``` + +### 8.4 CI에서 복사/생성할 파일 + +```bash +# GitHub Actions에서 실행 +# 1. ADOC 원본 복사 +cp -r bottlenote-product-api/src/docs/asciidoc/* docs/modules/product-api/pages/ +cp -r bottlenote-admin-api/src/docs/asciidoc/* docs/modules/admin-api/pages/ + +# 2. REST Docs 스니펫 복사 +cp -r bottlenote-product-api/build/generated-snippets/* docs/modules/product-api/examples/ +cp -r bottlenote-admin-api/build/generated-snippets/* docs/modules/admin-api/examples/ + +# 3. Antora 빌드 → _site/ 생성 +antora antora-playbook.yml +``` + +--- + +## 9. 배포 방식: GitHub Pages 유지 + +### 9.1 현재 vs Antora 배포 비교 + +배포 대상(GitHub Pages)은 동일하고, **빌드 도구만 변경**됩니다. + +| 단계 | 현재 방식 | Antora 방식 | +|------|-----------|-------------| +| 1. 스니펫 생성 | `./gradlew restDocsTest` | `./gradlew restDocsTest` | +| 2. HTML 빌드 | `./gradlew asciidoctor` | `antora antora-playbook.yml` | +| 3. 결과물 위치 | `docs/*.html` | `docs/_site/` | +| 4. 배포 | Jekyll → GitHub Pages | **그대로** GitHub Pages | + +### 9.2 GitHub Actions 변경점 비교 + +**현재 방식**: +```yaml +- name: Generate API documentation + run: | + ./gradlew :bottlenote-product-api:asciidoctor :bottlenote-admin-api:asciidoctor + cp bottlenote-product-api/build/docs/asciidoc/product-api.html docs/ + cp bottlenote-admin-api/build/docs/asciidoc/admin-api.html docs/ + +- name: Build with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./docs +``` + +**Antora 전환 후**: +```yaml +- name: Build Antora site + run: | + # 파일 복사 (CI에서만) + cp -r bottlenote-product-api/src/docs/asciidoc/* docs/modules/product-api/pages/ + cp -r bottlenote-admin-api/src/docs/asciidoc/* docs/modules/admin-api/pages/ + cp -r bottlenote-product-api/build/generated-snippets/* docs/modules/product-api/examples/ + cp -r bottlenote-admin-api/build/generated-snippets/* docs/modules/admin-api/examples/ + + # Antora 빌드 + npx antora docs/antora-playbook.yml + +- name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site # Antora 출력 폴더 +``` + +--- + +## 10. 추가 고려사항 + +### 10.1 Spring Antora Extensions (선택) Spring 공식 문서에서 사용하는 확장 기능: @@ -449,7 +556,7 @@ npm install @springio/antora-extensions - Latest Version 매핑 - Tabs 마이그레이션 -### 8.2 커스텀 UI Bundle (선택) +### 10.2 커스텀 UI Bundle (선택) 기본 Antora UI 대신 Spring 스타일 UI 사용 가능: @@ -459,7 +566,7 @@ ui: url: https://github.com/spring-io/antora-ui-spring/releases/download/latest/ui-bundle.zip ``` -### 8.3 Algolia 검색 통합 (선택) +### 10.3 Algolia 검색 통합 (선택) Antora에 Algolia DocSearch 통합 가능: @@ -472,7 +579,7 @@ site: --- -## 9. 참고 자료 +## 11. 참고 자료 | 자료 | URL | |------|-----| From 6cb00a6b12b56c5d852adca4ef12a1e45f89c7c7 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 3 Feb 2026 10:19:39 +0900 Subject: [PATCH 14/29] =?UTF-8?q?refactor:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=9D=91=EB=8B=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=20=EC=A0=95=EB=B3=B4=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminCurationControllerDocsTest.kt | 49 +++++-------------- git.environment-variables | 2 +- 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt index c25430aa7..3b9e9a87d 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt @@ -1,11 +1,6 @@ package app.docs.curation -import app.bottlenote.alcohols.dto.request.AdminCurationAlcoholRequest -import app.bottlenote.alcohols.dto.request.AdminCurationCreateRequest -import app.bottlenote.alcohols.dto.request.AdminCurationDisplayOrderRequest -import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest -import app.bottlenote.alcohols.dto.request.AdminCurationStatusRequest -import app.bottlenote.alcohols.dto.request.AdminCurationUpdateRequest +import app.bottlenote.alcohols.dto.request.* import app.bottlenote.alcohols.presentation.AdminCurationController import app.bottlenote.alcohols.service.AdminCurationService import app.bottlenote.global.data.response.GlobalResponse @@ -84,38 +79,20 @@ class AdminCurationControllerDocsTest { responseFields( fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("페이징 데이터"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("큐레이션 목록"), - fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("큐레이션 ID"), - fieldWithPath("data.content[].name").type(JsonFieldType.STRING).description("큐레이션명"), - fieldWithPath("data.content[].alcoholCount").type(JsonFieldType.NUMBER).description("포함된 위스키 수"), - fieldWithPath("data.content[].displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), - fieldWithPath("data.content[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), - fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.pageable").type(JsonFieldType.OBJECT).description("페이징 정보").ignored(), - fieldWithPath("data.pageable.pageNumber").type(JsonFieldType.NUMBER).description("페이지 번호").ignored(), - fieldWithPath("data.pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기").ignored(), - fieldWithPath("data.pageable.sort").type(JsonFieldType.OBJECT).description("정렬 정보").ignored(), - fieldWithPath("data.pageable.sort.empty").type(JsonFieldType.BOOLEAN).ignored(), - fieldWithPath("data.pageable.sort.sorted").type(JsonFieldType.BOOLEAN).ignored(), - fieldWithPath("data.pageable.sort.unsorted").type(JsonFieldType.BOOLEAN).ignored(), - fieldWithPath("data.pageable.offset").type(JsonFieldType.NUMBER).ignored(), - fieldWithPath("data.pageable.paged").type(JsonFieldType.BOOLEAN).ignored(), - fieldWithPath("data.pageable.unpaged").type(JsonFieldType.BOOLEAN).ignored(), - fieldWithPath("data.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), - fieldWithPath("data.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.size").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.number").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.sort").type(JsonFieldType.OBJECT).description("정렬 정보").ignored(), - fieldWithPath("data.sort.empty").type(JsonFieldType.BOOLEAN).ignored(), - fieldWithPath("data.sort.sorted").type(JsonFieldType.BOOLEAN).ignored(), - fieldWithPath("data.sort.unsorted").type(JsonFieldType.BOOLEAN).ignored(), - fieldWithPath("data.numberOfElements").type(JsonFieldType.NUMBER).description("현재 페이지 요소 수"), - fieldWithPath("data.first").type(JsonFieldType.BOOLEAN).description("첫 페이지 여부"), - fieldWithPath("data.empty").type(JsonFieldType.BOOLEAN).description("빈 페이지 여부"), + fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("큐레이션 목록"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("큐레이션명"), + fieldWithPath("data[].alcoholCount").type(JsonFieldType.NUMBER).description("포함된 위스키 수"), + fieldWithPath("data[].displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), + fieldWithPath("data[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), diff --git a/git.environment-variables b/git.environment-variables index 6cc75e4de..a248d6fc2 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 6cc75e4de1ce3e9489af01dd202e9550160d5049 +Subproject commit a248d6fc225594b197013d0be8211980e2453db5 From 877cd4ac0ac42ed645e527bccc7daf82d366ed35 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 3 Feb 2026 12:14:37 +0900 Subject: [PATCH 15/29] =?UTF-8?q?docs:=20Admin=20API=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottle Note 프로젝트의 관리 API 스펙 HTML 문서 제거 --- .github/workflows/github-pages.yml | 48 +- .gitignore | 11 + docs/admin-api.html | 2060 ------------------------ docs/index.html | 308 ---- plan/antora-documentation-migration.md | 21 - 5 files changed, 46 insertions(+), 2402 deletions(-) delete mode 100644 docs/admin-api.html delete mode 100644 docs/index.html diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index e063f9263..ea3b36186 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -1,4 +1,4 @@ -name: Deploy Jekyll with GitHub Pages dependencies preinstalled +name: Deploy Antora Documentation to GitHub Pages on: push: @@ -28,6 +28,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Java 21 uses: actions/setup-java@v4 with: @@ -40,24 +41,45 @@ jobs: - name: Generate REST Docs snippets run: ./gradlew restDocsTest - - name: Generate API documentation + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Antora + run: npm i -D @antora/cli@3.1 @antora/site-generator@3.1 + + - name: Copy files to Antora structure run: | - mkdir -p docs - ./gradlew :bottlenote-product-api:asciidoctor :bottlenote-admin-api:asciidoctor - cp bottlenote-product-api/build/docs/asciidoc/product-api.html docs/ - cp bottlenote-admin-api/build/docs/asciidoc/admin-api.html docs/ - echo "API documentation copied" + # Product API: ADOC 파일 복사 + mkdir -p docs/modules/product-api/pages + cp -r bottlenote-product-api/src/docs/asciidoc/* docs/modules/product-api/pages/ + + # Product API: 스니펫 복사 + mkdir -p docs/modules/product-api/examples + cp -r bottlenote-product-api/build/generated-snippets/* docs/modules/product-api/examples/ + + # Admin API: ADOC 파일 복사 + mkdir -p docs/modules/admin-api/pages + cp -r bottlenote-admin-api/src/docs/asciidoc/* docs/modules/admin-api/pages/ + # Admin API: 스니펫 복사 + mkdir -p docs/modules/admin-api/examples + cp -r bottlenote-admin-api/build/generated-snippets/* docs/modules/admin-api/examples/ + + - name: Build Antora site + run: | + cd docs + npx antora --fetch antora-playbook.yml - name: Setup Pages uses: actions/configure-pages@v5 - - name: Build with Jekyll - uses: actions/jekyll-build-pages@v1 - with: - source: ./docs - destination: ./_site + - name: Upload artifact uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site + deploy: environment: name: github-pages @@ -67,4 +89,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 385b309b0..1a6104d63 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,14 @@ cosign.pub # Log files spy.log /.omc/ + +# Antora (CI에서 생성되는 파일들) +docs/modules/product-api/pages/ +docs/modules/product-api/examples/ +docs/modules/admin-api/pages/ +docs/modules/admin-api/examples/ +docs/_site/ + +# 기존 Jekyll 빌드 결과물 (Antora 전환 후 삭제 예정) +docs/admin-api.html +docs/index.html diff --git a/docs/admin-api.html b/docs/admin-api.html deleted file mode 100644 index 6a88f3eca..000000000 --- a/docs/admin-api.html +++ /dev/null @@ -1,2060 +0,0 @@ - - - - - - - -Bottle Note Admin API 문서 - - - - - - - -
-
-

1. 개요 (overview)

-
-
- - - - - -
- - -
-

해당 프로젝트 API문서는 개발환경까지 노출되는 것을 권장합니다.
-이 API문서는 bottle-note 관리자 프로젝트의 API를 설명합니다.
-포함되면 좋을것 같은 내용이나 수정이 필요한 내용은 언제든지 디스코드 채널을 통해 알려주세요.

-
-
-
-
-

1.1. API 서버 경로

- ---- - - - - - - - - - - - - - - -

환경

DNS

개발 (dev)

https://admin-api.development.bottle-note.com/

운영(prod)

https://admin-api.bottle-note.com/

-
-
-
-

1.2. 응답형식

-
-

프로젝트는 다음과 같은 응답형식을 제공합니다.

-
-

요청 결과

- ---- - - - - - - - - - - - - -
요청이 성공한 경우 ( "success" : true )요청이 실패한 경우( "success" : false )
-
-
{
-  "success" : true,
-  "code" : 200,
-  "data" : {
-    "value1" : "value1",
-    "value2" : {
-            "value2-1" : "value2-1",
-            "value2-2" : "value2-2"
-    }
-  },
-  "errors" : {},
-  "meta" : {
-    "serverEncoding" : "UTF-8",
-    "serverVersion" : "0.0.1",
-    "serverPathVersion" : "v1",
-    "serverResponseTime" : "2024-04-16T16:22:56.561466"
-  }
-}
-
-
-
-
{
-  "success" : false,
-  "code" : 400,
-  "data" : {},
-  "errors" : {
-    "user_id": "사용자 아이디는 필수입니다.",
-    "user_pw": "사용자 비밀번호는 필수입니다.",
-    "type": "사용자 타입은 필수입니다."
-  },
-  "meta" : {
-    "serverEncoding" : "UTF-8",
-    "serverVersion" : "0.0.1",
-    "serverPathVersion" : "v1",
-    "serverResponseTime" : "2024-04-16T16:22:56.561466"
-  }
-}
-
-
-
-
    -
  • -

    기본적으로 success , code , data , errors , meta 로 구성되어 있습니다.

    -
    -
      -
    • -

      success : 요청 성공 여부

      -
    • -
    • -

      code : 요청 결과 코드 (http status code 와 동일합니다.)

      -
    • -
    • -

      data : 요청 결과 데이터 ( success가 true인 경우 )

      -
    • -
    • -

      errors : 요청 결과 에러 ( success가 false인 경우 )

      -
    • -
    • -

      meta : 요청 결과 메타정보 ( 서버 응답 정보, 기존 검색 정보 , page 정보 등)

      -
    • -
    -
    -
  • -
  • -

    누락된 정보는 서버 백엔드 팀에게 문의해주세요.

    -
  • -
-
-
-
-
-

1.3. 공통 예외 처리

-
-

공통적으로 처리되는 예외를 정리하는 내용입니다.

-
-
-

success 코드가 false인 경우 errors 값을 확인 바랍니다.

-
-
-

공통적인 예외의 종류에는 이런 값들이 있습니다.

-
-
-
    -
  • -

    숫자 값이 필요한데 문자열 값이 들어온 경우

    -
  • -
-
-
-
-
POST http://localhost:8080/admin/api/v1/example
-Content-Type: application/json
-
-{
-  "userId": "일", // 숫자 값이 필요한데 문자열 값이 들어온 경우
-  "targetId": 0,
-  "type": "EXAMPLE",
-  "content": "내용"
-}
-
-
-
-
-
{
-  "success": false,
-  "code": 400,
-  "data": [],
-  "errors": {
-    "message": "'userId' 필드의 값이 잘못되었습니다. 해당 필드의 값의 타입을 확인해주세요."
-  },
-  "meta": {
-    "serverVersion": "1.0.0",
-    "serverEncoding": "UTF-8",
-    "serverResponseTime": "2024-04-19T22:28:52.428425",
-    "serverPathVersion": "v1"
-  }
-}
-
-
-
-
    -
  • -

    필수적인 값이 없는 경우

    -
  • -
-
-
-
-
POST http://localhost:8080/admin/api/v1/example
-Content-Type: application/json
-
-{
-  "userId": "", // 필수적인 값이 없는 경우
-  "targetId": 0,
-  "type": "EXAMPLE",
-  "content": "내용"
-}
-
-
-
-
-
{
-  "success": false,
-  "code": 400,
-  "data": [],
-  "errors": {
-    "userId": "사용자 아이디는 필수입니다."
-  },
-  "meta": {
-    "serverVersion": "1.0.0",
-    "serverEncoding": "UTF-8",
-    "serverResponseTime": "2024-04-19T22:31:48.742731",
-    "serverPathVersion": "v1"
-  }
-}
-
-
-
-
    -
  • -

    누락된 정보는 서버 백엔드 팀에게 문의해주세요.

    -
  • -
  • -

    추가되었으면 좋은 정보가 있으면 서버 백엔드 팀에게 문의해주세요.

    -
  • -
-
-
-
-
-
-
-

2. Auth API

-
-
-

2.1. 로그인

-
-
    -
  • -

    관리자 계정으로 로그인하여 액세스 토큰과 리프레시 토큰을 발급받습니다.

    -
  • -
-
-
-
-
POST /admin/api/v1/auth/login
-
-
-

요청 파라미터

- ----- - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

email

String

관리자 이메일

password

String

비밀번호

-
-
-
POST /auth/login HTTP/1.1
-Content-Type: application/json;charset=UTF-8
-Content-Length: 68
-Host: localhost:8080
-
-{
-  "email" : "admin@bottlenote.com",
-  "password" : "password123"
-}
-
-
-

응답 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.accessToken

String

액세스 토큰

data.refreshToken

String

리프레시 토큰

errors

Array

에러 목록

-
-
-
HTTP/1.1 200 OK
-Content-Type: application/json
-Content-Length: 355
-
-{
-  "success" : true,
-  "code" : 200,
-  "data" : {
-    "accessToken" : "cKKYogKS4WKo4PXl3CFOPPmYZ7fuROzj",
-    "refreshToken" : "BaLRekHCi8HacQkNwZmGECM90pd4Cb7D"
-  },
-  "errors" : [ ],
-  "meta" : {
-    "serverVersion" : "1.0.0",
-    "serverEncoding" : "UTF-8",
-    "serverResponseTime" : "2025-12-31T13:44:59.874373",
-    "serverPathVersion" : "v1"
-  }
-}
-
-
-
-
-
-

2.2. 토큰 갱신

-
-
    -
  • -

    리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.

    -
  • -
-
-
-
-
POST /admin/api/v1/auth/refresh
-
-
-

요청 파라미터

- ----- - - - - - - - - - - - - - - -
PathTypeDescription

refreshToken

String

리프레시 토큰

-
-
-
POST /auth/refresh HTTP/1.1
-Content-Type: application/json;charset=UTF-8
-Content-Length: 47
-Host: localhost:8080
-
-{
-  "refreshToken" : "existing_refresh_token"
-}
-
-
-

응답 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.accessToken

String

새 액세스 토큰

data.refreshToken

String

새 리프레시 토큰

errors

Array

에러 목록

-
-
-
HTTP/1.1 200 OK
-Content-Type: application/json
-Content-Length: 355
-
-{
-  "success" : true,
-  "code" : 200,
-  "data" : {
-    "accessToken" : "gZkFjp3IDuqGOWskT1nzMrs6vSea5DMA",
-    "refreshToken" : "thq98RQinKwJiS2BBM2DUG07rUSEUp1a"
-  },
-  "errors" : [ ],
-  "meta" : {
-    "serverVersion" : "1.0.0",
-    "serverEncoding" : "UTF-8",
-    "serverResponseTime" : "2025-12-31T13:44:59.888659",
-    "serverPathVersion" : "v1"
-  }
-}
-
-
-
-
-
-

2.3. 회원가입

-
-
    -
  • -

    인증된 관리자가 새로운 관리자 계정을 생성합니다.

    -
  • -
  • -

    모든 인증된 어드민이 호출 가능합니다.

    -
  • -
  • -

    ROOT_ADMIN 역할은 ROOT_ADMIN만 부여할 수 있습니다.

    -
  • -
-
-
-
-
POST /admin/api/v1/auth/signup
-
-
-

요청 헤더

- ---- - - - - - - - - - - - - -
NameDescription

Authorization

Bearer 액세스 토큰

-

요청 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

email

String

어드민 이메일

password

String

비밀번호 (8~35자)

name

String

어드민 이름 (2~50자)

roles

Array

역할 목록 (ROOT_ADMIN, PARTNER, CLIENT, BAR_OWNER, COMMUNITY_MANAGER)

-
-
-
POST /auth/signup HTTP/1.1
-Content-Type: application/json;charset=UTF-8
-Authorization: Bearer test_access_token
-Content-Length: 142
-Host: localhost:8080
-
-{
-  "email" : "new@bottlenote.com",
-  "password" : "password123",
-  "name" : "새 어드민",
-  "roles" : [ "PARTNER", "COMMUNITY_MANAGER" ]
-}
-
-
-

응답 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.adminId

Number

생성된 어드민 ID

data.email

String

어드민 이메일

data.name

String

어드민 이름

data.roles

Array

부여된 역할 목록

errors

Array

에러 목록

-
-
-
HTTP/1.1 200 OK
-Content-Type: application/json
-Content-Length: 377
-
-{
-  "success" : true,
-  "code" : 200,
-  "data" : {
-    "adminId" : 1,
-    "email" : "new@bottlenote.com",
-    "name" : "새 어드민",
-    "roles" : [ "PARTNER", "COMMUNITY_MANAGER" ]
-  },
-  "errors" : [ ],
-  "meta" : {
-    "serverVersion" : "1.0.0",
-    "serverEncoding" : "UTF-8",
-    "serverResponseTime" : "2025-12-31T13:44:59.853345",
-    "serverPathVersion" : "v1"
-  }
-}
-
-
-
-
-
-

2.4. 탈퇴

-
-
    -
  • -

    인증된 관리자 계정을 탈퇴 처리합니다.

    -
  • -
-
-
-
-
DELETE /admin/api/v1/auth/withdraw
-
-
-

요청 헤더

- ---- - - - - - - - - - - - - -
NameDescription

Authorization

Bearer 액세스 토큰

-
-
-
DELETE /auth/withdraw HTTP/1.1
-Authorization: Bearer test_access_token
-Host: localhost:8080
-
-
-

응답 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.message

String

처리 결과 메시지

errors

Array

에러 목록

-
-
-
HTTP/1.1 200 OK
-Content-Type: application/json
-Content-Length: 291
-
-{
-  "success" : true,
-  "code" : 200,
-  "data" : {
-    "message" : "탈퇴 처리되었습니다."
-  },
-  "errors" : [ ],
-  "meta" : {
-    "serverVersion" : "1.0.0",
-    "serverEncoding" : "UTF-8",
-    "serverResponseTime" : "2025-12-31T13:44:59.774392",
-    "serverPathVersion" : "v1"
-  }
-}
-
-
-
-
-
-
-
-

3. Alcohol API

-
-
-

3.1. 술(Alcohol) 목록 조회

-
-
    -
  • -

    관리자용 술 목록 조회 API입니다.

    -
  • -
  • -

    키워드 검색, 카테고리 필터, 정렬, 페이징을 지원합니다.

    -
  • -
-
-
-
-
GET /admin/api/v1/alcohols
-
-
-

요청 파라미터

- ---- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDescription

keyword

검색어 (한글/영문 이름 검색)

category

카테고리 그룹 필터 (예: SINGLE_MALT, BLEND 등)

regionId

지역 ID 필터

sortType

정렬 기준 (KOR_NAME, ENG_NAME, KOR_CATEGORY, ENG_CATEGORY / 기본값: KOR_NAME)

sortOrder

정렬 방향 (기본값: ASC)

page

페이지 번호 (기본값: 0)

size

페이지 크기 (기본값: 20)

-
-
-
$ curl 'http://localhost:8080/alcohols?keyword=%EA%B8%80%EB%A0%8C&category=SINGLE_MALT&regionId=1&sortType=KOR_NAME&sortOrder=ASC&page=0&size=20' -i -X GET
-
-
-
-
-
GET /alcohols?keyword=%EA%B8%80%EB%A0%8C&category=SINGLE_MALT&regionId=1&sortType=KOR_NAME&sortOrder=ASC&page=0&size=20 HTTP/1.1
-Host: localhost:8080
-
-
-

응답 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data

Array

술 목록 데이터

data[].alcoholId

Number

술 ID

data[].korName

String

술 한글 이름

data[].engName

String

술 영문 이름

data[].korCategoryName

String

카테고리 한글명

data[].engCategoryName

String

카테고리 영문명

data[].imageUrl

String

술 이미지 URL

data[].createdAt

String

생성일시

data[].modifiedAt

String

수정일시

errors

Array

에러 목록

meta

Object

메타 정보

meta.page

Number

현재 페이지 번호

meta.size

Number

페이지 크기

meta.totalElements

Number

전체 요소 수

meta.totalPages

Number

전체 페이지 수

meta.hasNext

Boolean

다음 페이지 존재 여부

-
-
-
HTTP/1.1 200 OK
-Content-Type: application/json
-Content-Length: 969
-
-{
-  "success" : true,
-  "code" : 200,
-  "data" : [ {
-    "alcoholId" : 1,
-    "korName" : "테스트 위스키 1",
-    "engName" : "Test Whisky 1",
-    "korCategoryName" : "싱글몰트",
-    "engCategoryName" : "Single Malt",
-    "imageUrl" : "https://example.com/image.jpg",
-    "createdAt" : "2024-01-01T00:00:00",
-    "modifiedAt" : "2024-06-01T00:00:00"
-  }, {
-    "alcoholId" : 2,
-    "korName" : "테스트 위스키 2",
-    "engName" : "Test Whisky 2",
-    "korCategoryName" : "싱글몰트",
-    "engCategoryName" : "Single Malt",
-    "imageUrl" : "https://example.com/image.jpg",
-    "createdAt" : "2024-02-01T00:00:00",
-    "modifiedAt" : "2024-07-01T00:00:00"
-  } ],
-  "errors" : [ ],
-  "meta" : {
-    "page" : 0,
-    "size" : 20,
-    "totalElements" : 2,
-    "totalPages" : 1,
-    "hasNext" : false,
-    "serverVersion" : "1.0.0",
-    "serverEncoding" : "UTF-8",
-    "serverResponseTime" : "2025-12-31T13:44:59.120043",
-    "serverPathVersion" : "v1"
-  }
-}
-
-
-
-
-
-
-
-

4. Help API

-
-
-

4.1. 문의 목록 조회

-
-
    -
  • -

    전체 문의 목록을 조회합니다.

    -
  • -
  • -

    상태(status)와 유형(type)으로 필터링할 수 있습니다.

    -
  • -
  • -

    커서 기반 페이징을 지원합니다.

    -
  • -
-
-
-
-
GET /admin/api/v1/helps
-
-
-

요청 헤더

- ---- - - - - - - - - - - - - -
NameDescription

Authorization

Bearer 액세스 토큰

-

요청 파라미터

- ---- - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDescription

status

상태 필터 (WAITING, SUCCESS, REJECT, DELETED)

type

문의 유형 필터 (WHISKEY, REVIEW, USER, ETC)

cursor

페이징 커서 (기본값: 0)

pageSize

페이지 크기 (기본값: 20)

-
-
-
GET /helps?status=WAITING&type=WHISKEY&cursor=0&pageSize=20 HTTP/1.1
-Authorization: Bearer test_access_token
-Host: localhost:8080
-
-
-

응답 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.content.totalCount

Number

전체 문의 수

data.content.helpList[].helpId

Number

문의 ID

data.content.helpList[].userId

Number

문의자 ID

data.content.helpList[].userNickname

String

문의자 닉네임

data.content.helpList[].title

String

문의 제목

data.content.helpList[].type

String

문의 유형

data.content.helpList[].status

String

처리 상태

data.content.helpList[].createAt

String

생성일시

data.cursorPageable.cursor

Number

다음 커서

data.cursorPageable.pageSize

Number

페이지 크기

data.cursorPageable.hasNext

Boolean

다음 페이지 여부

data.cursorPageable.currentCursor

Number

현재 커서

errors

Array

에러 목록

-
-
-
HTTP/1.1 200 OK
-Content-Type: application/json
-Content-Length: 694
-
-{
-  "success" : true,
-  "code" : 200,
-  "data" : {
-    "content" : {
-      "totalCount" : 1,
-      "helpList" : [ {
-        "helpId" : 1,
-        "userId" : 100,
-        "userNickname" : "테스트유저",
-        "title" : "위스키 관련 문의",
-        "type" : "WHISKEY",
-        "status" : "WAITING",
-        "createAt" : "2025-12-31T13:45:00.114159"
-      } ]
-    },
-    "cursorPageable" : {
-      "currentCursor" : 0,
-      "cursor" : 20,
-      "pageSize" : 20,
-      "hasNext" : false
-    }
-  },
-  "errors" : [ ],
-  "meta" : {
-    "serverVersion" : "1.0.0",
-    "serverEncoding" : "UTF-8",
-    "serverResponseTime" : "2025-12-31T13:45:00.115917",
-    "serverPathVersion" : "v1"
-  }
-}
-
-
-
-
-
-

4.2. 문의 상세 조회

-
-
    -
  • -

    특정 문의의 상세 정보를 조회합니다.

    -
  • -
  • -

    문의 내용, 이미지, 답변 내용 등을 확인할 수 있습니다.

    -
  • -
-
-
-
-
GET /admin/api/v1/helps/{helpId}
-
-
-

요청 헤더

- ---- - - - - - - - - - - - - -
NameDescription

Authorization

Bearer 액세스 토큰

-

경로 파라미터

- - ---- - - - - - - - - - - - - -
Table 1. /helps/{helpId}
ParameterDescription

helpId

문의 ID

-
-
-
GET /helps/1 HTTP/1.1
-Authorization: Bearer test_access_token
-Host: localhost:8080
-
-
-

응답 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.helpId

Number

문의 ID

data.userId

Number

문의자 ID

data.userNickname

String

문의자 닉네임

data.title

String

문의 제목

data.content

String

문의 내용

data.type

String

문의 유형

data.imageUrlList[].order

Number

이미지 순서

data.imageUrlList[].viewUrl

String

이미지 URL

data.status

String

처리 상태

data.adminId

Null

담당 관리자 ID

data.responseContent

Null

답변 내용

data.createAt

String

생성일시

data.lastModifyAt

String

수정일시

errors

Array

에러 목록

-
-
-
HTTP/1.1 200 OK
-Content-Type: application/json
-Content-Length: 723
-
-{
-  "success" : true,
-  "code" : 200,
-  "data" : {
-    "helpId" : 1,
-    "userId" : 100,
-    "userNickname" : "테스트유저",
-    "title" : "위스키 관련 문의",
-    "content" : "위스키에 대해 문의드립니다.",
-    "type" : "WHISKEY",
-    "imageUrlList" : [ {
-      "order" : 1,
-      "viewUrl" : "https://example.com/image.jpg"
-    } ],
-    "status" : "WAITING",
-    "adminId" : null,
-    "responseContent" : null,
-    "createAt" : "2025-12-31T13:45:00.073754",
-    "lastModifyAt" : "2025-12-31T13:45:00.073762"
-  },
-  "errors" : [ ],
-  "meta" : {
-    "serverVersion" : "1.0.0",
-    "serverEncoding" : "UTF-8",
-    "serverResponseTime" : "2025-12-31T13:45:00.076799",
-    "serverPathVersion" : "v1"
-  }
-}
-
-
-
-
-
-

4.3. 문의 답변 등록

-
-
    -
  • -

    문의에 대한 답변을 등록합니다.

    -
  • -
  • -

    처리 상태를 SUCCESS(처리 완료) 또는 REJECT(반려)로 설정할 수 있습니다.

    -
  • -
-
-
-
-
POST /admin/api/v1/helps/{helpId}/answer
-
-
-

요청 헤더

- ---- - - - - - - - - - - - - -
NameDescription

Authorization

Bearer 액세스 토큰

-

경로 파라미터

- - ---- - - - - - - - - - - - - -
Table 2. /helps/{helpId}/answer
ParameterDescription

helpId

문의 ID

-

요청 파라미터

- ----- - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

responseContent

String

답변 내용

status

String

처리 상태 (SUCCESS, REJECT)

-
-
-
POST /helps/1/answer HTTP/1.1
-Content-Type: application/json;charset=UTF-8
-Authorization: Bearer test_access_token
-Content-Length: 75
-Host: localhost:8080
-
-{
-  "responseContent" : "답변 내용입니다.",
-  "status" : "SUCCESS"
-}
-
-
-

응답 파라미터

- ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PathTypeDescription

success

Boolean

응답 성공 여부

code

Number

응답 코드

data.helpId

Number

문의 ID

data.status

String

처리 상태

data.message

String

결과 메시지

errors

Array

에러 목록

-
-
-
HTTP/1.1 200 OK
-Content-Type: application/json
-Content-Length: 338
-
-{
-  "success" : true,
-  "code" : 200,
-  "data" : {
-    "helpId" : 1,
-    "status" : "SUCCESS",
-    "message" : "답변이 등록되었습니다."
-  },
-  "errors" : [ ],
-  "meta" : {
-    "serverVersion" : "1.0.0",
-    "serverEncoding" : "UTF-8",
-    "serverResponseTime" : "2025-12-31T13:45:00.103422",
-    "serverPathVersion" : "v1"
-  }
-}
-
-
-
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 728ffcac4..000000000 --- a/docs/index.html +++ /dev/null @@ -1,308 +0,0 @@ - - - - - - Bottle Note API 문서 - - - - - -
-
-
Product API
-
Admin API
-
-
- - -
-
-
- -
- - diff --git a/plan/antora-documentation-migration.md b/plan/antora-documentation-migration.md index cace2c13e..02671e679 100644 --- a/plan/antora-documentation-migration.md +++ b/plan/antora-documentation-migration.md @@ -1,26 +1,5 @@ # Antora 기반 API 문서 시스템 마이그레이션 계획 -``` -================================================================================ - PROJECT COMPLETION STAMP -================================================================================ -Status: **PLANNING** -Start Date: 2026-02-02 -Last Updated: 2026-02-02 - -** Completed Work ** -- 현행 문서 시스템 분석 완료 -- Antora 아키텍처 조사 완료 -- 기존 GitHub Actions 워크플로우 분석 완료 - -** Remaining Work ** -- Antora 디렉토리 구조 설계 -- antora-playbook.yml 작성 -- GitHub Actions 워크플로우 수정 -- 기존 ADOC 문서 include 경로 수정 -================================================================================ -``` - --- ## 1. Antora란? From 8a75e1e0007d61de632481af298a0065699a2a80 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 3 Feb 2026 12:14:47 +0900 Subject: [PATCH 16/29] =?UTF-8?q?docs:=20API=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottle Note API 문서를 위한 Antora 구성 파일과 네비게이션 문서 추가: - antora.yml 및 antora-playbook.yml - 홈, Product API, Admin API 네비게이션 문서 작성 --- docs/antora-playbook.yml | 21 +++++++++++++++++++++ docs/antora.yml | 11 +++++++++++ docs/modules/ROOT/nav.adoc | 1 + docs/modules/ROOT/pages/index.adoc | 8 ++++++++ docs/modules/admin-api/nav.adoc | 1 + docs/modules/product-api/nav.adoc | 1 + 6 files changed, 43 insertions(+) create mode 100644 docs/antora-playbook.yml create mode 100644 docs/antora.yml create mode 100644 docs/modules/ROOT/nav.adoc create mode 100644 docs/modules/ROOT/pages/index.adoc create mode 100644 docs/modules/admin-api/nav.adoc create mode 100644 docs/modules/product-api/nav.adoc diff --git a/docs/antora-playbook.yml b/docs/antora-playbook.yml new file mode 100644 index 000000000..101cbb693 --- /dev/null +++ b/docs/antora-playbook.yml @@ -0,0 +1,21 @@ +site: + title: Bottle Note API Documentation + start_page: bottle-note::index.adoc + +content: + sources: + - url: . + start_path: . + branches: HEAD + +ui: + bundle: + url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable + snapshot: true + +asciidoc: + attributes: + snippets: example$ + +output: + dir: ./_site \ No newline at end of file diff --git a/docs/antora.yml b/docs/antora.yml new file mode 100644 index 000000000..e06e002ec --- /dev/null +++ b/docs/antora.yml @@ -0,0 +1,11 @@ +name: bottle-note +title: Bottle Note API +version: ~ +start_page: ROOT:index.adoc +asciidoc: + attributes: + snippets: example$ +nav: + - modules/ROOT/nav.adoc + - modules/product-api/nav.adoc + - modules/admin-api/nav.adoc \ No newline at end of file diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc new file mode 100644 index 000000000..fdfe721e5 --- /dev/null +++ b/docs/modules/ROOT/nav.adoc @@ -0,0 +1 @@ +* xref:index.adoc[홈] \ No newline at end of file diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc new file mode 100644 index 000000000..3ea4dd64f --- /dev/null +++ b/docs/modules/ROOT/pages/index.adoc @@ -0,0 +1,8 @@ += Bottle Note API Documentation + +Bottle Note API 문서입니다. + +== API 문서 + +* xref:product-api:product-api.adoc[Product API] - 클라이언트용 API +* xref:admin-api:admin-api.adoc[Admin API] - 관리자용 API \ No newline at end of file diff --git a/docs/modules/admin-api/nav.adoc b/docs/modules/admin-api/nav.adoc new file mode 100644 index 000000000..41eef36c1 --- /dev/null +++ b/docs/modules/admin-api/nav.adoc @@ -0,0 +1 @@ +* xref:admin-api.adoc[Admin API] \ No newline at end of file diff --git a/docs/modules/product-api/nav.adoc b/docs/modules/product-api/nav.adoc new file mode 100644 index 000000000..e20756e9d --- /dev/null +++ b/docs/modules/product-api/nav.adoc @@ -0,0 +1 @@ +* xref:product-api.adoc[Product API] \ No newline at end of file From ed8ecc7490e772c6a4bbd6b24034a12159f0125f Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 3 Feb 2026 12:20:17 +0900 Subject: [PATCH 17/29] =?UTF-8?q?docs:=20Antora=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20=EC=8B=9C=EC=9E=91=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/antora-playbook.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/antora-playbook.yml b/docs/antora-playbook.yml index 101cbb693..4a5e11283 100644 --- a/docs/antora-playbook.yml +++ b/docs/antora-playbook.yml @@ -4,8 +4,8 @@ site: content: sources: - - url: . - start_path: . + - url: .. + start_path: docs branches: HEAD ui: From 782875c6e57f5308dc5f9bae6f222edc552f44e9 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 3 Feb 2026 13:24:05 +0900 Subject: [PATCH 18/29] =?UTF-8?q?docs:=20Antora=20UI=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=A7=88=EC=9D=B4=EC=A7=95=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=A4=ED=81=AC=EB=AA=A8=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/antora-playbook.yml | 2 + plan/antora-documentation-migration.md | 130 ++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1a6104d63..41cb2602f 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ cosign.pub # Log files spy.log /.omc/ +**/.omc/ # Antora (CI에서 생성되는 파일들) docs/modules/product-api/pages/ diff --git a/docs/antora-playbook.yml b/docs/antora-playbook.yml index 4a5e11283..3ae139f17 100644 --- a/docs/antora-playbook.yml +++ b/docs/antora-playbook.yml @@ -7,11 +7,13 @@ content: - url: .. start_path: docs branches: HEAD + edit_url: false ui: bundle: url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable snapshot: true + supplemental_files: ./supplemental-ui asciidoc: attributes: diff --git a/plan/antora-documentation-migration.md b/plan/antora-documentation-migration.md index 02671e679..2ddb0cb2a 100644 --- a/plan/antora-documentation-migration.md +++ b/plan/antora-documentation-migration.md @@ -13,7 +13,7 @@ Antora는 **멀티 리포지토리, 멀티 버전 문서 사이트 생성기** | **AsciiDoc 네이티브** | AsciiDoc 마크업 언어를 기본 지원 | | **멀티 버전** | 동일 문서의 여러 버전을 동시에 관리 | | **멀티 컴포넌트** | 여러 프로젝트/모듈 문서를 하나의 사이트로 통합 | -| **Git 기반** | Git 저장소에서 직접 콘텐츠 수집 | +| **Git 기반** | ``Git 저장소에서 직접 콘텐츠 수집 | | **정적 사이트** | HTML 정적 파일 생성 → 어디서든 호스팅 가능 | ### 1.2 현재 시스템 vs Antora @@ -386,6 +386,16 @@ jobs: - [ ] 네비게이션 동작 확인 - [ ] 검색 기능 확인 (추가 설정 필요 시) +### Phase 5: UI 커스터마이징 ✅ 완료 +- [x] Antora 기본 UI 번들 설정 +- [x] supplemental-ui 폴더 구조 생성 +- [x] header-content.hbs 작성 (헤더 간소화) +- [x] footer-content.hbs 작성 (푸터 간소화) +- [x] toolbar.hbs 작성 (Edit this page 제거) +- [x] 다크모드 토글 스위치 구현 +- [x] Spring 다크모드 색상 적용 +- [x] 로컬 빌드 테스트 통과 + --- ## 7. 롤백 계획 @@ -558,7 +568,112 @@ site: --- -## 11. 참고 자료 +## 11. UI 커스터마이징 (완료) + +### 11.1 적용된 방식 + +**Antora 기본 UI + Supplemental Files**로 헤더/푸터만 오버라이드하는 방식을 채택했습니다. + +| 시도 | 결과 | 문제점 | +|------|------|--------| +| Spring UI 번들 | ❌ 실패 | Spring 브랜딩이 너무 강함 | +| Spring UI + supplemental files | ❌ 실패 | CSS 스타일 없이 HTML만 넣어서 토글 깨짐 | +| Minimized Header UI (v1.1) | ❌ 실패 | 호환성 문제로 사이트 완전히 깨짐 | +| **Antora 기본 UI + supplemental files** | ✅ 성공 | 안정적이고 커스터마이징 용이 | + +### 11.2 현재 파일 구조 + +``` +docs/ +├── antora-playbook.yml +└── supplemental-ui/ + └── partials/ + ├── header-content.hbs # 커스텀 헤더 + 다크모드 CSS/JS + ├── footer-content.hbs # 커스텀 푸터 + └── toolbar.hbs # Edit this page 제거 +``` + +### 11.3 antora-playbook.yml 설정 + +```yaml +ui: + bundle: + url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable + snapshot: true + supplemental_files: ./supplemental-ui + +content: + sources: + - url: .. + start_path: docs + branches: HEAD + edit_url: false # Edit this page 비활성화 +``` + +### 11.4 커스터마이징 항목 + +#### 헤더 (header-content.hbs) +- Products/Services/Download 메뉴 제거 +- Home 링크만 유지 +- 다크모드 토글 스위치 추가 (☀️/🌙 아이콘) + +#### 푸터 (footer-content.hbs) +- Antora 라이선스 문구 제거 +- 사이트 제목만 표시 + +#### 툴바 (toolbar.hbs) +- "Edit this Page" 링크 완전 제거 + +### 11.5 다크모드 구현 + +#### 토글 스위치 UI +- 슬라이더 형태의 토글 (50px × 26px) +- 왼쪽: ☀️ (라이트), 오른쪽: 🌙 (다크) +- 부드러운 전환 애니메이션 (0.2s) + +#### 색상 테마 (Spring 다크모드 색상 적용) + +| 요소 | 라이트 모드 | 다크 모드 | +|------|-------------|-----------| +| 배경 | 기본 (흰색) | `#1b1f23` | +| 패널/코드 | 기본 | `#262a2d` | +| 텍스트 | 기본 | `#bbbcbe` | +| 제목 | 기본 | `#cecfd1` | +| 링크 | 기본 | `#086dc3` | +| 링크 호버 | 기본 | `#107ddd` | + +#### 기능 +- localStorage에 테마 설정 저장 (`antora-theme` 키) +- 시스템 다크모드 설정 자동 감지 (`prefers-color-scheme: dark`) +- 페이지 로드 시 저장된 테마 즉시 적용 (깜빡임 방지) + +### 11.6 빌드 및 확인 + +```bash +# 빌드 +cd docs +npx antora --fetch antora-playbook.yml + +# 결과 확인 +open _site/bottle-note/index.html +``` + +### 11.7 검증 체크리스트 + +- [x] 기본 사이트 CSS 정상 로드 +- [x] 헤더: Products/Services/Download 메뉴 제거됨 +- [x] 헤더: Home 링크만 표시 +- [x] 다크모드 토글 스위치 표시 +- [x] 다크모드 전환 정상 작동 +- [x] 다크모드 색상 Spring 테마 적용 (중립 그레이) +- [x] 테마 설정 localStorage 저장/복원 +- [x] Edit this page 링크 제거됨 +- [x] 푸터 Antora 라이선스 문구 제거됨 +- [x] 좌측 사이드바 네비게이션 정상 작동 + +--- + +## 12. 참고 자료 | 자료 | URL | |------|-----| @@ -570,6 +685,13 @@ site: --- -**작성일**: 2026-02-02 -**버전**: 1.0 +**작성일**: 2026-02-03 +**버전**: 1.1 **담당자**: Development Team + +### 변경 이력 + +| 버전 | 날짜 | 내용 | +|------|------|------| +| 1.0 | 2026-02-02 | 초안 작성 | +| 1.1 | 2026-02-03 | UI 커스터마이징 완료 (섹션 11 추가) | From 361efc7017182f3e98c8ee196b6d11f6cf84cb00 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 3 Feb 2026 13:24:14 +0900 Subject: [PATCH 19/29] =?UTF-8?q?docs:=20Antora=20UI=EC=97=90=20=EB=8B=A4?= =?UTF-8?q?=ED=81=AC=EB=AA=A8=EB=93=9C=20=ED=85=8C=EB=A7=88=20=EB=B0=8F=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94/=ED=91=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../partials/footer-content.hbs | 3 + .../partials/header-content.hbs | 134 ++++++++++++++++++ docs/supplemental-ui/partials/toolbar.hbs | 15 ++ 3 files changed, 152 insertions(+) create mode 100644 docs/supplemental-ui/partials/footer-content.hbs create mode 100644 docs/supplemental-ui/partials/header-content.hbs create mode 100644 docs/supplemental-ui/partials/toolbar.hbs diff --git a/docs/supplemental-ui/partials/footer-content.hbs b/docs/supplemental-ui/partials/footer-content.hbs new file mode 100644 index 000000000..d20272f76 --- /dev/null +++ b/docs/supplemental-ui/partials/footer-content.hbs @@ -0,0 +1,3 @@ +
+

{{site.title}}

+
diff --git a/docs/supplemental-ui/partials/header-content.hbs b/docs/supplemental-ui/partials/header-content.hbs new file mode 100644 index 000000000..a3350fcc3 --- /dev/null +++ b/docs/supplemental-ui/partials/header-content.hbs @@ -0,0 +1,134 @@ + + +
+ +
+ diff --git a/docs/supplemental-ui/partials/toolbar.hbs b/docs/supplemental-ui/partials/toolbar.hbs new file mode 100644 index 000000000..a0975eac9 --- /dev/null +++ b/docs/supplemental-ui/partials/toolbar.hbs @@ -0,0 +1,15 @@ + \ No newline at end of file From 7086c8db4d826c3c62ffdc0fd811aff51fa24484 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 3 Feb 2026 13:48:47 +0900 Subject: [PATCH 20/29] =?UTF-8?q?docs:=20=ED=97=A4=EB=8D=94=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=EC=97=90=20=EC=99=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=B0=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/supplemental-ui/partials/header-content.hbs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/supplemental-ui/partials/header-content.hbs b/docs/supplemental-ui/partials/header-content.hbs index a3350fcc3..e35d67ef8 100644 --- a/docs/supplemental-ui/partials/header-content.hbs +++ b/docs/supplemental-ui/partials/header-content.hbs @@ -1,4 +1,12 @@