diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 00000000..a8a8ecb8 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,64 @@ +name: Java CI/CD with Gradle (Dev Server) + +on: + push: + branches: [ "dev" ] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + + - name: Build with Gradle + run: | + chmod +x ./gradlew + ./gradlew build -x test + + # // 1. JAR 파일만 전송 준비 (환경변수는 서버에서 직접 생성하는게 더 깔끔해) + - name: Prepare deployment files + run: | + mkdir -p deploy + cp build/libs/*-SNAPSHOT.jar deploy/ + + # // 2. JAR 파일 EC2로 전송 + - name: Copy files to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "deploy/*" + target: "~/dev-server" + strip_components: 1 + + # // 3. EC2 서버에서 실행 스크립트 + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # 1. 기존 8081 프로세스 종료 + fuser -k 8081/tcp || true + + cd ~/dev-server + + # 2. .env 파일 생성 + cat <<'EOF' > .env + ${{ secrets.ENV_VARIABLES }} + EOF + + # 3. 환경 변수 로드 및 메모리 제한 걸어서 실행 + # // 1. set -a로 .env 로드, -Xmx256m으로 메모리 방어 + set -a; source .env; set +a + nohup java -Xmx256m -Dserver.port=8081 -jar *-SNAPSHOT.jar > dev-app.log 2>&1 & \ No newline at end of file diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml new file mode 100644 index 00000000..ea37df10 --- /dev/null +++ b/.github/workflows/deploy-main.yml @@ -0,0 +1,66 @@ +name: Java CI/CD with Gradle (Main Server) + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + + - name: Build with Gradle + run: | + chmod +x ./gradlew + ./gradlew build -x test + + # // 1. 전송용 폴더에 JAR 파일만 준비 (환경변수는 보안상 서버에서 직접 생성) + - name: Prepare deployment files + run: | + mkdir -p deploy + cp build/libs/*-SNAPSHOT.jar deploy/ + + # // 2. 메인 서버 폴더로 JAR 전송 + - name: Copy files to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "deploy/*" + target: "~/main-server" + strip_components: 1 + + # // 3. 운영 서버 실행 스크립트 + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # 1. 기존 8080 프로세스 종료 + fuser -k 8080/tcp || true + + # 2. 운영 서버 폴더 이동 + cd ~/main-server + + # 3. .env 파일 생성 (운영 전용 Secrets 사용) + # // 1. EOF를 써서 특수문자 깨짐 없이 안전하게 저장 + cat <<'EOF' > .env + ${{ secrets.ENV_VARIABLES }} + EOF + + # 4. 환경 변수 로드 및 운영 서버 실행 + # // 2. 운영은 400MB 제한으로 안정성 확보 + set -a; source .env; set +a + nohup java -Xmx400m -Dserver.port=8080 -jar *-SNAPSHOT.jar > server.log 2>&1 & \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 0f809702..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Java CI/CD with Gradle - -on: - push: - branches: [ "dev" ] # dev 브랜치에 푸시할 때 작동 - workflow_dispatch: # - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' # 캐싱 추가: 빌드 속도가 훨씬 빨라집니다. - - - name: Build with Gradle - run: | - chmod +x ./gradlew - ./gradlew build -x test - - - name: Create .env file from Secret - run: | - cat <<'EOF' > .env - ${{ secrets.ENV_VARIABLES }} - EOF - - - name: Copy JAR and .env to EC2 - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - # -plain.jar는 배포에 필요 없으므로 제외합니다. - source: "build/libs/*-SNAPSHOT.jar, .env" - target: "~/" - strip_components: 2 - - - name: Deploy to EC2 - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - script: | - fuser -k 8080/tcp || true - - chmod +x ~/start.sh - ~/start.sh \ No newline at end of file diff --git a/docs/api-specs/api-path-changes.md b/docs/api-specs/api-path-changes.md new file mode 100644 index 00000000..7884e6d3 --- /dev/null +++ b/docs/api-specs/api-path-changes.md @@ -0,0 +1,52 @@ +# API 경로 변경/추가 요약 (프론트 전달용) + +아래는 **사용자용 API 기준**으로, 기존 대비 바뀐 경로와 새로 분리/추가된 경로를 정리한 문서입니다. +관리자(`admin`) 경로는 제외했습니다. + +## 1. 변경된 경로 (기존 → 현재) + +### 1.1 콘텐츠 조회 + +| 기존(통합 Battle 타입 분기) | 현재(도메인 분리) | +|---|---| +| `GET /api/v1/battles?type=QUIZ` | `GET /api/v1/quizzes` | +| `GET /api/v1/battles/{battleId}` (QUIZ 상세) | `GET /api/v1/quizzes/{quizId}` | +| `GET /api/v1/battles?type=POLL` | `GET /api/v1/polls` | +| `GET /api/v1/battles/{battleId}` (POLL 상세) | `GET /api/v1/polls/{pollId}` | + +### 1.2 투표 제출/조회 + +| 기존(통합 투표 처리) | 현재(도메인별 투표) | +|---|---| +| `POST /api/v1/battles/{battleId}/votes/...` (퀴즈 선택 제출에 재사용) | `POST /api/v1/battles/{battleId}/quiz-vote` | +| `GET /api/v1/battles/{battleId}/votes/me` (퀴즈 결과 확인에 재사용) | `GET /api/v1/battles/{battleId}/quiz-vote/me` | +| `POST /api/v1/battles/{battleId}/votes/...` (Poll 선택 제출에 재사용) | `POST /api/v1/battles/{battleId}/poll-vote` | +| `GET /api/v1/battles/{battleId}/votes/me` (Poll 결과 확인에 재사용) | `GET /api/v1/battles/{battleId}/poll-vote/me` | + +## 2. 추가된 경로 (프론트에서 새로 호출 필요) + +- `GET /api/v1/quizzes` +- `GET /api/v1/quizzes/{quizId}` +- `GET /api/v1/polls` +- `GET /api/v1/polls/{pollId}` +- `POST /api/v1/battles/{battleId}/quiz-vote` +- `GET /api/v1/battles/{battleId}/quiz-vote/me` +- `POST /api/v1/battles/{battleId}/poll-vote` +- `GET /api/v1/battles/{battleId}/poll-vote/me` + +## 3. 유지되는 경로 (변경 없음) + +- 배틀 전용 투표: + - `POST /api/v1/battles/{battleId}/votes/pre` + - `POST /api/v1/battles/{battleId}/votes/post` + - `GET /api/v1/battles/{battleId}/vote-stats` + - `GET /api/v1/battles/{battleId}/votes/me` + +- 배틀 조회: + - `GET /api/v1/battles` + - `GET /api/v1/battles/{battleId}` + - `GET /api/v1/battles/today` + +## 4. 참고 + +- `quiz-vote`, `poll-vote` 경로의 Path Variable 이름은 코드상 `battleId`로 되어 있지만, 내부적으로는 각각 `quizId`, `pollId`로 처리됩니다. diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index 4fd9277a..cfbcd2b3 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -1,494 +1,77 @@ -# 배틀 API 명세서 +# 배틀(Battle) API 명세 ---- - -## 설계 메모 - -- **오늘의 배틀 :** - - 스와이프 UI를 위해 약 5개의 배틀 리스트를 반환합니다. '오늘의 배틀(검정 창)'과 '일반 배틀 카드(하얀 창)'의 진입점(API)을 분리하여 각기 필요한 데이터를 제공합니다. -- **태그 :** - - 배틀 응답의 `tags` 필드는 `{ tag_id, name }` 객체 배열로 반환됩니다. 태그 전체 목록 조회 및 태그 기반 배틀 필터링은 Tag API를 참조하세요. -- **도메인 분리 :** - - 사용자 서비스 API와 관리자(Admin) 전용 API 도메인을 분리했습니다. 기본 콘텐츠 발행은 관리자 도메인에서 이루어집니다. -- **AI 자동 생성 :** - - 스케줄러가 매일 자동으로 트렌딩 이슈를 검색·수집하여 AI API를 호출하고 배틀 초안을 `PENDING` 상태로 저장합니다. 관리자는 `/api/v1/admin/ai/battles`를 통해 검수·승인·반려합니다. -- **배틀 `status` 흐름 :** +기준 코드: `src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java`, +`src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java` - | status | 적용 대상 | 설명 | - |--------|--------------|------| - | `DRAFT` | 관리자 | 관리자가 작성 중인 초안 | - | `PENDING` | AI, 유저 [후순위] | 검수 대기 중 | - | `PUBLISHED` | 전체 | 검수 완료, 실제 노출 | - | `REJECTED` | AI, 유저 [후순위] | 검수 반려 | - | `ARCHIVED` | 전체 | 배틀 종료 후 이력 보존 | - -- **[후순위] 크리에이터 정책 :** - - 매너 온도 45도 이상의 사용자가 직접 배틀을 제안하는 기능은 런칭 스펙에서 제외됩니다. - ---- +## 1. 사용자 API -## 사용자 API +### 1.1 오늘의 배틀 목록 +- `GET /api/v1/battles/today` +- 설명: 오늘 노출 대상 배틀 목록 조회 (최대 5개) -### `GET /api/v1/battles/today` +### 1.2 배틀 목록 +- `GET /api/v1/battles` +- 쿼리 파라미터: + - `page` (기본값: `1`) + - `size` (기본값: `10`) + - `status` (기본값: `ALL`, 허용: `ALL`, `PENDING`, `PUBLISHED`, `REJECTED`, `ARCHIVED`) -- 스와이프 UI용으로 오늘 진행 중인 배틀 목록을 반환합니다. -- 피그마 디자인 상 5개로 임의 판단 -> 추후 수정 가능 +### 1.3 배틀 상세 +- `GET /api/v1/battles/{battleId}` +- 설명: 배틀 본문/선택지/태그/사용자 진행 상태 표시용 상세 조회 -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "items": [ - { - "battle_id": "battle_001", - "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", - "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", - "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", - "tags": [ - { "tag_id": "tag_001", "name": "사회" }, - { "tag_id": "tag_002", "name": "철학" }, - { "tag_id": "tag_003", "name": "롤스" }, - { "tag_id": "tag_004", "name": "니체" } - ], - "participants_count": 2148, - "audio_duration": 420, - "share_url": "https://pique.app/battles/battle_001", - "options": [ - { "option_id": "option_A", "label": "A", "title": "사기다 (롤스)" }, - { "option_id": "option_B", "label": "B", "title": "사기가 아니다 (니체)" } - ], - "user_vote_status": "NONE" - } - ], - "total_count": 5 - }, - "error": null -} -``` +### 1.4 사용자 배틀 진행 상태 +- `GET /api/v1/battles/{battleId}/status` +- 설명: 현재 로그인 사용자 기준 배틀 진행 단계 조회 --- -### `GET /api/v1/battles/{battle_id}` - -- 배틀 카드(하얀 창) 선택 시 노출되는 상세 정보(철학자, 성향, 인용구 등)를 조회합니다. - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "battle_id": "battle_001", - "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", - "tags": [ - { "tag_id": "tag_001", "name": "사회" }, - { "tag_id": "tag_002", "name": "철학" } - ], - "options": [ - { - "option_id": "option_A", - "label": "A", - "stance": "정보의 대칭 (공정성)", - "representative": "존 롤스", - "title": "사기다", - "quote": "베일 뒤에서 누구나 동의할 수 있는 공정한 규칙이 깨진 것입니다.", - "keywords": ["합리적", "원칙주의", "절대적"], - "image_url": "https://cdn.pique.app/images/rawls.png" - }, - { - "option_id": "option_B", - "label": "B", - "stance": "가치 창조 (욕망의 질서)", - "representative": "프리드리히 니체", - "title": "사기가 아니다", - "quote": "주인공은 가려운 욕망을 정확히 읽어내고, 새로운 가치를 창조해낸 예술가입니다.", - "keywords": ["본능적", "실용주의", "주관적"], - "image_url": "https://cdn.pique.app/images/nietzsche.png" - } - ] - }, - "error": null -} -``` - ---- - -## 관리자 API - -### `POST /api/v1/admin/battles` - -- 공식 배틀을 직접 생성합니다. - -#### Request Body - -```json -{ - "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", - "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", - "description": "예술과 사기의 경계에 대한 철학적 딜레마", - "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", - "target_date": "2026-03-10", - "tag_ids": ["tag_001", "tag_002", "tag_003", "tag_004"], - "options": [ - { - "label": "A", - "title": "사기다", - "stance": "정보의 대칭 (공정성)", - "representative": "존 롤스", - "quote": "베일 뒤에서 누구나 동의할 수 있는 공정한 규칙이 깨진 것입니다.", - "keywords": ["합리적", "원칙주의", "절대적"], - "image_url": "https://cdn.pique.app/images/rawls.png" - }, - { - "label": "B", - "title": "사기가 아니다", - "stance": "가치 창조 (욕망의 질서)", - "representative": "프리드리히 니체", - "quote": "주인공은 가려운 욕망을 정확히 읽어내고, 새로운 가치를 창조해낸 예술가입니다.", - "keywords": ["본능적", "실용주의", "주관적"], - "image_url": "https://cdn.pique.app/images/nietzsche.png" - } - ] -} -``` - -#### 성공 응답 `201 Created` - -```json -{ - "statusCode": 201, - "data": { - "battle_id": "battle_001", - "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", - "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", - "description": "예술과 사기의 경계에 대한 철학적 딜레마", - "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", - "target_date": "2026-03-10", - "status": "DRAFT", - "creator_type": "ADMIN", - "tags": [ - { "tag_id": "tag_001", "name": "사회" }, - { "tag_id": "tag_002", "name": "철학" }, - { "tag_id": "tag_003", "name": "롤스" }, - { "tag_id": "tag_004", "name": "니체" } - ], - "options": [ - { - "option_id": "option_A", - "label": "A", - "title": "사기다", - "stance": "정보의 대칭 (공정성)", - "representative": "존 롤스", - "quote": "베일 뒤에서 누구나 동의할 수 있는 공정한 규칙이 깨진 것입니다.", - "keywords": ["합리적", "원칙주의", "절대적"], - "image_url": "https://cdn.pique.app/images/rawls.png" - }, - { - "option_id": "option_B", - "label": "B", - "title": "사기가 아니다", - "stance": "가치 창조 (욕망의 질서)", - "representative": "프리드리히 니체", - "quote": "주인공은 가려운 욕망을 정확히 읽어내고, 새로운 가치를 창조해낸 예술가입니다.", - "keywords": ["본능적", "실용주의", "주관적"], - "image_url": "https://cdn.pique.app/images/nietzsche.png" - } - ], - "created_at": "2026-03-10T09:00:00Z" - }, - "error": null -} -``` - ---- - -### `PATCH /api/v1/admin/battles/{battle_id}` - -- 배틀 정보를 수정합니다. 변경할 필드만 포함합니다. - -#### Request Body - -```json -{ - "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가? (수정)", - "status": "PUBLISHED", - "tag_ids": ["tag_001", "tag_002"] -} -``` - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "battle_id": "battle_001", - "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가? (수정)", - "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", - "description": "예술과 사기의 경계에 대한 철학적 딜레마", - "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", - "target_date": "2026-03-10", - "status": "PUBLISHED", - "creator_type": "ADMIN", - "tags": [ - { "tag_id": "tag_001", "name": "사회" }, - { "tag_id": "tag_002", "name": "철학" } - ], - "updated_at": "2026-03-10T10:00:00Z" - }, - "error": null -} -``` - ---- - -### `DELETE /api/v1/admin/battles/{battle_id}` - -- 배틀을 삭제합니다. - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "success": true, - "deleted_at": "2026-03-10T11:00:00Z" - }, - "error": null -} -``` - ---- - -## `[후순위]` 관리자 AI 검수 API - -- 스케줄러가 자동 생성한 AI 배틀 초안(`PENDING`)을 관리자가 검수 · 승인 · 반려합니다. - -### `GET /api/v1/admin/ai/battles` - -- AI가 생성한 `PENDING` 상태의 배틀 목록을 조회합니다. - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "items": [ - { - "battle_id": "battle_ai_001", - "title": "AI가 제안한 배틀 제목", - "summary": "AI가 생성한 요약", - "thumbnail_url": "https://cdn.pique.app/battle/ai-001.png", - "target_date": "2026-03-11", - "status": "PENDING", - "creator_type": "AI", - "tags": [ - { "tag_id": "tag_001", "name": "사회" } - ], - "options": [ - { "option_id": "option_A", "label": "A", "title": "찬성", "keywords": ["합리적", "효율중심", "미래지향"] }, - { "option_id": "option_B", "label": "B", "title": "반대", "keywords": ["인본주의", "도덕중심", "전통적"] } - ], - "created_at": "2026-03-11T06:00:00Z" - } - ], - "total_count": 3 - }, - "error": null -} -``` +## 2. 관리자 API + +기준 컨트롤러: `AdminBattleController` + +### 2.1 배틀 생성 +- `POST /api/v1/admin/battles` +- 요청 본문(`AdminBattleCreateRequest`) 주요 필드: + - `title` + - `summary` + - `description` + - `thumbnailUrl` + - `status` (`DRAFT`, `PUBLISHED`, `ARCHIVED` 등) + - `tagIds` (카테고리 태그 ID 목록) + - `options[]` + - `label` (`A`, `B`, `C`, `D`) + - `title` + - `stance` + - `representative` + - `imageUrl` + - `tagIds` (철학자/가치관 태그 ID 목록) + +### 2.2 배틀 목록 +- `GET /api/v1/admin/battles` +- 쿼리 파라미터: + - `page` (기본값: `1`) + - `size` (기본값: `10`) + - `status` (선택) + +### 2.3 배틀 상세 +- `GET /api/v1/admin/battles/{battleId}` + +### 2.4 배틀 수정 +- `PATCH /api/v1/admin/battles/{battleId}` +- 요청 본문(`AdminBattleUpdateRequest`) 필드 구조는 생성과 동일 + +### 2.5 배틀 삭제 +- `DELETE /api/v1/admin/battles/{battleId}` --- -### `PATCH /api/v1/admin/ai/battles/{battle_id}` - -- AI가 생성한 배틀을 승인하거나 반려합니다. 승인 시 내용을 수정한 뒤 승인할 수 있습니다. - -#### Request Body — 승인 - -```json -{ - "action": "APPROVE", - "title": "AI 초안 제목 (수정 가능)", - "summary": "AI 초안 요약 (수정 가능)", - "tag_ids": ["tag_001", "tag_002"] -} -``` - -#### Request Body — 반려 - -```json -{ - "action": "REJECT", - "reject_reason": "주제가 서비스 방향과 맞지 않음" -} -``` - -#### 성공 응답 `200 OK` — 승인 - -```json -{ - "statusCode": 200, - "data": { - "battle_id": "battle_ai_001", - "status": "PUBLISHED", - "creator_type": "AI", - "updated_at": "2026-03-11T09:00:00Z" - }, - "error": null -} -``` - -#### 성공 응답 `200 OK` — 반려 - -```json -{ - "statusCode": 200, - "data": { - "battle_id": "battle_ai_001", - "status": "REJECTED", - "reject_reason": "주제가 서비스 방향과 맞지 않음", - "updated_at": "2026-03-11T09:00:00Z" - }, - "error": null -} -``` - ---- - -## `[후순위]` 크리에이터 API - -### `POST /api/v1/battles` - -- 배틀을 제안합니다. (매너 온도 45도 이상 유저) - -#### Request Body - -```json -{ - "title": "AI가 만든 예술 작품, 저작권은 누구에게?", - "summary": "AI 창작물의 저작권 귀속 주체에 대한 철학적 딜레마", - "description": "창작의 주체성과 소유권에 대한 철학적 논쟁", - "thumbnail_url": "https://cdn.pique.app/battle/ai-art.png", - "target_date": "2026-03-15", - "tag_ids": ["tag_002", "tag_005"], - "options": [ - { - "label": "A", - "title": "AI 개발사에게 귀속된다", - "stance": "도구 이론", - "representative": "존 로크", - "quote": "노동을 투입한 자에게 소유권이 있다.", - "keywords": ["합리적", "효율중심", "미래지향"], - "image_url": "https://cdn.pique.app/images/locke.png" - }, - { - "label": "B", - "title": "퍼블릭 도메인이어야 한다", - "stance": "공유재 이론", - "representative": "장 자크 루소", - "quote": "창작물은 사회의 산물이므로 모두의 것이다.", - "keywords": ["합리적", "효율중심", "미래지향"], - "image_url": "https://cdn.pique.app/images/rousseau.png" - } - ] -} -``` - -#### 성공 응답 `201 Created` - -```json -{ - "statusCode": 201, - "data": { - "battle_id": "battle_002", - "title": "AI가 만든 예술 작품, 저작권은 누구에게?", - "status": "PENDING", - "creator_type": "USER", - "created_at": "2026-03-10T12:00:00Z" - }, - "error": null -} -``` - ---- - -### `PATCH /api/v1/battles/{battle_id}` - -- 제안한 배틀 정보를 수정합니다. 변경할 필드만 포함합니다. - -#### Request Body - -```json -{ - "title": "AI가 만든 예술 작품, 저작권은 누구에게? (수정)", - "summary": "AI 창작물의 저작권 귀속 주체에 대한 철학적 딜레마" -} -``` - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "battle_id": "battle_002", - "title": "AI가 만든 예술 작품, 저작권은 누구에게? (수정)", - "summary": "AI 창작물의 저작권 귀속 주체에 대한 철학적 딜레마", - "status": "PENDING", - "creator_type": "USER", - "updated_at": "2026-03-10T13:00:00Z" - }, - "error": null -} -``` - ---- - -### `DELETE /api/v1/battles/{battle_id}` - -- 제안한 배틀을 삭제합니다. - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "success": true, - "deleted_at": "2026-03-10T14:00:00Z" - }, - "error": null -} -``` - ---- - -## 공통 에러 코드 - -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | -| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | -| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | -| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | -| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | -| `USER_BANNED` | `403` | 제재된 사용자 | -| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | - ---- - -## 배틀 에러 코드 - -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `BATTLE_NOT_FOUND` | `404` | 존재하지 않는 배틀 | -| `BATTLE_CLOSED` | `409` | 종료된 배틀 | -| `BATTLE_ALREADY_PUBLISHED` | `409` | 이미 발행된 배틀 | -| `BATTLE_OPTION_NOT_FOUND` | `404` | 존재하지 않는 선택지 | +## 3. 상태/정책 메모 ---- \ No newline at end of file +- 배틀 전용 태그: + - 카테고리 태그: `battle_tags` + - 옵션 태그(철학자/가치관): `battle_option_tags` +- 옵션 개수 제한: + - 최소 2개, 최대 4개 (`BATTLE_INVALID_OPTION_COUNT`) +- `target_date`: + - 관리자 폼에서 직접 입력하지 않고 서버 정책으로 관리 diff --git a/docs/api-specs/battle-proposal-api.md b/docs/api-specs/battle-proposal-api.md new file mode 100644 index 00000000..ad591f23 --- /dev/null +++ b/docs/api-specs/battle-proposal-api.md @@ -0,0 +1,137 @@ +# 배틀 주제 제안(Battle Proposal) API 명세 + +기준 코드: `src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java`, +`src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java` + +--- + +## 1. 사용자 API + +### 1.1 배틀 주제 제안 등록 +- `POST /api/v1/battles/proposals` +- 설명: 유저가 배틀 주제를 제안합니다. 제안 시 30크레딧이 차감됩니다. +- 요청 본문(`BattleProposalRequest`) 주요 필드: + - `category` (필수) — 카테고리 (철학, 문학, 예술, 과학, 사회, 역사) + - `topic` (필수) — 논쟁 주제 (최대 100자) + - `positionA` (필수) — A 입장 + - `positionB` (필수) — B 입장 + - `description` (선택) — 부가 설명 (최대 200자) + +#### 성공 응답 `201 Created` +```json +{ + "statusCode": 201, + "data": { + "id": 1, + "userId": 100, + "nickname": "유저닉네임", + "category": "철학", + "topic": "논쟁이 될만한 주제", + "positionA": "첫 번째 입장", + "positionB": "두 번째 입장", + "description": "부가 설명", + "status": "PENDING", + "createdAt": "2026-04-11T10:00:00" + }, + "error": null +} +``` + +--- + +## 2. 관리자 API + +기준 컨트롤러: `AdminBattleProposalController` + +### 2.1 배틀 주제 제안 목록 조회 +- `GET /api/v1/admin/battles/proposals` +- 쿼리 파라미터: + - `page` (기본값: `1`) + - `size` (기본값: `10`) + - `status` (선택, 허용: `PENDING`, `ACCEPTED`, `REJECTED`) + +#### 성공 응답 `200 OK` +```json +{ + "statusCode": 200, + "data": { + "content": [ + { + "id": 1, + "userId": 100, + "nickname": "유저닉네임", + "category": "철학", + "topic": "논쟁이 될만한 주제", + "positionA": "첫 번째 입장", + "positionB": "두 번째 입장", + "description": "부가 설명", + "status": "PENDING", + "createdAt": "2026-04-11T10:00:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "page": 1, + "size": 10 + }, + "error": null +} +``` + +### 2.2 배틀 주제 채택/미채택 처리 +- `PATCH /api/v1/admin/battles/proposals/{proposalId}` +- 설명: 제안된 주제를 채택하거나 거절합니다. 채택 시 제안자에게 100크레딧이 지급됩니다. +- 요청 본문(`BattleProposalReviewRequest`) 주요 필드: + - `action` (필수) — `ACCEPT` 또는 `REJECT` + +#### 성공 응답 `200 OK` +```json +{ + "statusCode": 200, + "data": { + "id": 1, + "userId": 100, + "nickname": "유저닉네임", + "category": "철학", + "topic": "논쟁이 될만한 주제", + "positionA": "첫 번째 입장", + "positionB": "두 번째 입장", + "description": "부가 설명", + "status": "ACCEPTED", + "createdAt": "2026-04-11T10:00:00" + }, + "error": null +} +``` + +--- + +## 3. 상태/정책 메모 + +- 제안 상태(`BattleProposalStatus`): + + | status | 설명 | + |--------|------| + | `PENDING` | 검토 대기 중 (기본값) | + | `ACCEPTED` | 채택 완료 → 제안자에게 100크레딧 지급 | + | `REJECTED` | 미채택 | + +- 크레딧 정책: + - 제안 등록 시: **-30크레딧** 차감 (`TOPIC_SUGGEST`) + - 채택 시: **+100크레딧** 지급 (`TOPIC_ADOPTED`) + - 크레딧 부족 시 제안 불가 (`CREDIT_NOT_ENOUGH`) +- `PENDING` 상태인 제안만 채택/미채택 처리 가능 + +--- + +## 4. 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | +| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | +| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | +| `BATTLE_NOT_FOUND` | `404` | 존재하지 않는 제안 | +| `BATTLE_ALREADY_PUBLISHED` | `409` | 이미 처리된 제안 | +| `CREDIT_NOT_ENOUGH` | `400` | 크레딧 부족 | +| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | \ No newline at end of file diff --git a/docs/api-specs/home-api.md b/docs/api-specs/home-api.md index ecebb59e..32efc238 100644 --- a/docs/api-specs/home-api.md +++ b/docs/api-specs/home-api.md @@ -1,148 +1,57 @@ -# 홈 API 명세서 +# 홈(Home) API 명세 -## 1. 설계 메모 +기준 코드: `src/main/java/com/swyp/picke/domain/home/controller/HomeController.java`, +`src/main/java/com/swyp/picke/domain/home/service/HomeService.java` -- 홈은 여러 조회 결과를 한 번에 내려주는 집계 API입니다. -- 이번 문서는 `GET /api/v1/home` 하나만 정의합니다. -- 공지 목록/상세는 홈에서 직접 내려주지 않고, 마이페이지 공지 탭에서 처리합니다. -- 홈에서는 공지 내용 대신 `newNotice` boolean만 내려서 새 공지 유입 여부만 표시합니다. -- `todayPicks` 안에는 찬반형과 4지선다형이 함께 포함됩니다. +## 1. 홈 조회 + +- `GET /api/v1/home` +- 설명: 홈 화면 전체 섹션 데이터를 한 번에 조회 + +### 응답 구조 (`HomeResponse`) +- `newNotice`: 새 공지 존재 여부 +- `editorPicks`: 에디터 픽 배틀 목록 +- `trendingBattles`: 트렌딩 배틀 목록 +- `bestBattles`: 베스트 배틀 목록 +- `todayQuizzes`: 오늘의 퀴즈 목록 +- `todayVotes`: 오늘의 투표(Poll) 목록 +- `newBattles`: 신규 배틀 목록 --- -## 2. 홈 API +## 2. todayQuizzes 응답 필드 + +`HomeTodayQuizResponse` -### 2.1 `GET /api/v1/home` +- `battleId` (실제 Quiz ID) +- `title` +- `summary` (고정 문구) +- `participantsCount` +- `itemA` +- `itemADesc` +- `isCorrectA` +- `itemB` +- `itemBDesc` +- `isCorrectB` -홈 화면 진입 시 필요한 데이터를 한 번에 조회합니다. +--- + +## 3. todayVotes 응답 필드 -반환 섹션: +`HomeTodayVoteResponse` -- `newNotice`: 새 공지가 있는지 여부 -- `editorPicks`: Editor Pick -- `trendingBattles`: 지금 뜨는 배틀 -- `bestBattles`: Best 배틀 -- `todayPicks`: 오늘의 Pické -- `newBattles`: 새로운 배틀 +- `battleId` (실제 Poll ID) +- `titlePrefix` +- `titleSuffix` +- `summary` (고정 문구) +- `participantsCount` +- `options[]` + - `label` + - `title` -```json -{ - "newNotice": true, - "editorPicks": [ - { - "battleId": "7b6c8d81-40f4-4f1e-9f13-4cc2fa0a3a10", - "title": "연애 상대의 전 애인 사진, 지워달라고 말한다 vs 그냥 둔다", - "summary": "에디터가 직접 골라본 오늘의 주제", - "thumbnailUrl": "https://cdn.example.com/battle/editor-pick-001.png", - "type": "BATTLE", - "viewCount": 182, - "participantsCount": 562, - "audioDuration": 153, - "tags": [], - "options": [] - } - ], - "trendingBattles": [ - { - "battleId": "40f4c311-0bd8-4baf-85df-58f8eaf1bf1f", - "title": "안락사 도입, 찬성 vs 반대", - "summary": "최근 24시간 참여가 급증한 배틀", - "thumbnailUrl": "https://cdn.example.com/battle/hot-001.png", - "type": "BATTLE", - "viewCount": 120, - "participantsCount": 420, - "audioDuration": 180, - "tags": [], - "options": [] - } - ], - "bestBattles": [ - { - "battleId": "11c22d33-44e5-6789-9abc-123456789def", - "title": "반려동물 출입 가능 식당, 확대해야 한다 vs 제한해야 한다", - "summary": "누적 참여와 댓글 반응이 높은 배틀", - "thumbnailUrl": "https://cdn.example.com/battle/best-001.png", - "type": "BATTLE", - "viewCount": 348, - "participantsCount": 1103, - "audioDuration": 201, - "tags": [], - "options": [] - } - ], - "todayPicks": [ - { - "battleId": "4e5291d2-b514-4d2a-a8fb-1258ae21a001", - "title": "배달 일회용 수저 기본 제공, 찬성 vs 반대", - "summary": "오늘의 Pické 찬반형 예시", - "thumbnailUrl": "https://cdn.example.com/battle/today-vote-001.png", - "type": "VOTE", - "viewCount": 97, - "participantsCount": 238, - "audioDuration": 96, - "tags": [], - "options": [ - { - "label": "A", - "text": "찬성" - }, - { - "label": "B", - "text": "반대" - } - ] - }, - { - "battleId": "9f8e7d6c-5b4a-3210-9abc-7f6e5d4c3b2a", - "title": "다음 중 세계에서 가장 큰 사막은?", - "summary": "오늘의 Pické 4지선다형 예시", - "thumbnailUrl": "https://cdn.example.com/battle/today-quiz-001.png", - "type": "QUIZ", - "viewCount": 76, - "participantsCount": 191, - "audioDuration": 88, - "tags": [], - "options": [ - { - "label": "A", - "text": "사하라 사막" - }, - { - "label": "B", - "text": "고비 사막" - }, - { - "label": "C", - "text": "남극 대륙" - }, - { - "label": "D", - "text": "아라비아 사막" - } - ] - } - ], - "newBattles": [ - { - "battleId": "aa11bb22-cc33-44dd-88ee-ff0011223344", - "title": "회사 회식은 근무의 연장이다 vs 사적인 친목이다", - "summary": "홈의 다른 섹션과 중복되지 않는 최신 배틀", - "thumbnailUrl": "https://cdn.example.com/battle/new-001.png", - "type": "BATTLE", - "viewCount": 24, - "participantsCount": 71, - "audioDuration": 142, - "tags": [], - "options": [] - } - ] -} -``` +--- -비고: +## 4. 정렬/노출 메모 -- `newNotice`는 홈에서 공지 내용을 직접 노출하지 않고, 마이페이지 공지 탭으로 이동시키기 위한 신규 공지 존재 여부입니다. -- `editorPicks`, `trendingBattles`, `bestBattles`, `newBattles`는 동일한 배틀 요약 카드 구조를 사용합니다. -- `todayPicks`는 `type`으로 찬반형과 4지선다형을 구분합니다. -- `todayPicks`의 4지선다형은 별도 `quizzes` 필드로 분리하지 않고 이 배열 안에 포함합니다. -- 데이터가 없으면 리스트 섹션은 빈 배열을, `newNotice`는 `false`를 반환합니다. +- 오늘의 퀴즈/투표는 서버에서 조회 및 정렬을 확정해 응답 +- 옵션 순서는 `displayOrder -> label -> id` 기준 오름차순으로 고정 diff --git a/docs/api-specs/poll-api.md b/docs/api-specs/poll-api.md new file mode 100644 index 00000000..f6258f56 --- /dev/null +++ b/docs/api-specs/poll-api.md @@ -0,0 +1,54 @@ +# Poll API 명세 + +기준 코드: +`src/main/java/com/swyp/picke/domain/poll/controller/PollController.java` +`src/main/java/com/swyp/picke/domain/admin/controller/AdminPollController.java` + +## 1. 사용자 API + +### 1.1 Poll 목록 +- `GET /api/v1/polls` +- 쿼리 파라미터: + - `page` (기본값: `1`) + - `size` (기본값: `10`) + +### 1.2 Poll 상세 +- `GET /api/v1/polls/{pollId}` + +--- + +## 2. 관리자 API + +### 2.1 Poll 생성 +- `POST /api/v1/admin/polls` +- 요청 본문(`AdminPollCreateRequest`) 주요 필드: + - `titlePrefix` + - `titleSuffix` + - `status` + - `options[]` + - `label` (`A`, `B`, ...) + - `title` + +### 2.2 Poll 목록 +- `GET /api/v1/admin/polls` +- 쿼리 파라미터: + - `page` + - `size` + - `status` (선택) + +### 2.3 Poll 상세 +- `GET /api/v1/admin/polls/{pollId}` + +### 2.4 Poll 수정 +- `PATCH /api/v1/admin/polls/{pollId}` +- 요청 본문(`AdminPollUpdateRequest`) 구조는 생성과 동일 + +### 2.5 Poll 삭제 +- `DELETE /api/v1/admin/polls/{pollId}` + +--- + +## 3. 필드 정책 메모 + +- Poll은 태그를 사용하지 않음 +- Poll 투표 결과 비율은 Vote API의 `poll-vote` 경로에서 조회 diff --git a/docs/api-specs/quiz-api.md b/docs/api-specs/quiz-api.md new file mode 100644 index 00000000..a825a23a --- /dev/null +++ b/docs/api-specs/quiz-api.md @@ -0,0 +1,55 @@ +# 퀴즈(Quiz) API 명세 + +기준 코드: +`src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java` +`src/main/java/com/swyp/picke/domain/admin/controller/AdminQuizController.java` + +## 1. 사용자 API + +### 1.1 퀴즈 목록 +- `GET /api/v1/quizzes` +- 쿼리 파라미터: + - `page` (기본값: `1`) + - `size` (기본값: `10`) + +### 1.2 퀴즈 상세 +- `GET /api/v1/quizzes/{quizId}` + +--- + +## 2. 관리자 API + +### 2.1 퀴즈 생성 +- `POST /api/v1/admin/quizzes` +- 요청 본문(`AdminQuizCreateRequest`) 주요 필드: + - `title` + - `status` + - `options[]` + - `label` (`A`, `B`, ...) + - `text` + - `detailText` + - `isCorrect` + +### 2.2 퀴즈 목록 +- `GET /api/v1/admin/quizzes` +- 쿼리 파라미터: + - `page` + - `size` + - `status` (선택) + +### 2.3 퀴즈 상세 +- `GET /api/v1/admin/quizzes/{quizId}` + +### 2.4 퀴즈 수정 +- `PATCH /api/v1/admin/quizzes/{quizId}` +- 요청 본문(`AdminQuizUpdateRequest`) 구조는 생성과 동일 + +### 2.5 퀴즈 삭제 +- `DELETE /api/v1/admin/quizzes/{quizId}` + +--- + +## 3. 필드 정책 메모 + +- 퀴즈는 태그를 사용하지 않음 +- 퀴즈 투표/정답 판정은 Vote API의 `quiz-vote` 경로 사용 diff --git a/docs/api-specs/recommendations-api.md b/docs/api-specs/recommendations-api.md index 1974890c..86063beb 100644 --- a/docs/api-specs/recommendations-api.md +++ b/docs/api-specs/recommendations-api.md @@ -1,81 +1,18 @@ -# 성향기반 배틀 추천 API 명세서 +# 추천(Recommendation) API 명세 ---- +기준 코드: `src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java` -## 설계 메모 +## 1. 흥미 기반 추천 -- 연관 , 비슷한 , 반대 성향에 대한 추천 / 내부 정책 로직 API 입니다. +- `GET /api/v1/battles/{battleId}/recommendations/interesting` +- 설명: 특정 배틀 기준으로 흥미 유사 배틀 목록 조회 +- 인증 사용자면 개인화 가중치가 적용될 수 있음 ---- - -## 성향 기반 비슷한 유저가 들은 배틀 조회 API -### `GET /api/v1/battles/{battle_id}/recommendations/similar` - -- 비슷한 유저가 들은 배틀 , PM의 전략 미확정 (26.03.15) - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "items": [ - { - "battle_id": "battle_002", - "title": "사후세계는 존재하는가, 인간이 만든 위안인가?", - "tags": [ - { "tag_id": "tag_001", "name": "철학" } - ], - "participants_count": 1340, - "options": [ - { - "option_id": "option_A", - "label": "A", - "title": "존재한다", - "representative": "플라톤", - "image_url": "https://cdn.pique.app/characters/platon.png" - }, - { - "option_id": "option_B", - "label": "B", - "title": "인간이 만든 위안이다", - "representative": "에피쿠로스", - "image_url": "https://cdn.pique.app/characters/epicurus.png" - } - ] - } - ] - }, - "error": null -} -``` - -### 예외 응답 `404 - 배틀 없음` - -```json -{ - "statusCode": 404, - "data": null, - "error": { - "code": "BATTLE_NOT_FOUND", - "message": "존재하지 않는 배틀입니다.", - "errors": [] - } -} -``` - ---- - -## 공통 에러 코드 - -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | -| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | -| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | -| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | -| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | -| `USER_BANNED` | `403` | 제재된 사용자 | -| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | - ---- \ No newline at end of file +### 응답 (`RecommendationListResponse`) 요약 +- `items[]` + - `battleId` + - `title` + - `summary` + - `thumbnailUrl` + - `tags` + - `options` diff --git a/docs/api-specs/reward-api.md b/docs/api-specs/reward-api.md index 24b8a3fe..31d05d7f 100644 --- a/docs/api-specs/reward-api.md +++ b/docs/api-specs/reward-api.md @@ -8,6 +8,7 @@ - **유저 식별**: `custom_data` 필드에 담긴 값을 내부 `user_id`로 매핑하여 처리합니다. - **타입 검증**: `reward_item` 값은 내부 `RewardType` Enum과 매핑하며, 정의되지 않은 값(예: "123")은 에러 처리합니다. - **데이터 보존**: 보상 요청의 성공 이력을 `ad_reward_history` 테이블에 적재합니다. +- 사용자 크레딧 히스토리 조회는 별도 `/api/v1/me/credits/history` API로 분리되어 있으며 이 문서 범위에 포함하지 않습니다. --- @@ -55,34 +56,9 @@ --- -## 3. 내 보상 이력 API +## 3. 에러 코드 -### 3.1 GET /api/v1/me/rewards/history - -로그인한 사용자의 보상 획득 이력 조회.쿼리 파라미터 - -```JSON -{ - "statusCode": 200, - "data": { - "items": [ - { - "history_id": 105, - "reward_type": "POINT", - "reward_amount": 100, - "transaction_id": "unique_trans_id_20260327_001", - "created_at": "2026-03-27T18:00:00Z" - } - ], - "next_cursor": 104 - }, - "error": null - } -``` - -## 4. 에러 코드 - -### 4.1 보상 관련 에러 코드 +### 3.1 보상 관련 에러 코드 ### 🚨 보상 API 에러 응답 JSON 샘플 @@ -126,11 +102,11 @@ ``` --- -## 공통 에러 코드 +## 4. 공통 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|-------------------------------------| | `REWARD_INVALID_USER` | `404` | custom_data에 해당하는 유저가 존재하지 않음 | | `REWARD_INVALID_TYPE` | `400` | 지원하지 않는 reward_item 타입 (Enum 미매칭) | | `REWARD_INVALID_SIGNATURE` | `401` | AdMob 서명(Signature) 검증 실패 또는 위변조 의심 | ---- \ No newline at end of file +--- diff --git a/docs/api-specs/scenario-api.md b/docs/api-specs/scenario-api.md index 575c7427..5d211914 100644 --- a/docs/api-specs/scenario-api.md +++ b/docs/api-specs/scenario-api.md @@ -1,569 +1,60 @@ -# 시나리오 API 명세서 +# 시나리오(Scenario) API 명세 ---- - -## 설계 메모 - -- **시나리오 구조 (인터랙티브 O/X 모두 지원) :** - - 배틀의 성격에 따라 인터랙티브(분기 선택)가 없는 '단일 오디오 재생'과 인터랙티브가 있는 '트리형 오디오 재생'을 모두 지원합니다. `is_interactive` 상태값으로 구분하여 클라이언트가 적절한 UI를 렌더링합니다. -- **트리(Node) 구조 :** - - 시나리오(오디오/대본)는 오프닝/1라운드 → 유저 선택 분기(2라운드) → 최종 결론(3라운드/클로징)으로 이어지는 트리(Node) 구조를 가집니다. -- **TTS 사전 생성 :** - - 관리자가 시나리오를 발행할 때 단 1번만 TTS API를 호출하여 `.mp3` 파일과 타임스탬프(`start_time`)를 생성하고 CDN에 저장합니다. 유저 플레이 시에는 실시간 호출 없이 저장된 파일을 스트리밍합니다. -- **AI 자동 생성 :** - - 스케줄러가 매일 자동으로 트렌딩 이슈를 검색·수집하여 AI API를 호출하고 시나리오 초안을 `PENDING` 상태로 저장합니다. 관리자는 `/api/v1/admin/ai/scenarios`를 통해 검수·승인·반려합니다. -- **프론트엔드 자체 처리 :** - - 글씨 크기(A-/A+) 및 오디오 플레이어 컨트롤(15초 전/후, 배속, 스와이프)은 프론트엔드에서 네이티브/UI 상태로 처리합니다. -- **시나리오 `status` 흐름 :** +기준 코드: +`src/main/java/com/swyp/picke/domain/scenario/controller/ScenarioController.java` +`src/main/java/com/swyp/picke/domain/admin/controller/AdminScenarioController.java` - | status | 적용 대상 | 설명 | - |--------|--------------|------| - | `DRAFT` | 관리자 | 관리자가 작성 중인 초안. TTS 미생성 상태 | - | `PENDING` | AI, 유저 [후순위] | 관리자 검수 대기 중 | - | `PUBLISHED` | 전체 | TTS 생성 완료, CDN 업로드 완료, 실제 노출 | - | `REJECTED` | AI, 유저 [후순위] | 검수 반려 | - | `ARCHIVED` | 전체 | 배틀 종료 후 이력 보존, 더 이상 노출 안 함 | +## 1. 사용자 API -- **[후순위] 크리에이터 정책 :** - - 매너 온도 45도 이상의 사용자가 직접 시나리오를 제안하는 기능은 런칭 스펙에서 제외됩니다. +### 1.1 배틀 시나리오 조회 +- `GET /api/v1/battles/{battleId}/scenario` +- 설명: 배틀 상세에서 시나리오 노드/스크립트/분기 옵션 조회 --- -## 사용자 API - -### `GET /api/v1/battles/{battle_id}/scenario` - -- 사전 투표 완료 후 시나리오 창 진입 시 호출합니다. -- `is_interactive` 값에 따라 클라이언트 렌더링 방식이 분기됩니다. +## 2. 관리자 API ---- +### 2.1 배틀 기준 시나리오 상세 조회 +- `GET /api/v1/admin/battles/{battleId}/scenario` -#### CASE 1 - 단일 재생 (`is_interactive: false`) +### 2.2 시나리오 생성 +- `POST /api/v1/admin/scenarios` +- 요청 본문(`AdminScenarioCreateRequest`) 주요 필드: + - `battleId` + - `isInteractive` + - `status` (`DRAFT`, `PUBLISHED`, `ARCHIVED`) + - `nodes[]` + - `nodeName` + - `isStartNode` + - `autoNextNode` + - `scripts[]` + - `speakerName` + - `speakerType` + - `text` + - `interactiveOptions[]` + - `label` + - `nextNodeName` + - `voiceSettings` (`Map`) -- 전체 시나리오가 1개의 노드에 담기며, `interactive_options`는 빈 배열로 반환됩니다. - -```json -{ - "statusCode": 200, - "data": { - "battle_id": "battle_001", - "is_interactive": false, - "my_pre_vote": { - "option_id": "option_A", - "label": "A", - "title": "사기다" - }, - "start_node_id": "node_001_full", - "nodes": [ - { - "node_id": "node_001_full", - "audio_url": "https://cdn.pique.app/audio/battle_001_full.mp3", - "audio_duration": 420, - "scripts": [ - { "start_time": 0, "speaker_name": "나레이션", "speaker_side": "NONE", "message": "여기 한 여자가 있습니다. 동대문에서 18만 원에 떼온 가방을 1억 원에 팔았습니다..." }, - { "start_time": 60000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "재판장님, 시장 경제의 핵심은 '정보의 대칭'입니다. 판매자가 원가를 은폐한 것은 기만입니다." }, - { "start_time": 90000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "명품을 사는 사람이 원가를 몰라서 삽니까? 그들은 남들보다 우월해지기 위해 기꺼이 1억을 지불한 겁니다." }, - { "start_time": 150000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "현명하십니다. 상품의 가치가 전적으로 기만에 의해 결정된다면 사회적 계약의 약탈입니다." }, - { "start_time": 210000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "역시 가치를 아시는군요! 거래는 예술입니다. 주인공은 가방에 독점적 서사를 입혔고 구매자는 만족했습니다." }, - { "start_time": 300000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "한 가지 묻겠습니다. 당신이 만약 그 가방의 구매자였다면, 원가를 알고도 웃으며 1억을 내놓겠습니까?" }, - { "start_time": 330000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "질문이 틀렸소. 명품을 사는 자들은 이미 그 게임의 규칙을 압니다. 불쾌함이 곧 사기는 아닙니다." }, - { "start_time": 390000, "speaker_name": "나레이션", "speaker_side": "NONE", "message": "거래는 끝났고, 가방은 누군가의 손에 들려 있습니다. 이제 당신의 최종 선택을 들려주세요." } - ], - "interactive_options": [] - } - ] - }, - "error": null -} -``` - ---- - -#### CASE 2 - 분기형 인터랙티브 재생 (`is_interactive: true`) - -- `interactive_options` 배열의 `next_node_id`를 따라 노드를 순회합니다. - -```json -{ - "statusCode": 200, - "data": { - "battle_id": "battle_001", - "is_interactive": true, - "my_pre_vote": { - "option_id": "option_A", - "label": "A", - "title": "사기다" - }, - "start_node_id": "node_001_opening", - "nodes": [ - { - "node_id": "node_001_opening", - "audio_url": "https://cdn.pique.app/audio/battle_001_round1.mp3", - "audio_duration": 150, - "scripts": [ - { "start_time": 0, "speaker_name": "나레이션", "speaker_side": "NONE", "message": "여기 한 여자가 있습니다. 동대문에서 18만 원에 떼온 가방을 1억 원에 팔았습니다..." }, - { "start_time": 60000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "재판장님, 시장 경제의 핵심은 '정보의 대칭'입니다..." }, - { "start_time": 90000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "명품을 사는 사람이 원가를 몰라서 삽니까? 그들은 차별화를 위해..." } - ], - "interactive_options": [ - { "label": "사회적 신뢰를 위해 정보의 투명성이 우선이다.", "next_node_id": "node_002_branch_a" }, - { "label": "시장은 개인의 욕망이 만나는 곳이다.", "next_node_id": "node_002_branch_b" } - ] - }, - { - "node_id": "node_002_branch_a", - "audio_url": "https://cdn.pique.app/audio/battle_001_round2_a.mp3", - "audio_duration": 110, - "scripts": [ - { "start_time": 0, "speaker_name": "유저", "speaker_side": "A", "message": "사회의 기본 신뢰를 위해 투명한 정보 공개가 우선되어야 합니다." }, - { "start_time": 10000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "현명하십니다. 상품의 가치가 전적으로 기만에 의해 결정된다면..." } - ], - "interactive_options": [ - { "label": "최종 충돌 및 정리 듣기", "next_node_id": "node_003_closing" } - ] - }, - { - "node_id": "node_002_branch_b", - "audio_url": "https://cdn.pique.app/audio/battle_001_round2_b.mp3", - "audio_duration": 120, - "scripts": [ - { "start_time": 0, "speaker_name": "유저", "speaker_side": "B", "message": "강요 없는 자발적 거래라면, 욕망에 따른 가격 결정은 시장의 자유입니다." }, - { "start_time": 10000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "역시 가치를 아시는군요! 거래는 예술입니다..." } - ], - "interactive_options": [ - { "label": "최종 충돌 및 정리 듣기", "next_node_id": "node_003_closing" } - ] - }, - { - "node_id": "node_003_closing", - "audio_url": "https://cdn.pique.app/audio/battle_001_round3_closing.mp3", - "audio_duration": 90, - "scripts": [ - { "start_time": 0, "speaker_name": "존 롤스", "speaker_side": "A", "message": "한 가지 묻겠습니다. 당신이 만약 그 가방의 구매자였다면..." }, - { "start_time": 30000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "질문이 틀렸소. 명품을 사는 자들은 이미 그 게임의 규칙을 압니다..." }, - { "start_time": 60000, "speaker_name": "나레이션", "speaker_side": "NONE", "message": "이제 당신의 최종 선택을 들려주세요." } - ], - "interactive_options": [] - } - ] - }, - "error": null -} -``` - ---- - -## 관리자 API - -### `POST /api/v1/admin/scenarios` - -- 공식 시나리오를 직접 생성합니다. 생성 시 TTS API가 자동 호출되어 `.mp3` 파일이 CDN에 업로드됩니다. - -#### Request Body - -```json -{ - "battle_id": "battle_001", - "is_interactive": true, - "nodes": [ - { - "node_name": "node_001_opening", - "is_start_node": true, - "scripts": [ - { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "여기 한 여자가 있습니다. 동대문에서 18만 원에 떼온 가방을 1억 원에 팔았습니다..." }, - { "speaker_name": "존 롤스", "speaker_side": "A", "message": "재판장님, 시장 경제의 핵심은 '정보의 대칭'입니다..." }, - { "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "명품을 사는 사람이 원가를 몰라서 삽니까?..." } - ], - "interactive_options": [ - { "label": "사회적 신뢰를 위해 정보의 투명성이 우선이다.", "next_node_name": "node_002_branch_a" }, - { "label": "시장은 개인의 욕망이 만나는 곳이다.", "next_node_name": "node_002_branch_b" } - ] - }, - { - "node_name": "node_002_branch_a", - "is_start_node": false, - "scripts": [ - { "speaker_name": "유저", "speaker_side": "A", "message": "사회의 기본 신뢰를 위해 투명한 정보 공개가 우선되어야 합니다." }, - { "speaker_name": "존 롤스", "speaker_side": "A", "message": "현명하십니다. 상품의 가치가 전적으로 기만에 의해 결정된다면..." } - ], - "interactive_options": [ - { "label": "최종 충돌 및 정리 듣기", "next_node_name": "node_003_closing" } - ] - }, - { - "node_name": "node_002_branch_b", - "is_start_node": false, - "scripts": [ - { "speaker_name": "유저", "speaker_side": "B", "message": "강요 없는 자발적 거래라면, 욕망에 따른 가격 결정은 시장의 자유입니다." }, - { "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "역시 가치를 아시는군요! 거래는 예술입니다..." } - ], - "interactive_options": [ - { "label": "최종 충돌 및 정리 듣기", "next_node_name": "node_003_closing" } - ] - }, - { - "node_name": "node_003_closing", - "is_start_node": false, - "scripts": [ - { "speaker_name": "존 롤스", "speaker_side": "A", "message": "한 가지 묻겠습니다. 당신이 만약 그 가방의 구매자였다면..." }, - { "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "질문이 틀렸소. 명품을 사는 자들은 이미 그 게임의 규칙을 압니다..." }, - { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "이제 당신의 최종 선택을 들려주세요." } - ], - "interactive_options": [] - } - ] -} -``` - -#### 성공 응답 `201 Created` - -```json -{ - "statusCode": 201, - "data": { - "scenario_id": "scenario_001", - "battle_id": "battle_001", - "is_interactive": true, - "status": "DRAFT", - "creator_type": "ADMIN", - "nodes": [ - { - "node_id": "node_001_opening", - "node_name": "node_001_opening", - "is_start_node": true, - "audio_url": "https://cdn.pique.app/audio/battle_001_round1.mp3", - "audio_duration": 150, - "interactive_options": [ - { "label": "사회적 신뢰를 위해 정보의 투명성이 우선이다.", "next_node_id": "node_002_branch_a" }, - { "label": "시장은 개인의 욕망이 만나는 곳이다.", "next_node_id": "node_002_branch_b" } - ] - }, - { - "node_id": "node_002_branch_a", - "node_name": "node_002_branch_a", - "is_start_node": false, - "audio_url": "https://cdn.pique.app/audio/battle_001_round2_a.mp3", - "audio_duration": 110, - "interactive_options": [ - { "label": "최종 충돌 및 정리 듣기", "next_node_id": "node_003_closing" } - ] - }, - { - "node_id": "node_002_branch_b", - "node_name": "node_002_branch_b", - "is_start_node": false, - "audio_url": "https://cdn.pique.app/audio/battle_001_round2_b.mp3", - "audio_duration": 120, - "interactive_options": [ - { "label": "최종 충돌 및 정리 듣기", "next_node_id": "node_003_closing" } - ] - }, - { - "node_id": "node_003_closing", - "node_name": "node_003_closing", - "is_start_node": false, - "audio_url": "https://cdn.pique.app/audio/battle_001_round3_closing.mp3", - "audio_duration": 90, - "interactive_options": [] - } - ], - "created_at": "2026-03-10T09:00:00Z" - }, - "error": null -} -``` - ---- - -### `PATCH /api/v1/admin/scenarios/{scenario_id}` - -- 시나리오 정보를 수정합니다. 변경할 필드만 포함합니다. - -#### Request Body +### 2.3 시나리오 본문 수정 +- `PUT /api/v1/admin/scenarios/{scenarioId}` +- 설명: 노드/스크립트/분기/보이스 설정 포함 전체 콘텐츠 수정 +### 2.4 시나리오 상태 수정 +- `PATCH /api/v1/admin/scenarios/{scenarioId}` +- 요청 본문: ```json { "status": "PUBLISHED" } ``` -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "scenario_id": "scenario_001", - "battle_id": "battle_001", - "is_interactive": true, - "status": "PUBLISHED", - "creator_type": "ADMIN", - "updated_at": "2026-03-10T10:00:00Z" - }, - "error": null -} -``` - ---- - -### `DELETE /api/v1/admin/scenarios/{scenario_id}` - -- 시나리오를 삭제합니다. - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "success": true, - "deleted_at": "2026-03-10T11:00:00Z" - }, - "error": null -} -``` - ---- - -## `[후순위]` 관리자 AI 검수 API - -- 스케줄러가 자동 생성한 AI 시나리오 초안(`PENDING`)을 관리자가 검수 · 승인 · 반려합니다. - -### `GET /api/v1/admin/ai/scenarios` - -- AI가 생성한 `PENDING` 상태의 시나리오 목록을 조회합니다. - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "items": [ - { - "scenario_id": "scenario_ai_001", - "battle_id": "battle_ai_001", - "is_interactive": true, - "status": "PENDING", - "creator_type": "AI", - "nodes": [ - { - "node_id": "node_ai_001_opening", - "node_name": "node_ai_001_opening", - "is_start_node": true, - "audio_url": "https://cdn.pique.app/audio/battle_ai_001_round1.mp3", - "audio_duration": 140, - "interactive_options": [ - { "label": "AI 생성 선택지 A", "next_node_id": "node_ai_002_branch_a" }, - { "label": "AI 생성 선택지 B", "next_node_id": "node_ai_002_branch_b" } - ] - } - ], - "created_at": "2026-03-11T06:00:00Z" - } - ], - "total_count": 2 - }, - "error": null -} -``` +### 2.5 시나리오 삭제 +- `DELETE /api/v1/admin/scenarios/{scenarioId}` --- -### `PATCH /api/v1/admin/ai/scenarios/{scenario_id}` - -- AI가 생성한 시나리오를 승인하거나 반려합니다. 승인 시 내용을 수정한 뒤 승인할 수 있습니다. - -#### Request Body — 승인 - -```json -{ - "action": "APPROVE", - "nodes": [ - { - "node_id": "node_ai_001_opening", - "scripts": [ - { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "수정된 나레이션 내용..." } - ], - "interactive_options": [ - { "label": "수정된 선택지 A", "next_node_id": "node_ai_002_branch_a" }, - { "label": "수정된 선택지 B", "next_node_id": "node_ai_002_branch_b" } - ] - } - ] -} -``` - -#### Request Body — 반려 - -```json -{ - "action": "REJECT", - "reject_reason": "시나리오 흐름이 부자연스러움" -} -``` - -#### 성공 응답 `200 OK` — 승인 - -```json -{ - "statusCode": 200, - "data": { - "scenario_id": "scenario_ai_001", - "battle_id": "battle_ai_001", - "status": "PUBLISHED", - "creator_type": "AI", - "updated_at": "2026-03-11T09:00:00Z" - }, - "error": null -} -``` - -#### 성공 응답 `200 OK` — 반려 - -```json -{ - "statusCode": 200, - "data": { - "scenario_id": "scenario_ai_001", - "status": "REJECTED", - "reject_reason": "시나리오 흐름이 부자연스러움", - "updated_at": "2026-03-11T09:00:00Z" - }, - "error": null -} -``` - ---- - -## `[후순위]` 크리에이터 API - -### `POST /api/v1/scenarios` - -- 시나리오를 제안합니다. (매너 온도 45도 이상 유저) - -#### Request Body - -```json -{ - "battle_id": "battle_002", - "is_interactive": false, - "nodes": [ - { - "node_name": "node_001_full", - "is_start_node": true, - "scripts": [ - { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "AI가 그린 그림 한 장이 경매에서 1억 원에 낙찰됐습니다..." }, - { "speaker_name": "존 로크", "speaker_side": "A", "message": "노동을 투입한 자에게 소유권이 있습니다. AI 개발사가 권리를 가져야 합니다." }, - { "speaker_name": "루소", "speaker_side": "B", "message": "AI는 인류의 지식을 학습했습니다. 그 결과물은 모두의 것이어야 합니다." } - ], - "interactive_options": [] - } - ] -} -``` - -#### 성공 응답 `201 Created` - -```json -{ - "statusCode": 201, - "data": { - "scenario_id": "scenario_002", - "battle_id": "battle_002", - "is_interactive": false, - "status": "PENDING", - "creator_type": "USER", - "created_at": "2026-03-10T12:00:00Z" - }, - "error": null -} -``` - ---- - -### `PATCH /api/v1/scenarios/{scenario_id}` - -제안한 시나리오를 수정합니다. 변경할 필드만 포함합니다. - -#### Request Body - -```json -{ - "nodes": [ - { - "node_name": "node_001_full", - "is_start_node": true, - "scripts": [ - { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "AI가 그린 그림 한 장이 경매에서 1억 원에 낙찰됐습니다. (수정)" }, - { "speaker_name": "존 로크", "speaker_side": "A", "message": "노동을 투입한 자에게 소유권이 있습니다." }, - { "speaker_name": "루소", "speaker_side": "B", "message": "AI는 인류의 지식을 학습했습니다." } - ], - "interactive_options": [] - } - ] -} -``` - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "scenario_id": "scenario_002", - "battle_id": "battle_002", - "is_interactive": false, - "status": "PENDING", - "creator_type": "USER", - "updated_at": "2026-03-10T13:00:00Z" - }, - "error": null -} -``` - ---- - -### `DELETE /api/v1/scenarios/{scenario_id}` - -- 제안한 시나리오를 삭제합니다. - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "success": true, - "deleted_at": "2026-03-10T14:00:00Z" - }, - "error": null -} -``` - ---- - -## 공통 에러 코드 - -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | -| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | -| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | -| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | -| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | -| `USER_BANNED` | `403` | 제재된 사용자 | -| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | - ---- - -## 시나리오 에러 코드 - -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `SCENARIO_NOT_FOUND` | `404` | 존재하지 않는 시나리오 | -| `SCENARIO_NODE_NOT_FOUND` | `404` | 존재하지 않는 노드 | -| `SCENARIO_ALREADY_PUBLISHED` | `409` | 이미 발행된 시나리오 | -| `SCENARIO_TTS_FAILED` | `500` | TTS 생성 실패 | +## 3. 상태/동작 메모 ---- \ No newline at end of file +- 임시저장(`DRAFT`) 상태에서는 대본/설정은 DB 저장, 발행(`PUBLISHED`) 시점에 TTS 파이프라인 수행 +- 발행 후 수정 시에는 변경된 스크립트 조각만 재생성하고 병합 오디오를 갱신 diff --git a/docs/api-specs/tag-api.md b/docs/api-specs/tag-api.md index e852f17b..ed2fc311 100644 --- a/docs/api-specs/tag-api.md +++ b/docs/api-specs/tag-api.md @@ -1,188 +1,58 @@ -# 태그 API 명세서 +# 태그(Tag) API 명세 ---- - -## 설계 메모 - -- **태그 구조 :** - - 태그는 별도 `TAGS` 테이블로 관리하며, `BATTLE_TAGS` 중간 테이블을 통해 배틀과 N:M 관계를 가집니다. -- **태그 목록 조회 :** - - 관리자가 배틀에 태그를 붙일 때 선택 목록 제공 및 클라이언트 필터 UI 구성에 활용됩니다. -- **태그 기반 배틀 필터링 :** - - `tag_id` 쿼리 파라미터로 특정 태그가 붙은 배틀 목록을 조회합니다. - ---- - -## 사용자 API - -### `GET /api/v1/tags` +기준 코드: +`src/main/java/com/swyp/picke/domain/tag/controller/TagController.java` +`src/main/java/com/swyp/picke/domain/admin/controller/AdminTagController.java` -- 전체 태그 목록을 조회합니다. 클라이언트 필터 UI 구성 및 관리자 태그 선택에 활용됩니다. +## 1. 태그 타입 -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "items": [ - { "tag_id": "tag_001", "name": "사회" }, - { "tag_id": "tag_002", "name": "철학" }, - { "tag_id": "tag_003", "name": "롤스" }, - { "tag_id": "tag_004", "name": "니체" }, - { "tag_id": "tag_005", "name": "경제" }, - { "tag_id": "tag_006", "name": "윤리" } - ], - "total_count": 6 - }, - "error": null -} -``` +`TagType` +- `CATEGORY` +- `PHILOSOPHER` +- `VALUE` --- -### `GET /api/v1/battles?tag_id={tag_id}` - -- 특정 태그가 붙은 배틀 목록을 조회합니다. - -#### Query Parameters - -| 파라미터 | 타입 | 필수 | 설명 | -|----------|------|:----:|------| -| `tag_id` | string | ✅ | 필터링할 태그 ID | - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "tag": { "tag_id": "tag_002", "name": "철학" }, - "items": [ - { - "battle_id": "battle_001", - "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", - "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", - "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", - "tags": [ - { "tag_id": "tag_001", "name": "사회" }, - { "tag_id": "tag_002", "name": "철학" } - ], - "participants_count": 2148, - "audio_duration": 420, - "options": [ - { "option_id": "option_A", "label": "A", "title": "사기다 (롤스)" }, - { "option_id": "option_B", "label": "B", "title": "사기가 아니다 (니체)" } - ], - "user_vote_status": "NONE" - } - ], - "total_count": 1 - }, - "error": null -} -``` - ---- - -## 관리자 API - -### `POST /api/v1/admin/tags` - -- 새 태그를 생성합니다. - -#### Request Body +## 2. 사용자 API -```json -{ - "name": "정치" -} -``` - -#### 성공 응답 `201 Created` - -```json -{ - "statusCode": 201, - "data": { - "tag_id": "tag_007", - "name": "정치", - "created_at": "2026-03-10T09:00:00Z" - }, - "error": null -} -``` +### 2.1 태그 목록 조회 +- `GET /api/v1/tags` +- 쿼리 파라미터: + - `type` (선택): `CATEGORY`, `PHILOSOPHER`, `VALUE` --- -### `PATCH /api/v1/admin/tags/{tag_id}` - -- 태그명을 수정합니다. - -#### Request Body +## 3. 관리자 API +### 3.1 태그 생성 +- `POST /api/v1/admin/tags` +- 요청 본문: ```json { - "name": "사회" + "name": "자유", + "type": "VALUE" } ``` -#### 성공 응답 `200 OK` - +### 3.2 태그 수정 +- `PATCH /api/v1/admin/tags/{tagId}` +- 요청 본문: ```json { - "statusCode": 200, - "data": { - "tag_id": "tag_007", - "name": "사회", - "updated_at": "2026-03-10T10:00:00Z" - }, - "error": null + "name": "연대", + "type": "VALUE" } ``` ---- - -### `DELETE /api/v1/admin/tags/{tag_id}` - -- 태그를 삭제합니다. 연결된 `BATTLE_TAGS` 레코드도 함께 삭제됩니다. - -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "success": true, - "deleted_at": "2026-03-10T11:00:00Z" - }, - "error": null -} -``` - ---- - -## 공통 에러 코드 - -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | -| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | -| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | -| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | -| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | -| `USER_BANNED` | `403` | 제재된 사용자 | -| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | +### 3.3 태그 삭제 +- `DELETE /api/v1/admin/tags/{tagId}` --- -## 태그 에러 코드 - -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `TAG_NOT_FOUND` | `404` | 존재하지 않는 태그 | -| `TAG_ALREADY_EXISTS` | `409` | 이미 존재하는 태그명 | -| `TAG_IN_USE` | `409` | 배틀에 사용 중인 태그 (삭제 불가) | -| `TAG_LIMIT_EXCEEDED` | `400` | 배틀당 태그 최대 개수 초과 | +## 4. 매핑 정책(중요) ---- \ No newline at end of file +- 현재 태그는 **배틀 도메인에서만 사용** +- 매핑 테이블: + - `battle_tags` (배틀-카테고리) + - `battle_option_tags` (배틀 옵션-철학자/가치관) +- 퀴즈/폴은 태그 매핑을 사용하지 않음 diff --git a/docs/api-specs/user-api.md b/docs/api-specs/user-api.md index bf50c055..917fa619 100644 --- a/docs/api-specs/user-api.md +++ b/docs/api-specs/user-api.md @@ -10,10 +10,13 @@ - `user_tag`는 prefix 없이 생성되는 8자리 이하의 랜덤 문자열입니다. - 프로필 아바타는 자유 입력 이모지가 아니라 `character_type` 선택 방식으로 관리합니다. - `GET /api/v1/me/mypage`는 상단 요약 조회, `GET /api/v1/me/recap`은 상세 리캡 조회에 사용합니다. +- `GET /api/v1/me/credits/history`는 로그인한 사용자의 크레딧 적립/소비 내역을 `offset/size` 기반으로 조회합니다. - 프론트는 `philosopher_type` 값에 따라 사전 정의된 철학자 카드를 통째로 교체 렌더링합니다. - 그래서 백엔드는 철학자 카드용 `title`, `description`, 해시태그 문구를 내려주지 않습니다. -- 포인트(`point`)는 새 개념으로 도입하되, 이번 버전에서는 현재 DB에서 계산 가능한 항목만 부분 반영합니다. -- 현재 반영 규칙은 `완료된 사후 투표 x 10P`, `입장 변경 x 20P 보너스`입니다. +- 현재 크레딧(`current_point`)은 `users.credit` 캐시 컬럼 기준으로 조회합니다. +- 현재 반영 크레딧 타입은 `BATTLE_VOTE(5)`, `MAJORITY_WIN(10)`, `BEST_COMMENT(50)`, `WEEKLY_CHARGE(40)`, `FREE_CHARGE(가변)` 입니다. +- 다수결/베댓 보상은 매주 월요일 00:00(KST) 배치로 정산하며 대상 배틀 윈도우는 `runDate - 20일`부터 `runDate - 14일`까지입니다. +- 베댓 보상은 배틀당 좋아요 상위 3개 관점만 대상이며 각 관점은 좋아요 10개 이상이어야 합니다. - 철학자 산출 로직은 추후 확정 예정이며, 현재는 프론트 연동을 위해 임시로 `SOCRATES`를 반환합니다. ### 1.1 공통 프로필 응답 필드 @@ -33,6 +36,7 @@ | `character_type` | `OWL \| FOX \| WOLF \| LION \| PENGUIN \| BEAR \| RABBIT \| CAT` | | `activity_type` | `COMMENT \| LIKE` | | `vote_side` | `PRO \| CON` | +| `credit_type` | `BATTLE_VOTE \| MAJORITY_WIN \| BEST_COMMENT \| WEEKLY_CHARGE \| FREE_CHARGE` | --- @@ -236,7 +240,101 @@ } ``` -### 3.5 `GET /api/v1/me/notification-settings` +### 3.5 `GET /api/v1/me/credits/history` + +로그인한 사용자의 크레딧 적립/소비 내역 조회. + +쿼리 파라미터: + +- `offset`: 선택, 0-based 시작 위치 +- `size`: 선택 + +응답: + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "id": 301, + "credit_type": "BEST_COMMENT", + "amount": 50, + "reference_id": 200, + "created_at": "2026-04-13T00:00:00" + }, + { + "id": 300, + "credit_type": "BATTLE_VOTE", + "amount": 5, + "reference_id": 12345, + "created_at": "2026-04-12T14:30:00" + } + ], + "next_offset": 20, + "has_next": true + }, + "error": null +} +``` + +### 3.6 `GET /api/v1/share/recap` + +현재 로그인한 사용자의 리캡 공유 키 발급. +이미 발급된 키가 있으면 동일 키를 재사용합니다. + +응답: + +```json +{ + "statusCode": 200, + "data": { + "shareKey": "550e8400-e29b-41d4-a716-446655440000" + }, + "error": null +} +``` + +### 3.7 `GET /api/v1/share/recap/{shareKey}` + +공유 키로 다른 사용자의 리캡 조회. +인증 없이 호출 가능합니다. + +응답: + +```json +{ + "statusCode": 200, + "data": { + "my_card": { + "philosopher_type": "SOCRATES" + }, + "best_match_card": { + "philosopher_type": "PLATO" + }, + "worst_match_card": { + "philosopher_type": "MARX" + }, + "scores": { + "principle": 88, + "reason": 74, + "individual": 62, + "change": 45, + "inner": 30, + "ideal": 15 + }, + "preference_report": { + "total_participation": 47, + "opinion_changes": 12, + "battle_win_rate": 68, + "favorite_topics": [] + } + }, + "error": null +} +``` + +### 3.8 `GET /api/v1/me/notification-settings` 마이페이지 알림 설정 조회. @@ -257,7 +355,7 @@ } ``` -### 3.6 `PATCH /api/v1/me/notification-settings` +### 3.9 `PATCH /api/v1/me/notification-settings` 마이페이지 알림 설정 부분 수정. @@ -287,7 +385,7 @@ } ``` -### 3.7 `GET /api/v1/me/notices` +### 3.10 `GET /api/v1/me/notices` 공지/이벤트 목록 조회. @@ -316,7 +414,7 @@ } ``` -### 3.8 `GET /api/v1/me/notices/{noticeId}` +### 3.11 `GET /api/v1/me/notices/{noticeId}` 공지/이벤트 상세 조회. diff --git a/docs/api-specs/vote-api.md b/docs/api-specs/vote-api.md index 0ebae6d4..6420da5e 100644 --- a/docs/api-specs/vote-api.md +++ b/docs/api-specs/vote-api.md @@ -1,256 +1,91 @@ -# 투표 API 명세서 +# 투표(Vote) API 명세 ---- - -## 설계 메모 - -- **사전/사후 투표 단일 레코드 :** - - 사전 투표와 사후 투표는 `VOTES` 테이블의 단일 레코드로 관리됩니다. `status` 필드(`NONE` → `PRE_VOTED` → `POST_VOTED`)로 진행 단계를 추적합니다. -- **투표 수정 :** - - 투표 입장 변경은 `PATCH` 메서드를 사용합니다. `vote_type` 필드로 사전/사후 구분합니다. -- **사후 투표 응답 :** - - 사후 투표 완료 시 `mind_changed` 여부와 전체 통계, 리워드 정보를 함께 반환합니다. - ---- - -## 사용자 API - -### `POST /api/v1/battles/{battle_id}/votes/pre` - -- 시나리오 청취 전 사전 투표를 진행합니다. +기준 코드: `src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java` -#### Request Body +## 1. 퀴즈 투표 +### 1.1 퀴즈 응답 제출 +- `POST /api/v1/battles/{battleId}/quiz-vote` +- 요청 본문: ```json { - "option_id": "option_A" + "optionId": 1 } ``` -#### 성공 응답 `200 OK` +### 1.2 내 퀴즈 투표 조회 +- `GET /api/v1/battles/{battleId}/quiz-vote/me` -```json -{ - "statusCode": 200, - "data": { - "vote_id": "vote_001", - "status": "PRE_VOTED", - "next_step_url": "/battles/battle_001/scenario" - }, - "error": null -} -``` +> 참고: 현재 경로 변수 이름은 `battleId`지만 내부적으로 `quizId`로 사용됩니다. --- -### `POST /api/v1/battles/{battle_id}/votes/post` - -- 시나리오 청취 후 최종 사후 투표를 진행합니다. 완료 시 결과 통계와 리워드를 함께 반환합니다. - -#### Request Body +## 2. Poll 투표 +### 2.1 Poll 선택 제출 +- `POST /api/v1/battles/{battleId}/poll-vote` +- 요청 본문: ```json { - "option_id": "option_A" + "optionId": 1 } ``` -#### 성공 응답 `200 OK` +### 2.2 내 Poll 투표 조회 +- `GET /api/v1/battles/{battleId}/poll-vote/me` -```json -{ - "statusCode": 200, - "data": { - "vote_id": "vote_001", - "mind_changed": false, - "status": "POST_VOTED", - "statistics": { - "option_A_ratio": 65, - "option_B_ratio": 35 - }, - "reward": { - "is_majority": true, - "credits_earned": 10 - }, - "updated_at": "2026-03-10T16:35:00Z" - }, - "error": null -} -``` +> 참고: 현재 경로 변수 이름은 `battleId`지만 내부적으로 `pollId`로 사용됩니다. --- -### `PATCH /api/v1/battles/{battle_id}/votes` - -- 기존 투표 입장을 변경합니다. `vote_type`으로 사전/사후 투표를 구분합니다. - -#### Request Body +## 3. 배틀 사전/사후 투표 +### 3.1 사전 투표 +- `POST /api/v1/battles/{battleId}/votes/pre` +- 요청 본문: ```json { - "vote_type": "PRE", - "option_id": "option_B" + "optionId": 1 } ``` -#### 성공 응답 `200 OK` - -```json -{ - "statusCode": 200, - "data": { - "vote_id": "vote_001", - "updated_at": "2026-03-10T16:40:00Z" - }, - "error": null -} -``` - ---- - -### `DELETE /api/v1/battles/{battle_id}/votes` - -- 투표 이력을 취소 및 삭제합니다. - -#### 성공 응답 `200 OK` - +### 3.2 사후 투표 +- `POST /api/v1/battles/{battleId}/votes/post` +- 요청 본문: ```json { - "statusCode": 200, - "data": { - "success": true, - "deleted_at": "2026-03-10T16:45:00Z" - }, - "error": null + "optionId": 1 } ``` ---- - -### `GET /api/v1/battles/{battle_id}/vote-stats` - -- 투표 %를 조회 - -#### 성공 응답 `200 OK` +### 3.3 TTS 청취 완료 +- `POST /api/v1/battles/{battleId}/votes/tts-complete` -```json -{ - "statusCode": 200, - "data": { - "options": [ - { - "option_id": "option_A", - "label": "A", - "title": "찬성", - "vote_count": 1259, - "ratio": 59.5 - }, - { - "option_id": "option_B", - "label": "B", - "title": "반대", - "vote_count": 856, - "ratio": 40.5 - } - ], - "total_count": 2115, - "updated_at": "2026-03-11T12:00:00Z" - }, - "error": null -} -``` +### 3.4 배틀 투표 통계 +- `GET /api/v1/battles/{battleId}/vote-stats` -#### 예외 응답 `404 - 배틀없음` +### 3.5 내 배틀 투표 이력 +- `GET /api/v1/battles/{battleId}/votes/me` -```json -{ - "statusCode": 404, - "data": null, - "error": { - "code": "BATTLE_NOT_FOUND", - "message": "존재하지 않는 배틀입니다.", - "errors": [] - } -} -``` --- -### `GET /api/v1/battles/{battle_id}/votes/me` -- 투표 %를 조회 +## 4. 관리자 투표 데이터 정리 API -#### 성공 응답 `200 OK` +### 4.1 배틀 투표 기록 삭제 +- `DELETE /api/v1/admin/votes/battle/{battleId}` -```json -{ - "statusCode": 200, - "data": { - "pre_vote": { - "option_id": "option_A", - "label": "A", - "title": "찬성" - }, - "post_vote": { - "option_id": "option_A", - "label": "A", - "title": "찬성" - }, - "mind_changed": false, - "status": "POST_VOTED" - }, - "error": null -} -``` +### 4.2 퀴즈 투표 기록 삭제 +- `DELETE /api/v1/admin/votes/quiz/{battleId}` -#### 예외 응답 `404 - 배틀없음` - -```json -{ - "statusCode": 404, - "data": null, - "error": { - "code": "BATTLE_NOT_FOUND", - "message": "존재하지 않는 배틀입니다.", - "errors": [] - } -} -``` - -#### 예외 응답 `404 - 투표 내역 없음` - -```json -{ - "statusCode": 404, - "data": null, - "error": { - "code": "VOTE_NOT_FOUND", - "message": "투표 내역이 없습니다.", - "errors": [] - } -} -``` +### 4.3 Poll 투표 기록 삭제 +- `DELETE /api/v1/admin/votes/poll/{battleId}` --- -## 공통 에러 코드 - -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | -| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | -| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | -| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | -| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | -| `USER_BANNED` | `403` | 제재된 사용자 | -| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | - ---- - -## 투표 에러 코드 -| Error Code | HTTP Status | 설명 | -|------------|:-----------:|------| -| `VOTE_NOT_FOUND` | `404` | 존재하지 않는 투표 | -| `VOTE_ALREADY_SUBMITTED` | `409` | 이미 투표 완료 | -| `PRE_VOTE_REQUIRED` | `409` | 사전 투표 필요 | -| `POST_VOTE_REQUIRED` | `409` | 사후 투표 필요 | +## 5. 응답 DTO 메모 ---- \ No newline at end of file +- 퀴즈 투표 응답: `QuizVoteResponse` + - `selectedOptionId`, `totalCount`, `stats[].isCorrect` 포함 +- Poll 투표 응답: `PollVoteResponse` + - `selectedOptionId`, `totalCount`, `stats[].ratio` 포함 +- 배틀 투표 응답: `VoteResultResponse`, `VoteStatsResponse`, `MyVoteResponse` diff --git a/docs/erd/battle.puml b/docs/erd/battle.puml index 5ee0808b..70fcc755 100644 --- a/docs/erd/battle.puml +++ b/docs/erd/battle.puml @@ -3,87 +3,60 @@ hide circle hide methods skinparam linetype ortho -' ─────────────────────────────── -' 테이블 정의 -' ─────────────────────────────── - -entity "users\n사용자" as users { +entity "users" as users { * id : BIGINT <> -- - email : VARCHAR(255) <> - nickname : VARCHAR(50) <> - character_id : INT <> - role : ENUM('USER', 'ADMIN') - status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') + email : VARCHAR(255) + nickname : VARCHAR(50) + role : VARCHAR(20) + status : VARCHAR(20) created_at : TIMESTAMP updated_at : TIMESTAMP } -entity "BATTLES\n배틀(주제)" as battles { - * id : Long <> +entity "battles" as battles { + * id : BIGINT <> -- title : VARCHAR(255) summary : VARCHAR(500) description : TEXT thumbnail_url : VARCHAR(500) + view_count : INT + total_participants : BIGINT target_date : DATE - status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') - creator_type : ENUM('ADMIN', 'USER', 'AI') - creator_id : BIGINT <> (nullable) - reject_reason : VARCHAR(500) (nullable) + audio_duration : INT + status : VARCHAR(20) + creator_type : VARCHAR(10) + creator_id : BIGINT <> + is_editor_pick : BOOLEAN + comment_count : BIGINT + deleted_at : TIMESTAMP created_at : TIMESTAMP updated_at : TIMESTAMP } -entity "BATTLE_OPTIONS\n선택지" as battle_options { - * id : Long <> +entity "battle_options" as battle_options { + * id : BIGINT <> -- - battle_id : Long <> - label : ENUM('A', 'B') + battle_id : BIGINT <> + label : VARCHAR(10) title : VARCHAR(100) stance : VARCHAR(255) representative : VARCHAR(100) - quote : TEXT - keywords : JSONB + vote_count : BIGINT image_url : VARCHAR(500) + display_order : INT + created_at : TIMESTAMP + updated_at : TIMESTAMP } -' ─────────────────────────────── -' 배치 가이드 (위→아래) -' ─────────────────────────────── - -users -[hidden]down- battles -battles -[hidden]down- battle_options - -' ─────────────────────────────── -' 관계 -' ─────────────────────────────── - -users ||--o{ battles : "creates" -battles ||--o{ battle_options : "has" - -' ─────────────────────────────── -' 노트 -' ─────────────────────────────── +users ||--o{ battles : creates +battles ||--o{ battle_options : has note right of battles - status 흐름: - - [관리자 직접 발행] - DRAFT → PUBLISHED → ARCHIVED - - [AI 자동 생성 · 스케줄러 - 후순위] - PENDING → PUBLISHED → ARCHIVED - → REJECTED - - [유저 크리에이터 - 후순위] - PENDING → PUBLISHED → ARCHIVED - → REJECTED - - creator_type - ADMIN : 관리자 직접 발행 → creator_id = null - AI : [후순위] 스케줄러 자동 생성 → creator_id = null - USER : [후순위] 유저 제안 → creator_id = users.id + Battle 도메인 전용 테이블 + - 퀴즈/폴 필드와 분리됨 + - target_date는 서버 정책으로 자동 관리 end note @enduml diff --git a/docs/erd/poll.puml b/docs/erd/poll.puml new file mode 100644 index 00000000..f4beebc5 --- /dev/null +++ b/docs/erd/poll.puml @@ -0,0 +1,38 @@ +@startuml poll +hide circle +hide methods +skinparam linetype ortho + +entity "poll_contents" as poll_contents { + * id : BIGINT <> + -- + title_prefix : VARCHAR(200) + title_suffix : VARCHAR(200) + target_date : DATE + total_participants_count : BIGINT + status : VARCHAR(20) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "poll_options" as poll_options { + * id : BIGINT <> + -- + poll_id : BIGINT <> + label : VARCHAR(10) + title : VARCHAR(200) + display_order : INT + vote_count : BIGINT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +poll_contents ||--o{ poll_options : has + +note right of poll_contents + Poll 도메인 전용 테이블 + - 태그 매핑 없음 + - 옵션별 vote_count 유지 +end note + +@enduml diff --git a/docs/erd/quiz.puml b/docs/erd/quiz.puml new file mode 100644 index 00000000..4af72ce7 --- /dev/null +++ b/docs/erd/quiz.puml @@ -0,0 +1,38 @@ +@startuml quiz +hide circle +hide methods +skinparam linetype ortho + +entity "quizzes" as quizzes { + * id : BIGINT <> + -- + title : VARCHAR(200) + target_date : DATE + total_participants_count : BIGINT + status : VARCHAR(20) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "quiz_options" as quiz_options { + * id : BIGINT <> + -- + quiz_id : BIGINT <> + label : VARCHAR(10) + text : VARCHAR(300) + detail_text : VARCHAR(1000) + is_correct : BOOLEAN + display_order : INT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +quizzes ||--o{ quiz_options : has + +note right of quizzes + Quiz 도메인 전용 테이블 + - 태그 매핑 없음 + - 총 참여자 수는 total_participants_count로 집계 +end note + +@enduml diff --git a/docs/erd/scenario.puml b/docs/erd/scenario.puml index 285c348b..0e5d2e38 100644 --- a/docs/erd/scenario.puml +++ b/docs/erd/scenario.puml @@ -3,125 +3,85 @@ hide circle hide methods skinparam linetype ortho -' ─────────────────────────────── -' 테이블 정의 -' ─────────────────────────────── +entity "battles" as battles { + * id : BIGINT <> + -- + title : VARCHAR(255) +} -entity "users\n사용자" as users { +entity "scenarios" as scenarios { * id : BIGINT <> -- - email : VARCHAR(255) <> - nickname : VARCHAR(50) <> - character_id : INT <> - role : ENUM('USER', 'ADMIN') - status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') + battle_id : BIGINT <> + is_interactive : BOOLEAN + status : VARCHAR(20) + creator_type : VARCHAR(20) created_at : TIMESTAMP updated_at : TIMESTAMP } -entity "BATTLES\n배틀(주제)" as battles { - * id : Long <> +entity "scenario_nodes" as scenario_nodes { + * id : BIGINT <> -- - title : VARCHAR(255) - summary : VARCHAR(500) - description : TEXT - thumbnail_url : VARCHAR(500) - target_date : DATE - status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') - creator_type : ENUM('ADMIN', 'USER', 'AI') - creator_id : BIGINT <> (nullable) - reject_reason : TEXT (nullable) + scenario_id : BIGINT <> + node_name : VARCHAR(100) + is_start_node : BOOLEAN + audio_duration : INT + auto_next_node_id : BIGINT + node_order : INT created_at : TIMESTAMP updated_at : TIMESTAMP } +entity "scenario_scripts" as scenario_scripts { + * id : BIGINT <> + -- + node_id : BIGINT <> + start_time_ms : INT + speaker_type : VARCHAR(20) + speaker_name : VARCHAR(100) + text : TEXT + audio_url : VARCHAR(500) + script_order : INT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} -entity "SCENARIOS\n시나리오 마스터" as scenarios { - * id : Long <> +entity "scenario_options" as scenario_options { + * id : BIGINT <> -- - battle_id : Long <> - creator_type : ENUM('ADMIN', 'USER', 'AI') - creator_id : BIGINT <> (nullable) - is_interactive : BOOLEAN - status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') - reject_reason : VARCHAR(500) (nullable) + node_id : BIGINT <> + label : VARCHAR(200) + next_node_id : BIGINT + option_order : INT created_at : TIMESTAMP updated_at : TIMESTAMP } -entity "SCENARIO_NODES\n시나리오 노드 (오디오/분기 통합)" as scenario_nodes { - * id : Long <> +entity "scenario_audios" as scenario_audios { + * scenario_id : BIGINT <> + * path_key : VARCHAR(50) -- - scenario_id : Long <> - node_name : VARCHAR(100) audio_url : VARCHAR(500) - audio_duration : INT - is_start_node : BOOLEAN - interactive_options : JSONB } -entity "SCENARIO_SCRIPTS\n대본(말풍선)" as scenario_scripts { - * id : Long <> +entity "scenario_voice_settings" as scenario_voice_settings { + * scenario_id : BIGINT <> + * speaker_type : VARCHAR(20) -- - node_id : Long <> - start_time : INT - speaker_name : VARCHAR(100) - speaker_side : ENUM('A', 'B', 'NONE') - message : TEXT + voice_code : VARCHAR(200) } -' ─────────────────────────────── -' 배치 가이드 (위→아래) -' ─────────────────────────────── - -users -[hidden]down- battles -battles -[hidden]down- scenarios -scenarios -[hidden]down- scenario_nodes -scenario_nodes -[hidden]down- scenario_scripts - -' ─────────────────────────────── -' 관계 -' ─────────────────────────────── - -users ||--o{ scenarios : "creates" -battles ||--|| scenarios : "has" -scenarios ||--o{ scenario_nodes : "contains" -scenario_nodes ||--o{ scenario_scripts : "contains" - -' ─────────────────────────────── -' 노트 -' ─────────────────────────────── +battles ||--|| scenarios : has +scenarios ||--o{ scenario_nodes : has +scenario_nodes ||--o{ scenario_scripts : has +scenario_nodes ||--o{ scenario_options : has +scenarios ||--o{ scenario_audios : has +scenarios ||--o{ scenario_voice_settings : has note right of scenarios - status 흐름: - - [관리자 직접 발행] - DRAFT → PUBLISHED → ARCHIVED - - [AI 자동 생성 · 스케줄러 - 후순위] - PENDING → PUBLISHED → ARCHIVED - → REJECTED - - [유저 크리에이터 - 후순위] - PENDING → PUBLISHED → ARCHIVED - → REJECTED - - is_interactive = false : - 노드 1개, interactive_options = [] - - is_interactive = true : - 오프닝 → 분기(A/B) → 클로징 - interactive_options = [ - { label, next_node_id } - ] - - PUBLISHED 전환 시 - TTS 생성 + CDN 업로드 자동 연동 - - creator_type - ADMIN : 관리자 직접 발행 → creator_id = null - AI : [후순위] 스케줄러 자동 생성 → creator_id = null - USER : [후순위] 유저 제안 → creator_id = users.id + 발행(PUBLISHED) 시점에 TTS 파이프라인 수행 + voice_settings는 화자별 보이스 코드 저장 end note @enduml diff --git a/docs/erd/tag.puml b/docs/erd/tag.puml index 8a9c5b53..4e575124 100644 --- a/docs/erd/tag.puml +++ b/docs/erd/tag.puml @@ -3,59 +3,60 @@ hide circle hide methods skinparam linetype ortho -' ─────────────────────────────── -' 테이블 정의 -' ─────────────────────────────── - -entity "BATTLES\n배틀(주제)" as battles { - * id : Long <> +entity "tags" as tags { + * id : BIGINT <> -- - title : VARCHAR(255) - summary : VARCHAR(500) - description : TEXT - thumbnail_url : VARCHAR(500) - target_date : DATE - status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') - creator_type : ENUM('ADMIN', 'USER', 'AI') - creator_id : BIGINT <> (nullable) - reject_reason : VARCHAR(500) (nullable) + name : VARCHAR(50) + type : VARCHAR(20) ' CATEGORY / PHILOSOPHER / VALUE + deleted_at : TIMESTAMP created_at : TIMESTAMP updated_at : TIMESTAMP } -entity "TAGS\n태그" as tags { - * id : Long <> +entity "battles" as battles { + * id : BIGINT <> -- - name : VARCHAR(50) <> - created_at : TIMESTAMP + title : VARCHAR(255) } -entity "BATTLE_TAGS\n배틀-태그 매핑" as battle_tags { - * battle_id : Long <> - * tag_id : Long <> +entity "battle_options" as battle_options { + * id : BIGINT <> + -- + battle_id : BIGINT <> + label : VARCHAR(10) + title : VARCHAR(100) } -' ─────────────────────────────── -' 배치 가이드 (좌→우→아래) -' ─────────────────────────────── - -battles -[hidden]right- tags -tags -[hidden]down- battle_tags +entity "battle_tags" as battle_tags { + * id : BIGINT <> + -- + battle_id : BIGINT <> + tag_id : BIGINT <> + created_at : TIMESTAMP + updated_at : TIMESTAMP + UNIQUE (battle_id, tag_id) +} -' ─────────────────────────────── -' 관계 -' ─────────────────────────────── +entity "battle_option_tags" as battle_option_tags { + * id : BIGINT <> + -- + battle_option_id : BIGINT <> + tag_id : BIGINT <> + created_at : TIMESTAMP + updated_at : TIMESTAMP + UNIQUE (battle_option_id, tag_id) +} -battles ||--o{ battle_tags : "tagged with" -tags ||--o{ battle_tags : "used in" +battles ||--o{ battle_tags : has +tags ||--o{ battle_tags : mapped -' ─────────────────────────────── -' 노트 -' ─────────────────────────────── +battle_options ||--o{ battle_option_tags : has +tags ||--o{ battle_option_tags : mapped -note bottom of battle_tags - 복합 PK: (battle_id, tag_id) - 배틀과 태그의 N:M 관계를 처리하는 중간 테이블 +note right of tags + 현재 태그는 Battle 도메인에서만 사용 + - battle_tags: 카테고리 + - battle_option_tags: 철학자/가치관 end note @enduml diff --git a/docs/erd/vote.puml b/docs/erd/vote.puml index ddadde81..18a95ab6 100644 --- a/docs/erd/vote.puml +++ b/docs/erd/vote.puml @@ -3,99 +3,111 @@ hide circle hide methods skinparam linetype ortho -' ─────────────────────────────── -' 테이블 정의 -' ─────────────────────────────── - -entity "users\n사용자" as users { +entity "users" as users { * id : BIGINT <> -- - email : VARCHAR(255) <> - nickname : VARCHAR(50) <> - character_id : INT <> - role : ENUM('USER', 'ADMIN') - status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') - created_at : TIMESTAMP - updated_at : TIMESTAMP + email : VARCHAR(255) + nickname : VARCHAR(50) } -entity "BATTLES\n배틀(주제)" as battles { - * id : Long <> +entity "battles" as battles { + * id : BIGINT <> -- title : VARCHAR(255) - summary : VARCHAR(500) - description : TEXT - thumbnail_url : VARCHAR(500) - target_date : DATE - status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') - creator_type : ENUM('ADMIN', 'USER', 'AI') - creator_id : BIGINT <> (nullable) - reject_reason : VARCHAR(500) (nullable) +} + +entity "battle_options" as battle_options { + * id : BIGINT <> + -- + battle_id : BIGINT <> + label : VARCHAR(10) +} + +entity "quizzes" as quizzes { + * id : BIGINT <> + -- + title : VARCHAR(200) + total_participants_count : BIGINT +} + +entity "quiz_options" as quiz_options { + * id : BIGINT <> + -- + quiz_id : BIGINT <> + label : VARCHAR(10) + is_correct : BOOLEAN +} + +entity "poll_contents" as poll_contents { + * id : BIGINT <> + -- + title_prefix : VARCHAR(200) + title_suffix : VARCHAR(200) + total_participants_count : BIGINT +} + +entity "poll_options" as poll_options { + * id : BIGINT <> + -- + poll_id : BIGINT <> + label : VARCHAR(10) + vote_count : BIGINT +} + +entity "votes" as votes { + * id : BIGINT <> + -- + user_id : BIGINT <> + battle_id : BIGINT <> + pre_vote_option_id : BIGINT <> + post_vote_option_id : BIGINT <> + is_tts_listened : BOOLEAN created_at : TIMESTAMP updated_at : TIMESTAMP } -entity "BATTLE_OPTIONS\n선택지" as battle_options { - * id : Long <> +entity "quiz_user_votes" as quiz_user_votes { + * id : BIGINT <> -- - battle_id : Long <> - label : ENUM('A', 'B') - title : VARCHAR(100) - stance : VARCHAR(255) - representative : VARCHAR(100) - quote : TEXT - image_url : VARCHAR(500) + user_id : BIGINT <> + quiz_id : BIGINT <> + option_id : BIGINT <> + created_at : TIMESTAMP + updated_at : TIMESTAMP } -entity "VOTES\n투표 이력" as votes { - * id : Long <> +entity "poll_user_votes" as poll_user_votes { + * id : BIGINT <> -- user_id : BIGINT <> - battle_id : Long <> - pre_vote_option_id : Long <> (nullable) - post_vote_option_id : Long <> (nullable) - mind_changed : BOOLEAN - reward_credits : INT - status : ENUM('NONE', 'PRE_VOTED', 'POST_VOTED') + poll_id : BIGINT <> + option_id : BIGINT <> created_at : TIMESTAMP updated_at : TIMESTAMP } -' ─────────────────────────────── -' 배치 가이드 -' users battles -' \ | -' votes battle_options -' ─────────────────────────────── - -users -[hidden]right- battles -battles -[hidden]down- battle_options -users -[hidden]down- votes -votes -[hidden]right- battle_options - -' ─────────────────────────────── -' 관계 -' ─────────────────────────────── - -users ||--o{ votes : "votes" -battles ||--o{ battle_options : "has" -battles ||--o{ votes : "receives" -votes }o--|| battle_options : "pre_vote" -votes }o--|| battle_options : "post_vote" - -' ─────────────────────────────── -' 노트 -' ─────────────────────────────── - -note right of votes - status 흐름: - NONE → PRE_VOTED → POST_VOTED - - pre_vote_option_id : 사전 투표 선택지 (nullable) - post_vote_option_id : 사후 투표 선택지 (nullable) - - mind_changed: - pre_vote_option_id ≠ post_vote_option_id 이면 true +users ||--o{ votes : votes +battles ||--o{ votes : target +battle_options ||--o{ votes : pre/post option + +users ||--o{ quiz_user_votes : votes +quizzes ||--o{ quiz_user_votes : target +quiz_options ||--o{ quiz_user_votes : selected + +users ||--o{ poll_user_votes : votes +poll_contents ||--o{ poll_user_votes : target +poll_options ||--o{ poll_user_votes : selected + +note bottom of votes + Battle 투표(사전/사후) 전용 테이블 +end note + +note bottom of quiz_user_votes + Quiz 정답 제출 투표 테이블 +end note + +note bottom of poll_user_votes + Poll 선택 투표 테이블 end note @enduml diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java new file mode 100644 index 00000000..49e5cf61 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java @@ -0,0 +1,75 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.admin.service.AdminBattleService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 배틀 API", description = "관리자 배틀 콘텐츠 생성, 조회, 수정, 삭제") +@RestController +@RequestMapping("/api/v1/admin/battles") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminBattleController { + + private final AdminBattleService adminBattleService; + + @Operation(summary = "배틀 생성") + @PostMapping + public ApiResponse createBattle( + @RequestBody @Valid AdminBattleCreateRequest request, + @AuthenticationPrincipal Long adminUserId + ) { + return ApiResponse.onSuccess(adminBattleService.createBattle(request, adminUserId)); + } + + @Operation(summary = "배틀 상세 조회") + @GetMapping("/{battleId}") + public ApiResponse getBattleDetail(@PathVariable Long battleId) { + return ApiResponse.onSuccess(adminBattleService.getBattleDetail(battleId)); + } + + @Operation(summary = "배틀 수정") + @PatchMapping("/{battleId}") + public ApiResponse updateBattle( + @PathVariable Long battleId, + @RequestBody @Valid AdminBattleUpdateRequest request + ) { + return ApiResponse.onSuccess(adminBattleService.updateBattle(battleId, request)); + } + + @Operation(summary = "배틀 삭제") + @DeleteMapping("/{battleId}") + public ApiResponse deleteBattle( + @PathVariable Long battleId + ) { + return ApiResponse.onSuccess(adminBattleService.deleteBattle(battleId)); + } + + @Operation(summary = "배틀 목록 조회") + @GetMapping + public ApiResponse getBattles( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size, + @RequestParam(value = "status", required = false) String status + ) { + return ApiResponse.onSuccess(adminBattleService.getBattles(page, size, status)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java new file mode 100644 index 00000000..bfb1cd16 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java @@ -0,0 +1,54 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; +import com.swyp.picke.domain.admin.service.AdminNotificationService; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 공지 API", description = "공지사항/이벤트 작성 및 조회") +@RestController +@RequestMapping("/api/v1/admin/notices") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminNotificationController { + + private final AdminNotificationService adminNotificationService; + + @Operation(summary = "공지사항 작성") + @PostMapping + public ApiResponse createNotice( + @RequestBody @Valid AdminNoticeCreateRequest request + ) { + return ApiResponse.onSuccess(adminNotificationService.createNotice(request)); + } + + @Operation(summary = "공지사항 목록 조회") + @GetMapping + public ApiResponse getNotices( + @RequestParam(required = false) NotificationCategory category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess(adminNotificationService.getNotices(category, page, size)); + } + + @Operation(summary = "공지사항 상세 조회") + @GetMapping("/{noticeId}") + public ApiResponse getNoticeDetail(@PathVariable Long noticeId) { + return ApiResponse.onSuccess(adminNotificationService.getNoticeDetail(noticeId)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java index ea89b32c..45a5031a 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java @@ -44,4 +44,9 @@ public String pickeListPage() { public String pickeCreatePage() { return "admin/picke-create"; } + + @GetMapping("/picke/notice") + public String noticePage() { + return "admin/admin-notice"; + } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminPollController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPollController.java new file mode 100644 index 00000000..ac40665e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPollController.java @@ -0,0 +1,69 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollCreateRequest; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollUpdateRequest; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDeleteResponse; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDetailResponse; +import com.swyp.picke.domain.admin.service.AdminPollService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 투표 콘텐츠 API", description = "관리자 투표 콘텐츠 생성, 조회, 수정, 삭제") +@RestController +@RequestMapping("/api/v1/admin/polls") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminPollController { + + private final AdminPollService adminPollService; + + @Operation(summary = "투표 콘텐츠 생성") + @PostMapping + public ApiResponse createPoll(@RequestBody @Valid AdminPollCreateRequest request) { + return ApiResponse.onSuccess(adminPollService.createPoll(request)); + } + + @Operation(summary = "투표 콘텐츠 상세 조회") + @GetMapping("/{pollId}") + public ApiResponse getPollDetail(@PathVariable Long pollId) { + return ApiResponse.onSuccess(adminPollService.getPollDetail(pollId)); + } + + @Operation(summary = "투표 콘텐츠 수정") + @PatchMapping("/{pollId}") + public ApiResponse updatePoll( + @PathVariable Long pollId, + @RequestBody @Valid AdminPollUpdateRequest request + ) { + return ApiResponse.onSuccess(adminPollService.updatePoll(pollId, request)); + } + + @Operation(summary = "투표 콘텐츠 삭제") + @DeleteMapping("/{pollId}") + public ApiResponse deletePoll(@PathVariable Long pollId) { + return ApiResponse.onSuccess(adminPollService.deletePoll(pollId)); + } + + @Operation(summary = "투표 콘텐츠 목록 조회") + @GetMapping + public ApiResponse getPolls( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size, + @RequestParam(value = "status", required = false) String status + ) { + return ApiResponse.onSuccess(adminPollService.getPolls(page, size, status)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminQuizController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminQuizController.java new file mode 100644 index 00000000..bd127a54 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminQuizController.java @@ -0,0 +1,69 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizCreateRequest; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizUpdateRequest; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDeleteResponse; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDetailResponse; +import com.swyp.picke.domain.admin.service.AdminQuizService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 퀴즈 API", description = "관리자 퀴즈 콘텐츠 생성, 조회, 수정, 삭제") +@RestController +@RequestMapping("/api/v1/admin/quizzes") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminQuizController { + + private final AdminQuizService adminQuizService; + + @Operation(summary = "퀴즈 생성") + @PostMapping + public ApiResponse createQuiz(@RequestBody @Valid AdminQuizCreateRequest request) { + return ApiResponse.onSuccess(adminQuizService.createQuiz(request)); + } + + @Operation(summary = "퀴즈 상세 조회") + @GetMapping("/{quizId}") + public ApiResponse getQuizDetail(@PathVariable Long quizId) { + return ApiResponse.onSuccess(adminQuizService.getQuizDetail(quizId)); + } + + @Operation(summary = "퀴즈 수정") + @PatchMapping("/{quizId}") + public ApiResponse updateQuiz( + @PathVariable Long quizId, + @RequestBody @Valid AdminQuizUpdateRequest request + ) { + return ApiResponse.onSuccess(adminQuizService.updateQuiz(quizId, request)); + } + + @Operation(summary = "퀴즈 삭제") + @DeleteMapping("/{quizId}") + public ApiResponse deleteQuiz(@PathVariable Long quizId) { + return ApiResponse.onSuccess(adminQuizService.deleteQuiz(quizId)); + } + + @Operation(summary = "퀴즈 목록 조회") + @GetMapping + public ApiResponse getQuizzes( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size, + @RequestParam(value = "status", required = false) String status + ) { + return ApiResponse.onSuccess(adminQuizService.getQuizzes(page, size, status)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminScenarioController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminScenarioController.java new file mode 100644 index 00000000..75f8ce8d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminScenarioController.java @@ -0,0 +1,73 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioCreateRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioStatusUpdateRequest; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminDeleteResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioCreateResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioDetailResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioResponse; +import com.swyp.picke.domain.admin.service.AdminScenarioService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 시나리오 API", description = "관리자 시나리오 생성, 조회, 수정, 삭제") +@RestController +@RequestMapping("/api/v1/admin") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminScenarioController { + + private final AdminScenarioService adminScenarioService; + + @Operation(summary = "배틀 시나리오 상세 조회") + @GetMapping("/battles/{battleId}/scenario") + public ApiResponse getAdminBattleScenario(@PathVariable Long battleId) { + return ApiResponse.onSuccess(adminScenarioService.getScenarioForAdmin(battleId)); + } + + @Operation(summary = "시나리오 생성") + @PostMapping("/scenarios") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createScenario(@RequestBody AdminScenarioCreateRequest request) { + return ApiResponse.onSuccess(adminScenarioService.createScenario(request)); + } + + @Operation(summary = "시나리오 본문 수정") + @PutMapping("/scenarios/{scenarioId}") + public ApiResponse updateScenarioContent( + @PathVariable Long scenarioId, + @RequestBody AdminScenarioCreateRequest request + ) { + adminScenarioService.updateScenarioContent(scenarioId, request); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "시나리오 상태 수정") + @PatchMapping("/scenarios/{scenarioId}") + public ApiResponse updateScenarioStatus( + @PathVariable Long scenarioId, + @RequestBody AdminScenarioStatusUpdateRequest request + ) { + return ApiResponse.onSuccess(adminScenarioService.updateScenarioStatus(scenarioId, request.status())); + } + + @Operation(summary = "시나리오 삭제") + @DeleteMapping("/scenarios/{scenarioId}") + public ApiResponse deleteScenario(@PathVariable Long scenarioId) { + return ApiResponse.onSuccess(adminScenarioService.deleteScenario(scenarioId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminTagController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminTagController.java new file mode 100644 index 00000000..fb79dd56 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminTagController.java @@ -0,0 +1,55 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.tag.request.TagRequest; +import com.swyp.picke.domain.admin.dto.tag.response.TagDeleteResponse; +import com.swyp.picke.domain.admin.dto.tag.response.TagResponse; +import com.swyp.picke.domain.admin.service.AdminTagService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 태그 API", description = "관리자 태그 생성, 수정, 삭제") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/tags") +@PreAuthorize("hasRole('ADMIN')") +public class AdminTagController { + + private final AdminTagService adminTagService; + + @Operation(summary = "태그 생성") + @PostMapping + public ApiResponse createTag(@Valid @RequestBody TagRequest request) { + return ApiResponse.onSuccess(adminTagService.createTag(request)); + } + + @Operation(summary = "태그 수정") + @PatchMapping("/{tagId}") + public ApiResponse updateTag( + @Parameter(description = "태그 ID", example = "1") + @PathVariable Long tagId, + @Valid @RequestBody TagRequest request + ) { + return ApiResponse.onSuccess(adminTagService.updateTag(tagId, request)); + } + + @Operation(summary = "태그 삭제") + @DeleteMapping("/{tagId}") + public ApiResponse deleteTag( + @Parameter(description = "태그 ID", example = "1") + @PathVariable Long tagId + ) { + return ApiResponse.onSuccess(adminTagService.deleteTag(tagId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleCreateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleCreateRequest.java new file mode 100644 index 00000000..8b165124 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleCreateRequest.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import java.util.List; + +public record AdminBattleCreateRequest( + String title, + String summary, + String description, + String thumbnailUrl, + BattleStatus status, + List tagIds, + List options +) {} + diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleOptionRequest.java similarity index 54% rename from src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java rename to src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleOptionRequest.java index 36c1c212..b610aa24 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleOptionRequest.java @@ -1,4 +1,4 @@ -package com.swyp.picke.domain.battle.dto.request; +package com.swyp.picke.domain.admin.dto.battle.request; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; @@ -9,8 +9,6 @@ public record AdminBattleOptionRequest( String title, String stance, String representative, - String quote, String imageUrl, - Boolean isCorrect, - List tagIds // 옵션 전용 태그 (철학자, 가치관 - 추후 사용자 유형 분석에 사용) -) {} \ No newline at end of file + List tagIds +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleUpdateRequest.java similarity index 52% rename from src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java rename to src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleUpdateRequest.java index aa5e4477..576da5bd 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleUpdateRequest.java @@ -1,23 +1,16 @@ -package com.swyp.picke.domain.battle.dto.request; +package com.swyp.picke.domain.admin.dto.battle.request; import com.swyp.picke.domain.battle.enums.BattleStatus; -import java.time.LocalDate; import java.util.List; public record AdminBattleUpdateRequest( String title, - String titlePrefix, - String titleSuffix, String summary, String description, String thumbnailUrl, - String itemA, - String itemADesc, - String itemB, - String itemBDesc, - LocalDate targetDate, - Integer audioDuration, BattleStatus status, List tagIds, List options -) {} \ No newline at end of file +) { +} + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDeleteResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDeleteResponse.java new file mode 100644 index 00000000..ce57f319 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDeleteResponse.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.dto.battle.response; + +import java.time.LocalDateTime; + +/** + * 관리자 배틀 삭제 응답 + */ +public record AdminBattleDeleteResponse( + Boolean success, + LocalDateTime deletedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java similarity index 56% rename from src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java rename to src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java index fd382332..f1873078 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java @@ -1,31 +1,24 @@ -package com.swyp.picke.domain.battle.dto.response; +package com.swyp.picke.domain.admin.dto.battle.response; +import com.swyp.picke.domain.battle.dto.response.BattleOptionResponse; +import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; import com.swyp.picke.domain.battle.enums.BattleCreatorType; import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; /** - * 관리자 - 배틀 상세 상세 조회 응답 - * 역할: 관리자가 배틀의 모든 설정 값(상태, 생성자 타입, 수정일 등)을 확인하고 수정할 때 사용합니다. + * 관리자 배틀 상세 조회 응답 */ - public record AdminBattleDetailResponse( Long battleId, String title, - String titlePrefix, - String titleSuffix, String summary, String description, String thumbnailUrl, - BattleType type, - String itemA, - String itemADesc, - String itemB, - String itemBDesc, + Integer audioDuration, LocalDate targetDate, BattleStatus status, BattleCreatorType creatorType, @@ -33,4 +26,4 @@ public record AdminBattleDetailResponse( List options, LocalDateTime createdAt, LocalDateTime updatedAt -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeCreateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeCreateRequest.java new file mode 100644 index 00000000..f71a7605 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeCreateRequest.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AdminNoticeCreateRequest( + @NotNull NotificationCategory category, + @NotBlank String title, + @NotBlank String body +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDetailResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDetailResponse.java new file mode 100644 index 00000000..f4777751 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDetailResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +import com.swyp.picke.domain.notification.enums.NotificationCategory; + +import java.time.LocalDateTime; + +public record AdminNoticeDetailResponse( + Long notificationId, + NotificationCategory category, + String detailCode, + String title, + String body, + Long referenceId, + LocalDateTime createdAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeListResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeListResponse.java new file mode 100644 index 00000000..fa53775a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeListResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +import java.util.List; + +public record AdminNoticeListResponse( + List items, + boolean hasNext +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeSummaryResponse.java new file mode 100644 index 00000000..a2bcd6f4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeSummaryResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +import com.swyp.picke.domain.notification.enums.NotificationCategory; + +import java.time.LocalDateTime; + +public record AdminNoticeSummaryResponse( + Long notificationId, + NotificationCategory category, + String detailCode, + String title, + String body, + Long referenceId, + LocalDateTime createdAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollCreateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollCreateRequest.java new file mode 100644 index 00000000..d7d305e5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollCreateRequest.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.admin.dto.poll.request; + +import com.swyp.picke.domain.poll.enums.PollStatus; +import java.util.List; + +public record AdminPollCreateRequest( + String titlePrefix, + String titleSuffix, + PollStatus status, + List options +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollOptionRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollOptionRequest.java new file mode 100644 index 00000000..e0b9ddb5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollOptionRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.poll.request; + +import com.swyp.picke.domain.poll.enums.PollOptionLabel; + +public record AdminPollOptionRequest( + PollOptionLabel label, + String title +) {} + + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollUpdateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollUpdateRequest.java new file mode 100644 index 00000000..88d7d007 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/poll/request/AdminPollUpdateRequest.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.admin.dto.poll.request; + +import com.swyp.picke.domain.poll.enums.PollStatus; + +import java.time.LocalDate; +import java.util.List; + +public record AdminPollUpdateRequest( + String titlePrefix, + String titleSuffix, + LocalDate targetDate, + PollStatus status, + List options +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/poll/response/AdminPollDeleteResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/poll/response/AdminPollDeleteResponse.java new file mode 100644 index 00000000..a9021a68 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/poll/response/AdminPollDeleteResponse.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.dto.poll.response; + +import java.time.LocalDateTime; + +public record AdminPollDeleteResponse( + boolean success, + LocalDateTime deletedAt +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/poll/response/AdminPollDetailResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/poll/response/AdminPollDetailResponse.java new file mode 100644 index 00000000..90515715 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/poll/response/AdminPollDetailResponse.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.admin.dto.poll.response; + +import com.swyp.picke.domain.poll.dto.response.PollOptionResponse; +import com.swyp.picke.domain.poll.enums.PollStatus; + +import java.time.LocalDate; +import java.util.List; + +public record AdminPollDetailResponse( + Long pollId, + String titlePrefix, + String titleSuffix, + LocalDate targetDate, + PollStatus status, + List options +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizCreateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizCreateRequest.java new file mode 100644 index 00000000..eb41491a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizCreateRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.quiz.request; + +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import java.util.List; + +public record AdminQuizCreateRequest( + String title, + QuizStatus status, + List options +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizOptionRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizOptionRequest.java new file mode 100644 index 00000000..4dd94c5a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizOptionRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.quiz.request; + +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; + +public record AdminQuizOptionRequest( + QuizOptionLabel label, + String text, + String detailText, + Boolean isCorrect +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizUpdateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizUpdateRequest.java new file mode 100644 index 00000000..3447892c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/request/AdminQuizUpdateRequest.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.admin.dto.quiz.request; + +import com.swyp.picke.domain.quiz.enums.QuizStatus; + +import java.time.LocalDate; +import java.util.List; + +public record AdminQuizUpdateRequest( + String title, + LocalDate targetDate, + QuizStatus status, + List options +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/quiz/response/AdminQuizDeleteResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/response/AdminQuizDeleteResponse.java new file mode 100644 index 00000000..8ea47cde --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/response/AdminQuizDeleteResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.quiz.response; + +import java.time.LocalDateTime; + +public record AdminQuizDeleteResponse( + boolean success, + LocalDateTime deletedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/quiz/response/AdminQuizDetailResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/response/AdminQuizDetailResponse.java new file mode 100644 index 00000000..2e5a147d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/quiz/response/AdminQuizDetailResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.admin.dto.quiz.response; + +import com.swyp.picke.domain.quiz.dto.response.QuizOptionResponse; +import com.swyp.picke.domain.quiz.enums.QuizStatus; + +import java.time.LocalDate; +import java.util.List; + +public record AdminQuizDetailResponse( + Long quizId, + String title, + LocalDate targetDate, + QuizStatus status, + List options +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioCreateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioCreateRequest.java new file mode 100644 index 00000000..21fc02e6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioCreateRequest.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.admin.dto.scenario.request; + +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; +import com.swyp.picke.domain.scenario.enums.SpeakerType; + +import java.util.List; +import java.util.Map; + +public record AdminScenarioCreateRequest( + Long battleId, + Boolean isInteractive, + ScenarioStatus status, + List nodes, + Map voiceSettings +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioNodeRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioNodeRequest.java new file mode 100644 index 00000000..ac447f07 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioNodeRequest.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.dto.scenario.request; + +import java.util.List; + +public record AdminScenarioNodeRequest( + String nodeName, + Boolean isStartNode, + String autoNextNode, + List scripts, + List interactiveOptions +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java new file mode 100644 index 00000000..4bf2988b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java @@ -0,0 +1,6 @@ +package com.swyp.picke.domain.admin.dto.scenario.request; + +public record AdminScenarioOptionRequest( + String label, + String nextNodeName +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java new file mode 100644 index 00000000..60563860 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.scenario.request; + +import com.swyp.picke.domain.scenario.enums.SpeakerType; + +public record AdminScenarioScriptRequest( + String speakerName, + SpeakerType speakerType, + String text +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioStatusUpdateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioStatusUpdateRequest.java new file mode 100644 index 00000000..23da7fb6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioStatusUpdateRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.scenario.request; + +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; + +public record AdminScenarioStatusUpdateRequest( + ScenarioStatus status +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminDeleteResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminDeleteResponse.java new file mode 100644 index 00000000..744b5d4a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminDeleteResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.scenario.response; + +import java.time.LocalDateTime; + +public record AdminDeleteResponse( + boolean success, + LocalDateTime deletedAt +) {} + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioCreateResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioCreateResponse.java new file mode 100644 index 00000000..cd102bd4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioCreateResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.scenario.response; + +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; + +public record AdminScenarioCreateResponse( + Long scenarioId, + ScenarioStatus status +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioDetailResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioDetailResponse.java new file mode 100644 index 00000000..c6981a8d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioDetailResponse.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.admin.dto.scenario.response; + +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import lombok.Builder; + +import java.util.List; +import java.util.Map; + +@Builder +public record AdminScenarioDetailResponse( + Long scenarioId, + Long battleId, + String title, + Boolean isInteractive, + List nodes, + Map voiceSettings +) {} + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioNodeResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioNodeResponse.java new file mode 100644 index 00000000..77dbf44e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioNodeResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.admin.dto.scenario.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record AdminScenarioNodeResponse( + Long nodeId, + String nodeName, + Integer audioDuration, + Long autoNextNodeId, + List scripts, + List interactiveOptions +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java new file mode 100644 index 00000000..50bfc1c7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.scenario.response; + +import lombok.Builder; + +@Builder +public record AdminScenarioOptionResponse( + String label, + Long nextNodeId +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioResponse.java new file mode 100644 index 00000000..10679249 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioResponse.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.scenario.response; + +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; + +public record AdminScenarioResponse( + Long scenarioId, + ScenarioStatus status, + String message +) {} + diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java new file mode 100644 index 00000000..2fd0f253 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.admin.dto.scenario.response; + +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import lombok.Builder; + +@Builder +public record AdminScenarioScriptResponse( + Long scriptId, + Integer startTimeMs, + SpeakerType speakerType, + String speakerName, + String text +) {} diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/tag/request/TagRequest.java similarity index 54% rename from src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java rename to src/main/java/com/swyp/picke/domain/admin/dto/tag/request/TagRequest.java index 736bfda6..577afa0d 100644 --- a/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/tag/request/TagRequest.java @@ -1,13 +1,13 @@ -package com.swyp.picke.domain.tag.dto.request; +package com.swyp.picke.domain.admin.dto.tag.request; import com.swyp.picke.domain.tag.enums.TagType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record TagRequest( - @NotBlank(message = "태그 이름을 입력해주세요.") + @NotBlank(message = "태그 이름을 입력해 주세요.") String name, - @NotNull(message = "태그 타입을 선택해주세요.") + @NotNull(message = "태그 타입을 선택해 주세요.") TagType type ) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagDeleteResponse.java similarity index 70% rename from src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java rename to src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagDeleteResponse.java index 71b350e8..c17ca85a 100644 --- a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagDeleteResponse.java @@ -1,4 +1,4 @@ -package com.swyp.picke.domain.tag.dto.response; +package com.swyp.picke.domain.admin.dto.tag.response; import java.time.LocalDateTime; diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagResponse.java similarity index 81% rename from src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java rename to src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagResponse.java index 70554dde..ab79ac01 100644 --- a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagResponse.java @@ -1,4 +1,4 @@ -package com.swyp.picke.domain.tag.dto.response; +package com.swyp.picke.domain.admin.dto.tag.response; import com.swyp.picke.domain.tag.enums.TagType; import java.time.LocalDateTime; diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java new file mode 100644 index 00000000..fee5af06 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; +import com.swyp.picke.domain.battle.service.BattleService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminBattleService { + + private final BattleService battleService; + + public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId) { + return battleService.createBattle(request, adminUserId); + } + + public AdminBattleDetailResponse getBattleDetail(Long battleId) { + return battleService.getAdminBattleDetail(battleId); + } + + public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request) { + return battleService.updateBattle(battleId, request); + } + + public AdminBattleDeleteResponse deleteBattle(Long battleId) { + return battleService.deleteBattle(battleId); + } + + public Object getBattles(int page, int size, String status) { + return battleService.getBattles(page, size, status); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java new file mode 100644 index 00000000..d131e75b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java @@ -0,0 +1,109 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeSummaryResponse; +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationRepository; +import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminNotificationService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final NotificationService notificationService; + private final NotificationRepository notificationRepository; + + @Transactional + public AdminNoticeDetailResponse createNotice(AdminNoticeCreateRequest request) { + NotificationDetailCode detailCode = toDetailCode(request.category()); + Notification notification = notificationService.createBroadcastNotification( + detailCode, + request.title(), + request.body(), + null + ); + return toDetailResponse(notification); + } + + public AdminNoticeListResponse getNotices(NotificationCategory category, int page, int size) { + int pageNumber = Math.max(0, page); + int pageSize = size <= 0 ? DEFAULT_PAGE_SIZE : size; + NotificationCategory filterCategory = normalizeCategory(category); + + Slice slice = notificationRepository.findNotificationsForAdmin( + filterCategory, + PageRequest.of(pageNumber, pageSize) + ); + + return new AdminNoticeListResponse( + slice.getContent().stream() + .map(this::toSummaryResponse) + .toList(), + slice.hasNext() + ); + } + + public AdminNoticeDetailResponse getNoticeDetail(Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + return toDetailResponse(notification); + } + + private NotificationCategory normalizeCategory(NotificationCategory category) { + if (category == null || category == NotificationCategory.ALL) { + return null; + } + return category; + } + + private NotificationDetailCode toDetailCode(NotificationCategory category) { + if (category == NotificationCategory.CONTENT) { + return NotificationDetailCode.NEW_BATTLE; + } + if (category == NotificationCategory.NOTICE) { + return NotificationDetailCode.POLICY_CHANGE; + } + if (category == NotificationCategory.EVENT) { + return NotificationDetailCode.PROMOTION; + } + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + private AdminNoticeSummaryResponse toSummaryResponse(Notification notification) { + return new AdminNoticeSummaryResponse( + notification.getId(), + notification.getCategory(), + notification.getDetailCode().name(), + notification.getTitle(), + notification.getBody(), + notification.getReferenceId(), + notification.getCreatedAt() + ); + } + + private AdminNoticeDetailResponse toDetailResponse(Notification notification) { + return new AdminNoticeDetailResponse( + notification.getId(), + notification.getCategory(), + notification.getDetailCode().name(), + notification.getTitle(), + notification.getBody(), + notification.getReferenceId(), + notification.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminPollService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminPollService.java new file mode 100644 index 00000000..6ff9a02f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminPollService.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollCreateRequest; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollUpdateRequest; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDeleteResponse; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDetailResponse; +import com.swyp.picke.domain.poll.service.PollService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminPollService { + + private final PollService pollService; + + public AdminPollDetailResponse createPoll(AdminPollCreateRequest request) { + return pollService.createPoll(request); + } + + public AdminPollDetailResponse getPollDetail(Long pollId) { + return pollService.getAdminPollDetail(pollId); + } + + public AdminPollDetailResponse updatePoll(Long pollId, AdminPollUpdateRequest request) { + return pollService.updatePoll(pollId, request); + } + + public AdminPollDeleteResponse deletePoll(Long pollId) { + return pollService.deletePoll(pollId); + } + + public Object getPolls(int page, int size, String status) { + return pollService.getPolls(page, size); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminQuizService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminQuizService.java new file mode 100644 index 00000000..c7d7983b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminQuizService.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizCreateRequest; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizUpdateRequest; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDeleteResponse; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDetailResponse; +import com.swyp.picke.domain.quiz.service.QuizService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminQuizService { + + private final QuizService quizService; + + public AdminQuizDetailResponse createQuiz(AdminQuizCreateRequest request) { + return quizService.createQuiz(request); + } + + public AdminQuizDetailResponse getQuizDetail(Long quizId) { + return quizService.getAdminQuizDetail(quizId); + } + + public AdminQuizDetailResponse updateQuiz(Long quizId, AdminQuizUpdateRequest request) { + return quizService.updateQuiz(quizId, request); + } + + public AdminQuizDeleteResponse deleteQuiz(Long quizId) { + return quizService.deleteQuiz(quizId); + } + + public Object getQuizzes(int page, int size, String status) { + return quizService.getQuizzes(page, size); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java new file mode 100644 index 00000000..50a62517 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java @@ -0,0 +1,102 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioCreateRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioNodeRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioOptionRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioScriptRequest; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminDeleteResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioCreateResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioDetailResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioResponse; +import com.swyp.picke.domain.scenario.dto.request.NodeRequest; +import com.swyp.picke.domain.scenario.dto.request.OptionRequest; +import com.swyp.picke.domain.scenario.dto.request.ScenarioCreateRequest; +import com.swyp.picke.domain.scenario.dto.request.ScriptRequest; +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; +import com.swyp.picke.domain.scenario.service.ScenarioService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminScenarioService { + + private final ScenarioService scenarioService; + + public AdminScenarioDetailResponse getScenarioForAdmin(Long battleId) { + return scenarioService.getScenarioForAdmin(battleId); + } + + public AdminScenarioCreateResponse createScenario(AdminScenarioCreateRequest request) { + Long scenarioId = scenarioService.createScenario(toScenarioCreateRequest(request)); + return new AdminScenarioCreateResponse(scenarioId, request.status()); + } + + public void updateScenarioContent(Long scenarioId, AdminScenarioCreateRequest request) { + scenarioService.updateScenarioContent(scenarioId, toScenarioCreateRequest(request)); + } + + public AdminScenarioResponse updateScenarioStatus(Long scenarioId, ScenarioStatus status) { + return scenarioService.updateScenarioStatus(scenarioId, status); + } + + public AdminDeleteResponse deleteScenario(Long scenarioId) { + return scenarioService.deleteScenario(scenarioId); + } + + private ScenarioCreateRequest toScenarioCreateRequest(AdminScenarioCreateRequest request) { + return new ScenarioCreateRequest( + request.battleId(), + request.isInteractive(), + request.status(), + toNodeRequests(request.nodes()), + request.voiceSettings() + ); + } + + private List toNodeRequests(List nodeRequests) { + if (nodeRequests == null) { + return List.of(); + } + return nodeRequests.stream() + .map(this::toNodeRequest) + .toList(); + } + + private NodeRequest toNodeRequest(AdminScenarioNodeRequest nodeRequest) { + return new NodeRequest( + nodeRequest.nodeName(), + nodeRequest.isStartNode(), + nodeRequest.autoNextNode(), + toScriptRequests(nodeRequest.scripts()), + toOptionRequests(nodeRequest.interactiveOptions()) + ); + } + + private List toScriptRequests(List scriptRequests) { + if (scriptRequests == null) { + return null; + } + return scriptRequests.stream() + .map(scriptRequest -> new ScriptRequest( + scriptRequest.speakerName(), + scriptRequest.speakerType(), + scriptRequest.text() + )) + .toList(); + } + + private List toOptionRequests(List optionRequests) { + if (optionRequests == null) { + return null; + } + return optionRequests.stream() + .map(optionRequest -> new OptionRequest( + optionRequest.label(), + optionRequest.nextNodeName() + )) + .toList(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminTagService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminTagService.java new file mode 100644 index 00000000..cfe96725 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminTagService.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.tag.request.TagRequest; +import com.swyp.picke.domain.admin.dto.tag.response.TagDeleteResponse; +import com.swyp.picke.domain.admin.dto.tag.response.TagResponse; +import com.swyp.picke.domain.tag.service.TagService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminTagService { + + private final TagService tagService; + + public TagResponse createTag(TagRequest request) { + return tagService.createTag(request); + } + + public TagResponse updateTag(Long tagId, TagRequest request) { + return tagService.updateTag(tagId, request); + } + + public TagDeleteResponse deleteTag(Long tagId) { + return tagService.deleteTag(tagId); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java deleted file mode 100644 index b115abc3..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.swyp.picke.domain.battle.controller; - -import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; -import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; -import com.swyp.picke.domain.battle.dto.response.AdminBattleDeleteResponse; -import com.swyp.picke.domain.battle.dto.response.AdminBattleDetailResponse; -import com.swyp.picke.domain.battle.service.BattleService; -import com.swyp.picke.global.common.response.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -@Tag(name = "배틀 API (관리자)", description = "배틀 생성/수정/삭제 (관리자 전용)") -@RestController -@RequestMapping("/api/v1/admin/battles") -@RequiredArgsConstructor -@PreAuthorize("hasRole('ADMIN')") -public class AdminBattleController { - - private final BattleService battleService; - - @Operation(summary = "배틀 생성") - @PostMapping - public ApiResponse createBattle( - @RequestBody @Valid AdminBattleCreateRequest request, - @AuthenticationPrincipal Long adminUserId - ) { - return ApiResponse.onSuccess(battleService.createBattle(request, adminUserId)); - } - - @Operation(summary = "배틀 수정 (변경 필드만 포함)") - @PatchMapping("/{battleId}") - public ApiResponse updateBattle( - @PathVariable Long battleId, - @RequestBody @Valid AdminBattleUpdateRequest request - ) { - return ApiResponse.onSuccess(battleService.updateBattle(battleId, request)); - } - - @Operation(summary = "배틀 삭제") - @DeleteMapping("/{battleId}") - public ApiResponse deleteBattle( - @PathVariable Long battleId - ) { - return ApiResponse.onSuccess(battleService.deleteBattle(battleId)); - } -} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java new file mode 100644 index 00000000..04f7fa21 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.battle.controller; + +import com.swyp.picke.domain.battle.dto.response.BattleProposalResponse; +import com.swyp.picke.domain.battle.dto.request.BattleProposalReviewRequest; +import com.swyp.picke.domain.battle.service.BattleProposalService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "관리자 배틀 제안 API", description = "주제 제안 목록 조회 및 채택/미채택 처리") +@RestController +@RequestMapping("/api/v1/admin/battles") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminBattleProposalController { + + private final BattleProposalService battleProposalService; + + @Operation(summary = "배틀 주제 제안 목록 조회") + @GetMapping("/proposals") + public ApiResponse getProposals( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size, + @RequestParam(value = "status", required = false) String status + ) { + return ApiResponse.onSuccess(battleProposalService.getProposals(page, size, status)); + } + + @Operation(summary = "배틀 주제 채택/미채택 처리") + @PatchMapping("/proposals/{proposalId}") + public ApiResponse review( + @PathVariable Long proposalId, + @Valid @RequestBody BattleProposalReviewRequest request + ) { + return ApiResponse.onSuccess(battleProposalService.review(proposalId, request)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java b/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java index 9450a078..eafacd8b 100644 --- a/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java +++ b/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java @@ -10,9 +10,13 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; -@Tag(name = "배틀 API (사용자)", description = "배틀 조회") +@Tag(name = "배틀 API", description = "배틀 조회") @RestController @RequestMapping("/api/v1/battles") @RequiredArgsConstructor @@ -20,36 +24,34 @@ public class BattleController { private final BattleService battleService; - @Operation(summary = "오늘의 배틀 목록 조회 (스와이프 UI용, 최대 5개)") + @Operation(summary = "오늘의 배틀 목록 조회 (최대 5개)") @GetMapping("/today") public ApiResponse getTodayBattles() { return ApiResponse.onSuccess(battleService.getTodayBattles()); } - @Operation(summary = "배틀 전체 목록 조회", description = "페이징 및 타입별(ALL, BATTLE, QUIZ, VOTE) 필터링된 배틀 목록을 조회합니다.") + @Operation(summary = "배틀 목록 조회") @GetMapping public ApiResponse getBattles( @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") @RequestParam(value = "page", defaultValue = "1") int page, @Parameter(description = "페이지 크기", example = "10") @RequestParam(value = "size", defaultValue = "10") int size, - @Parameter(description = "콘텐츠 타입 (ALL, BATTLE, QUIZ, VOTE)", example = "ALL") - @RequestParam(value = "type", required = false, defaultValue = "ALL") String type + @Parameter(description = "콘텐츠 상태 (ALL, PENDING, PUBLISHED, REJECTED, ARCHIVED)", example = "ALL") + @RequestParam(value = "status", required = false, defaultValue = "ALL") String status ) { - return ApiResponse.onSuccess(battleService.getBattles(page, size, type)); + return ApiResponse.onSuccess(battleService.getBattles(page, size, status)); } @Operation(summary = "배틀 상세 조회") @GetMapping("/{battleId}") - public ApiResponse getBattleDetail( - @PathVariable Long battleId - ) { + public ApiResponse getBattleDetail(@PathVariable Long battleId) { return ApiResponse.onSuccess(battleService.getBattleDetail(battleId)); } - @Operation(summary = "사용자 배틀 진행 상태 조회 (사전투표/TTS/사후투표)") + @Operation(summary = "사용자 배틀 진행 상태 조회") @GetMapping("/{battleId}/status") public ApiResponse getUserBattleStatus(@PathVariable Long battleId) { return ApiResponse.onSuccess(battleService.getUserBattleStatus(battleId)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java b/src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java new file mode 100644 index 00000000..66261c3d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java @@ -0,0 +1,28 @@ +package com.swyp.picke.domain.battle.controller; + +import com.swyp.picke.domain.battle.dto.request.BattleProposalRequest; +import com.swyp.picke.domain.battle.dto.response.BattleProposalResponse; +import com.swyp.picke.domain.battle.service.BattleProposalService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "배틀 제안 API", description = "배틀 제안") +@RestController +@RequestMapping("/api/v1/battles") +@RequiredArgsConstructor +public class BattleProposalController { + + private final BattleProposalService battleProposalService; + + @Operation(summary = "배틀 주제 제안") + @PostMapping("/proposals") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse propose(@Valid @RequestBody BattleProposalRequest request) { + return ApiResponse.onSuccess(battleProposalService.propose(request)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java index 3511d521..71d5c27a 100644 --- a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java @@ -1,21 +1,22 @@ package com.swyp.picke.domain.battle.converter; -import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; import com.swyp.picke.domain.battle.dto.response.*; import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleCreatorType; -import com.swyp.picke.domain.user.enums.PhilosopherType; -import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.tag.entity.Tag; import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.user.enums.VoteSide; import com.swyp.picke.global.infra.s3.enums.FileCategory; import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.Comparator; import java.util.List; import java.util.Map; @@ -25,21 +26,17 @@ public class BattleConverter { private final ResourceUrlProvider urlProvider; private static final String BASE_SHARE_URL = "https://pique.app/battles/"; + private static final Comparator OPTION_SORTER = + Comparator.comparing((BattleOption option) -> option.getDisplayOrder() == null ? Integer.MAX_VALUE : option.getDisplayOrder()) + .thenComparing(option -> option.getLabel() == null ? "" : option.getLabel().name()) + .thenComparing(BattleOption::getId); public Battle toEntity(AdminBattleCreateRequest request, User admin) { return Battle.builder() .title(request.title()) - .titlePrefix(request.titlePrefix()) - .titleSuffix(request.titleSuffix()) - .itemA(request.itemA()) - .itemADesc(request.itemADesc()) - .itemB(request.itemB()) - .itemBDesc(request.itemBDesc()) .summary(request.summary()) .description(request.description()) .thumbnailUrl(request.thumbnailUrl()) - .type(request.type()) - .targetDate(request.targetDate()) .status(request.status()) .creatorType(BattleCreatorType.ADMIN) .creator(admin) @@ -52,18 +49,11 @@ public TodayBattleResponse toTodayResponse(Battle battle, List tags, List return new AdminBattleDetailResponse( battle.getId(), battle.getTitle(), - battle.getTitlePrefix(), - battle.getTitleSuffix(), battle.getSummary(), battle.getDescription(), urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), - battle.getType(), - battle.getItemA(), - battle.getItemADesc(), - battle.getItemB(), - battle.getItemBDesc(), + battle.getAudioDuration(), battle.getTargetDate(), battle.getStatus(), battle.getCreatorType(), @@ -111,7 +94,6 @@ public BattleUserDetailResponse toUserDetailResponse( battle.getTitle(), battle.getSummary(), urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), - battle.getType(), battle.getViewCount() == null ? 0 : battle.getViewCount(), participantsCount == null ? 0L : participantsCount, battle.getAudioDuration() == null ? 0 : battle.getAudioDuration(), @@ -121,12 +103,6 @@ public BattleUserDetailResponse toUserDetailResponse( return new BattleUserDetailResponse( summary, - battle.getTitlePrefix(), - battle.getTitleSuffix(), - battle.getItemA(), - battle.getItemADesc(), - battle.getItemB(), - battle.getItemBDesc(), battle.getDescription(), BASE_SHARE_URL + battle.getId(), userVoteStatus, @@ -143,8 +119,7 @@ public BattleScenarioResponse toScenarioResponse(Battle battle, List toOptionResponses(List options, Map> optionTagsMap) { if (options == null) return List.of(); return options.stream() + .sorted(OPTION_SORTER) .map(option -> { List optionTags = optionTagsMap.getOrDefault(option.getId(), List.of()); return new BattleOptionResponse( @@ -161,8 +137,7 @@ private List toOptionResponses(List options, option.getTitle(), option.getStance(), option.getRepresentative(), - option.getQuote(), - urlProvider.getImageUrl(FileCategory.PHILOSOPHER, PhilosopherType.resolveImageKey(option.getRepresentative())), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()), toTagResponses(optionTags, null) ); }).toList(); @@ -170,15 +145,16 @@ private List toOptionResponses(List options, private List toTodayOptionResponses(List options) { if (options == null) return List.of(); - return options.stream().map(option -> new TodayOptionResponse( - option.getId(), - option.getLabel(), - option.getTitle(), - option.getRepresentative(), - option.getStance(), - urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()), - option.getIsCorrect() - )).toList(); + return options.stream() + .sorted(OPTION_SORTER) + .map(option -> new TodayOptionResponse( + option.getId(), + option.getLabel(), + option.getTitle(), + option.getRepresentative(), + option.getStance(), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()) + )).toList(); } private List toTagResponses(List tags, TagType targetType) { @@ -188,4 +164,4 @@ private List toTagResponses(List tags, TagType targetTyp .map(tag -> new BattleTagResponse(tag.getId(), tag.getName(), tag.getType())) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java deleted file mode 100644 index 48aa5b4a..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.swyp.picke.domain.battle.dto.request; - -import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; -import java.time.LocalDate; -import java.util.List; - -public record AdminBattleCreateRequest( - String title, - String titlePrefix, - String titleSuffix, - String summary, - String description, - String thumbnailUrl, - BattleType type, - BattleStatus status, - String itemA, - String itemADesc, - String itemB, - String itemBDesc, - LocalDate targetDate, - List tagIds, - List options -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalRequest.java new file mode 100644 index 00000000..324768c3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalRequest.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.battle.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class BattleProposalRequest { + + @NotNull(message = "카테고리를 선택해주세요") + private BattleCategory category; + + @NotBlank(message = "주제를 입력해주세요") + @Size(max = 100, message = "주제는 100자 이내로 입력해주세요") + private String topic; + + @NotBlank(message = "A 입장을 입력해주세요") + private String positionA; + + @NotBlank(message = "B 입장을 입력해주세요") + private String positionB; + + @Size(max = 200, message = "부가 설명은 200자 이내로 입력해주세요") + private String description; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalReviewRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalReviewRequest.java new file mode 100644 index 00000000..d67034e3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalReviewRequest.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.battle.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class BattleProposalReviewRequest { + + @NotNull(message = "action은 필수입니다") + private Action action; + + public enum Action { + ACCEPT, REJECT + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java deleted file mode 100644 index 43c64d66..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.swyp.picke.domain.battle.dto.response; - -import java.time.LocalDateTime; - -/** - * 관리자 - 배틀 삭제 응답 - * 역할: 배틀이 성공적으로 소프트 딜리트 되었는지 확인하고 삭제 시점을 반환합니다. - */ - -public record AdminBattleDeleteResponse( - Boolean success, // 삭제 성공 여부 - LocalDateTime deletedAt // 삭제 처리된 일시 (Soft Delete) -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java index 51ca1760..ce34930d 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java @@ -10,7 +10,6 @@ public record BattleOptionResponse( String title, String stance, String representative, - String quote, String imageUrl, List tags -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleProposalResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleProposalResponse.java new file mode 100644 index 00000000..030c9487 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleProposalResponse.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.entity.BattleProposal; +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleProposalStatus; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class BattleProposalResponse { + private final Long id; + private final Long userId; + private final String nickname; + private final BattleCategory category; + private final String topic; + private final String positionA; + private final String positionB; + private final String description; + private final BattleProposalStatus status; + private final LocalDateTime createdAt; + + public BattleProposalResponse(BattleProposal proposal) { + this.id = proposal.getId(); + this.userId = proposal.getUser().getId(); + this.nickname = proposal.getUser().getNickname(); + this.category = proposal.getCategory(); + this.topic = proposal.getTopic(); + this.positionA = proposal.getPositionA(); + this.positionB = proposal.getPositionB(); + this.description = proposal.getDescription(); + this.status = proposal.getStatus(); + this.createdAt = proposal.getCreatedAt(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java index de611ff9..1208010c 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java @@ -10,7 +10,6 @@ public record PhilosopherProfileResponse( String label, String name, String stance, - String quote, String imageUrl ) {} -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java index feef39fa..6ce79150 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java @@ -6,7 +6,6 @@ public record BattleSimpleResponse( Long battleId, String title, String thumbnailUrl, - String type, String status, LocalDateTime createdAt -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java index cd39f4d5..60cd7f24 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java @@ -1,23 +1,15 @@ package com.swyp.picke.domain.battle.dto.response; -import com.swyp.picke.domain.battle.enums.BattleType; - import java.util.List; -/** - * 유저 - 배틀 요약 정보 응답 - * 역할: 홈 화면의 각 섹션 카드나 리스트에서 '미리보기' 형태로 보여줄 데이터입니다. - */ - public record BattleSummaryResponse( - Long battleId, // 배틀 고유 ID - String title, // 배틀 제목 - String summary, // 배틀 요약 (누군가는 이것을...) - String thumbnailUrl, // 카드 배경 이미지 URL - BattleType type, // 배틀 타입 태그 (#BATTLE, #VOTE 등) - Integer viewCount, // 조회수 - Long participantsCount, // 누적 참여자 수 - Integer audioDuration, // 오디오 소요 시간 - List tags, // 카테고리/인물 태그 리스트 - List options // 선택지 요약 (A vs B) -) {} \ No newline at end of file + Long battleId, + String title, + String summary, + String thumbnailUrl, + Integer viewCount, + Long participantsCount, + Integer audioDuration, + List tags, + List options +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java index b08b9455..9b50d068 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java @@ -5,23 +5,13 @@ import java.util.List; -/** - * 유저 - 배틀 상세 페이지 응답 (시안 4, 5번) - * 역할: 배틀 클릭 시 진입하는 상세 화면의 모든 정보를 담습니다. 투표 여부에 따라 UI가 변합니다. - */ public record BattleUserDetailResponse( - BattleSummaryResponse battleInfo, // 기본적인 배틀 정보 (요약 DTO 재사용) - String titlePrefix, - String titleSuffix, - String itemA, - String itemADesc, - String itemB, - String itemBDesc, - String description, // 상세 본문 설명 - String shareUrl, // 공유하기 버튼용 링크 - VoteSide userVoteStatus, // 현재 유저의 투표 상태 + BattleSummaryResponse battleInfo, + String description, + String shareUrl, + VoteSide userVoteStatus, UserBattleStep currentStep, - List categoryTags, // UI 상단용 카테고리 태그 - List philosopherTags, // UI 하단용 철학자 태그 - List valueTags // 성향 분석용 가치관 태그 -) {} \ No newline at end of file + List categoryTags, + List philosopherTags, + List valueTags +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java index 64720c5b..fe2cdac5 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java @@ -12,4 +12,4 @@ public record BattleVoteResponse( Long selectedOptionId, // 유저가 방금 선택한 옵션 ID Long totalParticipants, // 실시간 전체 참여자 수 List results // 옵션별 득표 현황 리스트 -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java index 26e9567f..235a7f26 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java @@ -10,4 +10,4 @@ public record TodayBattleListResponse( List items, // 오늘의 배틀 리스트 Integer totalCount // 목록 총 개수 -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java index 8b14041d..097a0061 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java @@ -1,29 +1,15 @@ package com.swyp.picke.domain.battle.dto.response; -import com.swyp.picke.domain.battle.enums.BattleType; - import java.util.List; -/** - * 유저 - 오늘의 배틀 상세 응답 (시안 6번) - * 역할: 어두운 배경의 풀스크린 UI에 필요한 배경 이미지, 시간 등을 담습니다. - */ public record TodayBattleResponse( - Long battleId, // 배틀 고유 ID - String title, // 배틀 제목 - String summary, // 중간 요약 문구 - String thumbnailUrl, // 풀스크린 배경 이미지 URL - BattleType type, // 타입 태그 - Integer viewCount, // 조회수 - Long participantsCount, // 누적 참여자 수 - Integer audioDuration, // 소요 시간 (분:초 변환용 데이터) - List tags, // 상단 태그 리스트 - List options, // 중앙 세로형 대결 카드 데이터 - // 퀴즈·투표 전용 필드 - String titlePrefix, // 투표 접두사 (예: "도덕의 기준은") - String titleSuffix, // 투표 접미사 (예: "이다") - String itemA, // 퀴즈 O 선택지 - String itemADesc, // 퀴즈 O 설명 - String itemB, // 퀴즈 X 선택지 - String itemBDesc // 퀴즈 X 설명 + Long battleId, + String title, + String summary, + String thumbnailUrl, + Integer viewCount, + Long participantsCount, + Integer audioDuration, + List tags, + List options ) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java index 2fd15871..2da90246 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java @@ -2,17 +2,11 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -/** - * 유저 - 오늘의 배틀 전용 옵션 응답 - * 역할: 오늘의 배틀 시안의 세로형 카드에 들어가는 인물, 입장, 아바타 정보를 담습니다. - */ - public record TodayOptionResponse( - Long optionId, // 옵션 ID - BattleOptionLabel label,// 라벨 (A, B) - String title, // 제목 (예: 찬성한다) - String representative, // 인물 (예: 피터 싱어) - String stance, // 한 줄 입장 (예: 고통을 끝낼 권리는..) - String imageUrl, // 아바타 이미지 URL - Boolean isCorrect // 퀴즈 정답 여부 + Long optionId, + BattleOptionLabel label, + String title, + String representative, + String stance, + String imageUrl ) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java index 7a3ac8d5..e9905040 100644 --- a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java +++ b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java @@ -2,18 +2,27 @@ import com.swyp.picke.domain.battle.enums.BattleCreatorType; import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.global.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDate; -import java.time.LocalDateTime; - @Getter @Entity @Table(name = "battles") @@ -31,28 +40,6 @@ public class Battle extends BaseEntity { @Column(name = "thumbnail_url", length = 500) private String thumbnailUrl; - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private BattleType type; - - @Column(name = "title_prefix") - private String titlePrefix; - - @Column(name = "title_suffix") - private String titleSuffix; - - @Column(name = "item_a") - private String itemA; - - @Column(name = "item_a_desc") - private String itemADesc; - - @Column(name = "item_b") - private String itemB; - - @Column(name = "item_b_desc") - private String itemBDesc; - @Column(name = "view_count") private Integer viewCount = 0; @@ -77,7 +64,8 @@ public class Battle extends BaseEntity { @JoinColumn(name = "creator_id") private User creator; - // 홈 화면 5단 기획을 위한 필드들 + @OneToMany(mappedBy = "battle", cascade = CascadeType.ALL, orphanRemoval = true) + private final List options = new ArrayList<>(); @Column(name = "is_editor_pick") private Boolean isEditorPick = false; @@ -89,22 +77,21 @@ public class Battle extends BaseEntity { private LocalDateTime deletedAt; @Builder - public Battle(String title, String summary, String description, String thumbnailUrl, - BattleType type, String titlePrefix, String titleSuffix, - String itemA, String itemADesc, String itemB, String itemBDesc, - LocalDate targetDate, Integer audioDuration, BattleStatus status, - BattleCreatorType creatorType, User creator) { + public Battle( + String title, + String summary, + String description, + String thumbnailUrl, + LocalDate targetDate, + Integer audioDuration, + BattleStatus status, + BattleCreatorType creatorType, + User creator + ) { this.title = title; this.summary = summary; this.description = description; this.thumbnailUrl = thumbnailUrl; - this.type = type; - this.titlePrefix = titlePrefix; - this.titleSuffix = titleSuffix; - this.itemA = itemA; - this.itemADesc = itemADesc; - this.itemB = itemB; - this.itemBDesc = itemBDesc; this.targetDate = targetDate; this.audioDuration = audioDuration; this.status = status; @@ -117,26 +104,34 @@ public Battle(String title, String summary, String description, String thumbnail this.deletedAt = null; } - public void update(String title, String titlePrefix, String titleSuffix, - String itemA, String itemADesc, String itemB, String itemBDesc, - String summary, String description, - String thumbnailUrl, LocalDate targetDate, - Integer audioDuration, BattleStatus status) { - if (title != null) this.title = title; - if (titlePrefix != null) this.titlePrefix = titlePrefix; - if (titleSuffix != null) this.titleSuffix = titleSuffix; - - if (itemA != null) this.itemA = itemA; - if (itemADesc != null) this.itemADesc = itemADesc; - if (itemB != null) this.itemB = itemB; - if (itemBDesc != null) this.itemBDesc = itemBDesc; - - if (summary != null) this.summary = summary; - if (description != null) this.description = description; - if (thumbnailUrl != null) this.thumbnailUrl = thumbnailUrl; - if (targetDate != null) this.targetDate = targetDate; - if (audioDuration != null) this.audioDuration = audioDuration; - if (status != null) this.status = status; + public void update( + String title, + String summary, + String description, + String thumbnailUrl, + BattleStatus status + ) { + if (title != null) { + this.title = title; + } + if (summary != null) { + this.summary = summary; + } + if (description != null) { + this.description = description; + } + if (thumbnailUrl != null) { + this.thumbnailUrl = thumbnailUrl; + } + if (targetDate != null) { + this.targetDate = targetDate; + } + if (audioDuration != null) { + this.audioDuration = audioDuration; + } + if (status != null) { + this.status = status; + } } public void delete() { @@ -155,4 +150,9 @@ public void addParticipant() { public void updateAudioDuration(Integer audioDuration) { this.audioDuration = audioDuration; } + + public void updateTargetDate(LocalDate targetDate) { + this.targetDate = targetDate; + } + } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java b/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java index 8be17ceb..ab5ee23a 100644 --- a/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java +++ b/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java @@ -2,7 +2,18 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.global.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -31,29 +42,35 @@ public class BattleOption extends BaseEntity { @Column(length = 100) private String representative; - @Column(columnDefinition = "TEXT") - private String quote; - @Column(name = "vote_count") private Long voteCount = 0L; - @Column(name = "is_correct") - private Boolean isCorrect = false; - @Column(name = "image_url", length = 500) private String imageUrl; + @Column(name = "display_order") + private Integer displayOrder; + + @OneToMany(mappedBy = "battleOption", cascade = CascadeType.ALL, orphanRemoval = true) + private final List tags = new ArrayList<>(); + @Builder - public BattleOption(Battle battle, BattleOptionLabel label, String title, String stance, - String representative, String quote, String imageUrl, Boolean isCorrect) { + public BattleOption( + Battle battle, + BattleOptionLabel label, + String title, + String stance, + String representative, + String imageUrl, + Integer displayOrder + ) { this.battle = battle; this.label = label; this.title = title; this.stance = stance; this.representative = representative; - this.quote = quote; this.imageUrl = imageUrl; - this.isCorrect = (isCorrect != null) && isCorrect; + this.displayOrder = displayOrder; this.voteCount = 0L; } @@ -67,12 +84,21 @@ public void decreaseVoteCount() { } } - public void update(String title, String stance, String representative, String quote, String imageUrl, Boolean isCorrect) { - if (title != null) this.title = title; - if (stance != null) this.stance = stance; - if (representative != null) this.representative = representative; - if (quote != null) this.quote = quote; - if (imageUrl != null) this.imageUrl = imageUrl; - if (isCorrect != null) this.isCorrect = isCorrect; + public void update(String title, String stance, String representative, String imageUrl) { + if (title != null) { + this.title = title; + } + if (stance != null) { + this.stance = stance; + } + if (representative != null) { + this.representative = representative; + } + if (imageUrl != null) { + this.imageUrl = imageUrl; + } + if (displayOrder != null) { + this.displayOrder = displayOrder; + } } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/BattleProposal.java b/src/main/java/com/swyp/picke/domain/battle/entity/BattleProposal.java new file mode 100644 index 00000000..468461c0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/entity/BattleProposal.java @@ -0,0 +1,66 @@ +package com.swyp.picke.domain.battle.entity; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleProposalStatus; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "battle_proposals") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BattleProposal extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private BattleCategory category; + + @Column(nullable = false) + private String topic; + + @Column(name = "position_a", nullable = false) + private String positionA; + + @Column(name = "position_b", nullable = false) + private String positionB; + + @Column(columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private BattleProposalStatus status; + + @Builder + public BattleProposal(User user, BattleCategory category, String topic, + String positionA, String positionB, String description) { + this.user = user; + this.category = category; + this.topic = topic; + this.positionA = positionA; + this.positionB = positionB; + this.description = description; + this.status = BattleProposalStatus.PENDING; + } + + public void accept() { + this.status = BattleProposalStatus.ACCEPTED; + } + + public void reject() { + this.status = BattleProposalStatus.REJECTED; + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleCategory.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleCategory.java new file mode 100644 index 00000000..987865bc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleCategory.java @@ -0,0 +1,29 @@ +package com.swyp.picke.domain.battle.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum BattleCategory { + PHILOSOPHY("철학"), + LITERATURE("문학"), + ART("예술"), + SCIENCE("과학"), + SOCIETY("사회"), + HISTORY("역사"); + + private final String value; + BattleCategory(String value) { this.value = value; } + + @JsonCreator + public static BattleCategory from(String value) { + for (BattleCategory category : BattleCategory.values()) { + if (category.value.equals(value)) { + return category; + } + } + return null; + } + + @JsonValue + public String getValue() { return value; } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleProposalStatus.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleProposalStatus.java new file mode 100644 index 00000000..c5aa8985 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleProposalStatus.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.battle.enums; + +public enum BattleProposalStatus { + PENDING, ACCEPTED, REJECTED +} diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java deleted file mode 100644 index 648e1eff..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.swyp.picke.domain.battle.enums; - -public enum BattleType { - BATTLE, QUIZ, VOTE -} diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java index d30f2a8e..2260ed8e 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java @@ -4,13 +4,23 @@ import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; public interface BattleOptionRepository extends JpaRepository { - List findByBattle(Battle battle); + @Query("SELECT bo FROM BattleOption bo " + + "WHERE bo.battle = :battle " + + "ORDER BY COALESCE(bo.displayOrder, 9999), bo.label, bo.id") + List findByBattle(@Param("battle") Battle battle); + Optional findByBattleAndLabel(Battle battle, BattleOptionLabel label); - List findByBattleIn(List battles); + + @Query("SELECT bo FROM BattleOption bo " + + "WHERE bo.battle IN :battles " + + "ORDER BY bo.battle.id, COALESCE(bo.displayOrder, 9999), bo.label, bo.id") + List findByBattleIn(@Param("battles") List battles); } diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java index fb2ffce2..23f0d3dd 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.entity.BattleOptionTag; +import com.swyp.picke.domain.tag.entity.Tag; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -11,6 +12,7 @@ public interface BattleOptionTagRepository extends JpaRepository { List findByBattleOption(BattleOption battleOption); + boolean existsByTag(Tag tag); @Query("SELECT bot FROM BattleOptionTag bot JOIN FETCH bot.tag WHERE bot.battleOption.battle = :battle") List findByBattleWithTags(@Param("battle") Battle battle); diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleProposalRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleProposalRepository.java new file mode 100644 index 00000000..9079b381 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleProposalRepository.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.battle.repository; + +import com.swyp.picke.domain.battle.entity.BattleProposal; +import com.swyp.picke.domain.battle.enums.BattleProposalStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BattleProposalRepository extends JpaRepository { + Page findAllByStatusOrderByCreatedAtDesc(BattleProposalStatus status, Pageable pageable); + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + Page findAllByStatus(BattleProposalStatus status, Pageable pageable); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index c4aa3d8d..6bd79776 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -2,100 +2,107 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.data.domain.Page; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; public interface BattleRepository extends JpaRepository { - // 1. EDITOR PICK - type 파라미터 추가 + // 1. EDITOR PICK @Query("SELECT battle FROM Battle battle " + "WHERE battle.isEditorPick = true AND battle.status = :status " + - "AND battle.type = :type AND battle.deletedAt IS NULL " + + "AND battle.deletedAt IS NULL " + "ORDER BY battle.createdAt DESC") - List findEditorPicks(@Param("status") BattleStatus status, @Param("type") BattleType type, Pageable pageable); + List findEditorPicks(@Param("status") BattleStatus status, Pageable pageable); - // 2. 지금 뜨는 배틀 - type 파라미터 추가 - @Query("SELECT battle FROM Battle battle JOIN Vote vote ON vote.battle = battle " + - "WHERE vote.createdAt >= :yesterday AND battle.type = :type " + + // 2. 지금 뜨는 배틀 + @Query("SELECT battle FROM Battle battle JOIN BattleVote vote ON vote.battle = battle " + + "WHERE vote.createdAt >= :yesterday " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + "GROUP BY battle ORDER BY COUNT(vote) DESC") - List findTrendingBattles(@Param("yesterday") LocalDateTime yesterday, @Param("type") BattleType type, Pageable pageable); + List findTrendingBattles(@Param("yesterday") LocalDateTime yesterday, Pageable pageable); - // 3. Best 배틀 - type 파라미터 추가 + // 3. Best 배틀 @Query("SELECT battle FROM Battle battle " + - "WHERE battle.status = 'PUBLISHED' AND battle.type = :type AND battle.deletedAt IS NULL " + + "WHERE battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + "ORDER BY (battle.totalParticipantsCount + (battle.commentCount * 5)) DESC") - List findBestBattles(@Param("type") BattleType type, Pageable pageable); + List findBestBattles(Pageable pageable); // 4. 오늘의 Pické @Query("SELECT battle FROM Battle battle " + - "WHERE battle.type = :type AND battle.targetDate = :today " + + "WHERE battle.targetDate = :today " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL") - List findTodayPicks(@Param("type") BattleType type, @Param("today") LocalDate today, Pageable pageable); + List findTodayPicks(@Param("today") LocalDate today, Pageable pageable); + + // 5. 새로운 배틀 + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.status = 'PUBLISHED' " + + "AND battle.deletedAt IS NULL " + + "AND (battle.targetDate IS NULL OR battle.targetDate < :today) " + + "ORDER BY CASE WHEN battle.targetDate IS NULL THEN 0 ELSE 1 END, battle.targetDate ASC, battle.createdAt ASC") + List findAutoAssignableTodayPicks(@Param("today") LocalDate today, Pageable pageable); - // 5. 새로운 배틀 - type 파라미터 추가 @Query("SELECT battle FROM Battle battle " + - "WHERE battle.id NOT IN :excludeIds AND battle.type = :type " + + "WHERE battle.id NOT IN :excludeIds " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + "ORDER BY battle.createdAt DESC") - List findNewBattlesExcluding(@Param("excludeIds") List excludeIds, @Param("type") BattleType type, Pageable pageable); + List findNewBattlesExcluding(@Param("excludeIds") List excludeIds, Pageable pageable); - // 6. 전체 배틀 목록 조회 (페이징, 삭제된 항목 제외, 최신순) + // 6. 전체 배틀 목록 조회 Page findByDeletedAtIsNullOrderByCreatedAtDesc(Pageable pageable); - Page findByTypeAndDeletedAtIsNullOrderByCreatedAtDesc(BattleType type, Pageable pageable); + Page findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(BattleStatus status, Pageable pageable); + List findByStatusAndDeletedAtIsNull(BattleStatus status); // 기본 조회용 List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); - List findByTargetDateAndStatusAndTypeAndDeletedAtIsNull(LocalDate targetDate, BattleStatus status, BattleType type); + // 주간 배치: 특정 기간(targetDate BETWEEN from AND to)의 배틀 조회 + List findByTargetDateBetweenAndStatusAndDeletedAtIsNull(LocalDate from, LocalDate to, BattleStatus status); - // 탐색 탭: 전체 배틀 검색 (정렬은 Pageable Sort로 처리) - @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + // 탐색 탭: 전체 배틀 검색 + @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") List searchAll(Pageable pageable); - @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") long countSearchAll(); // 탐색 탭: 카테고리 태그 필터 배틀 검색 @Query("SELECT DISTINCT b FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + - "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + - "AND b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") List searchByCategory(@Param("categoryName") String categoryName, Pageable pageable); @Query("SELECT COUNT(DISTINCT b) FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + - "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + - "AND b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") long countSearchByCategory(@Param("categoryName") String categoryName); // 추천 폴백용: 전체 배틀 대상 인기 점수순 조회 (철학자 유형 로직 미구현 시 사용) // Score = V*1.0 + C*1.5 + Vw*0.2 @Query("SELECT b FROM Battle b " + "WHERE b.id NOT IN :excludeBattleIds " + - "AND b.type = 'BATTLE' " + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL " + "ORDER BY (b.totalParticipantsCount * 1.0 + b.commentCount * 1.5 + b.viewCount * 0.2) DESC") List findPopularBattlesExcluding( @Param("excludeBattleIds") List excludeBattleIds, - Pageable pageable); + Pageable pageable + ); // 추천용: 특정 유저들이 참여한 배틀 중 이미 참여한 배틀 제외하고 인기 점수순 조회 // Score = V*1.0 + C*1.5 + Vw*0.2 (R은 추후 반영 예정) @Query("SELECT b FROM Battle b " + "WHERE b.id IN :candidateBattleIds " + "AND b.id NOT IN :excludeBattleIds " + - "AND b.type = 'BATTLE' " + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL " + "ORDER BY (b.totalParticipantsCount * 1.0 + b.commentCount * 1.5 + b.viewCount * 0.2) DESC") List findRecommendedBattles( @Param("candidateBattleIds") List candidateBattleIds, @Param("excludeBattleIds") List excludeBattleIds, - Pageable pageable); -} \ No newline at end of file + Pageable pageable + ); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleProposalService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleProposalService.java new file mode 100644 index 00000000..ec3db614 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleProposalService.java @@ -0,0 +1,94 @@ +package com.swyp.picke.domain.battle.service; + +import com.swyp.picke.domain.battle.dto.request.BattleProposalRequest; +import com.swyp.picke.domain.battle.dto.request.BattleProposalReviewRequest; +import com.swyp.picke.domain.battle.dto.response.BattleProposalResponse; +import com.swyp.picke.domain.battle.entity.BattleProposal; +import com.swyp.picke.domain.battle.enums.BattleProposalStatus; +import com.swyp.picke.domain.battle.repository.BattleProposalRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.user.service.UserService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.common.response.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BattleProposalService { + + private final BattleProposalRepository battleProposalRepository; + private final CreditService creditService; + private final UserService userService; + + @Transactional + public BattleProposalResponse propose(BattleProposalRequest request) { + User user = userService.findCurrentUser(); + + int cost = CreditType.TOPIC_SUGGEST.getDefaultAmount(); + + int totalCredits = creditService.getTotalPoints(user.getId()); + if (totalCredits < cost) { + throw new CustomException(ErrorCode.CREDIT_NOT_ENOUGH); + } + + BattleProposal proposal = BattleProposal.builder() + .user(user) + .category(request.getCategory()) + .topic(request.getTopic()) + .positionA(request.getPositionA()) + .positionB(request.getPositionB()) + .description(request.getDescription()) + .build(); + + battleProposalRepository.save(proposal); + + creditService.addCredit(user.getId(), CreditType.TOPIC_SUGGEST, -cost, proposal.getId()); + + return new BattleProposalResponse(proposal); + } + + public PageResponse getProposals(int page, int size, String status) { + int pageNumber = Math.max(0, page - 1); + Pageable pageable = PageRequest.of(pageNumber, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + BattleProposalStatus proposalStatus = (status != null && !status.isEmpty()) + ? BattleProposalStatus.valueOf(status.toUpperCase()) + : null; + + Page proposals = (proposalStatus != null) + ? battleProposalRepository.findAllByStatus(proposalStatus, pageable) + : battleProposalRepository.findAll(pageable); + + return PageResponse.of(proposals.map(BattleProposalResponse::new)); + } + + @Transactional + public BattleProposalResponse review(Long proposalId, BattleProposalReviewRequest request) { + BattleProposal proposal = battleProposalRepository.findById(proposalId) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_NOT_FOUND)); + + if (proposal.getStatus() != BattleProposalStatus.PENDING) { + throw new CustomException(ErrorCode.BATTLE_ALREADY_PUBLISHED); + } + + if (request.getAction() == BattleProposalReviewRequest.Action.ACCEPT) { + proposal.accept(); + int reward = CreditType.TOPIC_ADOPTED.getDefaultAmount(); + creditService.addCredit(proposal.getUser().getId(), CreditType.TOPIC_ADOPTED, reward, proposalId); + } else { + proposal.reject(); + } + + return new BattleProposalResponse(proposal); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java index baf96eb4..a5d1df46 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java @@ -1,67 +1,56 @@ package com.swyp.picke.domain.battle.service; -import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; -import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; -import com.swyp.picke.domain.battle.dto.response.*; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; +import com.swyp.picke.domain.battle.dto.response.BattleListResponse; +import com.swyp.picke.domain.battle.dto.response.BattleScenarioResponse; +import com.swyp.picke.domain.battle.dto.response.BattleUserDetailResponse; +import com.swyp.picke.domain.battle.dto.response.BattleVoteResponse; +import com.swyp.picke.domain.battle.dto.response.TodayBattleListResponse; +import com.swyp.picke.domain.battle.dto.response.TodayBattleResponse; import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -import com.swyp.picke.domain.battle.enums.BattleType; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; - import java.util.List; public interface BattleService { - // === [내부 공통/조회 메서드] === Battle findById(Long battleId); - BattleOption findOptionById(Long optionId); - BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabel label); - - // === [사용자용 - 홈 화면 5단 로직 지원 API] === - // 1. 에디터 픽 조회 (isEditorPick = true) - List getEditorPicks(int limit); + BattleOption findOptionById(Long optionId); - // 2. 지금 뜨는 배틀 조회 (최근 24시간 투표 급증순) - List getTrendingBattles(int limit); + BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabel label); - // 3. Best 배틀 조회 (누적 지표 랭킹) - List getBestBattles(int limit); + List getEditorPicks(); - // 4. 오늘의 Pické 조회 (단일 타입 매칭) - List getTodayPicks(BattleType type, int limit); + List getTrendingBattles(); - // 5. 새로운 배틀 조회 (중복 제외 리스트) - List getNewBattles(List excludeIds, int limit); + List getBestBattles(); + List getTodayPicks(); - // === [사용자용 - 기본 API] === + List getNewBattles(List excludeIds); - // 전체 배틀 목록 페이징 조회 - BattleListResponse getBattles(int page, int size, String type); + BattleListResponse getBattles(int page, int size, String status); - // 오늘의 배틀 (기존 로직 유지용) TodayBattleListResponse getTodayBattles(); - // 배틀 상세 정보 BattleUserDetailResponse getBattleDetail(Long battleId); - // 투표 실행 및 실시간 통계 결과 반환 - BattleVoteResponse vote(Long battleId, Long optionId); + BattleVoteResponse BattleVote(Long battleId, Long optionId); BattleScenarioResponse getBattleScenario(Long battleId); UserBattleStatusResponse getUserBattleStatus(Long battleId); - // === [관리자용 API] === - - // 배틀 생성 AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId); - // 배틀 수정 + AdminBattleDetailResponse getAdminBattleDetail(Long battleId); + AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request); - // 배틀 삭제 (DB에서 지우지 않고 소프트 딜리트/상태변경을 수행합니다) AdminBattleDeleteResponse deleteBattle(Long battleId); } diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index 5956d719..e8b59d6c 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -1,8 +1,11 @@ package com.swyp.picke.domain.battle.service; import com.swyp.picke.domain.battle.converter.BattleConverter; -import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; -import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; import com.swyp.picke.domain.battle.dto.response.*; import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; @@ -10,7 +13,6 @@ import com.swyp.picke.domain.battle.entity.BattleTag; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.battle.repository.BattleOptionRepository; @@ -18,15 +20,18 @@ import com.swyp.picke.domain.battle.repository.BattleRepository; import com.swyp.picke.domain.battle.repository.BattleTagRepository; import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.tag.repository.TagRepository; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.VoteSide; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.service.UserBattleService; -import com.swyp.picke.domain.vote.entity.Vote; -import com.swyp.picke.domain.vote.repository.VoteRepository; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.local.service.LocalDraftFileStorageService; +import com.swyp.picke.global.infra.s3.enums.FileCategory; import com.swyp.picke.global.infra.s3.service.S3UploadService; import com.swyp.picke.global.util.SecurityUtil; import lombok.RequiredArgsConstructor; @@ -38,6 +43,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.*; import java.util.stream.Collectors; @@ -46,15 +54,23 @@ @Transactional(readOnly = true) public class BattleServiceImpl implements BattleService { + private static final int HOME_EDITOR_PICK_LIMIT = 10; + private static final int HOME_TRENDING_LIMIT = 4; + private static final int HOME_BEST_LIMIT = 3; + private static final int HOME_TODAY_PICK_LIMIT = 1; + private static final int HOME_NEW_LIMIT = 3; + private static final Pattern RESOURCE_IMAGE_PATH_PATTERN = Pattern.compile("/api/v1/resources/images/([A-Z_]+)/(.+)"); + private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; private final BattleTagRepository battleTagRepository; private final BattleOptionTagRepository battleOptionTagRepository; private final TagRepository tagRepository; private final UserRepository userRepository; - private final VoteRepository voteRepository; + private final BattleVoteRepository battleVoteRepository; private final BattleConverter battleConverter; private final S3UploadService s3UploadService; + private final LocalDraftFileStorageService localDraftFileStorageService; private final UserBattleService userBattleService; @Override @@ -68,49 +84,81 @@ public Battle findById(Long battleId) { } @Override - public List getEditorPicks(int limit) { - List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, BattleType.BATTLE, PageRequest.of(0, limit)); - return convertToTodayResponses(battles); + public List getEditorPicks() { + return loadEditorPicks(HOME_EDITOR_PICK_LIMIT); + } + + @Override + public List getTrendingBattles() { + return loadTrendingBattles(HOME_TRENDING_LIMIT); + } + + @Override + public List getBestBattles() { + return loadBestBattles(HOME_BEST_LIMIT); + } + + @Override + @Transactional + public List getTodayPicks() { + return loadTodayPicks(HOME_TODAY_PICK_LIMIT); } @Override - public List getTrendingBattles(int limit) { + public List getNewBattles(List excludeIds) { + return loadNewBattles(excludeIds, HOME_NEW_LIMIT); + } + + private List loadEditorPicks(int limit) { + int safeLimit = Math.max(1, limit); + List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, safeLimit)); + return convertToTodayResponses(battles); + } + + private List loadTrendingBattles(int limit) { + int safeLimit = Math.max(1, limit); LocalDateTime yesterday = LocalDateTime.now().minusDays(1); - List battles = battleRepository.findTrendingBattles(yesterday, BattleType.BATTLE, PageRequest.of(0, limit)); + List battles = battleRepository.findTrendingBattles(yesterday, PageRequest.of(0, safeLimit)); return convertToTodayResponses(battles); } - @Override - public List getBestBattles(int limit) { - List battles = battleRepository.findBestBattles(BattleType.BATTLE, PageRequest.of(0, limit)); + private List loadBestBattles(int limit) { + int safeLimit = Math.max(1, limit); + List battles = battleRepository.findBestBattles(PageRequest.of(0, safeLimit)); return convertToTodayResponses(battles); } - @Override - public List getTodayPicks(BattleType type, int limit) { - List battles = battleRepository.findTodayPicks(type, LocalDate.now(), PageRequest.of(0, limit)); + private List loadTodayPicks(int limit) { + int safeLimit = Math.max(1, limit); + LocalDate today = LocalDate.now(); + ensureTodayPicks(today, safeLimit); + + List battles = battleRepository.findTodayPicks(today, PageRequest.of(0, safeLimit)); return convertToTodayResponses(battles); } - @Override - public List getNewBattles(List excludeIds, int limit) { + private List loadNewBattles(List excludeIds, int limit) { + int safeLimit = Math.max(1, limit); List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) ? List.of(-1L) : excludeIds; - List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, BattleType.BATTLE, PageRequest.of(0, limit)); + List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, safeLimit)); return convertToTodayResponses(battles); } @Override - public BattleListResponse getBattles(int page, int size, String type) { + public BattleListResponse getBattles(int page, int size, String status) { int pageNumber = Math.max(0, page - 1); PageRequest pageRequest = PageRequest.of(pageNumber, size); - Page battlePage; + BattleStatus battleStatusFilter = parseBattleStatus(status); - if (type == null || type.equals("ALL")) { + Page battlePage; + if (battleStatusFilter == null) { battlePage = battleRepository.findByDeletedAtIsNullOrderByCreatedAtDesc(pageRequest); } else { - battlePage = battleRepository.findByTypeAndDeletedAtIsNullOrderByCreatedAtDesc( - BattleType.valueOf(type), pageRequest); + battlePage = battleRepository.findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc( + battleStatusFilter, + pageRequest + ); } List items = battlePage.getContent().stream() @@ -126,9 +174,11 @@ public BattleListResponse getBattles(int page, int size, String type) { } @Override + @Transactional public TodayBattleListResponse getTodayBattles() { - List battles = battleRepository.findByTargetDateAndStatusAndTypeAndDeletedAtIsNull( - LocalDate.now(), BattleStatus.PUBLISHED, BattleType.BATTLE); + LocalDate today = LocalDate.now(); + ensureTodayPicks(today, 5); + List battles = battleRepository.findByTargetDateAndStatusAndDeletedAtIsNull(today, BattleStatus.PUBLISHED); List limitedBattles = battles.stream() .limit(5) @@ -139,6 +189,17 @@ public TodayBattleListResponse getTodayBattles() { return new TodayBattleListResponse(items, items.size()); } + private void ensureTodayPicks(LocalDate today, int requiredCount) { + List todays = battleRepository.findTodayPicks(today, PageRequest.of(0, requiredCount)); + int missingCount = requiredCount - todays.size(); + if (missingCount <= 0) return; + + List candidates = battleRepository.findAutoAssignableTodayPicks(today, PageRequest.of(0, missingCount)); + for (Battle candidate : candidates) { + candidate.updateTargetDate(today); + } + } + @Override @Transactional(readOnly = true) public BattleUserDetailResponse getBattleDetail(Long battleId) { @@ -158,11 +219,11 @@ public BattleUserDetailResponse getBattleDetail(Long battleId) { UserBattleStatusResponse statusResponse = userBattleService.getUserBattleStatus(user, battle); UserBattleStep currentStep = statusResponse.step(); - Optional optionalVote = voteRepository.findByBattleIdAndUserIdWithOption(battleId, currentUserId); + Optional optionalVote = battleVoteRepository.findByBattleIdAndUserIdWithOption(battleId, currentUserId); VoteSide voteStatus = optionalVote - .map(vote -> { - if (vote.getPostVoteOption() != null) { - return vote.getPostVoteOption().getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; + .map(BattleVote -> { + if (BattleVote.getPostVoteOption() != null) { + return BattleVote.getPostVoteOption().getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; } return null; }) @@ -195,7 +256,7 @@ public UserBattleStatusResponse getUserBattleStatus(Long battleId) { @Override @Transactional - public BattleVoteResponse vote(Long battleId, Long optionId) { + public BattleVoteResponse BattleVote(Long battleId, Long optionId) { Battle battle = findById(battleId); BattleOption newOption = battleOptionRepository.findById(optionId) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); @@ -204,7 +265,7 @@ public BattleVoteResponse vote(Long battleId, Long optionId) { User user = userRepository.findById(currentUserId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - voteRepository.save(Vote.builder() + battleVoteRepository.save(BattleVote.builder() .user(user) .battle(battle) .preVoteOption(newOption) @@ -232,29 +293,46 @@ public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, User admin = userRepository.findById(adminUserId == null ? 1L : adminUserId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Battle battle = battleRepository.save(battleConverter.toEntity(request, admin)); + validateBattleOptionCount(request.options()); + + String resolvedThumbnailKey = resolveStoredImageKey(request.thumbnailUrl(), request.status(), FileCategory.BATTLE); + Battle battle = battleConverter.toEntity(request, admin); + battle.update( + request.title(), + request.summary(), + request.description(), + resolvedThumbnailKey, + request.status() + ); + battle = battleRepository.save(battle); if (request.tagIds() != null) { saveBattleTags(battle, request.tagIds().stream().distinct().toList()); } List savedOptions = new ArrayList<>(); - for (var optionRequest : request.options()) { - BattleOption option = battleOptionRepository.save(BattleOption.builder() - .battle(battle) - .label(optionRequest.label()) - .title(optionRequest.title()) - .stance(optionRequest.stance()) - .representative(optionRequest.representative()) - .quote(optionRequest.quote()) - .imageUrl(optionRequest.imageUrl()) - .isCorrect(optionRequest.isCorrect()) - .build()); - - if (optionRequest.tagIds() != null) { - saveBattleOptionTags(option, optionRequest.tagIds().stream().distinct().toList()); + if (request.options() != null) { + for (AdminBattleOptionRequest optionRequest : request.options()) { + String resolvedImageKey = resolveStoredImageKey( + optionRequest.imageUrl(), + request.status(), + FileCategory.PHILOSOPHER + ); + BattleOption option = BattleOption.builder() + .battle(battle) + .label(optionRequest.label()) + .title(optionRequest.title()) + .stance(optionRequest.stance()) + .representative(optionRequest.representative()) + .imageUrl(resolvedImageKey) + .build(); + option = battleOptionRepository.save(option); + + if (optionRequest.tagIds() != null) { + saveBattleOptionTags(option, optionRequest.tagIds().stream().distinct().toList()); + } + savedOptions.add(option); } - savedOptions.add(option); } Map> optionTagsMap = battleOptionTagRepository.findByBattleWithTags(battle) @@ -267,21 +345,41 @@ public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), savedOptions, optionTagsMap); } + @Override + @Transactional(readOnly = true) + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDetailResponse getAdminBattleDetail(Long battleId) { + Battle battle = findById(battleId); + List options = battleOptionRepository.findByBattle(battle); + Map> optionTagsMap = battleOptionTagRepository.findByBattleWithTags(battle) + .stream() + .collect(Collectors.groupingBy( + bot -> bot.getBattleOption().getId(), + Collectors.mapping(BattleOptionTag::getTag, Collectors.toList()) + )); + + return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), options, optionTagsMap); + } + @Override @Transactional @PreAuthorize("hasRole('ADMIN')") public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request) { Battle battle = findById(battleId); + validateBattleOptionCount(request.options()); - if (battle.getThumbnailUrl() != null && !battle.getThumbnailUrl().equals(request.thumbnailUrl())) { - s3UploadService.deleteFile(battle.getThumbnailUrl()); + String existingThumbnailKey = normalizeStoredImageReference(battle.getThumbnailUrl(), FileCategory.BATTLE); + String resolvedThumbnailKey = resolveStoredImageKey(request.thumbnailUrl(), request.status(), FileCategory.BATTLE); + if (existingThumbnailKey != null && !existingThumbnailKey.equals(resolvedThumbnailKey)) { + deleteStoredAsset(existingThumbnailKey); } battle.update( - request.title(), request.titlePrefix(), request.titleSuffix(), - request.itemA(), request.itemADesc(), request.itemB(), request.itemBDesc(), - request.summary(), request.description(), request.thumbnailUrl(), - request.targetDate(), request.audioDuration(), request.status() + request.title(), + request.summary(), + request.description(), + resolvedThumbnailKey, + request.status() ); if (request.tagIds() != null) { @@ -292,17 +390,56 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe if (request.options() != null) { List existingOptions = battleOptionRepository.findByBattle(battle); - for (var optionRequest : request.options()) { - existingOptions.stream() - .filter(option -> option.getLabel() == optionRequest.label()) - .findFirst() - .ifPresent(option -> { - if (option.getImageUrl() != null && !option.getImageUrl().equals(optionRequest.imageUrl())) { - s3UploadService.deleteFile(option.getImageUrl()); - } - option.update(optionRequest.title(), optionRequest.stance(), - optionRequest.representative(), optionRequest.quote(), optionRequest.imageUrl(), optionRequest.isCorrect()); - }); + Map existingOptionMap = existingOptions.stream() + .collect(Collectors.toMap(BattleOption::getLabel, option -> option)); + + Set requestedLabels = new HashSet<>(); + + for (AdminBattleOptionRequest optionRequest : request.options()) { + requestedLabels.add(optionRequest.label()); + + BattleOption option = existingOptionMap.get(optionRequest.label()); + String resolvedOptionImageKey = resolveStoredImageKey( + optionRequest.imageUrl(), + request.status(), + FileCategory.PHILOSOPHER + ); + if (option == null) { + option = BattleOption.builder() + .battle(battle) + .label(optionRequest.label()) + .title(optionRequest.title()) + .stance(optionRequest.stance()) + .representative(optionRequest.representative()) + .imageUrl(resolvedOptionImageKey) + .build(); + option = battleOptionRepository.save(option); + } else { + String existingOptionImageKey = normalizeStoredImageReference(option.getImageUrl(), FileCategory.PHILOSOPHER); + if (existingOptionImageKey != null && !existingOptionImageKey.equals(resolvedOptionImageKey)) { + deleteStoredAsset(existingOptionImageKey); + } + option.update(optionRequest.title(), optionRequest.stance(), + optionRequest.representative(), resolvedOptionImageKey); + } + + replaceBattleOptionTags(option, optionRequest.tagIds()); + } + + List removedOptions = existingOptions.stream() + .filter(existing -> !requestedLabels.contains(existing.getLabel())) + .toList(); + + for (BattleOption removedOption : removedOptions) { + deleteStoredAsset(removedOption.getImageUrl()); + List optionTags = battleOptionTagRepository.findByBattleOption(removedOption); + if (!optionTags.isEmpty()) { + battleOptionTagRepository.deleteAll(optionTags); + } + } + + if (!removedOptions.isEmpty()) { + battleOptionRepository.deleteAll(removedOptions); } } @@ -355,6 +492,7 @@ private List getTagsByBattle(Battle battle) { private void saveBattleTags(Battle battle, List ids) { tagRepository.findAllById(ids).stream() .filter(tag -> tag.getDeletedAt() == null) + .filter(tag -> tag.getType() == TagType.CATEGORY) .forEach(tag -> battleTagRepository.save( BattleTag.builder().battle(battle).tag(tag).build())); } @@ -362,10 +500,22 @@ private void saveBattleTags(Battle battle, List ids) { private void saveBattleOptionTags(BattleOption option, List tagIds) { tagRepository.findAllById(tagIds).stream() .filter(tag -> tag.getDeletedAt() == null) + .filter(tag -> tag.getType() == TagType.PHILOSOPHER || tag.getType() == TagType.VALUE) .forEach(tag -> battleOptionTagRepository.save( BattleOptionTag.builder().battleOption(option).tag(tag).build())); } + private void replaceBattleOptionTags(BattleOption option, List tagIds) { + if (tagIds == null) return; + + List existingTags = battleOptionTagRepository.findByBattleOption(option); + if (!existingTags.isEmpty()) { + battleOptionTagRepository.deleteAll(existingTags); + } + + saveBattleOptionTags(option, tagIds.stream().distinct().toList()); + } + @Override public BattleOption findOptionById(Long optionId) { return battleOptionRepository.findById(optionId) @@ -378,4 +528,95 @@ public BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabe return battleOptionRepository.findByBattleAndLabel(battle, label) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); } -} \ No newline at end of file + + private String resolveStoredImageKey(String rawReference, BattleStatus targetStatus, FileCategory fallbackCategory) { + String normalized = normalizeStoredImageReference(rawReference, fallbackCategory); + if (normalized == null) { + return null; + } + if (targetStatus == BattleStatus.PUBLISHED && localDraftFileStorageService.isLocalDraftReference(normalized)) { + return localDraftFileStorageService.promoteLocalDraftToS3(normalized, fallbackCategory, s3UploadService); + } + return normalized; + } + + private String normalizeStoredImageReference(String rawReference, FileCategory fallbackCategory) { + if (rawReference == null || rawReference.isBlank()) { + return null; + } + + String trimmed = rawReference.trim(); + String localNormalized = localDraftFileStorageService.normalizeLocalDraftKey(trimmed); + if (localDraftFileStorageService.isLocalDraftReference(localNormalized)) { + return localNormalized; + } + + String path = extractPath(trimmed); + Matcher matcher = RESOURCE_IMAGE_PATH_PATTERN.matcher(path); + if (matcher.find()) { + String categoryName = matcher.group(1); + String fileName = matcher.group(2); + try { + FileCategory category = FileCategory.valueOf(categoryName); + return category.getPath() + "/" + fileName; + } catch (IllegalArgumentException ignored) { + if (fallbackCategory != null) { + return fallbackCategory.getPath() + "/" + fileName; + } + } + } + + return trimmed; + } + + private String extractPath(String value) { + if (value.startsWith("http://") || value.startsWith("https://")) { + try { + URI uri = URI.create(value); + return uri.getPath(); + } catch (IllegalArgumentException ignored) { + return value; + } + } + return value; + } + + private void deleteStoredAsset(String rawReference) { + String normalized = normalizeStoredImageReference(rawReference, null); + if (normalized == null) { + return; + } + + if (localDraftFileStorageService.isLocalDraftReference(normalized)) { + localDraftFileStorageService.deleteIfLocalReference(normalized); + return; + } + + s3UploadService.deleteFile(normalized); + } + + private BattleStatus parseBattleStatus(String status) { + if (status == null || status.isBlank() || "ALL".equalsIgnoreCase(status)) { + return null; + } + + try { + return BattleStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + private void validateBattleOptionCount(List options) { + if (options == null) { + throw new CustomException(ErrorCode.BATTLE_INVALID_OPTION_COUNT); + } + int count = options.size(); + if (count < 2 || count > 4) { + throw new CustomException(ErrorCode.BATTLE_INVALID_OPTION_COUNT); + } + } +} + + + diff --git a/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java b/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java index 2cfddac7..e466fb6a 100644 --- a/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java +++ b/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "홈 API", description = "홈 화면 집계 조회") +@Tag(name = "홈 API", description = "홈 화면 데이터 조회") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") @@ -19,7 +19,7 @@ public class HomeController { private final HomeService homeService; - @Operation(summary = "홈 화면 집계 조회") + @Operation(summary = "홈 화면 데이터 조회") @GetMapping("/home") public ApiResponse getHome(@AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(homeService.getHome(userId)); diff --git a/src/main/java/com/swyp/picke/domain/home/service/HomeService.java b/src/main/java/com/swyp/picke/domain/home/service/HomeService.java index 6aa4f55b..4d3082cd 100644 --- a/src/main/java/com/swyp/picke/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/picke/domain/home/service/HomeService.java @@ -1,30 +1,47 @@ package com.swyp.picke.domain.home.service; -import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; import com.swyp.picke.domain.battle.dto.response.TodayBattleResponse; import com.swyp.picke.domain.battle.dto.response.TodayOptionResponse; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -import com.swyp.picke.domain.battle.enums.BattleType; -import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.battle.service.BattleService; -import com.swyp.picke.domain.home.dto.response.*; +import com.swyp.picke.domain.home.dto.response.HomeBestBattleResponse; +import com.swyp.picke.domain.home.dto.response.HomeEditorPickResponse; +import com.swyp.picke.domain.home.dto.response.HomeNewBattleResponse; +import com.swyp.picke.domain.home.dto.response.HomeResponse; +import com.swyp.picke.domain.home.dto.response.HomeTodayQuizResponse; +import com.swyp.picke.domain.home.dto.response.HomeTodayVoteOptionResponse; +import com.swyp.picke.domain.home.dto.response.HomeTodayVoteResponse; +import com.swyp.picke.domain.home.dto.response.HomeTrendingResponse; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.service.PollService; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import com.swyp.picke.domain.quiz.service.QuizService; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class HomeService { + private static final int HOME_TODAY_PICK_LIMIT = 1; + private static final String QUIZ_SUMMARY = "왼쪽과 오른쪽 중 정답을 선택하세요"; + private static final String POLL_SUMMARY = "빈칸에 들어갈 가장 적절한 답을 골라주세요"; + private final BattleService battleService; + private final QuizService quizService; + private final PollService pollService; private final NotificationService notificationService; private final S3PresignedUrlService s3PresignedUrlService; @@ -33,15 +50,15 @@ public HomeResponse getHome(Long userId) { if (userId != null) { newNotice = notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE); } - // DB 쿼리 단계에서 LIMIT을 걸어 필요한 개수만 깔끔하게 조회! - List editorPickRaw = battleService.getEditorPicks(10); - List trendingRaw = battleService.getTrendingBattles(4); - List bestRaw = battleService.getBestBattles(3); - List voteRaw = battleService.getTodayPicks(BattleType.VOTE, 1); - List quizRaw = battleService.getTodayPicks(BattleType.QUIZ, 1); - List excludeIds = collectBattleIds(editorPickRaw, trendingRaw, bestRaw, voteRaw, quizRaw); - List newRaw = battleService.getNewBattles(excludeIds, 3); + List editorPickRaw = battleService.getEditorPicks(); + List trendingRaw = battleService.getTrendingBattles(); + List bestRaw = battleService.getBestBattles(); + List quizRaw = quizService.getTodayPicks(HOME_TODAY_PICK_LIMIT); + List pollRaw = pollService.getTodayPicks(HOME_TODAY_PICK_LIMIT); + + List excludeIds = collectBattleIds(editorPickRaw, trendingRaw, bestRaw); + List newRaw = battleService.getNewBattles(excludeIds); return new HomeResponse( newNotice, @@ -49,131 +66,145 @@ public HomeResponse getHome(Long userId) { trendingRaw.stream().map(this::toTrending).toList(), bestRaw.stream().map(this::toBestBattle).toList(), quizRaw.stream().map(this::toTodayQuiz).toList(), - voteRaw.stream().map(this::toTodayVote).toList(), + pollRaw.stream().map(this::toTodayVote).toList(), newRaw.stream().map(this::toNewBattle).toList() ); } - // 에디터픽 썸네일 Presigned URL 적용 - private HomeEditorPickResponse toEditorPick(TodayBattleResponse b) { - String optionA = findOptionTitle(b.options(), BattleOptionLabel.A); - String optionB = findOptionTitle(b.options(), BattleOptionLabel.B); - - String secureThumb = b.thumbnailUrl(); - + private HomeEditorPickResponse toEditorPick(TodayBattleResponse battle) { return new HomeEditorPickResponse( - b.battleId(), secureThumb, - optionA, optionB, - b.title(), b.summary(), - b.tags(), b.viewCount() + battle.battleId(), + battle.thumbnailUrl(), + findOptionTitle(battle.options(), BattleOptionLabel.A), + findOptionTitle(battle.options(), BattleOptionLabel.B), + battle.title(), + battle.summary(), + battle.tags(), + battle.viewCount() ); } - private HomeTrendingResponse toTrending(TodayBattleResponse b) { + private HomeTrendingResponse toTrending(TodayBattleResponse battle) { return new HomeTrendingResponse( - b.battleId(), b.thumbnailUrl(), - b.title(), b.tags(), - b.audioDuration(), b.viewCount() + battle.battleId(), + battle.thumbnailUrl(), + battle.title(), + battle.tags(), + battle.audioDuration(), + battle.viewCount() ); } - private HomeBestBattleResponse toBestBattle(TodayBattleResponse b) { - String philoA = findOptionRepresentative(b.options(), BattleOptionLabel.A); - String philoB = findOptionRepresentative(b.options(), BattleOptionLabel.B); - + private HomeBestBattleResponse toBestBattle(TodayBattleResponse battle) { return new HomeBestBattleResponse( - b.battleId(), - philoA, philoB, - b.title(), b.tags(), - b.audioDuration(), b.viewCount() + battle.battleId(), + findOptionRepresentative(battle.options(), BattleOptionLabel.A), + findOptionRepresentative(battle.options(), BattleOptionLabel.B), + battle.title(), + battle.tags(), + battle.audioDuration(), + battle.viewCount() ); } - private HomeTodayQuizResponse toTodayQuiz(TodayBattleResponse b) { + private HomeTodayQuizResponse toTodayQuiz(Quiz quiz) { + List options = quizService.getOptions(quiz); + long participantsCount = quizService.countVotes(quiz); + + QuizOption optionA = findQuizOption(options, QuizOptionLabel.A); + QuizOption optionB = findQuizOption(options, QuizOptionLabel.B); + return new HomeTodayQuizResponse( - b.battleId(), b.title(), b.summary(), - b.participantsCount(), - b.itemA(), b.itemADesc(), - findOptionIsCorrect(b.options(), BattleOptionLabel.A), - b.itemB(), b.itemBDesc(), - findOptionIsCorrect(b.options(), BattleOptionLabel.B) + quiz.getId(), + quiz.getTitle(), + QUIZ_SUMMARY, + participantsCount, + optionA != null ? optionA.getText() : null, + optionA != null ? optionA.getDetailText() : null, + false, + optionB != null ? optionB.getText() : null, + optionB != null ? optionB.getDetailText() : null, + false ); } - private HomeTodayVoteResponse toTodayVote(TodayBattleResponse b) { - List options = Optional.ofNullable(b.options()).orElse(List.of()).stream() - .map(o -> new HomeTodayVoteOptionResponse(o.label(), o.title())) + private HomeTodayVoteResponse toTodayVote(Poll poll) { + List options = pollService.getOptions(poll); + long participantsCount = pollService.countVotes(poll); + + List homeOptions = options.stream() + .sorted(Comparator + .comparing((PollOption option) -> option.getDisplayOrder() == null ? Integer.MAX_VALUE : option.getDisplayOrder()) + .thenComparing(option -> option.getLabel() == null ? "" : option.getLabel().name()) + .thenComparing(option -> option.getId() == null ? Long.MAX_VALUE : option.getId())) + .map(option -> new HomeTodayVoteOptionResponse( + BattleOptionLabel.valueOf(option.getLabel().name()), + option.getTitle() + )) .toList(); + return new HomeTodayVoteResponse( - b.battleId(), - b.titlePrefix(), b.titleSuffix(), - b.summary(), b.participantsCount(), - options + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + POLL_SUMMARY, + participantsCount, + homeOptions ); } - // newBattle 썸네일 Presigned URL 적용 - private HomeNewBattleResponse toNewBattle(TodayBattleResponse b) { - String philoA = findOptionRepresentative(b.options(), BattleOptionLabel.A); - String philoB = findOptionRepresentative(b.options(), BattleOptionLabel.B); - - String optionA = findOptionTitle(b.options(), BattleOptionLabel.A); - String optionB = findOptionTitle(b.options(), BattleOptionLabel.B); - - String imageA = findRepresentativeImageUrl(b.options(), BattleOptionLabel.A); - String imageB = findRepresentativeImageUrl(b.options(), BattleOptionLabel.B); - + private HomeNewBattleResponse toNewBattle(TodayBattleResponse battle) { return new HomeNewBattleResponse( - b.battleId(), b.thumbnailUrl(), - b.title(), b.summary(), - philoA, optionA, imageA, - philoB, optionB, imageB, - b.tags(), b.audioDuration(), b.viewCount() + battle.battleId(), + battle.thumbnailUrl(), + battle.title(), + battle.summary(), + findOptionRepresentative(battle.options(), BattleOptionLabel.A), + findOptionTitle(battle.options(), BattleOptionLabel.A), + findRepresentativeImageUrl(battle.options(), BattleOptionLabel.A), + findOptionRepresentative(battle.options(), BattleOptionLabel.B), + findOptionTitle(battle.options(), BattleOptionLabel.B), + findRepresentativeImageUrl(battle.options(), BattleOptionLabel.B), + battle.tags(), + battle.audioDuration(), + battle.viewCount() ); } - private Boolean findOptionIsCorrect(List options, BattleOptionLabel label) { - return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(o -> o.label() == label) - .map(TodayOptionResponse::isCorrect) - .findFirst() - .map(Boolean.TRUE::equals) - .orElse(false); - } - private String findOptionTitle(List options, BattleOptionLabel label) { return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(o -> o.label() == label) + .filter(option -> option.label() == label) .map(TodayOptionResponse::title) .filter(Objects::nonNull) - .findFirst().orElse(null); + .findFirst() + .orElse(null); } - // 옵션에서 철학자 이름(Representative)을 추출하는 메서드 private String findOptionRepresentative(List options, BattleOptionLabel label) { return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(o -> o.label() == label) + .filter(option -> option.label() == label) .map(TodayOptionResponse::representative) .filter(Objects::nonNull) - .findFirst().orElse(null); - } - - private List findPhilosopherNames(List tags) { - return Optional.ofNullable(tags).orElse(List.of()).stream() - .filter(t -> t.type() == TagType.PHILOSOPHER) - .map(BattleTagResponse::name) - .toList(); + .findFirst() + .orElse(null); } private String findRepresentativeImageUrl(List options, BattleOptionLabel label) { return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(o -> o.label() == label) + .filter(option -> option.label() == label) .map(TodayOptionResponse::imageUrl) .filter(Objects::nonNull) .findFirst() .orElse(null); } + private QuizOption findQuizOption(List options, QuizOptionLabel label) { + return options.stream() + .filter(option -> option.getLabel() == label) + .findFirst() + .orElse(null); + } + @SafeVarargs private List collectBattleIds(List... groups) { return List.of(groups).stream() diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java index 9165eb58..973a589b 100644 --- a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java @@ -45,4 +45,14 @@ AND NOT EXISTS ( WHERE n.user.id = :userId AND n.read = false """) int markAllAsReadByUserId(@Param("userId") Long userId); + + @Query(""" + SELECT n FROM Notification n + WHERE (:category IS NULL OR n.category = :category) + ORDER BY n.createdAt DESC + """) + Slice findNotificationsForAdmin( + @Param("category") NotificationCategory category, + Pageable pageable + ); } diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java index 93b730f6..80539904 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java @@ -53,11 +53,19 @@ public Notification createNotification(Long userId, NotificationDetailCode detai @Transactional public Notification createBroadcastNotification(NotificationDetailCode detailCode, String body, Long referenceId) { + return createBroadcastNotification(detailCode, null, body, referenceId); + } + + @Transactional + public Notification createBroadcastNotification(NotificationDetailCode detailCode, String customTitle, String body, Long referenceId) { + String resolvedTitle = (customTitle == null || customTitle.isBlank()) + ? detailCode.getDefaultTitle() + : customTitle; Notification notification = Notification.builder() .user(null) .category(detailCode.getCategory()) .detailCode(detailCode) - .title(detailCode.getDefaultTitle()) + .title(resolvedTitle) .body(body) .referenceId(referenceId) .build(); diff --git a/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java b/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java index b7150503..0ac93e0a 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java +++ b/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java @@ -17,7 +17,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -@Tag(name = "인증 (Auth)", description = "인증 API") +@Tag(name = "인증 API", description = "소셜 로그인, 토큰 재발급, 로그아웃, 회원 탈퇴") public class AuthController { private final AuthService authService; diff --git a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java index 0f3477e7..8637f470 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -43,6 +43,10 @@ public class JwtFilter extends OncePerRequestFilter { "/api/v1/notices", // 공지사항 "/api/test", // 테스트용 "/result", // 공유 링크 리다이렉트 + "/report", // 철학자 리포트 딥링크 + "/battle", // 배틀 딥링크 + "/api/v1/share/recap/", // 공개 리캡 공유 조회 + "/.well-known", // Android App Links 인증 "/api/v1/resources" // 이미지, 오디오 파일 (Presigned URL) ); @@ -126,4 +130,4 @@ private boolean isWhitelisted(String uri) { // 1. URI가 화이트리스트의 어떤 값으로든 시작하면 true return WHITELIST.stream().anyMatch(uri::startsWith); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java new file mode 100644 index 00000000..552c30c5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.perspective.controller; + +import com.swyp.picke.domain.perspective.scheduler.BestCommentScheduler; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "[Test] BestCommentScheduler", description = "스케줄러 테스트 API") +@RestController +@RequestMapping("/api/test/scheduler") +@RequiredArgsConstructor +public class BestCommentSchedulerTestController { + + private final BestCommentScheduler bestCommentScheduler; + + @Operation(summary = "베스트 댓글 정산 전체 실행", description = "PUBLISHED 상태 배틀 전체를 대상으로 베스트 댓글 포인트 정산을 즉시 실행합니다.") + @PostMapping("/best-comment") + public ApiResponse runAll() { + bestCommentScheduler.awardBestComments(); + return ApiResponse.onSuccess("베스트 댓글 정산 완료"); + } + + @Operation(summary = "베스트 댓글 정산 단건 실행", description = "특정 battleId에 대해서만 베스트 댓글 포인트 정산을 즉시 실행합니다.") + @PostMapping("/best-comment/battles/{battleId}") + public ApiResponse runByBattle(@PathVariable Long battleId) { + bestCommentScheduler.processBattle(battleId); + return ApiResponse.onSuccess("battleId=" + battleId + " 베스트 댓글 정산 완료"); + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java index 76541533..c17eba4c 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java @@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "댓글 좋아요 (Comment Like)", description = "댓글 좋아요 등록, 취소 API") +@Tag(name = "댓글 좋아요 API", description = "댓글 좋아요 등록 및 취소") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -28,7 +28,7 @@ public ApiResponse addLike(@PathVariable Long commentId, return ApiResponse.onSuccess(commentLikeService.addLike(commentId, userId)); } - @Operation(summary = "댓글 좋아요 취소", description = "특정 댓글에 등록한 좋아요를 취소합니다.") + @Operation(summary = "댓글 좋아요 취소", description = "특정 댓글의 좋아요를 취소합니다.") @DeleteMapping("/comments/{commentId}/likes") public ApiResponse removeLike(@PathVariable Long commentId, @AuthenticationPrincipal Long userId) { diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java index d702d8aa..728a7fea 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "관점 댓글 (Comment)", description = "관점 댓글 생성, 조회, 수정, 삭제 API") +@Tag(name = "관점 댓글 API", description = "관점 댓글 생성, 조회, 수정, 삭제") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -51,7 +51,7 @@ public ApiResponse getComments( return ApiResponse.onSuccess(commentService.getComments(perspectiveId, userId, cursor, size)); } - @Operation(summary = "댓글 목록 조회 (옵션 라벨)", description = "특정 관점의 댓글 목록을 커서 기반 페이지네이션으로 조회합니다. stance는 투표한 옵션의 라벨(A/B)로 반환됩니다.") + @Operation(summary = "댓글 목록 조회 (옵션 라벨)", description = "특정 관점의 댓글 목록을 커서 기반 페이지네이션으로 조회하며, stance를 투표한 옵션 라벨(A/B)로 반환합니다.") @GetMapping("/perspectives/{perspectiveId}/comments/labeled") public ApiResponse getCommentsWithLabel( @PathVariable Long perspectiveId, @@ -73,7 +73,7 @@ public ApiResponse deleteComment( return ApiResponse.onSuccess(null); } - @Operation(summary = "댓글 수정", description = "본인이 작성한 댓글의 내용을 수정합니다.") + @Operation(summary = "댓글 수정", description = "본인이 작성한 댓글 내용을 수정합니다.") @PatchMapping("/perspectives/{perspectiveId}/comments/{commentId}") public ApiResponse updateComment( @PathVariable Long perspectiveId, diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java index 545f8146..03c9aa3f 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java @@ -24,7 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "관점 (Perspective)", description = "관점 생성, 조회, 수정, 삭제 API") +@Tag(name = "관점 API", description = "관점 생성, 조회, 수정, 삭제") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -32,7 +32,7 @@ public class PerspectiveController { private final PerspectiveService perspectiveService; - @Operation(summary = "관점 단건 조회", description = "특정 관점의 상세 정보를 조회합니다.") + @Operation(summary = "관점 상세 조회", description = "특정 관점의 상세 정보를 조회합니다.") @GetMapping("/perspectives/{perspectiveId}") public ApiResponse getPerspectiveDetail( @PathVariable Long perspectiveId, @@ -40,8 +40,7 @@ public ApiResponse getPerspectiveDetail( return ApiResponse.onSuccess(perspectiveService.getPerspectiveDetail(perspectiveId, userId)); } - // TODO: Prevote 의 여부를 Vote 도메인 개발 이후 교체 - @Operation(summary = "관점 생성", description = "특정 배틀에 대한 관점을 생성합니다. 사전 투표가 완료된 경우에만 가능합니다.") + @Operation(summary = "관점 생성", description = "특정 배틀에 대한 사용자 관점을 생성합니다.") @PostMapping("/battles/{battleId}/perspectives") public ApiResponse createPerspective( @PathVariable Long battleId, @@ -51,7 +50,7 @@ public ApiResponse createPerspective( return ApiResponse.onSuccess(perspectiveService.createPerspective(battleId, userId, request)); } - @Operation(summary = "관점 리스트 조회", description = "특정 배틀의 관점 목록을 커서 기반 페이지네이션으로 조회합니다. optionLabel(A/B)로 필터링, sort(latest/popular)로 정렬 가능합니다.") + @Operation(summary = "관점 목록 조회", description = "특정 배틀의 관점 목록을 커서 기반으로 조회합니다.") @GetMapping("/battles/{battleId}/perspectives") public ApiResponse getPerspectives( @PathVariable Long battleId, @@ -64,7 +63,7 @@ public ApiResponse getPerspectives( return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionLabel, sort)); } - @Operation(summary = "내 관점 조회", description = "특정 배틀에서 내가 작성한 관점을 조회합니다. 상태(PENDING/PUBLISHED/REJECTED 등)와 무관하게 반환하며, 작성한 관점이 없으면 404를 반환합니다.") + @Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점을 조회합니다.") @GetMapping("/battles/{battleId}/perspectives/me") public ApiResponse getMyPerspective( @PathVariable Long battleId, @@ -81,7 +80,7 @@ public ApiResponse deletePerspective( return ApiResponse.onSuccess(null); } - @Operation(summary = "관점 검수 재시도", description = "검수 실패(MODERATION_FAILED) 상태의 관점에 대해 GPT 검수를 다시 요청합니다.") + @Operation(summary = "관점 검수 재요청", description = "검수 실패 상태의 관점에 대해 검수를 다시 요청합니다.") @PostMapping("/perspectives/{perspectiveId}/moderation/retry") public ApiResponse retryModeration( @PathVariable Long perspectiveId, diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java index 75a6a1b4..7e090575 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "관점 좋아요 (Like)", description = "관점 좋아요 조회, 등록, 취소 API") +@Tag(name = "관점 좋아요 API", description = "관점 좋아요 조회, 등록, 취소") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -37,7 +37,7 @@ public ApiResponse addLike( return ApiResponse.onSuccess(likeService.addLike(perspectiveId, userId)); } - @Operation(summary = "좋아요 취소", description = "특정 관점에 등록한 좋아요를 취소합니다.") + @Operation(summary = "좋아요 취소", description = "특정 관점의 좋아요를 취소합니다.") @DeleteMapping("/perspectives/{perspectiveId}/likes") public ApiResponse removeLike( @PathVariable Long perspectiveId, diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java index 438cc00f..eb227348 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "신고 (Report)", description = "관점/댓글 신고 API") +@Tag(name = "신고 API", description = "관점/댓글 신고") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java index 0f0e0d30..b65fd2ac 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java @@ -22,4 +22,7 @@ public interface PerspectiveCommentRepository extends JpaRepository= :minLikeCount ORDER BY c.likeCount DESC") + List findTopCommentsByBattleId(@Param("battleId") Long battleId, @Param("minLikeCount") int minLikeCount, Pageable pageable); } diff --git a/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java b/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java new file mode 100644 index 00000000..ab7ff943 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java @@ -0,0 +1,68 @@ +package com.swyp.picke.domain.perspective.scheduler; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.service.CreditService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BestCommentScheduler { + + private static final int MIN_LIKE_COUNT = 10; + private static final int TOP_N = 3; + + private final BattleRepository battleRepository; + private final PerspectiveCommentRepository perspectiveCommentRepository; + private final CreditService creditService; + + @Scheduled(cron = "0 0 0 * * MON") + public void awardBestComments() { + log.info("[BestCommentScheduler] 베스트 댓글 포인트 정산 시작"); + + List battles = battleRepository.findByStatusAndDeletedAtIsNull(BattleStatus.PUBLISHED); + + for (Battle battle : battles) { + try { + processBattle(battle.getId()); + } catch (Exception e) { + log.error("[BestCommentScheduler] battleId={} 처리 중 오류 발생: {}", battle.getId(), e.getMessage()); + } + } + + log.info("[BestCommentScheduler] 베스트 댓글 포인트 정산 완료"); + } + + @Transactional + public void processBattle(Long battleId) { + List topComments = perspectiveCommentRepository.findTopCommentsByBattleId( + battleId, + MIN_LIKE_COUNT, + PageRequest.of(0, TOP_N) + ); + + if (topComments.isEmpty()) { + return; + } + + for (PerspectiveComment comment : topComments) { + Long userId = comment.getUser().getId(); + Long commentId = comment.getId(); + + creditService.addCredit(userId, CreditType.BEST_COMMENT, commentId); + log.info("[BestCommentScheduler] 포인트 지급 - battleId={}, commentId={}, userId={}", battleId, commentId, userId); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java index ac225705..c7808893 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java @@ -17,7 +17,7 @@ import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.enums.CharacterType; import com.swyp.picke.domain.user.service.UserService; -import com.swyp.picke.domain.vote.service.VoteService; +import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; @@ -41,7 +41,7 @@ public class PerspectiveCommentService { private final UserRepository userRepository; private final CommentLikeRepository commentLikeRepository; private final UserService userQueryService; - private final VoteService voteService; + private final BattleVoteService BattleVoteService; private final BattleService battleService; private final S3PresignedUrlService s3PresignedUrlService; @@ -62,7 +62,7 @@ public CreateCommentResponse createComment(Long perspectiveId, Long userId, Crea UserSummary userSummary = userQueryService.findSummaryById(userId); String characterImageUrl = resolveCharacterImageUrl(userSummary.characterType()); - Long postVoteOptionId = voteService.findPostVoteOptionId(perspective.getBattle().getId(), userId); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(perspective.getBattle().getId(), userId); String stance = null; if (postVoteOptionId != null) { stance = battleService.findOptionById(postVoteOptionId).getStance(); @@ -96,7 +96,7 @@ public CommentListResponse getComments(Long perspectiveId, Long userId, String c .map(c -> { UserSummary user = userQueryService.findSummaryById(c.getUser().getId()); String characterImageUrl = resolveCharacterImageUrl(user.characterType()); - Long postVoteOptionId = voteService.findPostVoteOptionId(battleId, c.getUser().getId()); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, c.getUser().getId()); String stance = null; if (postVoteOptionId != null) { BattleOption option = battleService.findOptionById(postVoteOptionId); @@ -140,7 +140,7 @@ public CommentListResponse getCommentsWithLabel(Long perspectiveId, Long userId, .map(c -> { UserSummary user = userQueryService.findSummaryById(c.getUser().getId()); String characterImageUrl = resolveCharacterImageUrl(user.characterType()); - Long postVoteOptionId = voteService.findPostVoteOptionId(battleId, c.getUser().getId()); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, c.getUser().getId()); String stance = null; if (postVoteOptionId != null) { BattleOption option = battleService.findOptionById(postVoteOptionId); @@ -209,4 +209,4 @@ private String resolveCharacterImageUrl(String characterType) { } return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java index e366aa63..ed8d596c 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java @@ -21,7 +21,7 @@ import com.swyp.picke.domain.user.dto.response.UserSummary; import com.swyp.picke.domain.user.enums.CharacterType; import com.swyp.picke.domain.user.service.UserService; -import com.swyp.picke.domain.vote.service.VoteService; +import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; @@ -44,7 +44,7 @@ public class PerspectiveService { private final PerspectiveCommentRepository perspectiveCommentRepository; private final PerspectiveLikeRepository perspectiveLikeRepository; private final BattleService battleService; - private final VoteService voteService; + private final BattleVoteService BattleVoteService; private final UserService userQueryService; private final UserRepository userRepository; private final GptModerationService gptModerationService; @@ -82,7 +82,7 @@ public CreatePerspectiveResponse createPerspective(Long battleId, Long userId, C throw new CustomException(ErrorCode.PERSPECTIVE_ALREADY_EXISTS); } - BattleOption option = voteService.findPreVoteOption(battleId, userId); + BattleOption option = BattleVoteService.findPreVoteOption(battleId, userId); Perspective perspective = Perspective.builder() .battle(battle) @@ -217,4 +217,4 @@ private String resolveCharacterImageUrl(String characterType) { } return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/poll/controller/PollController.java b/src/main/java/com/swyp/picke/domain/poll/controller/PollController.java new file mode 100644 index 00000000..97344834 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/controller/PollController.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.poll.controller; + +import com.swyp.picke.domain.poll.dto.response.PollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollListResponse; +import com.swyp.picke.domain.poll.service.PollService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "투표 콘텐츠 API", description = "투표 콘텐츠 조회") +@RestController +@RequestMapping("/api/v1/polls") +@RequiredArgsConstructor +public class PollController { + + private final PollService pollService; + + @Operation(summary = "투표 콘텐츠 목록 조회") + @GetMapping + public ApiResponse getPolls( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size + ) { + return ApiResponse.onSuccess(pollService.getPolls(page, size)); + } + + @Operation(summary = "투표 콘텐츠 상세 조회") + @GetMapping("/{pollId}") + public ApiResponse getPollDetail(@PathVariable Long pollId) { + return ApiResponse.onSuccess(pollService.getPollDetail(pollId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/poll/converter/PollConverter.java b/src/main/java/com/swyp/picke/domain/poll/converter/PollConverter.java new file mode 100644 index 00000000..03d74fec --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/converter/PollConverter.java @@ -0,0 +1,85 @@ +package com.swyp.picke.domain.poll.converter; + +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollCreateRequest; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollListResponse; +import com.swyp.picke.domain.poll.dto.response.PollOptionResponse; +import com.swyp.picke.domain.poll.dto.response.PollSimpleResponse; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import java.util.Comparator; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +@Component +public class PollConverter { + + private static final Comparator OPTION_SORTER = + Comparator.comparing((PollOption option) -> option.getDisplayOrder() == null ? Integer.MAX_VALUE : option.getDisplayOrder()) + .thenComparing(option -> option.getLabel() == null ? "" : option.getLabel().name()) + .thenComparing(PollOption::getId); + + public Poll toEntity(AdminPollCreateRequest request) { + return Poll.builder() + .titlePrefix(request.titlePrefix()) + .titleSuffix(request.titleSuffix()) + .status(request.status()) + .build(); + } + + public PollListResponse toListResponse(Page pollPage) { + List items = pollPage.getContent().stream() + .map(this::toSimpleResponse) + .toList(); + return new PollListResponse(items, pollPage.getNumber() + 1, pollPage.getTotalPages(), pollPage.getTotalElements()); + } + + public PollSimpleResponse toSimpleResponse(Poll poll) { + return new PollSimpleResponse( + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + poll.getStatus() + ); + } + + public AdminPollDetailResponse toAdminDetailResponse(Poll poll, List options) { + return new AdminPollDetailResponse( + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + poll.getTargetDate(), + poll.getStatus(), + toOptionResponses(options) + ); + } + + public PollDetailResponse toDetailResponse(Poll poll, List options) { + return new PollDetailResponse( + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + poll.getTargetDate(), + poll.getStatus(), + toOptionResponses(options) + ); + } + + private List toOptionResponses(List options) { + if (options == null) { + return List.of(); + } + return options.stream() + .sorted(OPTION_SORTER) + .map(option -> new PollOptionResponse( + option.getId(), + option.getLabel(), + option.getTitle(), + option.getDisplayOrder(), + option.getVoteCount() + )) + .toList(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollDetailResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollDetailResponse.java new file mode 100644 index 00000000..04f4db50 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollDetailResponse.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.dto.response; + +import com.swyp.picke.domain.poll.enums.PollStatus; +import java.time.LocalDate; +import java.util.List; + +public record PollDetailResponse( + Long pollId, + String titlePrefix, + String titleSuffix, + LocalDate targetDate, + PollStatus status, + List options +) {} diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollListResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollListResponse.java new file mode 100644 index 00000000..76f89133 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollListResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.poll.dto.response; + +import java.util.List; + +public record PollListResponse( + List items, + int page, + int totalPages, + long totalElements +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollOptionResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollOptionResponse.java new file mode 100644 index 00000000..b619a55f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollOptionResponse.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.dto.response; + +import com.swyp.picke.domain.poll.enums.PollOptionLabel; + +public record PollOptionResponse( + Long optionId, + PollOptionLabel label, + String title, + Integer displayOrder, + Long voteCount +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollSimpleResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollSimpleResponse.java new file mode 100644 index 00000000..de4a34e2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollSimpleResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.poll.dto.response; + +import com.swyp.picke.domain.poll.enums.PollStatus; + +import java.time.LocalDateTime; + +public record PollSimpleResponse( + Long pollId, + String titlePrefix, + String titleSuffix, + PollStatus status +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollTagResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollTagResponse.java new file mode 100644 index 00000000..4c334ae8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollTagResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.poll.dto.response; + +import com.swyp.picke.domain.tag.enums.TagType; + +public record PollTagResponse( + Long tagId, + String name, + TagType type +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/Poll.java b/src/main/java/com/swyp/picke/domain/poll/entity/Poll.java new file mode 100644 index 00000000..447a9081 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/Poll.java @@ -0,0 +1,70 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.poll.enums.PollStatus; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@Table(name = "poll_contents") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Poll extends BaseEntity { + + @Column(name = "title_prefix", nullable = false, length = 200) + private String titlePrefix; + + @Column(name = "title_suffix", length = 200) + private String titleSuffix; + + @Column(name = "target_date") + private LocalDate targetDate; + + @Column(name = "total_participants_count", nullable = false) + private Long totalParticipantsCount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PollStatus status; + + @OneToMany(mappedBy = "poll", cascade = CascadeType.ALL, orphanRemoval = true) + private final List options = new ArrayList<>(); + + @Builder + public Poll(String titlePrefix, String titleSuffix, LocalDate targetDate, PollStatus status) { + this.titlePrefix = titlePrefix; + this.titleSuffix = titleSuffix; + this.targetDate = targetDate; + this.status = status; + this.totalParticipantsCount = 0L; + } + + public void update(String titlePrefix, String titleSuffix, LocalDate targetDate, PollStatus status) { + if (titlePrefix != null) this.titlePrefix = titlePrefix; + if (titleSuffix != null) this.titleSuffix = titleSuffix; + if (targetDate != null) this.targetDate = targetDate; + if (status != null) this.status = status; + } + + public void increaseTotalParticipantsCount() { + this.totalParticipantsCount = (this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount) + 1L; + } + + public void decreaseTotalParticipantsCount() { + long current = this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount; + this.totalParticipantsCount = Math.max(0L, current - 1L); + } +} diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollOption.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollOption.java new file mode 100644 index 00000000..c0f86e9b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollOption.java @@ -0,0 +1,65 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.poll.enums.PollOptionLabel; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_options") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollOption extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_id", nullable = false) + private Poll poll; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private PollOptionLabel label; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Column(name = "vote_count", nullable = false) + private Long voteCount; + + @Builder + public PollOption(Poll poll, PollOptionLabel label, String title, Integer displayOrder, Long voteCount) { + this.poll = poll; + this.label = label; + this.title = title; + this.displayOrder = displayOrder; + this.voteCount = voteCount == null ? 0L : voteCount; + } + + + public void update(String title) { + if (title != null) this.title = title; + if (displayOrder != null) this.displayOrder = displayOrder; + } + + public void increaseVoteCount() { + this.voteCount = (this.voteCount == null ? 0L : this.voteCount) + 1; + } + + public void decreaseVoteCount() { + if (this.voteCount != null && this.voteCount > 0) { + this.voteCount--; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMap.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMap.java new file mode 100644 index 00000000..e148a80c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMap.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.tag.entity.ValueTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_option_value_tags") +@IdClass(PollOptionValueTagMapId.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollOptionValueTagMap { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_option_id", nullable = false) + private PollOption pollOption; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "value_tag_id", nullable = false) + private ValueTag valueTag; + + @Builder + public PollOptionValueTagMap(PollOption pollOption, ValueTag valueTag) { + this.pollOption = pollOption; + this.valueTag = valueTag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMapId.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMapId.java new file mode 100644 index 00000000..627f6ab4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMapId.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.poll.entity; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode +public class PollOptionValueTagMapId implements Serializable { + private Long pollOption; + private Long valueTag; +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMap.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMap.java new file mode 100644 index 00000000..1220879c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMap.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.tag.entity.CategoryTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_tags") +@IdClass(PollTagMapId.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollTagMap { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_id", nullable = false) + private Poll poll; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_tag_id", nullable = false) + private CategoryTag categoryTag; + + @Builder + public PollTagMap(Poll poll, CategoryTag categoryTag) { + this.poll = poll; + this.categoryTag = categoryTag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMapId.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMapId.java new file mode 100644 index 00000000..29263b1f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMapId.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.poll.entity; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode +public class PollTagMapId implements Serializable { + private Long poll; + private Long categoryTag; +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollUserVote.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollUserVote.java new file mode 100644 index 00000000..15502d92 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollUserVote.java @@ -0,0 +1,43 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_user_votes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollUserVote extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_id", nullable = false) + private Poll poll; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id", nullable = false) + private PollOption selectedOption; + + @Builder + public PollUserVote(User user, Poll poll, PollOption selectedOption) { + this.user = user; + this.poll = poll; + this.selectedOption = selectedOption; + } + + public void updateOption(PollOption option) { + this.selectedOption = option; + } +} diff --git a/src/main/java/com/swyp/picke/domain/poll/enums/PollOptionLabel.java b/src/main/java/com/swyp/picke/domain/poll/enums/PollOptionLabel.java new file mode 100644 index 00000000..5dc3dc74 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/enums/PollOptionLabel.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.poll.enums; + +public enum PollOptionLabel { + A, B, C, D +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/poll/enums/PollStatus.java b/src/main/java/com/swyp/picke/domain/poll/enums/PollStatus.java new file mode 100644 index 00000000..49757284 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/enums/PollStatus.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.poll.enums; + +public enum PollStatus { + PENDING, + PUBLISHED, + ARCHIVED +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionRepository.java new file mode 100644 index 00000000..47e2e727 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.enums.PollOptionLabel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PollOptionRepository extends JpaRepository { + List findByPollOrderByDisplayOrderAscLabelAscIdAsc(Poll poll); + Optional findByPollAndLabel(Poll poll, PollOptionLabel label); +} diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionValueTagMapRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionValueTagMapRepository.java new file mode 100644 index 00000000..1d9fcf9c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionValueTagMapRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.entity.PollOptionValueTagMap; +import com.swyp.picke.domain.poll.entity.PollOptionValueTagMapId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PollOptionValueTagMapRepository extends JpaRepository { + List findByPollOption(PollOption pollOption); + void deleteByPollOption(PollOption pollOption); +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollRepository.java new file mode 100644 index 00000000..535eec6c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollRepository.java @@ -0,0 +1,37 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.enums.PollStatus; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PollRepository extends JpaRepository { + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + @Query("SELECT p FROM Poll p WHERE p.status = :status AND p.targetDate = :targetDate ORDER BY p.createdAt ASC") + List findTodayPicks( + @Param("status") PollStatus status, + @Param("targetDate") LocalDate targetDate, + Pageable pageable + ); + + @Query(""" + SELECT p + FROM Poll p + WHERE p.status = :status + AND (p.targetDate IS NULL OR p.targetDate <> :targetDate) + ORDER BY CASE WHEN p.targetDate IS NULL THEN 0 ELSE 1 END, + p.targetDate ASC, + p.createdAt ASC + """) + List findAutoAssignableTodayPicks( + @Param("status") PollStatus status, + @Param("targetDate") LocalDate targetDate, + Pageable pageable + ); +} diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollTagMapRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollTagMapRepository.java new file mode 100644 index 00000000..77e8e9da --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollTagMapRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollTagMap; +import com.swyp.picke.domain.poll.entity.PollTagMapId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PollTagMapRepository extends JpaRepository { + List findByPoll(Poll poll); + void deleteByPoll(Poll poll); +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollUserVoteRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollUserVoteRepository.java new file mode 100644 index 00000000..dd2039d9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollUserVoteRepository.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.entity.PollUserVote; +import com.swyp.picke.domain.user.entity.User; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PollUserVoteRepository extends JpaRepository { + Optional findByPollAndUser(Poll poll, User user); + long countByPoll(Poll poll); + long countByPollAndSelectedOption(Poll poll, PollOption selectedOption); + List findAllByPoll(Poll poll); +} diff --git a/src/main/java/com/swyp/picke/domain/poll/service/PollService.java b/src/main/java/com/swyp/picke/domain/poll/service/PollService.java new file mode 100644 index 00000000..9b64a0f3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/service/PollService.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.poll.service; + +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollCreateRequest; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollUpdateRequest; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDeleteResponse; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollListResponse; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import java.util.List; + +public interface PollService { + Poll findById(Long pollId); + + PollListResponse getPolls(int page, int size); + + List getTodayPicks(int limit); + + List getOptions(Poll poll); + + long countVotes(Poll poll); + + PollDetailResponse getPollDetail(Long pollId); + + AdminPollDetailResponse getAdminPollDetail(Long pollId); + + AdminPollDetailResponse createPoll(AdminPollCreateRequest request); + + AdminPollDetailResponse updatePoll(Long pollId, AdminPollUpdateRequest request); + + AdminPollDeleteResponse deletePoll(Long pollId); +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/service/PollServiceImpl.java b/src/main/java/com/swyp/picke/domain/poll/service/PollServiceImpl.java new file mode 100644 index 00000000..4d3f9958 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/service/PollServiceImpl.java @@ -0,0 +1,186 @@ +package com.swyp.picke.domain.poll.service; + +import com.swyp.picke.domain.poll.converter.PollConverter; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollCreateRequest; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollOptionRequest; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollUpdateRequest; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDeleteResponse; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollListResponse; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.enums.PollOptionLabel; +import com.swyp.picke.domain.poll.enums.PollStatus; +import com.swyp.picke.domain.poll.repository.PollOptionRepository; +import com.swyp.picke.domain.poll.repository.PollRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PollServiceImpl implements PollService { + + private final PollRepository pollRepository; + private final PollOptionRepository pollOptionRepository; + private final PollConverter pollConverter; + + @Override + public Poll findById(Long pollId) { + return pollRepository.findById(pollId) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_NOT_FOUND)); + } + + @Override + public PollListResponse getPolls(int page, int size) { + int pageNumber = Math.max(0, page - 1); + Page pollPage = pollRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(pageNumber, size)); + return pollConverter.toListResponse(pollPage); + } + + @Override + @Transactional + public List getTodayPicks(int limit) { + int safeLimit = Math.max(1, limit); + LocalDate today = LocalDate.now(); + + ensureTodayPicks(today, safeLimit); + return pollRepository.findTodayPicks(PollStatus.PUBLISHED, today, PageRequest.of(0, safeLimit)); + } + + @Override + public List getOptions(Poll poll) { + return pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + } + + @Override + public long countVotes(Poll poll) { + return poll.getTotalParticipantsCount() == null ? 0L : poll.getTotalParticipantsCount(); + } + + @Override + public PollDetailResponse getPollDetail(Long pollId) { + Poll poll = findById(pollId); + List options = pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + return pollConverter.toDetailResponse(poll, options); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + public AdminPollDetailResponse getAdminPollDetail(Long pollId) { + Poll poll = findById(pollId); + List options = pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + return pollConverter.toAdminDetailResponse(poll, options); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminPollDetailResponse createPoll(AdminPollCreateRequest request) { + Poll poll = pollConverter.toEntity(request); + poll = pollRepository.save(poll); + + List savedOptions = new ArrayList<>(); + if (request.options() != null) { + for (AdminPollOptionRequest optionRequest : request.options()) { + PollOption option = PollOption.builder() + .poll(poll) + .label(optionRequest.label()) + .title(optionRequest.title()) + .build(); + option = pollOptionRepository.save(option); + savedOptions.add(option); + } + } + + return pollConverter.toAdminDetailResponse(poll, savedOptions); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminPollDetailResponse updatePoll(Long pollId, AdminPollUpdateRequest request) { + Poll poll = findById(pollId); + poll.update( + request.titlePrefix(), + request.titleSuffix(), + request.targetDate(), + request.status() + ); + + if (request.options() != null) { + List existingOptions = pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + Map existingOptionMap = new HashMap<>(); + for (PollOption option : existingOptions) { + existingOptionMap.put(option.getLabel(), option); + } + + Set requestedLabels = new HashSet<>(); + for (AdminPollOptionRequest optionRequest : request.options()) { + requestedLabels.add(optionRequest.label()); + PollOption option = existingOptionMap.get(optionRequest.label()); + + if (option == null) { + option = PollOption.builder() + .poll(poll) + .label(optionRequest.label()) + .title(optionRequest.title()) + .build(); + option = pollOptionRepository.save(option); + } else { + option.update(optionRequest.title()); + } + } + + for (PollOption existingOption : existingOptions) { + if (requestedLabels.contains(existingOption.getLabel())) continue; + pollOptionRepository.delete(existingOption); + } + } + + return getAdminPollDetail(pollId); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminPollDeleteResponse deletePoll(Long pollId) { + Poll poll = findById(pollId); + List options = pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + pollOptionRepository.deleteAll(options); + pollRepository.delete(poll); + return new AdminPollDeleteResponse(true, LocalDateTime.now()); + } + + private void ensureTodayPicks(LocalDate today, int requiredCount) { + List todays = pollRepository.findTodayPicks(PollStatus.PUBLISHED, today, PageRequest.of(0, requiredCount)); + int missingCount = requiredCount - todays.size(); + if (missingCount <= 0) return; + + List candidates = pollRepository.findAutoAssignableTodayPicks( + PollStatus.PUBLISHED, + today, + PageRequest.of(0, missingCount) + ); + for (Poll candidate : candidates) { + candidate.update(null, null, today, null); + } + } +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java b/src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java new file mode 100644 index 00000000..f290b147 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.quiz.controller; + +import com.swyp.picke.domain.quiz.dto.response.QuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizListResponse; +import com.swyp.picke.domain.quiz.service.QuizService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "퀴즈 API", description = "퀴즈 콘텐츠 조회") +@RestController +@RequestMapping("/api/v1/quizzes") +@RequiredArgsConstructor +public class QuizController { + + private final QuizService quizService; + + @Operation(summary = "퀴즈 목록 조회") + @GetMapping + public ApiResponse getQuizzes( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size + ) { + return ApiResponse.onSuccess(quizService.getQuizzes(page, size)); + } + + @Operation(summary = "퀴즈 상세 조회") + @GetMapping("/{quizId}") + public ApiResponse getQuizDetail(@PathVariable Long quizId) { + return ApiResponse.onSuccess(quizService.getQuizDetail(quizId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/quiz/converter/QuizConverter.java b/src/main/java/com/swyp/picke/domain/quiz/converter/QuizConverter.java new file mode 100644 index 00000000..bdcb8bbd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/converter/QuizConverter.java @@ -0,0 +1,85 @@ +package com.swyp.picke.domain.quiz.converter; + +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizCreateRequest; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizListResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizOptionResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizSimpleResponse; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import java.util.Comparator; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +@Component +public class QuizConverter { + + private static final Comparator OPTION_SORTER = + Comparator.comparing((QuizOption option) -> option.getDisplayOrder() == null ? Integer.MAX_VALUE : option.getDisplayOrder()) + .thenComparing(option -> option.getLabel() == null ? "" : option.getLabel().name()) + .thenComparing(QuizOption::getId); + + public Quiz toEntity(AdminQuizCreateRequest request) { + return Quiz.builder() + .title(request.title()) + .status(request.status()) + .build(); + } + + public QuizListResponse toListResponse(Page quizPage) { + List items = quizPage.getContent().stream() + .map(this::toSimpleResponse) + .toList(); + return new QuizListResponse(items, quizPage.getNumber() + 1, quizPage.getTotalPages(), quizPage.getTotalElements()); + } + + public QuizSimpleResponse toSimpleResponse(Quiz quiz) { + return new QuizSimpleResponse( + quiz.getId(), + quiz.getTitle(), + quiz.getStatus(), + quiz.getCreatedAt() + ); + } + + public AdminQuizDetailResponse toAdminDetailResponse(Quiz quiz, List options) { + return new AdminQuizDetailResponse( + quiz.getId(), + quiz.getTitle(), + quiz.getTargetDate(), + quiz.getStatus(), + toOptionResponses(options) + ); + } + + public QuizDetailResponse toDetailResponse(Quiz quiz, List options) { + return new QuizDetailResponse( + quiz.getId(), + quiz.getTitle(), + quiz.getTargetDate(), + quiz.getStatus(), + toOptionResponses(options), + quiz.getCreatedAt(), + quiz.getUpdatedAt() + ); + } + + private List toOptionResponses(List options) { + if (options == null) { + return List.of(); + } + return options.stream() + .sorted(OPTION_SORTER) + .map(option -> new QuizOptionResponse( + option.getId(), + option.getLabel(), + option.getText(), + option.getDetailText(), + option.getIsCorrect(), + option.getDisplayOrder() + )) + .toList(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizDetailResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizDetailResponse.java new file mode 100644 index 00000000..c5409dec --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizDetailResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record QuizDetailResponse( + Long quizId, + String title, + LocalDate targetDate, + QuizStatus status, + List options, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizListResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizListResponse.java new file mode 100644 index 00000000..ded527d5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizListResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import java.util.List; + +public record QuizListResponse( + List items, + int page, + int totalPages, + long totalElements +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizOptionResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizOptionResponse.java new file mode 100644 index 00000000..5f83007b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizOptionResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; + +public record QuizOptionResponse( + Long optionId, + QuizOptionLabel label, + String text, + String detailText, + Boolean isCorrect, + Integer displayOrder +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizSimpleResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizSimpleResponse.java new file mode 100644 index 00000000..85556fc9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizSimpleResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import com.swyp.picke.domain.quiz.enums.QuizStatus; + +import java.time.LocalDateTime; + +public record QuizSimpleResponse( + Long quizId, + String title, + QuizStatus status, + LocalDateTime createdAt +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizTagResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizTagResponse.java new file mode 100644 index 00000000..b283ca95 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizTagResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import com.swyp.picke.domain.tag.enums.TagType; + +public record QuizTagResponse( + Long tagId, + String name, + TagType type +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/Quiz.java b/src/main/java/com/swyp/picke/domain/quiz/entity/Quiz.java new file mode 100644 index 00000000..aade8606 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/Quiz.java @@ -0,0 +1,65 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@Table(name = "quizzes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Quiz extends BaseEntity { + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "target_date") + private LocalDate targetDate; + + @Column(name = "total_participants_count", nullable = false) + private Long totalParticipantsCount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private QuizStatus status; + + @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) + private final List options = new ArrayList<>(); + + @Builder + public Quiz(String title, LocalDate targetDate, QuizStatus status) { + this.title = title; + this.targetDate = targetDate; + this.status = status; + this.totalParticipantsCount = 0L; + } + + public void update(String title, LocalDate targetDate, QuizStatus status) { + if (title != null) this.title = title; + if (targetDate != null) this.targetDate = targetDate; + if (status != null) this.status = status; + } + + public void increaseTotalParticipantsCount() { + this.totalParticipantsCount = (this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount) + 1L; + } + + public void decreaseTotalParticipantsCount() { + long current = this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount; + this.totalParticipantsCount = Math.max(0L, current - 1L); + } +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOption.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOption.java new file mode 100644 index 00000000..85fd73e0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOption.java @@ -0,0 +1,71 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "quiz_options") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizOption extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private QuizOptionLabel label; + + @Column(nullable = false, length = 300) + private String text; + + @Column(name = "detail_text", length = 1000) + private String detailText; + + @Column(name = "is_correct", nullable = false) + private Boolean isCorrect = false; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Builder + public QuizOption( + Quiz quiz, + QuizOptionLabel label, + String text, + String detailText, + Boolean isCorrect, + Integer displayOrder + ) { + this.quiz = quiz; + this.label = label; + this.text = text; + this.detailText = detailText; + this.isCorrect = (isCorrect != null) ? isCorrect : false; + this.displayOrder = displayOrder; + } + + void assignQuiz(Quiz quiz) { + this.quiz = quiz; + } + + public void update(String text, String detailText, Boolean isCorrect) { + if (text != null) this.text = text; + if (detailText != null) this.detailText = detailText; + if (isCorrect != null) this.isCorrect = isCorrect; + if (displayOrder != null) this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMap.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMap.java new file mode 100644 index 00000000..43e94781 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMap.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.tag.entity.ValueTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "quiz_option_value_tags") +@IdClass(QuizOptionValueTagMapId.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizOptionValueTagMap { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_option_id", nullable = false) + private QuizOption quizOption; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "value_tag_id", nullable = false) + private ValueTag valueTag; + + @Builder + public QuizOptionValueTagMap(QuizOption quizOption, ValueTag valueTag) { + this.quizOption = quizOption; + this.valueTag = valueTag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMapId.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMapId.java new file mode 100644 index 00000000..ce65910d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMapId.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.quiz.entity; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode +public class QuizOptionValueTagMapId implements Serializable { + private Long quizOption; + private Long valueTag; +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMap.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMap.java new file mode 100644 index 00000000..bb19afa4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMap.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.tag.entity.CategoryTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "quiz_tags") +@IdClass(QuizTagMapId.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizTagMap { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_tag_id", nullable = false) + private CategoryTag categoryTag; + + @Builder + public QuizTagMap(Quiz quiz, CategoryTag categoryTag) { + this.quiz = quiz; + this.categoryTag = categoryTag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMapId.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMapId.java new file mode 100644 index 00000000..e61597e7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMapId.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.quiz.entity; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode +public class QuizTagMapId implements Serializable { + private Long quiz; + private Long categoryTag; +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizUserVote.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizUserVote.java new file mode 100644 index 00000000..f159720f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizUserVote.java @@ -0,0 +1,43 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "quiz_user_votes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizUserVote extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id", nullable = false) + private QuizOption selectedOption; + + @Builder + public QuizUserVote(User user, Quiz quiz, QuizOption selectedOption) { + this.user = user; + this.quiz = quiz; + this.selectedOption = selectedOption; + } + + public void updateOption(QuizOption option) { + this.selectedOption = option; + } +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/enums/QuizOptionLabel.java b/src/main/java/com/swyp/picke/domain/quiz/enums/QuizOptionLabel.java new file mode 100644 index 00000000..2eeb5355 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/enums/QuizOptionLabel.java @@ -0,0 +1,6 @@ +package com.swyp.picke.domain.quiz.enums; + +public enum QuizOptionLabel { + A, B, C, D +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/enums/QuizStatus.java b/src/main/java/com/swyp/picke/domain/quiz/enums/QuizStatus.java new file mode 100644 index 00000000..a6063700 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/enums/QuizStatus.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.quiz.enums; + +public enum QuizStatus { + PENDING, + PUBLISHED, + ARCHIVED +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionRepository.java new file mode 100644 index 00000000..f4c3c9b7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface QuizOptionRepository extends JpaRepository { + List findByQuizOrderByDisplayOrderAscLabelAscIdAsc(Quiz quiz); + Optional findByQuizAndLabel(Quiz quiz, QuizOptionLabel label); +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionValueTagMapRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionValueTagMapRepository.java new file mode 100644 index 00000000..bacf283b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionValueTagMapRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.entity.QuizOptionValueTagMap; +import com.swyp.picke.domain.quiz.entity.QuizOptionValueTagMapId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface QuizOptionValueTagMapRepository extends JpaRepository { + List findByQuizOption(QuizOption quizOption); + void deleteByQuizOption(QuizOption quizOption); +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizRepository.java new file mode 100644 index 00000000..f84f5583 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizRepository.java @@ -0,0 +1,37 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface QuizRepository extends JpaRepository { + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + @Query("SELECT q FROM Quiz q WHERE q.status = :status AND q.targetDate = :targetDate ORDER BY q.createdAt ASC") + List findTodayPicks( + @Param("status") QuizStatus status, + @Param("targetDate") LocalDate targetDate, + Pageable pageable + ); + + @Query(""" + SELECT q + FROM Quiz q + WHERE q.status = :status + AND (q.targetDate IS NULL OR q.targetDate <> :targetDate) + ORDER BY CASE WHEN q.targetDate IS NULL THEN 0 ELSE 1 END, + q.targetDate ASC, + q.createdAt ASC + """) + List findAutoAssignableTodayPicks( + @Param("status") QuizStatus status, + @Param("targetDate") LocalDate targetDate, + Pageable pageable + ); +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizTagMapRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizTagMapRepository.java new file mode 100644 index 00000000..aeb7ebe9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizTagMapRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizTagMap; +import com.swyp.picke.domain.quiz.entity.QuizTagMapId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface QuizTagMapRepository extends JpaRepository { + List findByQuiz(Quiz quiz); + void deleteByQuiz(Quiz quiz); +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizUserVoteRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizUserVoteRepository.java new file mode 100644 index 00000000..07f26949 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizUserVoteRepository.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.entity.QuizUserVote; +import com.swyp.picke.domain.user.entity.User; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuizUserVoteRepository extends JpaRepository { + Optional findByQuizAndUser(Quiz quiz, User user); + long countByQuiz(Quiz quiz); + long countByQuizAndSelectedOption(Quiz quiz, QuizOption selectedOption); + List findAllByQuiz(Quiz quiz); +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/service/QuizService.java b/src/main/java/com/swyp/picke/domain/quiz/service/QuizService.java new file mode 100644 index 00000000..c6d1678f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/service/QuizService.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.quiz.service; + +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizCreateRequest; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizUpdateRequest; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDeleteResponse; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizListResponse; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import java.util.List; + +public interface QuizService { + Quiz findById(Long quizId); + + QuizListResponse getQuizzes(int page, int size); + + List getTodayPicks(int limit); + + List getOptions(Quiz quiz); + + long countVotes(Quiz quiz); + + QuizDetailResponse getQuizDetail(Long quizId); + + AdminQuizDetailResponse getAdminQuizDetail(Long quizId); + + AdminQuizDetailResponse createQuiz(AdminQuizCreateRequest request); + + AdminQuizDetailResponse updateQuiz(Long quizId, AdminQuizUpdateRequest request); + + AdminQuizDeleteResponse deleteQuiz(Long quizId); +} + + diff --git a/src/main/java/com/swyp/picke/domain/quiz/service/QuizServiceImpl.java b/src/main/java/com/swyp/picke/domain/quiz/service/QuizServiceImpl.java new file mode 100644 index 00000000..3ee12e08 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/service/QuizServiceImpl.java @@ -0,0 +1,189 @@ +package com.swyp.picke.domain.quiz.service; + +import com.swyp.picke.domain.quiz.converter.QuizConverter; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizCreateRequest; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizOptionRequest; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizUpdateRequest; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDeleteResponse; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizListResponse; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import com.swyp.picke.domain.quiz.repository.QuizOptionRepository; +import com.swyp.picke.domain.quiz.repository.QuizRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QuizServiceImpl implements QuizService { + + private final QuizRepository quizRepository; + private final QuizOptionRepository quizOptionRepository; + private final QuizConverter quizConverter; + + @Override + public Quiz findById(Long quizId) { + return quizRepository.findById(quizId) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_NOT_FOUND)); + } + + @Override + public QuizListResponse getQuizzes(int page, int size) { + int pageNumber = Math.max(0, page - 1); + Page quizPage = quizRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(pageNumber, size)); + return quizConverter.toListResponse(quizPage); + } + + @Override + @Transactional + public List getTodayPicks(int limit) { + int safeLimit = Math.max(1, limit); + LocalDate today = LocalDate.now(); + + ensureTodayPicks(today, safeLimit); + return quizRepository.findTodayPicks(QuizStatus.PUBLISHED, today, PageRequest.of(0, safeLimit)); + } + + @Override + public List getOptions(Quiz quiz) { + return quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + } + + @Override + public long countVotes(Quiz quiz) { + return quiz.getTotalParticipantsCount() == null ? 0L : quiz.getTotalParticipantsCount(); + } + + @Override + public QuizDetailResponse getQuizDetail(Long quizId) { + Quiz quiz = findById(quizId); + List options = quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + return quizConverter.toDetailResponse(quiz, options); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + public AdminQuizDetailResponse getAdminQuizDetail(Long quizId) { + Quiz quiz = findById(quizId); + List options = quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + return quizConverter.toAdminDetailResponse(quiz, options); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminQuizDetailResponse createQuiz(AdminQuizCreateRequest request) { + Quiz quiz = quizConverter.toEntity(request); + quiz = quizRepository.save(quiz); + + List savedOptions = new ArrayList<>(); + if (request.options() != null) { + for (AdminQuizOptionRequest optionRequest : request.options()) { + QuizOption option = QuizOption.builder() + .quiz(quiz) + .label(optionRequest.label()) + .text(optionRequest.text()) + .detailText(optionRequest.detailText()) + .isCorrect(optionRequest.isCorrect()) + .build(); + option = quizOptionRepository.save(option); + savedOptions.add(option); + } + } + + return quizConverter.toAdminDetailResponse(quiz, savedOptions); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminQuizDetailResponse updateQuiz(Long quizId, AdminQuizUpdateRequest request) { + Quiz quiz = findById(quizId); + quiz.update(request.title(), request.targetDate(), request.status()); + + if (request.options() != null) { + List existingOptions = quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + Map existingOptionMap = new HashMap<>(); + for (QuizOption option : existingOptions) { + existingOptionMap.put(option.getLabel(), option); + } + + Set requestedLabels = new HashSet<>(); + for (AdminQuizOptionRequest optionRequest : request.options()) { + requestedLabels.add(optionRequest.label()); + QuizOption option = existingOptionMap.get(optionRequest.label()); + + if (option == null) { + option = QuizOption.builder() + .quiz(quiz) + .label(optionRequest.label()) + .text(optionRequest.text()) + .detailText(optionRequest.detailText()) + .isCorrect(optionRequest.isCorrect()) + .build(); + option = quizOptionRepository.save(option); + } else { + option.update( + optionRequest.text(), + optionRequest.detailText(), + optionRequest.isCorrect() + ); + } + } + + for (QuizOption existingOption : existingOptions) { + if (requestedLabels.contains(existingOption.getLabel())) continue; + quizOptionRepository.delete(existingOption); + } + } + + return getAdminQuizDetail(quizId); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminQuizDeleteResponse deleteQuiz(Long quizId) { + Quiz quiz = findById(quizId); + List options = quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + quizOptionRepository.deleteAll(options); + quizRepository.delete(quiz); + return new AdminQuizDeleteResponse(true, LocalDateTime.now()); + } + + private void ensureTodayPicks(LocalDate today, int requiredCount) { + List todays = quizRepository.findTodayPicks(QuizStatus.PUBLISHED, today, PageRequest.of(0, requiredCount)); + int missingCount = requiredCount - todays.size(); + if (missingCount <= 0) return; + + List candidates = quizRepository.findAutoAssignableTodayPicks( + QuizStatus.PUBLISHED, + today, + PageRequest.of(0, missingCount) + ); + for (Quiz candidate : candidates) { + candidate.update(null, today, null); + } + } +} + diff --git a/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java b/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java index c05a07c7..45dad51d 100644 --- a/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java +++ b/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "추천 (Recommendation)", description = "배틀 추천 API") +@Tag(name = "추천 API", description = "배틀 추천 조회") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -20,7 +20,7 @@ public class RecommendationController { private final RecommendationService recommendationService; - @Operation(summary = "흥미 기반 배틀 추천 조회", description = "특정 배틀 기반으로 흥미로운 배틀 목록을 추천합니다.") + @Operation(summary = "흥미 기반 배틀 추천 조회", description = "특정 배틀을 기준으로 흥미로운 배틀 목록을 추천합니다.") @GetMapping("/battles/{battleId}/recommendations/interesting") public ApiResponse getInterestingBattles( @PathVariable Long battleId, diff --git a/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java b/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java index 1a37f32f..00d3bb86 100644 --- a/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java +++ b/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java @@ -13,7 +13,7 @@ import com.swyp.picke.domain.user.service.UserService; import com.swyp.picke.global.infra.s3.enums.FileCategory; import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; -import com.swyp.picke.domain.vote.repository.VoteRepository; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -35,7 +35,7 @@ public class RecommendationService { private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; private final BattleOptionTagRepository battleOptionTagRepository; - private final VoteRepository voteRepository; + private final BattleVoteRepository BattleVoteRepository; private final UserService userService; private final ResourceUrlProvider urlProvider; @@ -47,7 +47,7 @@ public RecommendationListResponse getInterestingBattles(Long battleId, Long user PhilosopherType oppositeType = myType.getWorstMatch(); // 현재 유저가 이미 참여한 배틀 ID 목록 (제외 대상) - List excludeBattleIds = voteRepository.findParticipatedBattleIdsByUserId(userId); + List excludeBattleIds = BattleVoteRepository.findParticipatedBattleIdsByUserId(userId); if (excludeBattleIds.isEmpty()) excludeBattleIds = List.of(-1L); List sameTypeUserIds = findUserIdsByPhilosopherType(myType); @@ -56,12 +56,12 @@ public RecommendationListResponse getInterestingBattles(Long battleId, Long user // 같은 유형 유저들이 참여한 배틀 후보 ID List sameCandidateIds = sameTypeUserIds.isEmpty() ? List.of() - : voteRepository.findParticipatedBattleIdsByUserIds(sameTypeUserIds); + : BattleVoteRepository.findParticipatedBattleIdsByUserIds(sameTypeUserIds); // 반대 유형 유저들이 참여한 배틀 후보 ID List oppositeCandidateIds = oppositeTypeUserIds.isEmpty() ? List.of() - : voteRepository.findParticipatedBattleIdsByUserIds(oppositeTypeUserIds); + : BattleVoteRepository.findParticipatedBattleIdsByUserIds(oppositeTypeUserIds); // 인기 점수 기준 배틀 조회 (Score = V*1.0 + C*1.5 + Vw*0.2) // 철학자 유형 로직 미구현 시 인기 배틀로 폴백 @@ -130,4 +130,4 @@ private RecommendationListResponse.Item toItem(Battle battle) { private List findUserIdsByPhilosopherType(PhilosopherType type) { return List.of(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java b/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java index 723be0d9..71a4f239 100644 --- a/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java +++ b/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java @@ -8,14 +8,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springdoc.core.annotations.ParameterObject; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @Slf4j -@Tag(name = "보상 (Reward)", description = "AdMob 광고 보상 관련 API") +@Tag(name = "보상 API", description = "AdMob 광고 보상 관련 API") @RestController @RequestMapping("/api/v1/admob") @RequiredArgsConstructor diff --git a/src/main/java/com/swyp/picke/domain/scenario/controller/ScenarioController.java b/src/main/java/com/swyp/picke/domain/scenario/controller/ScenarioController.java index 6ac63f37..b03bca91 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/controller/ScenarioController.java +++ b/src/main/java/com/swyp/picke/domain/scenario/controller/ScenarioController.java @@ -1,24 +1,19 @@ package com.swyp.picke.domain.scenario.controller; import com.swyp.picke.domain.battle.service.BattleService; -import com.swyp.picke.domain.scenario.dto.request.ScenarioCreateRequest; -import com.swyp.picke.domain.scenario.dto.request.ScenarioStatusUpdateRequest; -import com.swyp.picke.domain.scenario.dto.response.AdminDeleteResponse; -import com.swyp.picke.domain.scenario.dto.response.AdminScenarioDetailResponse; -import com.swyp.picke.domain.scenario.dto.response.AdminScenarioResponse; import com.swyp.picke.domain.scenario.dto.response.UserScenarioResponse; import com.swyp.picke.domain.scenario.service.ScenarioService; import com.swyp.picke.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; -import java.util.Map; - -@Tag(name = "시나리오 (Scenario)", description = "시나리오 API") +@Tag(name = "시나리오 API", description = "사용자 시나리오 조회") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -27,19 +22,15 @@ public class ScenarioController { private final ScenarioService scenarioService; private final BattleService battleService; - @Operation(summary = "시나리오 통합 조회") + @Operation(summary = "배틀 시나리오 조회") @GetMapping("/battles/{battleId}/scenario") public ApiResponse getBattleScenario( @PathVariable Long battleId, @RequestAttribute(value = "userId", required = false) Long userId ) { - // 1. 배틀 데이터 조회 (제목, 철학자 리스트) var battleInfo = battleService.getBattleScenario(battleId); - - // 2. 시나리오 데이터 조회 (노드, 대사, 오디오 등) var scenarioInfo = scenarioService.getScenarioForUser(battleId, userId); - // 3. UserScenarioResponse 최상단에 바로 값 세팅 UserScenarioResponse response = scenarioInfo.toBuilder() .title(battleInfo.title()) .philosophers(battleInfo.philosophers()) @@ -47,61 +38,4 @@ public ApiResponse getBattleScenario( return ApiResponse.onSuccess(response); } - - @Operation(summary = "관리자용 배틀 시나리오 조회 (수정용)") - @PreAuthorize("hasRole('ADMIN')") - @GetMapping("/admin/battles/{battleId}/scenario") - public ApiResponse getAdminBattleScenario( - @PathVariable Long battleId) { - return ApiResponse.onSuccess(scenarioService.getScenarioForAdmin(battleId)); - } - - @Operation(summary = "시나리오 생성") - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/scenarios") - @ResponseStatus(HttpStatus.CREATED) - public ApiResponse> createScenario( - @RequestBody ScenarioCreateRequest request) { - - Long scenarioId = scenarioService.createScenario(request); - - // Map.of 대신 null에도 안전한 HashMap 사용 - Map response = new java.util.HashMap<>(); - response.put("scenarioId", scenarioId); - - // 고정값 대신 프론트에서 보낸 상태값(PENDING 등)을 그대로 반환! - response.put("status", request.status()); - - return ApiResponse.onSuccess(response); - } - - @Operation(summary = "시나리오 내용 수정") - @PreAuthorize("hasRole('ADMIN')") - @PutMapping("/admin/scenarios/{scenarioId}") - public ApiResponse updateScenarioContent( - @PathVariable Long scenarioId, - @RequestBody ScenarioCreateRequest request) { - - scenarioService.updateScenarioContent(scenarioId, request); - return ApiResponse.onSuccess(null); - } - - @Operation(summary = "시나리오 상태 수정 (PUBLISHED 변경 시 자동 오디오 처리)") - @PreAuthorize("hasRole('ADMIN')") - @PatchMapping("/admin/scenarios/{scenarioId}") - public ApiResponse updateScenarioStatus( - @PathVariable Long scenarioId, - @RequestBody ScenarioStatusUpdateRequest request) { - - return ApiResponse.onSuccess(scenarioService.updateScenarioStatus(scenarioId, request.status())); - } - - @Operation(summary = "시나리오 삭제 (Soft Delete)") - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/admin/scenarios/{scenarioId}") - public ApiResponse deleteScenario( - @PathVariable Long scenarioId) { - - return ApiResponse.onSuccess(scenarioService.deleteScenario(scenarioId)); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java index 53213bda..a5b73b06 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java +++ b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java @@ -1,5 +1,9 @@ package com.swyp.picke.domain.scenario.converter; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioDetailResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioNodeResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioOptionResponse; +import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioScriptResponse; import com.swyp.picke.domain.scenario.dto.response.*; import com.swyp.picke.domain.scenario.entity.InteractiveOption; import com.swyp.picke.domain.scenario.entity.Scenario; @@ -8,7 +12,6 @@ import com.swyp.picke.domain.scenario.enums.AudioPathType; import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.HashMap; @@ -21,12 +24,8 @@ public class ScenarioConverter { private final ResourceUrlProvider resourceUrlProvider; - private static final String BASE_SHARE_URL = "https://pique.app/battles/"; - /** - * [유저용] Scenario 엔티티를 프론트엔드 전달용 DTO로 변환합니다. - */ - public UserScenarioResponse toUserResponse(Scenario scenario, AudioPathType recommendedPathKey) { + public UserScenarioResponse toUserResponse(Scenario scenario, AudioPathType recommendedPathKey) { Long startNodeId = scenario.getNodes().stream() .filter(node -> Boolean.TRUE.equals(node.getIsStartNode())) .map(ScenarioNode::getId) @@ -56,22 +55,19 @@ public UserScenarioResponse toUserResponse(Scenario scenario, AudioPathType reco .build(); } - /** - * [관리자용] 시나리오 상세 변환 메서드 - */ - public AdminScenarioDetailResponse toAdminDetailResponse(Scenario scenario) { + public AdminScenarioDetailResponse toAdminDetailResponse(Scenario scenario) { return AdminScenarioDetailResponse.builder() .scenarioId(scenario.getId()) .battleId(scenario.getBattle().getId()) .title(scenario.getBattle().getTitle()) .isInteractive(scenario.getIsInteractive()) + .voiceSettings(new HashMap<>(scenario.getVoiceSettings())) .nodes(scenario.getNodes().stream() .map(this::toAdminNodeResponse) .collect(Collectors.toList())) .build(); } - // 유저용 변환 로직 private NodeResponse toUserNodeResponse(ScenarioNode node) { return NodeResponse.builder() .nodeId(node.getId()) @@ -82,7 +78,7 @@ private NodeResponse toUserNodeResponse(ScenarioNode node) { .map(this::toUserScriptResponse) .collect(Collectors.toList())) .interactiveOptions(node.getOptions().stream() - .map(this::toOptionResponse) + .map(this::toUserOptionResponse) .collect(Collectors.toList())) .build(); } @@ -102,9 +98,8 @@ private ScriptResponse toUserScriptResponse(Script script) { .build(); } - // 관리자용 변환 로직 - private NodeResponse toAdminNodeResponse(ScenarioNode node) { - return NodeResponse.builder() + private AdminScenarioNodeResponse toAdminNodeResponse(ScenarioNode node) { + return AdminScenarioNodeResponse.builder() .nodeId(node.getId()) .nodeName(node.getNodeName()) .audioDuration(node.getAudioDuration()) @@ -113,13 +108,13 @@ private NodeResponse toAdminNodeResponse(ScenarioNode node) { .map(this::toAdminScriptResponse) .collect(Collectors.toList())) .interactiveOptions(node.getOptions().stream() - .map(this::toOptionResponse) + .map(this::toAdminOptionResponse) .collect(Collectors.toList())) .build(); } - private ScriptResponse toAdminScriptResponse(Script script) { - return ScriptResponse.builder() + private AdminScenarioScriptResponse toAdminScriptResponse(Script script) { + return AdminScenarioScriptResponse.builder() .scriptId(script.getId()) .startTimeMs(script.getStartTimeMs()) .speakerType(script.getSpeakerType()) @@ -128,10 +123,17 @@ private ScriptResponse toAdminScriptResponse(Script script) { .build(); } - private OptionResponse toOptionResponse(InteractiveOption option) { + private OptionResponse toUserOptionResponse(InteractiveOption option) { return OptionResponse.builder() .label(option.getLabel()) .nextNodeId(option.getNextNodeId()) .build(); } + + private AdminScenarioOptionResponse toAdminOptionResponse(InteractiveOption option) { + return AdminScenarioOptionResponse.builder() + .label(option.getLabel()) + .nextNodeId(option.getNextNodeId()) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/request/NodeRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/NodeRequest.java index 0029cb55..53ddb56c 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/request/NodeRequest.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/NodeRequest.java @@ -5,7 +5,7 @@ public record NodeRequest( String nodeName, Boolean isStartNode, - String autoNextNode, // 자동 넘김 노드 이름 추가 + String autoNextNode, List scripts, List interactiveOptions ) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioCreateRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioCreateRequest.java index ff1d74db..cd1e38e8 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioCreateRequest.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioCreateRequest.java @@ -1,11 +1,14 @@ package com.swyp.picke.domain.scenario.dto.request; import com.swyp.picke.domain.scenario.enums.ScenarioStatus; +import com.swyp.picke.domain.scenario.enums.SpeakerType; import java.util.List; +import java.util.Map; public record ScenarioCreateRequest( Long battleId, Boolean isInteractive, ScenarioStatus status, - List nodes + List nodes, + Map voiceSettings ) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/NodeResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/NodeResponse.java index 7a26bb35..89e63d7c 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/response/NodeResponse.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/NodeResponse.java @@ -7,7 +7,7 @@ public record NodeResponse( Long nodeId, String nodeName, - Integer audioDuration, // 프론트엔드 재생 시간 표시에 활용 + Integer audioDuration, Long autoNextNodeId, List scripts, List interactiveOptions diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java index e5e8c4b5..189d16c3 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java @@ -6,4 +6,4 @@ public record OptionResponse( String label, Long nextNodeId -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/Scenario.java b/src/main/java/com/swyp/picke/domain/scenario/entity/Scenario.java index a30d9abe..4f68b794 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/entity/Scenario.java +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/Scenario.java @@ -4,6 +4,7 @@ import com.swyp.picke.domain.scenario.enums.AudioPathType; import com.swyp.picke.domain.scenario.enums.CreatorType; import com.swyp.picke.domain.scenario.enums.ScenarioStatus; +import com.swyp.picke.domain.scenario.enums.SpeakerType; import com.swyp.picke.global.common.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; @@ -41,6 +42,14 @@ public class Scenario extends BaseEntity { @Column(name = "audio_url") private Map audios = new EnumMap<>(AudioPathType.class); + @ElementCollection + @CollectionTable(name = "scenario_voice_settings", joinColumns = @JoinColumn(name = "scenario_id")) + @MapKeyEnumerated(EnumType.STRING) + @MapKeyColumn(name = "speaker_type") + @Column(name = "voice_code") + private Map voiceSettings = new EnumMap<>(SpeakerType.class); + + @OrderColumn(name = "node_order") @OneToMany(mappedBy = "scenario", cascade = CascadeType.ALL, orphanRemoval = true) private List nodes = new ArrayList<>(); @@ -68,4 +77,15 @@ public void addNode(ScenarioNode node) { public void clearAudios() { this.audios.clear(); } -} \ No newline at end of file + + public void replaceVoiceSettings(Map voiceSettings) { + this.voiceSettings.clear(); + if (voiceSettings != null) { + this.voiceSettings.putAll(voiceSettings); + } + } + + public String getVoiceCode(SpeakerType speakerType) { + return this.voiceSettings.get(speakerType); + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/ScenarioNode.java b/src/main/java/com/swyp/picke/domain/scenario/entity/ScenarioNode.java index 997e8765..1f060c45 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/entity/ScenarioNode.java +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/ScenarioNode.java @@ -32,9 +32,11 @@ public class ScenarioNode extends BaseEntity { @Column(name = "auto_next_node_id") private Long autoNextNodeId; + @OrderColumn(name = "script_order") @OneToMany(mappedBy = "node", cascade = CascadeType.ALL, orphanRemoval = true) private List + + + + + +
+ +
+
+

공지사항 작성

+

저장하면 사용자 알림으로 노출됩니다.

+ +
+
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+
+

최근 공지

+ +
+ +
+
+ + + + +
+
+ +
+ + + + + + + + + + + + +
ID카테고리제목작성일
불러오는 중...
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/admin/components/form-battle.html b/src/main/resources/templates/admin/components/form-battle.html index b98e850b..a35d2826 100644 --- a/src/main/resources/templates/admin/components/form-battle.html +++ b/src/main/resources/templates/admin/components/form-battle.html @@ -1,41 +1,41 @@
-
+

1 기본 정보

- BASIC INFO + BATTLE
- +
- +
- +
- - + +
- - + +
- -
\ No newline at end of file +
diff --git a/src/main/resources/templates/admin/components/form-quiz.html b/src/main/resources/templates/admin/components/form-quiz.html index 36f1f079..1274c881 100644 --- a/src/main/resources/templates/admin/components/form-quiz.html +++ b/src/main/resources/templates/admin/components/form-quiz.html @@ -7,83 +7,48 @@

- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - + +

- +
-
-
+
+ +
- +
-
-
- - -
-
- - -
+
+ + +
+
+ +
-
-
+
+ +
- + +
+
+ +
-
-
- - -
-
- - -
+
+ +

-
\ No newline at end of file + diff --git a/src/main/resources/templates/admin/components/form-vote.html b/src/main/resources/templates/admin/components/form-vote.html index 0a074c7f..19acdcab 100644 --- a/src/main/resources/templates/admin/components/form-vote.html +++ b/src/main/resources/templates/admin/components/form-vote.html @@ -2,53 +2,50 @@

1 투표 등록

- VOTE + POLL
- - + +
- - + +

- -
- - -
- - -
-
- 1 - + +
+
+ + +
-
- 2 - + +
+ + +
-
- 3 - + +
+ + +
-
- 4 - + +
+ + +
- \ No newline at end of file + diff --git a/src/main/resources/templates/admin/fragments/header.html b/src/main/resources/templates/admin/fragments/header.html index ec99dbe3..1a59f4c5 100644 --- a/src/main/resources/templates/admin/fragments/header.html +++ b/src/main/resources/templates/admin/fragments/header.html @@ -1,8 +1,16 @@
-
- Pické - Admin +
+
+ Picke + Admin +
+
-
ADMIN
-
\ No newline at end of file +
+ ADMIN +
+ diff --git a/src/main/resources/templates/admin/fragments/preview.html b/src/main/resources/templates/admin/fragments/preview.html index 0cac253e..58c99276 100644 --- a/src/main/resources/templates/admin/fragments/preview.html +++ b/src/main/resources/templates/admin/fragments/preview.html @@ -2,7 +2,7 @@
실시간 미리보기 - BRANCH MODE +
@@ -110,7 +110,7 @@

-
diff --git a/src/main/resources/templates/admin/picke-list.html b/src/main/resources/templates/admin/picke-list.html index 012960e7..52756a9e 100644 --- a/src/main/resources/templates/admin/picke-list.html +++ b/src/main/resources/templates/admin/picke-list.html @@ -3,15 +3,30 @@ - Pické Admin - 콘텐츠 관리 + Picke Admin - 콘텐츠 관리 @@ -22,19 +37,27 @@

콘텐츠 관리

-

배틀, 퀴즈, 투표 콘텐츠를 조회하고 관리합니다.

+

배틀, 퀴즈, 투표 콘텐츠를 확인하고 수정할 수 있습니다.

+
+ + + + +
+
- - - - + + + + +
@@ -43,44 +66,57 @@

콘텐츠 관리

ID 유형 - 콘텐츠 제목 + 제목 상태 - 등록일 + 생성일 관리 - -
-
- 데이터를 불러오는 중... -
- + + +
+
+ 데이터를 불러오는 중입니다... +
+ +
+
- \ No newline at end of file + diff --git a/src/main/resources/templates/share/battle.html b/src/main/resources/templates/share/battle.html new file mode 100644 index 00000000..c9f872ff --- /dev/null +++ b/src/main/resources/templates/share/battle.html @@ -0,0 +1,33 @@ + + + + + + Pické - 배틀 + + + + + + + +
+
🦉
+

Pické

+

+ 배틀에 참여하려면
모바일 앱에서 확인하세요. +

+
+

앱을 설치하고 배틀에 참여해보세요

+ +
+
+ + + diff --git a/src/main/resources/templates/share/report.html b/src/main/resources/templates/share/report.html new file mode 100644 index 00000000..b7e692a6 --- /dev/null +++ b/src/main/resources/templates/share/report.html @@ -0,0 +1,33 @@ + + + + + + Pické - 철학자 리포트 + + + + + + + +
+
🦉
+

Pické

+

+ 친구의 철학자 리포트를 보려면
모바일 앱에서 확인하세요. +

+
+

앱을 설치하고 리포트를 확인해보세요

+ +
+
+ + + diff --git a/src/main/resources/templates/share/result.html b/src/main/resources/templates/share/result.html deleted file mode 100644 index b75c451f..00000000 --- a/src/main/resources/templates/share/result.html +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - Pické - 철학자 유형 결과 - - - - - - - -
- - - - - - - -
- - - - - diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java new file mode 100644 index 00000000..4e3dcb53 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java @@ -0,0 +1,397 @@ +package com.swyp.picke.domain.admin.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.BattleTagRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.tag.repository.TagRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class AdminContentCreationIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TagRepository tagRepository; + + @Autowired + private BattleRepository battleRepository; + + @Autowired + private BattleOptionRepository battleOptionRepository; + + @Autowired + private BattleTagRepository battleTagRepository; + + @Autowired + private BattleOptionTagRepository battleOptionTagRepository; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private S3PresignedUrlService s3PresignedUrlService; + + @Test + @DisplayName("관리자가 배틀을 생성할 때 현재 매핑된 필드들을 저장한다") + void createBattle_persistsAllMappedFields() throws Exception { + User admin = createAdminUser(); + String adminToken = jwtProvider.createAccessToken(admin.getId(), "ADMIN"); + + Tag category = createTag("battle-category", TagType.CATEGORY); + Tag philosopher = createTag("battle-philosopher", TagType.PHILOSOPHER); + Tag value = createTag("battle-value", TagType.VALUE); + + Map payload = Map.of( + "type", "BATTLE", + "status", "PENDING", + "title", "배틀 제목", + "summary", "배틀 요약", + "description", "배틀 설명", + "thumbnailUrl", "images/battles/battle-thumb.png", + "targetDate", LocalDate.now().toString(), + "audioDuration", 95, + "tagIds", List.of(category.getId()), + "options", List.of( + Map.of( + "label", "A", + "title", "A 선택지", + "stance", "A 입장", + "representative", "소크라테스", + "imageUrl", "images/philosophers/a.png", + "displayOrder", 1, + "tagIds", List.of(philosopher.getId(), value.getId()) + ), + Map.of( + "label", "B", + "title", "B 선택지", + "stance", "B 입장", + "representative", "플라톤", + "imageUrl", "images/philosophers/b.png", + "displayOrder", 2, + "tagIds", List.of(value.getId()) + ) + ) + ); + + MvcResult result = mockMvc.perform(post("/api/v1/admin/battles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.battleId").exists()) + .andExpect(jsonPath("$.data.thumbnailUrl") + .value("http://localhost:8080/api/v1/resources/images/BATTLE/battle-thumb.png")) + .andReturn(); + + Long battleId = extractId(result, "battleId"); + Battle savedBattle = battleRepository.findById(battleId).orElseThrow(); + List options = battleOptionRepository.findByBattle(savedBattle); + + assertThat(savedBattle.getTitle()).isEqualTo("배틀 제목"); + assertThat(savedBattle.getSummary()).isEqualTo("배틀 요약"); + assertThat(savedBattle.getDescription()).isEqualTo("배틀 설명"); + assertThat(savedBattle.getThumbnailUrl()).isEqualTo("images/battles/battle-thumb.png"); + assertThat(savedBattle.getAudioDuration()).isNull(); + assertThat(savedBattle.getTargetDate()).isNull(); + + assertThat(options).hasSize(2); + BattleOption optionA = options.stream().filter(option -> option.getLabel().name().equals("A")).findFirst().orElseThrow(); + BattleOption optionB = options.stream().filter(option -> option.getLabel().name().equals("B")).findFirst().orElseThrow(); + + assertThat(optionA.getTitle()).isEqualTo("A 선택지"); + assertThat(optionA.getRepresentative()).isEqualTo("소크라테스"); + assertThat(optionA.getDisplayOrder()).isNull(); + assertThat(optionB.getTitle()).isEqualTo("B 선택지"); + assertThat(optionB.getRepresentative()).isEqualTo("플라톤"); + assertThat(optionB.getDisplayOrder()).isNull(); + + assertThat(battleTagRepository.findByBattle(savedBattle)).hasSize(1); + assertThat(battleOptionTagRepository.findByBattleOption(optionA)).hasSize(2); + assertThat(battleOptionTagRepository.findByBattleOption(optionB)).hasSize(1); + } + + @Test + @DisplayName("관리자가 퀴즈를 생성할 때 현재 500을 반환한다") + void createQuiz_persistsAllMappedFields() throws Exception { + User admin = createAdminUser(); + String adminToken = jwtProvider.createAccessToken(admin.getId(), "ADMIN"); + + Map payload = Map.of( + "title", "퀴즈 제목", + "targetDate", LocalDate.now().plusDays(1).toString(), + "status", "PENDING", + "options", List.of( + Map.of( + "label", "A", + "text", "정답 보기", + "detailText", "정답 해설", + "isCorrect", true, + "displayOrder", 1 + ), + Map.of( + "label", "B", + "text", "오답 보기", + "detailText", "오답 해설", + "isCorrect", false, + "displayOrder", 2 + ) + ) + ); + + mockMvc.perform(post("/api/v1/admin/quizzes") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.statusCode").value(500)); + } + + @Test + @DisplayName("관리자가 투표를 생성할 때 현재 500을 반환한다") + void createPoll_persistsAllMappedFields() throws Exception { + User admin = createAdminUser(); + String adminToken = jwtProvider.createAccessToken(admin.getId(), "ADMIN"); + + Map payload = Map.of( + "titlePrefix", "당신은", + "titleSuffix", "어느 쪽인가요?", + "targetDate", LocalDate.now().plusDays(2).toString(), + "status", "PENDING", + "options", List.of( + Map.of( + "label", "A", + "title", "선택지 A", + "displayOrder", 1 + ), + Map.of( + "label", "B", + "title", "선택지 B", + "displayOrder", 2 + ) + ) + ); + + mockMvc.perform(post("/api/v1/admin/polls") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.statusCode").value(500)); + } + + @Test + @DisplayName("리소스 이미지 URL이 사전서명된 URL로 리다이렉트된다") + void resourceImage_redirects_to_presigned_url() throws Exception { + String expectedPresignedUrl = "https://signed.example.com/images/battles/test.png?sig=abc"; + when(s3PresignedUrlService.generatePresignedUrl("images/battles/test.png")) + .thenReturn(expectedPresignedUrl); + + mockMvc.perform(get("/api/v1/resources/images/BATTLE/test.png")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", expectedPresignedUrl)); + } + + @Test + @DisplayName("대기 중인 로컬 이미지는 게시 시 S3로 옮겨진다") + void pending_local_images_are_promoted_to_s3_on_publish() throws Exception { + User admin = createAdminUser(); + String adminToken = jwtProvider.createAccessToken(admin.getId(), "ADMIN"); + + String localThumbKey = uploadLocalDraftKey(adminToken, "draft-thumb.png", "draft-thumb"); + String localAKey = uploadLocalDraftKey(adminToken, "draft-a.png", "draft-a"); + String localBKey = uploadLocalDraftKey(adminToken, "draft-b.png", "draft-b"); + + Map createPayload = Map.of( + "type", "BATTLE", + "status", "PENDING", + "title", "로컬 임시저장 테스트", + "summary", "요약", + "description", "설명", + "thumbnailUrl", localThumbKey, + "targetDate", LocalDate.now().toString(), + "audioDuration", 30, + "tagIds", List.of(), + "options", List.of( + Map.of( + "label", "A", + "title", "옵션 A", + "stance", "입장 A", + "representative", "철학자 A", + "imageUrl", localAKey, + "displayOrder", 1, + "tagIds", List.of() + ), + Map.of( + "label", "B", + "title", "옵션 B", + "stance", "입장 B", + "representative", "철학자 B", + "imageUrl", localBKey, + "displayOrder", 2, + "tagIds", List.of() + ) + ) + ); + + MvcResult createResult = mockMvc.perform(post("/api/v1/admin/battles") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createPayload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.battleId").exists()) + .andReturn(); + + Long battleId = extractId(createResult, "battleId"); + Battle pendingBattle = battleRepository.findById(battleId).orElseThrow(); + assertThat(pendingBattle.getThumbnailUrl()).startsWith("local/drafts/"); + + Map publishPayload = new LinkedHashMap<>(); + publishPayload.put("status", "PUBLISHED"); + publishPayload.put("title", pendingBattle.getTitle()); + publishPayload.put("summary", pendingBattle.getSummary()); + publishPayload.put("description", pendingBattle.getDescription()); + publishPayload.put("thumbnailUrl", pendingBattle.getThumbnailUrl()); + publishPayload.put("targetDate", LocalDate.now().toString()); + publishPayload.put("tagIds", List.of()); + publishPayload.put("options", List.of( + Map.of( + "label", "A", + "title", "옵션 A", + "stance", "입장 A", + "representative", "철학자 A", + "imageUrl", localAKey, + "displayOrder", 1, + "tagIds", List.of() + ), + Map.of( + "label", "B", + "title", "옵션 B", + "stance", "입장 B", + "representative", "철학자 B", + "imageUrl", localBKey, + "displayOrder", 2, + "tagIds", List.of() + ) + )); + + mockMvc.perform(patch("/api/v1/admin/battles/{battleId}", battleId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(publishPayload))) + .andExpect(status().isOk()); + + Battle publishedBattle = battleRepository.findById(battleId).orElseThrow(); + assertThat(publishedBattle.getThumbnailUrl()).startsWith("images/battles/"); + List publishedOptions = battleOptionRepository.findByBattle(publishedBattle); + assertThat(publishedOptions).allMatch(option -> option.getImageUrl().startsWith("images/philosophers/")); + + verify(s3Client, atLeastOnce()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + private String uploadLocalDraftKey(String adminToken, String fileName, String content) throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", + fileName, + MediaType.IMAGE_PNG_VALUE, + content.getBytes() + ); + + MvcResult uploadResult = mockMvc.perform(multipart("/api/v1/files/upload/local") + .file(file) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.s3Key").exists()) + .andReturn(); + + return objectMapper.readTree(uploadResult.getResponse().getContentAsString()) + .path("data") + .path("s3Key") + .asText(); + } + + private Long extractId(MvcResult result, String idField) throws Exception { + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + return root.path("data").path(idField).asLong(); + } + + private User createAdminUser() { + return userRepository.save( + User.builder() + .userTag("adm-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("admin") + .role(UserRole.ADMIN) + .status(UserStatus.ACTIVE) + .build() + ); + } + + private Tag createTag(String prefix, TagType type) { + String normalizedPrefix = prefix.length() > 10 ? prefix.substring(0, 10) : prefix; + return tagRepository.save( + Tag.builder() + .name(normalizedPrefix + "-" + UUID.randomUUID().toString().substring(0, 8)) + .type(type) + .build() + ); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java new file mode 100644 index 00000000..404f8137 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java @@ -0,0 +1,108 @@ +package com.swyp.picke.domain.admin.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.repository.NotificationRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminNoticeIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private NotificationRepository notificationRepository; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private S3PresignedUrlService s3PresignedUrlService; + + @Test + @DisplayName("관리자 공지 생성 및 목록 조회가 동작한다") + void admin_can_create_and_list_notices() throws Exception { + String adminToken = createAdminToken(); + + Map payload = Map.of( + "category", "NOTICE", + "title", "서비스 점검 안내", + "body", "오늘 22시에 점검이 진행됩니다.", + "referenceId", 123L + ); + + mockMvc.perform(post("/api/v1/admin/notices") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notificationId").exists()) + .andExpect(jsonPath("$.data.category").value("NOTICE")) + .andExpect(jsonPath("$.data.title").value("서비스 점검 안내")); + + Notification saved = notificationRepository.findAll().stream() + .filter(notification -> "서비스 점검 안내".equals(notification.getTitle())) + .findFirst() + .orElseThrow(); + + assertThat(saved.getUser()).isNull(); + assertThat(saved.getCategory()).isEqualTo(NotificationCategory.NOTICE); + + mockMvc.perform(get("/api/v1/admin/notices") + .header("Authorization", "Bearer " + adminToken) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].notificationId").exists()) + .andExpect(jsonPath("$.data.items[0].title").isNotEmpty()); + } + + private String createAdminToken() { + User admin = userRepository.save( + User.builder() + .userTag("adm-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("admin") + .role(UserRole.ADMIN) + .status(UserStatus.ACTIVE) + .build() + ); + return jwtProvider.createAccessToken(admin.getId(), "ADMIN"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminScenarioPublishFlowIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminScenarioPublishFlowIntegrationTest.java new file mode 100644 index 00000000..ed7c9c8b --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminScenarioPublishFlowIntegrationTest.java @@ -0,0 +1,168 @@ +package com.swyp.picke.domain.admin.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.scenario.service.ScenarioAudioPipelineService; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminScenarioPublishFlowIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private BattleRepository battleRepository; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private S3PresignedUrlService s3PresignedUrlService; + + @MockitoBean + private ScenarioAudioPipelineService scenarioAudioPipelineService; + + @Test + void createScenario_pending_doesNotTriggerAudioPipeline() throws Exception { + String adminToken = createAdminToken(); + Battle battle = createBattle(); + + Map payload = scenarioPayload(battle.getId(), "PENDING"); + + mockMvc.perform(post("/api/v1/admin/scenarios") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.scenarioId").exists()); + + verify(scenarioAudioPipelineService, never()).generateAndMergeAudioAsync(anyLong()); + } + + @Test + void patchScenarioStatus_toPublished_triggersAudioPipeline() throws Exception { + String adminToken = createAdminToken(); + Battle battle = createBattle(); + + MvcResult createResult = mockMvc.perform(post("/api/v1/admin/scenarios") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(scenarioPayload(battle.getId(), "PENDING")))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.scenarioId").exists()) + .andReturn(); + + Long scenarioId = extractId(createResult, "scenarioId"); + clearInvocations(scenarioAudioPipelineService); + + mockMvc.perform(patch("/api/v1/admin/scenarios/{scenarioId}", scenarioId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("status", "PUBLISHED")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + + verify(scenarioAudioPipelineService, timeout(1000)).generateAndMergeAudioAsync(scenarioId); + } + + private Map scenarioPayload(Long battleId, String status) { + return Map.of( + "battleId", battleId, + "isInteractive", false, + "status", status, + "nodes", List.of( + Map.of( + "nodeName", "START", + "isStartNode", true, + "autoNextNode", "", + "scripts", List.of( + Map.of( + "speakerType", "NARRATOR", + "speakerName", "Narrator", + "text", "Opening script" + ) + ), + "interactiveOptions", List.of() + ) + ), + "voiceSettings", Map.of("NARRATOR", "voice-narrator") + ); + } + + private String createAdminToken() { + User admin = userRepository.save( + User.builder() + .userTag("adm-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("admin") + .role(UserRole.ADMIN) + .status(UserStatus.ACTIVE) + .build() + ); + return jwtProvider.createAccessToken(admin.getId(), "ADMIN"); + } + + private Battle createBattle() { + return battleRepository.save( + Battle.builder() + .title("Scenario test battle") + .summary("summary") + .description("description") + .targetDate(LocalDate.now()) + .audioDuration(30) + .status(BattleStatus.PENDING) + .creatorType(BattleCreatorType.ADMIN) + .build() + ); + } + + private Long extractId(MvcResult result, String idField) throws Exception { + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + return root.path("data").path(idField).asLong(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java new file mode 100644 index 00000000..53b664e7 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java @@ -0,0 +1,85 @@ +package com.swyp.picke.domain.battle.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.swyp.picke.domain.battle.dto.request.BattleProposalRequest; +import com.swyp.picke.domain.battle.dto.response.BattleProposalResponse; +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.repository.BattleProposalRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.user.service.UserService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BattleProposalServiceTest { + + @InjectMocks + private BattleProposalService battleProposalService; + + @Mock + private BattleProposalRepository battleProposalRepository; + + @Mock + private CreditService creditService; + + @Mock + private UserService userService; + + @Test + @DisplayName("1. 배틀 제안 성공 - 크레딧 차감 및 저장 확인") + void propose_Success() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(userService.findCurrentUser()).willReturn(user); + given(creditService.getTotalPoints(1L)).willReturn(100); // 잔액 충분 + + BattleProposalRequest request = mock(BattleProposalRequest.class); + given(request.getCategory()).willReturn(BattleCategory.PHILOSOPHY); + given(request.getTopic()).willReturn("테스트 주제"); + + // when + BattleProposalResponse response = battleProposalService.propose(request); + + // then + // 제안 저장 메서드가 호출되었는지 확인 + verify(battleProposalRepository, times(1)).save(any()); + // 크레딧 차감(-30) 로직이 호출되었는지 확인 + verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.TOPIC_SUGGEST), eq(-30), any()); + } + + @Test + @DisplayName("2. 배틀 제안 실패 - 크레딧 부족 시 예외 발생") + void propose_Fail_CreditNotEnough() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(userService.findCurrentUser()).willReturn(user); + given(creditService.getTotalPoints(1L)).willReturn(10); // 잔액 부족 (30 미만) + + BattleProposalRequest request = mock(BattleProposalRequest.class); + + // when & then + // 에러 코드 CREDIT_NOT_ENOUGH가 발생하는지 확인 + CustomException exception = assertThrows(CustomException.class, () -> { + battleProposalService.propose(request); + }); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.CREDIT_NOT_ENOUGH); + } +} diff --git a/src/test/java/com/swyp/picke/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/picke/domain/home/service/HomeServiceTest.java index c4c2f046..8537c618 100644 --- a/src/test/java/com/swyp/picke/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/home/service/HomeServiceTest.java @@ -3,12 +3,24 @@ import com.swyp.picke.domain.battle.dto.response.TodayBattleResponse; import com.swyp.picke.domain.battle.dto.response.TodayOptionResponse; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -import com.swyp.picke.domain.battle.enums.BattleType; import com.swyp.picke.domain.battle.service.BattleService; -import com.swyp.picke.domain.home.dto.response.*; +import com.swyp.picke.domain.home.dto.response.HomeTodayQuizResponse; +import com.swyp.picke.domain.home.dto.response.HomeTodayVoteOptionResponse; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.enums.PollOptionLabel; +import com.swyp.picke.domain.poll.enums.PollStatus; +import com.swyp.picke.domain.poll.service.PollService; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import com.swyp.picke.domain.quiz.service.QuizService; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import java.time.LocalDate; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,14 +28,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; -import java.util.concurrent.atomic.AtomicLong; - -import static com.swyp.picke.domain.battle.enums.BattleType.BATTLE; -import static com.swyp.picke.domain.battle.enums.BattleType.QUIZ; -import static com.swyp.picke.domain.battle.enums.BattleType.VOTE; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,83 +37,139 @@ class HomeServiceTest { @Mock private BattleService battleService; + + @Mock + private QuizService quizService; + + @Mock + private PollService pollService; + @Mock private NotificationService notificationService; + @Mock private S3PresignedUrlService s3PresignedUrlService; @InjectMocks private HomeService homeService; - private final AtomicLong idGenerator = new AtomicLong(1L); - - private Long generateId() { - return idGenerator.getAndIncrement(); - } - @Test - @DisplayName("명세기준으로 섹션별 데이터를 조합한다") - void getHome_aggregates_sections_by_spec() { + @DisplayName("홈 응답에 배틀/퀴즈/투표 섹션을 조합해 반환한다") + void getHome_aggregates_sections() { Long userId = 1L; - TodayBattleResponse editorPick = battle("editor-id", BATTLE); - TodayBattleResponse trendingBattle = battle("trending-id", BATTLE); - TodayBattleResponse bestBattle = battle("best-id", BATTLE); - TodayBattleResponse todayVote = vote("vote-id"); - TodayBattleResponse todayQuiz = quiz("quiz-id"); - TodayBattleResponse newBattle = battle("new-id", BATTLE); + + TodayBattleResponse editorPick = battle(101L, "editor-id"); + TodayBattleResponse trendingBattle = battle(102L, "trending-id"); + TodayBattleResponse bestBattle = battle(103L, "best-id"); + TodayBattleResponse newBattle = battle(104L, "new-id"); + + Quiz quiz = Quiz.builder() + .title("오늘의 퀴즈") + .targetDate(LocalDate.now()) + .status(QuizStatus.PUBLISHED) + .build(); + QuizOption quizA = QuizOption.builder() + .quiz(quiz) + .label(QuizOptionLabel.A) + .text("정답") + .detailText("정답 설명") + .isCorrect(true) + .displayOrder(1) + .build(); + QuizOption quizB = QuizOption.builder() + .quiz(quiz) + .label(QuizOptionLabel.B) + .text("오답") + .detailText("오답 설명") + .isCorrect(false) + .displayOrder(2) + .build(); + + Poll poll = Poll.builder() + .titlePrefix("찬성 vs 반대") + .titleSuffix("당신의 선택은?") + .targetDate(LocalDate.now()) + .status(PollStatus.PUBLISHED) + .build(); + PollOption pollB = PollOption.builder() + .poll(poll) + .label(PollOptionLabel.B) + .title("반대") + .displayOrder(2) + .voteCount(3L) + .build(); + PollOption pollA = PollOption.builder() + .poll(poll) + .label(PollOptionLabel.A) + .title("찬성") + .displayOrder(1) + .voteCount(7L) + .build(); when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(true); - when(battleService.getEditorPicks(10)).thenReturn(List.of(editorPick)); - when(battleService.getTrendingBattles(4)).thenReturn(List.of(trendingBattle)); - when(battleService.getBestBattles(3)).thenReturn(List.of(bestBattle)); - when(battleService.getTodayPicks(VOTE, 1)).thenReturn(List.of(todayVote)); - when(battleService.getTodayPicks(QUIZ, 1)).thenReturn(List.of(todayQuiz)); + when(battleService.getEditorPicks()).thenReturn(List.of(editorPick)); + when(battleService.getTrendingBattles()).thenReturn(List.of(trendingBattle)); + when(battleService.getBestBattles()).thenReturn(List.of(bestBattle)); + when(quizService.getTodayPicks(1)).thenReturn(List.of(quiz)); + when(quizService.getOptions(quiz)).thenReturn(List.of(quizA, quizB)); + when(quizService.countVotes(quiz)).thenReturn(12L); + when(pollService.getTodayPicks(1)).thenReturn(List.of(poll)); + when(pollService.getOptions(poll)).thenReturn(List.of(pollB, pollA)); + when(pollService.countVotes(poll)).thenReturn(10L); when(battleService.getNewBattles(List.of( editorPick.battleId(), trendingBattle.battleId(), - bestBattle.battleId(), - todayVote.battleId(), - todayQuiz.battleId() - ), 3)).thenReturn(List.of(newBattle)); + bestBattle.battleId() + ))).thenReturn(List.of(newBattle)); var response = homeService.getHome(userId); assertThat(response.newNotice()).isTrue(); - assertThat(response.editorPicks()).extracting(HomeEditorPickResponse::title).containsExactly("editor-id"); - assertThat(response.trendingBattles()).extracting(HomeTrendingResponse::title).containsExactly("trending-id"); - assertThat(response.bestBattles()).extracting(HomeBestBattleResponse::title).containsExactly("best-id"); - assertThat(response.todayQuizzes()).extracting(HomeTodayQuizResponse::title).containsExactly("quiz-id"); + assertThat(response.editorPicks()).hasSize(1); + assertThat(response.trendingBattles()).hasSize(1); + assertThat(response.bestBattles()).hasSize(1); + assertThat(response.newBattles()).hasSize(1); + + assertThat(response.todayQuizzes()).hasSize(1); + HomeTodayQuizResponse quizResponse = response.todayQuizzes().getFirst(); + assertThat(quizResponse.title()).isEqualTo("오늘의 퀴즈"); + assertThat(quizResponse.summary()).isEqualTo("왼쪽과 오른쪽 중 정답을 선택하세요"); + assertThat(quizResponse.itemA()).isEqualTo("정답"); + assertThat(quizResponse.itemADesc()).isEqualTo("정답 설명"); + assertThat(quizResponse.itemB()).isEqualTo("오답"); + assertThat(quizResponse.participantsCount()).isEqualTo(12L); + assertThat(response.todayVotes()).hasSize(1); - assertThat(response.todayVotes().get(0).titlePrefix()).isEqualTo("도덕의 기준은"); - assertThat(response.todayVotes().get(0).options()).extracting(HomeTodayVoteOptionResponse::title) - .containsExactly("결과", "의도", "규칙", "덕"); - assertThat(response.todayQuizzes().get(0).itemA()).isEqualTo("정답"); - assertThat(response.newBattles()).extracting(HomeNewBattleResponse::title).containsExactly("new-id"); - assertThat(response.newBattles().getFirst().optionATitle()).isEqualTo("A"); - assertThat(response.newBattles().getFirst().optionBTitle()).isEqualTo("B"); - - verify(battleService).getNewBattles(argThat(ids -> ids.equals(List.of( + assertThat(response.todayVotes().getFirst().titlePrefix()).isEqualTo("찬성 vs 반대"); + assertThat(response.todayVotes().getFirst().summary()).isEqualTo("빈칸에 들어갈 가장 적절한 답을 골라주세요"); + assertThat(response.todayVotes().getFirst().participantsCount()).isEqualTo(10L); + assertThat(response.todayVotes().getFirst().options()) + .extracting(HomeTodayVoteOptionResponse::label, HomeTodayVoteOptionResponse::title) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(BattleOptionLabel.A, "찬성"), + org.assertj.core.groups.Tuple.tuple(BattleOptionLabel.B, "반대") + ); + + verify(battleService).getNewBattles(List.of( editorPick.battleId(), trendingBattle.battleId(), - bestBattle.battleId(), - todayVote.battleId(), - todayQuiz.battleId() - ))), eq(3)); + bestBattle.battleId() + )); } @Test - @DisplayName("데이터가 없으면 false와 빈리스트를 반환한다") - void getHome_returns_false_and_empty_lists_when_no_data() { + @DisplayName("데이터가 없으면 빈 리스트를 반환한다") + void getHome_returns_empty_lists_when_no_data() { Long userId = 1L; when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(false); - when(battleService.getEditorPicks(10)).thenReturn(List.of()); - when(battleService.getTrendingBattles(4)).thenReturn(List.of()); - when(battleService.getBestBattles(3)).thenReturn(List.of()); - when(battleService.getTodayPicks(VOTE, 1)).thenReturn(List.of()); - when(battleService.getTodayPicks(QUIZ, 1)).thenReturn(List.of()); - when(battleService.getNewBattles(List.of(), 3)).thenReturn(List.of()); + when(battleService.getEditorPicks()).thenReturn(List.of()); + when(battleService.getTrendingBattles()).thenReturn(List.of()); + when(battleService.getBestBattles()).thenReturn(List.of()); + when(quizService.getTodayPicks(1)).thenReturn(List.of()); + when(pollService.getTodayPicks(1)).thenReturn(List.of()); + when(battleService.getNewBattles(List.of())).thenReturn(List.of()); var response = homeService.getHome(userId); @@ -121,77 +182,20 @@ void getHome_returns_false_and_empty_lists_when_no_data() { assertThat(response.newBattles()).isEmpty(); } - @Test - @DisplayName("에디터픽만 있을때 제외목록이 정확하다") - void getHome_excludes_only_editor_pick_ids() { - Long userId = 1L; - TodayBattleResponse editorPick = battle("editor-only", BATTLE); - - when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(false); - when(battleService.getEditorPicks(10)).thenReturn(List.of(editorPick)); - when(battleService.getTrendingBattles(4)).thenReturn(List.of()); - when(battleService.getBestBattles(3)).thenReturn(List.of()); - when(battleService.getTodayPicks(VOTE, 1)).thenReturn(List.of()); - when(battleService.getTodayPicks(QUIZ, 1)).thenReturn(List.of()); - when(battleService.getNewBattles(List.of(editorPick.battleId()), 3)).thenReturn(List.of()); - - homeService.getHome(userId); - - verify(battleService).getNewBattles(List.of(editorPick.battleId()), 3); - } - - @Test - @DisplayName("공지 브로드캐스트가 있으면 newNotice는 true이다") - void getHome_newNotice_true_with_broadcast() { - Long userId = 1L; - when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(true); - when(battleService.getEditorPicks(10)).thenReturn(List.of()); - when(battleService.getTrendingBattles(4)).thenReturn(List.of()); - when(battleService.getBestBattles(3)).thenReturn(List.of()); - when(battleService.getTodayPicks(VOTE, 1)).thenReturn(List.of()); - when(battleService.getTodayPicks(QUIZ, 1)).thenReturn(List.of()); - when(battleService.getNewBattles(List.of(), 3)).thenReturn(List.of()); - - var response = homeService.getHome(userId); - - assertThat(response.newNotice()).isTrue(); - } - - private TodayBattleResponse battle(String title, BattleType type) { - return new TodayBattleResponse( - generateId(), title, "summary", "thumbnail", type, - 10, 20L, 90, - List.of(), - List.of( - new TodayOptionResponse(generateId(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a", null), - new TodayOptionResponse(generateId(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b", null) - ), - null, null, null, null, null, null - ); - } - - private TodayBattleResponse quiz(String title) { - return new TodayBattleResponse( - generateId(), title, "summary", "thumbnail", QUIZ, - 30, 40L, 60, - List.of(), - List.of(), - null, null, "정답", "정답 설명", "오답", "오답 설명" - ); - } - - private TodayBattleResponse vote(String title) { + private TodayBattleResponse battle(Long id, String title) { return new TodayBattleResponse( - generateId(), title, "summary", "thumbnail", VOTE, - 50, 60L, 0, + id, + title, + "summary", + "thumbnail", + 10, + 20L, + 90, List.of(), List.of( - new TodayOptionResponse(generateId(), BattleOptionLabel.A, "결과", null, null, null, null), - new TodayOptionResponse(generateId(), BattleOptionLabel.B, "의도", null, null, null, null), - new TodayOptionResponse(generateId(), BattleOptionLabel.C, "규칙", null, null, null, null), - new TodayOptionResponse(generateId(), BattleOptionLabel.D, "덕", null, null, null, null) - ), - "도덕의 기준은", "이다", null, null, null, null + new TodayOptionResponse(1001L, BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), + new TodayOptionResponse(1002L, BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b") + ) ); } } diff --git a/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java b/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java index 88acd723..b0d7faa3 100644 --- a/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java @@ -75,7 +75,7 @@ void processReward_Success() throws Exception { assertThat(result).isEqualTo("OK"); // // 4. 호출 검증 - verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.AD_REWARD), anyLong()); + verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.FREE_CHARGE), eq(100), anyLong()); verify(adRewardHistoryRepository, times(1)).save(any(AdRewardHistory.class)); verify(userService, times(1)).findByUserTag("pique-1cc4a030"); } @@ -88,4 +88,4 @@ private AdMobRewardRequest createSampleRequest(String transId) { transId, "sig-123", "key-123", "pique-1cc4a030" ); } -} \ No newline at end of file +} diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java new file mode 100644 index 00000000..2e60d4b3 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java @@ -0,0 +1,194 @@ +package com.swyp.picke.domain.scenario.service; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.scenario.converter.ScenarioConverter; +import com.swyp.picke.domain.scenario.dto.request.NodeRequest; +import com.swyp.picke.domain.scenario.dto.request.ScenarioCreateRequest; +import com.swyp.picke.domain.scenario.dto.request.ScriptRequest; +import com.swyp.picke.domain.scenario.entity.Scenario; +import com.swyp.picke.domain.scenario.entity.ScenarioNode; +import com.swyp.picke.domain.scenario.entity.Script; +import com.swyp.picke.domain.scenario.enums.AudioPathType; +import com.swyp.picke.domain.scenario.enums.CreatorType; +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.repository.ScenarioRepository; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScenarioServiceImplTest { + + @Mock + private ScenarioRepository scenarioRepository; + @Mock + private BattleRepository battleRepository; + @Mock + private BattleVoteRepository battleVoteRepository; + @Mock + private ScenarioConverter scenarioConverter; + @Mock + private ScenarioAudioPipelineService audioPipelineService; + @Mock + private S3UploadService s3Service; + @Mock + private BattleOptionRepository battleOptionRepository; + + private ScenarioServiceImpl scenarioService; + + @BeforeEach + void setUp() { + scenarioService = new ScenarioServiceImpl( + scenarioRepository, + battleRepository, + battleVoteRepository, + scenarioConverter, + audioPipelineService, + s3Service, + battleOptionRepository + ); + } + + @Test + void updateScenarioContent_textChanged_invalidatesOnlyChangedScriptChunk_andClearsMergedAudio() { + Scenario scenario = createScenario(); + ScenarioNode startNode = createNode("START", true); + Script unchangedScript = createScript(SpeakerType.NARRATOR, "NARRATOR", "line-1", "s3://chunks/script-1.mp3"); + Script changedScript = createScript(SpeakerType.NARRATOR, "NARRATOR", "line-2-old", "s3://chunks/script-2-old.mp3"); + startNode.addScript(unchangedScript); + startNode.addScript(changedScript); + scenario.addNode(startNode); + scenario.addAudioUrl(AudioPathType.COMMON, "s3://merged/common-old.mp3"); + scenario.replaceVoiceSettings(Map.of(SpeakerType.NARRATOR, "voice-narrator")); + + when(scenarioRepository.findById(1L)).thenReturn(Optional.of(scenario)); + when(battleOptionRepository.findByBattle(scenario.getBattle())).thenReturn(List.of()); + + ScenarioCreateRequest request = new ScenarioCreateRequest( + 1L, + false, + ScenarioStatus.PENDING, + List.of( + new NodeRequest( + "START", + true, + "", + List.of( + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-1"), + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-2-new") + ), + List.of() + ) + ), + Map.of(SpeakerType.NARRATOR, "voice-narrator") + ); + + scenarioService.updateScenarioContent(1L, request); + + assertThat(unchangedScript.getAudioUrl()).isNull(); + assertThat(changedScript.getAudioUrl()).isNull(); + assertThat(scenario.getAudios()).isEmpty(); + + verify(s3Service).deleteFile("s3://chunks/script-1.mp3"); + verify(s3Service).deleteFile("s3://chunks/script-2-old.mp3"); + verify(s3Service).deleteFile("s3://merged/common-old.mp3"); + } + + @Test + void updateScenarioContent_voiceChanged_invalidatesOnlyAffectedSpeakerChunks_andKeepsOthers() { + Scenario scenario = createScenario(); + ScenarioNode startNode = createNode("START", true); + Script narratorScript = createScript(SpeakerType.NARRATOR, "NARRATOR", "same-narrator", "s3://chunks/narrator-old.mp3"); + Script aScript = createScript(SpeakerType.A, "A", "same-a", "s3://chunks/a-old.mp3"); + startNode.addScript(narratorScript); + startNode.addScript(aScript); + scenario.addNode(startNode); + scenario.addAudioUrl(AudioPathType.COMMON, "s3://merged/common-old.mp3"); + scenario.replaceVoiceSettings(Map.of( + SpeakerType.NARRATOR, "voice-narrator-v1", + SpeakerType.A, "voice-a-v1" + )); + + when(scenarioRepository.findById(2L)).thenReturn(Optional.of(scenario)); + when(battleOptionRepository.findByBattle(scenario.getBattle())).thenReturn(List.of()); + + ScenarioCreateRequest request = new ScenarioCreateRequest( + 1L, + false, + ScenarioStatus.PENDING, + List.of( + new NodeRequest( + "START", + true, + "", + List.of( + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "same-narrator"), + new ScriptRequest("A", SpeakerType.A, "same-a") + ), + List.of() + ) + ), + Map.of( + SpeakerType.NARRATOR, "voice-narrator-v1", + SpeakerType.A, "voice-a-v2" + ) + ); + + scenarioService.updateScenarioContent(2L, request); + + assertThat(narratorScript.getAudioUrl()).isNull(); + assertThat(aScript.getAudioUrl()).isNull(); + assertThat(scenario.getAudios()).isEmpty(); + + verify(s3Service).deleteFile("s3://chunks/narrator-old.mp3"); + verify(s3Service).deleteFile("s3://chunks/a-old.mp3"); + verify(s3Service).deleteFile("s3://merged/common-old.mp3"); + } + + private Scenario createScenario() { + Battle battle = Battle.builder() + .title("battle") + .build(); + return Scenario.builder() + .battle(battle) + .isInteractive(false) + .status(ScenarioStatus.PENDING) + .creatorType(CreatorType.ADMIN) + .build(); + } + + private ScenarioNode createNode(String nodeName, boolean startNode) { + return ScenarioNode.builder() + .nodeName(nodeName) + .isStartNode(startNode) + .audioDuration(0) + .build(); + } + + private Script createScript(SpeakerType speakerType, String speakerName, String text, String audioUrl) { + Script script = Script.builder() + .startTimeMs(0) + .speakerType(speakerType) + .speakerName(speakerName) + .text(text) + .build(); + script.updateAudioUrl(audioUrl); + return script; + } +} diff --git a/src/test/java/com/swyp/picke/domain/share/controller/ShareApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/share/controller/ShareApiIntegrationTest.java new file mode 100644 index 00000000..c2e7ae3e --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/share/controller/ShareApiIntegrationTest.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.share.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserProfile; +import com.swyp.picke.domain.user.enums.CharacterType; +import com.swyp.picke.domain.user.enums.PhilosopherType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserProfileRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import software.amazon.awssdk.services.s3.S3Client; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ShareApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserProfileRepository userProfileRepository; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private S3PresignedUrlService s3PresignedUrlService; + + @Test + @DisplayName("인증 사용자는 공유 키를 발급받고 비로그인 사용자는 그 키로 리캡을 조회할 수 있다") + void recap_share_key_and_public_lookup_work() throws Exception { + when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); + + User user = userRepository.save( + User.builder() + .userTag("user-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("nickname") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build() + ); + + UserProfile profile = UserProfile.builder() + .user(user) + .nickname("recap-user") + .characterType(CharacterType.OWL) + .mannerTemperature(BigDecimal.valueOf(36.5)) + .build(); + profile.updatePhilosopherType(PhilosopherType.KANT); + userProfileRepository.save(profile); + + String token = jwtProvider.createAccessToken(user.getId(), "USER"); + + MvcResult shareKeyResult = mockMvc.perform(get("/api/v1/share/recap") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.shareKey").isNotEmpty()) + .andReturn(); + + Map body = objectMapper.readValue(shareKeyResult.getResponse().getContentAsByteArray(), Map.class); + Map data = (Map) body.get("data"); + String shareKey = (String) data.get("shareKey"); + + mockMvc.perform(get("/api/v1/share/recap/{shareKey}", shareKey)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.myCard.philosopherType").value("KANT")) + .andExpect(jsonPath("$.data.preferenceReport.totalParticipation").value(0)); + } + + @Test + @DisplayName("공유 키 발급 API는 인증이 필요하다") + void recap_share_key_requires_authentication() throws Exception { + mockMvc.perform(get("/api/v1/share/recap")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/share/service/ShareServiceTest.java b/src/test/java/com/swyp/picke/domain/share/service/ShareServiceTest.java new file mode 100644 index 00000000..4676e280 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/share/service/ShareServiceTest.java @@ -0,0 +1,117 @@ +package com.swyp.picke.domain.share.service; + +import com.swyp.picke.domain.share.dto.response.RecapShareKeyResponse; +import com.swyp.picke.domain.user.dto.response.RecapResponse; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserProfile; +import com.swyp.picke.domain.user.enums.CharacterType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserProfileRepository; +import com.swyp.picke.domain.user.service.MypageService; +import com.swyp.picke.domain.user.service.UserService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.math.BigDecimal; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ShareServiceTest { + + @Mock + private UserService userService; + + @Mock + private UserProfileRepository userProfileRepository; + + @Mock + private MypageService mypageService; + + @InjectMocks + private ShareService shareService; + + @Test + @DisplayName("리캡 공유 키는 최초 1회 생성 후 재사용된다") + void getRecapShareKey_generates_and_reuses_key() { + User user = createUser(1L, "tag"); + UserProfile profile = createProfile(user, "nick"); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserProfile(1L)).thenReturn(profile); + when(mypageService.findRecapByUserId(1L)).thenReturn(createRecap()); + + RecapShareKeyResponse first = shareService.getRecapShareKey(); + RecapShareKeyResponse second = shareService.getRecapShareKey(); + + assertThat(first.shareKey()).isNotBlank(); + assertThat(second.shareKey()).isEqualTo(first.shareKey()); + assertThat(profile.getRecapShareKey()).isEqualTo(first.shareKey()); + } + + @Test + @DisplayName("리캡이 없으면 공유 키를 발급하지 않는다") + void getRecapShareKey_throws_when_recap_missing() { + User user = createUser(1L, "tag"); + UserProfile profile = createProfile(user, "nick"); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserProfile(1L)).thenReturn(profile); + when(mypageService.findRecapByUserId(1L)).thenReturn(null); + + assertThatThrownBy(() -> shareService.getRecapShareKey()) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.RECAP_NOT_FOUND); + } + + @Test + @DisplayName("공유 키로 타인의 리캡을 조회한다") + void getSharedRecap_returns_recap() { + User user = createUser(2L, "other"); + UserProfile profile = createProfile(user, "other-nick"); + profile.updateRecapShareKey("share-key"); + RecapResponse recap = createRecap(); + + when(userProfileRepository.findByRecapShareKey("share-key")).thenReturn(Optional.of(profile)); + when(mypageService.findRecapByUserId(2L)).thenReturn(recap); + + RecapResponse response = shareService.getSharedRecap("share-key"); + + assertThat(response).isSameAs(recap); + } + + private User createUser(Long id, String userTag) { + User user = User.builder() + .userTag(userTag) + .nickname("nickname") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private UserProfile createProfile(User user, String nickname) { + return UserProfile.builder() + .user(user) + .nickname(nickname) + .characterType(CharacterType.OWL) + .mannerTemperature(BigDecimal.valueOf(36.5)) + .build(); + } + + private RecapResponse createRecap() { + return new RecapResponse(null, null, null, null, null); + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java index e3cb0975..dc7610f4 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java @@ -4,6 +4,8 @@ import com.swyp.picke.domain.user.enums.TierCode; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.repository.CreditHistoryRepository; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.global.common.exception.CustomException; @@ -16,13 +18,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -42,13 +44,27 @@ class CreditServiceTest { @InjectMocks private CreditService creditService; + private User newUser(Long id, int initialCredit) { + User user = User.builder() + .userTag("tag-" + id) + .nickname("nick") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + if (initialCredit != 0) { + user.addCredit(initialCredit); + } + return user; + } + @Test - @DisplayName("현재 로그인 유저에게 기본 크레딧을 적립한다") + @DisplayName("현재 로그인 유저에게 기본 크레딧을 적립하고 User.credit 캐시에도 반영한다") void addCredit_forCurrentUser_savesDefaultAmount() { - User user = org.mockito.Mockito.mock(User.class); - when(user.getId()).thenReturn(1L); + User user = newUser(1L, 0); when(userService.findCurrentUser()).thenReturn(user); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.incrementCredit(1L, CreditType.BATTLE_VOTE.getDefaultAmount())).thenReturn(1); creditService.addCredit(CreditType.BATTLE_VOTE, 10L); @@ -60,6 +76,7 @@ void addCredit_forCurrentUser_savesDefaultAmount() { assertThat(saved.getCreditType()).isEqualTo(CreditType.BATTLE_VOTE); assertThat(saved.getAmount()).isEqualTo(CreditType.BATTLE_VOTE.getDefaultAmount()); assertThat(saved.getReferenceId()).isEqualTo(10L); + verify(userRepository).incrementCredit(1L, CreditType.BATTLE_VOTE.getDefaultAmount()); } @Test @@ -74,24 +91,25 @@ void addCredit_withoutReferenceId_throwsException() { } @Test - @DisplayName("중복 적립 충돌이면 조용히 무시한다") + @DisplayName("중복 적립 충돌이면 조용히 무시하고 캐시도 증가시키지 않는다") void addCredit_duplicateInsert_ignoresConflict() { - User user = org.mockito.Mockito.mock(User.class); + User user = newUser(1L, 7); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) .thenThrow(new DataIntegrityViolationException("duplicate")); when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L)) .thenReturn(true); - creditService.addCredit(1L, CreditType.BATTLE_VOTE, 10, 10L); + creditService.addCredit(1L, CreditType.BATTLE_VOTE, 5, 10L); verify(creditHistoryRepository).existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L); + verify(userRepository, never()).incrementCredit(1L, 5); } @Test - @DisplayName("중복이 아닌 데이터 무결성 오류는 그대로 던진다") + @DisplayName("중복이 아닌 데이터 무결성 오류는 CREDIT_SAVE_FAILED 로 재기동하고 캐시도 증가시키지 않는다") void addCredit_nonDuplicateIntegrityFailure_rethrows() { - User user = org.mockito.Mockito.mock(User.class); + User user = newUser(1L, 3); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) .thenThrow(new DataIntegrityViolationException("broken")); @@ -102,12 +120,25 @@ void addCredit_nonDuplicateIntegrityFailure_rethrows() { .isInstanceOf(CustomException.class) .extracting("errorCode") .isEqualTo(ErrorCode.CREDIT_SAVE_FAILED); + + verify(userRepository, never()).incrementCredit(1L, 10); + } + + @Test + @DisplayName("getTotalPoints 는 User.credit 캐시 값을 반환한다 (히스토리 집계 아님)") + void getTotalPoints_readsUserCreditField() { + when(userRepository.findCreditById(1L)).thenReturn(2_500); + + int total = creditService.getTotalPoints(1L); + + assertThat(total).isEqualTo(2_500); + verify(creditHistoryRepository, never()).sumAmountByUserId(any()); } @Test @DisplayName("누적 포인트로 티어를 계산한다") void getTier_returnsTierFromTotalPoints() { - when(creditHistoryRepository.sumAmountByUserId(eq(1L))).thenReturn(2_500); + when(userRepository.findCreditById(1L)).thenReturn(2_500); TierCode tier = creditService.getTier(1L); diff --git a/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java index ffc644c4..e73f73a2 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java @@ -4,7 +4,6 @@ import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; import com.swyp.picke.domain.battle.service.BattleQueryService; import com.swyp.picke.domain.perspective.entity.Perspective; import com.swyp.picke.domain.perspective.entity.PerspectiveComment; @@ -13,12 +12,15 @@ import com.swyp.picke.domain.user.dto.request.UpdateNotificationSettingsRequest; import com.swyp.picke.domain.user.dto.response.BattleRecordListResponse; import com.swyp.picke.domain.user.dto.response.ContentActivityListResponse; +import com.swyp.picke.domain.user.dto.response.CreditHistoryListResponse; import com.swyp.picke.domain.user.dto.response.MypageResponse; import com.swyp.picke.domain.user.dto.response.NotificationSettingsResponse; import com.swyp.picke.domain.user.dto.response.RecapResponse; import com.swyp.picke.domain.user.dto.response.UserSummary; +import com.swyp.picke.domain.user.entity.CreditHistory; import com.swyp.picke.domain.user.enums.ActivityType; import com.swyp.picke.domain.user.enums.CharacterType; +import com.swyp.picke.domain.user.enums.CreditType; import com.swyp.picke.domain.user.enums.PhilosopherType; import com.swyp.picke.domain.user.enums.TierCode; import com.swyp.picke.domain.user.entity.User; @@ -27,7 +29,7 @@ import com.swyp.picke.domain.user.entity.UserSettings; import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.enums.VoteSide; -import com.swyp.picke.domain.vote.entity.Vote; +import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.service.VoteQueryService; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; import org.junit.jupiter.api.DisplayName; @@ -36,6 +38,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; @@ -158,7 +162,7 @@ void getBattleRecords_returns_paginated_records() { User user = createUser(1L, "tag"); Battle battle = createBattle("배틀 제목"); BattleOption optionA = createOption(battle, BattleOptionLabel.A); - Vote vote = Vote.builder() + BattleVote vote = BattleVote.builder() .user(user) .battle(battle) .preVoteOption(optionA) @@ -184,7 +188,7 @@ void getBattleRecords_returns_no_next_when_last_page() { User user = createUser(1L, "tag"); Battle battle = createBattle("제목"); BattleOption optionA = createOption(battle, BattleOptionLabel.A); - Vote vote = Vote.builder() + BattleVote vote = BattleVote.builder() .user(user) .battle(battle) .preVoteOption(optionA) @@ -220,6 +224,7 @@ void getBattleRecords_applies_vote_side_filter() { @DisplayName("COMMENT 타입으로 댓글활동을 반환한다") void getContentActivities_returns_comments() { User user = createUser(1L, "tag"); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); Battle battle = createBattle("배틀"); Long battleId = battle.getId(); BattleOption option = createOption(battle, BattleOptionLabel.A); @@ -242,6 +247,7 @@ void getContentActivities_returns_comments() { ReflectionTestUtils.setField(comment, "createdAt", LocalDateTime.now()); when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserProfile(1L)).thenReturn(profile); when(perspectiveQueryService.findUserComments(1L, 0, 20)).thenReturn(List.of(comment)); when(perspectiveQueryService.countUserComments(1L)).thenReturn(1L); when(battleQueryService.findBattlesByIds(List.of(battleId))).thenReturn(Map.of(battleId, battle)); @@ -292,6 +298,27 @@ void getContentActivities_returns_likes() { assertThat(response.items().get(0).activityType()).isEqualTo(ActivityType.LIKE); } + @Test + @DisplayName("크레딧 내역을 최신순으로 offset 페이징 변환해 반환한다") + void getCreditHistory_returns_paginated_history() { + User user = createUser(1L, "tag"); + CreditHistory latest = creditHistory(301L, user, CreditType.BEST_COMMENT, 50, 91L, LocalDateTime.now()); + CreditHistory older = creditHistory(300L, user, CreditType.BATTLE_VOTE, 5, 90L, LocalDateTime.now().minusDays(1)); + + when(userService.findCurrentUser()).thenReturn(user); + when(creditService.getHistory(1L, PageRequest.of(0, 2))) + .thenReturn(new PageImpl<>(List.of(latest, older), PageRequest.of(0, 2), 3)); + + CreditHistoryListResponse response = mypageService.getCreditHistory(0, 2); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).id()).isEqualTo(301L); + assertThat(response.items().get(0).creditType()).isEqualTo(CreditType.BEST_COMMENT); + assertThat(response.items().get(1).id()).isEqualTo(300L); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextOffset()).isEqualTo(2); + } + @Test @DisplayName("알림설정을 반환한다") void getNotificationSettings_returns_settings() { @@ -372,7 +399,6 @@ private Battle createBattle(String title) { Battle battle = Battle.builder() .title(title) .summary("summary") - .type(BattleType.BATTLE) .status(BattleStatus.PUBLISHED) .build(); ReflectionTestUtils.setField(battle, "id", generateId()); @@ -389,4 +415,23 @@ private BattleOption createOption(Battle battle, BattleOptionLabel label) { ReflectionTestUtils.setField(option, "id", generateId()); return option; } + + private CreditHistory creditHistory( + Long id, + User user, + CreditType creditType, + int amount, + Long referenceId, + LocalDateTime createdAt + ) { + CreditHistory history = CreditHistory.builder() + .user(user) + .creditType(creditType) + .amount(amount) + .referenceId(referenceId) + .build(); + ReflectionTestUtils.setField(history, "id", id); + ReflectionTestUtils.setField(history, "createdAt", createdAt); + return history; + } } diff --git a/src/test/java/com/swyp/picke/domain/user/service/UserServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/UserServiceTest.java index c740d204..000b7486 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/UserServiceTest.java @@ -16,15 +16,19 @@ import com.swyp.picke.domain.user.repository.UserTendencyScoreRepository; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -46,11 +50,17 @@ class UserServiceTest { @InjectMocks private UserService userService; + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + @Test @DisplayName("가장 최근 사용자를 반환한다") void findCurrentUser_returns_latest_user() { User user = createUser(1L, "testTag"); - when(userRepository.findTopByOrderByIdDesc()).thenReturn(Optional.of(user)); + setAuthenticatedUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); User result = userService.findCurrentUser(); @@ -60,7 +70,8 @@ void findCurrentUser_returns_latest_user() { @Test @DisplayName("사용자가 없으면 예외를 던진다") void findCurrentUser_throws_when_no_user() { - when(userRepository.findTopByOrderByIdDesc()).thenReturn(Optional.empty()); + setAuthenticatedUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> userService.findCurrentUser()) .isInstanceOf(CustomException.class) @@ -99,7 +110,8 @@ void updateMyProfile_updates_nickname_and_character() { User user = createUser(1L, "myTag"); UserProfile profile = createProfile(user, "oldNick", CharacterType.OWL); - when(userRepository.findTopByOrderByIdDesc()).thenReturn(Optional.of(user)); + setAuthenticatedUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(userProfileRepository.findByUserId(1L)).thenReturn(Optional.of(profile)); UpdateUserProfileRequest request = new UpdateUserProfileRequest("newNick", CharacterType.FOX); @@ -188,4 +200,10 @@ private UserProfile createProfile(User user, String nickname, CharacterType char .mannerTemperature(BigDecimal.valueOf(36.5)) .build(); } + + private void setAuthenticatedUser(Long userId) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(String.valueOf(userId), null, List.of()) + ); + } } diff --git a/src/test/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJobTest.java b/src/test/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJobTest.java new file mode 100644 index 00000000..a248024d --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJobTest.java @@ -0,0 +1,144 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.service.CreditService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BestCommentRewardJobTest { + + @Mock + private BattleRepository battleRepository; + + @Mock + private PerspectiveRepository perspectiveRepository; + + @Mock + private CreditService creditService; + + @InjectMocks + private BestCommentRewardJob job; + + @Test + @DisplayName("runDate 기준 14~20일 전 targetDate 윈도우로 배틀을 조회한다") + void run_queriesBattlesInTwoWeeksPriorWindow() { + LocalDate runDate = LocalDate.of(2026, 4, 13); + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull( + LocalDate.of(2026, 3, 24), LocalDate.of(2026, 3, 30), BattleStatus.PUBLISHED)) + .thenReturn(List.of()); + + job.run(runDate); + + verify(battleRepository).findByTargetDateBetweenAndStatusAndDeletedAtIsNull( + LocalDate.of(2026, 3, 24), LocalDate.of(2026, 3, 30), BattleStatus.PUBLISHED); + } + + @Test + @DisplayName("좋아요가 10개 미만이면 베댓 보상을 지급하지 않는다") + void run_skipsWhenPerspectiveHasLessThanMinimumLikes() { + Battle battle = battle(100L); + Perspective perspective = perspective(200L, battle, user(10L), 9); + + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull(any(), any(), any())) + .thenReturn(List.of(battle)); + when(perspectiveRepository.findByBattleIdAndStatusOrderByLikeCountDescCreatedAtDesc( + battle.getId(), PerspectiveStatus.PUBLISHED, PageRequest.of(0, 3))) + .thenReturn(List.of(perspective)); + + job.run(LocalDate.of(2026, 4, 13)); + + verify(creditService, never()).addCredit(any(), any(), any()); + } + + @Test + @DisplayName("좋아요 상위 3개 Perspective 작성자에게 BEST_COMMENT 를 지급한다") + void run_rewardsTopThreePerspectiveAuthors() { + Battle battle = battle(100L); + User author1 = user(10L); + User author2 = user(11L); + User author3 = user(12L); + Perspective top1 = perspective(200L, battle, author1, 20); + Perspective top2 = perspective(201L, battle, author2, 15); + Perspective top3 = perspective(202L, battle, author3, 10); + + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull(any(), any(), any())) + .thenReturn(List.of(battle)); + when(perspectiveRepository.findByBattleIdAndStatusOrderByLikeCountDescCreatedAtDesc( + battle.getId(), PerspectiveStatus.PUBLISHED, PageRequest.of(0, 3))) + .thenReturn(List.of(top1, top2, top3)); + + job.run(LocalDate.of(2026, 4, 13)); + + verify(creditService).addCredit(10L, CreditType.BEST_COMMENT, 200L); + verify(creditService).addCredit(11L, CreditType.BEST_COMMENT, 201L); + verify(creditService).addCredit(12L, CreditType.BEST_COMMENT, 202L); + verify(creditService, never()).addCredit(13L, CreditType.BEST_COMMENT, 203L); + } + + private Battle battle(Long id) { + Battle battle = Battle.builder() + .title("battle") + .status(BattleStatus.PUBLISHED) + .build(); + ReflectionTestUtils.setField(battle, "id", id); + return battle; + } + + private User user(Long id) { + User user = User.builder() + .userTag("user-" + id) + .nickname("nick") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private Perspective perspective(Long id, Battle battle, User user, int likeCount) { + BattleOption option = BattleOption.builder() + .battle(battle) + .label(BattleOptionLabel.A) + .title("A") + .stance("stance") + .build(); + + Perspective perspective = Perspective.builder() + .battle(battle) + .user(user) + .option(option) + .content("content") + .build(); + perspective.publish(); + while (perspective.getLikeCount() < likeCount) { + perspective.incrementLikeCount(); + } + ReflectionTestUtils.setField(perspective, "id", id); + return perspective; + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJobTest.java b/src/test/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJobTest.java new file mode 100644 index 00000000..2315ec3f --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJobTest.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MajorityWinRewardJobTest { + + @Mock private BattleRepository battleRepository; + @Mock private BattleOptionRepository battleOptionRepository; + @Mock private BattleVoteRepository battleVoteRepository; + @Mock private CreditService creditService; + + @InjectMocks + private MajorityWinRewardJob job; + + @Test + @DisplayName("runDate 기준 14~20일 전 targetDate 윈도우로 배틀을 조회한다") + void run_queriesBattlesInTwoWeeksPriorWindow() { + LocalDate runDate = LocalDate.of(2026, 4, 13); + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull( + LocalDate.of(2026, 3, 24), LocalDate.of(2026, 3, 30), BattleStatus.PUBLISHED)) + .thenReturn(List.of()); + + job.run(runDate); + + verify(battleRepository).findByTargetDateBetweenAndStatusAndDeletedAtIsNull( + LocalDate.of(2026, 3, 24), LocalDate.of(2026, 3, 30), BattleStatus.PUBLISHED); + } + + @Test + @DisplayName("최다 득표 옵션을 사전 투표한 사용자에게만 MAJORITY_WIN 을 지급한다") + void run_rewardsOnlyWinningOptionVoters() { + LocalDate runDate = LocalDate.of(2026, 4, 13); + Battle battle = battle(100L); + BattleOption winner = option(1L, battle); + BattleOption loser = option(2L, battle); + + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull(any(), any(), any())) + .thenReturn(List.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of(winner, loser)); + when(battleVoteRepository.countByBattleAndPreVoteOption(battle, winner)).thenReturn(10L); + when(battleVoteRepository.countByBattleAndPreVoteOption(battle, loser)).thenReturn(5L); + + User userA = user(11L); + User userB = user(12L); + User userC = user(13L); + BattleVote winVoteA = vote(userA, winner); + BattleVote winVoteB = vote(userB, winner); + BattleVote lossVoteC = vote(userC, loser); + when(battleVoteRepository.findAllByBattle(battle)).thenReturn(List.of(winVoteA, winVoteB, lossVoteC)); + + job.run(runDate); + + verify(creditService).addCredit(11L, CreditType.MAJORITY_WIN, 100L); + verify(creditService).addCredit(12L, CreditType.MAJORITY_WIN, 100L); + verify(creditService, never()).addCredit(eq(13L), eq(CreditType.MAJORITY_WIN), any()); + } + + private Battle battle(Long id) { + Battle b = Battle.builder().title("t").build(); + ReflectionTestUtils.setField(b, "id", id); + return b; + } + + private BattleOption option(Long id, Battle battle) { + BattleOption o = BattleOption.builder().battle(battle).title("t").build(); + ReflectionTestUtils.setField(o, "id", id); + return o; + } + + private User user(Long id) { + User u = User.builder().userTag("u" + id).role(UserRole.USER).status(UserStatus.ACTIVE).build(); + ReflectionTestUtils.setField(u, "id", id); + return u; + } + + private BattleVote vote(User user, BattleOption preOption) { + return BattleVote.builder().user(user).battle(preOption.getBattle()).preVoteOption(preOption).build(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java b/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java new file mode 100644 index 00000000..0b675986 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java @@ -0,0 +1,59 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.service.CreditService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WeeklyChargeJobTest { + + @Mock + private UserRepository userRepository; + + @Mock + private CreditService creditService; + + @InjectMocks + private WeeklyChargeJob job; + + @Test + @DisplayName("활성 사용자에게만 WEEKLY_CHARGE 를 지급한다") + void run_rewardsOnlyActiveUsers() { + User activeUser1 = user(1L); + User activeUser2 = user(2L); + LocalDate runDate = LocalDate.of(2026, 4, 13); + + when(userRepository.findAllByStatus(UserStatus.ACTIVE)).thenReturn(List.of(activeUser1, activeUser2)); + + job.run(runDate); + + verify(creditService).addCredit(1L, CreditType.WEEKLY_CHARGE, 20260413L); + verify(creditService).addCredit(2L, CreditType.WEEKLY_CHARGE, 20260413L); + } + + private User user(Long id) { + User user = User.builder() + .userTag("user-" + id) + .nickname("nick") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } +} diff --git a/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java new file mode 100644 index 00000000..acdba378 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java @@ -0,0 +1,120 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.user.service.UserBattleService; +import com.swyp.picke.domain.vote.dto.request.VoteRequest; +import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BattleVoteServiceImplTest { + + @Mock + private BattleVoteRepository battleVoteRepository; + + @Mock + private BattleService battleService; + + @Mock + private BattleOptionRepository battleOptionRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserBattleService userBattleService; + + @Mock + private CreditService creditService; + + @InjectMocks + private BattleVoteServiceImpl battleVoteService; + + @Test + @DisplayName("사후 투표 완료 시 참여 보상 크레딧을 지급한다") + void postVote_rewardsBattleParticipationCredit() { + Battle battle = battle(100L); + User user = user(10L); + BattleOption preOption = option(201L, battle, BattleOptionLabel.A); + BattleOption postOption = option(202L, battle, BattleOptionLabel.B); + BattleVote vote = BattleVote.builder() + .user(user) + .battle(battle) + .preVoteOption(preOption) + .build(); + ReflectionTestUtils.setField(vote, "id", 300L); + + when(battleService.findById(100L)).thenReturn(battle); + when(userRepository.findById(10L)).thenReturn(Optional.of(user)); + when(battleOptionRepository.findById(202L)).thenReturn(Optional.of(postOption)); + when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.of(vote)); + when(userBattleService.getUserBattleStatus(user, battle)) + .thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.POST_VOTE)); + + VoteResultResponse response = battleVoteService.postVote(100L, 10L, new VoteRequest(202L)); + + assertThat(vote.getPostVoteOption()).isEqualTo(postOption); + assertThat(response.voteId()).isEqualTo(300L); + assertThat(response.status()).isEqualTo(UserBattleStep.COMPLETED); + verify(userBattleService).upsertStep(user, battle, UserBattleStep.COMPLETED); + verify(creditService).addCredit(10L, CreditType.BATTLE_VOTE, 300L); + } + + private Battle battle(Long id) { + Battle battle = Battle.builder() + .title("battle") + .summary("summary") + .status(BattleStatus.PUBLISHED) + .build(); + ReflectionTestUtils.setField(battle, "id", id); + return battle; + } + + private User user(Long id) { + User user = User.builder() + .userTag("user-" + id) + .nickname("nick") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private BattleOption option(Long id, Battle battle, BattleOptionLabel label) { + BattleOption option = BattleOption.builder() + .battle(battle) + .label(label) + .title(label.name()) + .stance("stance") + .build(); + ReflectionTestUtils.setField(option, "id", id); + return option; + } +} diff --git a/src/test/java/com/swyp/picke/domain/vote/service/PollVoteServiceImplTest.java b/src/test/java/com/swyp/picke/domain/vote/service/PollVoteServiceImplTest.java new file mode 100644 index 00000000..0a0ac978 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/vote/service/PollVoteServiceImplTest.java @@ -0,0 +1,88 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.enums.PollOptionLabel; +import com.swyp.picke.domain.poll.enums.PollStatus; +import com.swyp.picke.domain.poll.repository.PollOptionRepository; +import com.swyp.picke.domain.poll.service.PollService; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.vote.dto.request.PollVoteRequest; +import com.swyp.picke.domain.vote.dto.response.PollVoteResponse; +import com.swyp.picke.domain.vote.entity.PollVote; +import com.swyp.picke.domain.vote.repository.PollVoteRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PollVoteServiceImplTest { + + @Mock + private PollService pollService; + + @Mock + private PollOptionRepository pollOptionRepository; + + @Mock + private PollVoteRepository pollVoteRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private PollVoteServiceImpl pollVoteService; + + @Test + @DisplayName("폴 신규 투표 시 totalParticipantsCount가 증가한다") + void submitPoll_increases_totalParticipants_on_new_vote() { + Long pollId = 1L; + Long userId = 10L; + Long optionId = 201L; + + Poll poll = Poll.builder() + .titlePrefix("찬성") + .titleSuffix("반대") + .targetDate(LocalDate.now()) + .status(PollStatus.PUBLISHED) + .build(); + ReflectionTestUtils.setField(poll, "id", pollId); + + PollOption optionA = PollOption.builder() + .poll(poll) + .label(PollOptionLabel.A) + .title("찬성") + .displayOrder(1) + .voteCount(0L) + .build(); + ReflectionTestUtils.setField(optionA, "id", optionId); + + User user = org.mockito.Mockito.mock(User.class); + + when(pollService.findById(pollId)).thenReturn(poll); + when(pollOptionRepository.findById(optionId)).thenReturn(Optional.of(optionA)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(pollVoteRepository.findByPollAndUser(poll, user)).thenReturn(Optional.empty()); + when(pollVoteRepository.save(any(PollVote.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll)).thenReturn(List.of(optionA)); + + PollVoteResponse response = pollVoteService.submitPoll(pollId, userId, new PollVoteRequest(optionId)); + + assertThat(poll.getTotalParticipantsCount()).isEqualTo(1L); + assertThat(optionA.getVoteCount()).isEqualTo(1L); + assertThat(response.totalCount()).isEqualTo(1L); + assertThat(response.selectedOptionId()).isEqualTo(optionId); + } +} \ No newline at end of file diff --git a/src/test/java/com/swyp/picke/domain/vote/service/QuizVoteServiceImplTest.java b/src/test/java/com/swyp/picke/domain/vote/service/QuizVoteServiceImplTest.java new file mode 100644 index 00000000..afb235cb --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/vote/service/QuizVoteServiceImplTest.java @@ -0,0 +1,88 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import com.swyp.picke.domain.quiz.repository.QuizOptionRepository; +import com.swyp.picke.domain.quiz.service.QuizService; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.vote.dto.request.QuizVoteRequest; +import com.swyp.picke.domain.vote.dto.response.QuizVoteResponse; +import com.swyp.picke.domain.vote.entity.QuizVote; +import com.swyp.picke.domain.vote.repository.QuizVoteRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class QuizVoteServiceImplTest { + + @Mock + private QuizService quizService; + + @Mock + private QuizOptionRepository quizOptionRepository; + + @Mock + private QuizVoteRepository quizVoteRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private QuizVoteServiceImpl quizVoteService; + + @Test + @DisplayName("퀴즈 신규 투표 시 totalParticipantsCount가 증가한다") + void submitQuiz_increases_totalParticipants_on_new_vote() { + Long quizId = 1L; + Long userId = 10L; + Long optionId = 101L; + + Quiz quiz = Quiz.builder() + .title("퀴즈") + .targetDate(LocalDate.now()) + .status(QuizStatus.PUBLISHED) + .build(); + ReflectionTestUtils.setField(quiz, "id", quizId); + + QuizOption optionA = QuizOption.builder() + .quiz(quiz) + .label(QuizOptionLabel.A) + .text("A") + .detailText("설명") + .isCorrect(true) + .displayOrder(1) + .build(); + ReflectionTestUtils.setField(optionA, "id", optionId); + + User user = org.mockito.Mockito.mock(User.class); + + when(quizService.findById(quizId)).thenReturn(quiz); + when(quizOptionRepository.findById(optionId)).thenReturn(Optional.of(optionA)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(quizVoteRepository.findByQuizAndUser(quiz, user)).thenReturn(Optional.empty()); + when(quizVoteRepository.save(any(QuizVote.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz)).thenReturn(List.of(optionA)); + when(quizVoteRepository.countByQuizAndSelectedOption(quiz, optionA)).thenReturn(1L); + + QuizVoteResponse response = quizVoteService.submitQuiz(quizId, userId, new QuizVoteRequest(optionId)); + + assertThat(quiz.getTotalParticipantsCount()).isEqualTo(1L); + assertThat(response.totalCount()).isEqualTo(1L); + assertThat(response.selectedOptionId()).isEqualTo(optionId); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index b637bc19..8a951d1f 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -70,9 +70,11 @@ app: baseUrl: http://localhost:8080 picke: baseUrl: http://localhost:8080 + local-storage: + root: ${java.io.tmpdir}/picke-local-storage-test media: ffmpeg: path: ffmpeg ffprobe: - path: ffprobe \ No newline at end of file + path: ffprobe