diff --git a/.claude/docs/ADMIN-API-GUIDE.md b/.claude/docs/ADMIN-API-GUIDE.md new file mode 100644 index 000000000..cd02d4be0 --- /dev/null +++ b/.claude/docs/ADMIN-API-GUIDE.md @@ -0,0 +1,169 @@ +# Admin API 구현 가이드 + +> Admin API 구현 시 따라야 할 규칙과 체크리스트를 정의합니다. +> 코드 예시는 기존 구현 파일을 참고하세요. + +--- + +## 아키텍처 개요 + +``` +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: 구현 (mono → admin-api 순서) + +| 순서 | 작업 | 모듈 | 위치 | +|------|------|------|------| +| 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}/{Domain}Helper.kt` (object 싱글톤) | +| 2 | Integration Test | `app/integration/{domain}/Admin{Domain}IntegrationTest.kt` | + +### Phase 3: 문서화 (선택) + +| 순서 | 작업 | 위치 | +|------|------|------| +| 1 | RestDocs Test | `app/docs/{domain}/Admin{Domain}ControllerDocsTest.kt` | +| 2 | AsciiDoc | `src/docs/asciidoc/api/{domain}/` | + +--- + +## 핵심 규칙 + +### DTO 규칙 + +| 규칙 | 설명 | +|------|------| +| **DTO-Entity 분리** | Response DTO는 Entity를 직접 참조하면 안 됨 (아키텍처 규칙 위반) | +| **팩토리 메서드** | `from(Entity)` 금지 → `of(...)` 사용, 변환 로직은 Service에서 처리 | +| **record 사용** | Java record로 작성, `@Builder` 생성자에서 기본값 설정 | +| **Validation** | `@NotBlank`, `@NotNull` 등 Bean Validation 사용 | + +### 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` 사용 | + +### 페이징 방식 + +| API 유형 | 방식 | 파라미터 | +|----------|------|----------| +| Admin (관리자) | 오프셋 페이징 | `page`, `size` | +| Product (클라이언트) | 커서 페이징 | `cursor`, `pageSize` | + +--- + +## 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` | + +--- + +## 테스트 규칙 + +### Integration Test + +| 항목 | 규칙 | +|------|------| +| 상속 | `IntegrationTestSupport` | +| 태그 | `@Tag("admin_integration")` | +| 인증 | `getAccessToken(admin)` → `Authorization: Bearer $token` | +| 검증 | `mockMvcTester.get/post/put/delete()` + AssertJ | + +### 필수 테스트 케이스 + +| API | 필수 테스트 | +|-----|-----------| +| 목록 조회 | 성공, 인증 실패, 필터링 | +| 상세 조회 | 성공, 인증 실패, 존재하지 않는 ID | +| 생성 | 성공, 인증 실패, 필수 필드 누락, 중복 검사 | +| 수정 | 성공, 인증 실패, 존재하지 않는 ID | +| 삭제 | 성공, 인증 실패, 존재하지 않는 ID | + +### RestDocs Test + +| 항목 | 규칙 | +|------|------| +| 어노테이션 | `@WebMvcTest(excludeAutoConfiguration = [SecurityAutoConfiguration::class])` | +| Mock | `@MockitoBean`으로 Service 목킹 | +| 목록 조회 Mock | `GlobalResponse.fromPage(page)` 반환 | +| 그 외 Mock | Response DTO 또는 `AdminResultResponse` 반환 | + +--- + +## 참고 구현 파일 + +| 항목 | 파일 경로 | +|------|----------| +| 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` | + +--- + +## 검증 절차 + +Admin API 구현 완료 후 아래 순서대로 검증: + +| 순서 | 검증 항목 | 명령어 | 태그/범위 | +|------|----------|--------|-----------| +| 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.*` | + +**전체 검증 (위 1-5 포함):** +```bash +./gradlew :bottlenote-admin-api:build +``` 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/.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` 참고 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/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index a497e8110..ea3b36186 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -1,11 +1,12 @@ -name: Deploy Jekyll with GitHub Pages dependencies preinstalled +name: Deploy Antora Documentation to GitHub Pages on: push: 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/**' @@ -27,6 +28,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Java 21 uses: actions/setup-java@v4 with: @@ -39,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 @@ -66,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 60934fbee..41cb2602f 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,16 @@ cosign.pub # Log files spy.log +/.omc/ +**/.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/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb6739a72..a3f462363 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,8 @@ repos: rev: v3.1.0 hooks: - id: conventional-pre-commit - stages: [commit-msg] + stages: [ + commit-msg] args: [feat, fix, docs, style, refactor, test, chore, remove] - repo: local hooks: diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index db5e8e22b..3cd22829f 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.7-2 +1.0.7-3 diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 39adcc475..624fd8d2a 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -52,6 +52,18 @@ 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[] + +''' + +== 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/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..705d8699b --- /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/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/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/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index 351dbff82..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 @@ -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.TastingTagNodeItem 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,408 @@ 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.createTastingTagNodeItems(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)").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("현재 페이지 번호"), + 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 childNode = TastingTagNodeItem.of( + 2L, "바닐라 크림", "Vanilla Cream", null, "바닐라 크림 향", null, emptyList() + ) + val tagNode = TastingTagNodeItem.of( + 1L, "바닐라", "Vanilla", "base64icon", "바닐라 향", null, listOf(childNode) ) + 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(tagNode, 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.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("위스키 한글명"), + 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("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 AlcoholsHelper.VALID_BASE64_PNG, + "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", + "icon" to AlcoholsHelper.VALID_BASE64_PNG, + "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() + ) + ) + ) + } } } 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..3b9e9a87d --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt @@ -0,0 +1,503 @@ +package app.docs.curation + +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 +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.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(), + 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/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 1ec30f963..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 @@ -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 = "테스트 위스키", @@ -104,16 +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", - 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/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/alcohols/AdminTastingTagIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt new file mode 100644 index 000000000..456bfe04b --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt @@ -0,0 +1,428 @@ +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.tag.korName").isEqualTo("허니") + } + + @Test + @DisplayName("부모 태그가 있는 경우 마트료시카 구조로 조상 정보가 포함된다") + fun getTagDetailWithParentChain() { + // given - 3depth 트리 생성 (root -> middle -> leaf) + val tree = tastingTagTestFactory.persistTastingTagTree() + val leafTag = tree[2] + + // 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.tag.parent.parent").isNotNull() + } + + @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-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-batch/VERSION b/bottlenote-batch/VERSION index 3eefcb9dd..6d7de6e6a 100644 --- a/bottlenote-batch/VERSION +++ b/bottlenote-batch/VERSION @@ -1 +1 @@ -1.0.0 +1.0.2 diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/job/report/DailyDataReportJobConfig.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/job/report/DailyDataReportJobConfig.java index 5df70b4cb..e2ea7f322 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/job/report/DailyDataReportJobConfig.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/job/report/DailyDataReportJobConfig.java @@ -45,6 +45,13 @@ public class DailyDataReportJobConfig { public Job dailyDataReportJob(JobRepository jobRepository, PlatformTransactionManager transactionManager) { log.debug("일일 데이터 리포트 Job 초기화 시작"); + String webhookUrl = discordWebhookProperties.getUrl(); + if (webhookUrl == null || webhookUrl.isBlank()) { + log.warn("[CONFIG] Discord 웹훅 URL이 설정되지 않았습니다. (WEBHOOK_DISCORD_URL 환경변수 확인 필요)"); + } else { + log.info("[CONFIG] Discord 웹훅 URL 설정 확인 완료"); + } + Step collectAndSendStep = getCollectAndSendStep(jobRepository, transactionManager); log.debug("일일 데이터 리포트 Job 초기화 완료"); diff --git a/bottlenote-batch/src/main/resources/application.yml b/bottlenote-batch/src/main/resources/application.yml index 010a8f48a..76bac60cb 100644 --- a/bottlenote-batch/src/main/resources/application.yml +++ b/bottlenote-batch/src/main/resources/application.yml @@ -66,6 +66,12 @@ management: tracing: enabled: false +schedules: + history: + view: + sync: + enable: false + logging: level: root: info 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/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/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..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,7 +1,8 @@ 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; import org.springframework.data.domain.Pageable; @@ -9,5 +10,19 @@ public interface TastingTagRepository { List findAll(); - Page findAllTastingTags(String keyword, Pageable pageable); + 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/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/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..1d4cd8c22 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java @@ -0,0 +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, + @Base64Image String icon, + String description, + Long parentId) {} 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/dto/response/AdminTastingTagDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java new file mode 100644 index 000000000..dff8f266d --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java @@ -0,0 +1,13 @@ +package app.bottlenote.alcohols.dto.response; + +import java.util.List; + +/** 테이스팅 태그 상세 조회 응답. 마트료시카 스타일 트리 구조 + 연결된 위스키 목록. */ +public record AdminTastingTagDetailResponse( + TastingTagNodeItem tag, List alcohols) { + + public static AdminTastingTagDetailResponse of( + 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 c7fa78074..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java +++ /dev/null @@ -1,12 +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, - 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/exception/AlcoholExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java index 5101c39ab..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 @@ -9,7 +9,16 @@ 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단계까지 가능합니다."), + 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/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/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/repository/JpaTastingTagRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java index f1c343aed..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,8 +2,10 @@ 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; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; @@ -17,13 +19,28 @@ public interface JpaTastingTagRepository @Override @Query( """ - select new app.bottlenote.alcohols.dto.response.AdminTastingTagItem( - t.id, t.korName, t.engName, t.icon, t.description, 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); + + @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/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/alcohols/service/TastingTagService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java index f5331dac8..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 @@ -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.TastingTagNodeItem; +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; @@ -38,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()) { @@ -52,4 +74,210 @@ 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)); + + TastingTagNodeItem parentChain = buildParentChain(tag.getParentId()); + List childrenTree = buildChildrenTree(tagId); + + 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(); + + return AdminTastingTagDetailResponse.of(tagNode, 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); + } + + /** 부모 체인을 마트료시카 구조로 빌드 (parent.parent.parent...) */ + private TastingTagNodeItem buildParentChain(Long parentId) { + if (parentId == null) { + return null; + } + + TastingTag parent = tastingTagRepository.findById(parentId).orElse(null); + if (parent == null) { + return null; + } + + 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 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) { + 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()); + } } 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..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 @@ -20,6 +20,18 @@ 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("위스키 연결이 해제되었습니다."), + CURATION_CREATED("큐레이션이 등록되었습니다."), + CURATION_UPDATED("큐레이션이 수정되었습니다."), + CURATION_DELETED("큐레이션이 삭제되었습니다."), + CURATION_STATUS_UPDATED("큐레이션 활성화 상태가 변경되었습니다."), + CURATION_DISPLAY_ORDER_UPDATED("큐레이션 노출 순서가 변경되었습니다."), + CURATION_ALCOHOL_ADDED("큐레이션에 위스키가 추가되었습니다."), + CURATION_ALCOHOL_REMOVED("큐레이션에서 위스키가 제거되었습니다."), ; private final String message; 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: redisHistories = redisViewHistoryRepository.findAll(); - List entitiesToSave = new ArrayList<>(); - List keysToDelete = new ArrayList<>(); - // 2. Redis 데이터를 DB 엔티티로 변환 + // 동일 (userId, alcoholId) 쌍에서 최신 viewTime만 유지 + Map latestEntries = new HashMap<>(); + redisHistories.forEach( redisHistory -> { if (redisHistory == null) { log.debug("TTL expired data found in index, skipping..."); return; } + var viewTime = LocalDateTime.ofInstant( Instant.ofEpochMilli(redisHistory.getViewTime()), ZoneId.systemDefault()); - var historyEntity = - AlcoholsViewHistory.of( - redisHistory.getUserId(), redisHistory.getAlcoholId(), viewTime); - entitiesToSave.add(historyEntity); - // 처리된 항목의 ID를 삭제 목록에 추가 - keysToDelete.add(redisHistory.getId()); + var compositeKey = + AlcoholsViewHistoryId.of(redisHistory.getUserId(), redisHistory.getAlcoholId()); + + latestEntries.merge( + compositeKey, + new RedisEntry(viewTime, redisHistory.getId()), + (existing, incoming) -> + incoming.viewTime.isAfter(existing.viewTime) ? incoming : existing); }); - // 3. DB에 저장 - if (!entitiesToSave.isEmpty()) { - historyRepository.saveAll(entitiesToSave); - // 4. 처리 완료된 Redis 데이터 삭제 - if (!keysToDelete.isEmpty()) { - redisViewHistoryRepository.deleteAllById(keysToDelete); - log.info("Redis에서 {}개 처리된 조회 기록 삭제 완료", keysToDelete.size()); + List successKeys = new ArrayList<>(); + + // 개별 처리로 에러 격리 + for (var entry : latestEntries.entrySet()) { + var compositeKey = entry.getKey(); + var redisEntry = entry.getValue(); + + try { + historyRepository + .findById(compositeKey) + .ifPresentOrElse( + existing -> existing.updateViewAt(redisEntry.viewTime), + () -> + historyRepository.save( + AlcoholsViewHistory.of( + compositeKey.getUserId(), + compositeKey.getAlcoholId(), + redisEntry.viewTime))); + successKeys.add(redisEntry.redisId); + } catch (Exception e) { + log.error( + "조회 기록 동기화 실패: userId={}, alcoholId={}, error={}", + compositeKey.getUserId(), + compositeKey.getAlcoholId(), + e.getMessage()); } } + + // 성공한 항목만 Redis에서 삭제 + if (!successKeys.isEmpty()) { + redisViewHistoryRepository.deleteAllById(successKeys); + log.info("Redis에서 {}개 처리된 조회 기록 삭제 완료", successKeys.size()); + } } + private record RedisEntry(LocalDateTime viewTime, UUID redisId) {} + @Transactional(readOnly = true) public CollectionResponse getViewHistory(Long id) { Pageable pageable = Pageable.ofSize(6); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java index 4c7b37cff..fb4f0e369 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/CustomReviewRepositoryImpl.java @@ -288,7 +288,7 @@ public Pair> getStandardExplore( user.imageUrl, alcohol.id, alcohol.korName) - .orderBy(review.lastModifyAt.desc()) + .orderBy(review.createAt.desc()) .offset(cursor) .limit(fetchSize) .fetch(); 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..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,10 +2,11 @@ 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; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -21,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 -> @@ -30,37 +31,67 @@ 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::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()); } + @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 TastingTagNodeItem toTastingTagNodeItem(TastingTag tag) { + return TastingTagNodeItem.forList( + tag.getId(), tag.getKorName(), tag.getEngName(), tag.getIcon(), tag.getDescription()); + } } 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/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index cf8f9136f..ee90284c2 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.3-1 +1.0.4 diff --git a/build.gradle b/build.gradle index 276153de1..de9d1fa9f 100644 --- a/build.gradle +++ b/build.gradle @@ -141,6 +141,7 @@ subprojects { useJUnitPlatform() filter { includeTestsMatching 'app.docs.*' + includeTestsMatching 'app.external.docs.*' } } } 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/antora-playbook.yml b/docs/antora-playbook.yml new file mode 100644 index 000000000..3ae139f17 --- /dev/null +++ b/docs/antora-playbook.yml @@ -0,0 +1,23 @@ +site: + title: Bottle Note API Documentation + start_page: bottle-note::index.adoc + +content: + sources: + - 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: + 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/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/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 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..e35d67ef8 --- /dev/null +++ b/docs/supplemental-ui/partials/header-content.hbs @@ -0,0 +1,142 @@ + + +
+ +
+ 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 diff --git a/git.environment-variables b/git.environment-variables index b9910dc0e..ad7d23c90 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit b9910dc0e8fed666e5f4a4a7bec41fb1c3439fd8 +Subproject commit ad7d23c9051f40cffea32f6a404adc639e095e3a 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] +} diff --git a/docs/plans/alcohol-search-performance-improvement.md b/plan/alcohol-search-performance-improvement.md similarity index 100% rename from docs/plans/alcohol-search-performance-improvement.md rename to plan/alcohol-search-performance-improvement.md diff --git a/plan/complete/antora-documentation-migration.md b/plan/complete/antora-documentation-migration.md new file mode 100644 index 000000000..4b2d38253 --- /dev/null +++ b/plan/complete/antora-documentation-migration.md @@ -0,0 +1,721 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-02-04 + +** Core Achievements ** +- Antora 기반 문서 시스템 전환 완료 (Jekyll → Antora) +- 다크모드 토글 기능 구현 (localStorage 테마 저장) +- Product API / Admin API 문서 통합 네비게이션 구축 +- GitHub Pages 배포 정상 작동 확인 + +** Key Components ** +- docs/antora-playbook.yml: Antora 빌드 설정 +- docs/antora.yml: 컴포넌트 버전 설명자 +- docs/supplemental-ui/partials/: 커스텀 헤더/푸터/툴바 +- .github/workflows/github-pages.yml: CI/CD 워크플로우 + +** Live Site ** +- https://bottle-note.github.io/bottle-note-api-server/bottle-note/index.html +================================================================================ +``` + +# Antora 기반 API 문서 시스템 마이그레이션 계획 + +--- + +## 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: 구조 생성 ✅ 완료 +- [x] `docs/antora.yml` 생성 +- [x] `docs/antora-playbook.yml` 생성 +- [x] `docs/modules/ROOT/` 디렉토리 생성 +- [x] `docs/modules/product-api/` 디렉토리 생성 +- [x] `docs/modules/admin-api/` 디렉토리 생성 +- [x] 각 모듈 `nav.adoc` 작성 + +### Phase 2: 문서 이동 ✅ 완료 +- [x] Product API ADOC 파일 복사 +- [x] Admin API ADOC 파일 복사 +- [x] Include 경로 수정 (`{snippets}` → `example$generated-snippets`) +- [x] `tasting-tags.adoc:23` 오타 수정 ("룰" 제거) + +### Phase 3: 빌드 설정 ✅ 완료 +- [x] `.github/workflows/github-pages.yml` 수정 +- [x] 로컬 Antora 빌드 테스트 +- [x] GitHub Actions 테스트 + +### Phase 4: 검증 ✅ 완료 +- [x] 모든 페이지 정상 렌더링 확인 +- [x] 모든 include 스니펫 정상 로드 확인 +- [x] 네비게이션 동작 확인 +- [x] 검색 기능 확인 (추가 설정 필요 시) + +### 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. 롤백 계획 + +### 문제 발생 시 + +```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. 파일 관리 전략: Git에 포함할 파일 vs CI에서 생성할 파일 + +### 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 공식 문서에서 사용하는 확장 기능: + +```bash +npm install @springio/antora-extensions +``` + +**기능**: +- Partial Build (단일 버전만 빌드) +- Latest Version 매핑 +- Tabs 마이그레이션 + +### 10.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 +``` + +### 10.3 Algolia 검색 통합 (선택) + +Antora에 Algolia DocSearch 통합 가능: + +```yaml +site: + keys: + algolia-api-key: 'YOUR_API_KEY' + algolia-index-name: 'bottle-note-docs' +``` + +--- + +## 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 | +|------|-----| +| 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-03 +**버전**: 1.1 +**담당자**: Development Team + +### 변경 이력 + +| 버전 | 날짜 | 내용 | +|------|------|------| +| 1.0 | 2026-02-02 | 초안 작성 | +| 1.1 | 2026-02-03 | UI 커스터마이징 완료 (섹션 11 추가) | diff --git a/plan/sql-to-code-migration.md b/plan/complete/sql-to-code-migration.md similarity index 97% rename from plan/sql-to-code-migration.md rename to plan/complete/sql-to-code-migration.md index 564330ff0..0decba88c 100644 --- a/plan/sql-to-code-migration.md +++ b/plan/complete/sql-to-code-migration.md @@ -1,3 +1,30 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-02-04 + +** Core Achievements ** +- @Sql 어노테이션 45개 → 0개 완전 제거 +- init-script/ 폴더 전체 삭제 +- 9개 통합 테스트 파일을 TestFactory 기반으로 전환 +- 테스트 가독성 및 유지보수성 대폭 향상 + +** Key Components ** +- UserTestFactory: 사용자 데이터 생성 (persistUser, persistStandardUsers) +- AlcoholTestFactory: 주류 데이터 생성 (persistAlcohol, persistLightweightAlcohols) +- ReviewTestFactory: 리뷰 데이터 생성 +- HelpTestFactory, ReportTestFactory 등 도메인별 TestFactory + +** Lessons Learned ** +- EntityManager는 @Autowired 필드 주입 사용 +- @Nested 클래스는 extends 없이 선언 +- getToken(user) 사용으로 토큰 생성 (OauthRequest 사용 금지) +- Factory 메서드 사용 전 내부 구현 확인 필수 +================================================================================ +``` + # @Sql 어노테이션을 코드 베이스 방식으로 마이그레이션 계획 ## 목표 diff --git a/plan/history-refactoring.md b/plan/history-refactoring.md new file mode 100644 index 000000000..ffb2d361a --- /dev/null +++ b/plan/history-refactoring.md @@ -0,0 +1,640 @@ +# UserHistory 테이블 리팩토링 가이드 + +## 1. 현재 아키텍처 + +### 3단계 흐름: 발행 → 수집 → 저장 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 1. 발행 (Publish) │ +│ 도메인 서비스에서 직접 호출 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ReviewService → reviewEventPublisher.publishReviewHistoryEvent() │ +│ ReviewReplyService → reviewReplyEventPublisher.publishReplyHistoryEvent()│ +│ LikesCommandService → likesEventPublisher.publishLikesHistoryEvent() │ +│ PicksCommandService → picksEventPublisher.publishPicksHistoryEvent() │ +│ RatingCommandService → ratingEventPublisher.publishRatingHistoryEvent() │ +│ │ +│ * 각 도메인별 이벤트 페이로드 사용 │ +│ - ReviewRegistryEvent, LikesRegistryEvent, PicksRegistryEvent 등 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 2. 수집 (Collect) │ +│ HistoryEventPublisher │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 역할: 도메인 이벤트 → HistoryEvent 변환 │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ 도메인 이벤트 → HistoryEvent │ │ +│ ├─────────────────────────────────────────────────────────────────────┤ │ +│ │ ReviewRegistryEvent → eventType: REVIEW_CREATE │ │ +│ │ eventCategory: REVIEW │ │ +│ │ redirectUrl: /review/{reviewId} │ │ +│ │ alcoholId: event.alcoholId() │ │ +│ │ content: event.content() │ │ +│ ├─────────────────────────────────────────────────────────────────────┤ │ +│ │ RatingRegistryEvent → eventType: START_RATING / MODIFY / DELETE │ │ +│ │ dynamicMessage: {currentValue, prevValue} │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ * 추가 정보 조회 │ +│ - ReviewFacade.getAlcoholIdByReviewId() (좋아요 시) │ +│ │ +│ * ApplicationEventPublisher.publishEvent(HistoryEvent) 호출 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 3. 저장 (Store) │ +│ HistoryListener │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ @Async ← 비동기 처리 │ +│ @Transactional(REQUIRES_NEW) ← 별도 트랜잭션 (원본과 분리) │ +│ @TransactionalEventListener ← 원본 트랜잭션 커밋 후 실행 │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ 1. 이미지 URL 조회 │ │ +│ │ alcoholFacade.findAlcoholImageUrlById(alcoholId) │ │ +│ │ │ │ +│ │ 2. UserHistory 엔티티 빌드 │ │ +│ │ - userId, alcoholId, eventCategory, eventType │ │ +│ │ - redirectUrl, imageUrl, content, dynamicMessage │ │ +│ │ - eventYear, eventMonth (현재 시점) │ │ +│ │ │ │ +│ │ 3. DB 저장 │ │ +│ │ userHistoryRepository.save() │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ * 실패해도 원본 트랜잭션에 영향 없음 │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 단계별 요약 + +| 단계 | 역할 | 주체 | 특징 | +|-----------|------------|-----------------------|---------------------------| +| **1. 발행** | 도메인 이벤트 발생 | 각 도메인 서비스 | 동기 호출, 도메인별 이벤트 페이로드 | +| **2. 수집** | 이벤트 변환/정규화 | HistoryEventPublisher | 도메인 이벤트 → HistoryEvent 변환 | +| **3. 저장** | DB 저장 | HistoryListener | 비동기, 별도 트랜잭션, 실패 격리 | + +--- + +## 2. 설계 철학 + +### 추상 이벤트 로그 테이블 + +UserHistory는 **특정 도메인에 종속되지 않는 추상 이벤트 로그 테이블**이다. + +- 사용자의 모든 활동 히스토리를 관장 +- 특정 엔티티(리뷰, 주류 등)에 FK로 종속되지 않음 +- **조합식**으로 의미를 결정하는 유연한 구조 + +### 조합식 활용 + +``` +event_type + event_resource_id = 이벤트 의미 +───────────────────────────────────────────── +REVIEW_CREATE + 64 → 64번 리뷰 작성 +REVIEW_LIKES + 64 → 64번 리뷰 좋아요 +REVIEW_REPLY_CREATE + 123 → 123번 댓글 작성 +IS_PICK + 6332 → 6332번 주류 찜 +START_RATING + 6332 → 6332번 주류 별점 + +# 미래 확장 예시 +FOLLOW_USER + 42 → 42번 유저 팔로우 +BADGE_EARNED + 7 → 7번 뱃지 획득 +``` + +### 락인 속성은 dynamic_message에서 제어 + +고정 컬럼은 최소화하고, **이벤트별 특수 속성은 `dynamic_message` (JSON)** 에서 관리한다. + +```json +// 별점 이벤트 - 변경 이력 저장 +{ + "currentValue": "5.0", + "prevValue": "4.5", + "ratingDiff": "0.5" +} + +// 미래 확장 - 뱃지 획득 이벤트 +{ + "badgeName": "리뷰왕", + "badgeLevel": "gold" +} + +// 미래 확장 - 팔로우 이벤트 +{ + "targetUserNickname": "위스키러버" +} +``` + +### 컬럼 역할 정리 + +| 컬럼 | 역할 | 락인 여부 | +|---------------------|-----------------------|--------| +| `event_type` | 이벤트 종류 (enum) | ✅ 고정 | +| `event_category` | 이벤트 카테고리 (enum) | ✅ 고정 | +| `event_resource_id` | 이벤트 대상 리소스 ID | ✅ 고정 | +| `alcohol_id` | 주류 ID (nullable, 필터용) | ⚠️ 선택적 | +| `dynamic_message` | 이벤트별 특수 속성 (JSON) | ❌ 유연 | +| `content` | 텍스트 내용 (리뷰 본문 등) | ❌ 유연 | +| `redirect_url` | 클릭 시 이동 경로 | ❌ 유연 | + +### 확장성 + +새로운 이벤트 추가 시: + +1. `EventType` enum에 추가 +2. 이벤트 발행 코드 작성 +3. **스키마 변경 없음** + +```java +// 1. EventType에 추가 +FOLLOW_USER(EventCategory.SOCIAL, "팔로우") + +// 2. 저장 +HistoryEvent. + +builder() + . + +eventType(FOLLOW_USER) + . + +eventResourceId(targetUserId) // 팔로우 대상 유저 ID + . + +alcoholId(null) // 주류 무관 + . + +dynamicMessage(Map.of("targetUserNickname", "위스키러버")) + . + +build(); +``` + +--- + +## 3. 변경 목적 + +- `alcohol_id` → `event_resource_id`로 이름 변경 +- 이벤트 대상 리소스 ID를 명확히 저장 (reviewId, replyId, alcoholId 등) +- 기존 주류 필터/검색용 `alcohol_id` 컬럼 별도 유지 (nullable) + +--- + +## 4. 스키마 변경 + +### 변경 후 컬럼 구조 + +| 컬럼 | 타입 | 설명 | +|---------------------|-------------------|----------------| +| `event_resource_id` | BIGINT | 이벤트 발생 리소스 ID | +| `alcohol_id` | BIGINT (nullable) | 주류 ID (검색/필터용) | + +### DDL + +```sql +-- 1) 기존 alcohol_id 백업 +ALTER TABLE user_histories + ADD COLUMN temp_alcohol_id BIGINT; +UPDATE user_histories +SET temp_alcohol_id = alcohol_id; + +-- 2) alcohol_id → event_resource_id 이름 변경 +ALTER TABLE user_histories + CHANGE COLUMN alcohol_id event_resource_id BIGINT +COMMENT +'이벤트 발생 리소스 ID'; + +-- 3) alcohol_id 컬럼 추가 (nullable) +ALTER TABLE user_histories + ADD COLUMN alcohol_id BIGINT NULL COMMENT '주류 ID' AFTER event_resource_id; + +-- 4) alcohol_id 복원 +UPDATE user_histories +SET alcohol_id = temp_alcohol_id; + +-- 5) REVIEW_CREATE, REVIEW_LIKES, BEST_REVIEW_SELECTED만 redirect_url에서 reviewId 추출 +UPDATE user_histories +SET event_resource_id = CAST(SUBSTRING_INDEX(redirect_url, '/', -1) AS UNSIGNED) +WHERE event_type IN ('REVIEW_CREATE', 'REVIEW_LIKES', 'BEST_REVIEW_SELECTED'); + +-- 6) 임시 컬럼 삭제 +ALTER TABLE user_histories + DROP COLUMN temp_alcohol_id; + +-- 7) 인덱스 추가 (선택) +CREATE INDEX idx_user_histories_event_resource ON user_histories (event_type, event_resource_id); +``` + +--- + +## 5. 마이그레이션 현황 + +| 이벤트 | 현재 저장값 | redirect_url | 마이그레이션 | 비고 | +|------------------------|-----------|---------------------------|--------|---------------------| +| `REVIEW_CREATE` | alcoholId | `/review/{reviewId}` | ✅ 가능 | reviewId 추출 | +| `REVIEW_LIKES` | alcoholId | `/review/{reviewId}` | ✅ 가능 | reviewId 추출 | +| `BEST_REVIEW_SELECTED` | alcoholId | `/review/{reviewId}` | ✅ 가능 | reviewId 추출 | +| `REVIEW_REPLY_CREATE` | alcoholId | `/review/{reviewId}` | ❌ 불가 | replyId 없음, 신규부터 적용 | +| `IS_PICK` / `UNPICK` | alcoholId | `/search/all/{alcoholId}` | 변경 없음 | | +| `START_RATING` 등 | alcoholId | `/search/all/{alcoholId}` | 변경 없음 | | + +--- + +## 6. 이벤트별 저장 값 (신규) + +| 이벤트 | event_resource_id | alcohol_id | dynamic_message | +|------------------------|-------------------|------------|-----------------------------------------| +| `REVIEW_CREATE` | reviewId | alcoholId | - | +| `REVIEW_LIKES` | reviewId | alcoholId | - | +| `REVIEW_REPLY_CREATE` | replyId | alcoholId | - | +| `BEST_REVIEW_SELECTED` | reviewId | alcoholId | - | +| `IS_PICK` / `UNPICK` | alcoholId | alcoholId | - | +| `START_RATING` | alcoholId | alcoholId | `{currentValue}` | +| `RATING_MODIFY` | alcoholId | alcoholId | `{currentValue, prevValue, ratingDiff}` | +| `RATING_DELETE` | alcoholId | alcoholId | `{currentValue}` | + +--- + +## 7. 코드 변경 + +### 7.1 엔티티 (UserHistory.java) + +```java +// 변경 전 +@Comment("알콜 ID") +@Column(name = "alcohol_id") +private Long alcoholId; + +// 변경 후 +@Comment("이벤트 발생 리소스 ID") +@Column(name = "event_resource_id") +private Long eventResourceId; + +@Comment("주류 ID") +@Column(name = "alcohol_id") +private Long alcoholId; +``` + +### 7.2 이벤트 페이로드 (HistoryEvent.java) + +```java + +@Builder +public record HistoryEvent( + Long userId, + EventCategory eventCategory, + EventType eventType, + String redirectUrl, + Long eventResourceId, // 추가 + Long alcoholId, // nullable + String content, + Map dynamicMessage) { + + public HistoryEvent { + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(eventResourceId, "eventResourceId must not be null"); + // alcoholId는 nullable (주류 무관 이벤트 허용) + } +} +``` + +### 7.3 이벤트 발행자 (HistoryEventPublisher.java) + +```java +// 리뷰 생성 +public void publishReviewHistoryEvent(ReviewRegistryEvent event) { + final Long reviewId = event.reviewId(); + + HistoryEvent historyEvent = + HistoryEvent.builder() + .userId(event.userId()) + .eventCategory(REVIEW) + .eventType(REVIEW_CREATE) + .redirectUrl(RedirectUrlType.REVIEW.getUrl() + "/" + reviewId) + .eventResourceId(reviewId) // reviewId + .alcoholId(event.alcoholId()) + .content(event.content()) + .build(); + eventPublisher.publishEvent(historyEvent); +} + +// 댓글 생성 (이벤트 타입 변경 필요) +public void publishReplyHistoryEvent(ReviewReplyRegistryEvent event) { + final Long replyId = event.replyId(); + final Long reviewId = event.reviewId(); + + HistoryEvent historyEvent = + HistoryEvent.builder() + .userId(event.userId()) + .eventCategory(REVIEW) + .eventType(REVIEW_REPLY_CREATE) + .redirectUrl(RedirectUrlType.REVIEW.getUrl() + "/" + reviewId) + .eventResourceId(replyId) // replyId + .alcoholId(event.alcoholId()) + .content(event.content()) + .build(); + eventPublisher.publishEvent(historyEvent); +} + +// 좋아요 +public void publishLikesHistoryEvent(LikesRegistryEvent event) { + final Long alcoholId = reviewFacade.getAlcoholIdByReviewId(event.reviewId()); + final Long reviewId = event.reviewId(); + + HistoryEvent historyEvent = + HistoryEvent.builder() + .userId(event.userId()) + .eventCategory(REVIEW) + .eventType(REVIEW_LIKES) + .redirectUrl(RedirectUrlType.REVIEW.getUrl() + "/" + reviewId) + .eventResourceId(reviewId) // reviewId + .alcoholId(alcoholId) + .content(event.content()) + .build(); + eventPublisher.publishEvent(historyEvent); +} + +// 찜하기 +public void publishPicksHistoryEvent(PicksRegistryEvent event) { + final Long alcoholId = event.alcoholId(); + + HistoryEvent historyEvent = + HistoryEvent.builder() + .userId(event.userId()) + .eventCategory(EventCategory.PICK) + .eventType(event.picksStatus() == PICK ? IS_PICK : UNPICK) + .redirectUrl(RedirectUrlType.ALCOHOL.getUrl() + "/" + alcoholId) + .eventResourceId(alcoholId) // alcoholId + .alcoholId(alcoholId) + .build(); + eventPublisher.publishEvent(historyEvent); +} + +// 별점 +public void publishRatingHistoryEvent(RatingRegistryEvent event) { + final Long alcoholId = event.alcoholId(); + final boolean isUpdate = !Objects.isNull(event.prevRating()); + double prevRatingPoint = 0.0; + + if (isUpdate) { + prevRatingPoint = event.prevRating().getRating(); + } + Double currentRatingPoint = event.currentRating().getRating(); + + HistoryEvent historyEvent = + HistoryEvent.builder() + .userId(event.userId()) + .eventCategory(RATING) + .eventType(makeEventType(isUpdate, currentRatingPoint)) + .redirectUrl(RedirectUrlType.ALCOHOL.getUrl() + "/" + alcoholId) + .eventResourceId(alcoholId) // alcoholId + .alcoholId(alcoholId) + .dynamicMessage( + isUpdate + ? makeDynamicMessage(currentRatingPoint, prevRatingPoint) + : Map.of("currentValue", currentRatingPoint.toString())) + .build(); + eventPublisher.publishEvent(historyEvent); +} +``` + +### 7.4 댓글 이벤트 추가 (ReviewReplyRegistryEvent.java) + +```java +package app.bottlenote.review.event.payload; + +public record ReviewReplyRegistryEvent( + Long replyId, + Long reviewId, + Long alcoholId, + Long userId, + String content) { + + public static ReviewReplyRegistryEvent of( + Long replyId, Long reviewId, Long alcoholId, Long userId, String content) { + return new ReviewReplyRegistryEvent(replyId, reviewId, alcoholId, userId, content); + } +} +``` + +### 7.5 ReviewReplyService.java 수정 + +```java +// 변경 전 +ReviewRegistryEvent event = + ReviewRegistryEvent.of(reply.getReviewId(), alcoholId, reply.getUserId(), reply.getContent()); +reviewReplyEventPublisher. + +publishReplyHistoryEvent(event); + +// 변경 후 +ReviewReplyRegistryEvent event = + ReviewReplyRegistryEvent.of( + reply.getId(), // replyId 추가 + reply.getReviewId(), + alcoholId, + reply.getUserId(), + reply.getContent()); +reviewReplyEventPublisher. + +publishReplyHistoryEvent(event); +``` + +### 7.6 리스너 (HistoryListener.java) + +```java + +@Async +@Transactional(propagation = Propagation.REQUIRES_NEW) +@TransactionalEventListener +public void handleUserHistoryRegistry(HistoryEvent event) { + String alcoholImageUrl = event.alcoholId() != null + ? alcoholFacade.findAlcoholImageUrlById(event.alcoholId()).orElse(null) + : null; + + UserHistory save = + userHistoryRepository.save( + UserHistory.builder() + .userId(event.userId()) + .eventResourceId(event.eventResourceId()) // 변경 + .alcoholId(event.alcoholId()) // nullable + .eventCategory(event.eventCategory()) + .eventType(event.eventType()) + .redirectUrl(event.redirectUrl()) + .imageUrl(alcoholImageUrl) + .content(event.content()) + .dynamicMessage(event.dynamicMessage()) + .eventYear(String.valueOf(LocalDateTime.now().getYear())) + .eventMonth(String.valueOf(LocalDateTime.now().getMonth())) + .build()); + + log.debug("History saved: {}", save); +} +``` + +--- + +## 8. 롤백 DDL + +```sql +ALTER TABLE user_histories + DROP INDEX idx_user_histories_event_resource, +DROP +COLUMN alcohol_id, + CHANGE COLUMN event_resource_id alcohol_id BIGINT COMMENT +'알콜 ID'; +``` + +--- + +## 9. 주의사항 + +1. **REVIEW_REPLY_CREATE 마이그레이션 불가** + - 기존 데이터는 `event_resource_id`에 alcoholId 유지 + - 신규 데이터부터 replyId 저장 + +2. **마이그레이션 순서 중요** + - 반드시 `temp_alcohol_id`로 백업 후 DDL 실행 + - 순서 틀리면 alcohol_id 데이터 유실 + +3. **alcohol_id nullable** + - 주류 무관 이벤트(팔로우, 뱃지 등) 확장 대비 + - 기존 조회 쿼리에서 NULL 체크 필요할 수 있음 + +--- + +## 10. 페이로드 네이밍 변경 + +### 배경 + +현재 도메인별 페이로드가 `*RegistryEvent`로 명명되어 있어 "진짜 이벤트"인 `HistoryEvent`와 혼동됨. +실제로는 **Publisher에게 전달하는 데이터 묶음(Payload)** 이므로 이름 변경. + +### 변경 내용 + +| 현재 | 변경 후 | 위치 | +|---------------------------|---------------------------|----------------------------------| +| `ReviewRegistryEvent` | `ReviewHistoryPayload` | `review.event.payload` | +| `ReviewReplyRegistryEvent`| `ReviewReplyHistoryPayload`| `review.event.payload` | +| `LikesRegistryEvent` | `LikesHistoryPayload` | `like.event.payload` | +| `PicksRegistryEvent` | `PicksHistoryPayload` | `picks.event.payload` | +| `RatingRegistryEvent` | `RatingHistoryPayload` | `rating.event.payload` | + +### 구조 명확화 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ [도메인 서비스] │ +│ ↓ *HistoryPayload (데이터 묶음) │ +│ [HistoryEventPublisher] - 정제/변환 │ +│ ↓ HistoryEvent (진짜 이벤트) │ +│ [HistoryListener / MQ] │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 아키텍처 룰 체크 수정 + +`*RegistryEvent` → `*HistoryPayload` 변경에 따라 아키텍처 테스트 규칙 수정 필요. + +```java +// 기존 룰 (수정 필요) +.should().haveSimpleNameEndingWith("Event") + +// 변경 후 +.should().haveSimpleNameEndingWith("Payload") // History 페이로드 +.should().haveSimpleNameEndingWith("Event") // 진짜 이벤트 (HistoryEvent) +``` + +--- + +## 11. EventCategory/EventType 확장 계획 + +### 신규 EventCategory + +| 카테고리 | 용도 | alcohol_id | +|--------------|------------------|------------| +| `REVIEW` | 리뷰, 댓글, 좋아요 | 필수 | +| `PICK` | 찜하기 | 필수 | +| `RATING` | 별점 | 필수 | +| `SOCIAL` | 팔로우, 차단 (신규) | null | +| `REPORT` | 신고 (신규) | nullable | +| `PROFILE` | 프로필 변경 (신규) | null | +| `ACHIEVEMENT`| 뱃지, 티어 (신규) | null | + +### 신규 EventType (우선순위: 팔로우) + +| 이벤트 | 카테고리 | event_resource_id | alcohol_id | dynamic_message | +|-----------------|----------|-------------------|------------|------------------------------| +| `FOLLOW_USER` | SOCIAL | targetUserId | null | `{targetNickname, followerCount}` | +| `UNFOLLOW_USER` | SOCIAL | targetUserId | null | - | + +### 팔로우 이벤트 예시 + +```java +// FollowService.java +private FollowHistoryPayload buildFollowPayload(Long userId, Long targetUserId, String targetNickname) { + return new FollowHistoryPayload(userId, targetUserId, targetNickname); +} + +// HistoryEventPublisher.java +public void publishFollowHistoryEvent(FollowHistoryPayload payload) { + HistoryEvent historyEvent = HistoryEvent.builder() + .userId(payload.userId()) + .eventCategory(EventCategory.SOCIAL) + .eventType(EventType.FOLLOW_USER) + .eventResourceId(payload.targetUserId()) + .alcoholId(null) + .redirectUrl("/user/" + payload.targetUserId() + "/profile") + .dynamicMessage(Map.of("targetNickname", payload.targetNickname())) + .build(); + eventPublisher.publishEvent(historyEvent); +} +``` + +--- + +## 12. MQ 전환 대비 + +### 현재 → 미래 전환 + +``` +현재: ApplicationEventPublisher → HistoryListener (로컬) +미래: ApplicationEventPublisher → RabbitMQ/NATS → 외부 서비스 +``` + +### Publisher 확장 포인트 + +```java +@Component +public class HistoryEventPublisher { + + private final ApplicationEventPublisher springPublisher; + // private final RabbitTemplate rabbitTemplate; // 미래 추가 + // private final NatsConnection natsConnection; // 미래 추가 + + public void publish(HistoryEvent event) { + // 로컬 리스너 + springPublisher.publishEvent(event); + + // MQ 발행 (미래) + // rabbitTemplate.convertAndSend("history.exchange", "history.event", event); + } +} +``` + +### 직렬화 대상 + +- `HistoryEvent` 하나만 직렬화 스키마 관리 +- `*HistoryPayload`는 내부용이므로 직렬화 불필요