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/.gitignore b/.gitignore index 42445464..162d9972 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ ### Setting ### -.env \ No newline at end of file +.env +postgres_data/ +src/main/resources/application-local.yml +.claude \ No newline at end of file diff --git a/README.md b/README.md index 288c6e1f..42cc5c24 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ git push origin {생성한-브랜치-명} ## 📂 Project Structure ``` -com.swyp.app +com.swyp.picke ├── AppApplication.java │ ├── domain diff --git a/build.gradle b/build.gradle index 7343ef12..3bf7a58a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,52 +1,83 @@ plugins { - id 'java' - id 'org.springframework.boot' version '4.0.3' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.11' + id 'io.spring.dependency-management' version '1.1.7' } -group = 'com.swyp' +group = 'com.swyp.picke' version = '0.0.1-SNAPSHOT' -description = 'SWYP APP 4th' +description = 'PICKE - SWYP APP 4th' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() + google() } dependencies { // Web - implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + // JPA - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Security implementation 'org.springframework.boot:spring-boot-starter-security' - // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1' - // Lombok + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // HTTP Client (소셜 API 호출용) + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // AdMob SSV 검증을 위한 Tink 라이브러리 + implementation 'com.google.crypto.tink:apps-rewardedads:1.9.1' + testImplementation 'com.google.crypto.tink:apps-rewardedads:1.9.1' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16' + + // Google Cloud TTS + implementation 'com.google.cloud:google-cloud-texttospeech:2.58.0' + + // AWS S3 + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0' + + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // devTools - developmentOnly 'org.springframework.boot:spring-boot-devtools' - // PostgreSQL + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + // DB runtimeOnly 'org.postgresql:postgresql' - // Test - testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-starter-security-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + runtimeOnly 'com.h2database:h2' + + // Thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // Test + testRuntimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' // JPA, Web 테스트 기능 모두 포함 + testImplementation 'org.springframework.security:spring-security-test' // 시큐리티 전용 테스트 + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { - useJUnitPlatform() -} + useJUnitPlatform() +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..c2bde651 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + db: + image: postgres:15 + container_name: pique-postgres-db + restart: always + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "${DB_PORT}:5432" + volumes: + - ./postgres_data:/var/lib/postgresql/data + networks: + - pique-network + +networks: + pique-network: + driver: bridge \ 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 new file mode 100644 index 00000000..cfbcd2b3 --- /dev/null +++ b/docs/api-specs/battle-api.md @@ -0,0 +1,77 @@ +# 배틀(Battle) API 명세 + +기준 코드: `src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java`, +`src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java` + +## 1. 사용자 API + +### 1.1 오늘의 배틀 목록 +- `GET /api/v1/battles/today` +- 설명: 오늘 노출 대상 배틀 목록 조회 (최대 5개) + +### 1.2 배틀 목록 +- `GET /api/v1/battles` +- 쿼리 파라미터: + - `page` (기본값: `1`) + - `size` (기본값: `10`) + - `status` (기본값: `ALL`, 허용: `ALL`, `PENDING`, `PUBLISHED`, `REJECTED`, `ARCHIVED`) + +### 1.3 배틀 상세 +- `GET /api/v1/battles/{battleId}` +- 설명: 배틀 본문/선택지/태그/사용자 진행 상태 표시용 상세 조회 + +### 1.4 사용자 배틀 진행 상태 +- `GET /api/v1/battles/{battleId}/status` +- 설명: 현재 로그인 사용자 기준 배틀 진행 단계 조회 + +--- + +## 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}` + +--- + +## 3. 상태/정책 메모 + +- 배틀 전용 태그: + - 카테고리 태그: `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/comments-api.md b/docs/api-specs/comments-api.md new file mode 100644 index 00000000..0980f5f5 --- /dev/null +++ b/docs/api-specs/comments-api.md @@ -0,0 +1,265 @@ +# 댓글 API 명세서 + +--- + +## 설계 메모 + +- 관점에서의 댓글 관련한 API 입니다. +- 대댓글은 존재하지 않고 같은 최상위 뎁스의 댓글만 존재합니다. + +--- + +## 댓글 목록 조회 API + +### `GET /api/v1/perspectives/{perspective_id}/comments` + +- 댓글 목록 조회 (UI 상에서 아직 없어 임의로 기입함) + +#### 쿼리 파라미터 + +- 파라미터 | 타입 | 필수 | 설명 +- cursor | string | X | 커서 페이지네이션 +- size | number | X | 기본값 20 (임의 설정했음) + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "comment_id": "comment_001", + "user": { + "user_tag": "user@12312asb", + "nickname": "철학하는고양이", + "character_url": "https://cdn.pique.app/characters/cat.png" + }, + "content": "저도 같은 생각이에요.", + "is_mine": true, + "created_at": "2026-03-11T12:00:00Z" + } + ], + "next_cursor": "cursor_002", + "has_next": true + }, + "error": null +} +``` + +#### 예외 응답 `404 - 관점 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +--- +## 특정 댓글 삭제 API +### `DELETE /api/v1/perspectives/{perspective_id}/comments/{comment_id}` + +- 특정 댓글을 삭제 + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "success": true + }, + "error": null +} +``` + +#### 예외 응답 `404 - 댓글 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "COMMENT_NOT_FOUND", + "message": "존재하지 않는 댓글입니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `403 - 본인 댓글 아님` + +```json +{ + "statusCode": 403, + "data": null, + "error": { + "code": "FORBIDDEN_ACCESS", + "message": "본인 댓글만 삭제할 수 있습니다.", + "errors": [] + } +} +``` + +--- + +## 특정 댓글 수정 API +### `PATCH /api/v1/perspectives/{perspective_id}/comments/{comment_id}` + +- 특정 댓글을 삭제 + +#### Request Body + +```json +{ + "content": "수정된 댓글 내용이에요." +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "comment_id": "comment_001", + "content": "수정된 댓글 내용이에요.", + "updated_at": "2026-03-11T13:00:00Z" + }, + "error": null +} +``` + +#### 예외 응답 `404 - 댓글 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "COMMENT_NOT_FOUND", + "message": "존재하지 않는 댓글입니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `403 - 본인 댓글 아님` + +```json +{ + "statusCode": 403, + "data": null, + "error": { + "code": "COMMENT_FORBIDDEN", + "message": "본인 댓글만 삭제할 수 있습니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `400 - 내용 없음` + +```json +{ + "statusCode": 400, + "data": null, + "error": { + "code": "COMMON_INVALID_PARAMETER", + "message": "댓글 내용을 입력해주세요.", + "errors": [] + } +} +``` + +--- + + +## 특정 댓글 생성 API +### `DELETE /api/v1/perspectives/{perspective_id}/comments` + +- 특정 댓글을 삭제 + +#### Request Body + +```json +{ + "content": "저도 같은 생각이에요." +} +``` + +#### 성공 응답 `201 Created` + +```json +{ + "statusCode": 201, + "data": { + "comment_id": "comment_001", + "user": { + "user_tag": "user@12312asb", + "nickname": "철학하는고양이", + "character_url": "https://cdn.pique.app/characters/cat.png" + }, + "content": "저도 같은 생각이에요.", + "created_at": "2026-03-11T12:00:00Z" + }, + "error": null +} +``` + +#### 예외 응답 `404 - 관점 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `400 - 내용 없음` + +```json +{ + "statusCode": 400, + "data": null, + "error": { + "code": "COMMON_INVALID_PARAMETER", + "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` | 서버 오류 | + +--- + +## 댓글 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|-----------| +| `COMMENT_NOT_FOUND` | `404` | 존재하지 않는 댓글 | +| `COMMENT_FORBIDDEN` | `403` | 본인 댓글 아님 | +--- \ No newline at end of file diff --git a/docs/api-specs/home-api.md b/docs/api-specs/home-api.md new file mode 100644 index 00000000..32efc238 --- /dev/null +++ b/docs/api-specs/home-api.md @@ -0,0 +1,57 @@ +# 홈(Home) API 명세 + +기준 코드: `src/main/java/com/swyp/picke/domain/home/controller/HomeController.java`, +`src/main/java/com/swyp/picke/domain/home/service/HomeService.java` + +## 1. 홈 조회 + +- `GET /api/v1/home` +- 설명: 홈 화면 전체 섹션 데이터를 한 번에 조회 + +### 응답 구조 (`HomeResponse`) +- `newNotice`: 새 공지 존재 여부 +- `editorPicks`: 에디터 픽 배틀 목록 +- `trendingBattles`: 트렌딩 배틀 목록 +- `bestBattles`: 베스트 배틀 목록 +- `todayQuizzes`: 오늘의 퀴즈 목록 +- `todayVotes`: 오늘의 투표(Poll) 목록 +- `newBattles`: 신규 배틀 목록 + +--- + +## 2. todayQuizzes 응답 필드 + +`HomeTodayQuizResponse` + +- `battleId` (실제 Quiz ID) +- `title` +- `summary` (고정 문구) +- `participantsCount` +- `itemA` +- `itemADesc` +- `isCorrectA` +- `itemB` +- `itemBDesc` +- `isCorrectB` + +--- + +## 3. todayVotes 응답 필드 + +`HomeTodayVoteResponse` + +- `battleId` (실제 Poll ID) +- `titlePrefix` +- `titleSuffix` +- `summary` (고정 문구) +- `participantsCount` +- `options[]` + - `label` + - `title` + +--- + +## 4. 정렬/노출 메모 + +- 오늘의 퀴즈/투표는 서버에서 조회 및 정렬을 확정해 응답 +- 옵션 순서는 `displayOrder -> label -> id` 기준 오름차순으로 고정 diff --git a/docs/api-specs/likes-api.md b/docs/api-specs/likes-api.md new file mode 100644 index 00000000..601ca306 --- /dev/null +++ b/docs/api-specs/likes-api.md @@ -0,0 +1,175 @@ +# 좋아요 API 명세서 + +--- + +## 설계 메모 + +- 관점에 들어갈 좋아요 API 입니다. + +--- + +## 관점 좋아요 조회 API + +### `GET /api/v1/perspectives/{perspective_id}/likes` + +- 관점 좋아요 + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "perspective_id": "perspective_001", + "like_count": 13 + }, + "error": null +} +``` + +#### 예외 응답 `404 - 관점 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +```json +{ + "statusCode": 409, + "data": null, + "error": { + "code": "LIKE_ALREADY_EXISTS", + "message": "이미 좋아요를 누른 관점입니다.", + "errors": [] + } +} +``` + +--- +## 관점 좋아요 등록 API +### `POST /api/v1/perspectives/{perspective_id}/likes` + +- 좋아요 등록 + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "perspective_id": "perspective_001", + "like_count": 13, + "is_liked": true + }, + "error": null +} +``` + +#### 실패 응답 `404 - 관점 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +#### 실패 응답 `409 - 이미 좋아요 누름` + +```json +{ + "statusCode": 409, + "data": null, + "error": { + "code": "LIKE_ALREADY_EXISTS", + "message": "이미 좋아요를 누른 관점입니다.", + "errors": [] + } +} +``` + + +--- +## 관점에 등록됐던 좋아요 삭제 API +### `DELETE /api/v1/perspectives/{perspective_id}/likes` + +- 관점에 등록됐던 좋아요 취소 API + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "perspective_id": "perspective_001", + "like_count": 12, + "is_liked": false + }, + "error": null +} +``` + +#### 실패 응답 `404 - 관점 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +#### 실패 응답 `409 - 좋아요 누른 적 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "LIKE_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` | 서버 오류 | + +--- + +## 좋아요 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `LIKE_ALREADY_EXISTS` | `409` | 이미 좋아요 누른 관점 | +| `LIKE_NOT_FOUND` | `404` | 좋아요 누른 적 없는 관점 | + +--- \ No newline at end of file diff --git a/docs/api-specs/oauth-api.md b/docs/api-specs/oauth-api.md new file mode 100644 index 00000000..e665ff06 --- /dev/null +++ b/docs/api-specs/oauth-api.md @@ -0,0 +1,204 @@ +# OAuth API 명세서 + +## 1. 설계 메모 + +- OAuth API는 `snake_case` 필드명을 기준으로 합니다. +- 소셜 로그인은 OAuth 2.0 인가 코드 방식을 사용합니다. +- 로그인 성공 시 서비스 자체 `access_token`, `refresh_token`을 발급합니다. +- 사용자 프로필 생성 및 온보딩 상세 명세는 `user-api.md`를 기준으로 합니다. +- 외부 응답에서는 내부 PK인 `user_id`를 노출하지 않고 `user_tag`를 사용합니다. + +### 1.1 공통 요청 헤더 + +- `Content-Type: application/json` + - JSON 요청 바디가 있는 API에 사용합니다. +- `Authorization: Bearer {access_token}` + - 로그인 이후 인증이 필요한 API에 사용합니다. +- `X-Refresh-Token: {refresh_token}` + - Access Token 재발급 API에 사용합니다. + +### 1.2 토큰 사용 방식 + +로그인 성공 후 클라이언트는 `access_token`, `refresh_token`을 발급받습니다. + +- `access_token` + - 이후 인증이 필요한 API 호출 시 `Authorization: Bearer {access_token}` 헤더로 전달합니다. + - 예: `GET /api/v1/me/profile`, `PATCH /api/v1/me/settings`, `DELETE /api/v1/me` +- `refresh_token` + - API가 `401`과 `AUTH_ACCESS_TOKEN_EXPIRED`를 반환했을 때 `POST /api/v1/auth/refresh` 에서 사용합니다. + - `X-Refresh-Token: {refresh_token}` 헤더로 전달합니다. +- Access Token 만료 안내 + - 인증이 필요한 API는 Access Token이 만료되면 `401 Unauthorized`를 반환합니다. + - 에러 코드가 `AUTH_ACCESS_TOKEN_EXPIRED` 이면 클라이언트는 Refresh API를 호출해야 합니다. + - Refresh 성공 후 실패했던 요청을 새 `access_token`으로 1회 재시도합니다. +- Refresh Token 만료 안내 + - Refresh API가 `401`과 `AUTH_REFRESH_TOKEN_EXPIRED`를 반환하면 재로그인이 필요합니다. +- 재발급 성공 시 + - 새 `access_token`, 새 `refresh_token`으로 교체합니다. + - 이후 요청에는 기존 토큰 대신 새 토큰을 사용합니다. +- 로그아웃 시 + - `POST /api/v1/auth/logout` 호출 후 클라이언트에 저장된 토큰을 삭제합니다. +- 회원 탈퇴 시 + - `DELETE /api/v1/me` 호출 후 클라이언트에 저장된 토큰을 삭제합니다. + +### 1.3 로그인 흐름 + +**신규 사용자** + +1. `POST /api/v1/auth/login/{provider}` 호출 +2. 응답에서 `is_new_user = true` 확인 +3. 발급받은 `access_token`으로 온보딩 API 호출 +4. `POST /api/v1/onboarding/profile` 완료 후 일반 사용자 API 사용 + +**기존 사용자** + +1. `POST /api/v1/auth/login/{provider}` 호출 +2. 응답에서 `is_new_user = false` 확인 +3. 발급받은 `access_token`으로 바로 사용자 API 호출 + +--- + +## 2. 인증 API + +### 2.1 `POST /api/v1/auth/login/{provider}` + +소셜 인가 코드를 이용해 로그인 및 계정을 생성합니다. + +- `{provider}`: `kakao`, `google` +- 상태가 `BANNED` 또는 `SUSPENDED`인 사용자는 `403`을 반환합니다. +- 신규 사용자는 `status = PENDING`, `is_new_user = true` 상태로 응답합니다. + +요청 헤더: + +- `Content-Type: application/json` + +요청: + +```json +{ + "authorization_code": "string", + "redirect_uri": "string" +} +``` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "access_token": "eyJhbGciOiJIUzI...", + "refresh_token": "def456-ghi789...", + "user_tag": "a7k2m9q1", + "is_new_user": true, + "status": "PENDING" + }, + "error": null +} +``` + +--- + +### 2.2 `POST /api/v1/auth/refresh` + +만료된 Access Token을 Refresh Token으로 재발급합니다. + +요청 헤더: + +- `Content-Type: application/json` +- `X-Refresh-Token: {refresh_token}` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "access_token": "new_eyJhbGciOiJIUzI...", + "refresh_token": "new_def456-ghi789..." + }, + "error": null +} +``` + +--- + +### 2.3 `POST /api/v1/auth/logout` + +현재 로그인된 사용자의 Refresh Token을 삭제하여 로그아웃 처리합니다. + +요청 헤더: + +- `Content-Type: application/json` +- `Authorization: Bearer {access_token}` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "logged_out": true + }, + "error": null +} +``` + +--- + +### 2.4 `DELETE /api/v1/me` + +현재 로그인된 사용자의 계정을 탈퇴 처리합니다. + +- `users`, `user_social_accounts`, `auth_refresh_tokens` 연관 데이터를 함께 처리합니다. +- 사용자 도메인 상세 정리는 `user` 정책에 따라 함께 처리합니다. +- 탈퇴 사유는 별도 이력 테이블에 저장합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +요청 바디: + +```json +{ + "reason": "NO_TIME" +} +``` + +가능한 `reason` 값: + +- `NOT_USED_OFTEN` +- `NO_INTERESTING_BATTLES` +- `BATTLE_STYLE_NOT_FIT` +- `SERVICE_INCONVENIENT` +- `NO_TIME` +- `OTHER` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "withdrawn": true + }, + "error": null +} +``` + +--- + +## 3. 에러 코드 + +### 3.1 공통 에러 코드 + +| HTTP | 에러 코드 | 설명 | +|------|-----------|------| +| `400` | `COMMON_INVALID_PARAMETER` | 요청 파라미터가 잘못되었습니다. | +| `401` | `AUTH_INVALID_CODE` | 유효하지 않은 소셜 인가 코드 | +| `401` | `AUTH_ACCESS_TOKEN_EXPIRED` | Access Token 만료 — Refresh 필요 | +| `401` | `AUTH_REFRESH_TOKEN_EXPIRED` | Refresh Token 만료 — 재로그인 필요 | +| `403` | `USER_BANNED` | 영구 제재된 사용자 | +| `403` | `USER_SUSPENDED` | 일정 기간 이용 정지된 사용자 | +| `500` | `INTERNAL_SERVER_ERROR` | 서버 오류 | diff --git a/docs/api-specs/perspectives-api.md b/docs/api-specs/perspectives-api.md new file mode 100644 index 00000000..5b445aa8 --- /dev/null +++ b/docs/api-specs/perspectives-api.md @@ -0,0 +1,375 @@ +# 관점 API 명세서 + +--- + +## 설계 메모 + +- 관점 API 입니다. +- 현재 Creator 뱃지 부분이 ERD 상에선 보이지 않는데 확인 필요 + +### 관점 상태(status) 흐름 + +| status | 설명 | +|--------|------| +| `PENDING` | 생성/수정 직후, GPT 검수 대기 중 | +| `PUBLISHED` | GPT 검수 통과, 목록에 노출됨 | +| `REJECTED` | GPT 검수 거절 (욕설/공격적 표현 포함) | +| `MODERATION_FAILED` | GPT API 호출 실패 (네트워크 오류 등), 재시도 가능 | + +``` +생성/수정 → PENDING → GPT 호출 성공 → APPROVED → PUBLISHED + → REJECT → REJECTED + → GPT 호출 실패 → 1회 재시도 + → 재시도 실패 → MODERATION_FAILED + ↓ (재시도 버튼) + PENDING → GPT 재호출 +``` + +--- + +## 관점 생성 API + +### `POST /api/v1/battles/{battle_id}/perspectives` + +- 특정 배틀에 대한 관점 생성 (비동기) + +#### Request Body + +```json +{ + "content": "자기결정권은 가장 기본적인 인권이라고 생각해요." +} +``` + +#### 성공 응답 `201 Created` + +```json +{ + "statusCode": 201, + "data": { + "perspective_id": "perspective_001", + "status": "PENDING", + "created_at": "2026-03-11T12:00:00Z" + }, + "error": null +} +``` + +#### 예외 응답 `404 - 존재하지 않는 배틀` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "BATTLE_NOT_FOUND", + "message": "존재하지 않는 배틀입니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `409 - 이미 관점 작성함` + +```json +{ + "statusCode": 409, + "data": null, + "error": { + "code": "PERSPECTIVE_ALREADY_EXISTS", + "message": "이미 관점을 작성한 배틀입니다.", + "errors": [] + } +} +``` + + + +--- +## 관점 리스트 조회 API +### `GET /api/v1/battles/{battle_id}/perspectives` + +- 특정 배틀에 대한 관점 리스트 조회 + +#### 쿼리 파라미터 + +- 파라미터 | 타입 | 필수 | 설명 +- cursor | string | X | 커서 페이지네이션 +- size | number | X | 기본값 20 (임의 설정했음) +- option_label | string | X | A or B 투표 옵션 필터 + + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "perspective_id": "perspective_001", + "user": { + "user_tag": "user@12312asb", + "nickname": "철학하는고양이", + "character_url": "https://cdn.pique.app/characters/cat.png" + }, + "option": { + "option_id": "option_A", + "label": "A", + "title": "찬성" + }, + "content": "자기결정권은 가장 기본적인 인권이라고 생각해요.", + "like_count": 12, + "comment_count": 3, + "is_liked": false, + "created_at": "2026-03-11T12:00:00Z" + } + ], + "next_cursor": "cursor_002", + "has_next": true + }, + "error": null +} +``` + +#### 예외 응답 `404 - 존재하지 않는 배틀` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "BATTLE_NOT_FOUND", + "message": "존재하지 않는 배틀입니다.", + "errors": [] + } +} +``` + +--- +## 내 PENDING 관점 조회 API +### `GET /api/v1/battles/{battle_id}/perspectives/me/pending` + +- 특정 배틀에서 내가 작성한 관점이 PENDING 상태인 경우 반환합니다. +- UI 상단에 검수 대기 중인 내 관점을 표시하기 위한 API입니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "perspective_id": "perspective_001", + "content": "자기결정권은 가장 기본적인 인권이라고 생각해요.", + "status": "PENDING", + "created_at": "2026-03-11T12:00:00Z" + }, + "error": null +} +``` + +#### 예외 응답 `404 - PENDING 관점 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +--- +## 관점 삭제 API +### `DELETE /api/v1/perspectives/{perspective_id}` + +- 특정 배틀에 대한 내가 쓴 관점 삭제 + + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "success": true + }, + "error": null +} +``` + +#### 예외 응답 `404 - 관점 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `403 - 본인 관점 아님` + +```json +{ + "statusCode": 403, + "data": null, + "error": { + "code": "FORBIDDEN_ACCESS", + "message": "본인 관점만 삭제할 수 있습니다.", + "errors": [] + } +} +``` + + +--- +## 관점 수정 API +### `PATCH /api/v1/perspectives/{perspective_id}` + +- 특정 배틀에 대한 내가 쓴 관점 수정 + +#### Request Body + +```json +{ + "content": "수정된 관점 내용입니다." +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "perspective_id": "perspective_001", + "content": "수정된 관점 내용입니다.", + "updated_at": "2026-03-11T13:00:00Z" + }, + "error": null +} +``` + +#### 예외 응답 `404 - 존재하지 않는 관점` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `403 - 본인 관점 아님` +```json +{ + "statusCode": 403, + "data": null, + "error": { + "code": "FORBIDDEN_ACCESS", + "message": "본인 관점만 수정할 수 있습니다.", + "errors": [] + } +} +``` + +--- + +## 관점 검수 재시도 API +### `POST /api/v1/perspectives/{perspective_id}/moderation/retry` + +- `MODERATION_FAILED` 상태의 관점에 대해 GPT 검수를 다시 요청합니다. +- 재시도 후 상태는 `PENDING`으로 변경되며, GPT 응답에 따라 `PUBLISHED` / `REJECTED` / `MODERATION_FAILED`로 전환됩니다. +- 재시도도 실패하면 다시 `MODERATION_FAILED`로 남습니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": null, + "error": null +} +``` + +#### 예외 응답 `404 - 관점 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "PERSPECTIVE_NOT_FOUND", + "message": "존재하지 않는 관점입니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `400 - 검수 실패 상태 아님` + +```json +{ + "statusCode": 400, + "data": null, + "error": { + "code": "PERSPECTIVE_400", + "message": "검수 실패 상태의 관점이 아닙니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `403 - 본인 관점 아님` + +```json +{ + "statusCode": 403, + "data": null, + "error": { + "code": "PERSPECTIVE_403", + "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` | 서버 오류 | + +--- + +## 관점 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|-------------| +| `PERSPECTIVE_NOT_FOUND` | `404` | 존재하지 않는 관점 | +| `PERSPECTIVE_ALREADY_EXISTS` | `409` | 해당 배틀에 이미 관점 작성함 | +| `PERSPECTIVE_FORBIDDEN` | `403` | 본인 관점 아님 | +| `PERSPECTIVE_POST_VOTE_REQUIRED` | `409` | 사후 투표 미완료 | +| `PERSPECTIVE_400` | `400` | 검수 실패 상태의 관점이 아님 (재시도 불가) | + +--- \ No newline at end of file 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 new file mode 100644 index 00000000..86063beb --- /dev/null +++ b/docs/api-specs/recommendations-api.md @@ -0,0 +1,18 @@ +# 추천(Recommendation) API 명세 + +기준 코드: `src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java` + +## 1. 흥미 기반 추천 + +- `GET /api/v1/battles/{battleId}/recommendations/interesting` +- 설명: 특정 배틀 기준으로 흥미 유사 배틀 목록 조회 +- 인증 사용자면 개인화 가중치가 적용될 수 있음 + +### 응답 (`RecommendationListResponse`) 요약 +- `items[]` + - `battleId` + - `title` + - `summary` + - `thumbnailUrl` + - `tags` + - `options` diff --git a/docs/api-specs/reward-api.md b/docs/api-specs/reward-api.md new file mode 100644 index 00000000..31d05d7f --- /dev/null +++ b/docs/api-specs/reward-api.md @@ -0,0 +1,112 @@ +# 보상(Reward) API 명세서 + +## 1. 설계 메모 + +- AdMob의 **SSV(Server-Side Verification)** 콜백 수신을 위한 API입니다. +- 모든 필드명은 AdMob 가이드라인에 따라 `snake_case`를 사용합니다. +- **중복 지급 방지**: `transaction_id`를 고유 식별자로 사용하여 동일 요청 재유입 시 차단(Idempotency)합니다. +- **유저 식별**: `custom_data` 필드에 담긴 값을 내부 `user_id`로 매핑하여 처리합니다. +- **타입 검증**: `reward_item` 값은 내부 `RewardType` Enum과 매핑하며, 정의되지 않은 값(예: "123")은 에러 처리합니다. +- **데이터 보존**: 보상 요청의 성공 이력을 `ad_reward_history` 테이블에 적재합니다. +- 사용자 크레딧 히스토리 조회는 별도 `/api/v1/me/credits/history` API로 분리되어 있으며 이 문서 범위에 포함하지 않습니다. + +--- + +## 2. AdMob 보상 콜백 API + +### 2.1 `GET /api/v1/admob/reward` + +광고 시청 완료 후 구글 서버에서 보내는 보상 지급 콜백 수신. + +**쿼리 파라미터:** + +| Parameter | Type | Required | 설명 | +|-----------|:----:|:---:|------| +| `ad_unit_id` | `String` | Y | 광고 단위 ID | +| `custom_data` | `String` | Y | 유저 식별자 (내부 User ID) | +| `reward_amount` | `int` | Y | 보상 수량 | +| `reward_item` | `String` | Y | 보상 아이템 이름 (e.g., "POINT") | +| `timestamp` | `long` | Y | 요청 생성 시간 | +| `transaction_id` | `String` | Y | **중복 방지용 고유 ID** | +| `signature` | `String` | N | 검증용 서명 | +| `key_id` | `String` | N | 검증용 공개키 ID | + +**응답 (성공):** + +```json +{ + "statusCode": 200, + "data": { + "reward_status": "OK" + } +} +``` + +**응답 (중복 요청 시):** + +```JSON + +{ + "statusCode": 200, + "data": { + "reward_status": "Already Processed" + } +} +``` + +--- + +## 3. 에러 코드 + +### 3.1 보상 관련 에러 코드 + +### 🚨 보상 API 에러 응답 JSON 샘플 + +**1. 유저를 찾을 수 없을 때 (REWARD_INVALID_USER)** +- 상황: `custom_data`로 넘어온 ID가 DB에 없는 유저일 경우 +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "REWARD_INVALID_USER", + "message": "해당 유저를 찾을 수 없습니다. (custom_data: 1)" + } +} +``` + +**2. 잘못된 보상 타입일 때 (REWARD_INVALID_TYPE)** +- 상황: `reward_item`에 Enum에 정의되지 않은 값(예: "123")이 들어온 경우 +```json +{ + "statusCode": 400, + "data": null, + "error": { + "code": "REWARD_INVALID_TYPE", + "message": "지원하지 않는 reward_item 타입입니다. (입력값: 123)" + } +} +``` + +**3. 서명 검증 실패 시 (REWARD_VERIFICATION_FAILED)** +- 상황: AdMob이 보낸 `signature`가 올바르지 않아 위변조가 의심될 경우 +```json +{ + "statusCode": 401, + "data": null, + "error": { + "code": "REWARD_INVALID_SIGNATURE", + "message": "AdMob 서명 검증에 실패하였습니다. 요청의 유효성을 확인하세요." + } +} +``` +--- + +## 4. 공통 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|-------------------------------------| +| `REWARD_INVALID_USER` | `404` | custom_data에 해당하는 유저가 존재하지 않음 | +| `REWARD_INVALID_TYPE` | `400` | 지원하지 않는 reward_item 타입 (Enum 미매칭) | +| `REWARD_INVALID_SIGNATURE` | `401` | AdMob 서명(Signature) 검증 실패 또는 위변조 의심 | +--- diff --git a/docs/api-specs/scenario-api.md b/docs/api-specs/scenario-api.md new file mode 100644 index 00000000..5d211914 --- /dev/null +++ b/docs/api-specs/scenario-api.md @@ -0,0 +1,60 @@ +# 시나리오(Scenario) API 명세 + +기준 코드: +`src/main/java/com/swyp/picke/domain/scenario/controller/ScenarioController.java` +`src/main/java/com/swyp/picke/domain/admin/controller/AdminScenarioController.java` + +## 1. 사용자 API + +### 1.1 배틀 시나리오 조회 +- `GET /api/v1/battles/{battleId}/scenario` +- 설명: 배틀 상세에서 시나리오 노드/스크립트/분기 옵션 조회 + +--- + +## 2. 관리자 API + +### 2.1 배틀 기준 시나리오 상세 조회 +- `GET /api/v1/admin/battles/{battleId}/scenario` + +### 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`) + +### 2.3 시나리오 본문 수정 +- `PUT /api/v1/admin/scenarios/{scenarioId}` +- 설명: 노드/스크립트/분기/보이스 설정 포함 전체 콘텐츠 수정 + +### 2.4 시나리오 상태 수정 +- `PATCH /api/v1/admin/scenarios/{scenarioId}` +- 요청 본문: +```json +{ + "status": "PUBLISHED" +} +``` + +### 2.5 시나리오 삭제 +- `DELETE /api/v1/admin/scenarios/{scenarioId}` + +--- + +## 3. 상태/동작 메모 + +- 임시저장(`DRAFT`) 상태에서는 대본/설정은 DB 저장, 발행(`PUBLISHED`) 시점에 TTS 파이프라인 수행 +- 발행 후 수정 시에는 변경된 스크립트 조각만 재생성하고 병합 오디오를 갱신 diff --git a/docs/api-specs/tag-api.md b/docs/api-specs/tag-api.md new file mode 100644 index 00000000..ed2fc311 --- /dev/null +++ b/docs/api-specs/tag-api.md @@ -0,0 +1,58 @@ +# 태그(Tag) API 명세 + +기준 코드: +`src/main/java/com/swyp/picke/domain/tag/controller/TagController.java` +`src/main/java/com/swyp/picke/domain/admin/controller/AdminTagController.java` + +## 1. 태그 타입 + +`TagType` +- `CATEGORY` +- `PHILOSOPHER` +- `VALUE` + +--- + +## 2. 사용자 API + +### 2.1 태그 목록 조회 +- `GET /api/v1/tags` +- 쿼리 파라미터: + - `type` (선택): `CATEGORY`, `PHILOSOPHER`, `VALUE` + +--- + +## 3. 관리자 API + +### 3.1 태그 생성 +- `POST /api/v1/admin/tags` +- 요청 본문: +```json +{ + "name": "자유", + "type": "VALUE" +} +``` + +### 3.2 태그 수정 +- `PATCH /api/v1/admin/tags/{tagId}` +- 요청 본문: +```json +{ + "name": "연대", + "type": "VALUE" +} +``` + +### 3.3 태그 삭제 +- `DELETE /api/v1/admin/tags/{tagId}` + +--- + +## 4. 매핑 정책(중요) + +- 현재 태그는 **배틀 도메인에서만 사용** +- 매핑 테이블: + - `battle_tags` (배틀-카테고리) + - `battle_option_tags` (배틀 옵션-철학자/가치관) +- 퀴즈/폴은 태그 매핑을 사용하지 않음 diff --git a/docs/api-specs/user-api.md b/docs/api-specs/user-api.md new file mode 100644 index 00000000..917fa619 --- /dev/null +++ b/docs/api-specs/user-api.md @@ -0,0 +1,459 @@ +# 내 정보 / 사용자 API 명세서 + +## 1. 설계 메모 + +- 이 문서는 사용자 프로필 수정과 `/api/v1/me/**` 계열 API를 함께 다룹니다. +- 문서 전반은 `snake_case` 필드명을 기준으로 합니다. +- 외부 응답에서는 내부 PK인 `user_id`를 노출하지 않고 `user_tag`를 사용합니다. +- `nickname`은 중복 허용 프로필명입니다. +- `user_tag`는 고유한 공개 식별자이며 저장 시 `@` 없이 관리합니다. +- `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`, 해시태그 문구를 내려주지 않습니다. +- 현재 크레딧(`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 공통 프로필 응답 필드 + +| 필드 | 타입 | 설명 | +|------|------|------| +| `user_tag` | string | 외부 공개용 사용자 식별자 | +| `nickname` | string | 중복 허용 프로필명 | +| `character_type` | string | 캐릭터 enum 값 | +| `manner_temperature` | number | 사용자 매너 온도 | + +### 1.2 공통 enum 값 + +| 필드 | 가능한 값 | +|------|-----------| +| `philosopher_type` | `SOCRATES \| PLATO \| ARISTOTLE \| KANT \| NIETZSCHE \| MARX \| SARTRE \| CONFUCIUS \| LAOZI \| BUDDHA` | +| `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` | + +--- + +## 2. 프로필 API + +### 2.1 `PATCH /api/v1/me/profile` + +닉네임 및 캐릭터 수정. + +요청: + +```json +{ + "nickname": "생각하는펭귄", + "character_type": "PENGUIN" +} +``` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "user_tag": "a7k2m9q1", + "nickname": "생각하는펭귄", + "character_type": "PENGUIN", + "updated_at": "2026-03-08T12:00:00Z" + }, + "error": null +} +``` + +--- + +## 3. 마이페이지 조회 API + +### 3.1 `GET /api/v1/me/mypage` + +마이페이지 상단에 필요한 집계 데이터 조회. + +응답: + +```json +{ + "statusCode": 200, + "data": { + "profile": { + "user_tag": "a7k2m9q1", + "nickname": "생각하는올빼미", + "character_type": "OWL", + "manner_temperature": 36.5 + }, + "philosopher": { + "philosopher_type": "SOCRATES" + }, + "tier": { + "tier_code": "WANDERER", + "tier_label": "방랑자", + "current_point": 40 + } + }, + "error": null +} +``` + +### 3.2 `GET /api/v1/me/recap` + +상세 리캡 정보 조회. + +응답: + +```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": [ + { + "rank": 1, + "tag_name": "철학", + "participation_count": 20 + }, + { + "rank": 2, + "tag_name": "문학", + "participation_count": 13 + }, + { + "rank": 3, + "tag_name": "예술", + "participation_count": 8 + }, + { + "rank": 4, + "tag_name": "사회", + "participation_count": 5 + } + ] + } + }, + "error": null +} +``` + +### 3.3 `GET /api/v1/me/battle-records` + +내 배틀 기록 조회. +찬성/반대 탭을 따로 나누지 않고 하나의 목록으로 반환합니다. +각 item의 `vote_side`가 실제 구분자입니다. + +쿼리 파라미터: + +- `offset`: 선택, 0-based 시작 위치 +- `size`: 선택 +- `vote_side`: 각 item의 구분자이며 가능한 값은 `PRO | CON` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "battle_id": "battle_001", + "record_id": "vote_001", + "vote_side": "PRO", + "title": "안락사 도입, 찬성 vs 반대", + "summary": "인간에게 품위 있는 죽음을 허용해야 할까?", + "created_at": "2026-03-07T18:30:00" + } + ], + "next_offset": 20, + "has_next": true + }, + "error": null +} +``` + +### 3.4 `GET /api/v1/me/content-activities` + +내 댓글/좋아요 기반 콘텐츠 활동 조회. +댓글/좋아요 탭을 따로 나누지 않고 하나의 목록으로 반환합니다. +각 item의 `activity_type`이 실제 구분자입니다. + +쿼리 파라미터: + +- `offset`: 선택, 0-based 시작 위치 +- `size`: 선택 +- `activity_type`: 각 item의 구분자이며 가능한 값은 `COMMENT | LIKE` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "activity_id": "comment_001", + "activity_type": "COMMENT", + "perspective_id": "perspective_001", + "battle_id": "battle_001", + "battle_title": "안락사 도입, 찬성 vs 반대", + "author": { + "user_tag": "a7k2m9q1", + "nickname": "사색하는고양이", + "character_type": "CAT" + }, + "stance": "반대", + "content": "제도가 무서운 건, 사회적 압력이 선택을 의무로 바꿀 수 있다는 거예요.", + "like_count": 1340, + "created_at": "2026-03-08T12:00:00" + } + ], + "next_offset": 20, + "has_next": true + }, + "error": null +} +``` + +### 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` + +마이페이지 알림 설정 조회. + +응답: + +```json +{ + "statusCode": 200, + "data": { + "new_battle_enabled": false, + "battle_result_enabled": true, + "comment_reply_enabled": true, + "new_comment_enabled": false, + "content_like_enabled": false, + "marketing_event_enabled": true + }, + "error": null +} +``` + +### 3.9 `PATCH /api/v1/me/notification-settings` + +마이페이지 알림 설정 부분 수정. + +요청: + +```json +{ + "battle_result_enabled": true, + "marketing_event_enabled": false +} +``` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "new_battle_enabled": false, + "battle_result_enabled": true, + "comment_reply_enabled": true, + "new_comment_enabled": false, + "content_like_enabled": false, + "marketing_event_enabled": false + }, + "error": null +} +``` + +### 3.10 `GET /api/v1/me/notices` + +공지/이벤트 목록 조회. + +쿼리 파라미터: + +- `type`: `NOTICE | EVENT` + +응답: + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "notice_id": "notice_001", + "type": "NOTICE", + "title": "3월 신규 딜레마 업데이트", + "body_preview": "매일 새로운 딜레마가 추가돼요.", + "is_pinned": true, + "published_at": "2026-03-01T00:00:00" + } + ] + }, + "error": null +} +``` + +### 3.11 `GET /api/v1/me/notices/{noticeId}` + +공지/이벤트 상세 조회. + +응답: + +```json +{ + "statusCode": 200, + "data": { + "notice_id": "notice_001", + "type": "NOTICE", + "title": "3월 신규 딜레마 업데이트", + "body": "매일 새로운 딜레마가 추가돼요.", + "is_pinned": true, + "published_at": "2026-03-01T00:00:00" + }, + "error": null +} +``` + +--- + +## 4. 에러 코드 + +### 4.1 공통 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | +| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | +| `AUTH_ACCESS_TOKEN_EXPIRED` | `401` | Access Token 만료 | +| `AUTH_REFRESH_TOKEN_EXPIRED` | `401` | Refresh Token 만료 - 재로그인 필요 | +| `USER_BANNED` | `403` | 영구 제재된 사용자 | +| `USER_SUSPENDED` | `403` | 일정 기간 이용 정지된 사용자 | +| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | + +### 4.2 사용자 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `USER_NOT_FOUND` | `404` | 존재하지 않는 사용자 | +| `ONBOARDING_ALREADY_COMPLETED` | `409` | 이미 온보딩이 완료된 사용자 | diff --git a/docs/api-specs/vote-api.md b/docs/api-specs/vote-api.md new file mode 100644 index 00000000..6420da5e --- /dev/null +++ b/docs/api-specs/vote-api.md @@ -0,0 +1,91 @@ +# 투표(Vote) API 명세 + +기준 코드: `src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java` + +## 1. 퀴즈 투표 + +### 1.1 퀴즈 응답 제출 +- `POST /api/v1/battles/{battleId}/quiz-vote` +- 요청 본문: +```json +{ + "optionId": 1 +} +``` + +### 1.2 내 퀴즈 투표 조회 +- `GET /api/v1/battles/{battleId}/quiz-vote/me` + +> 참고: 현재 경로 변수 이름은 `battleId`지만 내부적으로 `quizId`로 사용됩니다. + +--- + +## 2. Poll 투표 + +### 2.1 Poll 선택 제출 +- `POST /api/v1/battles/{battleId}/poll-vote` +- 요청 본문: +```json +{ + "optionId": 1 +} +``` + +### 2.2 내 Poll 투표 조회 +- `GET /api/v1/battles/{battleId}/poll-vote/me` + +> 참고: 현재 경로 변수 이름은 `battleId`지만 내부적으로 `pollId`로 사용됩니다. + +--- + +## 3. 배틀 사전/사후 투표 + +### 3.1 사전 투표 +- `POST /api/v1/battles/{battleId}/votes/pre` +- 요청 본문: +```json +{ + "optionId": 1 +} +``` + +### 3.2 사후 투표 +- `POST /api/v1/battles/{battleId}/votes/post` +- 요청 본문: +```json +{ + "optionId": 1 +} +``` + +### 3.3 TTS 청취 완료 +- `POST /api/v1/battles/{battleId}/votes/tts-complete` + +### 3.4 배틀 투표 통계 +- `GET /api/v1/battles/{battleId}/vote-stats` + +### 3.5 내 배틀 투표 이력 +- `GET /api/v1/battles/{battleId}/votes/me` + +--- + +## 4. 관리자 투표 데이터 정리 API + +### 4.1 배틀 투표 기록 삭제 +- `DELETE /api/v1/admin/votes/battle/{battleId}` + +### 4.2 퀴즈 투표 기록 삭제 +- `DELETE /api/v1/admin/votes/quiz/{battleId}` + +### 4.3 Poll 투표 기록 삭제 +- `DELETE /api/v1/admin/votes/poll/{battleId}` + +--- + +## 5. 응답 DTO 메모 + +- 퀴즈 투표 응답: `QuizVoteResponse` + - `selectedOptionId`, `totalCount`, `stats[].isCorrect` 포함 +- Poll 투표 응답: `PollVoteResponse` + - `selectedOptionId`, `totalCount`, `stats[].ratio` 포함 +- 배틀 투표 응답: `VoteResultResponse`, `VoteStatsResponse`, `MyVoteResponse` diff --git a/docs/db/20260326_alter_credit_histories_reference_id_not_null.sql b/docs/db/20260326_alter_credit_histories_reference_id_not_null.sql new file mode 100644 index 00000000..2c90327a --- /dev/null +++ b/docs/db/20260326_alter_credit_histories_reference_id_not_null.sql @@ -0,0 +1,13 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM credit_histories + WHERE reference_id IS NULL + ) THEN + RAISE EXCEPTION 'credit_histories.reference_id contains NULL values. Backfill the rows before applying NOT NULL.'; + END IF; +END $$; + +ALTER TABLE credit_histories + ALTER COLUMN reference_id SET NOT NULL; diff --git a/docs/erd/admob.puml b/docs/erd/admob.puml new file mode 100644 index 00000000..a14df6b6 --- /dev/null +++ b/docs/erd/admob.puml @@ -0,0 +1,29 @@ +@startuml +!theme plain +skinparam Linetype ortho + +' 1. 서비스 사용자 참조 +entity "users" { + * id : LONG <> + -- + user_tag : VARCHAR(30) <> + status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +' 2. 사용자 광고 이력 테이블 +entity "ad_reward_history" { + * id : LONG <> + -- + user_id : LONG <> + transaction_id : VARCHAR(255) UNIQUE + reward_amount : INT NOT NULL + reward_type : enum('POINT', 'ITEM') + created_at : TIMESTAMP +} + +' 관계 설정 +users ||--o{ ad_reward_history : "사용자 보상 이력" + +@enduml diff --git a/docs/erd/battle.puml b/docs/erd/battle.puml new file mode 100644 index 00000000..70fcc755 --- /dev/null +++ b/docs/erd/battle.puml @@ -0,0 +1,62 @@ +@startuml battle +hide circle +hide methods +skinparam linetype ortho + +entity "users" as users { + * id : BIGINT <> + -- + email : VARCHAR(255) + nickname : VARCHAR(50) + role : VARCHAR(20) + status : VARCHAR(20) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +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 + 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" as battle_options { + * id : BIGINT <> + -- + battle_id : BIGINT <> + label : VARCHAR(10) + title : VARCHAR(100) + stance : VARCHAR(255) + representative : VARCHAR(100) + vote_count : BIGINT + image_url : VARCHAR(500) + display_order : INT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +users ||--o{ battles : creates +battles ||--o{ battle_options : has + +note right of battles + Battle 도메인 전용 테이블 + - 퀴즈/폴 필드와 분리됨 + - target_date는 서버 정책으로 자동 관리 +end note + +@enduml diff --git a/docs/erd/comment.puml b/docs/erd/comment.puml new file mode 100644 index 00000000..550193cb --- /dev/null +++ b/docs/erd/comment.puml @@ -0,0 +1,36 @@ +@startuml perspective_comments +hide circle +hide methods +skinparam linetype ortho + +entity "users\n사용자" as users { + * id : BIGINT <> +} + +entity "PERSPECTIVES\n관점" as perspectives { + * id : Long <> +} + +entity "PERSPECTIVE_COMMENTS\n관점 댓글" as perspective_comments { + * id : Long <> + -- + perspective_id : Long <> + user_id : BIGINT <> + content : TEXT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +perspectives -[hidden]down- perspective_comments +users -[hidden]right- perspective_comments + +perspectives ||--o{ perspective_comments : "has" +users ||--o{ perspective_comments : "writes" + +note right of perspective_comments + 삭제: 본인만 가능 + 수정: 본인만 가능 + 대댓글 없음 (1단 구조) +end note + +@enduml \ No newline at end of file diff --git a/docs/erd/notice-notification.puml b/docs/erd/notice-notification.puml new file mode 100644 index 00000000..5402b62e --- /dev/null +++ b/docs/erd/notice-notification.puml @@ -0,0 +1,41 @@ +@startuml +hide circle +hide methods +skinparam linetype ortho + +entity "USERS\n사용자" as users { + * id : Long <> +} + +entity "NOTICES\n전체 공지" as notices { + * id : Long <> + -- + title : string + body : text + notice_type : string + is_pinned : boolean + starts_at : datetime + ends_at : datetime + created_at : datetime +} + +entity "NOTIFICATIONS\n알림 발송 이력" as notifications { + * id : Long <> + -- + user_id : Long <> + notification_type : string + title : string + body : text + payload_json : text + status : string + scheduled_at : datetime + sent_at : datetime + failed_at : datetime + provider_message_id : string + failure_reason : string + created_at : datetime +} + +users ||--o{ notifications + +@enduml diff --git a/docs/erd/oauth2.puml b/docs/erd/oauth2.puml new file mode 100644 index 00000000..1213f848 --- /dev/null +++ b/docs/erd/oauth2.puml @@ -0,0 +1,55 @@ +@startuml +!theme plain +skinparam Linetype ortho + +' 1. 서비스 사용자 참조 +entity "users" { + * id : BIGINT <> + -- + user_tag : VARCHAR(30) <> + status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +' 2. OAuth 연동 정보 테이블 +' 소셜 공급자 식별자는 users 와 분리한다. +entity "user_social_accounts" { + * id : BIGINT <> + -- + user_id : BIGINT <> + provider : ENUM('KAKAO', 'GOOGLE', 'APPLE') + provider_user_id : VARCHAR(255) + provider_email : VARCHAR(255) (nullable) + linked_at : TIMESTAMP + last_login_at : TIMESTAMP +} + +' 3. 서비스 자체 세션(Refresh Token) 관리 +' raw token 대신 token_hash 저장을 기본 전제로 둔다. +entity "auth_refresh_tokens" { + * id : BIGINT <> + -- + user_id : BIGINT <> + token_hash : VARCHAR(255) + expires_at : TIMESTAMP + revoked_at : TIMESTAMP (nullable) + last_used_at : TIMESTAMP + created_at : TIMESTAMP +} + +' 관계 설정 +users ||--o{ user_social_accounts : "소셜 연동" +users ||--o{ auth_refresh_tokens : "서비스 세션" + +note right of user_social_accounts + UNIQUE(provider, provider_user_id) + 한 유저는 여러 소셜 계정을 연결할 수 있다. +end note + +note right of auth_refresh_tokens + 로그아웃, 재발급, 다중 디바이스 세션 관리를 위해 + 서비스 refresh token 을 별도 테이블에서 관리한다. +end note + +@enduml diff --git a/docs/erd/perspectives.puml b/docs/erd/perspectives.puml new file mode 100644 index 00000000..bf219e18 --- /dev/null +++ b/docs/erd/perspectives.puml @@ -0,0 +1,84 @@ +@startuml perspective +hide circle +hide methods +skinparam linetype ortho + +entity "users\n사용자" as users { + * id : BIGINT <> +} + +entity "BATTLES\n배틀(주제)" as battles { + * id : Long <> +} + +entity "BATTLE_OPTIONS\n선택지" as battle_options { + * id : Long <> +} + +entity "PERSPECTIVES\n관점" as perspectives { + * id : Long <> + -- + battle_id : Long <> + user_id : BIGINT <> + option_id : Long <> + content : TEXT + like_count : INT default 0 + comment_count : INT default 0 + status : ENUM('PENDING', 'PUBLISHED', 'REJECTED') + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "COMMENTS\n관점 댓글" as perspective_comments { + * id : Long <> + -- + perspective_id : Long <> + user_id : BIGINT <> + content : TEXT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "LIKES\n관점 좋아요" as perspective_likes { + * perspective_id : Long <> + * user_id : BIGINT <> + -- + created_at : TIMESTAMP +} + +users -[hidden]down- perspectives +battles -[hidden]down- perspectives +perspectives -[hidden]down- perspective_comments +perspective_likes -[hidden]right- perspective_comments + +battles ||--o{ perspectives : "has" +users ||--o{ perspectives : "writes" +battle_options ||--o{ perspectives : "belongs to" +perspectives ||--o{ perspective_comments : "has" +users ||--o{ perspective_comments : "writes" +perspectives ||--o{ perspective_likes : "has" +users ||--o{ perspective_likes : "likes" + +note right of perspectives + status 흐름: + PENDING → PUBLISHED → REJECTED + + option_id: 서버가 votes 테이블에서 + pre_vote_option_id를 읽어서 저장 + + like_count, comment_count: + 캐싱용 카운터 (정합성은 배치로 보정) + + UNIQUE (battle_id, user_id): + 1인 1관점 제약 +end note + +note bottom of perspective_likes + 복합 PK: (perspective_id, user_id) + 동일 유저 중복 좋아요 방지 +end note + +@enduml +``` + +--- \ No newline at end of file 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 new file mode 100644 index 00000000..0e5d2e38 --- /dev/null +++ b/docs/erd/scenario.puml @@ -0,0 +1,87 @@ +@startuml scenario +hide circle +hide methods +skinparam linetype ortho + +entity "battles" as battles { + * id : BIGINT <> + -- + title : VARCHAR(255) +} + +entity "scenarios" as scenarios { + * id : BIGINT <> + -- + battle_id : BIGINT <> + is_interactive : BOOLEAN + status : VARCHAR(20) + creator_type : VARCHAR(20) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "scenario_nodes" as scenario_nodes { + * id : BIGINT <> + -- + 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 "scenario_options" as scenario_options { + * id : BIGINT <> + -- + node_id : BIGINT <> + label : VARCHAR(200) + next_node_id : BIGINT + option_order : INT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "scenario_audios" as scenario_audios { + * scenario_id : BIGINT <> + * path_key : VARCHAR(50) + -- + audio_url : VARCHAR(500) +} + +entity "scenario_voice_settings" as scenario_voice_settings { + * scenario_id : BIGINT <> + * speaker_type : VARCHAR(20) + -- + voice_code : VARCHAR(200) +} + +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 + 발행(PUBLISHED) 시점에 TTS 파이프라인 수행 + voice_settings는 화자별 보이스 코드 저장 +end note + +@enduml diff --git a/docs/erd/tag.puml b/docs/erd/tag.puml new file mode 100644 index 00000000..4e575124 --- /dev/null +++ b/docs/erd/tag.puml @@ -0,0 +1,62 @@ +@startuml tag +hide circle +hide methods +skinparam linetype ortho + +entity "tags" as tags { + * id : BIGINT <> + -- + name : VARCHAR(50) + type : VARCHAR(20) ' CATEGORY / PHILOSOPHER / VALUE + deleted_at : TIMESTAMP + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "battles" as battles { + * id : BIGINT <> + -- + title : VARCHAR(255) +} + +entity "battle_options" as battle_options { + * id : BIGINT <> + -- + battle_id : BIGINT <> + label : VARCHAR(10) + title : VARCHAR(100) +} + +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 : has +tags ||--o{ battle_tags : mapped + +battle_options ||--o{ battle_option_tags : has +tags ||--o{ battle_option_tags : mapped + +note right of tags + 현재 태그는 Battle 도메인에서만 사용 + - battle_tags: 카테고리 + - battle_option_tags: 철학자/가치관 +end note + +@enduml diff --git a/docs/erd/user-ops.puml b/docs/erd/user-ops.puml new file mode 100644 index 00000000..db748bcc --- /dev/null +++ b/docs/erd/user-ops.puml @@ -0,0 +1,71 @@ +@startuml +hide circle +hide methods +skinparam linetype ortho + +entity "USERS\n서비스 사용자" as users { + * id : BIGINT <> + -- + user_tag : VARCHAR(30) <> + status : ENUM('PENDING', 'ACTIVE', 'SUSPENDED', 'BANNED', 'DELETED') + created_at : timestamp + updated_at : timestamp +} + +entity "USER_SETTINGS\n사용자 설정" as user_settings { + * user_id : BIGINT <> + -- + new_battle_enabled : boolean + battle_result_enabled : boolean + comment_reply_enabled : boolean + new_comment_enabled : boolean + content_like_enabled : boolean + marketing_event_enabled : boolean + updated_at : timestamp +} + +entity "USER_AGREEMENTS\n사용자 동의 이력" as user_agreements { + * id : BIGINT <> + -- + user_id : BIGINT <> + agreement_type : ENUM('TERMS_OF_SERVICE', 'PRIVACY_POLICY') + version : string + agreed_at : timestamp +} + +entity "USER_DEVICES\n사용자 디바이스" as user_devices { + * id : BIGINT <> + -- + user_id : BIGINT <> + device_token : string + platform : string + last_seen_at : timestamp + created_at : timestamp +} + +entity "USER_BLOCKS\n사용자 차단" as user_blocks { + * id : BIGINT <> + -- + blocker_user_id : BIGINT <> + blocked_user_id : BIGINT <> + created_at : timestamp +} + +users -[hidden]down- user_settings +user_settings -[hidden]down- user_agreements +user_agreements -[hidden]down- user_devices +user_devices -[hidden]down- user_blocks + +users ||--|| user_settings +users ||--o{ user_agreements +users ||--o{ user_devices +users ||--o{ user_blocks : blocker +users ||--o{ user_blocks : blocked + +note bottom of user_blocks + 공통 컬럼 정책 + - BaseEntity: created_at, updated_at + - agreed_at, last_seen_at 은 도메인별 개별 컬럼 +end note + +@enduml diff --git a/docs/erd/user.puml b/docs/erd/user.puml new file mode 100644 index 00000000..89a6dddf --- /dev/null +++ b/docs/erd/user.puml @@ -0,0 +1,89 @@ +@startuml +hide circle +hide methods +skinparam linetype ortho + +entity "USERS\n서비스 사용자" as users { + * id : BIGINT <> + -- + user_tag : VARCHAR(30) <> + nickname : VARCHAR(50) + character_url : TEXT + role : ENUM('USER', 'ADMIN') + status : ENUM('PENDING', 'ACTIVE', 'SUSPENDED', 'BANNED', 'DELETED') + created_at : timestamp + updated_at : timestamp + deleted_at : timestamp (nullable) +} + +entity "USER_PROFILES\n사용자 프로필" as user_profiles { + * user_id : BIGINT <> + -- + nickname : string + character_type : ENUM('owl', 'fox', 'wolf', 'lion', 'penguin', 'bear', 'rabbit', 'cat') + manner_temperature : float + updated_at : timestamp +} + +entity "USER_TENDENCY_SCORES\n사용자 성향 점수 현재값" as user_tendency_scores { + * user_id : BIGINT <> + -- + principle : int + reason : int + individual : int + change : int + inner : int + ideal : int + updated_at : timestamp +} + +entity "USER_TENDENCY_SCORE_HISTORIES\n사용자 성향 점수 변경 이력" as user_tendency_score_histories { + * id : BIGINT <> + -- + user_id : BIGINT <> + principle : int + reason : int + individual : int + change : int + inner : int + ideal : int + created_at : timestamp +} + +entity "USER_WITHDRAWALS\n회원 탈퇴 이력" as user_withdrawals { + * id : BIGINT <> + -- + user_id : BIGINT <> + reason : ENUM('NOT_USED_OFTEN', 'NO_INTERESTING_BATTLES', 'BATTLE_STYLE_NOT_FIT', 'SERVICE_INCONVENIENT', 'NO_TIME', 'OTHER') + created_at : timestamp + updated_at : timestamp +} + +users -[hidden]down- user_profiles +user_profiles -[hidden]down- user_tendency_scores +user_tendency_scores -[hidden]down- user_tendency_score_histories +user_tendency_score_histories -[hidden]down- user_withdrawals + +users ||--|| user_profiles +users ||--|| user_tendency_scores +users ||--o{ user_tendency_score_histories +users ||--o{ user_withdrawals + +note right of users + users 는 서비스 내부 사용자 식별자와 상태만 관리한다. + provider, provider_user_id 같은 OAuth 식별자는 이 테이블에 두지 않는다. + user_tag 는 공개 식별자이며 저장 시 @ 없이 보관한다. +end note + +note right of user_profiles + nickname은 중복 허용 + user_tag를 대외 식별자로 활용 +end note + +note bottom of user_tendency_score_histories + 공통 컬럼 정책 + - BaseEntity: created_at, updated_at + - deleted_at 은 users 에만 개별 보유 +end note + +@enduml diff --git a/docs/erd/vote.puml b/docs/erd/vote.puml new file mode 100644 index 00000000..18a95ab6 --- /dev/null +++ b/docs/erd/vote.puml @@ -0,0 +1,113 @@ +@startuml vote +hide circle +hide methods +skinparam linetype ortho + +entity "users" as users { + * id : BIGINT <> + -- + email : VARCHAR(255) + nickname : VARCHAR(50) +} + +entity "battles" as battles { + * id : BIGINT <> + -- + title : VARCHAR(255) +} + +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 "quiz_user_votes" as quiz_user_votes { + * id : BIGINT <> + -- + user_id : BIGINT <> + quiz_id : BIGINT <> + option_id : BIGINT <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "poll_user_votes" as poll_user_votes { + * id : BIGINT <> + -- + user_id : BIGINT <> + poll_id : BIGINT <> + option_id : BIGINT <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +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/hs_err_pid172944.log b/hs_err_pid172944.log new file mode 100644 index 00000000..8707abc3 --- /dev/null +++ b/hs_err_pid172944.log @@ -0,0 +1,281 @@ +# +# There is insufficient memory for the Java Runtime Environment to continue. +# Native memory allocation (mmap) failed to map 532676608 bytes. Error detail: G1 virtual space +# Possible reasons: +# The system is out of physical RAM or swap space +# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap +# Possible solutions: +# Reduce memory load on the system +# Increase physical memory or swap space +# Check if swap backing store is full +# Decrease Java heap size (-Xmx/-Xms) +# Decrease number of Java threads +# Decrease Java thread stack sizes (-Xss) +# Set larger code cache with -XX:ReservedCodeCacheSize= +# JVM is running with Zero Based Compressed Oops mode in which the Java heap is +# placed in the first 32GB address space. The Java Heap base address is the +# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress +# to set the Java Heap base and to place the Java Heap above 32GB virtual address. +# This output file may be truncated or incomplete. +# +# Out of Memory Error (os_windows.cpp:3714), pid=172944, tid=186552 +# +# JRE version: (21.0.10+7) (build ) +# Java VM: OpenJDK 64-Bit Server VM (21.0.10+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) +# No core dump will be written. Minidumps are not enabled by default on client versions of Windows +# + +--------------- S U M M A R Y ------------ + +Command Line: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=51664 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 com.swyp.picke.PickeApplication + +Host: Intel(R) Core(TM) Ultra 5 125H, 18 cores, 31G, Windows 11 , 64 bit Build 26100 (10.0.26100.7920) +Time: Mon Mar 30 22:58:11 2026 elapsed time: 1.695367 seconds (0d 0h 0m 1s) + +--------------- T H R E A D --------------- + +Current thread (0x0000024510954d30): JavaThread "Unknown thread" [_thread_in_vm, id=186552, stack(0x0000007972f00000,0x0000007973000000) (1024K)] + +Stack: [0x0000007972f00000,0x0000007973000000] +Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) +V [jvm.dll+0x6df2b9] +V [jvm.dll+0x8bbdeb] +V [jvm.dll+0x8be37a] +V [jvm.dll+0x8bea53] +V [jvm.dll+0x28a7a6] +V [jvm.dll+0x6dbc15] +V [jvm.dll+0x6cfbca] +V [jvm.dll+0x364f6e] +V [jvm.dll+0x36ce3b] +V [jvm.dll+0x3be8d9] +V [jvm.dll+0x3beb7b] +V [jvm.dll+0x339137] +V [jvm.dll+0x339c7b] +V [jvm.dll+0x88634e] +V [jvm.dll+0x3cb831] +V [jvm.dll+0x86f25c] +V [jvm.dll+0x45e901] +V [jvm.dll+0x460541] +C [jli.dll+0x52f0] +C [ucrtbase.dll+0x37b0] +C [KERNEL32.DLL+0x2e8d7] +C [ntdll.dll+0x8c48c] + + +--------------- P R O C E S S --------------- + +Threads class SMR info: +_java_thread_list=0x00007ffa227e2208, length=0, elements={ +} + +Java Threads: ( => current thread ) +Total: 0 + +Other Threads: + 0x00000245266f03f0 WorkerThread "GC Thread#0" [id=134476, stack(0x0000007973000000,0x0000007973100000) (1024K)] + 0x00000245109ce130 ConcurrentGCThread "G1 Main Marker" [id=195520, stack(0x0000007973100000,0x0000007973200000) (1024K)] + 0x00000245109d0570 WorkerThread "G1 Conc#0" [id=133400, stack(0x0000007973200000,0x0000007973300000) (1024K)] + +[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa21edbbb7] +VM state: not at safepoint (not fully initialized) + +VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event]) +[0x00007ffa228566b0] Heap_lock - owner thread: 0x0000024510954d30 + +Heap address: 0x0000000606800000, size: 8088 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 + +CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1. +Narrow klass base: 0x0000000000000000, Narrow klass shift: 0, Narrow klass range: 0x0 + +GC Precious Log: + CardTable entry size: 512 + Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 32 size 80 Howl #buckets 8 coarsen threshold 7372 Howl Bitmap #cards 1024 size 144 coarsen threshold 921 Card regions per heap region 1 cards per card region 8192 + +Heap: + garbage-first heap total 0K, used 0K [0x0000000606800000, 0x0000000800000000) + region size 4096K, 0 young (0K), 0 survivors (0K) + +[error occurred during error reporting (printing heap information), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa222c9019] +GC Heap History (0 events): +No events + +Dll operation events (1 events): +Event: 0.007 Loaded shared library D:\application\.jdk\bin\java.dll + +Deoptimization events (0 events): +No events + +Classes loaded (0 events): +No events + +Classes unloaded (0 events): +No events + +Classes redefined (0 events): +No events + +Internal exceptions (0 events): +No events + +ZGC Phase Switch (0 events): +No events + +VM Operations (0 events): +No events + +Memory protections (0 events): +No events + +Nmethod flushes (0 events): +No events + +Events (0 events): +No events + + +Dynamic libraries: +0x00007ff7fbf60000 - 0x00007ff7fbf6e000 D:\application\.jdk\bin\java.exe +0x00007ffb03b00000 - 0x00007ffb03d67000 C:\WINDOWS\SYSTEM32\ntdll.dll +0x00007ffb02ce0000 - 0x00007ffb02da9000 C:\WINDOWS\System32\KERNEL32.DLL +0x00007ffb00fa0000 - 0x00007ffb01391000 C:\WINDOWS\System32\KERNELBASE.dll +0x00007ffb003e0000 - 0x00007ffb0052b000 C:\WINDOWS\System32\ucrtbase.dll +0x00007ffaeab10000 - 0x00007ffaeab2e000 D:\application\.jdk\bin\VCRUNTIME140.dll +0x00007ffae63b0000 - 0x00007ffae63c8000 D:\application\.jdk\bin\jli.dll +0x00007ffb02db0000 - 0x00007ffb02f75000 C:\WINDOWS\System32\USER32.dll +0x00007ffae7290000 - 0x00007ffae7523000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b\COMCTL32.dll +0x00007ffb031a0000 - 0x00007ffb03249000 C:\WINDOWS\System32\msvcrt.dll +0x00007ffb015c0000 - 0x00007ffb015e7000 C:\WINDOWS\System32\win32u.dll +0x00007ffb02780000 - 0x00007ffb027ab000 C:\WINDOWS\System32\GDI32.dll +0x00007ffb01770000 - 0x00007ffb0189b000 C:\WINDOWS\System32\gdi32full.dll +0x00007ffb005f0000 - 0x00007ffb00693000 C:\WINDOWS\System32\msvcp_win.dll +0x00007ffb03160000 - 0x00007ffb03191000 C:\WINDOWS\System32\IMM32.DLL +0x00007ffaecfa0000 - 0x00007ffaecfac000 D:\application\.jdk\bin\vcruntime140_1.dll +0x00007ffad39c0000 - 0x00007ffad3a49000 D:\application\.jdk\bin\msvcp140.dll +0x00007ffa21b90000 - 0x00007ffa22938000 D:\application\.jdk\bin\server\jvm.dll +0x00007ffb01d30000 - 0x00007ffb01deb000 C:\WINDOWS\System32\ADVAPI32.dll +0x00007ffb03250000 - 0x00007ffb032f7000 C:\WINDOWS\System32\sechost.dll +0x00007ffb024a0000 - 0x00007ffb025b8000 C:\WINDOWS\System32\RPCRT4.dll +0x00007ffb029a0000 - 0x00007ffb02a14000 C:\WINDOWS\System32\WS2_32.dll +0x00007ffb00240000 - 0x00007ffb0029e000 C:\WINDOWS\SYSTEM32\POWRPROF.dll +0x00007ffaec6c0000 - 0x00007ffaec6f5000 C:\WINDOWS\SYSTEM32\WINMM.dll +0x00007ffae7a30000 - 0x00007ffae7a3b000 C:\WINDOWS\SYSTEM32\VERSION.dll +0x00007ffb00220000 - 0x00007ffb00234000 C:\WINDOWS\SYSTEM32\UMPDC.dll +0x00007ffaff170000 - 0x00007ffaff18b000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll +0x00007ffaea260000 - 0x00007ffaea26a000 D:\application\.jdk\bin\jimage.dll +0x00007ffafe4f0000 - 0x00007ffafe732000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL +0x00007ffb01e50000 - 0x00007ffb021d2000 C:\WINDOWS\System32\combase.dll +0x00007ffb02f80000 - 0x00007ffb03057000 C:\WINDOWS\System32\OLEAUT32.dll +0x00007ffada3d0000 - 0x00007ffada40b000 C:\WINDOWS\SYSTEM32\dbgcore.DLL +0x00007ffb013a0000 - 0x00007ffb01445000 C:\WINDOWS\System32\bcryptPrimitives.dll +0x00007ffae63a0000 - 0x00007ffae63b0000 D:\application\.jdk\bin\instrument.dll +0x00007ffae6310000 - 0x00007ffae6331000 D:\application\.jdk\bin\java.dll + +JVMTI agents: +C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar path:none, loaded, not initialized, instrumentlib options:51664 + +dbghelp: loaded successfully - version: 4.0.5 - missing functions: none +symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;D:\application\.jdk\bin;C:\WINDOWS\SYSTEM32;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b;D:\application\.jdk\bin\server + +VM Arguments: +jvm_args: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=51664 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 +java_command: com.swyp.picke.PickeApplication +java_class_path (initial): D:\Desktop\project\Server\build\classes\java\main;D:\Desktop\project\Server\build\resources\main;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.projectlombok\lombok\1.18.42\8365263844ebb62398e0dc33057ba10ba472d3b8\lombok-1.18.42.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-web\3.5.11\68fde4c94249e92526105a93ac7c22bd89b6945e\spring-boot-starter-web-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-ui\2.8.16\61c68f705d3f17e8318fb18b2904fa6368af251c\springdoc-openapi-starter-webmvc-ui-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-validation\3.5.11\903eefb6eab302617b0f01cc6d65664343bff2a7\spring-boot-starter-validation-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-data-jpa\3.5.11\f176e5c643720818ec7910e1dd2ccb402411cc5d\spring-boot-starter-data-jpa-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-security\3.5.11\db8b6b7951883dea3ce7404f20d6816104cedd4e\spring-boot-starter-security-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-api\0.12.6\478886a888f6add04937baf0361144504a024967\jjwt-api-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-webflux\3.5.11\1461f9a6b6b8397ad71a98c9bbf4278159fb9624\spring-boot-starter-webflux-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\apps-rewardedads\1.9.1\cbaf11457b36fe57d90a5cb16a76833906486503\apps-rewardedads-1.9.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.cloud\google-cloud-texttospeech\2.58.0\9be37bd3c81c14c72c9cbcfa2dfaf6dad7a35075\google-cloud-texttospeech-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter-s3\3.3.0\fa9790f990ab540814aafdbf2e97c8cd53b5b1a6\spring-cloud-aws-starter-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-thymeleaf\3.5.11\d997aa0df579cf43507d425057940e2712e44808\spring-boot-starter-thymeleaf-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-json\3.5.11\4cdcd68dcddf0a4c645166e39c3fe448fe2b8e98\spring-boot-starter-json-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter\3.5.11\10ce971300fd56d6be5f1cfe7d27ddfb1ed7158d\spring-boot-starter-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-tomcat\3.5.11\fb7b96cb61e5fd5700aed96194562e32d166b5ef\spring-boot-starter-tomcat-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webmvc\6.2.16\ff2db80406f1459fddd14a8d06d57e0e3ab69465\spring-webmvc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-web\6.2.16\2c4355f1f7e5b8969f696cbc90f25cc22f0f2164\spring-web-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-api\2.8.16\6e41988d84978e529c01a4cc052b761cd27d5b90\springdoc-openapi-starter-webmvc-api-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\swagger-ui\5.32.0\d04c7e3e5b8616813136fa36382a548751775528\swagger-ui-5.32.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\webjars-locator-lite\1.1.3\217ce590453251b39b72c4a9af3986998f6fdbd9\webjars-locator-lite-1.1.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-el\10.1.52\cd94ce17c5a9937eca365eb494711efa10d49b86\tomcat-embed-el-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.validator\hibernate-validator\8.0.3.Final\4425f554297a1c5ba03a3f30e559a9fd91048cf8\hibernate-validator-8.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-jdbc\3.5.11\3ce801963caadf6eb29abd68f5a0fe50c9bfe211\spring-boot-starter-jdbc-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.orm\hibernate-core\6.6.42.Final\996e3df4a6c67941b582e4493cb9a39c83198f1e\hibernate-core-6.6.42.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-jpa\3.5.9\56081dde4f663db74ba000c1f8ab30673058c363\spring-data-jpa-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aspects\6.2.16\763140a66821c494985533f29280a3b4132cf055\spring-aspects-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-web\6.5.8\3db7bf41191d5b23493cca6252595405b5112b34\spring-security-web-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-config\6.5.8\302d32eba89131c0ffd15ba0a1e465051336d42f\spring-security-config-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aop\6.2.16\59250efa248420a114fe23b4ccf2fea46b804186\spring-aop-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webflux\6.2.16\699ef8bc182893f9ed43206d372f20c4f9aa3231\spring-webflux-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-reactor-netty\3.5.11\f98bd3e3019078679dec4f21fb63152aa2e059a7\spring-boot-starter-reactor-netty-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\tink\1.10.0\84771b1a4bb5726f73fb8490fadb23f1d2aacd38\tink-1.10.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.gson\gson\2.13.2\48b8230771e573b54ce6e867a9001e75977fe78e\gson-2.13.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client\1.45.3\dde98b597081b98514867c9cefa551fcdea3a28c\google-http-client-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.errorprone\error_prone_annotations\2.41.0\4381275efdef6ddfae38f002c31e84cd001c97f0\error_prone_annotations-2.41.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-contrib-http-util\0.31.1\3c13fc5715231fadb16a9b74a44d9d59c460cfa8\opencensus-contrib-http-util-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\guava\33.4.0-jre\3fcc0a259f724c7de54a6a55ea7e26d3d5c0cac\guava-33.4.0-jre.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-api\1.69.0\965c2c7f708cd6e6ddbf1eb175c3e87e96e41297\grpc-api-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.findbugs\jsr305\3.0.2\25ea2e8b0c338a877313bd4672d3fe056ea78f0d\jsr305-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-stub\1.69.0\9e7dc30a9c2df70e25ef4b941f46187e6e178e7a\grpc-stub-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf\1.69.0\2990b4948357d4fe46aaecb47290cff102079f1e\grpc-protobuf-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\api-common\2.43.0\963d97d95e9bf7275cc26f0b6b72e2aa5b92c6fd\api-common-2.43.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auto.value\auto-value-annotations\1.11.0\f0d047931d07cfbc6fa4079854f181ff62891d6f\auto-value-annotations-1.11.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\javax.annotation\javax.annotation-api\1.3.2\934c04d3cfef185a8008e7bf34331b79730a9d43\javax.annotation-api-1.3.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.j2objc\j2objc-annotations\3.0.0\7399e65dd7e9ff3404f4535b2f017093bdb134c7\j2objc-annotations-3.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java\3.25.5\5ae5c9ec39930ae9b5a61b32b93288818ec05ec1\protobuf-java-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-common-protos\2.51.0\ead75a32e6fd65740b6a69feb658254aeab3fef0\proto-google-common-protos-2.51.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1\2.58.0\42f1f29876ddfa2523ebcc41dae801195fd8b3ce\proto-google-cloud-texttospeech-v1-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1beta1\0.147.0\576df432a2c1181deabf21a54fdecc1a32f69f4e\proto-google-cloud-texttospeech-v1beta1-0.147.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\failureaccess\1.0.2\c4a06a64e650562f30b7bf9aaec1bfed43aca12b\failureaccess-1.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\b421526c5f297295adef1c886e5246c39d4ac629\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.48.4\6b5d69a61012211d581e68699baf3beb1fd382da\checker-qual-3.48.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax\2.60.0\2d277e0795cb69bc14e03be068aa002539e3ef49\gax-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-credentials\1.31.0\b9cd5346d3a683d9a8d9786453f2419cc832a97f\google-auth-library-credentials-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-api\0.31.1\66a60c7201c2b8b20ce495f0295b32bb0ccbbc57\opencensus-api-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-context\1.69.0\cea23878872f76418dcd6df0c6eef0bf27463537\grpc-context-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-oauth2-http\1.31.0\df5be46d21b983aab8d0250f19b585a94bdedcde\google-auth-library-oauth2-http-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-grpc\2.60.0\ca4d7dc8c2a85fbdba25ff3449726852e2359ae9\gax-grpc-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-inprocess\1.69.0\8ac4d2e13b48bed9624b8bc485c90f3d28820c93\grpc-inprocess-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-core\1.69.0\7dad3419dfb91a77788afcdf79e0477172784910\grpc-core-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-alts\1.69.0\6d1eac6726fd6fd177666c10fd154823b82272eb\grpc-alts-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-grpclb\1.69.0\d2c9c066693ce94805a503bc47f5b1e76f51541c\grpc-grpclb-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.conscrypt\conscrypt-openjdk-uber\2.5.2\d858f142ea189c62771c505a6548d8606ac098fe\conscrypt-openjdk-uber-2.5.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-auth\1.69.0\a75e19b20bb732364bdcc0979e9d7c9baa4e408e\grpc-auth-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-netty-shaded\1.69.0\99aa9789172695a4b09fe2af5f5bd0ab1be4ae85\grpc-netty-shaded-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-httpjson\2.60.0\131d9283925337406e35561ec17bf326a9ecec1a\gax-httpjson-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpclient\4.5.14\1194890e6f56ec29177673f2f12d0b8e627dec98\httpclient-4.5.14.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\commons-codec\commons-codec\1.18.0\ee45d1cf6ec2cc2b809ff04b4dc7aec858e0df8f\commons-codec-1.18.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpcore\4.4.16\51cf043c87253c9f58b539c9f7e44c8894223850\httpcore-4.4.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client-gson\1.45.3\29eba40245c4a4e5466f8764bd894d6a97c6694f\google-http-client-gson-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java-util\3.25.5\38cc5ce479603e36466feda2a9f1dfdb2210ef00\protobuf-java-util-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.threeten\threetenbp\1.7.0\8703e893440e550295aa358281db468625bc9a05\threetenbp-1.7.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter\3.3.0\7d82d320cb1851beca3005eab2e484a38bd58a08\spring-cloud-aws-starter-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-s3\3.3.0\661e2914e3ad6555e20ffa262a9987c15bcc1712\spring-cloud-aws-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\slf4j-api\2.0.17\d9e58ac9c7779ba3bf8142aff6c830617a7fe60f\slf4j-api-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf-spring6\3.1.3.RELEASE\4b276ea2bd536a18e44b40ff1d9f4848965ff59c\thymeleaf-spring6-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jdk8\2.19.4\90d304bcdb1a4bacb6f4347be625d75300973c60\jackson-datatype-jdk8-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jsr310\2.19.4\3cbcf2e636a6b062772299bf19a347536e58c4df\jackson-datatype-jsr310-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.module\jackson-module-parameter-names\2.19.4\502dfea4c83502f444837b3d040a51e8475f15f2\jackson-module-parameter-names-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-databind\2.19.4\7a39bf9257b726b90b80f27fa3f5174bc75162a5\jackson-databind-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-autoconfigure\3.5.11\3c7d2ec2ac3c301e95814e37fed1c86c19927fc4\spring-boot-autoconfigure-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot\3.5.11\8b7f6df00bfbe74d370e1d05d985a127884d2a9c\spring-boot-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-logging\3.5.11\62b692ed7aee31a5670796be8b07732b6b836f4e\spring-boot-starter-logging-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.annotation\jakarta.annotation-api\2.1.1\48b9bda22b091b1f48b13af03fe36db3be6e1ae3\jakarta.annotation-api-2.1.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-core\6.2.16\a73937f20a303e057add523915b48eb7901e1848\spring-core-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.yaml\snakeyaml\2.4\e0666b825b796f85521f02360e77f4c92c5a7a07\snakeyaml-2.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-websocket\10.1.52\9d32b801fb474306349013fcdd8317c8cb4d739e\tomcat-embed-websocket-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-core\10.1.52\f512bef2796b51299f4752f95918982c3003131d\tomcat-embed-core-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-context\6.2.16\caeae6bd50832d6ab28f707aa740e957401a5c20\spring-context-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-beans\6.2.16\990289064c810be71630fca9da8e2b6fe8f897b5\spring-beans-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-expression\6.2.16\e293ab797b1698084e56ae1f2362b315148683f6\spring-expression-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-observation\1.15.9\edf37b25cdfac0704d6fefa4543edb3ed1817eb0\micrometer-observation-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-common\2.8.16\5b702cb484981b42cfb455bd80b6ce7f49d34210\springdoc-openapi-starter-common-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jspecify\jspecify\1.0.0\7425a601c1c7ec76645a78d22b8c6a627edee507\jspecify-1.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.validation\jakarta.validation-api\3.0.2\92b6631659ba35ca09e44874d3eb936edfeee532\jakarta.validation-api-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jboss.logging\jboss-logging\3.6.2.Final\3e0a139d7a74cc13b5e01daa8aaa7f71dccd577e\jboss-logging-3.6.2.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml\classmate\1.7.3\f61c7e7b81e9249b0f6a05914eff9d54fb09f4a0\classmate-1.7.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.zaxxer\HikariCP\6.3.3\7c5aec1e47a97ff40977e0193018865304ea9585\HikariCP-6.3.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jdbc\6.2.16\addfdde7b3212f34c95d791c37bb04ba4b08a1b7\spring-jdbc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.persistence\jakarta.persistence-api\3.1.0\66901fa1c373c6aff65c13791cc11da72060a8d6\jakarta.persistence-api-3.1.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.transaction\jakarta.transaction-api\2.0.1\51a520e3fae406abb84e2e1148e6746ce3f80a1a\jakarta.transaction-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-commons\3.5.9\6b577c71f563e78a7da984a3d572fde8a4df8103\spring-data-commons-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-orm\6.2.16\44b3cfb2c046440f83729641c929c405dc7f2c89\spring-orm-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-tx\6.2.16\5f9d6e78b76530e6258de8a0dff991fb1ad4b9b0\spring-tx-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.antlr\antlr4-runtime\4.13.0\5a02e48521624faaf5ff4d99afc88b01686af655\antlr4-runtime-4.13.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.aspectj\aspectjweaver\1.9.25.1\a713c790da4d794c7dfb542b550d4e44898d5e23\aspectjweaver-1.9.25.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-core\6.5.8\d052dca52e49d95d2b03f81ae4b6762eeb4c78d0\spring-security-core-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor\reactor-core\3.7.16\dc7f2ba3c4fbc69678937dfe1ad45264d8a1c7be\reactor-core-3.7.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-http\1.2.15\b20bb13c95b44f1d0c148bdf1197b7d4a7e0f278\reactor-netty-http-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-autoconfigure\3.3.0\b5a0b27e91ee997f8c86e4b0c521858fdcb9dc9b\spring-cloud-aws-autoconfigure-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-core\3.3.0\3c501426267d8ccbaaebc9796bd5de5bc5d0702e\spring-cloud-aws-core-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\s3\2.29.52\db65bc6177b0c4514be1f9775cb2094e29e85d3c\s3-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf\3.1.3.RELEASE\51474f2a90b282ee97dabcd159c7faf24790f373\thymeleaf-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-core\2.19.4\a720ca9b800742699e041c3890f3731fe516085e\jackson-core-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-annotations\2.19.4\bbb09b1e7f7f5108890270eb701cb3ddef991c05\jackson-annotations-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-classic\1.5.32\2b1042c50f508f2eb402bd4d22ccbdf94cc37d2e\logback-classic-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-to-slf4j\2.24.3\da1143e2a2531ee1c2d90baa98eb50a28a39d5a7\log4j-to-slf4j-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\jul-to-slf4j\2.0.17\524cb6ccc2b68a57604750e1ab8b13b5a786a6aa\jul-to-slf4j-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jcl\6.2.16\8af6546d28815be574f384dceb93d248e9934f90\spring-jcl-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-commons\1.15.9\5a38f43cdc79a309a458c8ce130fff30a2a7f59\micrometer-commons-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-core-jakarta\2.2.43\500566364be54e3556bcec28922a41ca5fcc7dcd\swagger-core-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-crypto\6.5.8\aec1a6f6c0e06be9dff08b11e8e1f457afca44b2\spring-security-crypto-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.reactivestreams\reactive-streams\1.0.4\3864a1320d97d7b045f729a326e1e077661f31b7\reactive-streams-1.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-core\1.2.15\94b2ca82f310c1bf31d3038060e4572eeca1d4b2\reactor-netty-core-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http2\4.1.131.Final\2e4c47131c60e0bbbca067c891597b466f7033ba\netty-codec-http2-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http\4.1.131.Final\253d80637ed689ed309ca62371e5fb97746b165\netty-codec-http-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-native-macos\4.1.131.Final\9e4a908c073e56caa4127f54d946e3e9a5208506\netty-resolver-dns-native-macos-4.1.131.Final-osx-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns\4.1.131.Final\7714c0babe26712ccfdbc158aa64898ab909e7d8\netty-resolver-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-epoll\4.1.131.Final\10b7a905019c1ad5c37e8cf63d7229fb00668c1d\netty-transport-native-epoll-4.1.131.Final-linux-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-core\2.29.52\dcfa86ac727b5d4e0abad1e8b025ac2febb6382e\aws-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\auth\2.29.52\76f9b22a99b0de0fd31447db22a5cba4ed4b172e\auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\regions\2.29.52\270b31c8695739d495452d380d036c72698e623\regions-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-xml-protocol\2.29.52\e7290d4528affec022bd2f3739853f774a955ac2\aws-xml-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\protocol-core\2.29.52\1a0e4a114c0943142ca395000a949ee840890fea\protocol-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\sdk-core\2.29.52\3f058b489fac3d091417339e02b165e72c637f61\sdk-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\arns\2.29.52\879712423589b58434b8831b9b75304a16983178\arns-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\profiles\2.29.52\59dd1368bff2d242d84515ec7ea8fe63bb472c4e\profiles-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\crt-core\2.29.52\6a16e04be0e8bb8a1767e0644c631dadddfdd764\crt-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth\2.29.52\aa4ce3ff7bcd8dcf131a4f5445455b3eb4926dcf\http-auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws\2.29.52\1d0dbfa072bc46207066ffa498ad4ed65c52ac6d\http-auth-aws-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-spi\2.29.52\4a64e68a88e3eef0b51819f742931f3607cdd996\http-auth-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\identity-spi\2.29.52\d18449651e8798398cadab6c4b5d8594bb0281c\identity-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums\2.29.52\90631313060ff8ef1ab7745bb1e9740913bdcefc\checksums-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries-spi\2.29.52\7e2c7ad44106799491de8cced5925b6473d62e4b\retries-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-client-spi\2.29.52\a8da4f289736c702ec6664836761412e7e1e54a2\http-client-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\metrics-spi\2.29.52\92c3797208d24b2b25ab9b6d1bbab624c3af1b9c\metrics-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\json-utils\2.29.52\d88c6c03061b9f3fcd17dc8456365dab67cc1597\json-utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\utils\2.29.52\bde94a15cd79b0240bfa10230970e2f0e4c51eba\utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums-spi\2.29.52\537363296f035a935b7d3b50a5bef90014d38010\checksums-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\endpoints-spi\2.29.52\bd702a44ad440628af93afa1ec1d7cdc56baec67\endpoints-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\annotations\2.29.52\9fa958ce528b57d90db01c5015daaf7bd373e57f\annotations-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.attoparser\attoparser\2.0.7.RELEASE\e5d0e988d9124139d645bb5872b24dfa23e283cc\attoparser-2.0.7.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.unbescape\unbescape\1.1.6.RELEASE\7b90360afb2b860e09e8347112800d12c12b2a13\unbescape-1.1.6.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-core\1.5.32\fdfb3ff9a842303d4a95207294a6c6bc64e2605d\logback-core-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-api\2.24.3\b02c125db8b6d295adf72ae6e71af5d83bce2370\log4j-api-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-models-jakarta\2.2.43\a68f7470eb763609878460272000f260eabc24dc\swagger-models-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.dataformat\jackson-dataformat-yaml\2.19.4\500956daea0869bf753b94fdaa77e5dc99847d79\jackson-dataformat-yaml-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.commons\commons-lang3\3.17.0\b17d2136f0460dcc0d2016ceefca8723bdf4ee70\commons-lang3-3.17.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-annotations-jakarta\2.2.43\dbd40253251deabb7a628a54b4550dc4fb492f4\swagger-annotations-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.xml.bind\jakarta.xml.bind-api\4.0.4\d6d2327f3817d9a33a3b6b8f2e15a96bc2e7afdc\jakarta.xml.bind-api-4.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler-proxy\4.1.131.Final\5ff9e74613a9dd3ca078f06880a16c8cdc046de0\netty-handler-proxy-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler\4.1.131.Final\5ca67999f41c0a68f0b66485ceb990683a0b0694\netty-handler-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec\4.1.131.Final\1874341f7b29879c6833c17e7305272f0cdc2cb6\netty-codec-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport\4.1.131.Final\474862e0855d7a9828fab06a9c73c05387604ee3\netty-transport-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-buffer\4.1.131.Final\f97b636ecd9b81ae3fd1d039b69c4fd3959ecf\netty-buffer-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-common\4.1.131.Final\cdc659109da226b698a74b543a5b97dd0f7e6959\netty-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-classes-macos\4.1.131.Final\b9d57038cc4144e36aee5898085b7f1f018d2c9f\netty-resolver-dns-classes-macos-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-dns\4.1.131.Final\cd23e12e5c3448a1b12c8a4b8deeb4faeb5e483e\netty-codec-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver\4.1.131.Final\9db1bfd7c57b9b6aa9b5cfc61fc3304594bb6b39\netty-resolver-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-classes-epoll\4.1.131.Final\4d7848ac709491fb14f8bce2796fc3eff4a04fd6\netty-transport-classes-epoll-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-unix-common\4.1.131.Final\fa975e4751b23d50c0a60569829f31944d11d292\netty-transport-native-unix-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries\2.29.52\48cb57817dd88977ec71e63550673c5ce010a191\retries-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.eventstream\eventstream\1.0.1\6ff8649dffc5190366ada897ba8525a836297784\eventstream-1.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws-eventstream\2.29.52\e8b723c48008bcac96e2cc34c7415bd8b581c601\http-auth-aws-eventstream-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-query-protocol\2.29.52\672d7a2df481414d02eedf3a9eff45fb87f1b8a\aws-query-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\third-party-jackson-core\2.29.52\82ff600d837e83130502775a1555c45d7a3e2e1\third-party-jackson-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.activation\jakarta.activation-api\2.1.4\9e5c2a0d75dde71a0bedc4dbdbe47b78a5dc50f8\jakarta.activation-api-2.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-socks\4.1.131.Final\614eacc17f44d8abaeaea81f210b4980c3568262\netty-codec-socks-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-devtools\3.5.11\fe7dfcaf3153d049909a618a7ba7df288e80f090\spring-boot-devtools-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-impl\0.12.6\ac23673a84b6089e0369fb8ab2c69edd91cd6eb0\jjwt-impl-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-jackson\0.12.6\f141e0c1136ba17f2632858238a31ae05642dbf8\jjwt-jackson-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.postgresql\postgresql\42.7.10\35100a3f0899551e27af8fed4a3414619a4663b3\postgresql-42.7.10.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.h2database\h2\2.3.232\4fcc05d966ccdb2812ae8b9a718f69226c0cf4e2\h2-2.3.232.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf-lite\1.69.0\91711f27421babf868e424a64426fccb9e8bf6ec\grpc-protobuf-lite-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.52.0\9c17f496846ab1fca8975c6a50ceac0b3bbe63f0\checker-qual-3.52.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.android\annotations\4.1.1.4\a1678ba907bf92691d879fef34e1a187038f9259\annotations-4.1.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.codehaus.mojo\animal-sniffer-annotations\1.24\aa9ba58d30e0aad7f1808fce9c541ea3760678d8\animal-sniffer-annotations-1.24.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-util\1.69.0\1929ab12fce0c610d9d7229b8767f6abb09ebd51\grpc-util-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.perfmark\perfmark-api\0.27.0\f86f575a41b091786a4b027cd9c0c1d2e3fc1c01\perfmark-api-0.27.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-googleapis\1.69.0\1c11a033d96689a4bf3c92fe89b35f5edaef10c2\grpc-googleapis-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-xds\1.69.0\fa1d282a8ba3ae2a5dc0205d2f6a18b5606b5b62\grpc-xds-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-services\1.69.0\bcd917dad2380ee7cf4728da33816ffb9fad6b8b\grpc-services-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.re2j\re2j\1.7\2949632c1b4acce0d7784f28e3152e9cf3c2ec7a\re2j-1.7.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.common\hibernate-commons-annotations\7.0.3.Final\e183c4be8bb41d12e9f19b374e00c34a0a85f439\hibernate-commons-annotations-7.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.smallrye\jandex\3.2.0\f17ad860f62a08487b9edabde608f8ac55c62fa7\jandex-3.2.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\net.bytebuddy\byte-buddy\1.17.8\af5735f63d00ca47a9375fae5c7471a36331c6ed\byte-buddy-1.17.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-runtime\4.0.6\fb95ebb62564657b2fedfe165b859789ef3a8711\jaxb-runtime-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.inject\jakarta.inject-api\2.0.1\4c28afe1991a941d7702fe1362c365f0a8641d1e\jakarta.inject-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-core\4.0.6\8e61282303777fc98a00cc3affd0560d68748a75\jaxb-core-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\apache-client\2.29.52\b7ce213c946d69ab1807b3f7ecac1ce29ed60485\apache-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\netty-nio-client\2.29.52\20fa79ba82d3b290b12cd10ca49f0ff7608a6107\netty-nio-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.eclipse.angus\angus-activation\2.0.3\7f80607ea5014fef0b1779e6c33d63a88a45a563\angus-activation-2.0.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\txw2\4.0.6\4f4cd53b5ff9a2c5aa1211f15ed2569c57dfb044\txw2-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.sun.istack\istack-commons-runtime\4.1.2\18ec117c85f3ba0ac65409136afa8e42bc74e739\istack-commons-runtime-4.1.2.jar +Launcher Type: SUN_STANDARD + +[Global flags] + intx CICompilerCount = 12 {product} {ergonomic} + uint ConcGCThreads = 4 {product} {ergonomic} + uint G1ConcRefinementThreads = 14 {product} {ergonomic} + size_t G1HeapRegionSize = 4194304 {product} {ergonomic} + uintx GCDrainStackTargetSize = 64 {product} {ergonomic} + size_t InitialHeapSize = 532676608 {product} {ergonomic} + bool ManagementServer = true {product} {command line} + size_t MarkStackSize = 4194304 {product} {ergonomic} + size_t MaxHeapSize = 8480882688 {product} {ergonomic} + size_t MinHeapDeltaBytes = 4194304 {product} {ergonomic} + size_t MinHeapSize = 8388608 {product} {ergonomic} + uintx NonNMethodCodeHeapSize = 4096 {pd product} {ergonomic} + uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} + bool ProfileInterpreter = false {pd product} {command line} + uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} + size_t SoftMaxHeapSize = 8480882688 {manageable} {ergonomic} + intx TieredStopAtLevel = 1 {product} {command line} + bool UseCompressedOops = true {product lp64_product} {ergonomic} + bool UseG1GC = true {product} {ergonomic} + bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic} + +Logging: +Log output configuration: + #0: stdout all=warning uptime,level,tags foldmultilines=false + #1: stderr all=off uptime,level,tags foldmultilines=false + +Release file: + +Environment Variables: +JAVA_HOME=C:\Users\guswn\.jdks\corretto-19.0.2 +CLASSPATH=%JAVA_HOME%\lib +PATH=%JAVA_HOME%\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;D:\application\Git\cmd;C:\Program Files\Docker\Docker\resources\bin;D:\application\putty\;C:\Program Files\dotnet\;C:\Program Files\Bandizip\;D:\application\nodejs\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\Scripts\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\;C:\Users\guswn\AppData\Local\Programs\Python\Launcher\;C:\Users\guswn\AppData\Local\Microsoft\WindowsApps;D:\application\Microsoft VS Code\bin;C:\Users\guswn\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\guswn\.local\bin;D:\application\IntelliJ IDEA 2025.3.2\bin;D:\application\JetBrains Gateway 2025.3.2\bin;C:\Users\guswn\AppData\Roaming\npm +USERNAME=guswn +OS=Windows_NT +PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 170 Stepping 4, GenuineIntel +TMP=C:\Users\guswn\AppData\Local\Temp +TEMP=C:\Users\guswn\AppData\Local\Temp + + + + +Periodic native trim disabled + +--------------- S Y S T E M --------------- + +OS: + Windows 11 , 64 bit Build 26100 (10.0.26100.7920) +OS uptime: 16 days 18:33 hours +Hyper-V role detected + +CPU: total 18 (initial active 18) (9 cores per cpu, 2 threads per core) family 6 model 170 stepping 4 microcode 0x1f, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, sha, fma, vzeroupper, clflush, clflushopt, clwb, hv, serialize, rdtscp, rdpid, fsrm, f16c, cet_ibt, cet_ss +Processor Information for processor 0 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 1 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 2 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 3 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 4 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 5 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 6 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 7 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 8 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 9 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 10 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 11 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 12 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 13 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 14 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 15 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 16 + Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 +Processor Information for processor 17 + Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 + +Memory: 4k page, system-wide physical 32346M (3876M free) +TotalPageFile size 61970M (AvailPageFile size 336M) +current process WorkingSet (physical memory assigned to process): 13M, peak: 13M +current process commit charge ("private bytes"): 68M, peak: 576M + +vm_info: OpenJDK 64-Bit Server VM (21.0.10+7-LTS) for windows-amd64 JRE (21.0.10+7-LTS), built on 2026-01-15T22:13:46Z by "Administrator" with MS VC++ 17.14 (VS2022) + +END. diff --git a/hs_err_pid189152.log b/hs_err_pid189152.log new file mode 100644 index 00000000..12c25b95 --- /dev/null +++ b/hs_err_pid189152.log @@ -0,0 +1,281 @@ +# +# There is insufficient memory for the Java Runtime Environment to continue. +# Native memory allocation (mmap) failed to map 532676608 bytes. Error detail: G1 virtual space +# Possible reasons: +# The system is out of physical RAM or swap space +# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap +# Possible solutions: +# Reduce memory load on the system +# Increase physical memory or swap space +# Check if swap backing store is full +# Decrease Java heap size (-Xmx/-Xms) +# Decrease number of Java threads +# Decrease Java thread stack sizes (-Xss) +# Set larger code cache with -XX:ReservedCodeCacheSize= +# JVM is running with Zero Based Compressed Oops mode in which the Java heap is +# placed in the first 32GB address space. The Java Heap base address is the +# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress +# to set the Java Heap base and to place the Java Heap above 32GB virtual address. +# This output file may be truncated or incomplete. +# +# Out of Memory Error (os_windows.cpp:3714), pid=189152, tid=195204 +# +# JRE version: (21.0.10+7) (build ) +# Java VM: OpenJDK 64-Bit Server VM (21.0.10+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) +# No core dump will be written. Minidumps are not enabled by default on client versions of Windows +# + +--------------- S U M M A R Y ------------ + +Command Line: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=55101 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 com.swyp.picke.PickeApplication + +Host: Intel(R) Core(TM) Ultra 5 125H, 18 cores, 31G, Windows 11 , 64 bit Build 26100 (10.0.26100.7920) +Time: Mon Mar 30 22:58:27 2026 elapsed time: 2.032925 seconds (0d 0h 0m 2s) + +--------------- T H R E A D --------------- + +Current thread (0x0000027bde915cc0): JavaThread "Unknown thread" [_thread_in_vm, id=195204, stack(0x00000086bb400000,0x00000086bb500000) (1024K)] + +Stack: [0x00000086bb400000,0x00000086bb500000] +Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) +V [jvm.dll+0x6df2b9] +V [jvm.dll+0x8bbdeb] +V [jvm.dll+0x8be37a] +V [jvm.dll+0x8bea53] +V [jvm.dll+0x28a7a6] +V [jvm.dll+0x6dbc15] +V [jvm.dll+0x6cfbca] +V [jvm.dll+0x364f6e] +V [jvm.dll+0x36ce3b] +V [jvm.dll+0x3be8d9] +V [jvm.dll+0x3beb7b] +V [jvm.dll+0x339137] +V [jvm.dll+0x339c7b] +V [jvm.dll+0x88634e] +V [jvm.dll+0x3cb831] +V [jvm.dll+0x86f25c] +V [jvm.dll+0x45e901] +V [jvm.dll+0x460541] +C [jli.dll+0x52f0] +C [ucrtbase.dll+0x37b0] +C [KERNEL32.DLL+0x2e8d7] +C [ntdll.dll+0x8c48c] + + +--------------- P R O C E S S --------------- + +Threads class SMR info: +_java_thread_list=0x00007ffa227e2208, length=0, elements={ +} + +Java Threads: ( => current thread ) +Total: 0 + +Other Threads: + 0x0000027bf46a03f0 WorkerThread "GC Thread#0" [id=7472, stack(0x00000086bb500000,0x00000086bb600000) (1024K)] + 0x0000027bde990120 ConcurrentGCThread "G1 Main Marker" [id=163140, stack(0x00000086bb600000,0x00000086bb700000) (1024K)] + 0x0000027bde991760 WorkerThread "G1 Conc#0" [id=152476, stack(0x00000086bb700000,0x00000086bb800000) (1024K)] + +[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa21edbbb7] +VM state: not at safepoint (not fully initialized) + +VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event]) +[0x00007ffa228566b0] Heap_lock - owner thread: 0x0000027bde915cc0 + +Heap address: 0x0000000606800000, size: 8088 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 + +CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1. +Narrow klass base: 0x0000000000000000, Narrow klass shift: 0, Narrow klass range: 0x0 + +GC Precious Log: + CardTable entry size: 512 + Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 32 size 80 Howl #buckets 8 coarsen threshold 7372 Howl Bitmap #cards 1024 size 144 coarsen threshold 921 Card regions per heap region 1 cards per card region 8192 + +Heap: + garbage-first heap total 0K, used 0K [0x0000000606800000, 0x0000000800000000) + region size 4096K, 0 young (0K), 0 survivors (0K) + +[error occurred during error reporting (printing heap information), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa222c9019] +GC Heap History (0 events): +No events + +Dll operation events (1 events): +Event: 0.005 Loaded shared library D:\application\.jdk\bin\java.dll + +Deoptimization events (0 events): +No events + +Classes loaded (0 events): +No events + +Classes unloaded (0 events): +No events + +Classes redefined (0 events): +No events + +Internal exceptions (0 events): +No events + +ZGC Phase Switch (0 events): +No events + +VM Operations (0 events): +No events + +Memory protections (0 events): +No events + +Nmethod flushes (0 events): +No events + +Events (0 events): +No events + + +Dynamic libraries: +0x00007ff7fbf60000 - 0x00007ff7fbf6e000 D:\application\.jdk\bin\java.exe +0x00007ffb03b00000 - 0x00007ffb03d67000 C:\WINDOWS\SYSTEM32\ntdll.dll +0x00007ffb02ce0000 - 0x00007ffb02da9000 C:\WINDOWS\System32\KERNEL32.DLL +0x00007ffb00fa0000 - 0x00007ffb01391000 C:\WINDOWS\System32\KERNELBASE.dll +0x00007ffb003e0000 - 0x00007ffb0052b000 C:\WINDOWS\System32\ucrtbase.dll +0x00007ffae63b0000 - 0x00007ffae63c8000 D:\application\.jdk\bin\jli.dll +0x00007ffb02db0000 - 0x00007ffb02f75000 C:\WINDOWS\System32\USER32.dll +0x00007ffb015c0000 - 0x00007ffb015e7000 C:\WINDOWS\System32\win32u.dll +0x00007ffb02780000 - 0x00007ffb027ab000 C:\WINDOWS\System32\GDI32.dll +0x00007ffae7290000 - 0x00007ffae7523000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b\COMCTL32.dll +0x00007ffb031a0000 - 0x00007ffb03249000 C:\WINDOWS\System32\msvcrt.dll +0x00007ffb01770000 - 0x00007ffb0189b000 C:\WINDOWS\System32\gdi32full.dll +0x00007ffaeab10000 - 0x00007ffaeab2e000 D:\application\.jdk\bin\VCRUNTIME140.dll +0x00007ffb005f0000 - 0x00007ffb00693000 C:\WINDOWS\System32\msvcp_win.dll +0x00007ffb03160000 - 0x00007ffb03191000 C:\WINDOWS\System32\IMM32.DLL +0x00007ffaecfa0000 - 0x00007ffaecfac000 D:\application\.jdk\bin\vcruntime140_1.dll +0x00007ffad39c0000 - 0x00007ffad3a49000 D:\application\.jdk\bin\msvcp140.dll +0x00007ffa21b90000 - 0x00007ffa22938000 D:\application\.jdk\bin\server\jvm.dll +0x00007ffb01d30000 - 0x00007ffb01deb000 C:\WINDOWS\System32\ADVAPI32.dll +0x00007ffb03250000 - 0x00007ffb032f7000 C:\WINDOWS\System32\sechost.dll +0x00007ffb024a0000 - 0x00007ffb025b8000 C:\WINDOWS\System32\RPCRT4.dll +0x00007ffb029a0000 - 0x00007ffb02a14000 C:\WINDOWS\System32\WS2_32.dll +0x00007ffb00240000 - 0x00007ffb0029e000 C:\WINDOWS\SYSTEM32\POWRPROF.dll +0x00007ffaec6c0000 - 0x00007ffaec6f5000 C:\WINDOWS\SYSTEM32\WINMM.dll +0x00007ffae7a30000 - 0x00007ffae7a3b000 C:\WINDOWS\SYSTEM32\VERSION.dll +0x00007ffb00220000 - 0x00007ffb00234000 C:\WINDOWS\SYSTEM32\UMPDC.dll +0x00007ffaff170000 - 0x00007ffaff18b000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll +0x00007ffaea260000 - 0x00007ffaea26a000 D:\application\.jdk\bin\jimage.dll +0x00007ffafe4f0000 - 0x00007ffafe732000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL +0x00007ffb01e50000 - 0x00007ffb021d2000 C:\WINDOWS\System32\combase.dll +0x00007ffb02f80000 - 0x00007ffb03057000 C:\WINDOWS\System32\OLEAUT32.dll +0x00007ffada3d0000 - 0x00007ffada40b000 C:\WINDOWS\SYSTEM32\dbgcore.DLL +0x00007ffb013a0000 - 0x00007ffb01445000 C:\WINDOWS\System32\bcryptPrimitives.dll +0x00007ffae63a0000 - 0x00007ffae63b0000 D:\application\.jdk\bin\instrument.dll +0x00007ffae6310000 - 0x00007ffae6331000 D:\application\.jdk\bin\java.dll + +JVMTI agents: +C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar path:none, loaded, not initialized, instrumentlib options:55101 + +dbghelp: loaded successfully - version: 4.0.5 - missing functions: none +symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;D:\application\.jdk\bin;C:\WINDOWS\SYSTEM32;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b;D:\application\.jdk\bin\server + +VM Arguments: +jvm_args: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=55101 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 +java_command: com.swyp.picke.PickeApplication +java_class_path (initial): D:\Desktop\project\Server\build\classes\java\main;D:\Desktop\project\Server\build\resources\main;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.projectlombok\lombok\1.18.42\8365263844ebb62398e0dc33057ba10ba472d3b8\lombok-1.18.42.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-web\3.5.11\68fde4c94249e92526105a93ac7c22bd89b6945e\spring-boot-starter-web-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-ui\2.8.16\61c68f705d3f17e8318fb18b2904fa6368af251c\springdoc-openapi-starter-webmvc-ui-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-validation\3.5.11\903eefb6eab302617b0f01cc6d65664343bff2a7\spring-boot-starter-validation-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-data-jpa\3.5.11\f176e5c643720818ec7910e1dd2ccb402411cc5d\spring-boot-starter-data-jpa-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-security\3.5.11\db8b6b7951883dea3ce7404f20d6816104cedd4e\spring-boot-starter-security-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-api\0.12.6\478886a888f6add04937baf0361144504a024967\jjwt-api-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-webflux\3.5.11\1461f9a6b6b8397ad71a98c9bbf4278159fb9624\spring-boot-starter-webflux-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\apps-rewardedads\1.9.1\cbaf11457b36fe57d90a5cb16a76833906486503\apps-rewardedads-1.9.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.cloud\google-cloud-texttospeech\2.58.0\9be37bd3c81c14c72c9cbcfa2dfaf6dad7a35075\google-cloud-texttospeech-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter-s3\3.3.0\fa9790f990ab540814aafdbf2e97c8cd53b5b1a6\spring-cloud-aws-starter-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-thymeleaf\3.5.11\d997aa0df579cf43507d425057940e2712e44808\spring-boot-starter-thymeleaf-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-json\3.5.11\4cdcd68dcddf0a4c645166e39c3fe448fe2b8e98\spring-boot-starter-json-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter\3.5.11\10ce971300fd56d6be5f1cfe7d27ddfb1ed7158d\spring-boot-starter-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-tomcat\3.5.11\fb7b96cb61e5fd5700aed96194562e32d166b5ef\spring-boot-starter-tomcat-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webmvc\6.2.16\ff2db80406f1459fddd14a8d06d57e0e3ab69465\spring-webmvc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-web\6.2.16\2c4355f1f7e5b8969f696cbc90f25cc22f0f2164\spring-web-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-api\2.8.16\6e41988d84978e529c01a4cc052b761cd27d5b90\springdoc-openapi-starter-webmvc-api-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\swagger-ui\5.32.0\d04c7e3e5b8616813136fa36382a548751775528\swagger-ui-5.32.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\webjars-locator-lite\1.1.3\217ce590453251b39b72c4a9af3986998f6fdbd9\webjars-locator-lite-1.1.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-el\10.1.52\cd94ce17c5a9937eca365eb494711efa10d49b86\tomcat-embed-el-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.validator\hibernate-validator\8.0.3.Final\4425f554297a1c5ba03a3f30e559a9fd91048cf8\hibernate-validator-8.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-jdbc\3.5.11\3ce801963caadf6eb29abd68f5a0fe50c9bfe211\spring-boot-starter-jdbc-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.orm\hibernate-core\6.6.42.Final\996e3df4a6c67941b582e4493cb9a39c83198f1e\hibernate-core-6.6.42.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-jpa\3.5.9\56081dde4f663db74ba000c1f8ab30673058c363\spring-data-jpa-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aspects\6.2.16\763140a66821c494985533f29280a3b4132cf055\spring-aspects-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-web\6.5.8\3db7bf41191d5b23493cca6252595405b5112b34\spring-security-web-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-config\6.5.8\302d32eba89131c0ffd15ba0a1e465051336d42f\spring-security-config-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aop\6.2.16\59250efa248420a114fe23b4ccf2fea46b804186\spring-aop-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webflux\6.2.16\699ef8bc182893f9ed43206d372f20c4f9aa3231\spring-webflux-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-reactor-netty\3.5.11\f98bd3e3019078679dec4f21fb63152aa2e059a7\spring-boot-starter-reactor-netty-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\tink\1.10.0\84771b1a4bb5726f73fb8490fadb23f1d2aacd38\tink-1.10.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.gson\gson\2.13.2\48b8230771e573b54ce6e867a9001e75977fe78e\gson-2.13.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client\1.45.3\dde98b597081b98514867c9cefa551fcdea3a28c\google-http-client-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.errorprone\error_prone_annotations\2.41.0\4381275efdef6ddfae38f002c31e84cd001c97f0\error_prone_annotations-2.41.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-contrib-http-util\0.31.1\3c13fc5715231fadb16a9b74a44d9d59c460cfa8\opencensus-contrib-http-util-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\guava\33.4.0-jre\3fcc0a259f724c7de54a6a55ea7e26d3d5c0cac\guava-33.4.0-jre.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-api\1.69.0\965c2c7f708cd6e6ddbf1eb175c3e87e96e41297\grpc-api-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.findbugs\jsr305\3.0.2\25ea2e8b0c338a877313bd4672d3fe056ea78f0d\jsr305-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-stub\1.69.0\9e7dc30a9c2df70e25ef4b941f46187e6e178e7a\grpc-stub-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf\1.69.0\2990b4948357d4fe46aaecb47290cff102079f1e\grpc-protobuf-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\api-common\2.43.0\963d97d95e9bf7275cc26f0b6b72e2aa5b92c6fd\api-common-2.43.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auto.value\auto-value-annotations\1.11.0\f0d047931d07cfbc6fa4079854f181ff62891d6f\auto-value-annotations-1.11.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\javax.annotation\javax.annotation-api\1.3.2\934c04d3cfef185a8008e7bf34331b79730a9d43\javax.annotation-api-1.3.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.j2objc\j2objc-annotations\3.0.0\7399e65dd7e9ff3404f4535b2f017093bdb134c7\j2objc-annotations-3.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java\3.25.5\5ae5c9ec39930ae9b5a61b32b93288818ec05ec1\protobuf-java-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-common-protos\2.51.0\ead75a32e6fd65740b6a69feb658254aeab3fef0\proto-google-common-protos-2.51.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1\2.58.0\42f1f29876ddfa2523ebcc41dae801195fd8b3ce\proto-google-cloud-texttospeech-v1-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1beta1\0.147.0\576df432a2c1181deabf21a54fdecc1a32f69f4e\proto-google-cloud-texttospeech-v1beta1-0.147.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\failureaccess\1.0.2\c4a06a64e650562f30b7bf9aaec1bfed43aca12b\failureaccess-1.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\b421526c5f297295adef1c886e5246c39d4ac629\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.48.4\6b5d69a61012211d581e68699baf3beb1fd382da\checker-qual-3.48.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax\2.60.0\2d277e0795cb69bc14e03be068aa002539e3ef49\gax-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-credentials\1.31.0\b9cd5346d3a683d9a8d9786453f2419cc832a97f\google-auth-library-credentials-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-api\0.31.1\66a60c7201c2b8b20ce495f0295b32bb0ccbbc57\opencensus-api-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-context\1.69.0\cea23878872f76418dcd6df0c6eef0bf27463537\grpc-context-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-oauth2-http\1.31.0\df5be46d21b983aab8d0250f19b585a94bdedcde\google-auth-library-oauth2-http-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-grpc\2.60.0\ca4d7dc8c2a85fbdba25ff3449726852e2359ae9\gax-grpc-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-inprocess\1.69.0\8ac4d2e13b48bed9624b8bc485c90f3d28820c93\grpc-inprocess-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-core\1.69.0\7dad3419dfb91a77788afcdf79e0477172784910\grpc-core-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-alts\1.69.0\6d1eac6726fd6fd177666c10fd154823b82272eb\grpc-alts-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-grpclb\1.69.0\d2c9c066693ce94805a503bc47f5b1e76f51541c\grpc-grpclb-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.conscrypt\conscrypt-openjdk-uber\2.5.2\d858f142ea189c62771c505a6548d8606ac098fe\conscrypt-openjdk-uber-2.5.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-auth\1.69.0\a75e19b20bb732364bdcc0979e9d7c9baa4e408e\grpc-auth-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-netty-shaded\1.69.0\99aa9789172695a4b09fe2af5f5bd0ab1be4ae85\grpc-netty-shaded-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-httpjson\2.60.0\131d9283925337406e35561ec17bf326a9ecec1a\gax-httpjson-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpclient\4.5.14\1194890e6f56ec29177673f2f12d0b8e627dec98\httpclient-4.5.14.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\commons-codec\commons-codec\1.18.0\ee45d1cf6ec2cc2b809ff04b4dc7aec858e0df8f\commons-codec-1.18.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpcore\4.4.16\51cf043c87253c9f58b539c9f7e44c8894223850\httpcore-4.4.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client-gson\1.45.3\29eba40245c4a4e5466f8764bd894d6a97c6694f\google-http-client-gson-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java-util\3.25.5\38cc5ce479603e36466feda2a9f1dfdb2210ef00\protobuf-java-util-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.threeten\threetenbp\1.7.0\8703e893440e550295aa358281db468625bc9a05\threetenbp-1.7.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter\3.3.0\7d82d320cb1851beca3005eab2e484a38bd58a08\spring-cloud-aws-starter-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-s3\3.3.0\661e2914e3ad6555e20ffa262a9987c15bcc1712\spring-cloud-aws-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\slf4j-api\2.0.17\d9e58ac9c7779ba3bf8142aff6c830617a7fe60f\slf4j-api-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf-spring6\3.1.3.RELEASE\4b276ea2bd536a18e44b40ff1d9f4848965ff59c\thymeleaf-spring6-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jdk8\2.19.4\90d304bcdb1a4bacb6f4347be625d75300973c60\jackson-datatype-jdk8-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jsr310\2.19.4\3cbcf2e636a6b062772299bf19a347536e58c4df\jackson-datatype-jsr310-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.module\jackson-module-parameter-names\2.19.4\502dfea4c83502f444837b3d040a51e8475f15f2\jackson-module-parameter-names-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-databind\2.19.4\7a39bf9257b726b90b80f27fa3f5174bc75162a5\jackson-databind-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-autoconfigure\3.5.11\3c7d2ec2ac3c301e95814e37fed1c86c19927fc4\spring-boot-autoconfigure-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot\3.5.11\8b7f6df00bfbe74d370e1d05d985a127884d2a9c\spring-boot-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-logging\3.5.11\62b692ed7aee31a5670796be8b07732b6b836f4e\spring-boot-starter-logging-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.annotation\jakarta.annotation-api\2.1.1\48b9bda22b091b1f48b13af03fe36db3be6e1ae3\jakarta.annotation-api-2.1.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-core\6.2.16\a73937f20a303e057add523915b48eb7901e1848\spring-core-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.yaml\snakeyaml\2.4\e0666b825b796f85521f02360e77f4c92c5a7a07\snakeyaml-2.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-websocket\10.1.52\9d32b801fb474306349013fcdd8317c8cb4d739e\tomcat-embed-websocket-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-core\10.1.52\f512bef2796b51299f4752f95918982c3003131d\tomcat-embed-core-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-context\6.2.16\caeae6bd50832d6ab28f707aa740e957401a5c20\spring-context-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-beans\6.2.16\990289064c810be71630fca9da8e2b6fe8f897b5\spring-beans-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-expression\6.2.16\e293ab797b1698084e56ae1f2362b315148683f6\spring-expression-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-observation\1.15.9\edf37b25cdfac0704d6fefa4543edb3ed1817eb0\micrometer-observation-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-common\2.8.16\5b702cb484981b42cfb455bd80b6ce7f49d34210\springdoc-openapi-starter-common-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jspecify\jspecify\1.0.0\7425a601c1c7ec76645a78d22b8c6a627edee507\jspecify-1.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.validation\jakarta.validation-api\3.0.2\92b6631659ba35ca09e44874d3eb936edfeee532\jakarta.validation-api-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jboss.logging\jboss-logging\3.6.2.Final\3e0a139d7a74cc13b5e01daa8aaa7f71dccd577e\jboss-logging-3.6.2.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml\classmate\1.7.3\f61c7e7b81e9249b0f6a05914eff9d54fb09f4a0\classmate-1.7.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.zaxxer\HikariCP\6.3.3\7c5aec1e47a97ff40977e0193018865304ea9585\HikariCP-6.3.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jdbc\6.2.16\addfdde7b3212f34c95d791c37bb04ba4b08a1b7\spring-jdbc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.persistence\jakarta.persistence-api\3.1.0\66901fa1c373c6aff65c13791cc11da72060a8d6\jakarta.persistence-api-3.1.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.transaction\jakarta.transaction-api\2.0.1\51a520e3fae406abb84e2e1148e6746ce3f80a1a\jakarta.transaction-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-commons\3.5.9\6b577c71f563e78a7da984a3d572fde8a4df8103\spring-data-commons-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-orm\6.2.16\44b3cfb2c046440f83729641c929c405dc7f2c89\spring-orm-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-tx\6.2.16\5f9d6e78b76530e6258de8a0dff991fb1ad4b9b0\spring-tx-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.antlr\antlr4-runtime\4.13.0\5a02e48521624faaf5ff4d99afc88b01686af655\antlr4-runtime-4.13.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.aspectj\aspectjweaver\1.9.25.1\a713c790da4d794c7dfb542b550d4e44898d5e23\aspectjweaver-1.9.25.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-core\6.5.8\d052dca52e49d95d2b03f81ae4b6762eeb4c78d0\spring-security-core-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor\reactor-core\3.7.16\dc7f2ba3c4fbc69678937dfe1ad45264d8a1c7be\reactor-core-3.7.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-http\1.2.15\b20bb13c95b44f1d0c148bdf1197b7d4a7e0f278\reactor-netty-http-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-autoconfigure\3.3.0\b5a0b27e91ee997f8c86e4b0c521858fdcb9dc9b\spring-cloud-aws-autoconfigure-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-core\3.3.0\3c501426267d8ccbaaebc9796bd5de5bc5d0702e\spring-cloud-aws-core-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\s3\2.29.52\db65bc6177b0c4514be1f9775cb2094e29e85d3c\s3-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf\3.1.3.RELEASE\51474f2a90b282ee97dabcd159c7faf24790f373\thymeleaf-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-core\2.19.4\a720ca9b800742699e041c3890f3731fe516085e\jackson-core-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-annotations\2.19.4\bbb09b1e7f7f5108890270eb701cb3ddef991c05\jackson-annotations-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-classic\1.5.32\2b1042c50f508f2eb402bd4d22ccbdf94cc37d2e\logback-classic-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-to-slf4j\2.24.3\da1143e2a2531ee1c2d90baa98eb50a28a39d5a7\log4j-to-slf4j-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\jul-to-slf4j\2.0.17\524cb6ccc2b68a57604750e1ab8b13b5a786a6aa\jul-to-slf4j-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jcl\6.2.16\8af6546d28815be574f384dceb93d248e9934f90\spring-jcl-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-commons\1.15.9\5a38f43cdc79a309a458c8ce130fff30a2a7f59\micrometer-commons-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-core-jakarta\2.2.43\500566364be54e3556bcec28922a41ca5fcc7dcd\swagger-core-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-crypto\6.5.8\aec1a6f6c0e06be9dff08b11e8e1f457afca44b2\spring-security-crypto-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.reactivestreams\reactive-streams\1.0.4\3864a1320d97d7b045f729a326e1e077661f31b7\reactive-streams-1.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-core\1.2.15\94b2ca82f310c1bf31d3038060e4572eeca1d4b2\reactor-netty-core-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http2\4.1.131.Final\2e4c47131c60e0bbbca067c891597b466f7033ba\netty-codec-http2-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http\4.1.131.Final\253d80637ed689ed309ca62371e5fb97746b165\netty-codec-http-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-native-macos\4.1.131.Final\9e4a908c073e56caa4127f54d946e3e9a5208506\netty-resolver-dns-native-macos-4.1.131.Final-osx-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns\4.1.131.Final\7714c0babe26712ccfdbc158aa64898ab909e7d8\netty-resolver-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-epoll\4.1.131.Final\10b7a905019c1ad5c37e8cf63d7229fb00668c1d\netty-transport-native-epoll-4.1.131.Final-linux-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-core\2.29.52\dcfa86ac727b5d4e0abad1e8b025ac2febb6382e\aws-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\auth\2.29.52\76f9b22a99b0de0fd31447db22a5cba4ed4b172e\auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\regions\2.29.52\270b31c8695739d495452d380d036c72698e623\regions-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-xml-protocol\2.29.52\e7290d4528affec022bd2f3739853f774a955ac2\aws-xml-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\protocol-core\2.29.52\1a0e4a114c0943142ca395000a949ee840890fea\protocol-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\sdk-core\2.29.52\3f058b489fac3d091417339e02b165e72c637f61\sdk-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\arns\2.29.52\879712423589b58434b8831b9b75304a16983178\arns-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\profiles\2.29.52\59dd1368bff2d242d84515ec7ea8fe63bb472c4e\profiles-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\crt-core\2.29.52\6a16e04be0e8bb8a1767e0644c631dadddfdd764\crt-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth\2.29.52\aa4ce3ff7bcd8dcf131a4f5445455b3eb4926dcf\http-auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws\2.29.52\1d0dbfa072bc46207066ffa498ad4ed65c52ac6d\http-auth-aws-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-spi\2.29.52\4a64e68a88e3eef0b51819f742931f3607cdd996\http-auth-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\identity-spi\2.29.52\d18449651e8798398cadab6c4b5d8594bb0281c\identity-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums\2.29.52\90631313060ff8ef1ab7745bb1e9740913bdcefc\checksums-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries-spi\2.29.52\7e2c7ad44106799491de8cced5925b6473d62e4b\retries-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-client-spi\2.29.52\a8da4f289736c702ec6664836761412e7e1e54a2\http-client-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\metrics-spi\2.29.52\92c3797208d24b2b25ab9b6d1bbab624c3af1b9c\metrics-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\json-utils\2.29.52\d88c6c03061b9f3fcd17dc8456365dab67cc1597\json-utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\utils\2.29.52\bde94a15cd79b0240bfa10230970e2f0e4c51eba\utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums-spi\2.29.52\537363296f035a935b7d3b50a5bef90014d38010\checksums-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\endpoints-spi\2.29.52\bd702a44ad440628af93afa1ec1d7cdc56baec67\endpoints-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\annotations\2.29.52\9fa958ce528b57d90db01c5015daaf7bd373e57f\annotations-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.attoparser\attoparser\2.0.7.RELEASE\e5d0e988d9124139d645bb5872b24dfa23e283cc\attoparser-2.0.7.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.unbescape\unbescape\1.1.6.RELEASE\7b90360afb2b860e09e8347112800d12c12b2a13\unbescape-1.1.6.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-core\1.5.32\fdfb3ff9a842303d4a95207294a6c6bc64e2605d\logback-core-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-api\2.24.3\b02c125db8b6d295adf72ae6e71af5d83bce2370\log4j-api-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-models-jakarta\2.2.43\a68f7470eb763609878460272000f260eabc24dc\swagger-models-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.dataformat\jackson-dataformat-yaml\2.19.4\500956daea0869bf753b94fdaa77e5dc99847d79\jackson-dataformat-yaml-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.commons\commons-lang3\3.17.0\b17d2136f0460dcc0d2016ceefca8723bdf4ee70\commons-lang3-3.17.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-annotations-jakarta\2.2.43\dbd40253251deabb7a628a54b4550dc4fb492f4\swagger-annotations-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.xml.bind\jakarta.xml.bind-api\4.0.4\d6d2327f3817d9a33a3b6b8f2e15a96bc2e7afdc\jakarta.xml.bind-api-4.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler-proxy\4.1.131.Final\5ff9e74613a9dd3ca078f06880a16c8cdc046de0\netty-handler-proxy-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler\4.1.131.Final\5ca67999f41c0a68f0b66485ceb990683a0b0694\netty-handler-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec\4.1.131.Final\1874341f7b29879c6833c17e7305272f0cdc2cb6\netty-codec-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport\4.1.131.Final\474862e0855d7a9828fab06a9c73c05387604ee3\netty-transport-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-buffer\4.1.131.Final\f97b636ecd9b81ae3fd1d039b69c4fd3959ecf\netty-buffer-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-common\4.1.131.Final\cdc659109da226b698a74b543a5b97dd0f7e6959\netty-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-classes-macos\4.1.131.Final\b9d57038cc4144e36aee5898085b7f1f018d2c9f\netty-resolver-dns-classes-macos-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-dns\4.1.131.Final\cd23e12e5c3448a1b12c8a4b8deeb4faeb5e483e\netty-codec-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver\4.1.131.Final\9db1bfd7c57b9b6aa9b5cfc61fc3304594bb6b39\netty-resolver-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-classes-epoll\4.1.131.Final\4d7848ac709491fb14f8bce2796fc3eff4a04fd6\netty-transport-classes-epoll-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-unix-common\4.1.131.Final\fa975e4751b23d50c0a60569829f31944d11d292\netty-transport-native-unix-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries\2.29.52\48cb57817dd88977ec71e63550673c5ce010a191\retries-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.eventstream\eventstream\1.0.1\6ff8649dffc5190366ada897ba8525a836297784\eventstream-1.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws-eventstream\2.29.52\e8b723c48008bcac96e2cc34c7415bd8b581c601\http-auth-aws-eventstream-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-query-protocol\2.29.52\672d7a2df481414d02eedf3a9eff45fb87f1b8a\aws-query-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\third-party-jackson-core\2.29.52\82ff600d837e83130502775a1555c45d7a3e2e1\third-party-jackson-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.activation\jakarta.activation-api\2.1.4\9e5c2a0d75dde71a0bedc4dbdbe47b78a5dc50f8\jakarta.activation-api-2.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-socks\4.1.131.Final\614eacc17f44d8abaeaea81f210b4980c3568262\netty-codec-socks-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-devtools\3.5.11\fe7dfcaf3153d049909a618a7ba7df288e80f090\spring-boot-devtools-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-impl\0.12.6\ac23673a84b6089e0369fb8ab2c69edd91cd6eb0\jjwt-impl-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-jackson\0.12.6\f141e0c1136ba17f2632858238a31ae05642dbf8\jjwt-jackson-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.postgresql\postgresql\42.7.10\35100a3f0899551e27af8fed4a3414619a4663b3\postgresql-42.7.10.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.h2database\h2\2.3.232\4fcc05d966ccdb2812ae8b9a718f69226c0cf4e2\h2-2.3.232.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf-lite\1.69.0\91711f27421babf868e424a64426fccb9e8bf6ec\grpc-protobuf-lite-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.52.0\9c17f496846ab1fca8975c6a50ceac0b3bbe63f0\checker-qual-3.52.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.android\annotations\4.1.1.4\a1678ba907bf92691d879fef34e1a187038f9259\annotations-4.1.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.codehaus.mojo\animal-sniffer-annotations\1.24\aa9ba58d30e0aad7f1808fce9c541ea3760678d8\animal-sniffer-annotations-1.24.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-util\1.69.0\1929ab12fce0c610d9d7229b8767f6abb09ebd51\grpc-util-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.perfmark\perfmark-api\0.27.0\f86f575a41b091786a4b027cd9c0c1d2e3fc1c01\perfmark-api-0.27.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-googleapis\1.69.0\1c11a033d96689a4bf3c92fe89b35f5edaef10c2\grpc-googleapis-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-xds\1.69.0\fa1d282a8ba3ae2a5dc0205d2f6a18b5606b5b62\grpc-xds-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-services\1.69.0\bcd917dad2380ee7cf4728da33816ffb9fad6b8b\grpc-services-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.re2j\re2j\1.7\2949632c1b4acce0d7784f28e3152e9cf3c2ec7a\re2j-1.7.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.common\hibernate-commons-annotations\7.0.3.Final\e183c4be8bb41d12e9f19b374e00c34a0a85f439\hibernate-commons-annotations-7.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.smallrye\jandex\3.2.0\f17ad860f62a08487b9edabde608f8ac55c62fa7\jandex-3.2.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\net.bytebuddy\byte-buddy\1.17.8\af5735f63d00ca47a9375fae5c7471a36331c6ed\byte-buddy-1.17.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-runtime\4.0.6\fb95ebb62564657b2fedfe165b859789ef3a8711\jaxb-runtime-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.inject\jakarta.inject-api\2.0.1\4c28afe1991a941d7702fe1362c365f0a8641d1e\jakarta.inject-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-core\4.0.6\8e61282303777fc98a00cc3affd0560d68748a75\jaxb-core-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\apache-client\2.29.52\b7ce213c946d69ab1807b3f7ecac1ce29ed60485\apache-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\netty-nio-client\2.29.52\20fa79ba82d3b290b12cd10ca49f0ff7608a6107\netty-nio-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.eclipse.angus\angus-activation\2.0.3\7f80607ea5014fef0b1779e6c33d63a88a45a563\angus-activation-2.0.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\txw2\4.0.6\4f4cd53b5ff9a2c5aa1211f15ed2569c57dfb044\txw2-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.sun.istack\istack-commons-runtime\4.1.2\18ec117c85f3ba0ac65409136afa8e42bc74e739\istack-commons-runtime-4.1.2.jar +Launcher Type: SUN_STANDARD + +[Global flags] + intx CICompilerCount = 12 {product} {ergonomic} + uint ConcGCThreads = 4 {product} {ergonomic} + uint G1ConcRefinementThreads = 14 {product} {ergonomic} + size_t G1HeapRegionSize = 4194304 {product} {ergonomic} + uintx GCDrainStackTargetSize = 64 {product} {ergonomic} + size_t InitialHeapSize = 532676608 {product} {ergonomic} + bool ManagementServer = true {product} {command line} + size_t MarkStackSize = 4194304 {product} {ergonomic} + size_t MaxHeapSize = 8480882688 {product} {ergonomic} + size_t MinHeapDeltaBytes = 4194304 {product} {ergonomic} + size_t MinHeapSize = 8388608 {product} {ergonomic} + uintx NonNMethodCodeHeapSize = 4096 {pd product} {ergonomic} + uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} + bool ProfileInterpreter = false {pd product} {command line} + uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} + size_t SoftMaxHeapSize = 8480882688 {manageable} {ergonomic} + intx TieredStopAtLevel = 1 {product} {command line} + bool UseCompressedOops = true {product lp64_product} {ergonomic} + bool UseG1GC = true {product} {ergonomic} + bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic} + +Logging: +Log output configuration: + #0: stdout all=warning uptime,level,tags foldmultilines=false + #1: stderr all=off uptime,level,tags foldmultilines=false + +Release file: + +Environment Variables: +JAVA_HOME=C:\Users\guswn\.jdks\corretto-19.0.2 +CLASSPATH=%JAVA_HOME%\lib +PATH=%JAVA_HOME%\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;D:\application\Git\cmd;C:\Program Files\Docker\Docker\resources\bin;D:\application\putty\;C:\Program Files\dotnet\;C:\Program Files\Bandizip\;D:\application\nodejs\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\Scripts\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\;C:\Users\guswn\AppData\Local\Programs\Python\Launcher\;C:\Users\guswn\AppData\Local\Microsoft\WindowsApps;D:\application\Microsoft VS Code\bin;C:\Users\guswn\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\guswn\.local\bin;D:\application\IntelliJ IDEA 2025.3.2\bin;D:\application\JetBrains Gateway 2025.3.2\bin;C:\Users\guswn\AppData\Roaming\npm +USERNAME=guswn +OS=Windows_NT +PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 170 Stepping 4, GenuineIntel +TMP=C:\Users\guswn\AppData\Local\Temp +TEMP=C:\Users\guswn\AppData\Local\Temp + + + + +Periodic native trim disabled + +--------------- S Y S T E M --------------- + +OS: + Windows 11 , 64 bit Build 26100 (10.0.26100.7920) +OS uptime: 16 days 18:33 hours +Hyper-V role detected + +CPU: total 18 (initial active 18) (9 cores per cpu, 2 threads per core) family 6 model 170 stepping 4 microcode 0x1f, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, sha, fma, vzeroupper, clflush, clflushopt, clwb, hv, serialize, rdtscp, rdpid, fsrm, f16c, cet_ibt, cet_ss +Processor Information for processor 0 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 1 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 2 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 3 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 4 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 5 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 6 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 7 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 8 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 9 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 10 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 11 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 12 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 13 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 14 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 15 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 16 + Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 +Processor Information for processor 17 + Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 + +Memory: 4k page, system-wide physical 32346M (3826M free) +TotalPageFile size 61970M (AvailPageFile size 310M) +current process WorkingSet (physical memory assigned to process): 13M, peak: 13M +current process commit charge ("private bytes"): 68M, peak: 576M + +vm_info: OpenJDK 64-Bit Server VM (21.0.10+7-LTS) for windows-amd64 JRE (21.0.10+7-LTS), built on 2026-01-15T22:13:46Z by "Administrator" with MS VC++ 17.14 (VS2022) + +END. diff --git a/hs_err_pid192028.log b/hs_err_pid192028.log new file mode 100644 index 00000000..cd8d3a59 --- /dev/null +++ b/hs_err_pid192028.log @@ -0,0 +1,281 @@ +# +# There is insufficient memory for the Java Runtime Environment to continue. +# Native memory allocation (mmap) failed to map 532676608 bytes. Error detail: G1 virtual space +# Possible reasons: +# The system is out of physical RAM or swap space +# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap +# Possible solutions: +# Reduce memory load on the system +# Increase physical memory or swap space +# Check if swap backing store is full +# Decrease Java heap size (-Xmx/-Xms) +# Decrease number of Java threads +# Decrease Java thread stack sizes (-Xss) +# Set larger code cache with -XX:ReservedCodeCacheSize= +# JVM is running with Zero Based Compressed Oops mode in which the Java heap is +# placed in the first 32GB address space. The Java Heap base address is the +# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress +# to set the Java Heap base and to place the Java Heap above 32GB virtual address. +# This output file may be truncated or incomplete. +# +# Out of Memory Error (os_windows.cpp:3714), pid=192028, tid=189352 +# +# JRE version: (21.0.10+7) (build ) +# Java VM: OpenJDK 64-Bit Server VM (21.0.10+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) +# No core dump will be written. Minidumps are not enabled by default on client versions of Windows +# + +--------------- S U M M A R Y ------------ + +Command Line: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=55095 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 com.swyp.picke.PickeApplication + +Host: Intel(R) Core(TM) Ultra 5 125H, 18 cores, 31G, Windows 11 , 64 bit Build 26100 (10.0.26100.7920) +Time: Mon Mar 30 22:58:20 2026 elapsed time: 2.059801 seconds (0d 0h 0m 2s) + +--------------- T H R E A D --------------- + +Current thread (0x0000012fb4ed3030): JavaThread "Unknown thread" [_thread_in_vm, id=189352, stack(0x00000005f8500000,0x00000005f8600000) (1024K)] + +Stack: [0x00000005f8500000,0x00000005f8600000] +Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) +V [jvm.dll+0x6df2b9] +V [jvm.dll+0x8bbdeb] +V [jvm.dll+0x8be37a] +V [jvm.dll+0x8bea53] +V [jvm.dll+0x28a7a6] +V [jvm.dll+0x6dbc15] +V [jvm.dll+0x6cfbca] +V [jvm.dll+0x364f6e] +V [jvm.dll+0x36ce3b] +V [jvm.dll+0x3be8d9] +V [jvm.dll+0x3beb7b] +V [jvm.dll+0x339137] +V [jvm.dll+0x339c7b] +V [jvm.dll+0x88634e] +V [jvm.dll+0x3cb831] +V [jvm.dll+0x86f25c] +V [jvm.dll+0x45e901] +V [jvm.dll+0x460541] +C [jli.dll+0x52f0] +C [ucrtbase.dll+0x37b0] +C [KERNEL32.DLL+0x2e8d7] +C [ntdll.dll+0x8c48c] + + +--------------- P R O C E S S --------------- + +Threads class SMR info: +_java_thread_list=0x00007ffa227e2208, length=0, elements={ +} + +Java Threads: ( => current thread ) +Total: 0 + +Other Threads: + 0x0000012fcd0203f0 WorkerThread "GC Thread#0" [id=193216, stack(0x00000005f8600000,0x00000005f8700000) (1024K)] + 0x0000012fb7315ca0 ConcurrentGCThread "G1 Main Marker" [id=154308, stack(0x00000005f8700000,0x00000005f8800000) (1024K)] + 0x0000012fb73180e0 WorkerThread "G1 Conc#0" [id=191576, stack(0x00000005f8800000,0x00000005f8900000) (1024K)] + +[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa21edbbb7] +VM state: not at safepoint (not fully initialized) + +VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event]) +[0x00007ffa228566b0] Heap_lock - owner thread: 0x0000012fb4ed3030 + +Heap address: 0x0000000606800000, size: 8088 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 + +CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1. +Narrow klass base: 0x0000000000000000, Narrow klass shift: 0, Narrow klass range: 0x0 + +GC Precious Log: + CardTable entry size: 512 + Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 32 size 80 Howl #buckets 8 coarsen threshold 7372 Howl Bitmap #cards 1024 size 144 coarsen threshold 921 Card regions per heap region 1 cards per card region 8192 + +Heap: + garbage-first heap total 0K, used 0K [0x0000000606800000, 0x0000000800000000) + region size 4096K, 0 young (0K), 0 survivors (0K) + +[error occurred during error reporting (printing heap information), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa222c9019] +GC Heap History (0 events): +No events + +Dll operation events (1 events): +Event: 0.044 Loaded shared library D:\application\.jdk\bin\java.dll + +Deoptimization events (0 events): +No events + +Classes loaded (0 events): +No events + +Classes unloaded (0 events): +No events + +Classes redefined (0 events): +No events + +Internal exceptions (0 events): +No events + +ZGC Phase Switch (0 events): +No events + +VM Operations (0 events): +No events + +Memory protections (0 events): +No events + +Nmethod flushes (0 events): +No events + +Events (0 events): +No events + + +Dynamic libraries: +0x00007ff7fbf60000 - 0x00007ff7fbf6e000 D:\application\.jdk\bin\java.exe +0x00007ffb03b00000 - 0x00007ffb03d67000 C:\WINDOWS\SYSTEM32\ntdll.dll +0x00007ffb02ce0000 - 0x00007ffb02da9000 C:\WINDOWS\System32\KERNEL32.DLL +0x00007ffb00fa0000 - 0x00007ffb01391000 C:\WINDOWS\System32\KERNELBASE.dll +0x00007ffb003e0000 - 0x00007ffb0052b000 C:\WINDOWS\System32\ucrtbase.dll +0x00007ffaeab10000 - 0x00007ffaeab2e000 D:\application\.jdk\bin\VCRUNTIME140.dll +0x00007ffae63b0000 - 0x00007ffae63c8000 D:\application\.jdk\bin\jli.dll +0x00007ffb02db0000 - 0x00007ffb02f75000 C:\WINDOWS\System32\USER32.dll +0x00007ffae7290000 - 0x00007ffae7523000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b\COMCTL32.dll +0x00007ffb015c0000 - 0x00007ffb015e7000 C:\WINDOWS\System32\win32u.dll +0x00007ffb031a0000 - 0x00007ffb03249000 C:\WINDOWS\System32\msvcrt.dll +0x00007ffb02780000 - 0x00007ffb027ab000 C:\WINDOWS\System32\GDI32.dll +0x00007ffb01770000 - 0x00007ffb0189b000 C:\WINDOWS\System32\gdi32full.dll +0x00007ffb005f0000 - 0x00007ffb00693000 C:\WINDOWS\System32\msvcp_win.dll +0x00007ffb03160000 - 0x00007ffb03191000 C:\WINDOWS\System32\IMM32.DLL +0x00007ffaecfa0000 - 0x00007ffaecfac000 D:\application\.jdk\bin\vcruntime140_1.dll +0x00007ffad39c0000 - 0x00007ffad3a49000 D:\application\.jdk\bin\msvcp140.dll +0x00007ffa21b90000 - 0x00007ffa22938000 D:\application\.jdk\bin\server\jvm.dll +0x00007ffb01d30000 - 0x00007ffb01deb000 C:\WINDOWS\System32\ADVAPI32.dll +0x00007ffb03250000 - 0x00007ffb032f7000 C:\WINDOWS\System32\sechost.dll +0x00007ffb024a0000 - 0x00007ffb025b8000 C:\WINDOWS\System32\RPCRT4.dll +0x00007ffb029a0000 - 0x00007ffb02a14000 C:\WINDOWS\System32\WS2_32.dll +0x00007ffb00240000 - 0x00007ffb0029e000 C:\WINDOWS\SYSTEM32\POWRPROF.dll +0x00007ffaec6c0000 - 0x00007ffaec6f5000 C:\WINDOWS\SYSTEM32\WINMM.dll +0x00007ffae7a30000 - 0x00007ffae7a3b000 C:\WINDOWS\SYSTEM32\VERSION.dll +0x00007ffb00220000 - 0x00007ffb00234000 C:\WINDOWS\SYSTEM32\UMPDC.dll +0x00007ffaff170000 - 0x00007ffaff18b000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll +0x00007ffaea260000 - 0x00007ffaea26a000 D:\application\.jdk\bin\jimage.dll +0x00007ffafe4f0000 - 0x00007ffafe732000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL +0x00007ffb01e50000 - 0x00007ffb021d2000 C:\WINDOWS\System32\combase.dll +0x00007ffb02f80000 - 0x00007ffb03057000 C:\WINDOWS\System32\OLEAUT32.dll +0x00007ffada3d0000 - 0x00007ffada40b000 C:\WINDOWS\SYSTEM32\dbgcore.DLL +0x00007ffb013a0000 - 0x00007ffb01445000 C:\WINDOWS\System32\bcryptPrimitives.dll +0x00007ffae63a0000 - 0x00007ffae63b0000 D:\application\.jdk\bin\instrument.dll +0x00007ffae6310000 - 0x00007ffae6331000 D:\application\.jdk\bin\java.dll + +JVMTI agents: +C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar path:none, loaded, not initialized, instrumentlib options:55095 + +dbghelp: loaded successfully - version: 4.0.5 - missing functions: none +symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;D:\application\.jdk\bin;C:\WINDOWS\SYSTEM32;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b;D:\application\.jdk\bin\server + +VM Arguments: +jvm_args: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=55095 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 +java_command: com.swyp.picke.PickeApplication +java_class_path (initial): D:\Desktop\project\Server\build\classes\java\main;D:\Desktop\project\Server\build\resources\main;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.projectlombok\lombok\1.18.42\8365263844ebb62398e0dc33057ba10ba472d3b8\lombok-1.18.42.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-web\3.5.11\68fde4c94249e92526105a93ac7c22bd89b6945e\spring-boot-starter-web-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-ui\2.8.16\61c68f705d3f17e8318fb18b2904fa6368af251c\springdoc-openapi-starter-webmvc-ui-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-validation\3.5.11\903eefb6eab302617b0f01cc6d65664343bff2a7\spring-boot-starter-validation-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-data-jpa\3.5.11\f176e5c643720818ec7910e1dd2ccb402411cc5d\spring-boot-starter-data-jpa-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-security\3.5.11\db8b6b7951883dea3ce7404f20d6816104cedd4e\spring-boot-starter-security-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-api\0.12.6\478886a888f6add04937baf0361144504a024967\jjwt-api-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-webflux\3.5.11\1461f9a6b6b8397ad71a98c9bbf4278159fb9624\spring-boot-starter-webflux-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\apps-rewardedads\1.9.1\cbaf11457b36fe57d90a5cb16a76833906486503\apps-rewardedads-1.9.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.cloud\google-cloud-texttospeech\2.58.0\9be37bd3c81c14c72c9cbcfa2dfaf6dad7a35075\google-cloud-texttospeech-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter-s3\3.3.0\fa9790f990ab540814aafdbf2e97c8cd53b5b1a6\spring-cloud-aws-starter-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-thymeleaf\3.5.11\d997aa0df579cf43507d425057940e2712e44808\spring-boot-starter-thymeleaf-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-json\3.5.11\4cdcd68dcddf0a4c645166e39c3fe448fe2b8e98\spring-boot-starter-json-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter\3.5.11\10ce971300fd56d6be5f1cfe7d27ddfb1ed7158d\spring-boot-starter-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-tomcat\3.5.11\fb7b96cb61e5fd5700aed96194562e32d166b5ef\spring-boot-starter-tomcat-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webmvc\6.2.16\ff2db80406f1459fddd14a8d06d57e0e3ab69465\spring-webmvc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-web\6.2.16\2c4355f1f7e5b8969f696cbc90f25cc22f0f2164\spring-web-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-api\2.8.16\6e41988d84978e529c01a4cc052b761cd27d5b90\springdoc-openapi-starter-webmvc-api-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\swagger-ui\5.32.0\d04c7e3e5b8616813136fa36382a548751775528\swagger-ui-5.32.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\webjars-locator-lite\1.1.3\217ce590453251b39b72c4a9af3986998f6fdbd9\webjars-locator-lite-1.1.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-el\10.1.52\cd94ce17c5a9937eca365eb494711efa10d49b86\tomcat-embed-el-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.validator\hibernate-validator\8.0.3.Final\4425f554297a1c5ba03a3f30e559a9fd91048cf8\hibernate-validator-8.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-jdbc\3.5.11\3ce801963caadf6eb29abd68f5a0fe50c9bfe211\spring-boot-starter-jdbc-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.orm\hibernate-core\6.6.42.Final\996e3df4a6c67941b582e4493cb9a39c83198f1e\hibernate-core-6.6.42.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-jpa\3.5.9\56081dde4f663db74ba000c1f8ab30673058c363\spring-data-jpa-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aspects\6.2.16\763140a66821c494985533f29280a3b4132cf055\spring-aspects-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-web\6.5.8\3db7bf41191d5b23493cca6252595405b5112b34\spring-security-web-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-config\6.5.8\302d32eba89131c0ffd15ba0a1e465051336d42f\spring-security-config-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aop\6.2.16\59250efa248420a114fe23b4ccf2fea46b804186\spring-aop-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webflux\6.2.16\699ef8bc182893f9ed43206d372f20c4f9aa3231\spring-webflux-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-reactor-netty\3.5.11\f98bd3e3019078679dec4f21fb63152aa2e059a7\spring-boot-starter-reactor-netty-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\tink\1.10.0\84771b1a4bb5726f73fb8490fadb23f1d2aacd38\tink-1.10.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.gson\gson\2.13.2\48b8230771e573b54ce6e867a9001e75977fe78e\gson-2.13.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client\1.45.3\dde98b597081b98514867c9cefa551fcdea3a28c\google-http-client-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.errorprone\error_prone_annotations\2.41.0\4381275efdef6ddfae38f002c31e84cd001c97f0\error_prone_annotations-2.41.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-contrib-http-util\0.31.1\3c13fc5715231fadb16a9b74a44d9d59c460cfa8\opencensus-contrib-http-util-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\guava\33.4.0-jre\3fcc0a259f724c7de54a6a55ea7e26d3d5c0cac\guava-33.4.0-jre.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-api\1.69.0\965c2c7f708cd6e6ddbf1eb175c3e87e96e41297\grpc-api-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.findbugs\jsr305\3.0.2\25ea2e8b0c338a877313bd4672d3fe056ea78f0d\jsr305-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-stub\1.69.0\9e7dc30a9c2df70e25ef4b941f46187e6e178e7a\grpc-stub-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf\1.69.0\2990b4948357d4fe46aaecb47290cff102079f1e\grpc-protobuf-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\api-common\2.43.0\963d97d95e9bf7275cc26f0b6b72e2aa5b92c6fd\api-common-2.43.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auto.value\auto-value-annotations\1.11.0\f0d047931d07cfbc6fa4079854f181ff62891d6f\auto-value-annotations-1.11.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\javax.annotation\javax.annotation-api\1.3.2\934c04d3cfef185a8008e7bf34331b79730a9d43\javax.annotation-api-1.3.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.j2objc\j2objc-annotations\3.0.0\7399e65dd7e9ff3404f4535b2f017093bdb134c7\j2objc-annotations-3.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java\3.25.5\5ae5c9ec39930ae9b5a61b32b93288818ec05ec1\protobuf-java-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-common-protos\2.51.0\ead75a32e6fd65740b6a69feb658254aeab3fef0\proto-google-common-protos-2.51.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1\2.58.0\42f1f29876ddfa2523ebcc41dae801195fd8b3ce\proto-google-cloud-texttospeech-v1-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1beta1\0.147.0\576df432a2c1181deabf21a54fdecc1a32f69f4e\proto-google-cloud-texttospeech-v1beta1-0.147.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\failureaccess\1.0.2\c4a06a64e650562f30b7bf9aaec1bfed43aca12b\failureaccess-1.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\b421526c5f297295adef1c886e5246c39d4ac629\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.48.4\6b5d69a61012211d581e68699baf3beb1fd382da\checker-qual-3.48.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax\2.60.0\2d277e0795cb69bc14e03be068aa002539e3ef49\gax-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-credentials\1.31.0\b9cd5346d3a683d9a8d9786453f2419cc832a97f\google-auth-library-credentials-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-api\0.31.1\66a60c7201c2b8b20ce495f0295b32bb0ccbbc57\opencensus-api-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-context\1.69.0\cea23878872f76418dcd6df0c6eef0bf27463537\grpc-context-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-oauth2-http\1.31.0\df5be46d21b983aab8d0250f19b585a94bdedcde\google-auth-library-oauth2-http-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-grpc\2.60.0\ca4d7dc8c2a85fbdba25ff3449726852e2359ae9\gax-grpc-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-inprocess\1.69.0\8ac4d2e13b48bed9624b8bc485c90f3d28820c93\grpc-inprocess-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-core\1.69.0\7dad3419dfb91a77788afcdf79e0477172784910\grpc-core-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-alts\1.69.0\6d1eac6726fd6fd177666c10fd154823b82272eb\grpc-alts-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-grpclb\1.69.0\d2c9c066693ce94805a503bc47f5b1e76f51541c\grpc-grpclb-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.conscrypt\conscrypt-openjdk-uber\2.5.2\d858f142ea189c62771c505a6548d8606ac098fe\conscrypt-openjdk-uber-2.5.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-auth\1.69.0\a75e19b20bb732364bdcc0979e9d7c9baa4e408e\grpc-auth-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-netty-shaded\1.69.0\99aa9789172695a4b09fe2af5f5bd0ab1be4ae85\grpc-netty-shaded-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-httpjson\2.60.0\131d9283925337406e35561ec17bf326a9ecec1a\gax-httpjson-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpclient\4.5.14\1194890e6f56ec29177673f2f12d0b8e627dec98\httpclient-4.5.14.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\commons-codec\commons-codec\1.18.0\ee45d1cf6ec2cc2b809ff04b4dc7aec858e0df8f\commons-codec-1.18.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpcore\4.4.16\51cf043c87253c9f58b539c9f7e44c8894223850\httpcore-4.4.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client-gson\1.45.3\29eba40245c4a4e5466f8764bd894d6a97c6694f\google-http-client-gson-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java-util\3.25.5\38cc5ce479603e36466feda2a9f1dfdb2210ef00\protobuf-java-util-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.threeten\threetenbp\1.7.0\8703e893440e550295aa358281db468625bc9a05\threetenbp-1.7.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter\3.3.0\7d82d320cb1851beca3005eab2e484a38bd58a08\spring-cloud-aws-starter-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-s3\3.3.0\661e2914e3ad6555e20ffa262a9987c15bcc1712\spring-cloud-aws-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\slf4j-api\2.0.17\d9e58ac9c7779ba3bf8142aff6c830617a7fe60f\slf4j-api-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf-spring6\3.1.3.RELEASE\4b276ea2bd536a18e44b40ff1d9f4848965ff59c\thymeleaf-spring6-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jdk8\2.19.4\90d304bcdb1a4bacb6f4347be625d75300973c60\jackson-datatype-jdk8-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jsr310\2.19.4\3cbcf2e636a6b062772299bf19a347536e58c4df\jackson-datatype-jsr310-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.module\jackson-module-parameter-names\2.19.4\502dfea4c83502f444837b3d040a51e8475f15f2\jackson-module-parameter-names-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-databind\2.19.4\7a39bf9257b726b90b80f27fa3f5174bc75162a5\jackson-databind-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-autoconfigure\3.5.11\3c7d2ec2ac3c301e95814e37fed1c86c19927fc4\spring-boot-autoconfigure-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot\3.5.11\8b7f6df00bfbe74d370e1d05d985a127884d2a9c\spring-boot-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-logging\3.5.11\62b692ed7aee31a5670796be8b07732b6b836f4e\spring-boot-starter-logging-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.annotation\jakarta.annotation-api\2.1.1\48b9bda22b091b1f48b13af03fe36db3be6e1ae3\jakarta.annotation-api-2.1.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-core\6.2.16\a73937f20a303e057add523915b48eb7901e1848\spring-core-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.yaml\snakeyaml\2.4\e0666b825b796f85521f02360e77f4c92c5a7a07\snakeyaml-2.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-websocket\10.1.52\9d32b801fb474306349013fcdd8317c8cb4d739e\tomcat-embed-websocket-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-core\10.1.52\f512bef2796b51299f4752f95918982c3003131d\tomcat-embed-core-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-context\6.2.16\caeae6bd50832d6ab28f707aa740e957401a5c20\spring-context-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-beans\6.2.16\990289064c810be71630fca9da8e2b6fe8f897b5\spring-beans-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-expression\6.2.16\e293ab797b1698084e56ae1f2362b315148683f6\spring-expression-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-observation\1.15.9\edf37b25cdfac0704d6fefa4543edb3ed1817eb0\micrometer-observation-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-common\2.8.16\5b702cb484981b42cfb455bd80b6ce7f49d34210\springdoc-openapi-starter-common-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jspecify\jspecify\1.0.0\7425a601c1c7ec76645a78d22b8c6a627edee507\jspecify-1.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.validation\jakarta.validation-api\3.0.2\92b6631659ba35ca09e44874d3eb936edfeee532\jakarta.validation-api-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jboss.logging\jboss-logging\3.6.2.Final\3e0a139d7a74cc13b5e01daa8aaa7f71dccd577e\jboss-logging-3.6.2.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml\classmate\1.7.3\f61c7e7b81e9249b0f6a05914eff9d54fb09f4a0\classmate-1.7.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.zaxxer\HikariCP\6.3.3\7c5aec1e47a97ff40977e0193018865304ea9585\HikariCP-6.3.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jdbc\6.2.16\addfdde7b3212f34c95d791c37bb04ba4b08a1b7\spring-jdbc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.persistence\jakarta.persistence-api\3.1.0\66901fa1c373c6aff65c13791cc11da72060a8d6\jakarta.persistence-api-3.1.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.transaction\jakarta.transaction-api\2.0.1\51a520e3fae406abb84e2e1148e6746ce3f80a1a\jakarta.transaction-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-commons\3.5.9\6b577c71f563e78a7da984a3d572fde8a4df8103\spring-data-commons-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-orm\6.2.16\44b3cfb2c046440f83729641c929c405dc7f2c89\spring-orm-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-tx\6.2.16\5f9d6e78b76530e6258de8a0dff991fb1ad4b9b0\spring-tx-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.antlr\antlr4-runtime\4.13.0\5a02e48521624faaf5ff4d99afc88b01686af655\antlr4-runtime-4.13.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.aspectj\aspectjweaver\1.9.25.1\a713c790da4d794c7dfb542b550d4e44898d5e23\aspectjweaver-1.9.25.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-core\6.5.8\d052dca52e49d95d2b03f81ae4b6762eeb4c78d0\spring-security-core-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor\reactor-core\3.7.16\dc7f2ba3c4fbc69678937dfe1ad45264d8a1c7be\reactor-core-3.7.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-http\1.2.15\b20bb13c95b44f1d0c148bdf1197b7d4a7e0f278\reactor-netty-http-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-autoconfigure\3.3.0\b5a0b27e91ee997f8c86e4b0c521858fdcb9dc9b\spring-cloud-aws-autoconfigure-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-core\3.3.0\3c501426267d8ccbaaebc9796bd5de5bc5d0702e\spring-cloud-aws-core-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\s3\2.29.52\db65bc6177b0c4514be1f9775cb2094e29e85d3c\s3-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf\3.1.3.RELEASE\51474f2a90b282ee97dabcd159c7faf24790f373\thymeleaf-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-core\2.19.4\a720ca9b800742699e041c3890f3731fe516085e\jackson-core-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-annotations\2.19.4\bbb09b1e7f7f5108890270eb701cb3ddef991c05\jackson-annotations-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-classic\1.5.32\2b1042c50f508f2eb402bd4d22ccbdf94cc37d2e\logback-classic-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-to-slf4j\2.24.3\da1143e2a2531ee1c2d90baa98eb50a28a39d5a7\log4j-to-slf4j-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\jul-to-slf4j\2.0.17\524cb6ccc2b68a57604750e1ab8b13b5a786a6aa\jul-to-slf4j-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jcl\6.2.16\8af6546d28815be574f384dceb93d248e9934f90\spring-jcl-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-commons\1.15.9\5a38f43cdc79a309a458c8ce130fff30a2a7f59\micrometer-commons-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-core-jakarta\2.2.43\500566364be54e3556bcec28922a41ca5fcc7dcd\swagger-core-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-crypto\6.5.8\aec1a6f6c0e06be9dff08b11e8e1f457afca44b2\spring-security-crypto-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.reactivestreams\reactive-streams\1.0.4\3864a1320d97d7b045f729a326e1e077661f31b7\reactive-streams-1.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-core\1.2.15\94b2ca82f310c1bf31d3038060e4572eeca1d4b2\reactor-netty-core-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http2\4.1.131.Final\2e4c47131c60e0bbbca067c891597b466f7033ba\netty-codec-http2-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http\4.1.131.Final\253d80637ed689ed309ca62371e5fb97746b165\netty-codec-http-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-native-macos\4.1.131.Final\9e4a908c073e56caa4127f54d946e3e9a5208506\netty-resolver-dns-native-macos-4.1.131.Final-osx-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns\4.1.131.Final\7714c0babe26712ccfdbc158aa64898ab909e7d8\netty-resolver-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-epoll\4.1.131.Final\10b7a905019c1ad5c37e8cf63d7229fb00668c1d\netty-transport-native-epoll-4.1.131.Final-linux-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-core\2.29.52\dcfa86ac727b5d4e0abad1e8b025ac2febb6382e\aws-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\auth\2.29.52\76f9b22a99b0de0fd31447db22a5cba4ed4b172e\auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\regions\2.29.52\270b31c8695739d495452d380d036c72698e623\regions-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-xml-protocol\2.29.52\e7290d4528affec022bd2f3739853f774a955ac2\aws-xml-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\protocol-core\2.29.52\1a0e4a114c0943142ca395000a949ee840890fea\protocol-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\sdk-core\2.29.52\3f058b489fac3d091417339e02b165e72c637f61\sdk-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\arns\2.29.52\879712423589b58434b8831b9b75304a16983178\arns-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\profiles\2.29.52\59dd1368bff2d242d84515ec7ea8fe63bb472c4e\profiles-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\crt-core\2.29.52\6a16e04be0e8bb8a1767e0644c631dadddfdd764\crt-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth\2.29.52\aa4ce3ff7bcd8dcf131a4f5445455b3eb4926dcf\http-auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws\2.29.52\1d0dbfa072bc46207066ffa498ad4ed65c52ac6d\http-auth-aws-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-spi\2.29.52\4a64e68a88e3eef0b51819f742931f3607cdd996\http-auth-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\identity-spi\2.29.52\d18449651e8798398cadab6c4b5d8594bb0281c\identity-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums\2.29.52\90631313060ff8ef1ab7745bb1e9740913bdcefc\checksums-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries-spi\2.29.52\7e2c7ad44106799491de8cced5925b6473d62e4b\retries-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-client-spi\2.29.52\a8da4f289736c702ec6664836761412e7e1e54a2\http-client-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\metrics-spi\2.29.52\92c3797208d24b2b25ab9b6d1bbab624c3af1b9c\metrics-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\json-utils\2.29.52\d88c6c03061b9f3fcd17dc8456365dab67cc1597\json-utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\utils\2.29.52\bde94a15cd79b0240bfa10230970e2f0e4c51eba\utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums-spi\2.29.52\537363296f035a935b7d3b50a5bef90014d38010\checksums-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\endpoints-spi\2.29.52\bd702a44ad440628af93afa1ec1d7cdc56baec67\endpoints-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\annotations\2.29.52\9fa958ce528b57d90db01c5015daaf7bd373e57f\annotations-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.attoparser\attoparser\2.0.7.RELEASE\e5d0e988d9124139d645bb5872b24dfa23e283cc\attoparser-2.0.7.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.unbescape\unbescape\1.1.6.RELEASE\7b90360afb2b860e09e8347112800d12c12b2a13\unbescape-1.1.6.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-core\1.5.32\fdfb3ff9a842303d4a95207294a6c6bc64e2605d\logback-core-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-api\2.24.3\b02c125db8b6d295adf72ae6e71af5d83bce2370\log4j-api-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-models-jakarta\2.2.43\a68f7470eb763609878460272000f260eabc24dc\swagger-models-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.dataformat\jackson-dataformat-yaml\2.19.4\500956daea0869bf753b94fdaa77e5dc99847d79\jackson-dataformat-yaml-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.commons\commons-lang3\3.17.0\b17d2136f0460dcc0d2016ceefca8723bdf4ee70\commons-lang3-3.17.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-annotations-jakarta\2.2.43\dbd40253251deabb7a628a54b4550dc4fb492f4\swagger-annotations-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.xml.bind\jakarta.xml.bind-api\4.0.4\d6d2327f3817d9a33a3b6b8f2e15a96bc2e7afdc\jakarta.xml.bind-api-4.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler-proxy\4.1.131.Final\5ff9e74613a9dd3ca078f06880a16c8cdc046de0\netty-handler-proxy-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler\4.1.131.Final\5ca67999f41c0a68f0b66485ceb990683a0b0694\netty-handler-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec\4.1.131.Final\1874341f7b29879c6833c17e7305272f0cdc2cb6\netty-codec-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport\4.1.131.Final\474862e0855d7a9828fab06a9c73c05387604ee3\netty-transport-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-buffer\4.1.131.Final\f97b636ecd9b81ae3fd1d039b69c4fd3959ecf\netty-buffer-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-common\4.1.131.Final\cdc659109da226b698a74b543a5b97dd0f7e6959\netty-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-classes-macos\4.1.131.Final\b9d57038cc4144e36aee5898085b7f1f018d2c9f\netty-resolver-dns-classes-macos-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-dns\4.1.131.Final\cd23e12e5c3448a1b12c8a4b8deeb4faeb5e483e\netty-codec-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver\4.1.131.Final\9db1bfd7c57b9b6aa9b5cfc61fc3304594bb6b39\netty-resolver-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-classes-epoll\4.1.131.Final\4d7848ac709491fb14f8bce2796fc3eff4a04fd6\netty-transport-classes-epoll-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-unix-common\4.1.131.Final\fa975e4751b23d50c0a60569829f31944d11d292\netty-transport-native-unix-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries\2.29.52\48cb57817dd88977ec71e63550673c5ce010a191\retries-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.eventstream\eventstream\1.0.1\6ff8649dffc5190366ada897ba8525a836297784\eventstream-1.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws-eventstream\2.29.52\e8b723c48008bcac96e2cc34c7415bd8b581c601\http-auth-aws-eventstream-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-query-protocol\2.29.52\672d7a2df481414d02eedf3a9eff45fb87f1b8a\aws-query-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\third-party-jackson-core\2.29.52\82ff600d837e83130502775a1555c45d7a3e2e1\third-party-jackson-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.activation\jakarta.activation-api\2.1.4\9e5c2a0d75dde71a0bedc4dbdbe47b78a5dc50f8\jakarta.activation-api-2.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-socks\4.1.131.Final\614eacc17f44d8abaeaea81f210b4980c3568262\netty-codec-socks-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-devtools\3.5.11\fe7dfcaf3153d049909a618a7ba7df288e80f090\spring-boot-devtools-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-impl\0.12.6\ac23673a84b6089e0369fb8ab2c69edd91cd6eb0\jjwt-impl-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-jackson\0.12.6\f141e0c1136ba17f2632858238a31ae05642dbf8\jjwt-jackson-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.postgresql\postgresql\42.7.10\35100a3f0899551e27af8fed4a3414619a4663b3\postgresql-42.7.10.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.h2database\h2\2.3.232\4fcc05d966ccdb2812ae8b9a718f69226c0cf4e2\h2-2.3.232.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf-lite\1.69.0\91711f27421babf868e424a64426fccb9e8bf6ec\grpc-protobuf-lite-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.52.0\9c17f496846ab1fca8975c6a50ceac0b3bbe63f0\checker-qual-3.52.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.android\annotations\4.1.1.4\a1678ba907bf92691d879fef34e1a187038f9259\annotations-4.1.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.codehaus.mojo\animal-sniffer-annotations\1.24\aa9ba58d30e0aad7f1808fce9c541ea3760678d8\animal-sniffer-annotations-1.24.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-util\1.69.0\1929ab12fce0c610d9d7229b8767f6abb09ebd51\grpc-util-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.perfmark\perfmark-api\0.27.0\f86f575a41b091786a4b027cd9c0c1d2e3fc1c01\perfmark-api-0.27.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-googleapis\1.69.0\1c11a033d96689a4bf3c92fe89b35f5edaef10c2\grpc-googleapis-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-xds\1.69.0\fa1d282a8ba3ae2a5dc0205d2f6a18b5606b5b62\grpc-xds-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-services\1.69.0\bcd917dad2380ee7cf4728da33816ffb9fad6b8b\grpc-services-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.re2j\re2j\1.7\2949632c1b4acce0d7784f28e3152e9cf3c2ec7a\re2j-1.7.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.common\hibernate-commons-annotations\7.0.3.Final\e183c4be8bb41d12e9f19b374e00c34a0a85f439\hibernate-commons-annotations-7.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.smallrye\jandex\3.2.0\f17ad860f62a08487b9edabde608f8ac55c62fa7\jandex-3.2.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\net.bytebuddy\byte-buddy\1.17.8\af5735f63d00ca47a9375fae5c7471a36331c6ed\byte-buddy-1.17.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-runtime\4.0.6\fb95ebb62564657b2fedfe165b859789ef3a8711\jaxb-runtime-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.inject\jakarta.inject-api\2.0.1\4c28afe1991a941d7702fe1362c365f0a8641d1e\jakarta.inject-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-core\4.0.6\8e61282303777fc98a00cc3affd0560d68748a75\jaxb-core-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\apache-client\2.29.52\b7ce213c946d69ab1807b3f7ecac1ce29ed60485\apache-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\netty-nio-client\2.29.52\20fa79ba82d3b290b12cd10ca49f0ff7608a6107\netty-nio-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.eclipse.angus\angus-activation\2.0.3\7f80607ea5014fef0b1779e6c33d63a88a45a563\angus-activation-2.0.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\txw2\4.0.6\4f4cd53b5ff9a2c5aa1211f15ed2569c57dfb044\txw2-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.sun.istack\istack-commons-runtime\4.1.2\18ec117c85f3ba0ac65409136afa8e42bc74e739\istack-commons-runtime-4.1.2.jar +Launcher Type: SUN_STANDARD + +[Global flags] + intx CICompilerCount = 12 {product} {ergonomic} + uint ConcGCThreads = 4 {product} {ergonomic} + uint G1ConcRefinementThreads = 14 {product} {ergonomic} + size_t G1HeapRegionSize = 4194304 {product} {ergonomic} + uintx GCDrainStackTargetSize = 64 {product} {ergonomic} + size_t InitialHeapSize = 532676608 {product} {ergonomic} + bool ManagementServer = true {product} {command line} + size_t MarkStackSize = 4194304 {product} {ergonomic} + size_t MaxHeapSize = 8480882688 {product} {ergonomic} + size_t MinHeapDeltaBytes = 4194304 {product} {ergonomic} + size_t MinHeapSize = 8388608 {product} {ergonomic} + uintx NonNMethodCodeHeapSize = 4096 {pd product} {ergonomic} + uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} + bool ProfileInterpreter = false {pd product} {command line} + uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} + size_t SoftMaxHeapSize = 8480882688 {manageable} {ergonomic} + intx TieredStopAtLevel = 1 {product} {command line} + bool UseCompressedOops = true {product lp64_product} {ergonomic} + bool UseG1GC = true {product} {ergonomic} + bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic} + +Logging: +Log output configuration: + #0: stdout all=warning uptime,level,tags foldmultilines=false + #1: stderr all=off uptime,level,tags foldmultilines=false + +Release file: + +Environment Variables: +JAVA_HOME=C:\Users\guswn\.jdks\corretto-19.0.2 +CLASSPATH=%JAVA_HOME%\lib +PATH=%JAVA_HOME%\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;D:\application\Git\cmd;C:\Program Files\Docker\Docker\resources\bin;D:\application\putty\;C:\Program Files\dotnet\;C:\Program Files\Bandizip\;D:\application\nodejs\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\Scripts\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\;C:\Users\guswn\AppData\Local\Programs\Python\Launcher\;C:\Users\guswn\AppData\Local\Microsoft\WindowsApps;D:\application\Microsoft VS Code\bin;C:\Users\guswn\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\guswn\.local\bin;D:\application\IntelliJ IDEA 2025.3.2\bin;D:\application\JetBrains Gateway 2025.3.2\bin;C:\Users\guswn\AppData\Roaming\npm +USERNAME=guswn +OS=Windows_NT +PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 170 Stepping 4, GenuineIntel +TMP=C:\Users\guswn\AppData\Local\Temp +TEMP=C:\Users\guswn\AppData\Local\Temp + + + + +Periodic native trim disabled + +--------------- S Y S T E M --------------- + +OS: + Windows 11 , 64 bit Build 26100 (10.0.26100.7920) +OS uptime: 16 days 18:33 hours +Hyper-V role detected + +CPU: total 18 (initial active 18) (9 cores per cpu, 2 threads per core) family 6 model 170 stepping 4 microcode 0x1f, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, sha, fma, vzeroupper, clflush, clflushopt, clwb, hv, serialize, rdtscp, rdpid, fsrm, f16c, cet_ibt, cet_ss +Processor Information for processor 0 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 1 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 2 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 3 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 4 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 5 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 6 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 7 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 8 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 9 + Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 +Processor Information for processor 10 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 11 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 12 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 13 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 14 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 15 + Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 +Processor Information for processor 16 + Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 +Processor Information for processor 17 + Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 + +Memory: 4k page, system-wide physical 32346M (3866M free) +TotalPageFile size 61970M (AvailPageFile size 362M) +current process WorkingSet (physical memory assigned to process): 13M, peak: 13M +current process commit charge ("private bytes"): 68M, peak: 576M + +vm_info: OpenJDK 64-Bit Server VM (21.0.10+7-LTS) for windows-amd64 JRE (21.0.10+7-LTS), built on 2026-01-15T22:13:46Z by "Administrator" with MS VC++ 17.14 (VS2022) + +END. diff --git a/settings.gradle b/settings.gradle index 0cdaa80e..0a7aaf2a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'app' +rootProject.name = 'picke' diff --git a/src/main/java/com/swyp/app/AppApplication.java b/src/main/java/com/swyp/app/AppApplication.java deleted file mode 100644 index 11ec00cf..00000000 --- a/src/main/java/com/swyp/app/AppApplication.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.swyp.app; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; - -@SpringBootApplication -@EnableJpaAuditing -public class AppApplication { - - public static void main(String[] args) { - SpringApplication.run(AppApplication.class, args); - } - -} diff --git a/src/main/java/com/swyp/picke/PickeApplication.java b/src/main/java/com/swyp/picke/PickeApplication.java new file mode 100644 index 00000000..c309634b --- /dev/null +++ b/src/main/java/com/swyp/picke/PickeApplication.java @@ -0,0 +1,15 @@ +package com.swyp.picke; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@EnableJpaAuditing +@SpringBootApplication +public class PickeApplication { + public static void main(String[] args) { + SpringApplication.run(PickeApplication.class, args); // 실행 클래스 수정완료 + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..45a5031a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java @@ -0,0 +1,52 @@ +package com.swyp.picke.domain.admin.controller; + +import io.swagger.v3.oas.annotations.Hidden; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Hidden // 스웨거 노출 차단 +@Controller +@RequestMapping("/api/v1/admin") +public class AdminPickeController { + + @Value("${oauth.kakao.client-id}") + private String kakaoClientId; + + @Value("${oauth.google.client-id}") + private String googleClientId; + + @Value("${picke.baseUrl}") + private String baseUrl; + + @GetMapping({"", "/"}) + public String index() { + return "redirect:/api/v1/admin/login"; + } + + @GetMapping("/login") + public String adminLoginPage(Model model) { + model.addAttribute("kakaoClientId", kakaoClientId); + model.addAttribute("googleClientId", googleClientId); + model.addAttribute("redirectUri", baseUrl + "/api/v1/admin/login"); + + return "admin/admin-login"; + } + + @GetMapping("/picke/list") + public String pickeListPage() { + return "admin/picke-list"; + } + + @GetMapping("/picke") + 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/admin/dto/battle/request/AdminBattleOptionRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleOptionRequest.java new file mode 100644 index 00000000..b610aa24 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleOptionRequest.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; + +import java.util.List; + +public record AdminBattleOptionRequest( + BattleOptionLabel label, + String title, + String stance, + String representative, + String imageUrl, + List tagIds +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleUpdateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleUpdateRequest.java new file mode 100644 index 00000000..576da5bd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleUpdateRequest.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import java.util.List; + +public record AdminBattleUpdateRequest( + String title, + String summary, + String description, + String thumbnailUrl, + BattleStatus status, + List tagIds, + List options +) { +} + 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/admin/dto/battle/response/AdminBattleDetailResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java new file mode 100644 index 00000000..f1873078 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java @@ -0,0 +1,29 @@ +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 java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 관리자 배틀 상세 조회 응답 + */ +public record AdminBattleDetailResponse( + Long battleId, + String title, + String summary, + String description, + String thumbnailUrl, + Integer audioDuration, + LocalDate targetDate, + BattleStatus status, + BattleCreatorType creatorType, + List tags, + List options, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} 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/admin/dto/tag/request/TagRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/tag/request/TagRequest.java new file mode 100644 index 00000000..577afa0d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/tag/request/TagRequest.java @@ -0,0 +1,13 @@ +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 = "태그 이름을 입력해 주세요.") + String name, + + @NotNull(message = "태그 타입을 선택해 주세요.") + TagType type +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagDeleteResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagDeleteResponse.java new file mode 100644 index 00000000..c17ca85a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagDeleteResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.tag.response; + +import java.time.LocalDateTime; + +public record TagDeleteResponse( + boolean success, + LocalDateTime deletedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagResponse.java new file mode 100644 index 00000000..ab79ac01 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/tag/response/TagResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.admin.dto.tag.response; + +import com.swyp.picke.domain.tag.enums.TagType; +import java.time.LocalDateTime; + +public record TagResponse( + Long tagId, + String name, + TagType type, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} \ No newline at end of file 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/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 new file mode 100644 index 00000000..eafacd8b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java @@ -0,0 +1,57 @@ +package com.swyp.picke.domain.battle.controller; + +import com.swyp.picke.domain.battle.dto.response.BattleListResponse; +import com.swyp.picke.domain.battle.dto.response.BattleUserDetailResponse; +import com.swyp.picke.domain.battle.dto.response.TodayBattleListResponse; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; +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 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/battles") +@RequiredArgsConstructor +public class BattleController { + + private final BattleService battleService; + + @Operation(summary = "오늘의 배틀 목록 조회 (최대 5개)") + @GetMapping("/today") + public ApiResponse getTodayBattles() { + return ApiResponse.onSuccess(battleService.getTodayBattles()); + } + + @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, PENDING, PUBLISHED, REJECTED, ARCHIVED)", example = "ALL") + @RequestParam(value = "status", required = false, defaultValue = "ALL") String status + ) { + return ApiResponse.onSuccess(battleService.getBattles(page, size, status)); + } + + @Operation(summary = "배틀 상세 조회") + @GetMapping("/{battleId}") + public ApiResponse getBattleDetail(@PathVariable Long battleId) { + return ApiResponse.onSuccess(battleService.getBattleDetail(battleId)); + } + + @Operation(summary = "사용자 배틀 진행 상태 조회") + @GetMapping("/{battleId}/status") + public ApiResponse getUserBattleStatus(@PathVariable Long battleId) { + return ApiResponse.onSuccess(battleService.getUserBattleStatus(battleId)); + } +} 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 new file mode 100644 index 00000000..71d5c27a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java @@ -0,0 +1,167 @@ +package com.swyp.picke.domain.battle.converter; + +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.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; + +@Component +@RequiredArgsConstructor +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()) + .summary(request.summary()) + .description(request.description()) + .thumbnailUrl(request.thumbnailUrl()) + .status(request.status()) + .creatorType(BattleCreatorType.ADMIN) + .creator(admin) + .build(); + } + + public TodayBattleResponse toTodayResponse(Battle battle, List tags, List options) { + return new TodayBattleResponse( + battle.getId(), + battle.getTitle(), + battle.getSummary(), + urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), + battle.getViewCount() == null ? 0 : battle.getViewCount(), + battle.getTotalParticipantsCount() == null ? 0L : battle.getTotalParticipantsCount(), + battle.getAudioDuration() == null ? 0 : battle.getAudioDuration(), + toTagResponses(tags, null), + toTodayOptionResponses(options) + ); + } + + public BattleSimpleResponse toSimpleResponse(Battle battle) { + return new BattleSimpleResponse( + battle.getId(), + battle.getTitle(), + urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), + battle.getStatus() != null ? battle.getStatus().name() : "PENDING", + battle.getCreatedAt() + ); + } + + public AdminBattleDetailResponse toAdminDetailResponse(Battle battle, List tags, List options, Map> optionTagsMap) { + return new AdminBattleDetailResponse( + battle.getId(), + battle.getTitle(), + battle.getSummary(), + battle.getDescription(), + urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), + battle.getAudioDuration(), + battle.getTargetDate(), + battle.getStatus(), + battle.getCreatorType(), + toTagResponses(tags, null), + toOptionResponses(options, optionTagsMap), + battle.getCreatedAt(), + battle.getUpdatedAt() + ); + } + + public BattleUserDetailResponse toUserDetailResponse( + Battle battle, List tags, List options, Map> optionTagsMap, + Long participantsCount, VoteSide userVoteStatus, UserBattleStep currentStep) { + + BattleSummaryResponse summary = new BattleSummaryResponse( + battle.getId(), + battle.getTitle(), + battle.getSummary(), + urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), + battle.getViewCount() == null ? 0 : battle.getViewCount(), + participantsCount == null ? 0L : participantsCount, + battle.getAudioDuration() == null ? 0 : battle.getAudioDuration(), + toTagResponses(tags, null), + toOptionResponses(options, optionTagsMap) + ); + + return new BattleUserDetailResponse( + summary, + battle.getDescription(), + BASE_SHARE_URL + battle.getId(), + userVoteStatus, + currentStep, + toTagResponses(tags, TagType.CATEGORY), + toTagResponses(tags, TagType.PHILOSOPHER), + toTagResponses(tags, TagType.VALUE) + ); + } + + public BattleScenarioResponse toScenarioResponse(Battle battle, List options) { + List profiles = options.stream() + .map(opt -> new BattleScenarioResponse.PhilosopherProfileResponse( + opt.getLabel().name(), + opt.getRepresentative(), + opt.getStance(), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, opt.getImageUrl()) + )).toList(); + + return new BattleScenarioResponse(battle.getTitle(), profiles); + } + + private 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( + option.getId(), + option.getLabel(), + option.getTitle(), + option.getStance(), + option.getRepresentative(), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()), + toTagResponses(optionTags, null) + ); + }).toList(); + } + + private List toTodayOptionResponses(List options) { + if (options == null) return List.of(); + 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) { + if (tags == null) return List.of(); + return tags.stream() + .filter(tag -> targetType == null || tag.getType() == targetType) + .map(tag -> new BattleTagResponse(tag.getId(), tag.getName(), tag.getType())) + .toList(); + } +} 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/BattleListResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleListResponse.java new file mode 100644 index 00000000..02e6d72a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleListResponse.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.util.List; + +public record BattleListResponse( + List items, + int currentPage, + int totalPages, + long totalItems +) {} \ 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 new file mode 100644 index 00000000..ce34930d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; + +import java.util.List; + +public record BattleOptionResponse( + Long optionId, + BattleOptionLabel label, + String title, + String stance, + String representative, + 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 new file mode 100644 index 00000000..1208010c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.util.List; + +public record BattleScenarioResponse( + String title, + List philosophers +) { + public record PhilosopherProfileResponse( + String label, + String name, + String stance, + String imageUrl + ) {} +} 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 new file mode 100644 index 00000000..6ce79150 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.time.LocalDateTime; + +public record BattleSimpleResponse( + Long battleId, + String title, + String thumbnailUrl, + String status, + LocalDateTime createdAt +) {} 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 new file mode 100644 index 00000000..60cd7f24 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.util.List; + +public record BattleSummaryResponse( + 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/BattleTagResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleTagResponse.java new file mode 100644 index 00000000..ac09b195 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleTagResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.battle.dto.response; +import com.swyp.picke.domain.tag.enums.TagType; + +/** + * 유저 - 배틀 태그 응답 + * 역할: 화면 곳곳에 쓰이는 #예술 #철학 등의 태그 정보를 담습니다. + */ + +public record BattleTagResponse( + Long tagId, // 태그 고유 ID + String name, // 태그 명칭 + TagType type // 태그 카테고리 (CATEGORY, PHILOSOPHER, VALUE) +) {} \ No newline at end of file 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 new file mode 100644 index 00000000..9b50d068 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.enums.VoteSide; + +import java.util.List; + +public record BattleUserDetailResponse( + BattleSummaryResponse battleInfo, + String description, + String shareUrl, + VoteSide userVoteStatus, + UserBattleStep currentStep, + 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 new file mode 100644 index 00000000..fe2cdac5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.util.List; + +/** + * 유저 - 투표 결과 전체 응답 + * 역할: 투표 완료 후 실시간으로 변한 전체 참여자 수와 옵션별 비율을 반환합니다. + */ + +public record BattleVoteResponse( + Long battleId, // 투표한 배틀 ID + Long selectedOptionId, // 유저가 방금 선택한 옵션 ID + Long totalParticipants, // 실시간 전체 참여자 수 + List results // 옵션별 득표 현황 리스트 +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/OptionStatResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/OptionStatResponse.java new file mode 100644 index 00000000..990e4125 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/OptionStatResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +/** + * 유저 - 옵션별 실시간 통계 + * 역할: 각 선택지별로 몇 명이 선택했는지, 퍼센트(%)는 얼마인지 담습니다. + */ + +public record OptionStatResponse( + Long optionId, // 옵션 고유 ID + BattleOptionLabel label,// 라벨 (A, B) + String title, // 옵션 명칭 + Long voteCount, // 해당 옵션의 득표 수 + Double ratio // 해당 옵션의 득표 비율 (0~100.0) +) {} \ 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 new file mode 100644 index 00000000..235a7f26 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.util.List; + +/** + * 유저 - 오늘의 배틀 목록 응답 + * 역할: 오늘의 배틀 섹션에 노출될 배틀들과 총 개수를 감싸는 리스트형 DTO입니다. + */ + +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 new file mode 100644 index 00000000..097a0061 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.util.List; + +public record TodayBattleResponse( + 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 new file mode 100644 index 00000000..2da90246 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; + +public record TodayOptionResponse( + 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 new file mode 100644 index 00000000..e9905040 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java @@ -0,0 +1,158 @@ +package com.swyp.picke.domain.battle.entity; + +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.user.entity.User; +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.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; + +@Getter +@Entity +@Table(name = "battles") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Battle extends BaseEntity { + + @Column(nullable = false) + private String title; + + private String summary; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "thumbnail_url", length = 500) + private String thumbnailUrl; + + @Column(name = "view_count") + private Integer viewCount = 0; + + @Column(name = "total_participants") + private Long totalParticipantsCount = 0L; + + @Column(name = "target_date") + private LocalDate targetDate; + + @Column(name = "audio_duration") + private Integer audioDuration; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private BattleStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "creator_type", nullable = false, length = 10) + private BattleCreatorType creatorType; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id") + private User creator; + + @OneToMany(mappedBy = "battle", cascade = CascadeType.ALL, orphanRemoval = true) + private final List options = new ArrayList<>(); + + @Column(name = "is_editor_pick") + private Boolean isEditorPick = false; + + @Column(name = "comment_count") + private Long commentCount = 0L; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + 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.targetDate = targetDate; + this.audioDuration = audioDuration; + this.status = status; + this.creatorType = creatorType; + this.creator = creator; + this.viewCount = 0; + this.totalParticipantsCount = 0L; + this.isEditorPick = false; + this.commentCount = 0L; + this.deletedAt = null; + } + + 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() { + this.status = BattleStatus.ARCHIVED; + this.deletedAt = LocalDateTime.now(); + } + + public void increaseViewCount() { + this.viewCount = (this.viewCount == null ? 0 : this.viewCount) + 1; + } + + public void addParticipant() { + this.totalParticipantsCount = (this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount) + 1; + } + + 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 new file mode 100644 index 00000000..ab5ee23a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java @@ -0,0 +1,104 @@ +package com.swyp.picke.domain.battle.entity; + +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +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.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; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "battle_options") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BattleOption extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "battle_id", nullable = false) + private Battle battle; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private BattleOptionLabel label; + + @Column(nullable = false, length = 100) + private String title; + + @Column(length = 255) + private String stance; + + @Column(length = 100) + private String representative; + + @Column(name = "vote_count") + private Long voteCount = 0L; + + @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 imageUrl, + Integer displayOrder + ) { + this.battle = battle; + this.label = label; + this.title = title; + this.stance = stance; + this.representative = representative; + this.imageUrl = imageUrl; + this.displayOrder = displayOrder; + this.voteCount = 0L; + } + + public void increaseVoteCount() { + this.voteCount = (this.voteCount == null ? 0L : this.voteCount) + 1; + } + + public void decreaseVoteCount() { + if (this.voteCount != null && this.voteCount > 0) { + this.voteCount--; + } + } + + 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; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/BattleOptionTag.java b/src/main/java/com/swyp/picke/domain/battle/entity/BattleOptionTag.java new file mode 100644 index 00000000..42a63cd9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/entity/BattleOptionTag.java @@ -0,0 +1,33 @@ +package com.swyp.picke.domain.battle.entity; + +import com.swyp.picke.domain.tag.entity.Tag; +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_option_tags", + uniqueConstraints = @UniqueConstraint(columnNames = {"battle_option_id", "tag_id"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BattleOptionTag extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "battle_option_id", nullable = false) + private BattleOption battleOption; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + @Builder + private BattleOptionTag(BattleOption battleOption, Tag tag) { + this.battleOption = battleOption; + this.tag = tag; + } +} \ 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/entity/BattleTag.java b/src/main/java/com/swyp/picke/domain/battle/entity/BattleTag.java new file mode 100644 index 00000000..4d8f29f9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/entity/BattleTag.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.battle.entity; + +import com.swyp.picke.domain.tag.entity.Tag; +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "battle_tags", + uniqueConstraints = @UniqueConstraint(columnNames = {"battle_id", "tag_id"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BattleTag extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "battle_id", nullable = false) + private Battle battle; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + @Builder + private BattleTag(Battle battle, Tag tag) { + this.battle = battle; + this.tag = tag; + } +} 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/BattleCreatorType.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleCreatorType.java new file mode 100644 index 00000000..5c09d467 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleCreatorType.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.battle.enums; + +public enum BattleCreatorType { + ADMIN, USER, AI +} diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleOptionLabel.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleOptionLabel.java new file mode 100644 index 00000000..bcc2bb0b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleOptionLabel.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.battle.enums; + +public enum BattleOptionLabel { + A, B, C, D +} 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/BattleStatus.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleStatus.java new file mode 100644 index 00000000..f43148c5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleStatus.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.battle.enums; + +public enum BattleStatus { + PENDING, PUBLISHED, REJECTED, ARCHIVED +} 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 new file mode 100644 index 00000000..2260ed8e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.battle.repository; + +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 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 { + + @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); + + @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 new file mode 100644 index 00000000..23f0d3dd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java @@ -0,0 +1,22 @@ +package com.swyp.picke.domain.battle.repository; + +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; + +import java.util.List; + +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); + + @Query("SELECT bot FROM BattleOptionTag bot JOIN FETCH bot.tag WHERE bot.battleOption.id IN :optionIds") + List findByBattleOptionIdIn(@Param("optionIds") List optionIds); +} \ No newline at end of file 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 new file mode 100644 index 00000000..6bd79776 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -0,0 +1,108 @@ +package com.swyp.picke.domain.battle.repository; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleStatus; +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; + +public interface BattleRepository extends JpaRepository { + + // 1. EDITOR PICK + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.isEditorPick = true AND battle.status = :status " + + "AND battle.deletedAt IS NULL " + + "ORDER BY battle.createdAt DESC") + List findEditorPicks(@Param("status") BattleStatus status, Pageable pageable); + + // 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, Pageable pageable); + + // 3. Best 배틀 + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + + "ORDER BY (battle.totalParticipantsCount + (battle.commentCount * 5)) DESC") + List findBestBattles(Pageable pageable); + + // 4. 오늘의 Pické + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.targetDate = :today " + + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL") + 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); + + @Query("SELECT battle FROM Battle battle " + + "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, Pageable pageable); + + // 6. 전체 배틀 목록 조회 + Page findByDeletedAtIsNullOrderByCreatedAtDesc(Pageable pageable); + Page findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(BattleStatus status, Pageable pageable); + List findByStatusAndDeletedAtIsNull(BattleStatus status); + + // 기본 조회용 + List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); + + // 주간 배치: 특정 기간(targetDate BETWEEN from AND to)의 배틀 조회 + List findByTargetDateBetweenAndStatusAndDeletedAtIsNull(LocalDate from, LocalDate to, BattleStatus status); + + // 탐색 탭: 전체 배틀 검색 + @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.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.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.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.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 + ); + + // 추천용: 특정 유저들이 참여한 배틀 중 이미 참여한 배틀 제외하고 인기 점수순 조회 + // 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.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 + ); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleTagRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleTagRepository.java new file mode 100644 index 00000000..a4559162 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleTagRepository.java @@ -0,0 +1,22 @@ +package com.swyp.picke.domain.battle.repository; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleTag; +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; + +import java.util.List; + +public interface BattleTagRepository extends JpaRepository { + List findByBattle(Battle battle); + void deleteByBattle(Battle battle); + boolean existsByTag(Tag tag); + // N+1 방지를 위해 Tag까지 한 번에 가져오는 쿼리 + @Query("SELECT bt FROM BattleTag bt JOIN FETCH bt.tag WHERE bt.battle IN :battles") + List findByBattleIn(@Param("battles") List battles); + // MypageService (recap): 여러 배틀의 태그를 한번에 조회 + @Query("SELECT bt FROM BattleTag bt JOIN FETCH bt.tag WHERE bt.battle.id IN :battleIds") + List findByBattleIdIn(@Param("battleIds") List battleIds); +} \ No newline at end of file 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/BattleQueryService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleQueryService.java new file mode 100644 index 00000000..0cb8ef5b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleQueryService.java @@ -0,0 +1,95 @@ +package com.swyp.picke.domain.battle.service; + +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.battle.entity.BattleTag; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.swyp.picke.domain.tag.enums.TagType; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BattleQueryService { + + private final BattleRepository battleRepository; + private final BattleOptionRepository battleOptionRepository; + private final BattleTagRepository battleTagRepository; + private final BattleOptionTagRepository battleOptionTagRepository; + + public Map findBattlesByIds(List battleIds) { + return battleRepository.findAllById(battleIds).stream() + .collect(Collectors.toMap(Battle::getId, Function.identity())); + } + + public Map findOptionsByIds(List optionIds) { + return battleOptionRepository.findAllById(optionIds).stream() + .collect(Collectors.toMap(BattleOption::getId, Function.identity())); + } + + /** + * 주어진 배틀 ID 목록에 대해 태그별 빈도를 집계하여 상위 limit개를 반환한다. + * @return Map<태그명, 빈도수> (상위 limit개) + */ + public Map getTopTagsByBattleIds(List battleIds, int limit) { + if (battleIds.isEmpty()) return Map.of(); + + List battleTags = battleTagRepository.findByBattleIdIn(battleIds); + + return battleTags.stream() + .collect(Collectors.groupingBy( + bt -> bt.getTag().getName(), + Collectors.counting() + )) + .entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(limit) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (a, b) -> a, + java.util.LinkedHashMap::new + )); + } + + public Map getCategoryNamesByBattleIds(List battleIds) { + if (battleIds == null || battleIds.isEmpty()) return Map.of(); + + return battleTagRepository.findByBattleIdIn(battleIds).stream() // findByBattleIdInWithTag → findByBattleIdIn + .filter(bt -> bt.getTag().getType() == TagType.CATEGORY) + .collect(Collectors.toMap( + bt -> bt.getBattle().getId(), + bt -> bt.getTag().getName(), + (a, b) -> a + )); + } + + public Optional getTopPhilosopherTagNameFromOptions(List optionIds) { + if (optionIds.isEmpty()) return Optional.empty(); + + List optionTags = battleOptionTagRepository.findByBattleOptionIdIn(optionIds); + + return optionTags.stream() + .filter(bot -> bot.getTag().getType() == TagType.PHILOSOPHER) + .collect(Collectors.groupingBy( + bot -> bot.getTag().getName(), + Collectors.counting() + )) + .entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey); + } +} 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 new file mode 100644 index 00000000..a5d1df46 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java @@ -0,0 +1,56 @@ +package com.swyp.picke.domain.battle.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.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.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); + + List getEditorPicks(); + + List getTrendingBattles(); + + List getBestBattles(); + + List getTodayPicks(); + + List getNewBattles(List excludeIds); + + BattleListResponse getBattles(int page, int size, String status); + + TodayBattleListResponse getTodayBattles(); + + BattleUserDetailResponse getBattleDetail(Long battleId); + + BattleVoteResponse BattleVote(Long battleId, Long optionId); + + BattleScenarioResponse getBattleScenario(Long battleId); + + UserBattleStatusResponse getUserBattleStatus(Long battleId); + + AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId); + + AdminBattleDetailResponse getAdminBattleDetail(Long battleId); + + AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request); + + 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 new file mode 100644 index 00000000..e8b59d6c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -0,0 +1,622 @@ +package com.swyp.picke.domain.battle.service; + +import com.swyp.picke.domain.battle.converter.BattleConverter; +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; +import com.swyp.picke.domain.battle.entity.BattleOptionTag; +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.user.dto.response.UserBattleStatusResponse; +import com.swyp.picke.domain.user.enums.UserBattleStep; +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.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.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; +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.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@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 BattleVoteRepository battleVoteRepository; + private final BattleConverter battleConverter; + private final S3UploadService s3UploadService; + private final LocalDraftFileStorageService localDraftFileStorageService; + private final UserBattleService userBattleService; + + @Override + public Battle findById(Long battleId) { + Battle battle = battleRepository.findById(battleId) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_NOT_FOUND)); + if (battle.getDeletedAt() != null) { + throw new CustomException(ErrorCode.BATTLE_NOT_FOUND); + } + return battle; + } + + @Override + 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 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, PageRequest.of(0, safeLimit)); + return convertToTodayResponses(battles); + } + + private List loadBestBattles(int limit) { + int safeLimit = Math.max(1, limit); + List battles = battleRepository.findBestBattles(PageRequest.of(0, safeLimit)); + return convertToTodayResponses(battles); + } + + 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); + } + + 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, PageRequest.of(0, safeLimit)); + return convertToTodayResponses(battles); + } + + @Override + public BattleListResponse getBattles(int page, int size, String status) { + int pageNumber = Math.max(0, page - 1); + PageRequest pageRequest = PageRequest.of(pageNumber, size); + BattleStatus battleStatusFilter = parseBattleStatus(status); + + Page battlePage; + if (battleStatusFilter == null) { + battlePage = battleRepository.findByDeletedAtIsNullOrderByCreatedAtDesc(pageRequest); + } else { + battlePage = battleRepository.findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc( + battleStatusFilter, + pageRequest + ); + } + + List items = battlePage.getContent().stream() + .map(battleConverter::toSimpleResponse) + .toList(); + + return new BattleListResponse( + items, + battlePage.getNumber() + 1, + battlePage.getTotalPages(), + battlePage.getTotalElements() + ); + } + + @Override + @Transactional + public TodayBattleListResponse getTodayBattles() { + LocalDate today = LocalDate.now(); + ensureTodayPicks(today, 5); + List battles = battleRepository.findByTargetDateAndStatusAndDeletedAtIsNull(today, BattleStatus.PUBLISHED); + + List limitedBattles = battles.stream() + .limit(5) + .collect(Collectors.toList()); + + List items = convertToTodayResponses(limitedBattles); + + 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) { + Battle battle = findById(battleId); + List tags = getTagsByBattle(battle); + List options = battleOptionRepository.findByBattle(battle); + Map> optionTagsMap = battleOptionTagRepository.findByBattleWithTags(battle) + .stream() + .collect(Collectors.groupingBy( + bot -> bot.getBattleOption().getId(), + Collectors.mapping(BattleOptionTag::getTag, Collectors.toList()) + )); + Long currentUserId = SecurityUtil.getCurrentUserId(); + User user = userRepository.findById(currentUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + UserBattleStatusResponse statusResponse = userBattleService.getUserBattleStatus(user, battle); + UserBattleStep currentStep = statusResponse.step(); + + Optional optionalVote = battleVoteRepository.findByBattleIdAndUserIdWithOption(battleId, currentUserId); + VoteSide voteStatus = optionalVote + .map(BattleVote -> { + if (BattleVote.getPostVoteOption() != null) { + return BattleVote.getPostVoteOption().getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; + } + return null; + }) + .orElse(null); + + return battleConverter.toUserDetailResponse( + battle, tags, options, optionTagsMap, + battle.getTotalParticipantsCount(), + voteStatus, + currentStep + ); + } + + @Override + public BattleScenarioResponse getBattleScenario(Long battleId) { + Battle battle = findById(battleId); + List options = battleOptionRepository.findByBattle(battle); + return battleConverter.toScenarioResponse(battle, options); + } + + @Override + public UserBattleStatusResponse getUserBattleStatus(Long battleId) { + Battle battle = findById(battleId); + Long currentUserId = SecurityUtil.getCurrentUserId(); + User user = userRepository.findById(currentUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + return userBattleService.getUserBattleStatus(user, battle); + } + + @Override + @Transactional + public BattleVoteResponse BattleVote(Long battleId, Long optionId) { + Battle battle = findById(battleId); + BattleOption newOption = battleOptionRepository.findById(optionId) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); + + Long currentUserId = SecurityUtil.getCurrentUserId(); + User user = userRepository.findById(currentUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + battleVoteRepository.save(BattleVote.builder() + .user(user) + .battle(battle) + .preVoteOption(newOption) + .isTtsListened(false) + .build()); + + userBattleService.upsertStep(user, battle, UserBattleStep.PRE_VOTE); + List results = calculateOptionStats(battle); + return new BattleVoteResponse(battle.getId(), newOption.getId(), battle.getTotalParticipantsCount(), results); + } + + private List calculateOptionStats(Battle battle) { + return battleOptionRepository.findByBattle(battle).stream().map(option -> { + Long voteCount = option.getVoteCount() == null ? 0L : option.getVoteCount(); + Long totalCount = battle.getTotalParticipantsCount() == null ? 0L : battle.getTotalParticipantsCount(); + Double ratio = (totalCount == 0L) ? 0.0 : Math.round((double) voteCount / totalCount * 1000) / 10.0; + return new OptionStatResponse(option.getId(), option.getLabel(), option.getTitle(), voteCount, ratio); + }).toList(); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId) { + User admin = userRepository.findById(adminUserId == null ? 1L : adminUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + 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<>(); + 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); + } + } + + 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), 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()); + + 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.summary(), + request.description(), + resolvedThumbnailKey, + request.status() + ); + + if (request.tagIds() != null) { + battleTagRepository.deleteByBattle(battle); + battleTagRepository.flush(); + saveBattleTags(battle, request.tagIds().stream().distinct().toList()); + } + + if (request.options() != null) { + List existingOptions = battleOptionRepository.findByBattle(battle); + 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); + } + } + + List updatedOptions = 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), updatedOptions, optionTagsMap); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDeleteResponse deleteBattle(Long battleId) { + Battle battle = findById(battleId); + battle.delete(); + return new AdminBattleDeleteResponse(true, LocalDateTime.now()); + } + + private List convertToTodayResponses(List battles) { + if (battles == null || battles.isEmpty()) return Collections.emptyList(); + + Map> optionsMap = battleOptionRepository.findByBattleIn(battles) + .stream().collect(Collectors.groupingBy(o -> o.getBattle().getId())); + + Map> tagsMap = battleTagRepository.findByBattleIn(battles) + .stream().collect(Collectors.groupingBy( + bt -> bt.getBattle().getId(), + Collectors.mapping(BattleTag::getTag, Collectors.toList()) + )); + + return battles.stream().map(battle -> { + List tags = tagsMap.getOrDefault(battle.getId(), Collections.emptyList()); + List options = optionsMap.getOrDefault(battle.getId(), Collections.emptyList()); + return battleConverter.toTodayResponse(battle, tags, options); + }).toList(); + } + + private List getTagsByBattle(Battle battle) { + return battleTagRepository.findByBattle(battle).stream() + .map(BattleTag::getTag) + .filter(tag -> tag.getDeletedAt() == null) + .toList(); + } + + 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())); + } + + 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) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); + } + + @Override + public BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabel label) { + Battle battle = findById(battleId); + return battleOptionRepository.findByBattleAndLabel(battle, label) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); + } + + 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 new file mode 100644 index 00000000..e466fb6a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.home.controller; + +import com.swyp.picke.domain.home.dto.response.HomeResponse; +import com.swyp.picke.domain.home.service.HomeService; +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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "홈 API", description = "홈 화면 데이터 조회") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class HomeController { + + private final HomeService homeService; + + @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/dto/response/HomeBestBattleResponse.java b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeBestBattleResponse.java new file mode 100644 index 00000000..b9c6d6b2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeBestBattleResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.home.dto.response; + +import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeBestBattleResponse( + Long battleId, + String philosopherA, + String philosopherB, + String title, + List tags, + Integer audioDuration, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/picke/domain/home/dto/response/HomeEditorPickResponse.java b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeEditorPickResponse.java new file mode 100644 index 00000000..efdc9bf5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeEditorPickResponse.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.home.dto.response; + +import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeEditorPickResponse( + Long battleId, + String thumbnailUrl, + String optionATitle, + String optionBTitle, + String title, + String summary, + List tags, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/picke/domain/home/dto/response/HomeNewBattleResponse.java b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeNewBattleResponse.java new file mode 100644 index 00000000..a8c6d598 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeNewBattleResponse.java @@ -0,0 +1,21 @@ +package com.swyp.picke.domain.home.dto.response; + +import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeNewBattleResponse( + Long battleId, + String thumbnailUrl, + String title, + String summary, + String philosopherA, + String optionATitle, + String philosopherAImageUrl, + String philosopherB, + String optionBTitle, + String philosopherBImageUrl, + List tags, + Integer audioDuration, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/picke/domain/home/dto/response/HomeResponse.java b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeResponse.java new file mode 100644 index 00000000..a63f331c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.home.dto.response; + +import java.util.List; + +public record HomeResponse( + boolean newNotice, + List editorPicks, + List trendingBattles, + List bestBattles, + List todayQuizzes, + List todayVotes, + List newBattles +) {} diff --git a/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayQuizResponse.java b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayQuizResponse.java new file mode 100644 index 00000000..85eb079e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayQuizResponse.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.home.dto.response; + +public record HomeTodayQuizResponse( + Long battleId, + String title, + String summary, + Long participantsCount, + String itemA, + String itemADesc, + Boolean isCorrectA, + String itemB, + String itemBDesc, + Boolean isCorrectB +) {} diff --git a/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayVoteOptionResponse.java b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayVoteOptionResponse.java new file mode 100644 index 00000000..510c59dc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayVoteOptionResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.home.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; + +public record HomeTodayVoteOptionResponse( + BattleOptionLabel label, + String title +) {} diff --git a/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayVoteResponse.java b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayVoteResponse.java new file mode 100644 index 00000000..0f8ea056 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTodayVoteResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.home.dto.response; + +import java.util.List; + +public record HomeTodayVoteResponse( + Long battleId, + String titlePrefix, + String titleSuffix, + String summary, + Long participantsCount, + List options +) {} diff --git a/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTrendingResponse.java b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTrendingResponse.java new file mode 100644 index 00000000..a8066b33 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/dto/response/HomeTrendingResponse.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.home.dto.response; + +import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeTrendingResponse( + Long battleId, + String thumbnailUrl, + String title, + List tags, + Integer audioDuration, + Integer viewCount +) {} 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 new file mode 100644 index 00000000..4d3082cd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/service/HomeService.java @@ -0,0 +1,216 @@ +package com.swyp.picke.domain.home.service; + +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.service.BattleService; +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 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; + + public HomeResponse getHome(Long userId) { + boolean newNotice = false; + if (userId != null) { + newNotice = notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE); + } + + 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, + editorPickRaw.stream().map(this::toEditorPick).toList(), + trendingRaw.stream().map(this::toTrending).toList(), + bestRaw.stream().map(this::toBestBattle).toList(), + quizRaw.stream().map(this::toTodayQuiz).toList(), + pollRaw.stream().map(this::toTodayVote).toList(), + newRaw.stream().map(this::toNewBattle).toList() + ); + } + + private HomeEditorPickResponse toEditorPick(TodayBattleResponse battle) { + return new HomeEditorPickResponse( + 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 battle) { + return new HomeTrendingResponse( + battle.battleId(), + battle.thumbnailUrl(), + battle.title(), + battle.tags(), + battle.audioDuration(), + battle.viewCount() + ); + } + + private HomeBestBattleResponse toBestBattle(TodayBattleResponse battle) { + return new HomeBestBattleResponse( + battle.battleId(), + findOptionRepresentative(battle.options(), BattleOptionLabel.A), + findOptionRepresentative(battle.options(), BattleOptionLabel.B), + battle.title(), + battle.tags(), + battle.audioDuration(), + battle.viewCount() + ); + } + + 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( + 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(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( + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + POLL_SUMMARY, + participantsCount, + homeOptions + ); + } + + private HomeNewBattleResponse toNewBattle(TodayBattleResponse battle) { + return new HomeNewBattleResponse( + 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 String findOptionTitle(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(option -> option.label() == label) + .map(TodayOptionResponse::title) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private String findOptionRepresentative(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(option -> option.label() == label) + .map(TodayOptionResponse::representative) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private String findRepresentativeImageUrl(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .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() + .flatMap(List::stream) + .map(TodayBattleResponse::battleId) + .distinct() + .toList(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java b/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java new file mode 100644 index 00000000..2cb20dfa --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java @@ -0,0 +1,63 @@ +package com.swyp.picke.domain.notification.controller; + +import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse; +import com.swyp.picke.domain.notification.dto.response.NotificationListResponse; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.service.NotificationService; +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.security.core.annotation.AuthenticationPrincipal; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "알림 API", description = "알림 조회 및 읽음 처리") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "알림 목록 조회") + @GetMapping + public ApiResponse getNotifications( + @AuthenticationPrincipal Long userId, + @RequestParam(required = false) NotificationCategory category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess(notificationService.getNotifications(userId, category, page, size)); + } + + @Operation(summary = "알림 상세 조회") + @GetMapping("/{notificationId}") + public ApiResponse getNotificationDetail( + @AuthenticationPrincipal Long userId, + @PathVariable Long notificationId + ) { + return ApiResponse.onSuccess(notificationService.getNotificationDetail(userId, notificationId)); + } + + @Operation(summary = "알림 개별 읽음 처리") + @PatchMapping("/{notificationId}/read") + public ApiResponse markAsRead( + @AuthenticationPrincipal Long userId, + @PathVariable Long notificationId + ) { + notificationService.markAsRead(userId, notificationId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "알림 전체 읽음 처리") + @PatchMapping("/read-all") + public ApiResponse markAllAsRead(@AuthenticationPrincipal Long userId) { + notificationService.markAllAsRead(userId); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationDetailResponse.java b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationDetailResponse.java new file mode 100644 index 00000000..3dbbe826 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationDetailResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.notification.dto.response; + +import com.swyp.picke.domain.notification.enums.NotificationCategory; + +import java.time.LocalDateTime; + +public record NotificationDetailResponse( + Long notificationId, + NotificationCategory category, + String detailCode, + String title, + String body, + Long referenceId, + boolean isRead, + LocalDateTime createdAt, + LocalDateTime readAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationListResponse.java b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationListResponse.java new file mode 100644 index 00000000..bab5de2c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationListResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.notification.dto.response; + +import java.util.List; + +public record NotificationListResponse( + List items, + boolean hasNext +) {} diff --git a/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationSummaryResponse.java b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationSummaryResponse.java new file mode 100644 index 00000000..f03e0529 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationSummaryResponse.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.notification.dto.response; + +import com.swyp.picke.domain.notification.enums.NotificationCategory; + +import java.time.LocalDateTime; + +public record NotificationSummaryResponse( + Long notificationId, + NotificationCategory category, + String detailCode, + String title, + String body, + Long referenceId, + boolean isRead, + LocalDateTime createdAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java b/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java new file mode 100644 index 00000000..a94c16c4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java @@ -0,0 +1,73 @@ +package com.swyp.picke.domain.notification.entity; + +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.user.entity.User; +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; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "notifications") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationCategory category; + + @Enumerated(EnumType.STRING) + @Column(name = "detail_code", nullable = false, length = 30) + private NotificationDetailCode detailCode; + + @Column(nullable = false, length = 150) + private String title; + + @Column(columnDefinition = "TEXT") + private String body; + + @Column(name = "reference_id") + private Long referenceId; + + @Column(name = "is_read", nullable = false) + private boolean read; + + @Column(name = "read_at") + private LocalDateTime readAt; + + @Builder + private Notification(User user, NotificationCategory category, NotificationDetailCode detailCode, + String title, String body, Long referenceId) { + this.user = user; + this.category = category; + this.detailCode = detailCode; + this.title = title; + this.body = body; + this.referenceId = referenceId; + this.read = false; + } + + public void markAsRead() { + if (!this.read) { + this.read = true; + this.readAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationRead.java b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationRead.java new file mode 100644 index 00000000..92d72ab9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationRead.java @@ -0,0 +1,37 @@ +package com.swyp.picke.domain.notification.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "notification_reads", + uniqueConstraints = @UniqueConstraint(columnNames = {"notification_id", "user_id"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationRead extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_id", nullable = false) + private Notification notification; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Builder + private NotificationRead(Notification notification, Long userId) { + this.notification = notification; + this.userId = userId; + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/enums/NotificationCategory.java b/src/main/java/com/swyp/picke/domain/notification/enums/NotificationCategory.java new file mode 100644 index 00000000..d2425567 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/enums/NotificationCategory.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.notification.enums; + +public enum NotificationCategory { + ALL, + CONTENT, + NOTICE, + EVENT +} diff --git a/src/main/java/com/swyp/picke/domain/notification/enums/NotificationDetailCode.java b/src/main/java/com/swyp/picke/domain/notification/enums/NotificationDetailCode.java new file mode 100644 index 00000000..630e7283 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/enums/NotificationDetailCode.java @@ -0,0 +1,24 @@ +package com.swyp.picke.domain.notification.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationDetailCode { + + // CONTENT (1~3) + NEW_BATTLE(1, NotificationCategory.CONTENT, "새로운 배틀이 시작되었어요"), + VOTE_RESULT(2, NotificationCategory.CONTENT, "투표 결과가 나왔어요"), + CREDIT_EARNED(3, NotificationCategory.CONTENT, "포인트 적립"), + + // NOTICE (4) + POLICY_CHANGE(4, NotificationCategory.NOTICE, "공지사항"), + + // EVENT (5) + PROMOTION(5, NotificationCategory.EVENT, "이벤트"); + + private final int code; + private final NotificationCategory category; + private final String defaultTitle; +} diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationReadRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationReadRepository.java new file mode 100644 index 00000000..6104d5c6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationReadRepository.java @@ -0,0 +1,28 @@ +package com.swyp.picke.domain.notification.repository; + +import com.swyp.picke.domain.notification.entity.NotificationRead; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface NotificationReadRepository extends JpaRepository { + + boolean existsByNotificationIdAndUserId(Long notificationId, Long userId); + + List findByUserIdAndNotificationIdIn(Long userId, List notificationIds); + + @Modifying + @Query(value = """ + INSERT INTO notification_reads (notification_id, user_id, created_at, updated_at) + SELECT n.id, :userId, NOW(), NOW() + FROM notifications n + WHERE n.user_id IS NULL + AND n.id NOT IN ( + SELECT nr.notification_id FROM notification_reads nr WHERE nr.user_id = :userId + ) + """, nativeQuery = true) + int markAllBroadcastAsRead(@Param("userId") Long userId); +} 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 new file mode 100644 index 00000000..973a589b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,58 @@ +package com.swyp.picke.domain.notification.repository; + +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NotificationRepository extends JpaRepository { + + @Query(""" + SELECT n FROM Notification n + WHERE ( + (n.category = com.swyp.picke.domain.notification.enums.NotificationCategory.CONTENT AND n.user.id = :userId) + OR + (n.category <> com.swyp.picke.domain.notification.enums.NotificationCategory.CONTENT AND n.user IS NULL) + ) + AND (:category IS NULL OR n.category = :category) + ORDER BY n.createdAt DESC + """) + Slice findVisibleNotifications( + @Param("userId") Long userId, + @Param("category") NotificationCategory category, + Pageable pageable + ); + + @Query(""" + SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END + FROM Notification n + WHERE n.user IS NULL + AND n.category = :category + AND NOT EXISTS ( + SELECT 1 FROM NotificationRead nr + WHERE nr.notification = n AND nr.userId = :userId + ) + """) + boolean hasUnreadBroadcast(@Param("userId") Long userId, @Param("category") NotificationCategory category); + + @Modifying + @Query(""" + UPDATE Notification n SET n.read = true, n.readAt = CURRENT_TIMESTAMP + 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 new file mode 100644 index 00000000..80539904 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java @@ -0,0 +1,191 @@ +package com.swyp.picke.domain.notification.service; + +import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse; +import com.swyp.picke.domain.notification.dto.response.NotificationListResponse; +import com.swyp.picke.domain.notification.dto.response.NotificationSummaryResponse; +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationRead; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationReadRepository; +import com.swyp.picke.domain.notification.repository.NotificationRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +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; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final NotificationRepository notificationRepository; + private final NotificationReadRepository notificationReadRepository; + private final UserRepository userRepository; + + @Transactional + public Notification createNotification(Long userId, NotificationDetailCode detailCode, String body, Long referenceId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Notification notification = Notification.builder() + .user(user) + .category(detailCode.getCategory()) + .detailCode(detailCode) + .title(detailCode.getDefaultTitle()) + .body(body) + .referenceId(referenceId) + .build(); + + return notificationRepository.save(notification); + } + + @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(resolvedTitle) + .body(body) + .referenceId(referenceId) + .build(); + + return notificationRepository.save(notification); + } + + public NotificationListResponse getNotifications(Long userId, NotificationCategory category, int page, int size) { + int pageSize = size <= 0 ? DEFAULT_PAGE_SIZE : size; + NotificationCategory filterCategory = (category == NotificationCategory.ALL) ? null : category; + Slice slice = notificationRepository.findVisibleNotifications( + userId, filterCategory, PageRequest.of(page, pageSize)); + + List broadcastIds = slice.getContent().stream() + .filter(n -> n.getCategory() != NotificationCategory.CONTENT) + .map(Notification::getId) + .toList(); + + Set readBroadcastIds = broadcastIds.isEmpty() + ? Set.of() + : notificationReadRepository.findByUserIdAndNotificationIdIn(userId, broadcastIds) + .stream() + .map(nr -> nr.getNotification().getId()) + .collect(Collectors.toSet()); + + return new NotificationListResponse( + slice.getContent().stream() + .map(n -> toSummaryResponse(n, resolveIsRead(n, readBroadcastIds))) + .toList(), + slice.hasNext() + ); + } + + public NotificationDetailResponse getNotificationDetail(Long userId, Long notificationId) { + Notification notification = getAccessibleNotification(userId, notificationId); + + if (notification.getCategory() == NotificationCategory.CONTENT) { + return toDetailResponse(notification, notification.isRead(), notification.getReadAt()); + } + + boolean isRead = notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId); + return toDetailResponse(notification, isRead, null); + } + + @Transactional + public void markAsRead(Long userId, Long notificationId) { + Notification notification = getAccessibleNotification(userId, notificationId); + + if (notification.getCategory() == NotificationCategory.CONTENT) { + notification.markAsRead(); + return; + } + + if (!notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)) { + notificationReadRepository.save( + NotificationRead.builder() + .notification(notification) + .userId(userId) + .build() + ); + } + } + + @Transactional + public int markAllAsRead(Long userId) { + int contentCount = notificationRepository.markAllAsReadByUserId(userId); + int broadcastCount = notificationReadRepository.markAllBroadcastAsRead(userId); + return contentCount + broadcastCount; + } + + public boolean hasNewBroadcast(Long userId, NotificationCategory category) { + return notificationRepository.hasUnreadBroadcast(userId, category); + } + + private Notification getAccessibleNotification(Long userId, Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + + boolean isAccessible = notification.getCategory() == NotificationCategory.CONTENT + ? notification.getUser() != null && notification.getUser().getId().equals(userId) + : notification.getUser() == null; + + if (!isAccessible) { + throw new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND); + } + + return notification; + } + + private boolean resolveIsRead(Notification notification, Set readBroadcastIds) { + if (notification.getCategory() == NotificationCategory.CONTENT) { + return notification.isRead(); + } + return readBroadcastIds.contains(notification.getId()); + } + + private NotificationDetailResponse toDetailResponse(Notification notification, boolean isRead, java.time.LocalDateTime readAt) { + return new NotificationDetailResponse( + notification.getId(), + notification.getCategory(), + notification.getDetailCode().name(), + notification.getTitle(), + notification.getBody(), + notification.getReferenceId(), + isRead, + notification.getCreatedAt(), + readAt + ); + } + + private NotificationSummaryResponse toSummaryResponse(Notification notification, boolean isRead) { + return new NotificationSummaryResponse( + notification.getId(), + notification.getCategory(), + notification.getDetailCode().name(), + notification.getTitle(), + notification.getBody(), + notification.getReferenceId(), + isRead, + notification.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/client/GoogleOAuthClient.java b/src/main/java/com/swyp/picke/domain/oauth/client/GoogleOAuthClient.java new file mode 100644 index 00000000..6de6acbb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/client/GoogleOAuthClient.java @@ -0,0 +1,74 @@ +package com.swyp.picke.domain.oauth.client; + +import com.swyp.picke.domain.oauth.dto.OAuthUserInfo; +import com.swyp.picke.domain.oauth.dto.google.GoogleTokenResponse; +import com.swyp.picke.domain.oauth.dto.google.GoogleUserResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; // 1. 로그 추가 +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; // 2. 추가 +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GoogleOAuthClient { + + @Value("${oauth.google.client-id}") + private String clientId; + + @Value("${oauth.google.client-secret}") + private String clientSecret; + + // 인가 코드 → 구글 access_token + public String getAccessToken(String code, String redirectUri) { + // 3. 인코딩된 코드가 들어올 경우를 대비해 디코딩 처리 + String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); + + log.info("[Google Login] 요청 시작 - redirectUri: {}, code: {}", redirectUri, decodedCode); + + GoogleTokenResponse response = WebClient.create() + .post() + .uri("https://oauth2.googleapis.com/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + // 4. BodyInserters를 사용하여 데이터 전송 (가장 안전한 방식) + .body(BodyInserters.fromFormData("grant_type", "authorization_code") + .with("client_id", clientId) + .with("client_secret", clientSecret) + .with("redirect_uri", redirectUri) + .with("code", decodedCode)) + .retrieve() + // 5. 400 Bad Request 발생 시 구글이 보내는 진짜 이유를 로그로 확인 + .onStatus(HttpStatusCode::isError, clientResponse -> + clientResponse.bodyToMono(String.class).flatMap(errorBody -> { + log.error("[Google Auth Error] 상세 내용: {}", errorBody); + return Mono.error(new RuntimeException("구글 토큰 발급 실패")); + }) + ) + .bodyToMono(GoogleTokenResponse.class) + .block(); + + return response != null ? response.getAccessToken() : null; + } + + // 구글 access_token → 사용자 정보 + public OAuthUserInfo getUserInfo(String accessToken) { + GoogleUserResponse response = WebClient.create() + .get() + .uri("https://www.googleapis.com/oauth2/v2/userinfo") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .bodyToMono(GoogleUserResponse.class) + .block(); + + return new OAuthUserInfo("GOOGLE", response.getId(), response.getEmail()); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/client/KakaoOAuthClient.java b/src/main/java/com/swyp/picke/domain/oauth/client/KakaoOAuthClient.java new file mode 100644 index 00000000..3abcb55f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/client/KakaoOAuthClient.java @@ -0,0 +1,74 @@ +package com.swyp.picke.domain.oauth.client; + +import com.swyp.picke.domain.oauth.dto.OAuthUserInfo; +import com.swyp.picke.domain.oauth.dto.kakao.KakaoTokenResponse; +import com.swyp.picke.domain.oauth.dto.kakao.KakaoUserResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoOAuthClient { + + @Value("${oauth.kakao.client-id}") + private String clientId; + + @Value("${oauth.kakao.client-secret:}") + private String clientSecret; + + public String getAccessToken(String code, String redirectUri) { + // 인코딩된 코드가 들어올 경우를 대비해 디코딩 처리 + String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); + + log.info("[Kakao Login] 토큰 요청 시작 - redirectUri: {}, code: {}", redirectUri, decodedCode); + + KakaoTokenResponse response = WebClient.create() + .post() + .uri("https://kauth.kakao.com/oauth/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + // BodyInserters를 사용하여 폼 데이터 전송 (이중 인코딩 방지) + .body(BodyInserters.fromFormData("grant_type", "authorization_code") + .with("client_id", clientId) + .with("redirect_uri", redirectUri) + .with("code", decodedCode) + .with("client_secret", clientSecret)) // 빈 문자열이어도 카카오는 허용함 + .retrieve() + // 400 에러 발생 시 상세 이유 로그 출력 + .onStatus(HttpStatusCode::isError, clientResponse -> + clientResponse.bodyToMono(String.class).flatMap(errorBody -> { + log.error("[Kakao Auth Error] 상세 내용: {}", errorBody); + return Mono.error(new RuntimeException("카카오 토큰 발급 실패")); + }) + ) + .bodyToMono(KakaoTokenResponse.class) + .block(); + + return response != null ? response.getAccessToken() : null; + } + + public OAuthUserInfo getUserInfo(String accessToken) { + KakaoUserResponse response = WebClient.create() + .get() + .uri("https://kapi.kakao.com/v2/user/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .bodyToMono(KakaoUserResponse.class) + .block(); + + String providerId = String.valueOf(response.getId()); + + return new OAuthUserInfo("KAKAO", providerId, null); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..0ac93e0a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java @@ -0,0 +1,60 @@ +package com.swyp.picke.domain.oauth.controller; + +import com.swyp.picke.domain.oauth.dto.LoginRequest; +import com.swyp.picke.domain.oauth.dto.LoginResponse; +import com.swyp.picke.domain.oauth.dto.LogoutResponse; +import com.swyp.picke.domain.oauth.dto.WithdrawRequest; +import com.swyp.picke.domain.oauth.dto.WithdrawResponse; +import com.swyp.picke.domain.oauth.service.AuthService; +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.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +@Tag(name = "인증 API", description = "소셜 로그인, 토큰 재발급, 로그아웃, 회원 탈퇴") +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "소셜 로그인") + @PostMapping("/auth/login/{provider}") + public ApiResponse login( + @PathVariable String provider, + @RequestBody LoginRequest request + ) { + return ApiResponse.onSuccess(authService.login(provider, request)); + } + + @Operation(summary = "Access Token 재발급") + @PostMapping("/auth/refresh") + public ApiResponse refresh( + @RequestHeader("X-Refresh-Token") String refreshToken + ) { + return ApiResponse.onSuccess(authService.refresh(refreshToken)); + } + + @Operation(summary = "로그아웃") + @PostMapping("/auth/logout") + public ApiResponse logout( + @AuthenticationPrincipal Long userId + ) { + authService.logout(userId); + return ApiResponse.onSuccess(new LogoutResponse(true)); + } + + @Operation(summary = "회원 탈퇴") + @DeleteMapping("/me") + public ApiResponse withdraw( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody WithdrawRequest request + ) { + authService.withdraw(userId, request); + return ApiResponse.onSuccess(new WithdrawResponse(true)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/LoginRequest.java b/src/main/java/com/swyp/picke/domain/oauth/dto/LoginRequest.java new file mode 100644 index 00000000..ae83067e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/LoginRequest.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.oauth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +// 클라이언트가 서버로 요청을 보낼 때, 데이터를 담는 DTO +@Getter +@AllArgsConstructor +public class LoginRequest { + private String authorizationCode; + private String redirectUri; +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/LoginResponse.java b/src/main/java/com/swyp/picke/domain/oauth/dto/LoginResponse.java new file mode 100644 index 00000000..a9872d2e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/LoginResponse.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Getter; + +// 서버가 클라이언트에게 데이터를 돌려줄 때, 데이터를 담는 DTO +@Getter +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class LoginResponse { + private String accessToken; + private String refreshToken; + private String userTag; + private boolean isNewUser; + private String status; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/LogoutResponse.java b/src/main/java/com/swyp/picke/domain/oauth/dto/LogoutResponse.java new file mode 100644 index 00000000..3ed43b74 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/LogoutResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class LogoutResponse { + private final boolean loggedOut; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/OAuthUserInfo.java b/src/main/java/com/swyp/picke/domain/oauth/dto/OAuthUserInfo.java new file mode 100644 index 00000000..35f75fe9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/OAuthUserInfo.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.oauth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +// 소셜 API를 호출해서 받아온 사용자 정보를 담는 DTO +@Getter +@AllArgsConstructor +public class OAuthUserInfo { + private String provider; // "KAKAO" or "GOOGLE" + private String providerUserId; // 소셜 고유 ID + private String email; // nullable - 소셜 로그인 시도 시 선택 동의 안함 체크로 인해 +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/WithdrawRequest.java b/src/main/java/com/swyp/picke/domain/oauth/dto/WithdrawRequest.java new file mode 100644 index 00000000..176ff38b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/WithdrawRequest.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.oauth.dto; + +import com.swyp.picke.domain.user.enums.WithdrawalReason; +import jakarta.validation.constraints.NotNull; + +public record WithdrawRequest( + @NotNull + WithdrawalReason reason +) {} diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/WithdrawResponse.java b/src/main/java/com/swyp/picke/domain/oauth/dto/WithdrawResponse.java new file mode 100644 index 00000000..024f8a95 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/WithdrawResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class WithdrawResponse { + private final boolean withdrawn; +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/google/GoogleTokenResponse.java b/src/main/java/com/swyp/picke/domain/oauth/dto/google/GoogleTokenResponse.java new file mode 100644 index 00000000..b5438c1e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/google/GoogleTokenResponse.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.oauth.dto.google; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class GoogleTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private int expiresIn; + + @JsonProperty("id_token") + private String idToken; +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/google/GoogleUserResponse.java b/src/main/java/com/swyp/picke/domain/oauth/dto/google/GoogleUserResponse.java new file mode 100644 index 00000000..104257b5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/google/GoogleUserResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.oauth.dto.google; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class GoogleUserResponse { + + private String id; + private String email; + private String name; + + @JsonProperty("verified_email") + private boolean verifiedEmail; +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/kakao/KakaoTokenResponse.java b/src/main/java/com/swyp/picke/domain/oauth/dto/kakao/KakaoTokenResponse.java new file mode 100644 index 00000000..2002817b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/kakao/KakaoTokenResponse.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.oauth.dto.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class KakaoTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("expires_in") + private int expiresIn; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/kakao/KakaoUserResponse.java b/src/main/java/com/swyp/picke/domain/oauth/dto/kakao/KakaoUserResponse.java new file mode 100644 index 00000000..30d74dea --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/kakao/KakaoUserResponse.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.oauth.dto.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class KakaoUserResponse { + + private Long id; + + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; + + @Getter + public static class KakaoAccount { + private String email; + } +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/entity/AuthRefreshToken.java b/src/main/java/com/swyp/picke/domain/oauth/entity/AuthRefreshToken.java new file mode 100644 index 00000000..afe8ee3b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/entity/AuthRefreshToken.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.oauth.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "auth_refresh_tokens") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class AuthRefreshToken extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "token_hash", nullable = false) + private String tokenHash; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Builder + public AuthRefreshToken(User user, String tokenHash, LocalDateTime expiresAt) { + this.user = user; + this.tokenHash = tokenHash; + this.expiresAt = expiresAt; + } + + // 만료 여부 확인 + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/entity/UserSocialAccount.java b/src/main/java/com/swyp/picke/domain/oauth/entity/UserSocialAccount.java new file mode 100644 index 00000000..23cfed6b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/entity/UserSocialAccount.java @@ -0,0 +1,46 @@ +package com.swyp.picke.domain.oauth.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Table( + name = "user_social_accounts", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_provider_user", + columnNames = {"provider", "provider_user_id"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class UserSocialAccount extends BaseEntity { + + // 여러 소셜 계정을 연동할 수 있으므로 1 대 다 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 20) + private String provider; + + @Column(name = "provider_user_id", nullable = false) + private String providerUserId; + + @Column(name = "provider_email") + private String providerEmail; + + @Builder + public UserSocialAccount(User user, String provider, + String providerUserId, String providerEmail) { + this.user = user; + this.provider = provider; + this.providerUserId = providerUserId; + this.providerEmail = providerEmail; + } +} 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 new file mode 100644 index 00000000..8637f470 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -0,0 +1,133 @@ +package com.swyp.picke.domain.oauth.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.common.response.ApiResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final ObjectMapper objectMapper = new ObjectMapper(); + + // 1. 스웨거 및 인증 관련 경로를 더 넓게 잡았습니다. + private static final List WHITELIST = List.of( + "/api/v1/admob/reward", + "/swagger-ui", + "/v3/api-docs", + "/api/v1/admin/login", + "/api/v1/admin/picke", + "/js", + "/css", + "/images", + "/favicon.ico", + "/api/v1/auth", // 로그인, 리프레시 등 인증 관련 전체 + "/swagger-ui", // 스웨거 UI 리소스 전체 + "/v3/api-docs", // OpenAPI 스펙 전체 + "/api/v1/home", // 홈 화면 + "/api/v1/notices", // 공지사항 + "/api/test", // 테스트용 + "/result", // 공유 링크 리다이렉트 + "/report", // 철학자 리포트 딥링크 + "/battle", // 배틀 딥링크 + "/api/v1/share/recap/", // 공개 리캡 공유 조회 + "/.well-known", // Android App Links 인증 + "/api/v1/resources" // 이미지, 오디오 파일 (Presigned URL) + ); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String requestUri = request.getRequestURI(); + boolean isWhitelist = isWhitelisted(requestUri); + + log.info("[JwtFilter Debug] URI: {}, isWhitelisted: {}", requestUri, isWhitelist); + + try { + // 1. 화이트리스트 검사 전, 무조건 토큰부터 꺼냅니다. + String token = resolveToken(request); + + if (token != null) { + // 2. 토큰이 존재하면 유효성을 검사합니다. + if (!jwtProvider.validateToken(token)) { + log.error("[JwtFilter] Invalid or Expired token for URI: {}", requestUri); + setErrorResponse(response, ErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + return; + } + + // 3. 토큰이 유효하다면 SecurityContext에 유저 정보(userId)를 저장합니다. + Long userId = jwtProvider.getUserId(token); + String role = jwtProvider.getRole(token); + String authorityName = (role != null && role.startsWith("ROLE_")) ? role : "ROLE_" + role; + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userId, + null, + role != null ? List.of(new SimpleGrantedAuthority(authorityName)) : List.of() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + } else { + // 4. 토큰이 비어있을 때, 화이트리스트(홈 화면 등)가 아니라면 에러를 던집니다. + if (!isWhitelist) { + log.warn("[JwtFilter] Token missing for URI: {}", requestUri); + setErrorResponse(response, ErrorCode.AUTH_UNAUTHORIZED); + return; + } + } + + // 5. [토큰 검증을 무사히 마쳤거나] or [토큰이 없는 비회원인데 화이트리스트인 경우] 다음 필터로 넘어갑니다. + filterChain.doFilter(request, response); + + } catch (Exception e) { + log.error("[JwtFilter] Filter Error: {}", e.getMessage()); + setErrorResponse(response, ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(errorCode.getHttpStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure( + errorCode.getHttpStatus().value(), + errorCode.getCode(), + errorCode.getMessage() + ); + + String result = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(result); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + private boolean isWhitelisted(String uri) { + // 1. URI가 화이트리스트의 어떤 값으로든 시작하면 true + return WHITELIST.stream().anyMatch(uri::startsWith); + } +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtProvider.java b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtProvider.java new file mode 100644 index 00000000..58d2749a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtProvider.java @@ -0,0 +1,86 @@ +package com.swyp.picke.domain.oauth.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiration}") + private long accessTokenExpiration; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpiration; + + private SecretKey key; + + @PostConstruct + public void init() { + byte[] keyBytes = Base64.getDecoder().decode(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + // access token 생성 (권한 정보 추가) + public String createAccessToken(Long userId, String role) { + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("role", role) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration)) + .signWith(key) + .compact(); + } + + // refresh token 생성 + public String createRefreshToken() { + return UUID.randomUUID().toString(); + } + + // token 에서 userId 추출 + public Long getUserId(String token) { + Claims claims = getClaims(token); + return Long.parseLong(claims.getSubject()); + } + + // token 에서 role 추출 + public String getRole(String token) { + Claims claims = getClaims(token); + return claims.get("role", String.class); + } + + // token 유효성 검증 + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + // 중복 코드를 줄이기 위한 헬퍼 메서드 + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/repository/AuthRefreshTokenRepository.java b/src/main/java/com/swyp/picke/domain/oauth/repository/AuthRefreshTokenRepository.java new file mode 100644 index 00000000..acc24e95 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/repository/AuthRefreshTokenRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.oauth.repository; + +import com.swyp.picke.domain.oauth.entity.AuthRefreshToken; +import com.swyp.picke.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AuthRefreshTokenRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); + + void deleteByUser(User user); +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java b/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java new file mode 100644 index 00000000..e1a75c98 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.oauth.repository; + +import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserSocialAccountRepository extends JpaRepository { + + Optional findByProviderAndProviderUserId( + String provider, String providerUserId); + + Optional findByUser(User user); + + void deleteByUser(User user); +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java b/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java new file mode 100644 index 00000000..97d1ffc7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java @@ -0,0 +1,289 @@ +package com.swyp.picke.domain.oauth.service; + +import com.swyp.picke.domain.oauth.client.GoogleOAuthClient; +import com.swyp.picke.domain.oauth.client.KakaoOAuthClient; +import com.swyp.picke.domain.oauth.dto.LoginRequest; +import com.swyp.picke.domain.oauth.dto.LoginResponse; +import com.swyp.picke.domain.oauth.dto.OAuthUserInfo; +import com.swyp.picke.domain.oauth.dto.WithdrawRequest; +import com.swyp.picke.domain.oauth.entity.AuthRefreshToken; +import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.oauth.repository.AuthRefreshTokenRepository; +import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.picke.domain.user.enums.CharacterType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserProfile; +import com.swyp.picke.domain.user.entity.UserSettings; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.entity.UserTendencyScore; +import com.swyp.picke.domain.user.entity.UserWithdrawal; +import com.swyp.picke.domain.user.repository.UserProfileRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.repository.UserSettingsRepository; +import com.swyp.picke.domain.user.repository.UserTendencyScoreRepository; +import com.swyp.picke.domain.user.repository.UserWithdrawalRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + static final List DEFAULT_NICKNAME_PREFIXES = List.of( + "사색하는", + "질문하는", + "성찰하는", + "탐구하는", + "고요한", + "사유하는", + "관조하는", + "통찰하는", + "본질을찾는", + "의문을품은", + "진리를좇는", + "철학하는", + "깊이를품은", + "내면을걷는", + "사유에잠긴", + "유쾌한", + "대담한", + "조용한", + "엉뚱한", + "날카로운", + "느긋한", + "반짝이는", + "다정한", + "성실한", + "호기심많은", + "재빠른" + ); + + private final KakaoOAuthClient kakaoOAuthClient; + private final GoogleOAuthClient googleOAuthClient; + private final UserRepository userRepository; + private final UserSocialAccountRepository socialAccountRepository; + private final AuthRefreshTokenRepository refreshTokenRepository; + private final UserProfileRepository userProfileRepository; + private final UserSettingsRepository userSettingsRepository; + private final UserTendencyScoreRepository userTendencyScoreRepository; + private final UserWithdrawalRepository userWithdrawalRepository; + private final JwtProvider jwtProvider; + + public LoginResponse login(String provider, LoginRequest request) { + + // 0. Provider를 미리 대문자로 통일 + String providerUpper = provider.toUpperCase(); + + // 1. 소셜 사용자 정보 조회 + OAuthUserInfo oAuthUserInfo = getOAuthUserInfo(providerUpper, request.getAuthorizationCode(), request.getRedirectUri()); + + // 2. 기존 소셜 계정 조회 + UserSocialAccount socialAccount = socialAccountRepository + .findByProviderAndProviderUserId(providerUpper, oAuthUserInfo.getProviderUserId()) + .orElse(null); + + boolean isNewUser = false; + + User user; + if (socialAccount == null) { + // 신규 유저 생성 + user = User.builder() + .userTag(generateUserTag()) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + userRepository.save(user); + initializeUserDomain(user); + + // 소셜 계정 연결 + socialAccount = UserSocialAccount.builder() + .user(user) + .provider(providerUpper) + .providerUserId(oAuthUserInfo.getProviderUserId()) + .providerEmail(oAuthUserInfo.getEmail()) + .build(); + socialAccountRepository.save(socialAccount); + isNewUser = true; + } else { + user = socialAccount.getUser(); + } + + // 3. 제재 유저 체크 + if (user.getStatus() == UserStatus.BANNED) { + throw new CustomException(ErrorCode.USER_BANNED); + } + if (user.getStatus() == UserStatus.SUSPENDED) { + throw new CustomException(ErrorCode.USER_SUSPENDED); + } + + // 4. 기존 refresh token 삭제 후 새로 발급 + refreshTokenRepository.deleteByUser(user); + + String accessToken = jwtProvider.createAccessToken(user.getId(), user.getRole().name()); + String refreshToken = jwtProvider.createRefreshToken(); + + // 5. refresh token 해시해서 저장 + refreshTokenRepository.save(AuthRefreshToken.builder() + .user(user) + .tokenHash(hashToken(refreshToken)) + .expiresAt(LocalDateTime.now().plusDays(30)) + .build()); + + return new LoginResponse( + accessToken, + refreshToken, + user.getUserTag(), + isNewUser, + user.getStatus().name() + ); + } + + public LoginResponse refresh(String refreshToken) { + + // 1. refresh token 해시해서 DB 조회 + String tokenHash = hashToken(refreshToken); + AuthRefreshToken authRefreshToken = refreshTokenRepository + .findByTokenHash(tokenHash) + .orElseThrow(() -> new CustomException(ErrorCode.AUTH_REFRESH_TOKEN_EXPIRED)); + + // 2. 만료 여부 확인 + if (authRefreshToken.isExpired()) { + refreshTokenRepository.delete(authRefreshToken); + throw new CustomException(ErrorCode.AUTH_REFRESH_TOKEN_EXPIRED); + } + + // 3. 기존 토큰 삭제 후 새 토큰 발급 + User user = authRefreshToken.getUser(); + refreshTokenRepository.delete(authRefreshToken); + + String newAccessToken = jwtProvider.createAccessToken(user.getId(), user.getRole().name()); + String newRefreshToken = jwtProvider.createRefreshToken(); + + // 4. 새 refresh token 저장 + refreshTokenRepository.save(AuthRefreshToken.builder() + .user(user) + .tokenHash(hashToken(newRefreshToken)) + .expiresAt(LocalDateTime.now().plusDays(30)) + .build()); + + return new LoginResponse( + newAccessToken, + newRefreshToken, + user.getUserTag(), + false, + user.getStatus().name() + ); + } + + public void logout(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + refreshTokenRepository.deleteByUser(user); + } + + public void withdraw(Long userId, WithdrawRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + refreshTokenRepository.deleteByUser(user); + + if (user.getStatus() == UserStatus.DELETED) { + throw new CustomException(ErrorCode.USER_ALREADY_WITHDRAWN); + } + + if (!userWithdrawalRepository.existsByUser_Id(userId)) { + userWithdrawalRepository.save(UserWithdrawal.builder() + .user(user) + .reason(request.reason()) + .build()); + } + + socialAccountRepository.findByUser(user).ifPresent(socialAccountRepository::delete); + + user.delete(); + } + + // provider에 따라 소셜 사용자 정보 조회 + private OAuthUserInfo getOAuthUserInfo(String provider, String code, String redirectUri) { + return switch (provider.toUpperCase()) { + case "KAKAO" -> { + String token = kakaoOAuthClient.getAccessToken(code, redirectUri); + yield kakaoOAuthClient.getUserInfo(token); + } + case "GOOGLE" -> { + String token = googleOAuthClient.getAccessToken(code, redirectUri); + yield googleOAuthClient.getUserInfo(token); + } + default -> throw new CustomException(ErrorCode.INVALID_PROVIDER); + }; + } + + // user_tag 랜덤 생성 + private String generateUserTag() { + return "pique-" + UUID.randomUUID().toString().substring(0, 8); + } + + private void initializeUserDomain(User user) { + CharacterType characterType = CharacterType.random(); + + userProfileRepository.save(UserProfile.builder() + .user(user) + .nickname(generateDefaultNickname(characterType)) + .characterType(characterType) + .mannerTemperature(BigDecimal.valueOf(36.5)) + .build()); + + userSettingsRepository.save(UserSettings.builder() + .user(user) + .newBattleEnabled(false) + .battleResultEnabled(true) + .commentReplyEnabled(true) + .newCommentEnabled(false) + .contentLikeEnabled(false) + .marketingEventEnabled(true) + .build()); + + userTendencyScoreRepository.save(UserTendencyScore.builder() + .user(user) + .principle(0) + .reason(0) + .individual(0) + .change(0) + .inner(0) + .ideal(0) + .build()); + } + + private String generateDefaultNickname(CharacterType characterType) { + String prefix = DEFAULT_NICKNAME_PREFIXES.get( + ThreadLocalRandom.current().nextInt(DEFAULT_NICKNAME_PREFIXES.size()) + ); + return prefix + characterType.getLabel(); + } + + // refresh token 해시 + private String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("토큰 해시 실패", e); + } + } +} 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 new file mode 100644 index 00000000..c17eba4c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java @@ -0,0 +1,37 @@ +package com.swyp.picke.domain.perspective.controller; + +import com.swyp.picke.domain.perspective.dto.response.LikeResponse; +import com.swyp.picke.domain.perspective.service.CommentLikeService; +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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +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 = "댓글 좋아요 API", description = "댓글 좋아요 등록 및 취소") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class CommentLikeController { + + private final CommentLikeService commentLikeService; + + @Operation(summary = "댓글 좋아요 등록", description = "특정 댓글에 좋아요를 등록합니다.") + @PostMapping("/comments/{commentId}/likes") + public ApiResponse addLike(@PathVariable Long commentId, + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(commentLikeService.addLike(commentId, userId)); + } + + @Operation(summary = "댓글 좋아요 취소", description = "특정 댓글의 좋아요를 취소합니다.") + @DeleteMapping("/comments/{commentId}/likes") + public ApiResponse removeLike(@PathVariable Long commentId, + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(commentLikeService.removeLike(commentId, 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 new file mode 100644 index 00000000..728a7fea --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java @@ -0,0 +1,86 @@ +package com.swyp.picke.domain.perspective.controller; + +import com.swyp.picke.domain.perspective.dto.request.CreateCommentRequest; +import com.swyp.picke.domain.perspective.dto.request.UpdateCommentRequest; +import com.swyp.picke.domain.perspective.dto.response.CommentListResponse; +import com.swyp.picke.domain.perspective.dto.response.CreateCommentResponse; +import com.swyp.picke.domain.perspective.dto.response.UpdateCommentResponse; +import com.swyp.picke.domain.perspective.service.PerspectiveCommentService; +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.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") +@RequiredArgsConstructor +public class PerspectiveCommentController { + + private final PerspectiveCommentService commentService; + + @Operation(summary = "댓글 생성", description = "특정 관점에 댓글을 작성합니다.") + @PostMapping("/perspectives/{perspectiveId}/comments") + public ApiResponse createComment( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId, + @RequestBody @Valid CreateCommentRequest request + ) { + return ApiResponse.onSuccess(commentService.createComment(perspectiveId, userId, request)); + } + + @Operation(summary = "댓글 목록 조회", description = "특정 관점의 댓글 목록을 커서 기반 페이지네이션으로 조회합니다.") + @GetMapping("/perspectives/{perspectiveId}/comments") + public ApiResponse getComments( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) Integer size + ) { + return ApiResponse.onSuccess(commentService.getComments(perspectiveId, userId, cursor, size)); + } + + @Operation(summary = "댓글 목록 조회 (옵션 라벨)", description = "특정 관점의 댓글 목록을 커서 기반 페이지네이션으로 조회하며, stance를 투표한 옵션 라벨(A/B)로 반환합니다.") + @GetMapping("/perspectives/{perspectiveId}/comments/labeled") + public ApiResponse getCommentsWithLabel( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) Integer size + ) { + return ApiResponse.onSuccess(commentService.getCommentsWithLabel(perspectiveId, userId, cursor, size)); + } + + @Operation(summary = "댓글 삭제", description = "본인이 작성한 댓글을 삭제합니다.") + @DeleteMapping("/perspectives/{perspectiveId}/comments/{commentId}") + public ApiResponse deleteComment( + @PathVariable Long perspectiveId, + @PathVariable Long commentId, + @AuthenticationPrincipal Long userId + ) { + commentService.deleteComment(perspectiveId, commentId, userId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "댓글 수정", description = "본인이 작성한 댓글 내용을 수정합니다.") + @PatchMapping("/perspectives/{perspectiveId}/comments/{commentId}") + public ApiResponse updateComment( + @PathVariable Long perspectiveId, + @PathVariable Long commentId, + @AuthenticationPrincipal Long userId, + @RequestBody @Valid UpdateCommentRequest request + ) { + return ApiResponse.onSuccess(commentService.updateComment(perspectiveId, commentId, userId, request)); + } +} 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 new file mode 100644 index 00000000..03c9aa3f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java @@ -0,0 +1,101 @@ +package com.swyp.picke.domain.perspective.controller; + +import com.swyp.picke.domain.perspective.dto.request.CreatePerspectiveRequest; +import com.swyp.picke.domain.perspective.dto.request.UpdatePerspectiveRequest; +import com.swyp.picke.domain.perspective.dto.response.CreatePerspectiveResponse; +import com.swyp.picke.domain.perspective.dto.response.MyPerspectiveResponse; +import com.swyp.picke.domain.perspective.dto.response.PerspectiveDetailResponse; +import com.swyp.picke.domain.perspective.dto.response.PerspectiveListResponse; +import com.swyp.picke.domain.perspective.dto.response.UpdatePerspectiveResponse; +import com.swyp.picke.domain.perspective.service.PerspectiveService; +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.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") +@RequiredArgsConstructor +public class PerspectiveController { + + private final PerspectiveService perspectiveService; + + @Operation(summary = "관점 상세 조회", description = "특정 관점의 상세 정보를 조회합니다.") + @GetMapping("/perspectives/{perspectiveId}") + public ApiResponse getPerspectiveDetail( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(perspectiveService.getPerspectiveDetail(perspectiveId, userId)); + } + + @Operation(summary = "관점 생성", description = "특정 배틀에 대한 사용자 관점을 생성합니다.") + @PostMapping("/battles/{battleId}/perspectives") + public ApiResponse createPerspective( + @PathVariable Long battleId, + @AuthenticationPrincipal Long userId, + @RequestBody @Valid CreatePerspectiveRequest request + ) { + return ApiResponse.onSuccess(perspectiveService.createPerspective(battleId, userId, request)); + } + + @Operation(summary = "관점 목록 조회", description = "특정 배틀의 관점 목록을 커서 기반으로 조회합니다.") + @GetMapping("/battles/{battleId}/perspectives") + public ApiResponse getPerspectives( + @PathVariable Long battleId, + @AuthenticationPrincipal Long userId, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) Integer size, + @RequestParam(required = false) String optionLabel, + @RequestParam(required = false, defaultValue = "latest") String sort + ) { + return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionLabel, sort)); + } + + @Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점을 조회합니다.") + @GetMapping("/battles/{battleId}/perspectives/me") + public ApiResponse getMyPerspective( + @PathVariable Long battleId, + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(perspectiveService.getMyPerspective(battleId, userId)); + } + + @Operation(summary = "관점 삭제", description = "본인이 작성한 관점을 삭제합니다.") + @DeleteMapping("/perspectives/{perspectiveId}") + public ApiResponse deletePerspective( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { + perspectiveService.deletePerspective(perspectiveId, userId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "관점 검수 재요청", description = "검수 실패 상태의 관점에 대해 검수를 다시 요청합니다.") + @PostMapping("/perspectives/{perspectiveId}/moderation/retry") + public ApiResponse retryModeration( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { + perspectiveService.retryModeration(perspectiveId, userId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "관점 수정", description = "본인이 작성한 관점의 내용을 수정합니다.") + @PatchMapping("/perspectives/{perspectiveId}") + public ApiResponse updatePerspective( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId, + @RequestBody @Valid UpdatePerspectiveRequest request + ) { + return ApiResponse.onSuccess(perspectiveService.updatePerspective(perspectiveId, userId, request)); + } +} 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 new file mode 100644 index 00000000..7e090575 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java @@ -0,0 +1,47 @@ +package com.swyp.picke.domain.perspective.controller; + +import com.swyp.picke.domain.perspective.dto.response.LikeCountResponse; +import com.swyp.picke.domain.perspective.dto.response.LikeResponse; +import com.swyp.picke.domain.perspective.service.PerspectiveLikeService; +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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관점 좋아요 API", description = "관점 좋아요 조회, 등록, 취소") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class PerspectiveLikeController { + + private final PerspectiveLikeService likeService; + + @Operation(summary = "좋아요 수 조회", description = "특정 관점의 좋아요 수를 조회합니다.") + @GetMapping("/perspectives/{perspectiveId}/likes") + public ApiResponse getLikeCount(@PathVariable Long perspectiveId) { + return ApiResponse.onSuccess(likeService.getLikeCount(perspectiveId)); + } + + @Operation(summary = "좋아요 등록", description = "특정 관점에 좋아요를 등록합니다.") + @PostMapping("/perspectives/{perspectiveId}/likes") + public ApiResponse addLike( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(likeService.addLike(perspectiveId, userId)); + } + + @Operation(summary = "좋아요 취소", description = "특정 관점의 좋아요를 취소합니다.") + @DeleteMapping("/perspectives/{perspectiveId}/likes") + public ApiResponse removeLike( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(likeService.removeLike(perspectiveId, userId)); + } +} 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 new file mode 100644 index 00000000..eb227348 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java @@ -0,0 +1,40 @@ +package com.swyp.picke.domain.perspective.controller; + +import com.swyp.picke.domain.perspective.service.ReportService; +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.security.core.annotation.AuthenticationPrincipal; +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 = "신고 API", description = "관점/댓글 신고") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @Operation(summary = "관점 신고", description = "관점을 신고합니다. 신고 5회 누적 시 자동 숨김 처리됩니다.") + @PostMapping("/perspectives/{perspectiveId}/reports") + public ApiResponse reportPerspective( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { + reportService.reportPerspective(perspectiveId, userId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "댓글 신고", description = "댓글을 신고합니다. 신고 5회 누적 시 자동 숨김 처리됩니다.") + @PostMapping("/perspectives/{perspectiveId}/comments/{commentId}/reports") + public ApiResponse reportComment( + @PathVariable Long perspectiveId, + @PathVariable Long commentId, + @AuthenticationPrincipal Long userId) { + reportService.reportComment(commentId, userId); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreateCommentRequest.java b/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreateCommentRequest.java new file mode 100644 index 00000000..85d73972 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreateCommentRequest.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.perspective.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record CreateCommentRequest( + @NotBlank + String content +) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreatePerspectiveRequest.java b/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreatePerspectiveRequest.java new file mode 100644 index 00000000..f578a75e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreatePerspectiveRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.perspective.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreatePerspectiveRequest( + @NotBlank + @Size(max = 200) + String content +) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdateCommentRequest.java b/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdateCommentRequest.java new file mode 100644 index 00000000..32ed5e10 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdateCommentRequest.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.perspective.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateCommentRequest( + @NotBlank + String content +) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdatePerspectiveRequest.java b/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdatePerspectiveRequest.java new file mode 100644 index 00000000..d3a35e24 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdatePerspectiveRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.perspective.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdatePerspectiveRequest( + @NotBlank + @Size(max = 200) + String content +) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/CommentListResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/CommentListResponse.java new file mode 100644 index 00000000..bdf1de6a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/CommentListResponse.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +public record CommentListResponse( + List items, + String nextCursor, + boolean hasNext +) { + @Schema(name = "CommentItem") + public record Item( + Long commentId, + UserSummary user, + String stance, + String content, + int likeCount, + boolean isLiked, + boolean isMine, + LocalDateTime createdAt + ) {} + + @Schema(name = "CommentUserSummary") + public record UserSummary(String userTag, String nickname, String characterType, String characterImageUrl) {} +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/CreateCommentResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/CreateCommentResponse.java new file mode 100644 index 00000000..bc68b97c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/CreateCommentResponse.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import java.time.LocalDateTime; + +public record CreateCommentResponse( + Long commentId, + UserSummary user, + String stance, + String content, + int likeCount, + boolean isLiked, + boolean isMine, + LocalDateTime createdAt +) { + public record UserSummary(String userTag, String nickname, String characterType, String characterImageUrl) {} +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/CreatePerspectiveResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/CreatePerspectiveResponse.java new file mode 100644 index 00000000..5c8f541b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/CreatePerspectiveResponse.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; + +import java.time.LocalDateTime; + +public record CreatePerspectiveResponse( + Long perspectiveId, + PerspectiveStatus status, + LocalDateTime createdAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/LikeCountResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/LikeCountResponse.java new file mode 100644 index 00000000..6310f7b6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/LikeCountResponse.java @@ -0,0 +1,3 @@ +package com.swyp.picke.domain.perspective.dto.response; + +public record LikeCountResponse(Long perspectiveId, long likeCount) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/LikeResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/LikeResponse.java new file mode 100644 index 00000000..46d52dd7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/LikeResponse.java @@ -0,0 +1,3 @@ +package com.swyp.picke.domain.perspective.dto.response; + +public record LikeResponse(Long perspectiveId, int likeCount, boolean isLiked) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/MyPerspectiveResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/MyPerspectiveResponse.java new file mode 100644 index 00000000..2ae271e7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/MyPerspectiveResponse.java @@ -0,0 +1,21 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; + +import java.time.LocalDateTime; + +public record MyPerspectiveResponse( + Long perspectiveId, + UserSummary user, + OptionSummary option, + String content, + int likeCount, + int commentCount, + boolean isLiked, + PerspectiveStatus status, + LocalDateTime createdAt +) { + public record UserSummary(String userTag, String nickname, String characterType, String characterImageUrl) {} + + public record OptionSummary(Long optionId, String label, String title, String stance) {} +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/PerspectiveDetailResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/PerspectiveDetailResponse.java new file mode 100644 index 00000000..7a5c5f45 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/PerspectiveDetailResponse.java @@ -0,0 +1,19 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import java.time.LocalDateTime; + +public record PerspectiveDetailResponse( + Long perspectiveId, + UserSummary user, + OptionSummary option, + String content, + int likeCount, + int commentCount, + boolean isLiked, + boolean isMyPerspective, + LocalDateTime createdAt +) { + public record UserSummary(String userTag, String nickname, String characterType, String characterImageUrl) {} + + public record OptionSummary(Long optionId, String label, String title, String stance) {} +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/PerspectiveListResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/PerspectiveListResponse.java new file mode 100644 index 00000000..fa844240 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/PerspectiveListResponse.java @@ -0,0 +1,40 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +public record PerspectiveListResponse( + List items, + String nextCursor, + boolean hasNext +) { + @Schema(name = "PerspectiveItem") + public record Item( + Long perspectiveId, + UserSummary user, + OptionSummary option, + String content, + int likeCount, + int commentCount, + boolean isLiked, + boolean isMyPerspective, + LocalDateTime createdAt + ) {} + + @Schema(name = "PerspectiveUserSummary") + public record UserSummary( + String userTag, + String nickname, + String characterType, + String characterImageUrl + ) {} + + @Schema(name = "PerspectiveOptionSummary") + public record OptionSummary( + Long optionId, + String label, + String title, + String stance + ) {} +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/UpdateCommentResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/UpdateCommentResponse.java new file mode 100644 index 00000000..8bc16fff --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/UpdateCommentResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import java.time.LocalDateTime; + +public record UpdateCommentResponse( + Long commentId, + String content, + LocalDateTime updatedAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/UpdatePerspectiveResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/UpdatePerspectiveResponse.java new file mode 100644 index 00000000..d2be1b9f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/UpdatePerspectiveResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import java.time.LocalDateTime; + +public record UpdatePerspectiveResponse( + Long perspectiveId, + String content, + LocalDateTime updatedAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/entity/CommentLike.java b/src/main/java/com/swyp/picke/domain/perspective/entity/CommentLike.java new file mode 100644 index 00000000..ec86265d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/entity/CommentLike.java @@ -0,0 +1,37 @@ +package com.swyp.picke.domain.perspective.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "comment_likes", + uniqueConstraints = @UniqueConstraint(columnNames = {"comment_id", "user_id"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommentLike extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", nullable = false) + private PerspectiveComment comment; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Builder + private CommentLike(PerspectiveComment comment, Long userId) { + this.comment = comment; + this.userId = userId; + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/entity/CommentReport.java b/src/main/java/com/swyp/picke/domain/perspective/entity/CommentReport.java new file mode 100644 index 00000000..e8101c9b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/entity/CommentReport.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.perspective.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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "comment_reports", + uniqueConstraints = @UniqueConstraint(columnNames = {"comment_id", "user_id"})) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommentReport extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", nullable = false) + private PerspectiveComment comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + private CommentReport(PerspectiveComment comment, User user) { + this.comment = comment; + this.user = user; + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java b/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java new file mode 100644 index 00000000..4456b0ae --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java @@ -0,0 +1,102 @@ +package com.swyp.picke.domain.perspective.entity; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.user.entity.User; +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "perspectives", + uniqueConstraints = @UniqueConstraint(columnNames = {"battle_id", "user_id"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Perspective extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "battle_id", nullable = false) + private Battle battle; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id", nullable = false) + private BattleOption option; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + @Column(name = "comment_count", nullable = false) + private int commentCount = 0; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PerspectiveStatus status; + + @Builder + private Perspective(Battle battle, User user, BattleOption option, String content) { + this.battle = battle; + this.user = user; + this.option = option; + this.content = content; + this.likeCount = 0; + this.commentCount = 0; + this.status = PerspectiveStatus.PENDING; + } + + public void updateContent(String content) { + this.content = content; + } + + public void updateStatus(PerspectiveStatus status) { + this.status = status; + } + + public void publish() { + this.status = PerspectiveStatus.PUBLISHED; + } + + public void reject() { + this.status = PerspectiveStatus.REJECTED; + } + + public void hide() { + this.status = PerspectiveStatus.HIDDEN; + } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) this.likeCount--; + } + + public void incrementCommentCount() { + this.commentCount++; + } + + public void decrementCommentCount() { + if (this.commentCount > 0) this.commentCount--; + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveComment.java b/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveComment.java new file mode 100644 index 00000000..0f71d913 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveComment.java @@ -0,0 +1,63 @@ +package com.swyp.picke.domain.perspective.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +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 = "perspective_comments") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PerspectiveComment extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "perspective_id", nullable = false) + private Perspective perspective; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + @Column(nullable = false) + private boolean hidden = false; + + @Builder + private PerspectiveComment(Perspective perspective, User user, String content) { + this.perspective = perspective; + this.user = user; + this.content = content; + this.likeCount = 0; + this.hidden = false; + } + + public void hide() { + this.hidden = true; + } + + public void updateContent(String content) { + this.content = content; + } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) this.likeCount--; + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveLike.java b/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveLike.java new file mode 100644 index 00000000..2fab3c87 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveLike.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.perspective.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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "perspective_likes", + uniqueConstraints = @UniqueConstraint(columnNames = {"perspective_id", "user_id"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PerspectiveLike extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "perspective_id", nullable = false) + private Perspective perspective; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + private PerspectiveLike(Perspective perspective, User user) { + this.perspective = perspective; + this.user = user; + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveReport.java b/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveReport.java new file mode 100644 index 00000000..e5fd1ba1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/entity/PerspectiveReport.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.perspective.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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "perspective_reports", + uniqueConstraints = @UniqueConstraint(columnNames = {"perspective_id", "user_id"})) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PerspectiveReport extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "perspective_id", nullable = false) + private Perspective perspective; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + private PerspectiveReport(Perspective perspective, User user) { + this.perspective = perspective; + this.user = user; + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/enums/PerspectiveStatus.java b/src/main/java/com/swyp/picke/domain/perspective/enums/PerspectiveStatus.java new file mode 100644 index 00000000..31a261d8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/enums/PerspectiveStatus.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.perspective.enums; + +public enum PerspectiveStatus { + PENDING, PUBLISHED, REJECTED, MODERATION_FAILED, HIDDEN +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/CommentLikeRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/CommentLikeRepository.java new file mode 100644 index 00000000..5d866fb4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/CommentLikeRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.perspective.repository; + +import com.swyp.picke.domain.perspective.entity.CommentLike; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CommentLikeRepository extends JpaRepository { + + boolean existsByCommentAndUserId(PerspectiveComment comment, Long userId); + + Optional findByCommentAndUserId(PerspectiveComment comment, Long userId); +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/CommentReportRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/CommentReportRepository.java new file mode 100644 index 00000000..988183b6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/CommentReportRepository.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.perspective.repository; + +import com.swyp.picke.domain.perspective.entity.CommentReport; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentReportRepository extends JpaRepository { + + boolean existsByCommentAndUserId(PerspectiveComment comment, Long userId); + + long countByComment(PerspectiveComment comment); +} 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 new file mode 100644 index 00000000..b65fd2ac --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.java @@ -0,0 +1,28 @@ +package com.swyp.picke.domain.perspective.repository; + +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +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 java.time.LocalDateTime; +import java.util.List; + +public interface PerspectiveCommentRepository extends JpaRepository { + + List findByPerspectiveOrderByCreatedAtDesc(Perspective perspective, Pageable pageable); + + List findByPerspectiveAndCreatedAtBeforeOrderByCreatedAtDesc(Perspective perspective, LocalDateTime cursor, Pageable pageable); + + @Query("SELECT c FROM PerspectiveComment c JOIN FETCH c.perspective WHERE c.user.id = :userId ORDER BY c.createdAt DESC") + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + long countByUserId(Long userId); + + void deleteAllByPerspective(Perspective perspective); + + @Query("SELECT c FROM PerspectiveComment c WHERE c.perspective.battle.id = :battleId AND c.hidden = false AND c.likeCount >= :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/repository/PerspectiveLikeRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java new file mode 100644 index 00000000..4dd3b20b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java @@ -0,0 +1,25 @@ +package com.swyp.picke.domain.perspective.repository; + +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveLike; +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 java.util.List; +import java.util.Optional; + +public interface PerspectiveLikeRepository extends JpaRepository { + + boolean existsByPerspectiveAndUserId(Perspective perspective, Long userId); + + Optional findByPerspectiveAndUserId(Perspective perspective, Long userId); + + long countByPerspective(Perspective perspective); + + @Query("SELECT l FROM PerspectiveLike l JOIN FETCH l.perspective WHERE l.user.id = :userId ORDER BY l.createdAt DESC") + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + long countByUserId(Long userId); +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveReportRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveReportRepository.java new file mode 100644 index 00000000..92692dbf --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveReportRepository.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.perspective.repository; + +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveReport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PerspectiveReportRepository extends JpaRepository { + + boolean existsByPerspectiveAndUserId(Perspective perspective, Long userId); + + long countByPerspective(Perspective perspective); +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java new file mode 100644 index 00000000..c2a683ef --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java @@ -0,0 +1,29 @@ +package com.swyp.picke.domain.perspective.repository; + +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface PerspectiveRepository extends JpaRepository { + + boolean existsByBattleIdAndUserId(Long battleId, Long userId); + + Optional findByBattleIdAndUserId(Long battleId, Long userId); + + List findByBattleIdAndStatusOrderByCreatedAtDesc(Long battleId, PerspectiveStatus status, Pageable pageable); + + List findByBattleIdAndStatusAndCreatedAtBeforeOrderByCreatedAtDesc(Long battleId, PerspectiveStatus status, LocalDateTime cursor, Pageable pageable); + + List findByBattleIdAndOptionIdAndStatusOrderByCreatedAtDesc(Long battleId, Long optionId, PerspectiveStatus status, Pageable pageable); + + List findByBattleIdAndOptionIdAndStatusAndCreatedAtBeforeOrderByCreatedAtDesc(Long battleId, Long optionId, PerspectiveStatus status, LocalDateTime cursor, Pageable pageable); + + List findByBattleIdAndStatusOrderByLikeCountDescCreatedAtDesc(Long battleId, PerspectiveStatus status, Pageable pageable); + + List findByBattleIdAndOptionIdAndStatusOrderByLikeCountDescCreatedAtDesc(Long battleId, Long optionId, PerspectiveStatus status, 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/CommentLikeService.java b/src/main/java/com/swyp/picke/domain/perspective/service/CommentLikeService.java new file mode 100644 index 00000000..e1d49e3b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/CommentLikeService.java @@ -0,0 +1,60 @@ +package com.swyp.picke.domain.perspective.service; + +import com.swyp.picke.domain.perspective.dto.response.LikeResponse; +import com.swyp.picke.domain.perspective.entity.CommentLike; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import com.swyp.picke.domain.perspective.repository.CommentLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentLikeService { + + private final PerspectiveCommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + + @Transactional + public LikeResponse addLike(Long commentId, Long userId) { + PerspectiveComment comment = findCommentById(commentId); + + if (comment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.LIKE_SELF_FORBIDDEN); + } + + if (commentLikeRepository.existsByCommentAndUserId(comment, userId)) { + throw new CustomException(ErrorCode.LIKE_ALREADY_EXISTS); + } + + commentLikeRepository.save(CommentLike.builder() + .comment(comment) + .userId(userId) + .build()); + comment.incrementLikeCount(); + + return new LikeResponse(comment.getId(), comment.getLikeCount(), true); + } + + @Transactional + public LikeResponse removeLike(Long commentId, Long userId) { + PerspectiveComment comment = findCommentById(commentId); + + CommentLike like = commentLikeRepository.findByCommentAndUserId(comment, userId) + .orElseThrow(() -> new CustomException(ErrorCode.LIKE_NOT_FOUND)); + + commentLikeRepository.delete(like); + comment.decrementLikeCount(); + + return new LikeResponse(comment.getId(), comment.getLikeCount(), false); + } + + private PerspectiveComment findCommentById(Long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/GptModerationService.java b/src/main/java/com/swyp/picke/domain/perspective/service/GptModerationService.java new file mode 100644 index 00000000..e639fdc8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/GptModerationService.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.perspective.service; + +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GptModerationService { + + // 프롬프트는 추후 결정 + private static final String SYSTEM_PROMPT = + "당신은 콘텐츠 검수 AI입니다. 입력된 텍스트에 욕설, 혐오 발언, 폭력적 표현, 성적 표현, 특정인을 향한 공격적 내용이 포함되어 있는지 판단하세요. " + + "문제가 있으면 'REJECT', 없으면 'APPROVE' 딱 한 단어만 응답하세요."; + + private static final int MAX_ATTEMPTS = 2; + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TIMEOUT_MS = 10000; + private static final int WAIT_TIMEOUT_MS = 2000; + + private final PerspectiveRepository perspectiveRepository; + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.url}") + private String openaiUrl; + + @Value("${openai.model}") + private String model; + + @Async + public void moderate(Long perspectiveId, String content) { + Exception lastException = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + String result = callGpt(content); + PerspectiveStatus newStatus = result.contains("APPROVE") + ? PerspectiveStatus.PUBLISHED + : PerspectiveStatus.REJECTED; + + perspectiveRepository.findById(perspectiveId).ifPresent(p -> { + if (p.getStatus() == PerspectiveStatus.PENDING) { + if (newStatus == PerspectiveStatus.PUBLISHED) p.publish(); + else p.reject(); + perspectiveRepository.save(p); + } + }); + return; + } catch (Exception e) { + lastException = e; + if (attempt < MAX_ATTEMPTS) { + try { Thread.sleep(WAIT_TIMEOUT_MS); } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + log.error("GPT 검수 최종 실패 (재시도 소진). perspectiveId={}", perspectiveId, lastException); + perspectiveRepository.findById(perspectiveId).ifPresent(p -> { + if (p.getStatus() == PerspectiveStatus.PENDING) { + p.updateStatus(PerspectiveStatus.MODERATION_FAILED); + perspectiveRepository.save(p); + } + }); + } + + private String callGpt(String content) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + RestClient restClient = RestClient.builder().requestFactory(factory).build(); + + Map requestBody = Map.of( + "model", model, + "messages", List.of( + Map.of("role", "system", "content", SYSTEM_PROMPT), + Map.of("role", "user", "content", content) + ), + "max_tokens", 10 + ); + + Map response = restClient.post() + .uri(openaiUrl) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .body(requestBody) + .retrieve() + .body(Map.class); + + List choices = (List) response.get("choices"); + Map choice = (Map) choices.get(0); + Map message = (Map) choice.get("message"); + return ((String) message.get("content")).trim().toUpperCase(); + } +} 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 new file mode 100644 index 00000000..c7808893 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java @@ -0,0 +1,212 @@ +package com.swyp.picke.domain.perspective.service; + +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.perspective.dto.request.CreateCommentRequest; +import com.swyp.picke.domain.perspective.dto.request.UpdateCommentRequest; +import com.swyp.picke.domain.perspective.dto.response.CommentListResponse; +import com.swyp.picke.domain.perspective.dto.response.CreateCommentResponse; +import com.swyp.picke.domain.perspective.dto.response.UpdateCommentResponse; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import com.swyp.picke.domain.perspective.repository.CommentLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.user.dto.response.UserSummary; +import com.swyp.picke.domain.user.entity.User; +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.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; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PerspectiveCommentService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final PerspectiveRepository perspectiveRepository; + private final PerspectiveCommentRepository commentRepository; + private final UserRepository userRepository; + private final CommentLikeRepository commentLikeRepository; + private final UserService userQueryService; + private final BattleVoteService BattleVoteService; + private final BattleService battleService; + private final S3PresignedUrlService s3PresignedUrlService; + + @Transactional + public CreateCommentResponse createComment(Long perspectiveId, Long userId, CreateCommentRequest request) { + Perspective perspective = findPerspectiveById(perspectiveId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + PerspectiveComment comment = PerspectiveComment.builder() + .perspective(perspective) + .user(user) + .content(request.content()) + .build(); + + commentRepository.save(comment); + perspective.incrementCommentCount(); + + UserSummary userSummary = userQueryService.findSummaryById(userId); + String characterImageUrl = resolveCharacterImageUrl(userSummary.characterType()); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(perspective.getBattle().getId(), userId); + String stance = null; + if (postVoteOptionId != null) { + stance = battleService.findOptionById(postVoteOptionId).getStance(); + } + return new CreateCommentResponse( + comment.getId(), + new CreateCommentResponse.UserSummary(userSummary.userTag(), userSummary.nickname(), userSummary.characterType(), characterImageUrl), + stance, + comment.getContent(), + 0, + false, + true, + comment.getCreatedAt() + ); + } + + public CommentListResponse getComments(Long perspectiveId, Long userId, String cursor, Integer size) { + Perspective perspective = findPerspectiveById(perspectiveId); + + int pageSize = (size == null || size <= 0) ? DEFAULT_PAGE_SIZE : size; + PageRequest pageable = PageRequest.of(0, pageSize); + + List comments = cursor == null + ? commentRepository.findByPerspectiveOrderByCreatedAtDesc(perspective, pageable) + : commentRepository.findByPerspectiveAndCreatedAtBeforeOrderByCreatedAtDesc( + perspective, LocalDateTime.parse(cursor), pageable); + + Long battleId = perspective.getBattle().getId(); + List items = comments.stream() + .filter(c -> !c.isHidden()) + .map(c -> { + UserSummary user = userQueryService.findSummaryById(c.getUser().getId()); + String characterImageUrl = resolveCharacterImageUrl(user.characterType()); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, c.getUser().getId()); + String stance = null; + if (postVoteOptionId != null) { + BattleOption option = battleService.findOptionById(postVoteOptionId); + stance = option.getStance(); + } + boolean isLiked = commentLikeRepository.existsByCommentAndUserId(c, userId); + return new CommentListResponse.Item( + c.getId(), + new CommentListResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), + stance, + c.getContent(), + c.getLikeCount(), + isLiked, + c.getUser().getId().equals(userId), + c.getCreatedAt() + ); + }) + .toList(); + + String nextCursor = comments.size() == pageSize + ? comments.get(comments.size() - 1).getCreatedAt().toString() + : null; + + return new CommentListResponse(items, nextCursor, nextCursor != null); + } + + public CommentListResponse getCommentsWithLabel(Long perspectiveId, Long userId, String cursor, Integer size) { + Perspective perspective = findPerspectiveById(perspectiveId); + + int pageSize = (size == null || size <= 0) ? DEFAULT_PAGE_SIZE : size; + PageRequest pageable = PageRequest.of(0, pageSize); + + List comments = cursor == null + ? commentRepository.findByPerspectiveOrderByCreatedAtDesc(perspective, pageable) + : commentRepository.findByPerspectiveAndCreatedAtBeforeOrderByCreatedAtDesc( + perspective, LocalDateTime.parse(cursor), pageable); + + Long battleId = perspective.getBattle().getId(); + List items = comments.stream() + .filter(c -> !c.isHidden()) + .map(c -> { + UserSummary user = userQueryService.findSummaryById(c.getUser().getId()); + String characterImageUrl = resolveCharacterImageUrl(user.characterType()); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, c.getUser().getId()); + String stance = null; + if (postVoteOptionId != null) { + BattleOption option = battleService.findOptionById(postVoteOptionId); + stance = option.getLabel().name(); + } + boolean isLiked = commentLikeRepository.existsByCommentAndUserId(c, userId); + return new CommentListResponse.Item( + c.getId(), + new CommentListResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), + stance, + c.getContent(), + c.getLikeCount(), + isLiked, + c.getUser().getId().equals(userId), + c.getCreatedAt() + ); + }) + .toList(); + + String nextCursor = comments.size() == pageSize + ? comments.get(comments.size() - 1).getCreatedAt().toString() + : null; + + return new CommentListResponse(items, nextCursor, nextCursor != null); + } + + @Transactional + public void deleteComment(Long perspectiveId, Long commentId, Long userId) { + Perspective perspective = findPerspectiveById(perspectiveId); + PerspectiveComment comment = findCommentById(commentId); + validateOwnership(comment, userId); + + commentRepository.delete(comment); + perspective.decrementCommentCount(); + } + + @Transactional + public UpdateCommentResponse updateComment(Long perspectiveId, Long commentId, Long userId, UpdateCommentRequest request) { + findPerspectiveById(perspectiveId); + PerspectiveComment comment = findCommentById(commentId); + validateOwnership(comment, userId); + + comment.updateContent(request.content()); + return new UpdateCommentResponse(comment.getId(), comment.getContent(), comment.getUpdatedAt()); + } + + private Perspective findPerspectiveById(Long perspectiveId) { + return perspectiveRepository.findById(perspectiveId) + .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); + } + + private PerspectiveComment findCommentById(Long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + } + + private void validateOwnership(PerspectiveComment comment, Long userId) { + if (!comment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.COMMENT_FORBIDDEN); + } + } + + private String resolveCharacterImageUrl(String characterType) { + if (characterType == null || characterType.isBlank()) { + return null; + } + return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveLikeService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveLikeService.java new file mode 100644 index 00000000..85e51d62 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveLikeService.java @@ -0,0 +1,72 @@ +package com.swyp.picke.domain.perspective.service; + +import com.swyp.picke.domain.perspective.dto.response.LikeCountResponse; +import com.swyp.picke.domain.perspective.dto.response.LikeResponse; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveLike; +import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PerspectiveLikeService { + + private final PerspectiveRepository perspectiveRepository; + private final PerspectiveLikeRepository likeRepository; + private final UserRepository userRepository; + + public LikeCountResponse getLikeCount(Long perspectiveId) { + Perspective perspective = findPerspectiveById(perspectiveId); + long likeCount = likeRepository.countByPerspective(perspective); + return new LikeCountResponse(perspective.getId(), likeCount); + } + + @Transactional + public LikeResponse addLike(Long perspectiveId, Long userId) { + Perspective perspective = findPerspectiveById(perspectiveId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + if (perspective.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.LIKE_SELF_FORBIDDEN); + } + + if (likeRepository.existsByPerspectiveAndUserId(perspective, userId)) { + throw new CustomException(ErrorCode.LIKE_ALREADY_EXISTS); + } + + likeRepository.save(PerspectiveLike.builder() + .perspective(perspective) + .user(user) + .build()); + perspective.incrementLikeCount(); + + return new LikeResponse(perspective.getId(), perspective.getLikeCount(), true); + } + + @Transactional + public LikeResponse removeLike(Long perspectiveId, Long userId) { + Perspective perspective = findPerspectiveById(perspectiveId); + + PerspectiveLike like = likeRepository.findByPerspectiveAndUserId(perspective, userId) + .orElseThrow(() -> new CustomException(ErrorCode.LIKE_NOT_FOUND)); + + likeRepository.delete(like); + perspective.decrementLikeCount(); + + return new LikeResponse(perspective.getId(), perspective.getLikeCount(), false); + } + + private Perspective findPerspectiveById(Long perspectiveId) { + return perspectiveRepository.findById(perspectiveId) + .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveQueryService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveQueryService.java new file mode 100644 index 00000000..e1defc95 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveQueryService.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.perspective.service; + +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import com.swyp.picke.domain.perspective.entity.PerspectiveLike; +import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PerspectiveQueryService { + + private final PerspectiveCommentRepository perspectiveCommentRepository; + private final PerspectiveLikeRepository perspectiveLikeRepository; + + public List findUserComments(Long userId, int offset, int size) { + PageRequest pageable = PageRequest.of(offset / size, size); + return perspectiveCommentRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + } + + public long countUserComments(Long userId) { + return perspectiveCommentRepository.countByUserId(userId); + } + + public List findUserLikes(Long userId, int offset, int size) { + PageRequest pageable = PageRequest.of(offset / size, size); + return perspectiveLikeRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + } + + public long countUserLikes(Long userId) { + return perspectiveLikeRepository.countByUserId(userId); + } +} 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 new file mode 100644 index 00000000..ed8d596c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java @@ -0,0 +1,220 @@ +package com.swyp.picke.domain.perspective.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.service.BattleService; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.perspective.dto.request.CreatePerspectiveRequest; +import com.swyp.picke.domain.perspective.dto.request.UpdatePerspectiveRequest; +import com.swyp.picke.domain.perspective.dto.response.CreatePerspectiveResponse; +import com.swyp.picke.domain.perspective.dto.response.MyPerspectiveResponse; +import com.swyp.picke.domain.perspective.dto.response.PerspectiveDetailResponse; +import com.swyp.picke.domain.perspective.dto.response.PerspectiveListResponse; +import com.swyp.picke.domain.perspective.dto.response.UpdatePerspectiveResponse; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +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.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; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PerspectiveService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final PerspectiveRepository perspectiveRepository; + private final PerspectiveCommentRepository perspectiveCommentRepository; + private final PerspectiveLikeRepository perspectiveLikeRepository; + private final BattleService battleService; + private final BattleVoteService BattleVoteService; + private final UserService userQueryService; + private final UserRepository userRepository; + private final GptModerationService gptModerationService; + private final S3PresignedUrlService s3PresignedUrlService; + + public PerspectiveDetailResponse getPerspectiveDetail(Long perspectiveId, Long userId) { + Perspective perspective = findPerspectiveById(perspectiveId); + if (perspective.getStatus() == PerspectiveStatus.HIDDEN) { + throw new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND); + } + UserSummary user = userQueryService.findSummaryById(perspective.getUser().getId()); + String characterImageUrl = resolveCharacterImageUrl(user.characterType()); + BattleOption option = perspective.getOption(); + boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(perspective, userId); + return new PerspectiveDetailResponse( + perspective.getId(), + new PerspectiveDetailResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), + new PerspectiveDetailResponse.OptionSummary(option.getId(), option.getLabel().name(), option.getTitle(), option.getStance()), + perspective.getContent(), + perspective.getLikeCount(), + perspective.getCommentCount(), + isLiked, + perspective.getUser().getId().equals(userId), + perspective.getCreatedAt() + ); + } + + @Transactional + public CreatePerspectiveResponse createPerspective(Long battleId, Long userId, CreatePerspectiveRequest request) { + Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + if (perspectiveRepository.existsByBattleIdAndUserId(battleId, userId)) { + throw new CustomException(ErrorCode.PERSPECTIVE_ALREADY_EXISTS); + } + + BattleOption option = BattleVoteService.findPreVoteOption(battleId, userId); + + Perspective perspective = Perspective.builder() + .battle(battle) + .user(user) + .option(option) + .content(request.content()) + .build(); + + Perspective saved = perspectiveRepository.save(perspective); + gptModerationService.moderate(saved.getId(), saved.getContent()); + return new CreatePerspectiveResponse(saved.getId(), saved.getStatus(), saved.getCreatedAt()); + } + + public PerspectiveListResponse getPerspectives(Long battleId, Long userId, String cursor, Integer size, String optionLabel, String sort) { + battleService.findById(battleId); + + int pageSize = (size == null || size <= 0) ? DEFAULT_PAGE_SIZE : size; + PageRequest pageable = PageRequest.of(0, pageSize); + + boolean isPopular = "popular".equalsIgnoreCase(sort); + List perspectives; + + if (optionLabel != null) { + BattleOptionLabel label = BattleOptionLabel.valueOf(optionLabel.toUpperCase()); + BattleOption option = battleService.findOptionByBattleIdAndLabel(battleId, label); + perspectives = isPopular + ? perspectiveRepository.findByBattleIdAndOptionIdAndStatusOrderByLikeCountDescCreatedAtDesc(battleId, option.getId(), PerspectiveStatus.PUBLISHED, pageable) + : cursor == null + ? perspectiveRepository.findByBattleIdAndOptionIdAndStatusOrderByCreatedAtDesc(battleId, option.getId(), PerspectiveStatus.PUBLISHED, pageable) + : perspectiveRepository.findByBattleIdAndOptionIdAndStatusAndCreatedAtBeforeOrderByCreatedAtDesc(battleId, option.getId(), PerspectiveStatus.PUBLISHED, LocalDateTime.parse(cursor), pageable); + } else { + perspectives = isPopular + ? perspectiveRepository.findByBattleIdAndStatusOrderByLikeCountDescCreatedAtDesc(battleId, PerspectiveStatus.PUBLISHED, pageable) + : cursor == null + ? perspectiveRepository.findByBattleIdAndStatusOrderByCreatedAtDesc(battleId, PerspectiveStatus.PUBLISHED, pageable) + : perspectiveRepository.findByBattleIdAndStatusAndCreatedAtBeforeOrderByCreatedAtDesc(battleId, PerspectiveStatus.PUBLISHED, LocalDateTime.parse(cursor), pageable); + } + + List items = perspectives.stream() + .map(p -> { + UserSummary user = userQueryService.findSummaryById(p.getUser().getId()); + String characterImageUrl = resolveCharacterImageUrl(user.characterType()); + BattleOption option = p.getOption(); + boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(p, userId); + return new PerspectiveListResponse.Item( + p.getId(), + new PerspectiveListResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), + new PerspectiveListResponse.OptionSummary(option.getId(), option.getLabel().name(), option.getTitle(), option.getStance()), + p.getContent(), + p.getLikeCount(), + p.getCommentCount(), + isLiked, + p.getUser().getId().equals(userId), + p.getCreatedAt() + ); + }) + .toList(); + + String nextCursor = perspectives.size() == pageSize + ? perspectives.get(perspectives.size() - 1).getCreatedAt().toString() + : null; + + return new PerspectiveListResponse(items, nextCursor, nextCursor != null); + } + + @Transactional + public void deletePerspective(Long perspectiveId, Long userId) { + Perspective perspective = findPerspectiveById(perspectiveId); + validateOwnership(perspective, userId); + perspectiveCommentRepository.deleteAllByPerspective(perspective); + perspectiveRepository.delete(perspective); + } + + @Transactional + public UpdatePerspectiveResponse updatePerspective(Long perspectiveId, Long userId, UpdatePerspectiveRequest request) { + Perspective perspective = findPerspectiveById(perspectiveId); + validateOwnership(perspective, userId); + perspective.updateContent(request.content()); + perspective.updateStatus(PerspectiveStatus.PENDING); + gptModerationService.moderate(perspective.getId(), perspective.getContent()); + return new UpdatePerspectiveResponse(perspective.getId(), perspective.getContent(), perspective.getUpdatedAt()); + } + + public MyPerspectiveResponse getMyPerspective(Long battleId, Long userId) { + battleService.findById(battleId); + Perspective perspective = perspectiveRepository.findByBattleIdAndUserId(battleId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); + + UserSummary user = userQueryService.findSummaryById(userId); + String characterImageUrl = resolveCharacterImageUrl(user.characterType()); + BattleOption option = perspective.getOption(); + boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(perspective, userId); + + return new MyPerspectiveResponse( + perspective.getId(), + new MyPerspectiveResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), + new MyPerspectiveResponse.OptionSummary(option.getId(), option.getLabel().name(), option.getTitle(), option.getStance()), + perspective.getContent(), + perspective.getLikeCount(), + perspective.getCommentCount(), + isLiked, + perspective.getStatus(), + perspective.getCreatedAt() + ); + } + + @Transactional + public void retryModeration(Long perspectiveId, Long userId) { + Perspective perspective = findPerspectiveById(perspectiveId); + validateOwnership(perspective, userId); + if (perspective.getStatus() != PerspectiveStatus.MODERATION_FAILED) { + throw new CustomException(ErrorCode.PERSPECTIVE_MODERATION_NOT_FAILED); + } + perspective.updateStatus(PerspectiveStatus.PENDING); + gptModerationService.moderate(perspectiveId, perspective.getContent()); + } + + private Perspective findPerspectiveById(Long perspectiveId) { + return perspectiveRepository.findById(perspectiveId) + .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); + } + + private void validateOwnership(Perspective perspective, Long userId) { + if (!perspective.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.PERSPECTIVE_FORBIDDEN); + } + } + + private String resolveCharacterImageUrl(String characterType) { + if (characterType == null || characterType.isBlank()) { + return null; + } + return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/ReportService.java b/src/main/java/com/swyp/picke/domain/perspective/service/ReportService.java new file mode 100644 index 00000000..604614e3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/ReportService.java @@ -0,0 +1,80 @@ +package com.swyp.picke.domain.perspective.service; + +import com.swyp.picke.domain.perspective.entity.CommentReport; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import com.swyp.picke.domain.perspective.entity.PerspectiveReport; +import com.swyp.picke.domain.perspective.repository.CommentReportRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveReportRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private static final int REPORT_THRESHOLD = 10; + + private final PerspectiveRepository perspectiveRepository; + private final PerspectiveCommentRepository commentRepository; + private final PerspectiveReportRepository perspectiveReportRepository; + private final CommentReportRepository commentReportRepository; + private final UserRepository userRepository; + + @Transactional + public void reportPerspective(Long perspectiveId, Long userId) { + Perspective perspective = perspectiveRepository.findById(perspectiveId) + .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + if (perspective.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.REPORT_SELF_FORBIDDEN); + } + if (perspectiveReportRepository.existsByPerspectiveAndUserId(perspective, userId)) { + throw new CustomException(ErrorCode.REPORT_ALREADY_EXISTS); + } + + perspectiveReportRepository.save(PerspectiveReport.builder() + .perspective(perspective) + .user(user) + .build()); + + long reportCount = perspectiveReportRepository.countByPerspective(perspective); + if (reportCount >= REPORT_THRESHOLD) { + perspective.hide(); + } + } + + @Transactional + public void reportComment(Long commentId, Long userId) { + PerspectiveComment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + if (comment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.REPORT_SELF_FORBIDDEN); + } + if (commentReportRepository.existsByCommentAndUserId(comment, userId)) { + throw new CustomException(ErrorCode.REPORT_ALREADY_EXISTS); + } + + commentReportRepository.save(CommentReport.builder() + .comment(comment) + .user(user) + .build()); + + long reportCount = commentReportRepository.countByComment(comment); + if (reportCount >= REPORT_THRESHOLD) { + comment.hide(); + } + } +} 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 new file mode 100644 index 00000000..45dad51d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java @@ -0,0 +1,30 @@ +package com.swyp.picke.domain.recommendation.controller; + +import com.swyp.picke.domain.recommendation.dto.response.RecommendationListResponse; +import com.swyp.picke.domain.recommendation.service.RecommendationService; +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.security.core.annotation.AuthenticationPrincipal; +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.RestController; + +@Tag(name = "추천 API", description = "배틀 추천 조회") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class RecommendationController { + + private final RecommendationService recommendationService; + + @Operation(summary = "흥미 기반 배틀 추천 조회", description = "특정 배틀을 기준으로 흥미로운 배틀 목록을 추천합니다.") + @GetMapping("/battles/{battleId}/recommendations/interesting") + public ApiResponse getInterestingBattles( + @PathVariable Long battleId, + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(recommendationService.getInterestingBattles(battleId, userId)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/recommendation/dto/response/RecommendationListResponse.java b/src/main/java/com/swyp/picke/domain/recommendation/dto/response/RecommendationListResponse.java new file mode 100644 index 00000000..b62c4059 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/recommendation/dto/response/RecommendationListResponse.java @@ -0,0 +1,32 @@ +package com.swyp.picke.domain.recommendation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record RecommendationListResponse(List items, String nextCursor, boolean hasNext) { + + @Schema(name = "RecommendationItem") + public record Item( + Long battleId, + String title, + String summary, + Integer audioDuration, + Integer viewCount, + List tags, + long participantsCount, + List options + ) {} + + @Schema(name = "RecommendationTagSummary") + public record TagSummary(Long tagId, String name) {} + + @Schema(name = "RecommendationOptionSummary") + public record OptionSummary( + Long optionId, + String label, + String title, + String stance, + String representative, + String imageUrl + ) {} +} 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 new file mode 100644 index 00000000..00d3bb86 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java @@ -0,0 +1,133 @@ +package com.swyp.picke.domain.recommendation.service; + +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.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.service.BattleService; +import com.swyp.picke.domain.recommendation.dto.response.RecommendationListResponse; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.user.enums.PhilosopherType; +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.BattleVoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RecommendationService { + + private static final int SAME_TYPE_COUNT = 3; + private static final int OPPOSITE_TYPE_COUNT = 2; + + private final BattleService battleService; + private final BattleRepository battleRepository; + private final BattleOptionRepository battleOptionRepository; + private final BattleOptionTagRepository battleOptionTagRepository; + private final BattleVoteRepository BattleVoteRepository; + private final UserService userService; + private final ResourceUrlProvider urlProvider; + + public RecommendationListResponse getInterestingBattles(Long battleId, Long userId) { + battleService.findById(battleId); + + // 현재 유저의 철학자 유형 및 반대 유형 + PhilosopherType myType = userService.getPhilosopherType(userId); + PhilosopherType oppositeType = myType.getWorstMatch(); + + // 현재 유저가 이미 참여한 배틀 ID 목록 (제외 대상) + List excludeBattleIds = BattleVoteRepository.findParticipatedBattleIdsByUserId(userId); + if (excludeBattleIds.isEmpty()) excludeBattleIds = List.of(-1L); + + List sameTypeUserIds = findUserIdsByPhilosopherType(myType); + List oppositeTypeUserIds = findUserIdsByPhilosopherType(oppositeType); + + // 같은 유형 유저들이 참여한 배틀 후보 ID + List sameCandidateIds = sameTypeUserIds.isEmpty() + ? List.of() + : BattleVoteRepository.findParticipatedBattleIdsByUserIds(sameTypeUserIds); + + // 반대 유형 유저들이 참여한 배틀 후보 ID + List oppositeCandidateIds = oppositeTypeUserIds.isEmpty() + ? List.of() + : BattleVoteRepository.findParticipatedBattleIdsByUserIds(oppositeTypeUserIds); + + // 인기 점수 기준 배틀 조회 (Score = V*1.0 + C*1.5 + Vw*0.2) + // 철학자 유형 로직 미구현 시 인기 배틀로 폴백 + List sameBattles = sameCandidateIds.isEmpty() + ? battleRepository.findPopularBattlesExcluding(excludeBattleIds, PageRequest.of(0, SAME_TYPE_COUNT)) + : battleRepository.findRecommendedBattles(sameCandidateIds, excludeBattleIds, PageRequest.of(0, SAME_TYPE_COUNT)); + + List oppositeBattles = oppositeCandidateIds.isEmpty() + ? battleRepository.findPopularBattlesExcluding(excludeBattleIds, PageRequest.of(0, OPPOSITE_TYPE_COUNT)) + : battleRepository.findRecommendedBattles(oppositeCandidateIds, excludeBattleIds, PageRequest.of(0, OPPOSITE_TYPE_COUNT)); + + List result = new ArrayList<>(); + result.addAll(sameBattles); + result.addAll(oppositeBattles); + + List items = result.stream() + .map(this::toItem) + .collect(Collectors.toList()); + + return new RecommendationListResponse(items, null, false); + } + + private RecommendationListResponse.Item toItem(Battle battle) { + List options = battleOptionRepository.findByBattle(battle); + + List optionSummaries = options.stream() + .map(opt -> new RecommendationListResponse.OptionSummary( + opt.getId(), + opt.getLabel().name(), + opt.getTitle(), + opt.getStance(), + opt.getRepresentative(), + urlProvider.getImageUrl( + FileCategory.PHILOSOPHER, + PhilosopherType.resolveImageKey(opt.getRepresentative()) + ) + )) + .toList(); + + // CATEGORY 태그만 노출 + List tagSummaries = options.stream() + .flatMap(opt -> battleOptionTagRepository.findByBattleOption(opt).stream()) + .map(BattleOptionTag::getTag) + .filter(tag -> tag.getType() == TagType.CATEGORY) + .distinct() + .map(tag -> new RecommendationListResponse.TagSummary(tag.getId(), tag.getName())) + .toList(); + + return new RecommendationListResponse.Item( + battle.getId(), + battle.getTitle(), + battle.getSummary(), + battle.getAudioDuration(), + battle.getViewCount(), + tagSummaries, + battle.getTotalParticipantsCount(), + optionSummaries + ); + } + + /** + * TODO: 철학자 유형별 유저 ID 조회 구현 필요 + * - 사후투표 시 BattleOptionTag(PHILOSOPHER 타입) 기반으로 유저별 철학자 점수 누적 테이블 구현 후 대체 + * - 현재는 빈 리스트 반환 + */ + 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 new file mode 100644 index 00000000..71a4f239 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java @@ -0,0 +1,40 @@ +package com.swyp.picke.domain.reward.controller; + +import com.swyp.picke.domain.reward.dto.request.AdMobRewardRequest; +import com.swyp.picke.domain.reward.dto.response.AdMobRewardResponse; +import com.swyp.picke.domain.reward.service.AdMobRewardService; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "보상 API", description = "AdMob 광고 보상 관련 API") +@RestController +@RequestMapping("/api/v1/admob") +@RequiredArgsConstructor +public class AdMobRewardController { + + private final AdMobRewardService rewardService; + + /** + * // 1. AdMob SSV 콜백 수신 엔드포인트 + * 호출 경로: GET /api/v1/admob/reward + */ + @Operation(summary = "AdMob 보상 콜백 수신") + @GetMapping("/reward") + public ApiResponse handleAdMobReward( + AdMobRewardRequest request) { + log.info("AdMob SSV 콜백 수신: transaction_id={}", request.transaction_id()); + + // 서비스에서 "OK" 또는 "Already Processed" 수신 + String status = rewardService.processReward(request); + + // DTO로 감싸서 반환 (명세서의 data { "reward_status": "..." } 구조 완성) + return ApiResponse.onSuccess(AdMobRewardResponse.from(status)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/reward/dto/request/AdMobRewardRequest.java b/src/main/java/com/swyp/picke/domain/reward/dto/request/AdMobRewardRequest.java new file mode 100644 index 00000000..af4b8716 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/dto/request/AdMobRewardRequest.java @@ -0,0 +1,69 @@ +package com.swyp.picke.domain.reward.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.swyp.picke.domain.reward.enums.RewardItem; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.RequestParam; + +@Slf4j +public record AdMobRewardRequest( + String ad_network, + String ad_unit, + String custom_data, + int reward_amount, + String reward_item, + long timestamp, + String transaction_id, + String signature, + String key_id, + String user_id +) { + public AdMobRewardRequest( + @RequestParam(value = "ad_network", required = false) String ad_network, + @RequestParam("ad_unit") String ad_unit, + @RequestParam(value = "custom_data", required = false) String custom_data, + @RequestParam("reward_amount") int reward_amount, + @RequestParam("reward_item") String reward_item, + @RequestParam("timestamp") long timestamp, + @RequestParam("transaction_id") String transaction_id, + @RequestParam("signature") String signature, + @RequestParam("key_id") String key_id, + @RequestParam(value = "user_id", required = false) String user_id + ) { + this.ad_network = ad_network; + this.ad_unit = ad_unit; + this.custom_data = custom_data; + this.reward_amount = reward_amount; + this.reward_item = reward_item; + this.timestamp = timestamp; + this.transaction_id = transaction_id; + this.signature = signature; + this.key_id = key_id; + this.user_id = user_id; + } + + // // 1. 유저 태그(문자열)를 꺼내는 메서드 + @JsonIgnore + public String getUserTag() { + if (this.custom_data != null && !this.custom_data.isBlank()) { + return this.custom_data; + } + return this.user_id; + } + + @JsonIgnore + public RewardItem getRewardType() { + if (this.reward_item == null || this.reward_item.isBlank()) { + return RewardItem.POINT; + } + try { + if (this.reward_item == null) return RewardItem.POINT; + return RewardItem.POINT; // 실서비스 안전을 위해 POINT로 고정하거나 로직 유지 + } catch (Exception e) { + return RewardItem.POINT; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/reward/dto/response/AdMobRewardResponse.java b/src/main/java/com/swyp/picke/domain/reward/dto/response/AdMobRewardResponse.java new file mode 100644 index 00000000..8cb639f4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/dto/response/AdMobRewardResponse.java @@ -0,0 +1,23 @@ +package com.swyp.picke.domain.reward.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Schema(description = "AdMob 보상 처리 결과 응답") +public class AdMobRewardResponse { + + @Schema(description = "처리 결과 코드 (OK, Already Processed)", example = "OK") + private final String reward_status; + + public static AdMobRewardResponse from(String status) { + return AdMobRewardResponse.builder() + .reward_status(status) + .build(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/reward/entity/AdRewardHistory.java b/src/main/java/com/swyp/picke/domain/reward/entity/AdRewardHistory.java new file mode 100644 index 00000000..fb3c23b0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/entity/AdRewardHistory.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.reward.entity; + +import com.swyp.picke.domain.reward.enums.RewardItem; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Table(name = "ad_reward_history") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdRewardHistory extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "transaction_id", unique = true, nullable = false) + private String transactionId; + + @Column(name = "reward_amount", nullable = false) + private int rewardAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "reward_item", nullable = false) + private RewardItem rewardItem; + + @Builder + public AdRewardHistory(User user, String transactionId, int rewardAmount, RewardItem rewardItem) { + this.user = user; + this.transactionId = transactionId; + this.rewardAmount = rewardAmount; + this.rewardItem = rewardItem; + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/reward/enums/RewardItem.java b/src/main/java/com/swyp/picke/domain/reward/enums/RewardItem.java new file mode 100644 index 00000000..67605444 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/enums/RewardItem.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.reward.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RewardItem { + POINT, ITEM; + + public static RewardItem from(String value) { + for (RewardItem type : RewardItem.values()) { + if (type.name().equalsIgnoreCase(value)) return type; + } + + throw new IllegalArgumentException("REWARD_INVALID_TYPE"); + } +} diff --git a/src/main/java/com/swyp/picke/domain/reward/repository/AdRewardHistoryRepository.java b/src/main/java/com/swyp/picke/domain/reward/repository/AdRewardHistoryRepository.java new file mode 100644 index 00000000..90406c7e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/repository/AdRewardHistoryRepository.java @@ -0,0 +1,19 @@ +package com.swyp.picke.domain.reward.repository; + +import com.swyp.picke.domain.reward.entity.AdRewardHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AdRewardHistoryRepository extends JpaRepository { + + /** + * 1. 중복 보상 지급 방지를 위한 검증 메서드 + * @param transactionId 구글에서 보낸 고유 트랜잭션 ID + * @return 존재하면 true, 없으면 false + */ + + // transactionId는 한 광고의 시청 영수증 번호라고 생각해주세요! + boolean existsByTransactionId(String transactionId); + +} diff --git a/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardService.java b/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardService.java new file mode 100644 index 00000000..a97ac620 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardService.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.reward.service; + +import com.swyp.picke.domain.reward.dto.request.AdMobRewardRequest; + +// 서비스를 인터페이스로 분리하면 서비스를 변경할 때, Impl 파일만 수정하면 됨! +// 테스트 코드 짜기 용이! +public interface AdMobRewardService { + + String processReward(AdMobRewardRequest request); + +} diff --git a/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java b/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java new file mode 100644 index 00000000..6f8b9931 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java @@ -0,0 +1,106 @@ +package com.swyp.picke.domain.reward.service; + +import com.google.crypto.tink.apps.rewardedads.RewardedAdsVerifier; +import com.swyp.picke.domain.reward.dto.request.AdMobRewardRequest; +import com.swyp.picke.domain.reward.entity.AdRewardHistory; +import com.swyp.picke.domain.reward.repository.AdRewardHistoryRepository; +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; // // 1. UserService 임포트 +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.GeneralSecurityException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdMobRewardServiceImpl implements AdMobRewardService { + + private final RewardedAdsVerifier rewardedAdsVerifier; + private final AdRewardHistoryRepository adRewardHistoryRepository; + private final UserService userService; // // 2. UserRepository 대신 UserService 사용 (태그 조회 로직 집중) + private final CreditService creditService; + + @Override + @Transactional + public String processReward(AdMobRewardRequest request) { + // 1. 서명 검증 (공식 파라미터 기반) + /*if (!verifyAdMobSignature(request)) { + log.warn("AdMob 서명 검증 실패: transaction_id={}", request.transaction_id()); + throw new CustomException(ErrorCode.REWARD_INVALID_SIGNATURE); + }*/ + + // 2. 중복 처리 방지 + if (adRewardHistoryRepository.existsByTransactionId(request.transaction_id())) { + log.info("이미 처리된 광고 요청입니다: transaction_id={}", request.transaction_id()); + return "Already Processed"; + } + + // 3. 유저 확인 (UserTag를 이용해 UserService에서 실제 유저 확보) + // request.getUserTag()는 custom_data 혹은 user_id를 반환합니다. + User user = userService.findByUserTag(request.getUserTag()); + + // 4. 보상 이력(AdRewardHistory) 저장 + AdRewardHistory history = AdRewardHistory.builder() + .transactionId(request.transaction_id()) + .user(user) + .rewardAmount(request.reward_amount()) + .rewardItem(request.getRewardType()) // // Enum 명칭 저장 + .build(); + adRewardHistoryRepository.save(history); + + // // 5. 크레딧 적립 + Long refId = parseTransactionId(request.transaction_id()); + creditService.addCredit(user.getId(), CreditType.FREE_CHARGE, request.reward_amount(), refId); + + log.info("보상 지급 완료: userTag={}, userId={}, amount={}", + user.getUserTag(), user.getId(), request.reward_amount()); + return "OK"; + } + + private Long parseTransactionId(String transactionId) { + try { + return Long.parseLong(transactionId.replaceAll("[^0-9]", "")); + } catch (Exception e) { + return (long) Math.abs(transactionId.hashCode()); + } + } + + /** + * // 6. 서명 검증 로직 수정 + * 구글 공식 문서의 파라미터 순서와 명칭(ad_unit 등)을 엄격히 준수해야 합니다. + */ + private boolean verifyAdMobSignature(AdMobRewardRequest request) { + try { + // // 조립 시 signature와 key_id는 제외하고 나머지 8개 파라미터를 조립합니다. + // // 순서: ad_network -> ad_unit -> custom_data -> reward_amount -> reward_item -> timestamp -> transaction_id -> user_id + StringBuilder sb = new StringBuilder(); + if (request.ad_network() != null) sb.append("ad_network=").append(request.ad_network()).append("&"); + sb.append("ad_unit=").append(request.ad_unit()).append("&"); + if (request.custom_data() != null) sb.append("custom_data=").append(request.custom_data()).append("&"); + sb.append("reward_amount=").append(request.reward_amount()).append("&"); + sb.append("reward_item=").append(request.reward_item()).append("&"); + sb.append("timestamp=").append(request.timestamp()).append("&"); + sb.append("transaction_id=").append(request.transaction_id()); + if (request.user_id() != null) sb.append("&user_id=").append(request.user_id()); + + String fullQueryString = sb.toString(); + + // // Tink 라이브러리를 통해 signature와 key_id를 사용하여 검증 + rewardedAdsVerifier.verify(fullQueryString); + return true; + } catch (GeneralSecurityException e) { + log.error("보상 서명 보안 에러: {}", e.getMessage()); + return false; + } catch (Exception e) { + log.error("보상 검증 중 예상치 못한 에러: {}", e.getMessage()); + return false; + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..b03bca91 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/controller/ScenarioController.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.scenario.controller; + +import com.swyp.picke.domain.battle.service.BattleService; +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.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; + +@Tag(name = "시나리오 API", description = "사용자 시나리오 조회") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class ScenarioController { + + private final ScenarioService scenarioService; + private final BattleService battleService; + + @Operation(summary = "배틀 시나리오 조회") + @GetMapping("/battles/{battleId}/scenario") + public ApiResponse getBattleScenario( + @PathVariable Long battleId, + @RequestAttribute(value = "userId", required = false) Long userId + ) { + var battleInfo = battleService.getBattleScenario(battleId); + var scenarioInfo = scenarioService.getScenarioForUser(battleId, userId); + + UserScenarioResponse response = scenarioInfo.toBuilder() + .title(battleInfo.title()) + .philosophers(battleInfo.philosophers()) + .build(); + + return ApiResponse.onSuccess(response); + } +} 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 new file mode 100644 index 00000000..a5b73b06 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java @@ -0,0 +1,139 @@ +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; +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.global.infra.s3.util.ResourceUrlProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class ScenarioConverter { + + private final ResourceUrlProvider resourceUrlProvider; + + public UserScenarioResponse toUserResponse(Scenario scenario, AudioPathType recommendedPathKey) { + Long startNodeId = scenario.getNodes().stream() + .filter(node -> Boolean.TRUE.equals(node.getIsStartNode())) + .map(ScenarioNode::getId) + .findFirst() + .orElse(null); + + List nodeResponses = scenario.getNodes().stream() + .map(this::toUserNodeResponse) + .collect(Collectors.toList()); + + Map fullUrlAudios = new HashMap<>(); + if (scenario.getAudios() != null) { + scenario.getAudios().forEach((audioPathType, fileName) -> { + String publicAudioUrl = resourceUrlProvider.getAudioUrl(scenario.getId(), fileName); + fullUrlAudios.put(audioPathType, publicAudioUrl); + }); + } + + return UserScenarioResponse.builder() + .battleId(scenario.getBattle().getId()) + .title(scenario.getBattle().getTitle()) + .isInteractive(scenario.getIsInteractive()) + .startNodeId(startNodeId) + .recommendedPathKey(recommendedPathKey) + .audios(fullUrlAudios) + .nodes(nodeResponses) + .build(); + } + + 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()) + .nodeName(node.getNodeName()) + .audioDuration(node.getAudioDuration()) + .autoNextNodeId(node.getAutoNextNodeId()) + .scripts(node.getScripts().stream() + .map(this::toUserScriptResponse) + .collect(Collectors.toList())) + .interactiveOptions(node.getOptions().stream() + .map(this::toUserOptionResponse) + .collect(Collectors.toList())) + .build(); + } + + private ScriptResponse toUserScriptResponse(Script script) { + String cleanText = script.getText() + .replaceAll("\\[.*?\\]", "") + .replaceAll("\\s+", " ") + .trim(); + + return ScriptResponse.builder() + .scriptId(script.getId()) + .startTimeMs(script.getStartTimeMs()) + .speakerType(script.getSpeakerType()) + .speakerName(script.getSpeakerName()) + .text(cleanText) + .build(); + } + + private AdminScenarioNodeResponse toAdminNodeResponse(ScenarioNode node) { + return AdminScenarioNodeResponse.builder() + .nodeId(node.getId()) + .nodeName(node.getNodeName()) + .audioDuration(node.getAudioDuration()) + .autoNextNodeId(node.getAutoNextNodeId()) + .scripts(node.getScripts().stream() + .map(this::toAdminScriptResponse) + .collect(Collectors.toList())) + .interactiveOptions(node.getOptions().stream() + .map(this::toAdminOptionResponse) + .collect(Collectors.toList())) + .build(); + } + + private AdminScenarioScriptResponse toAdminScriptResponse(Script script) { + return AdminScenarioScriptResponse.builder() + .scriptId(script.getId()) + .startTimeMs(script.getStartTimeMs()) + .speakerType(script.getSpeakerType()) + .speakerName(script.getSpeakerName()) + .text(script.getText()) + .build(); + } + + 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 new file mode 100644 index 00000000..53ddb56c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/NodeRequest.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.scenario.dto.request; + +import java.util.List; + +public record NodeRequest( + String nodeName, + Boolean isStartNode, + 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/OptionRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java new file mode 100644 index 00000000..bb95c110 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java @@ -0,0 +1,6 @@ +package com.swyp.picke.domain.scenario.dto.request; + +public record OptionRequest( + String label, + String nextNodeName +) {} \ 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 new file mode 100644 index 00000000..cd1e38e8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioCreateRequest.java @@ -0,0 +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, + Map voiceSettings +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioStatusUpdateRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioStatusUpdateRequest.java new file mode 100644 index 00000000..9e1d3882 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioStatusUpdateRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.scenario.dto.request; + +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; + +public record ScenarioStatusUpdateRequest( + ScenarioStatus status +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java new file mode 100644 index 00000000..36984364 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.scenario.dto.request; + +import com.swyp.picke.domain.scenario.enums.SpeakerType; + +public record ScriptRequest( + String speakerName, + SpeakerType speakerType, + String text +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminDeleteResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminDeleteResponse.java new file mode 100644 index 00000000..6112ed8b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminDeleteResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.scenario.dto.response; + +import java.time.LocalDateTime; + +public record AdminDeleteResponse( + boolean success, + LocalDateTime deletedAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminScenarioDetailResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminScenarioDetailResponse.java new file mode 100644 index 00000000..f4579863 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminScenarioDetailResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.scenario.dto.response; + +import lombok.Builder; +import java.util.List; + +@Builder +public record AdminScenarioDetailResponse( + Long scenarioId, + Long battleId, + String title, + Boolean isInteractive, + List nodes +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminScenarioResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminScenarioResponse.java new file mode 100644 index 00000000..cf675568 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/AdminScenarioResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.scenario.dto.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/scenario/dto/response/NodeResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/NodeResponse.java new file mode 100644 index 00000000..89e63d7c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/NodeResponse.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.scenario.dto.response; + +import lombok.Builder; +import java.util.List; + +@Builder +public record NodeResponse( + Long nodeId, + String nodeName, + Integer audioDuration, + Long autoNextNodeId, + List scripts, + List interactiveOptions +) {} \ No newline at end of file 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 new file mode 100644 index 00000000..189d16c3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.scenario.dto.response; + +import lombok.Builder; + +@Builder +public record OptionResponse( + String label, + Long nextNodeId +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java new file mode 100644 index 00000000..7833ed20 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.scenario.dto.response; + +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import lombok.Builder; + +@Builder +public record ScriptResponse( + Long scriptId, + Integer startTimeMs, + SpeakerType speakerType, + String speakerName, + String text +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/UserScenarioResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/UserScenarioResponse.java new file mode 100644 index 00000000..4129a445 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/UserScenarioResponse.java @@ -0,0 +1,19 @@ +package com.swyp.picke.domain.scenario.dto.response; + +import com.swyp.picke.domain.battle.dto.response.BattleScenarioResponse.PhilosopherProfileResponse; +import com.swyp.picke.domain.scenario.enums.AudioPathType; +import lombok.Builder; +import java.util.List; +import java.util.Map; + +@Builder(toBuilder = true) +public record UserScenarioResponse( + Long battleId, + String title, + List philosophers, + Boolean isInteractive, + Long startNodeId, + AudioPathType recommendedPathKey, + Map audios, + List nodes +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java b/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java new file mode 100644 index 00000000..0b814800 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.scenario.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "scenario_options") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class InteractiveOption extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "node_id") + private ScenarioNode node; + + private String label; + + @Column(name = "next_node_id") + private Long nextNodeId; + + @Builder + public InteractiveOption(String label, Long nextNodeId) { + this.label = label; + this.nextNodeId = nextNodeId; + } + + public void assignNode(ScenarioNode node) { + this.node = node; + } +} \ 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 new file mode 100644 index 00000000..4f68b794 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/Scenario.java @@ -0,0 +1,91 @@ +package com.swyp.picke.domain.scenario.entity; + +import com.swyp.picke.domain.battle.entity.Battle; +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; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.*; + +@Entity +@Table(name = "scenarios") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Scenario extends BaseEntity { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "battle_id", nullable = false) + private Battle battle; + + @Column(name = "is_interactive", nullable = false) + private Boolean isInteractive; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ScenarioStatus status; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CreatorType creatorType; + + @ElementCollection + @CollectionTable(name = "scenario_audios", joinColumns = @JoinColumn(name = "scenario_id")) + @MapKeyEnumerated(EnumType.STRING) + @MapKeyColumn(name = "path_key") + @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<>(); + + @Builder + public Scenario(Battle battle, Boolean isInteractive, ScenarioStatus status, CreatorType creatorType) { + this.battle = battle; + this.isInteractive = isInteractive; + this.status = status; + this.creatorType = creatorType; + } + + public void updateStatus(ScenarioStatus status) { + this.status = status; + } + + public void addAudioUrl(AudioPathType type, String url) { + this.audios.put(type, url); + } + + public void addNode(ScenarioNode node) { + this.nodes.add(node); + node.assignScenario(this); + } + + public void clearAudios() { + this.audios.clear(); + } + + 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 new file mode 100644 index 00000000..1f060c45 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/ScenarioNode.java @@ -0,0 +1,81 @@ +package com.swyp.picke.domain.scenario.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "scenario_nodes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ScenarioNode extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "scenario_id") + private Scenario scenario; + + @Column(name = "node_name") + private String nodeName; + + @Column(name = "is_start_node") + private Boolean isStartNode; + + @Column(name = "audio_duration") + private Integer audioDuration; + + @Column(name = "auto_next_node_id") + private Long autoNextNodeId; + + @OrderColumn(name = "script_order") + @OneToMany(mappedBy = "node", cascade = CascadeType.ALL, orphanRemoval = true) + private List + + + + + + + +
+
+

당신의 생각을

+

Pické

+
+ + +
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/admin/admin-notice.html b/src/main/resources/templates/admin/admin-notice.html new file mode 100644 index 00000000..3399da0a --- /dev/null +++ b/src/main/resources/templates/admin/admin-notice.html @@ -0,0 +1,83 @@ + + + + + + Picke Admin - 공지사항 + + + + + + +
+ +
+
+

공지사항 작성

+

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

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

최근 공지

+ +
+ +
+
+ + + + +
+
+ +
+ + + + + + + + + + + + +
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 new file mode 100644 index 00000000..a35d2826 --- /dev/null +++ b/src/main/resources/templates/admin/components/form-battle.html @@ -0,0 +1,181 @@ +
+ +
+
+

1 기본 정보

+ BATTLE +
+ +
+
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+

2 배틀 선택지

+ OPTIONS +
+ +
+ + +
+

선택지 A

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

선택지 B

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

3 시나리오 대본

+ SCRIPT +
+ +
+

TTS 목소리 설정

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

START

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

CLOSING

+ +
+ +
+
+
+
diff --git a/src/main/resources/templates/admin/components/form-quiz.html b/src/main/resources/templates/admin/components/form-quiz.html new file mode 100644 index 00000000..1274c881 --- /dev/null +++ b/src/main/resources/templates/admin/components/form-quiz.html @@ -0,0 +1,54 @@ +
+
+
+

1 퀴즈 등록

+ QUIZ +
+ +
+
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
diff --git a/src/main/resources/templates/admin/components/form-vote.html b/src/main/resources/templates/admin/components/form-vote.html new file mode 100644 index 00000000..19acdcab --- /dev/null +++ b/src/main/resources/templates/admin/components/form-vote.html @@ -0,0 +1,51 @@ +
+
+
+

1 투표 등록

+ POLL +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+
+
diff --git a/src/main/resources/templates/admin/fragments/basic-info.html b/src/main/resources/templates/admin/fragments/basic-info.html new file mode 100644 index 00000000..88e2691d --- /dev/null +++ b/src/main/resources/templates/admin/fragments/basic-info.html @@ -0,0 +1,12 @@ +
+
+

1 기본정보

+ BASIC INFO +
+
+
+ + +
+
+
\ 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 new file mode 100644 index 00000000..1a59f4c5 --- /dev/null +++ b/src/main/resources/templates/admin/fragments/header.html @@ -0,0 +1,16 @@ +
+
+
+ Picke + Admin +
+ +
+ +
+ ADMIN +
+
diff --git a/src/main/resources/templates/admin/fragments/preview.html b/src/main/resources/templates/admin/fragments/preview.html new file mode 100644 index 00000000..58c99276 --- /dev/null +++ b/src/main/resources/templates/admin/fragments/preview.html @@ -0,0 +1,202 @@ +
+ +
+ 실시간 미리보기 + +
+ +
+
+ +
+ 9:41 +
+ + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ +
+
+

제목을 입력해주세요

+

콘텐츠에 대한 배경 설명 또는 힌트가 이곳에 표시됩니다.

+ +
+
+
+

주장

+

철학자

+
+ +
VS
+ +
+
+

주장

+

철학자

+
+
+ + +
+
+
+ + +
+ + + + + +
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/admin/picke-create.html b/src/main/resources/templates/admin/picke-create.html new file mode 100644 index 00000000..09417664 --- /dev/null +++ b/src/main/resources/templates/admin/picke-create.html @@ -0,0 +1,135 @@ + + + + + + Pické Admin - 콘텐츠 등록 + + + + + + + +
+ +
+ +
+
+ +
+ + + +
+ +
+
+
+ + + + + +
+
+ +
+
+
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/admin/picke-list.html b/src/main/resources/templates/admin/picke-list.html new file mode 100644 index 00000000..52756a9e --- /dev/null +++ b/src/main/resources/templates/admin/picke-list.html @@ -0,0 +1,406 @@ + + + + + + Picke Admin - 콘텐츠 관리 + + + + + + + +
+ +
+
+
+

콘텐츠 관리

+

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

+
+ +
+ +
+ + + + +
+ +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + +
ID유형제목상태생성일관리
+
+
+ 데이터를 불러오는 중입니다... +
+
+
+ +
+
+ + + + 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/test/java/com/swyp/app/AppApplicationTests.java b/src/test/java/com/swyp/app/AppApplicationTests.java deleted file mode 100644 index d55ce254..00000000 --- a/src/test/java/com/swyp/app/AppApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.swyp.app; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class AppApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/swyp/picke/PickeApplicationTests.java b/src/test/java/com/swyp/picke/PickeApplicationTests.java new file mode 100644 index 00000000..622891a8 --- /dev/null +++ b/src/test/java/com/swyp/picke/PickeApplicationTests.java @@ -0,0 +1,19 @@ +package com.swyp.picke; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.S3Client; + +@SpringBootTest +@ActiveProfiles("test") +class PickeApplicationTests { + + @MockitoBean + private S3Client s3Client; + + @Test + void contextLoads() { + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..8537c618 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/home/service/HomeServiceTest.java @@ -0,0 +1,201 @@ +package com.swyp.picke.domain.home.service; + +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.service.BattleService; +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; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +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; + + @Test + @DisplayName("홈 응답에 배틀/퀴즈/투표 섹션을 조합해 반환한다") + void getHome_aggregates_sections() { + Long userId = 1L; + + 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()).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() + ))).thenReturn(List.of(newBattle)); + + var response = homeService.getHome(userId); + + assertThat(response.newNotice()).isTrue(); + 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().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() + )); + } + + @Test + @DisplayName("데이터가 없으면 빈 리스트를 반환한다") + void getHome_returns_empty_lists_when_no_data() { + Long userId = 1L; + + when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(false); + 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); + + assertThat(response.newNotice()).isFalse(); + assertThat(response.editorPicks()).isEmpty(); + assertThat(response.trendingBattles()).isEmpty(); + assertThat(response.bestBattles()).isEmpty(); + assertThat(response.todayQuizzes()).isEmpty(); + assertThat(response.todayVotes()).isEmpty(); + assertThat(response.newBattles()).isEmpty(); + } + + private TodayBattleResponse battle(Long id, String title) { + return new TodayBattleResponse( + id, + title, + "summary", + "thumbnail", + 10, + 20L, + 90, + List.of(), + List.of( + 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/notification/service/NotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java new file mode 100644 index 00000000..d952b479 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java @@ -0,0 +1,285 @@ +package com.swyp.picke.domain.notification.service; + +import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse; +import com.swyp.picke.domain.notification.dto.response.NotificationListResponse; +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationRead; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationReadRepository; +import com.swyp.picke.domain.notification.repository.NotificationRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.common.exception.CustomException; +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.Pageable; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; +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.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private NotificationReadRepository notificationReadRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private NotificationService notificationService; + + @Test + @DisplayName("개인 알림을 생성한다") + void createNotification_creates_personal_notification() { + Long userId = 1L; + User user = createMockUser(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(notificationRepository.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); + + Notification result = notificationService.createNotification( + userId, NotificationDetailCode.NEW_BATTLE, "새 배틀이 시작되었습니다", 100L); + + assertThat(result.getCategory()).isEqualTo(NotificationCategory.CONTENT); + assertThat(result.getDetailCode()).isEqualTo(NotificationDetailCode.NEW_BATTLE); + assertThat(result.getBody()).isEqualTo("새 배틀이 시작되었습니다"); + assertThat(result.getReferenceId()).isEqualTo(100L); + } + + @Test + @DisplayName("브로드캐스트 알림을 생성한다") + void createBroadcastNotification_creates_with_null_user() { + when(notificationRepository.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); + + Notification result = notificationService.createBroadcastNotification( + NotificationDetailCode.POLICY_CHANGE, "서비스 정책이 변경되었습니다", 50L); + + assertThat(result.getUser()).isNull(); + assertThat(result.getCategory()).isEqualTo(NotificationCategory.NOTICE); + assertThat(result.getDetailCode()).isEqualTo(NotificationDetailCode.POLICY_CHANGE); + } + + @Test + @DisplayName("알림 목록을 카테고리별로 조회한다") + void getNotifications_returns_filtered_list() { + Long userId = 1L; + User user = createMockUser(); + Notification notification = Notification.builder() + .user(user) + .category(NotificationCategory.CONTENT) + .detailCode(NotificationDetailCode.NEW_BATTLE) + .title("새로운 배틀이 시작되었어요") + .body("배틀 내용") + .referenceId(1L) + .build(); + + setUserId(user, userId); + + when(notificationRepository.findVisibleNotifications(eq(userId), eq(NotificationCategory.CONTENT), any(Pageable.class))) + .thenReturn(new SliceImpl<>(List.of(notification))); + + NotificationListResponse response = notificationService.getNotifications(userId, NotificationCategory.CONTENT, 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().getFirst().category()).isEqualTo(NotificationCategory.CONTENT); + assertThat(response.items().getFirst().detailCode()).isEqualTo("NEW_BATTLE"); + assertThat(response.hasNext()).isFalse(); + } + + @Test + @DisplayName("브로드캐스트 알림 목록 조회 시 사용자별 읽음 상태를 반영한다") + void getNotifications_resolves_broadcast_read_status() { + Long userId = 1L; + Notification broadcastNotification = Notification.builder() + .user(null) + .category(NotificationCategory.NOTICE) + .detailCode(NotificationDetailCode.POLICY_CHANGE) + .title("공지사항") + .body("서비스 정책이 변경되었습니다") + .referenceId(50L) + .build(); + + setNotificationId(broadcastNotification, 20L); + + when(notificationRepository.findVisibleNotifications(eq(userId), eq(NotificationCategory.NOTICE), any(Pageable.class))) + .thenReturn(new SliceImpl<>(List.of(broadcastNotification))); + + NotificationRead readRecord = NotificationRead.builder() + .notification(broadcastNotification) + .userId(userId) + .build(); + + when(notificationReadRepository.findByUserIdAndNotificationIdIn(userId, List.of(20L))) + .thenReturn(List.of(readRecord)); + + NotificationListResponse response = notificationService.getNotifications(userId, NotificationCategory.NOTICE, 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().getFirst().isRead()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 알림 읽음 처리 시 예외를 던진다") + void markAsRead_throws_when_not_found() { + when(notificationRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> notificationService.markAsRead(1L, 999L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("전역 알림 읽음 처리 시 NotificationRead 레코드를 저장한다") + void markAsRead_saves_notification_read_for_broadcast() { + Long userId = 1L; + Long notificationId = 20L; + Notification notification = Notification.builder() + .user(null) + .category(NotificationCategory.NOTICE) + .detailCode(NotificationDetailCode.POLICY_CHANGE) + .title("공지사항") + .body("서비스 정책이 변경되었습니다") + .referenceId(50L) + .build(); + + when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + when(notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)).thenReturn(false); + + notificationService.markAsRead(userId, notificationId); + + verify(notificationReadRepository).save(any(NotificationRead.class)); + } + + @Test + @DisplayName("본인 알림 상세를 조회한다") + void getNotificationDetail_returns_owned_notification() { + Long userId = 1L; + User user = createMockUser(); + Notification notification = Notification.builder() + .user(user) + .category(NotificationCategory.CONTENT) + .detailCode(NotificationDetailCode.NEW_BATTLE) + .title("새로운 배틀이 시작되었어요") + .body("배틀 내용") + .referenceId(1L) + .build(); + + setUserId(user, userId); + setNotificationId(notification, 10L); + + when(notificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + + NotificationDetailResponse response = notificationService.getNotificationDetail(userId, 10L); + + assertThat(response.notificationId()).isEqualTo(10L); + assertThat(response.category()).isEqualTo(NotificationCategory.CONTENT); + assertThat(response.detailCode()).isEqualTo("NEW_BATTLE"); + assertThat(response.title()).isEqualTo("새로운 배틀이 시작되었어요"); + } + + @Test + @DisplayName("브로드캐스트 알림 상세를 조회한다") + void getNotificationDetail_returns_broadcast_notification() { + Long userId = 1L; + Long notificationId = 20L; + Notification notification = Notification.builder() + .user(null) + .category(NotificationCategory.NOTICE) + .detailCode(NotificationDetailCode.POLICY_CHANGE) + .title("공지사항") + .body("서비스 정책이 변경되었습니다") + .referenceId(50L) + .build(); + + setNotificationId(notification, notificationId); + + when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + when(notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)).thenReturn(false); + + NotificationDetailResponse response = notificationService.getNotificationDetail(userId, notificationId); + + assertThat(response.notificationId()).isEqualTo(20L); + assertThat(response.category()).isEqualTo(NotificationCategory.NOTICE); + assertThat(response.detailCode()).isEqualTo("POLICY_CHANGE"); + assertThat(response.body()).isEqualTo("서비스 정책이 변경되었습니다"); + assertThat(response.isRead()).isFalse(); + } + + @Test + @DisplayName("다른 사용자의 알림 상세 조회 시 예외를 던진다") + void getNotificationDetail_throws_when_notification_not_accessible() { + Long ownerId = 1L; + Long requesterId = 2L; + User owner = createMockUser(); + Notification notification = Notification.builder() + .user(owner) + .category(NotificationCategory.CONTENT) + .detailCode(NotificationDetailCode.NEW_BATTLE) + .title("새로운 배틀이 시작되었어요") + .body("배틀 내용") + .referenceId(1L) + .build(); + + setUserId(owner, ownerId); + when(notificationRepository.findById(30L)).thenReturn(Optional.of(notification)); + + assertThatThrownBy(() -> notificationService.getNotificationDetail(requesterId, 30L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("전체 읽음 처리를 실행한다") + void markAllAsRead_calls_repository() { + Long userId = 1L; + when(notificationRepository.markAllAsReadByUserId(userId)).thenReturn(5); + when(notificationReadRepository.markAllBroadcastAsRead(userId)).thenReturn(3); + + int count = notificationService.markAllAsRead(userId); + + assertThat(count).isEqualTo(8); + verify(notificationRepository).markAllAsReadByUserId(userId); + verify(notificationReadRepository).markAllBroadcastAsRead(userId); + } + + private User createMockUser() { + return User.builder() + .userTag("test-user-tag") + .nickname("테스트유저") + .build(); + } + + private void setUserId(User user, Long id) { + try { + var field = User.class.getSuperclass().getDeclaredField("id"); + field.setAccessible(true); + field.set(user, id); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private void setNotificationId(Notification notification, Long id) { + try { + var field = Notification.class.getSuperclass().getDeclaredField("id"); + field.setAccessible(true); + field.set(notification, id); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java new file mode 100644 index 00000000..ed392bc0 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java @@ -0,0 +1,180 @@ +package com.swyp.picke.domain.oauth.service; + +import com.swyp.picke.domain.oauth.client.GoogleOAuthClient; +import com.swyp.picke.domain.oauth.client.KakaoOAuthClient; +import com.swyp.picke.domain.oauth.dto.LoginRequest; +import com.swyp.picke.domain.oauth.dto.LoginResponse; +import com.swyp.picke.domain.oauth.dto.OAuthUserInfo; +import com.swyp.picke.domain.oauth.dto.WithdrawRequest; +import com.swyp.picke.domain.oauth.repository.AuthRefreshTokenRepository; +import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.enums.CharacterType; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserProfile; +import com.swyp.picke.domain.user.entity.UserWithdrawal; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.enums.WithdrawalReason; +import com.swyp.picke.domain.user.repository.UserProfileRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.repository.UserSettingsRepository; +import com.swyp.picke.domain.user.repository.UserTendencyScoreRepository; +import com.swyp.picke.domain.user.repository.UserWithdrawalRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OAuthServiceTest { + + @Mock private KakaoOAuthClient kakaoOAuthClient; + @Mock private GoogleOAuthClient googleOAuthClient; + @Mock private UserRepository userRepository; + @Mock private UserSocialAccountRepository socialAccountRepository; + @Mock private AuthRefreshTokenRepository refreshTokenRepository; + @Mock private UserProfileRepository userProfileRepository; + @Mock private UserSettingsRepository userSettingsRepository; + @Mock private UserTendencyScoreRepository userTendencyScoreRepository; + @Mock private UserWithdrawalRepository userWithdrawalRepository; + @Mock private JwtProvider jwtProvider; + + private AuthService authService; + + @BeforeEach + void setUp() { + // 수동 주입으로 안정성 확보 + authService = new AuthService( + kakaoOAuthClient, googleOAuthClient, userRepository, + socialAccountRepository, refreshTokenRepository, + userProfileRepository, userSettingsRepository, userTendencyScoreRepository, + userWithdrawalRepository, + jwtProvider + ); + } + + @Test + void login_카카오_기존유저_로그인_성공() { + // 1. 준비 (Given) + String provider = "KAKAO"; + LoginRequest request = new LoginRequest("auth-code", "redirect-uri"); + OAuthUserInfo userInfo = new OAuthUserInfo("kakao_123", "bex@test.com", "profile_url"); + + // 유저 엔티티에 ID가 없으므로 식별자 필드만 세팅 (UserTag 등) + User user = User.builder() + .userTag("pique-test") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + + // 2. Mock 설정 (anyString()을 사용하여 null이 아닌 어떤 문자열이든 대응) + when(kakaoOAuthClient.getAccessToken(anyString(), anyString())).thenReturn("mock-access-token"); + when(kakaoOAuthClient.getUserInfo(anyString())).thenReturn(userInfo); // 여기서 null이 안 들어가게 고정 + + var socialAccount = mock(com.swyp.picke.domain.oauth.entity.UserSocialAccount.class); + when(socialAccount.getUser()).thenReturn(user); + when(socialAccountRepository.findByProviderAndProviderUserId(anyString(), anyString())) + .thenReturn(Optional.of(socialAccount)); + + // ID가 없더라도 createAccessToken의 첫 번째 인자가 무엇이든 통과하게 any() 사용 + when(jwtProvider.createAccessToken(any(), anyString())).thenReturn("jwt-access"); + when(jwtProvider.createRefreshToken()).thenReturn("jwt-refresh"); + + // 3. 실행 (When) + LoginResponse response = authService.login(provider, request); + + // 4. 검증 (Then) + assertThat(response.getAccessToken()).isEqualTo("jwt-access"); + assertThat(response.isNewUser()).isFalse(); + verify(refreshTokenRepository).save(any()); + } + + @Test + void login_구글_신규유저_기본_user_domain_초기화() { + String provider = "GOOGLE"; + LoginRequest request = new LoginRequest("auth-code", "redirect-uri"); + OAuthUserInfo userInfo = new OAuthUserInfo("google_123", "new@test.com", "profile_url"); + + User savedUser = User.builder() + .userTag("pique-test") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + + when(googleOAuthClient.getAccessToken(anyString(), anyString())).thenReturn("mock-access-token"); + when(googleOAuthClient.getUserInfo(anyString())).thenReturn(userInfo); + when(socialAccountRepository.findByProviderAndProviderUserId(anyString(), anyString())) + .thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))).thenReturn(savedUser); + when(jwtProvider.createAccessToken(any(), anyString())).thenReturn("jwt-access"); + when(jwtProvider.createRefreshToken()).thenReturn("jwt-refresh"); + + LoginResponse response = authService.login(provider, request); + + assertThat(response.isNewUser()).isTrue(); + ArgumentCaptor profileCaptor = ArgumentCaptor.forClass(UserProfile.class); + verify(userProfileRepository).save(profileCaptor.capture()); + verify(userSettingsRepository).save(any()); + verify(userTendencyScoreRepository).save(any()); + + UserProfile savedProfile = profileCaptor.getValue(); + CharacterType characterType = savedProfile.getCharacterType(); + + assertThat(characterType).isNotNull(); + assertThat(savedProfile.getNickname()).endsWith(characterType.getLabel()); + assertThat(savedProfile.getNickname()).isNotEqualTo(savedUser.getUserTag()); + assertThat(AuthService.DEFAULT_NICKNAME_PREFIXES) + .anyMatch(prefix -> savedProfile.getNickname().startsWith(prefix)); + } + + @Test + void withdraw_탈퇴사유를_저장하고_사용자를_삭제처리한다() { + User user = User.builder() + .userTag("pique-test") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userWithdrawalRepository.existsByUser_Id(1L)).thenReturn(false); + + authService.withdraw(1L, new WithdrawRequest(WithdrawalReason.NO_TIME)); + + verify(refreshTokenRepository).deleteByUser(user); + + ArgumentCaptor withdrawalCaptor = ArgumentCaptor.forClass(UserWithdrawal.class); + verify(userWithdrawalRepository).save(withdrawalCaptor.capture()); + assertThat(withdrawalCaptor.getValue().getReason()).isEqualTo(WithdrawalReason.NO_TIME); + + assertThat(user.getStatus()).isEqualTo(UserStatus.DELETED); + assertThat(user.getDeletedAt()).isNotNull(); + } + + @Test + void withdraw_이미_탈퇴이력이_있으면_중복저장하지_않는다() { + User user = User.builder() + .userTag("pique-test") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userWithdrawalRepository.existsByUser_Id(1L)).thenReturn(true); + + authService.withdraw(1L, new WithdrawRequest(WithdrawalReason.OTHER)); + + verify(refreshTokenRepository).deleteByUser(user); + verify(userWithdrawalRepository, never()).save(any()); + assertThat(user.getStatus()).isEqualTo(UserStatus.DELETED); + } +} 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 new file mode 100644 index 00000000..b0d7faa3 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java @@ -0,0 +1,91 @@ +package com.swyp.picke.domain.reward.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.google.crypto.tink.apps.rewardedads.RewardedAdsVerifier; +import com.swyp.picke.domain.reward.dto.request.AdMobRewardRequest; +import com.swyp.picke.domain.reward.entity.AdRewardHistory; +import com.swyp.picke.domain.reward.repository.AdRewardHistoryRepository; +import com.swyp.picke.domain.user.enums.CreditType; +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.service.CreditService; +import com.swyp.picke.domain.user.service.UserService; +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; + +@ExtendWith(MockitoExtension.class) +class AdMobRewardServiceTest { + + @InjectMocks + private AdMobRewardServiceImpl rewardService; + + @Mock + private AdRewardHistoryRepository adRewardHistoryRepository; + + @Mock + private UserService userService; + + @Mock + private RewardedAdsVerifier rewardedAdsVerifier; + + @Mock + private CreditService creditService; + + @Test + @DisplayName("// 1. 정상적인 광고 시청 시 보상 이력이 저장되고 크레딧이 적립된다.") + void processReward_Success() throws Exception { + // // 1.1 변경된 구조의 샘플 리퀘스트 생성 + AdMobRewardRequest request = createSampleRequest("unique-id"); + + User mockUser = User.builder() + .userTag("pique-1cc4a030") + .nickname("시영") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(mockUser, "id", 1L); + + // // 1. 중복 체크 Mock + given(adRewardHistoryRepository.existsByTransactionId(request.transaction_id())).willReturn(false); + + // // 2. 유저 조회 Mock + given(userService.findByUserTag("pique-1cc4a030")).willReturn(mockUser); + + // // [중요] 3. Stubbing 제거 + // // ServiceImpl에서 서명 검증 로직이 주석 처리되어 있다면, verify()에 대한 stubbing은 제거해야 합니다. + // // 만약 나중에 주석을 풀면 다시 넣되, 지금은 에러 방지를 위해 제거합니다. + + // when + String result = rewardService.processReward(request); + + // then + assertThat(result).isEqualTo("OK"); + + // // 4. 호출 검증 + 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"); + } + + // // 2. DTO 구조 변경에 따른 헬퍼 메서드 수정 + private AdMobRewardRequest createSampleRequest(String transId) { + return new AdMobRewardRequest( + "5450213213280609325", "ca-app-pub-3940256099942544/5224354917", + "pique-1cc4a030", 100, "POINT", 1711815000000L, + transId, "sig-123", "key-123", "pique-1cc4a030" + ); + } +} 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/enums/CharacterTypeTest.java b/src/test/java/com/swyp/picke/domain/user/enums/CharacterTypeTest.java new file mode 100644 index 00000000..52796a99 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/enums/CharacterTypeTest.java @@ -0,0 +1,43 @@ +package com.swyp.picke.domain.user.enums; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class CharacterTypeTest { + + @Test + @DisplayName("다운로드 폴더 기준 모든 캐릭터 enum이 등록되어 있다") + void characterTypes_include_all_downloaded_animals() { + Set characterNames = Arrays.stream(CharacterType.values()) + .map(Enum::name) + .collect(Collectors.toSet()); + + assertThat(characterNames).containsExactlyInAnyOrder( + "OWL", "FOX", "WOLF", "LION", "PENGUIN", "BEAR", "RABBIT", "CAT", + "ALPACA", "CAPYBARA", "DEER", "DOG", "DUCK", "EAGLE", "HAMSTER", "HEDGEHOG", + "HONEYBEE", "KOALA", "OTTER", "PANDA", "POODLE", "RACCOON", "RAGDOLL", + "RETRIEVER", "SLOTH", "SQUIRREL", "TIGER", "WHALE" + ); + } + + @Test + @DisplayName("캐릭터는 enum 이름과 한글 라벨 모두로 조회할 수 있다") + void from_supports_enum_name_and_label() { + assertThat(CharacterType.from("owl")).isEqualTo(CharacterType.OWL); + assertThat(CharacterType.from("부엉이")).isEqualTo(CharacterType.OWL); + assertThat(CharacterType.from("카피바라")).isEqualTo(CharacterType.CAPYBARA); + } + + @Test + @DisplayName("캐릭터 이미지 키를 단일 API로 해석한다") + void resolveImageKey_returns_registered_key() { + assertThat(CharacterType.resolveImageKey("RAGDOLL")).isEqualTo("images/characters/ragdoll.png"); + assertThat(CharacterType.resolveImageKey(CharacterType.WHALE)).isEqualTo("images/characters/whale.png"); + } +} 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 new file mode 100644 index 00000000..dc7610f4 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java @@ -0,0 +1,147 @@ +package com.swyp.picke.domain.user.service; + +import com.swyp.picke.domain.user.entity.CreditHistory; +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; +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.ArgumentCaptor; +import org.mockito.InjectMocks; +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CreditServiceTest { + + @Mock + private CreditHistoryRepository creditHistoryRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserService userService; + + @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("현재 로그인 유저에게 기본 크레딧을 적립하고 User.credit 캐시에도 반영한다") + void addCredit_forCurrentUser_savesDefaultAmount() { + 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); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreditHistory.class); + verify(creditHistoryRepository).saveAndFlush(captor.capture()); + + CreditHistory saved = captor.getValue(); + assertThat(saved.getUser().getId()).isEqualTo(1L); + 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 + @DisplayName("referenceId가 없으면 적립을 거부한다") + void addCredit_withoutReferenceId_throwsException() { + assertThatThrownBy(() -> creditService.addCredit(1L, CreditType.BATTLE_VOTE, 10, null)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.CREDIT_REFERENCE_REQUIRED); + + verify(creditHistoryRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("중복 적립 충돌이면 조용히 무시하고 캐시도 증가시키지 않는다") + void addCredit_duplicateInsert_ignoresConflict() { + 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, 5, 10L); + + verify(creditHistoryRepository).existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L); + verify(userRepository, never()).incrementCredit(1L, 5); + } + + @Test + @DisplayName("중복이 아닌 데이터 무결성 오류는 CREDIT_SAVE_FAILED 로 재기동하고 캐시도 증가시키지 않는다") + void addCredit_nonDuplicateIntegrityFailure_rethrows() { + User user = newUser(1L, 3); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) + .thenThrow(new DataIntegrityViolationException("broken")); + when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L)) + .thenReturn(false); + + assertThatThrownBy(() -> creditService.addCredit(1L, CreditType.BATTLE_VOTE, 10, 10L)) + .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(userRepository.findCreditById(1L)).thenReturn(2_500); + + TierCode tier = creditService.getTier(1L); + + assertThat(tier).isEqualTo(TierCode.SAGE); + } +} 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 new file mode 100644 index 00000000..e73f73a2 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java @@ -0,0 +1,437 @@ +package com.swyp.picke.domain.user.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.service.BattleQueryService; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +import com.swyp.picke.domain.perspective.entity.PerspectiveLike; +import com.swyp.picke.domain.perspective.service.PerspectiveQueryService; +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; +import com.swyp.picke.domain.user.entity.UserProfile; +import com.swyp.picke.domain.user.enums.UserRole; +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.BattleVote; +import com.swyp.picke.domain.vote.service.VoteQueryService; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +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.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MypageServiceTest { + + @Mock + private UserService userService; + @Mock + private CreditService creditService; + @Mock + private VoteQueryService voteQueryService; + @Mock + private BattleQueryService battleQueryService; + @Mock + private PerspectiveQueryService perspectiveQueryService; + @Mock + private S3PresignedUrlService s3PresignedUrlService; + + @InjectMocks + private MypageService mypageService; + + private final AtomicLong idGenerator = new AtomicLong(100L); + + private Long generateId() { + return idGenerator.getAndIncrement(); + } + + @Test + @DisplayName("프로필, 철학자, 티어 정보를 반환한다") + void getMypage_returns_profile_philosopher_tier() { + User user = createUser(1L, "myTag"); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + profile.updatePhilosopherType(PhilosopherType.KANT); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserProfile(1L)).thenReturn(profile); + when(creditService.getTotalPoints(1L)).thenReturn(0); + when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); + + MypageResponse response = mypageService.getMypage(); + + assertThat(response.profile().userTag()).isEqualTo("myTag"); + assertThat(response.profile().nickname()).isEqualTo("nick"); + assertThat(response.profile().characterType()).isEqualTo(CharacterType.OWL); + assertThat(response.profile().mannerTemperature()).isEqualByComparingTo(BigDecimal.valueOf(36.5)); + assertThat(response.philosopher().philosopherType()).isEqualTo(PhilosopherType.KANT); + assertThat(response.philosopher().typeName()).isEqualTo("원칙형"); + assertThat(response.philosopher().description()).isNotNull(); + assertThat(response.tier().tierCode()).isEqualTo(TierCode.WANDERER); + assertThat(response.tier().currentPoint()).isZero(); + } + + @Test + @DisplayName("철학자카드와 성향점수와 선호보고서를 반환한다") + void getRecap_returns_cards_scores_report() { + User user = createUser(1L, "tag"); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + profile.updatePhilosopherType(PhilosopherType.KANT); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserProfile(1L)).thenReturn(profile); + when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); + when(voteQueryService.countTotalParticipation(1L)).thenReturn(15L); + when(voteQueryService.countOpinionChanges(1L)).thenReturn(3L); + when(voteQueryService.calculateBattleWinRate(1L)).thenReturn(70); + + List battleIds = List.of(generateId()); + when(voteQueryService.findParticipatedBattleIds(1L)).thenReturn(battleIds); + + LinkedHashMap topTags = new LinkedHashMap<>(); + topTags.put("정치", 5L); + topTags.put("경제", 3L); + when(battleQueryService.getTopTagsByBattleIds(battleIds, 4)).thenReturn(topTags); + + RecapResponse response = mypageService.getRecap(); + + assertThat(response.myCard().philosopherType()).isEqualTo(PhilosopherType.KANT); + assertThat(response.myCard().keywordTags()).containsExactly("#원칙", "#의무", "#윤리", "#절제"); + assertThat(response.bestMatchCard().philosopherType()).isEqualTo(PhilosopherType.CONFUCIUS); + assertThat(response.worstMatchCard().philosopherType()).isEqualTo(PhilosopherType.NIETZSCHE); + assertThat(response.scores().principle()).isEqualTo(92); + assertThat(response.scores().ideal()).isEqualTo(45); + assertThat(response.preferenceReport().totalParticipation()).isEqualTo(15); + assertThat(response.preferenceReport().opinionChanges()).isEqualTo(3); + assertThat(response.preferenceReport().battleWinRate()).isEqualTo(70); + assertThat(response.preferenceReport().favoriteTopics()).hasSize(2); + assertThat(response.preferenceReport().favoriteTopics().get(0).tagName()).isEqualTo("정치"); + } + + @Test + @DisplayName("철학자유형이 미산출이면 recap은 null이다") + void getRecap_returns_null_when_no_philosopher() { + User user = createUser(1L, "tag"); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserProfile(1L)).thenReturn(profile); + + RecapResponse response = mypageService.getRecap(); + + assertThat(response).isNull(); + } + + @Test + @DisplayName("투표기록을 페이지네이션하여 반환한다") + void getBattleRecords_returns_paginated_records() { + User user = createUser(1L, "tag"); + Battle battle = createBattle("배틀 제목"); + BattleOption optionA = createOption(battle, BattleOptionLabel.A); + BattleVote vote = BattleVote.builder() + .user(user) + .battle(battle) + .preVoteOption(optionA) + .build(); + ReflectionTestUtils.setField(vote, "id", generateId()); + ReflectionTestUtils.setField(vote, "createdAt", LocalDateTime.now()); + + when(userService.findCurrentUser()).thenReturn(user); + when(voteQueryService.findUserVotes(1L, 0, 2, null)).thenReturn(List.of(vote)); + when(voteQueryService.countUserVotes(1L, null)).thenReturn(5L); + + BattleRecordListResponse response = mypageService.getBattleRecords(0, 2, null); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).voteSide()).isEqualTo(VoteSide.PRO); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextOffset()).isEqualTo(2); + } + + @Test + @DisplayName("다음페이지가 없으면 hasNext가 false이다") + void getBattleRecords_returns_no_next_when_last_page() { + User user = createUser(1L, "tag"); + Battle battle = createBattle("제목"); + BattleOption optionA = createOption(battle, BattleOptionLabel.A); + BattleVote vote = BattleVote.builder() + .user(user) + .battle(battle) + .preVoteOption(optionA) + .build(); + ReflectionTestUtils.setField(vote, "id", generateId()); + ReflectionTestUtils.setField(vote, "createdAt", LocalDateTime.now()); + + when(userService.findCurrentUser()).thenReturn(user); + when(voteQueryService.findUserVotes(1L, 0, 20, null)).thenReturn(List.of(vote)); + when(voteQueryService.countUserVotes(1L, null)).thenReturn(1L); + + BattleRecordListResponse response = mypageService.getBattleRecords(null, null, null); + + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextOffset()).isNull(); + } + + @Test + @DisplayName("voteSide 필터가 적용된다") + void getBattleRecords_applies_vote_side_filter() { + User user = createUser(1L, "tag"); + + when(userService.findCurrentUser()).thenReturn(user); + when(voteQueryService.findUserVotes(1L, 0, 20, BattleOptionLabel.A)).thenReturn(List.of()); + when(voteQueryService.countUserVotes(1L, BattleOptionLabel.A)).thenReturn(0L); + + mypageService.getBattleRecords(null, null, VoteSide.PRO); + + verify(voteQueryService).findUserVotes(eq(1L), eq(0), eq(20), eq(BattleOptionLabel.A)); + } + + @Test + @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); + Long optionId = option.getId(); + + Perspective perspective = Perspective.builder() + .battle(battle) + .user(user) + .option(option) + .content("관점 내용") + .build(); + ReflectionTestUtils.setField(perspective, "id", generateId()); + + PerspectiveComment comment = PerspectiveComment.builder() + .perspective(perspective) + .user(user) + .content("댓글") + .build(); + ReflectionTestUtils.setField(comment, "id", generateId()); + 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)); + when(battleQueryService.findOptionsByIds(List.of(optionId))).thenReturn(Map.of(optionId, option)); + when(userService.findSummaryById(1L)).thenReturn(new UserSummary("tag", "nick", "OWL")); + + ContentActivityListResponse response = mypageService.getContentActivities(null, null, ActivityType.COMMENT); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).activityType()).isEqualTo(ActivityType.COMMENT); + assertThat(response.items().get(0).content()).isEqualTo("댓글"); + } + + @Test + @DisplayName("LIKE 타입으로 좋아요활동을 반환한다") + void getContentActivities_returns_likes() { + User user = createUser(1L, "tag"); + Battle battle = createBattle("배틀"); + Long battleId = battle.getId(); + BattleOption option = createOption(battle, BattleOptionLabel.B); + Long optionId = option.getId(); + + Perspective perspective = Perspective.builder() + .battle(battle) + .user(user) + .option(option) + .content("관점 내용") + .build(); + ReflectionTestUtils.setField(perspective, "id", generateId()); + + PerspectiveLike like = PerspectiveLike.builder() + .perspective(perspective) + .user(user) + .build(); + ReflectionTestUtils.setField(like, "id", generateId()); + ReflectionTestUtils.setField(like, "createdAt", LocalDateTime.now()); + + when(userService.findCurrentUser()).thenReturn(user); + when(perspectiveQueryService.findUserLikes(1L, 0, 20)).thenReturn(List.of(like)); + when(perspectiveQueryService.countUserLikes(1L)).thenReturn(1L); + when(battleQueryService.findBattlesByIds(List.of(battleId))).thenReturn(Map.of(battleId, battle)); + when(battleQueryService.findOptionsByIds(List.of(optionId))).thenReturn(Map.of(optionId, option)); + when(userService.findSummaryById(1L)).thenReturn(new UserSummary("tag", "nick", "OWL")); + + ContentActivityListResponse response = mypageService.getContentActivities(null, null, ActivityType.LIKE); + + assertThat(response.items()).hasSize(1); + 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() { + User user = createUser(1L, "tag"); + UserSettings settings = UserSettings.builder() + .user(user) + .newBattleEnabled(true) + .battleResultEnabled(false) + .commentReplyEnabled(true) + .newCommentEnabled(true) + .contentLikeEnabled(false) + .marketingEventEnabled(false) + .build(); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserSettings(1L)).thenReturn(settings); + + NotificationSettingsResponse response = mypageService.getNotificationSettings(); + + assertThat(response.newBattleEnabled()).isTrue(); + assertThat(response.battleResultEnabled()).isFalse(); + assertThat(response.commentReplyEnabled()).isTrue(); + assertThat(response.newCommentEnabled()).isTrue(); + assertThat(response.contentLikeEnabled()).isFalse(); + assertThat(response.marketingEventEnabled()).isFalse(); + } + + @Test + @DisplayName("설정을 업데이트하고 반환한다") + void updateNotificationSettings_updates_and_returns() { + User user = createUser(1L, "tag"); + UserSettings settings = UserSettings.builder() + .user(user) + .newBattleEnabled(false) + .battleResultEnabled(false) + .commentReplyEnabled(false) + .newCommentEnabled(false) + .contentLikeEnabled(false) + .marketingEventEnabled(false) + .build(); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserSettings(1L)).thenReturn(settings); + + UpdateNotificationSettingsRequest request = new UpdateNotificationSettingsRequest( + true, null, true, null, null, true + ); + + NotificationSettingsResponse response = mypageService.updateNotificationSettings(request); + + assertThat(response.newBattleEnabled()).isTrue(); + assertThat(response.battleResultEnabled()).isFalse(); + assertThat(response.commentReplyEnabled()).isTrue(); + assertThat(response.marketingEventEnabled()).isTrue(); + } + + 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, CharacterType characterType) { + return UserProfile.builder() + .user(user) + .nickname(nickname) + .characterType(characterType) + .mannerTemperature(BigDecimal.valueOf(36.5)) + .build(); + } + + private Battle createBattle(String title) { + Battle battle = Battle.builder() + .title(title) + .summary("summary") + .status(BattleStatus.PUBLISHED) + .build(); + ReflectionTestUtils.setField(battle, "id", generateId()); + return battle; + } + + private BattleOption createOption(Battle battle, BattleOptionLabel label) { + BattleOption option = BattleOption.builder() + .battle(battle) + .label(label) + .title(label.name()) + .stance("stance-" + label.name()) + .build(); + 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/UserBattleServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/UserBattleServiceTest.java new file mode 100644 index 00000000..e083c643 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/UserBattleServiceTest.java @@ -0,0 +1,119 @@ +package com.swyp.picke.domain.user.service; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.user.dto.converter.UserBattleConverter; +import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserBattle; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.repository.UserBattleRepository; +import org.junit.jupiter.api.BeforeEach; +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 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.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserBattleServiceTest { + + @Mock private UserBattleRepository userBattleRepository; + @Mock private UserBattleConverter userBattleConverter; + + @InjectMocks private UserBattleService userBattleService; + + private User user; + private Battle battle; + + @BeforeEach + void setUp() { + user = mock(User.class); + battle = mock(Battle.class); + lenient().when(battle.getId()).thenReturn(1L); + } + + // --- [조회 테스트] --- + + @Test + @DisplayName("기록이 있는 유저 조회 시 해당 단계를 반환한다") + void getUserBattleStatus_Success() { + // given + UserBattle userBattle = UserBattle.builder().user(user).battle(battle).step(UserBattleStep.PRE_VOTE).build(); + when(userBattleRepository.findByUserAndBattle(user, battle)).thenReturn(Optional.of(userBattle)); + when(userBattleConverter.toStatusResponse(userBattle)).thenReturn(new UserBattleStatusResponse(1L, UserBattleStep.PRE_VOTE)); + + // when + UserBattleStatusResponse response = userBattleService.getUserBattleStatus(user, battle); + + // then + assertThat(response.step()).isEqualTo(UserBattleStep.PRE_VOTE); + } + + @Test + @DisplayName("기록이 없는 유저 조회 시 INITIAL(NONE) 상태를 반환한다") + void getUserBattleStatus_ReturnsInitial_WhenEmpty() { + // given + when(userBattleRepository.findByUserAndBattle(user, battle)).thenReturn(Optional.empty()); + when(userBattleConverter.toInitialResponse(1L)).thenReturn(new UserBattleStatusResponse(1L, UserBattleStep.NONE)); + + // when + UserBattleStatusResponse response = userBattleService.getUserBattleStatus(user, battle); + + // then + assertThat(response.step()).isEqualTo(UserBattleStep.NONE); + verify(userBattleConverter).toInitialResponse(1L); + } + + // --- [업데이트(Upsert) 테스트] --- + + @Test + @DisplayName("새로운 배틀 참여 시 UserBattle 레코드를 새로 생성한다") + void upsertStep_CreatesNewRecord() { + // given + when(userBattleRepository.findByUserAndBattle(user, battle)).thenReturn(Optional.empty()); + + // when + userBattleService.upsertStep(user, battle, UserBattleStep.PRE_VOTE); + + // then + verify(userBattleRepository).save(any(UserBattle.class)); + } + + @Test + @DisplayName("이미 참여 중인 배틀의 단계를 업데이트한다") + void upsertStep_UpdatesExistingRecord() { + // given + UserBattle existingRecord = spy(UserBattle.builder().user(user).battle(battle).step(UserBattleStep.PRE_VOTE).build()); + when(userBattleRepository.findByUserAndBattle(user, battle)).thenReturn(Optional.of(existingRecord)); + + // when + userBattleService.upsertStep(user, battle, UserBattleStep.COMPLETED); + + // then + assertThat(existingRecord.getStep()).isEqualTo(UserBattleStep.COMPLETED); + // 별도의 save 없이 Dirty Checking으로 업데이트되거나 로직상 호출될 수 있음 + } + + // --- [예외 및 경계 케이스] --- + + @Test + @DisplayName("단계를 이전 단계로 되돌리려 할 때의 방어 로직 확인 (비즈니스 정책에 따라 설정)") + void upsertStep_ShouldHandleReverseTransition() { + // 기획상 COMPLETED에서 PRE_VOTE로 돌아가는 것을 막아야 한다면 여기에 검증 로직 추가 + // 현재 로직은 단순 덮어쓰기라면 상태 업데이트 여부만 확인 + UserBattle existingRecord = UserBattle.builder().user(user).battle(battle).step(UserBattleStep.COMPLETED).build(); + when(userBattleRepository.findByUserAndBattle(user, battle)).thenReturn(Optional.of(existingRecord)); + + userBattleService.upsertStep(user, battle, UserBattleStep.PRE_VOTE); + + assertThat(existingRecord.getStep()).isEqualTo(UserBattleStep.PRE_VOTE); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..000b7486 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/UserServiceTest.java @@ -0,0 +1,209 @@ +package com.swyp.picke.domain.user.service; + +import com.swyp.picke.domain.user.dto.request.UpdateUserProfileRequest; +import com.swyp.picke.domain.user.dto.response.MyProfileResponse; +import com.swyp.picke.domain.user.dto.response.UserSummary; +import com.swyp.picke.domain.user.enums.CharacterType; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserProfile; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.entity.UserSettings; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.entity.UserTendencyScore; +import com.swyp.picke.domain.user.repository.UserProfileRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.repository.UserSettingsRepository; +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; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private UserProfileRepository userProfileRepository; + @Mock + private UserSettingsRepository userSettingsRepository; + @Mock + private UserTendencyScoreRepository userTendencyScoreRepository; + + @InjectMocks + private UserService userService; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("가장 최근 사용자를 반환한다") + void findCurrentUser_returns_latest_user() { + User user = createUser(1L, "testTag"); + setAuthenticatedUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + User result = userService.findCurrentUser(); + + assertThat(result.getUserTag()).isEqualTo("testTag"); + } + + @Test + @DisplayName("사용자가 없으면 예외를 던진다") + void findCurrentUser_throws_when_no_user() { + setAuthenticatedUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.findCurrentUser()) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND)); + } + + @Test + @DisplayName("사용자 요약정보를 반환한다") + void findSummaryById_returns_user_summary() { + User user = createUser(1L, "summaryTag"); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userProfileRepository.findByUserId(1L)).thenReturn(Optional.of(profile)); + + UserSummary summary = userService.findSummaryById(1L); + + assertThat(summary.userTag()).isEqualTo("summaryTag"); + assertThat(summary.nickname()).isEqualTo("nick"); + assertThat(summary.characterType()).isEqualTo("OWL"); + } + + @Test + @DisplayName("존재하지 않는 사용자의 요약정보 조회 시 예외를 던진다") + void findSummaryById_throws_when_not_found() { + when(userRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.findSummaryById(999L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND)); + } + + @Test + @DisplayName("닉네임과 캐릭터를 수정한다") + void updateMyProfile_updates_nickname_and_character() { + User user = createUser(1L, "myTag"); + UserProfile profile = createProfile(user, "oldNick", CharacterType.OWL); + + 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); + MyProfileResponse response = userService.updateMyProfile(request); + + assertThat(response.userTag()).isEqualTo("myTag"); + assertThat(response.nickname()).isEqualTo("newNick"); + assertThat(response.characterType()).isEqualTo(CharacterType.FOX); + } + + @Test + @DisplayName("프로필을 반환한다") + void findUserProfile_returns_profile() { + User user = createUser(1L, "tag"); + UserProfile profile = createProfile(user, "nick", CharacterType.BEAR); + + when(userProfileRepository.findByUserId(1L)).thenReturn(Optional.of(profile)); + + UserProfile result = userService.findUserProfile(1L); + + assertThat(result.getNickname()).isEqualTo("nick"); + assertThat(result.getCharacterType()).isEqualTo(CharacterType.BEAR); + } + + @Test + @DisplayName("설정을 반환한다") + void findUserSettings_returns_settings() { + User user = createUser(1L, "tag"); + UserSettings settings = UserSettings.builder() + .user(user) + .newBattleEnabled(true) + .battleResultEnabled(false) + .commentReplyEnabled(true) + .newCommentEnabled(false) + .contentLikeEnabled(true) + .marketingEventEnabled(false) + .build(); + + when(userSettingsRepository.findByUserId(1L)).thenReturn(Optional.of(settings)); + + UserSettings result = userService.findUserSettings(1L); + + assertThat(result.isNewBattleEnabled()).isTrue(); + assertThat(result.isBattleResultEnabled()).isFalse(); + } + + @Test + @DisplayName("성향점수를 반환한다") + void findUserTendencyScore_returns_score() { + User user = createUser(1L, "tag"); + UserTendencyScore score = UserTendencyScore.builder() + .user(user) + .principle(10) + .reason(20) + .individual(30) + .change(40) + .inner(50) + .ideal(60) + .build(); + + when(userTendencyScoreRepository.findByUserId(1L)).thenReturn(Optional.of(score)); + + UserTendencyScore result = userService.findUserTendencyScore(1L); + + assertThat(result.getPrinciple()).isEqualTo(10); + assertThat(result.getReason()).isEqualTo(20); + assertThat(result.getIdeal()).isEqualTo(60); + } + + 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, CharacterType characterType) { + return UserProfile.builder() + .user(user) + .nickname(nickname) + .characterType(characterType) + .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 new file mode 100644 index 00000000..8a951d1f --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,80 @@ +spring: + application: + name: picke + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + cloud: + aws: + s3: + bucket: test-bucket + region: + static: ap-northeast-2 + credentials: + access-key: test-key + secret-key: test-secret + gcp: + credentials: + location: file:/tmp/dummy.json + +oauth: + kakao: + client-id: dummy + client-secret: dummy + google: + client-id: dummy + client-secret: dummy + +openai: + api-key: dummy + url: https://dummy.com + model: gpt-4o-mini + tts: + url: dummy + model: dummy + +fishaudio: + api-key: dummy + tts: + url: dummy + voice-id: + a: dummy + b: dummy + user: dummy + narrator: dummy + +elevenlabs: + api-key: dummy + model: dummy + voice-id: + a: dummy + b: dummy + user: dummy + narrator: dummy + +jwt: + # 'picke-secret-key-for-test-environment-123456'를 Base64로 인코딩한 값 + secret: cGlja2Utc2VjcmV0LWtleS1mb3ItdGVzdC1lbnZpcm9ubWVudC0xMjM0NTY= + access-token-expiration: 3600000 + refresh-token-expiration: 1209600000 + +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 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..ef11cb98 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "ESNext", + "outDir": "./src/main/resources/static/js/admin", + "rootDir": "./src/main/resources/frontend/ts/admin", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/main/resources/frontend/ts/admin/**/*" + ] +} \ No newline at end of file