diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0f80970 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,53 @@ +name: Java CI/CD with Gradle + +on: + push: + branches: [ "dev" ] # dev 브랜치에 푸시할 때 작동 + workflow_dispatch: # + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' # 캐싱 추가: 빌드 속도가 훨씬 빨라집니다. + + - name: Build with Gradle + run: | + chmod +x ./gradlew + ./gradlew build -x test + + - name: Create .env file from Secret + run: | + cat <<'EOF' > .env + ${{ secrets.ENV_VARIABLES }} + EOF + + - name: Copy JAR and .env to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + # -plain.jar는 배포에 필요 없으므로 제외합니다. + source: "build/libs/*-SNAPSHOT.jar, .env" + target: "~/" + strip_components: 2 + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + fuser -k 8080/tcp || true + + chmod +x ~/start.sh + ~/start.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4244546..162d997 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 288c6e1..42cc5c2 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 7343ef1..3bf7a58 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 0000000..c2bde65 --- /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/battle-api.md b/docs/api-specs/battle-api.md new file mode 100644 index 0000000..4fd9277 --- /dev/null +++ b/docs/api-specs/battle-api.md @@ -0,0 +1,494 @@ +# 배틀 API 명세서 + +--- + +## 설계 메모 + +- **오늘의 배틀 :** + - 스와이프 UI를 위해 약 5개의 배틀 리스트를 반환합니다. '오늘의 배틀(검정 창)'과 '일반 배틀 카드(하얀 창)'의 진입점(API)을 분리하여 각기 필요한 데이터를 제공합니다. +- **태그 :** + - 배틀 응답의 `tags` 필드는 `{ tag_id, name }` 객체 배열로 반환됩니다. 태그 전체 목록 조회 및 태그 기반 배틀 필터링은 Tag API를 참조하세요. +- **도메인 분리 :** + - 사용자 서비스 API와 관리자(Admin) 전용 API 도메인을 분리했습니다. 기본 콘텐츠 발행은 관리자 도메인에서 이루어집니다. +- **AI 자동 생성 :** + - 스케줄러가 매일 자동으로 트렌딩 이슈를 검색·수집하여 AI API를 호출하고 배틀 초안을 `PENDING` 상태로 저장합니다. 관리자는 `/api/v1/admin/ai/battles`를 통해 검수·승인·반려합니다. +- **배틀 `status` 흐름 :** + + | status | 적용 대상 | 설명 | + |--------|--------------|------| + | `DRAFT` | 관리자 | 관리자가 작성 중인 초안 | + | `PENDING` | AI, 유저 [후순위] | 검수 대기 중 | + | `PUBLISHED` | 전체 | 검수 완료, 실제 노출 | + | `REJECTED` | AI, 유저 [후순위] | 검수 반려 | + | `ARCHIVED` | 전체 | 배틀 종료 후 이력 보존 | + +- **[후순위] 크리에이터 정책 :** + - 매너 온도 45도 이상의 사용자가 직접 배틀을 제안하는 기능은 런칭 스펙에서 제외됩니다. + +--- + +## 사용자 API + +### `GET /api/v1/battles/today` + +- 스와이프 UI용으로 오늘 진행 중인 배틀 목록을 반환합니다. +- 피그마 디자인 상 5개로 임의 판단 -> 추후 수정 가능 + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "battle_id": "battle_001", + "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", + "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", + "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", + "tags": [ + { "tag_id": "tag_001", "name": "사회" }, + { "tag_id": "tag_002", "name": "철학" }, + { "tag_id": "tag_003", "name": "롤스" }, + { "tag_id": "tag_004", "name": "니체" } + ], + "participants_count": 2148, + "audio_duration": 420, + "share_url": "https://pique.app/battles/battle_001", + "options": [ + { "option_id": "option_A", "label": "A", "title": "사기다 (롤스)" }, + { "option_id": "option_B", "label": "B", "title": "사기가 아니다 (니체)" } + ], + "user_vote_status": "NONE" + } + ], + "total_count": 5 + }, + "error": null +} +``` + +--- + +### `GET /api/v1/battles/{battle_id}` + +- 배틀 카드(하얀 창) 선택 시 노출되는 상세 정보(철학자, 성향, 인용구 등)를 조회합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "battle_id": "battle_001", + "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", + "tags": [ + { "tag_id": "tag_001", "name": "사회" }, + { "tag_id": "tag_002", "name": "철학" } + ], + "options": [ + { + "option_id": "option_A", + "label": "A", + "stance": "정보의 대칭 (공정성)", + "representative": "존 롤스", + "title": "사기다", + "quote": "베일 뒤에서 누구나 동의할 수 있는 공정한 규칙이 깨진 것입니다.", + "keywords": ["합리적", "원칙주의", "절대적"], + "image_url": "https://cdn.pique.app/images/rawls.png" + }, + { + "option_id": "option_B", + "label": "B", + "stance": "가치 창조 (욕망의 질서)", + "representative": "프리드리히 니체", + "title": "사기가 아니다", + "quote": "주인공은 가려운 욕망을 정확히 읽어내고, 새로운 가치를 창조해낸 예술가입니다.", + "keywords": ["본능적", "실용주의", "주관적"], + "image_url": "https://cdn.pique.app/images/nietzsche.png" + } + ] + }, + "error": null +} +``` + +--- + +## 관리자 API + +### `POST /api/v1/admin/battles` + +- 공식 배틀을 직접 생성합니다. + +#### Request Body + +```json +{ + "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", + "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", + "description": "예술과 사기의 경계에 대한 철학적 딜레마", + "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", + "target_date": "2026-03-10", + "tag_ids": ["tag_001", "tag_002", "tag_003", "tag_004"], + "options": [ + { + "label": "A", + "title": "사기다", + "stance": "정보의 대칭 (공정성)", + "representative": "존 롤스", + "quote": "베일 뒤에서 누구나 동의할 수 있는 공정한 규칙이 깨진 것입니다.", + "keywords": ["합리적", "원칙주의", "절대적"], + "image_url": "https://cdn.pique.app/images/rawls.png" + }, + { + "label": "B", + "title": "사기가 아니다", + "stance": "가치 창조 (욕망의 질서)", + "representative": "프리드리히 니체", + "quote": "주인공은 가려운 욕망을 정확히 읽어내고, 새로운 가치를 창조해낸 예술가입니다.", + "keywords": ["본능적", "실용주의", "주관적"], + "image_url": "https://cdn.pique.app/images/nietzsche.png" + } + ] +} +``` + +#### 성공 응답 `201 Created` + +```json +{ + "statusCode": 201, + "data": { + "battle_id": "battle_001", + "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", + "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", + "description": "예술과 사기의 경계에 대한 철학적 딜레마", + "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", + "target_date": "2026-03-10", + "status": "DRAFT", + "creator_type": "ADMIN", + "tags": [ + { "tag_id": "tag_001", "name": "사회" }, + { "tag_id": "tag_002", "name": "철학" }, + { "tag_id": "tag_003", "name": "롤스" }, + { "tag_id": "tag_004", "name": "니체" } + ], + "options": [ + { + "option_id": "option_A", + "label": "A", + "title": "사기다", + "stance": "정보의 대칭 (공정성)", + "representative": "존 롤스", + "quote": "베일 뒤에서 누구나 동의할 수 있는 공정한 규칙이 깨진 것입니다.", + "keywords": ["합리적", "원칙주의", "절대적"], + "image_url": "https://cdn.pique.app/images/rawls.png" + }, + { + "option_id": "option_B", + "label": "B", + "title": "사기가 아니다", + "stance": "가치 창조 (욕망의 질서)", + "representative": "프리드리히 니체", + "quote": "주인공은 가려운 욕망을 정확히 읽어내고, 새로운 가치를 창조해낸 예술가입니다.", + "keywords": ["본능적", "실용주의", "주관적"], + "image_url": "https://cdn.pique.app/images/nietzsche.png" + } + ], + "created_at": "2026-03-10T09:00:00Z" + }, + "error": null +} +``` + +--- + +### `PATCH /api/v1/admin/battles/{battle_id}` + +- 배틀 정보를 수정합니다. 변경할 필드만 포함합니다. + +#### Request Body + +```json +{ + "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가? (수정)", + "status": "PUBLISHED", + "tag_ids": ["tag_001", "tag_002"] +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "battle_id": "battle_001", + "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가? (수정)", + "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", + "description": "예술과 사기의 경계에 대한 철학적 딜레마", + "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", + "target_date": "2026-03-10", + "status": "PUBLISHED", + "creator_type": "ADMIN", + "tags": [ + { "tag_id": "tag_001", "name": "사회" }, + { "tag_id": "tag_002", "name": "철학" } + ], + "updated_at": "2026-03-10T10:00:00Z" + }, + "error": null +} +``` + +--- + +### `DELETE /api/v1/admin/battles/{battle_id}` + +- 배틀을 삭제합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "success": true, + "deleted_at": "2026-03-10T11:00:00Z" + }, + "error": null +} +``` + +--- + +## `[후순위]` 관리자 AI 검수 API + +- 스케줄러가 자동 생성한 AI 배틀 초안(`PENDING`)을 관리자가 검수 · 승인 · 반려합니다. + +### `GET /api/v1/admin/ai/battles` + +- AI가 생성한 `PENDING` 상태의 배틀 목록을 조회합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "battle_id": "battle_ai_001", + "title": "AI가 제안한 배틀 제목", + "summary": "AI가 생성한 요약", + "thumbnail_url": "https://cdn.pique.app/battle/ai-001.png", + "target_date": "2026-03-11", + "status": "PENDING", + "creator_type": "AI", + "tags": [ + { "tag_id": "tag_001", "name": "사회" } + ], + "options": [ + { "option_id": "option_A", "label": "A", "title": "찬성", "keywords": ["합리적", "효율중심", "미래지향"] }, + { "option_id": "option_B", "label": "B", "title": "반대", "keywords": ["인본주의", "도덕중심", "전통적"] } + ], + "created_at": "2026-03-11T06:00:00Z" + } + ], + "total_count": 3 + }, + "error": null +} +``` + +--- + +### `PATCH /api/v1/admin/ai/battles/{battle_id}` + +- AI가 생성한 배틀을 승인하거나 반려합니다. 승인 시 내용을 수정한 뒤 승인할 수 있습니다. + +#### Request Body — 승인 + +```json +{ + "action": "APPROVE", + "title": "AI 초안 제목 (수정 가능)", + "summary": "AI 초안 요약 (수정 가능)", + "tag_ids": ["tag_001", "tag_002"] +} +``` + +#### Request Body — 반려 + +```json +{ + "action": "REJECT", + "reject_reason": "주제가 서비스 방향과 맞지 않음" +} +``` + +#### 성공 응답 `200 OK` — 승인 + +```json +{ + "statusCode": 200, + "data": { + "battle_id": "battle_ai_001", + "status": "PUBLISHED", + "creator_type": "AI", + "updated_at": "2026-03-11T09:00:00Z" + }, + "error": null +} +``` + +#### 성공 응답 `200 OK` — 반려 + +```json +{ + "statusCode": 200, + "data": { + "battle_id": "battle_ai_001", + "status": "REJECTED", + "reject_reason": "주제가 서비스 방향과 맞지 않음", + "updated_at": "2026-03-11T09:00:00Z" + }, + "error": null +} +``` + +--- + +## `[후순위]` 크리에이터 API + +### `POST /api/v1/battles` + +- 배틀을 제안합니다. (매너 온도 45도 이상 유저) + +#### Request Body + +```json +{ + "title": "AI가 만든 예술 작품, 저작권은 누구에게?", + "summary": "AI 창작물의 저작권 귀속 주체에 대한 철학적 딜레마", + "description": "창작의 주체성과 소유권에 대한 철학적 논쟁", + "thumbnail_url": "https://cdn.pique.app/battle/ai-art.png", + "target_date": "2026-03-15", + "tag_ids": ["tag_002", "tag_005"], + "options": [ + { + "label": "A", + "title": "AI 개발사에게 귀속된다", + "stance": "도구 이론", + "representative": "존 로크", + "quote": "노동을 투입한 자에게 소유권이 있다.", + "keywords": ["합리적", "효율중심", "미래지향"], + "image_url": "https://cdn.pique.app/images/locke.png" + }, + { + "label": "B", + "title": "퍼블릭 도메인이어야 한다", + "stance": "공유재 이론", + "representative": "장 자크 루소", + "quote": "창작물은 사회의 산물이므로 모두의 것이다.", + "keywords": ["합리적", "효율중심", "미래지향"], + "image_url": "https://cdn.pique.app/images/rousseau.png" + } + ] +} +``` + +#### 성공 응답 `201 Created` + +```json +{ + "statusCode": 201, + "data": { + "battle_id": "battle_002", + "title": "AI가 만든 예술 작품, 저작권은 누구에게?", + "status": "PENDING", + "creator_type": "USER", + "created_at": "2026-03-10T12:00:00Z" + }, + "error": null +} +``` + +--- + +### `PATCH /api/v1/battles/{battle_id}` + +- 제안한 배틀 정보를 수정합니다. 변경할 필드만 포함합니다. + +#### Request Body + +```json +{ + "title": "AI가 만든 예술 작품, 저작권은 누구에게? (수정)", + "summary": "AI 창작물의 저작권 귀속 주체에 대한 철학적 딜레마" +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "battle_id": "battle_002", + "title": "AI가 만든 예술 작품, 저작권은 누구에게? (수정)", + "summary": "AI 창작물의 저작권 귀속 주체에 대한 철학적 딜레마", + "status": "PENDING", + "creator_type": "USER", + "updated_at": "2026-03-10T13:00:00Z" + }, + "error": null +} +``` + +--- + +### `DELETE /api/v1/battles/{battle_id}` + +- 제안한 배틀을 삭제합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "success": true, + "deleted_at": "2026-03-10T14:00:00Z" + }, + "error": null +} +``` + +--- + +## 공통 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | +| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | +| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | +| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | +| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | +| `USER_BANNED` | `403` | 제재된 사용자 | +| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | + +--- + +## 배틀 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `BATTLE_NOT_FOUND` | `404` | 존재하지 않는 배틀 | +| `BATTLE_CLOSED` | `409` | 종료된 배틀 | +| `BATTLE_ALREADY_PUBLISHED` | `409` | 이미 발행된 배틀 | +| `BATTLE_OPTION_NOT_FOUND` | `404` | 존재하지 않는 선택지 | + +--- \ 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 0000000..0980f5f --- /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 0000000..ecebb59 --- /dev/null +++ b/docs/api-specs/home-api.md @@ -0,0 +1,148 @@ +# 홈 API 명세서 + +## 1. 설계 메모 + +- 홈은 여러 조회 결과를 한 번에 내려주는 집계 API입니다. +- 이번 문서는 `GET /api/v1/home` 하나만 정의합니다. +- 공지 목록/상세는 홈에서 직접 내려주지 않고, 마이페이지 공지 탭에서 처리합니다. +- 홈에서는 공지 내용 대신 `newNotice` boolean만 내려서 새 공지 유입 여부만 표시합니다. +- `todayPicks` 안에는 찬반형과 4지선다형이 함께 포함됩니다. + +--- + +## 2. 홈 API + +### 2.1 `GET /api/v1/home` + +홈 화면 진입 시 필요한 데이터를 한 번에 조회합니다. + +반환 섹션: + +- `newNotice`: 새 공지가 있는지 여부 +- `editorPicks`: Editor Pick +- `trendingBattles`: 지금 뜨는 배틀 +- `bestBattles`: Best 배틀 +- `todayPicks`: 오늘의 Pické +- `newBattles`: 새로운 배틀 + +```json +{ + "newNotice": true, + "editorPicks": [ + { + "battleId": "7b6c8d81-40f4-4f1e-9f13-4cc2fa0a3a10", + "title": "연애 상대의 전 애인 사진, 지워달라고 말한다 vs 그냥 둔다", + "summary": "에디터가 직접 골라본 오늘의 주제", + "thumbnailUrl": "https://cdn.example.com/battle/editor-pick-001.png", + "type": "BATTLE", + "viewCount": 182, + "participantsCount": 562, + "audioDuration": 153, + "tags": [], + "options": [] + } + ], + "trendingBattles": [ + { + "battleId": "40f4c311-0bd8-4baf-85df-58f8eaf1bf1f", + "title": "안락사 도입, 찬성 vs 반대", + "summary": "최근 24시간 참여가 급증한 배틀", + "thumbnailUrl": "https://cdn.example.com/battle/hot-001.png", + "type": "BATTLE", + "viewCount": 120, + "participantsCount": 420, + "audioDuration": 180, + "tags": [], + "options": [] + } + ], + "bestBattles": [ + { + "battleId": "11c22d33-44e5-6789-9abc-123456789def", + "title": "반려동물 출입 가능 식당, 확대해야 한다 vs 제한해야 한다", + "summary": "누적 참여와 댓글 반응이 높은 배틀", + "thumbnailUrl": "https://cdn.example.com/battle/best-001.png", + "type": "BATTLE", + "viewCount": 348, + "participantsCount": 1103, + "audioDuration": 201, + "tags": [], + "options": [] + } + ], + "todayPicks": [ + { + "battleId": "4e5291d2-b514-4d2a-a8fb-1258ae21a001", + "title": "배달 일회용 수저 기본 제공, 찬성 vs 반대", + "summary": "오늘의 Pické 찬반형 예시", + "thumbnailUrl": "https://cdn.example.com/battle/today-vote-001.png", + "type": "VOTE", + "viewCount": 97, + "participantsCount": 238, + "audioDuration": 96, + "tags": [], + "options": [ + { + "label": "A", + "text": "찬성" + }, + { + "label": "B", + "text": "반대" + } + ] + }, + { + "battleId": "9f8e7d6c-5b4a-3210-9abc-7f6e5d4c3b2a", + "title": "다음 중 세계에서 가장 큰 사막은?", + "summary": "오늘의 Pické 4지선다형 예시", + "thumbnailUrl": "https://cdn.example.com/battle/today-quiz-001.png", + "type": "QUIZ", + "viewCount": 76, + "participantsCount": 191, + "audioDuration": 88, + "tags": [], + "options": [ + { + "label": "A", + "text": "사하라 사막" + }, + { + "label": "B", + "text": "고비 사막" + }, + { + "label": "C", + "text": "남극 대륙" + }, + { + "label": "D", + "text": "아라비아 사막" + } + ] + } + ], + "newBattles": [ + { + "battleId": "aa11bb22-cc33-44dd-88ee-ff0011223344", + "title": "회사 회식은 근무의 연장이다 vs 사적인 친목이다", + "summary": "홈의 다른 섹션과 중복되지 않는 최신 배틀", + "thumbnailUrl": "https://cdn.example.com/battle/new-001.png", + "type": "BATTLE", + "viewCount": 24, + "participantsCount": 71, + "audioDuration": 142, + "tags": [], + "options": [] + } + ] +} +``` + +비고: + +- `newNotice`는 홈에서 공지 내용을 직접 노출하지 않고, 마이페이지 공지 탭으로 이동시키기 위한 신규 공지 존재 여부입니다. +- `editorPicks`, `trendingBattles`, `bestBattles`, `newBattles`는 동일한 배틀 요약 카드 구조를 사용합니다. +- `todayPicks`는 `type`으로 찬반형과 4지선다형을 구분합니다. +- `todayPicks`의 4지선다형은 별도 `quizzes` 필드로 분리하지 않고 이 배열 안에 포함합니다. +- 데이터가 없으면 리스트 섹션은 빈 배열을, `newNotice`는 `false`를 반환합니다. diff --git a/docs/api-specs/likes-api.md b/docs/api-specs/likes-api.md new file mode 100644 index 0000000..601ca30 --- /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 0000000..e665ff0 --- /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 0000000..5b445aa --- /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/recommendations-api.md b/docs/api-specs/recommendations-api.md new file mode 100644 index 0000000..1974890 --- /dev/null +++ b/docs/api-specs/recommendations-api.md @@ -0,0 +1,81 @@ +# 성향기반 배틀 추천 API 명세서 + +--- + +## 설계 메모 + +- 연관 , 비슷한 , 반대 성향에 대한 추천 / 내부 정책 로직 API 입니다. + +--- + +## 성향 기반 비슷한 유저가 들은 배틀 조회 API +### `GET /api/v1/battles/{battle_id}/recommendations/similar` + +- 비슷한 유저가 들은 배틀 , PM의 전략 미확정 (26.03.15) + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "battle_id": "battle_002", + "title": "사후세계는 존재하는가, 인간이 만든 위안인가?", + "tags": [ + { "tag_id": "tag_001", "name": "철학" } + ], + "participants_count": 1340, + "options": [ + { + "option_id": "option_A", + "label": "A", + "title": "존재한다", + "representative": "플라톤", + "image_url": "https://cdn.pique.app/characters/platon.png" + }, + { + "option_id": "option_B", + "label": "B", + "title": "인간이 만든 위안이다", + "representative": "에피쿠로스", + "image_url": "https://cdn.pique.app/characters/epicurus.png" + } + ] + } + ] + }, + "error": null +} +``` + +### 예외 응답 `404 - 배틀 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "BATTLE_NOT_FOUND", + "message": "존재하지 않는 배틀입니다.", + "errors": [] + } +} +``` + +--- + +## 공통 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | +| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | +| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | +| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | +| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | +| `USER_BANNED` | `403` | 제재된 사용자 | +| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | + +--- \ No newline at end of file diff --git a/docs/api-specs/reward-api.md b/docs/api-specs/reward-api.md new file mode 100644 index 0000000..24b8a3f --- /dev/null +++ b/docs/api-specs/reward-api.md @@ -0,0 +1,136 @@ +# 보상(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` 테이블에 적재합니다. + +--- + +## 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. 내 보상 이력 API + +### 3.1 GET /api/v1/me/rewards/history + +로그인한 사용자의 보상 획득 이력 조회.쿼리 파라미터 + +```JSON +{ + "statusCode": 200, + "data": { + "items": [ + { + "history_id": 105, + "reward_type": "POINT", + "reward_amount": 100, + "transaction_id": "unique_trans_id_20260327_001", + "created_at": "2026-03-27T18:00:00Z" + } + ], + "next_cursor": 104 + }, + "error": null + } +``` + +## 4. 에러 코드 + +### 4.1 보상 관련 에러 코드 + +### 🚨 보상 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 서명 검증에 실패하였습니다. 요청의 유효성을 확인하세요." + } +} +``` +--- + +## 공통 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|-------------------------------------| +| `REWARD_INVALID_USER` | `404` | custom_data에 해당하는 유저가 존재하지 않음 | +| `REWARD_INVALID_TYPE` | `400` | 지원하지 않는 reward_item 타입 (Enum 미매칭) | +| `REWARD_INVALID_SIGNATURE` | `401` | AdMob 서명(Signature) 검증 실패 또는 위변조 의심 | +--- \ No newline at end of file diff --git a/docs/api-specs/scenario-api.md b/docs/api-specs/scenario-api.md new file mode 100644 index 0000000..575c742 --- /dev/null +++ b/docs/api-specs/scenario-api.md @@ -0,0 +1,569 @@ +# 시나리오 API 명세서 + +--- + +## 설계 메모 + +- **시나리오 구조 (인터랙티브 O/X 모두 지원) :** + - 배틀의 성격에 따라 인터랙티브(분기 선택)가 없는 '단일 오디오 재생'과 인터랙티브가 있는 '트리형 오디오 재생'을 모두 지원합니다. `is_interactive` 상태값으로 구분하여 클라이언트가 적절한 UI를 렌더링합니다. +- **트리(Node) 구조 :** + - 시나리오(오디오/대본)는 오프닝/1라운드 → 유저 선택 분기(2라운드) → 최종 결론(3라운드/클로징)으로 이어지는 트리(Node) 구조를 가집니다. +- **TTS 사전 생성 :** + - 관리자가 시나리오를 발행할 때 단 1번만 TTS API를 호출하여 `.mp3` 파일과 타임스탬프(`start_time`)를 생성하고 CDN에 저장합니다. 유저 플레이 시에는 실시간 호출 없이 저장된 파일을 스트리밍합니다. +- **AI 자동 생성 :** + - 스케줄러가 매일 자동으로 트렌딩 이슈를 검색·수집하여 AI API를 호출하고 시나리오 초안을 `PENDING` 상태로 저장합니다. 관리자는 `/api/v1/admin/ai/scenarios`를 통해 검수·승인·반려합니다. +- **프론트엔드 자체 처리 :** + - 글씨 크기(A-/A+) 및 오디오 플레이어 컨트롤(15초 전/후, 배속, 스와이프)은 프론트엔드에서 네이티브/UI 상태로 처리합니다. +- **시나리오 `status` 흐름 :** + + | status | 적용 대상 | 설명 | + |--------|--------------|------| + | `DRAFT` | 관리자 | 관리자가 작성 중인 초안. TTS 미생성 상태 | + | `PENDING` | AI, 유저 [후순위] | 관리자 검수 대기 중 | + | `PUBLISHED` | 전체 | TTS 생성 완료, CDN 업로드 완료, 실제 노출 | + | `REJECTED` | AI, 유저 [후순위] | 검수 반려 | + | `ARCHIVED` | 전체 | 배틀 종료 후 이력 보존, 더 이상 노출 안 함 | + +- **[후순위] 크리에이터 정책 :** + - 매너 온도 45도 이상의 사용자가 직접 시나리오를 제안하는 기능은 런칭 스펙에서 제외됩니다. + +--- + +## 사용자 API + +### `GET /api/v1/battles/{battle_id}/scenario` + +- 사전 투표 완료 후 시나리오 창 진입 시 호출합니다. +- `is_interactive` 값에 따라 클라이언트 렌더링 방식이 분기됩니다. + +--- + +#### CASE 1 - 단일 재생 (`is_interactive: false`) + +- 전체 시나리오가 1개의 노드에 담기며, `interactive_options`는 빈 배열로 반환됩니다. + +```json +{ + "statusCode": 200, + "data": { + "battle_id": "battle_001", + "is_interactive": false, + "my_pre_vote": { + "option_id": "option_A", + "label": "A", + "title": "사기다" + }, + "start_node_id": "node_001_full", + "nodes": [ + { + "node_id": "node_001_full", + "audio_url": "https://cdn.pique.app/audio/battle_001_full.mp3", + "audio_duration": 420, + "scripts": [ + { "start_time": 0, "speaker_name": "나레이션", "speaker_side": "NONE", "message": "여기 한 여자가 있습니다. 동대문에서 18만 원에 떼온 가방을 1억 원에 팔았습니다..." }, + { "start_time": 60000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "재판장님, 시장 경제의 핵심은 '정보의 대칭'입니다. 판매자가 원가를 은폐한 것은 기만입니다." }, + { "start_time": 90000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "명품을 사는 사람이 원가를 몰라서 삽니까? 그들은 남들보다 우월해지기 위해 기꺼이 1억을 지불한 겁니다." }, + { "start_time": 150000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "현명하십니다. 상품의 가치가 전적으로 기만에 의해 결정된다면 사회적 계약의 약탈입니다." }, + { "start_time": 210000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "역시 가치를 아시는군요! 거래는 예술입니다. 주인공은 가방에 독점적 서사를 입혔고 구매자는 만족했습니다." }, + { "start_time": 300000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "한 가지 묻겠습니다. 당신이 만약 그 가방의 구매자였다면, 원가를 알고도 웃으며 1억을 내놓겠습니까?" }, + { "start_time": 330000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "질문이 틀렸소. 명품을 사는 자들은 이미 그 게임의 규칙을 압니다. 불쾌함이 곧 사기는 아닙니다." }, + { "start_time": 390000, "speaker_name": "나레이션", "speaker_side": "NONE", "message": "거래는 끝났고, 가방은 누군가의 손에 들려 있습니다. 이제 당신의 최종 선택을 들려주세요." } + ], + "interactive_options": [] + } + ] + }, + "error": null +} +``` + +--- + +#### CASE 2 - 분기형 인터랙티브 재생 (`is_interactive: true`) + +- `interactive_options` 배열의 `next_node_id`를 따라 노드를 순회합니다. + +```json +{ + "statusCode": 200, + "data": { + "battle_id": "battle_001", + "is_interactive": true, + "my_pre_vote": { + "option_id": "option_A", + "label": "A", + "title": "사기다" + }, + "start_node_id": "node_001_opening", + "nodes": [ + { + "node_id": "node_001_opening", + "audio_url": "https://cdn.pique.app/audio/battle_001_round1.mp3", + "audio_duration": 150, + "scripts": [ + { "start_time": 0, "speaker_name": "나레이션", "speaker_side": "NONE", "message": "여기 한 여자가 있습니다. 동대문에서 18만 원에 떼온 가방을 1억 원에 팔았습니다..." }, + { "start_time": 60000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "재판장님, 시장 경제의 핵심은 '정보의 대칭'입니다..." }, + { "start_time": 90000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "명품을 사는 사람이 원가를 몰라서 삽니까? 그들은 차별화를 위해..." } + ], + "interactive_options": [ + { "label": "사회적 신뢰를 위해 정보의 투명성이 우선이다.", "next_node_id": "node_002_branch_a" }, + { "label": "시장은 개인의 욕망이 만나는 곳이다.", "next_node_id": "node_002_branch_b" } + ] + }, + { + "node_id": "node_002_branch_a", + "audio_url": "https://cdn.pique.app/audio/battle_001_round2_a.mp3", + "audio_duration": 110, + "scripts": [ + { "start_time": 0, "speaker_name": "유저", "speaker_side": "A", "message": "사회의 기본 신뢰를 위해 투명한 정보 공개가 우선되어야 합니다." }, + { "start_time": 10000, "speaker_name": "존 롤스", "speaker_side": "A", "message": "현명하십니다. 상품의 가치가 전적으로 기만에 의해 결정된다면..." } + ], + "interactive_options": [ + { "label": "최종 충돌 및 정리 듣기", "next_node_id": "node_003_closing" } + ] + }, + { + "node_id": "node_002_branch_b", + "audio_url": "https://cdn.pique.app/audio/battle_001_round2_b.mp3", + "audio_duration": 120, + "scripts": [ + { "start_time": 0, "speaker_name": "유저", "speaker_side": "B", "message": "강요 없는 자발적 거래라면, 욕망에 따른 가격 결정은 시장의 자유입니다." }, + { "start_time": 10000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "역시 가치를 아시는군요! 거래는 예술입니다..." } + ], + "interactive_options": [ + { "label": "최종 충돌 및 정리 듣기", "next_node_id": "node_003_closing" } + ] + }, + { + "node_id": "node_003_closing", + "audio_url": "https://cdn.pique.app/audio/battle_001_round3_closing.mp3", + "audio_duration": 90, + "scripts": [ + { "start_time": 0, "speaker_name": "존 롤스", "speaker_side": "A", "message": "한 가지 묻겠습니다. 당신이 만약 그 가방의 구매자였다면..." }, + { "start_time": 30000, "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "질문이 틀렸소. 명품을 사는 자들은 이미 그 게임의 규칙을 압니다..." }, + { "start_time": 60000, "speaker_name": "나레이션", "speaker_side": "NONE", "message": "이제 당신의 최종 선택을 들려주세요." } + ], + "interactive_options": [] + } + ] + }, + "error": null +} +``` + +--- + +## 관리자 API + +### `POST /api/v1/admin/scenarios` + +- 공식 시나리오를 직접 생성합니다. 생성 시 TTS API가 자동 호출되어 `.mp3` 파일이 CDN에 업로드됩니다. + +#### Request Body + +```json +{ + "battle_id": "battle_001", + "is_interactive": true, + "nodes": [ + { + "node_name": "node_001_opening", + "is_start_node": true, + "scripts": [ + { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "여기 한 여자가 있습니다. 동대문에서 18만 원에 떼온 가방을 1억 원에 팔았습니다..." }, + { "speaker_name": "존 롤스", "speaker_side": "A", "message": "재판장님, 시장 경제의 핵심은 '정보의 대칭'입니다..." }, + { "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "명품을 사는 사람이 원가를 몰라서 삽니까?..." } + ], + "interactive_options": [ + { "label": "사회적 신뢰를 위해 정보의 투명성이 우선이다.", "next_node_name": "node_002_branch_a" }, + { "label": "시장은 개인의 욕망이 만나는 곳이다.", "next_node_name": "node_002_branch_b" } + ] + }, + { + "node_name": "node_002_branch_a", + "is_start_node": false, + "scripts": [ + { "speaker_name": "유저", "speaker_side": "A", "message": "사회의 기본 신뢰를 위해 투명한 정보 공개가 우선되어야 합니다." }, + { "speaker_name": "존 롤스", "speaker_side": "A", "message": "현명하십니다. 상품의 가치가 전적으로 기만에 의해 결정된다면..." } + ], + "interactive_options": [ + { "label": "최종 충돌 및 정리 듣기", "next_node_name": "node_003_closing" } + ] + }, + { + "node_name": "node_002_branch_b", + "is_start_node": false, + "scripts": [ + { "speaker_name": "유저", "speaker_side": "B", "message": "강요 없는 자발적 거래라면, 욕망에 따른 가격 결정은 시장의 자유입니다." }, + { "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "역시 가치를 아시는군요! 거래는 예술입니다..." } + ], + "interactive_options": [ + { "label": "최종 충돌 및 정리 듣기", "next_node_name": "node_003_closing" } + ] + }, + { + "node_name": "node_003_closing", + "is_start_node": false, + "scripts": [ + { "speaker_name": "존 롤스", "speaker_side": "A", "message": "한 가지 묻겠습니다. 당신이 만약 그 가방의 구매자였다면..." }, + { "speaker_name": "프리드리히 니체", "speaker_side": "B", "message": "질문이 틀렸소. 명품을 사는 자들은 이미 그 게임의 규칙을 압니다..." }, + { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "이제 당신의 최종 선택을 들려주세요." } + ], + "interactive_options": [] + } + ] +} +``` + +#### 성공 응답 `201 Created` + +```json +{ + "statusCode": 201, + "data": { + "scenario_id": "scenario_001", + "battle_id": "battle_001", + "is_interactive": true, + "status": "DRAFT", + "creator_type": "ADMIN", + "nodes": [ + { + "node_id": "node_001_opening", + "node_name": "node_001_opening", + "is_start_node": true, + "audio_url": "https://cdn.pique.app/audio/battle_001_round1.mp3", + "audio_duration": 150, + "interactive_options": [ + { "label": "사회적 신뢰를 위해 정보의 투명성이 우선이다.", "next_node_id": "node_002_branch_a" }, + { "label": "시장은 개인의 욕망이 만나는 곳이다.", "next_node_id": "node_002_branch_b" } + ] + }, + { + "node_id": "node_002_branch_a", + "node_name": "node_002_branch_a", + "is_start_node": false, + "audio_url": "https://cdn.pique.app/audio/battle_001_round2_a.mp3", + "audio_duration": 110, + "interactive_options": [ + { "label": "최종 충돌 및 정리 듣기", "next_node_id": "node_003_closing" } + ] + }, + { + "node_id": "node_002_branch_b", + "node_name": "node_002_branch_b", + "is_start_node": false, + "audio_url": "https://cdn.pique.app/audio/battle_001_round2_b.mp3", + "audio_duration": 120, + "interactive_options": [ + { "label": "최종 충돌 및 정리 듣기", "next_node_id": "node_003_closing" } + ] + }, + { + "node_id": "node_003_closing", + "node_name": "node_003_closing", + "is_start_node": false, + "audio_url": "https://cdn.pique.app/audio/battle_001_round3_closing.mp3", + "audio_duration": 90, + "interactive_options": [] + } + ], + "created_at": "2026-03-10T09:00:00Z" + }, + "error": null +} +``` + +--- + +### `PATCH /api/v1/admin/scenarios/{scenario_id}` + +- 시나리오 정보를 수정합니다. 변경할 필드만 포함합니다. + +#### Request Body + +```json +{ + "status": "PUBLISHED" +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "scenario_id": "scenario_001", + "battle_id": "battle_001", + "is_interactive": true, + "status": "PUBLISHED", + "creator_type": "ADMIN", + "updated_at": "2026-03-10T10:00:00Z" + }, + "error": null +} +``` + +--- + +### `DELETE /api/v1/admin/scenarios/{scenario_id}` + +- 시나리오를 삭제합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "success": true, + "deleted_at": "2026-03-10T11:00:00Z" + }, + "error": null +} +``` + +--- + +## `[후순위]` 관리자 AI 검수 API + +- 스케줄러가 자동 생성한 AI 시나리오 초안(`PENDING`)을 관리자가 검수 · 승인 · 반려합니다. + +### `GET /api/v1/admin/ai/scenarios` + +- AI가 생성한 `PENDING` 상태의 시나리오 목록을 조회합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "scenario_id": "scenario_ai_001", + "battle_id": "battle_ai_001", + "is_interactive": true, + "status": "PENDING", + "creator_type": "AI", + "nodes": [ + { + "node_id": "node_ai_001_opening", + "node_name": "node_ai_001_opening", + "is_start_node": true, + "audio_url": "https://cdn.pique.app/audio/battle_ai_001_round1.mp3", + "audio_duration": 140, + "interactive_options": [ + { "label": "AI 생성 선택지 A", "next_node_id": "node_ai_002_branch_a" }, + { "label": "AI 생성 선택지 B", "next_node_id": "node_ai_002_branch_b" } + ] + } + ], + "created_at": "2026-03-11T06:00:00Z" + } + ], + "total_count": 2 + }, + "error": null +} +``` + +--- + +### `PATCH /api/v1/admin/ai/scenarios/{scenario_id}` + +- AI가 생성한 시나리오를 승인하거나 반려합니다. 승인 시 내용을 수정한 뒤 승인할 수 있습니다. + +#### Request Body — 승인 + +```json +{ + "action": "APPROVE", + "nodes": [ + { + "node_id": "node_ai_001_opening", + "scripts": [ + { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "수정된 나레이션 내용..." } + ], + "interactive_options": [ + { "label": "수정된 선택지 A", "next_node_id": "node_ai_002_branch_a" }, + { "label": "수정된 선택지 B", "next_node_id": "node_ai_002_branch_b" } + ] + } + ] +} +``` + +#### Request Body — 반려 + +```json +{ + "action": "REJECT", + "reject_reason": "시나리오 흐름이 부자연스러움" +} +``` + +#### 성공 응답 `200 OK` — 승인 + +```json +{ + "statusCode": 200, + "data": { + "scenario_id": "scenario_ai_001", + "battle_id": "battle_ai_001", + "status": "PUBLISHED", + "creator_type": "AI", + "updated_at": "2026-03-11T09:00:00Z" + }, + "error": null +} +``` + +#### 성공 응답 `200 OK` — 반려 + +```json +{ + "statusCode": 200, + "data": { + "scenario_id": "scenario_ai_001", + "status": "REJECTED", + "reject_reason": "시나리오 흐름이 부자연스러움", + "updated_at": "2026-03-11T09:00:00Z" + }, + "error": null +} +``` + +--- + +## `[후순위]` 크리에이터 API + +### `POST /api/v1/scenarios` + +- 시나리오를 제안합니다. (매너 온도 45도 이상 유저) + +#### Request Body + +```json +{ + "battle_id": "battle_002", + "is_interactive": false, + "nodes": [ + { + "node_name": "node_001_full", + "is_start_node": true, + "scripts": [ + { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "AI가 그린 그림 한 장이 경매에서 1억 원에 낙찰됐습니다..." }, + { "speaker_name": "존 로크", "speaker_side": "A", "message": "노동을 투입한 자에게 소유권이 있습니다. AI 개발사가 권리를 가져야 합니다." }, + { "speaker_name": "루소", "speaker_side": "B", "message": "AI는 인류의 지식을 학습했습니다. 그 결과물은 모두의 것이어야 합니다." } + ], + "interactive_options": [] + } + ] +} +``` + +#### 성공 응답 `201 Created` + +```json +{ + "statusCode": 201, + "data": { + "scenario_id": "scenario_002", + "battle_id": "battle_002", + "is_interactive": false, + "status": "PENDING", + "creator_type": "USER", + "created_at": "2026-03-10T12:00:00Z" + }, + "error": null +} +``` + +--- + +### `PATCH /api/v1/scenarios/{scenario_id}` + +제안한 시나리오를 수정합니다. 변경할 필드만 포함합니다. + +#### Request Body + +```json +{ + "nodes": [ + { + "node_name": "node_001_full", + "is_start_node": true, + "scripts": [ + { "speaker_name": "나레이션", "speaker_side": "NONE", "message": "AI가 그린 그림 한 장이 경매에서 1억 원에 낙찰됐습니다. (수정)" }, + { "speaker_name": "존 로크", "speaker_side": "A", "message": "노동을 투입한 자에게 소유권이 있습니다." }, + { "speaker_name": "루소", "speaker_side": "B", "message": "AI는 인류의 지식을 학습했습니다." } + ], + "interactive_options": [] + } + ] +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "scenario_id": "scenario_002", + "battle_id": "battle_002", + "is_interactive": false, + "status": "PENDING", + "creator_type": "USER", + "updated_at": "2026-03-10T13:00:00Z" + }, + "error": null +} +``` + +--- + +### `DELETE /api/v1/scenarios/{scenario_id}` + +- 제안한 시나리오를 삭제합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "success": true, + "deleted_at": "2026-03-10T14:00:00Z" + }, + "error": null +} +``` + +--- + +## 공통 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | +| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | +| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | +| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | +| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | +| `USER_BANNED` | `403` | 제재된 사용자 | +| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | + +--- + +## 시나리오 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `SCENARIO_NOT_FOUND` | `404` | 존재하지 않는 시나리오 | +| `SCENARIO_NODE_NOT_FOUND` | `404` | 존재하지 않는 노드 | +| `SCENARIO_ALREADY_PUBLISHED` | `409` | 이미 발행된 시나리오 | +| `SCENARIO_TTS_FAILED` | `500` | TTS 생성 실패 | + +--- \ No newline at end of file diff --git a/docs/api-specs/tag-api.md b/docs/api-specs/tag-api.md new file mode 100644 index 0000000..e852f17 --- /dev/null +++ b/docs/api-specs/tag-api.md @@ -0,0 +1,188 @@ +# 태그 API 명세서 + +--- + +## 설계 메모 + +- **태그 구조 :** + - 태그는 별도 `TAGS` 테이블로 관리하며, `BATTLE_TAGS` 중간 테이블을 통해 배틀과 N:M 관계를 가집니다. +- **태그 목록 조회 :** + - 관리자가 배틀에 태그를 붙일 때 선택 목록 제공 및 클라이언트 필터 UI 구성에 활용됩니다. +- **태그 기반 배틀 필터링 :** + - `tag_id` 쿼리 파라미터로 특정 태그가 붙은 배틀 목록을 조회합니다. + +--- + +## 사용자 API + +### `GET /api/v1/tags` + +- 전체 태그 목록을 조회합니다. 클라이언트 필터 UI 구성 및 관리자 태그 선택에 활용됩니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { "tag_id": "tag_001", "name": "사회" }, + { "tag_id": "tag_002", "name": "철학" }, + { "tag_id": "tag_003", "name": "롤스" }, + { "tag_id": "tag_004", "name": "니체" }, + { "tag_id": "tag_005", "name": "경제" }, + { "tag_id": "tag_006", "name": "윤리" } + ], + "total_count": 6 + }, + "error": null +} +``` + +--- + +### `GET /api/v1/battles?tag_id={tag_id}` + +- 특정 태그가 붙은 배틀 목록을 조회합니다. + +#### Query Parameters + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|:----:|------| +| `tag_id` | string | ✅ | 필터링할 태그 ID | + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "tag": { "tag_id": "tag_002", "name": "철학" }, + "items": [ + { + "battle_id": "battle_001", + "title": "드라마 <레이디 두아>, 원가 18만원 명품은 사기인가?", + "summary": "18만 원짜리 가방을 1억에 판 주인공, 사기꾼일까 예술가일까?", + "thumbnail_url": "https://cdn.pique.app/battle/hot-001.png", + "tags": [ + { "tag_id": "tag_001", "name": "사회" }, + { "tag_id": "tag_002", "name": "철학" } + ], + "participants_count": 2148, + "audio_duration": 420, + "options": [ + { "option_id": "option_A", "label": "A", "title": "사기다 (롤스)" }, + { "option_id": "option_B", "label": "B", "title": "사기가 아니다 (니체)" } + ], + "user_vote_status": "NONE" + } + ], + "total_count": 1 + }, + "error": null +} +``` + +--- + +## 관리자 API + +### `POST /api/v1/admin/tags` + +- 새 태그를 생성합니다. + +#### Request Body + +```json +{ + "name": "정치" +} +``` + +#### 성공 응답 `201 Created` + +```json +{ + "statusCode": 201, + "data": { + "tag_id": "tag_007", + "name": "정치", + "created_at": "2026-03-10T09:00:00Z" + }, + "error": null +} +``` + +--- + +### `PATCH /api/v1/admin/tags/{tag_id}` + +- 태그명을 수정합니다. + +#### Request Body + +```json +{ + "name": "사회" +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "tag_id": "tag_007", + "name": "사회", + "updated_at": "2026-03-10T10:00:00Z" + }, + "error": null +} +``` + +--- + +### `DELETE /api/v1/admin/tags/{tag_id}` + +- 태그를 삭제합니다. 연결된 `BATTLE_TAGS` 레코드도 함께 삭제됩니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "success": true, + "deleted_at": "2026-03-10T11:00:00Z" + }, + "error": null +} +``` + +--- + +## 공통 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | +| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | +| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | +| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | +| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | +| `USER_BANNED` | `403` | 제재된 사용자 | +| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | + +--- + +## 태그 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `TAG_NOT_FOUND` | `404` | 존재하지 않는 태그 | +| `TAG_ALREADY_EXISTS` | `409` | 이미 존재하는 태그명 | +| `TAG_IN_USE` | `409` | 배틀에 사용 중인 태그 (삭제 불가) | +| `TAG_LIMIT_EXCEEDED` | `400` | 배틀당 태그 최대 개수 초과 | + +--- \ No newline at end of file diff --git a/docs/api-specs/user-api.md b/docs/api-specs/user-api.md new file mode 100644 index 0000000..bf50c05 --- /dev/null +++ b/docs/api-specs/user-api.md @@ -0,0 +1,361 @@ +# 내 정보 / 사용자 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`은 상세 리캡 조회에 사용합니다. +- 프론트는 `philosopher_type` 값에 따라 사전 정의된 철학자 카드를 통째로 교체 렌더링합니다. +- 그래서 백엔드는 철학자 카드용 `title`, `description`, 해시태그 문구를 내려주지 않습니다. +- 포인트(`point`)는 새 개념으로 도입하되, 이번 버전에서는 현재 DB에서 계산 가능한 항목만 부분 반영합니다. +- 현재 반영 규칙은 `완료된 사후 투표 x 10P`, `입장 변경 x 20P 보너스`입니다. +- 철학자 산출 로직은 추후 확정 예정이며, 현재는 프론트 연동을 위해 임시로 `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` | + +--- + +## 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/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.6 `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.7 `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.8 `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 0000000..0ebae6d --- /dev/null +++ b/docs/api-specs/vote-api.md @@ -0,0 +1,256 @@ +# 투표 API 명세서 + +--- + +## 설계 메모 + +- **사전/사후 투표 단일 레코드 :** + - 사전 투표와 사후 투표는 `VOTES` 테이블의 단일 레코드로 관리됩니다. `status` 필드(`NONE` → `PRE_VOTED` → `POST_VOTED`)로 진행 단계를 추적합니다. +- **투표 수정 :** + - 투표 입장 변경은 `PATCH` 메서드를 사용합니다. `vote_type` 필드로 사전/사후 구분합니다. +- **사후 투표 응답 :** + - 사후 투표 완료 시 `mind_changed` 여부와 전체 통계, 리워드 정보를 함께 반환합니다. + +--- + +## 사용자 API + +### `POST /api/v1/battles/{battle_id}/votes/pre` + +- 시나리오 청취 전 사전 투표를 진행합니다. + +#### Request Body + +```json +{ + "option_id": "option_A" +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "vote_id": "vote_001", + "status": "PRE_VOTED", + "next_step_url": "/battles/battle_001/scenario" + }, + "error": null +} +``` + +--- + +### `POST /api/v1/battles/{battle_id}/votes/post` + +- 시나리오 청취 후 최종 사후 투표를 진행합니다. 완료 시 결과 통계와 리워드를 함께 반환합니다. + +#### Request Body + +```json +{ + "option_id": "option_A" +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "vote_id": "vote_001", + "mind_changed": false, + "status": "POST_VOTED", + "statistics": { + "option_A_ratio": 65, + "option_B_ratio": 35 + }, + "reward": { + "is_majority": true, + "credits_earned": 10 + }, + "updated_at": "2026-03-10T16:35:00Z" + }, + "error": null +} +``` + +--- + +### `PATCH /api/v1/battles/{battle_id}/votes` + +- 기존 투표 입장을 변경합니다. `vote_type`으로 사전/사후 투표를 구분합니다. + +#### Request Body + +```json +{ + "vote_type": "PRE", + "option_id": "option_B" +} +``` + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "vote_id": "vote_001", + "updated_at": "2026-03-10T16:40:00Z" + }, + "error": null +} +``` + +--- + +### `DELETE /api/v1/battles/{battle_id}/votes` + +- 투표 이력을 취소 및 삭제합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "success": true, + "deleted_at": "2026-03-10T16:45:00Z" + }, + "error": null +} +``` + +--- + +### `GET /api/v1/battles/{battle_id}/vote-stats` + +- 투표 %를 조회 + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "options": [ + { + "option_id": "option_A", + "label": "A", + "title": "찬성", + "vote_count": 1259, + "ratio": 59.5 + }, + { + "option_id": "option_B", + "label": "B", + "title": "반대", + "vote_count": 856, + "ratio": 40.5 + } + ], + "total_count": 2115, + "updated_at": "2026-03-11T12:00:00Z" + }, + "error": null +} +``` + +#### 예외 응답 `404 - 배틀없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "BATTLE_NOT_FOUND", + "message": "존재하지 않는 배틀입니다.", + "errors": [] + } +} +``` +--- +### `GET /api/v1/battles/{battle_id}/votes/me` + +- 투표 %를 조회 + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "pre_vote": { + "option_id": "option_A", + "label": "A", + "title": "찬성" + }, + "post_vote": { + "option_id": "option_A", + "label": "A", + "title": "찬성" + }, + "mind_changed": false, + "status": "POST_VOTED" + }, + "error": null +} +``` + +#### 예외 응답 `404 - 배틀없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "BATTLE_NOT_FOUND", + "message": "존재하지 않는 배틀입니다.", + "errors": [] + } +} +``` + +#### 예외 응답 `404 - 투표 내역 없음` + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "VOTE_NOT_FOUND", + "message": "투표 내역이 없습니다.", + "errors": [] + } +} +``` + +--- +## 공통 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | +| `COMMON_BAD_REQUEST` | `400` | 잘못된 요청 | +| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | +| `AUTH_TOKEN_EXPIRED` | `401` | 토큰 만료 | +| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | +| `USER_BANNED` | `403` | 제재된 사용자 | +| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | + +--- + +## 투표 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `VOTE_NOT_FOUND` | `404` | 존재하지 않는 투표 | +| `VOTE_ALREADY_SUBMITTED` | `409` | 이미 투표 완료 | +| `PRE_VOTE_REQUIRED` | `409` | 사전 투표 필요 | +| `POST_VOTE_REQUIRED` | `409` | 사후 투표 필요 | + +--- \ No newline at end of file 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 0000000..2c90327 --- /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 0000000..a14df6b --- /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 0000000..5ee0808 --- /dev/null +++ b/docs/erd/battle.puml @@ -0,0 +1,89 @@ +@startuml battle +hide circle +hide methods +skinparam linetype ortho + +' ─────────────────────────────── +' 테이블 정의 +' ─────────────────────────────── + +entity "users\n사용자" as users { + * id : BIGINT <> + -- + email : VARCHAR(255) <> + nickname : VARCHAR(50) <> + character_id : INT <> + role : ENUM('USER', 'ADMIN') + status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "BATTLES\n배틀(주제)" as battles { + * id : Long <> + -- + title : VARCHAR(255) + summary : VARCHAR(500) + description : TEXT + thumbnail_url : VARCHAR(500) + target_date : DATE + status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') + creator_type : ENUM('ADMIN', 'USER', 'AI') + creator_id : BIGINT <> (nullable) + reject_reason : VARCHAR(500) (nullable) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "BATTLE_OPTIONS\n선택지" as battle_options { + * id : Long <> + -- + battle_id : Long <> + label : ENUM('A', 'B') + title : VARCHAR(100) + stance : VARCHAR(255) + representative : VARCHAR(100) + quote : TEXT + keywords : JSONB + image_url : VARCHAR(500) +} + +' ─────────────────────────────── +' 배치 가이드 (위→아래) +' ─────────────────────────────── + +users -[hidden]down- battles +battles -[hidden]down- battle_options + +' ─────────────────────────────── +' 관계 +' ─────────────────────────────── + +users ||--o{ battles : "creates" +battles ||--o{ battle_options : "has" + +' ─────────────────────────────── +' 노트 +' ─────────────────────────────── + +note right of battles + status 흐름: + + [관리자 직접 발행] + DRAFT → PUBLISHED → ARCHIVED + + [AI 자동 생성 · 스케줄러 - 후순위] + PENDING → PUBLISHED → ARCHIVED + → REJECTED + + [유저 크리에이터 - 후순위] + PENDING → PUBLISHED → ARCHIVED + → REJECTED + + creator_type + ADMIN : 관리자 직접 발행 → creator_id = null + AI : [후순위] 스케줄러 자동 생성 → creator_id = null + USER : [후순위] 유저 제안 → creator_id = users.id +end note + +@enduml diff --git a/docs/erd/comment.puml b/docs/erd/comment.puml new file mode 100644 index 0000000..550193c --- /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 0000000..5402b62 --- /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 0000000..1213f84 --- /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 0000000..bf219e1 --- /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/scenario.puml b/docs/erd/scenario.puml new file mode 100644 index 0000000..285c348 --- /dev/null +++ b/docs/erd/scenario.puml @@ -0,0 +1,127 @@ +@startuml scenario +hide circle +hide methods +skinparam linetype ortho + +' ─────────────────────────────── +' 테이블 정의 +' ─────────────────────────────── + +entity "users\n사용자" as users { + * id : BIGINT <> + -- + email : VARCHAR(255) <> + nickname : VARCHAR(50) <> + character_id : INT <> + role : ENUM('USER', 'ADMIN') + status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "BATTLES\n배틀(주제)" as battles { + * id : Long <> + -- + title : VARCHAR(255) + summary : VARCHAR(500) + description : TEXT + thumbnail_url : VARCHAR(500) + target_date : DATE + status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') + creator_type : ENUM('ADMIN', 'USER', 'AI') + creator_id : BIGINT <> (nullable) + reject_reason : TEXT (nullable) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + + +entity "SCENARIOS\n시나리오 마스터" as scenarios { + * id : Long <> + -- + battle_id : Long <> + creator_type : ENUM('ADMIN', 'USER', 'AI') + creator_id : BIGINT <> (nullable) + is_interactive : BOOLEAN + status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') + reject_reason : VARCHAR(500) (nullable) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "SCENARIO_NODES\n시나리오 노드 (오디오/분기 통합)" as scenario_nodes { + * id : Long <> + -- + scenario_id : Long <> + node_name : VARCHAR(100) + audio_url : VARCHAR(500) + audio_duration : INT + is_start_node : BOOLEAN + interactive_options : JSONB +} + +entity "SCENARIO_SCRIPTS\n대본(말풍선)" as scenario_scripts { + * id : Long <> + -- + node_id : Long <> + start_time : INT + speaker_name : VARCHAR(100) + speaker_side : ENUM('A', 'B', 'NONE') + message : TEXT +} + +' ─────────────────────────────── +' 배치 가이드 (위→아래) +' ─────────────────────────────── + +users -[hidden]down- battles +battles -[hidden]down- scenarios +scenarios -[hidden]down- scenario_nodes +scenario_nodes -[hidden]down- scenario_scripts + +' ─────────────────────────────── +' 관계 +' ─────────────────────────────── + +users ||--o{ scenarios : "creates" +battles ||--|| scenarios : "has" +scenarios ||--o{ scenario_nodes : "contains" +scenario_nodes ||--o{ scenario_scripts : "contains" + +' ─────────────────────────────── +' 노트 +' ─────────────────────────────── + +note right of scenarios + status 흐름: + + [관리자 직접 발행] + DRAFT → PUBLISHED → ARCHIVED + + [AI 자동 생성 · 스케줄러 - 후순위] + PENDING → PUBLISHED → ARCHIVED + → REJECTED + + [유저 크리에이터 - 후순위] + PENDING → PUBLISHED → ARCHIVED + → REJECTED + + is_interactive = false : + 노드 1개, interactive_options = [] + + is_interactive = true : + 오프닝 → 분기(A/B) → 클로징 + interactive_options = [ + { label, next_node_id } + ] + + PUBLISHED 전환 시 + TTS 생성 + CDN 업로드 자동 연동 + + creator_type + ADMIN : 관리자 직접 발행 → creator_id = null + AI : [후순위] 스케줄러 자동 생성 → creator_id = null + USER : [후순위] 유저 제안 → creator_id = users.id +end note + +@enduml diff --git a/docs/erd/tag.puml b/docs/erd/tag.puml new file mode 100644 index 0000000..8a9c5b5 --- /dev/null +++ b/docs/erd/tag.puml @@ -0,0 +1,61 @@ +@startuml tag +hide circle +hide methods +skinparam linetype ortho + +' ─────────────────────────────── +' 테이블 정의 +' ─────────────────────────────── + +entity "BATTLES\n배틀(주제)" as battles { + * id : Long <> + -- + title : VARCHAR(255) + summary : VARCHAR(500) + description : TEXT + thumbnail_url : VARCHAR(500) + target_date : DATE + status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') + creator_type : ENUM('ADMIN', 'USER', 'AI') + creator_id : BIGINT <> (nullable) + reject_reason : VARCHAR(500) (nullable) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "TAGS\n태그" as tags { + * id : Long <> + -- + name : VARCHAR(50) <> + created_at : TIMESTAMP +} + +entity "BATTLE_TAGS\n배틀-태그 매핑" as battle_tags { + * battle_id : Long <> + * tag_id : Long <> +} + +' ─────────────────────────────── +' 배치 가이드 (좌→우→아래) +' ─────────────────────────────── + +battles -[hidden]right- tags +tags -[hidden]down- battle_tags + +' ─────────────────────────────── +' 관계 +' ─────────────────────────────── + +battles ||--o{ battle_tags : "tagged with" +tags ||--o{ battle_tags : "used in" + +' ─────────────────────────────── +' 노트 +' ─────────────────────────────── + +note bottom of battle_tags + 복합 PK: (battle_id, tag_id) + 배틀과 태그의 N:M 관계를 처리하는 중간 테이블 +end note + +@enduml diff --git a/docs/erd/user-ops.puml b/docs/erd/user-ops.puml new file mode 100644 index 0000000..db748bc --- /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 0000000..89a6ddd --- /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 0000000..ddadde8 --- /dev/null +++ b/docs/erd/vote.puml @@ -0,0 +1,101 @@ +@startuml vote +hide circle +hide methods +skinparam linetype ortho + +' ─────────────────────────────── +' 테이블 정의 +' ─────────────────────────────── + +entity "users\n사용자" as users { + * id : BIGINT <> + -- + email : VARCHAR(255) <> + nickname : VARCHAR(50) <> + character_id : INT <> + role : ENUM('USER', 'ADMIN') + status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "BATTLES\n배틀(주제)" as battles { + * id : Long <> + -- + title : VARCHAR(255) + summary : VARCHAR(500) + description : TEXT + thumbnail_url : VARCHAR(500) + target_date : DATE + status : ENUM('DRAFT', 'PENDING', 'PUBLISHED', 'REJECTED', 'ARCHIVED') + creator_type : ENUM('ADMIN', 'USER', 'AI') + creator_id : BIGINT <> (nullable) + reject_reason : VARCHAR(500) (nullable) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "BATTLE_OPTIONS\n선택지" as battle_options { + * id : Long <> + -- + battle_id : Long <> + label : ENUM('A', 'B') + title : VARCHAR(100) + stance : VARCHAR(255) + representative : VARCHAR(100) + quote : TEXT + image_url : VARCHAR(500) +} + +entity "VOTES\n투표 이력" as votes { + * id : Long <> + -- + user_id : BIGINT <> + battle_id : Long <> + pre_vote_option_id : Long <> (nullable) + post_vote_option_id : Long <> (nullable) + mind_changed : BOOLEAN + reward_credits : INT + status : ENUM('NONE', 'PRE_VOTED', 'POST_VOTED') + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +' ─────────────────────────────── +' 배치 가이드 +' users battles +' \ | +' votes battle_options +' ─────────────────────────────── + +users -[hidden]right- battles +battles -[hidden]down- battle_options +users -[hidden]down- votes +votes -[hidden]right- battle_options + +' ─────────────────────────────── +' 관계 +' ─────────────────────────────── + +users ||--o{ votes : "votes" +battles ||--o{ battle_options : "has" +battles ||--o{ votes : "receives" +votes }o--|| battle_options : "pre_vote" +votes }o--|| battle_options : "post_vote" + +' ─────────────────────────────── +' 노트 +' ─────────────────────────────── + +note right of votes + status 흐름: + NONE → PRE_VOTED → POST_VOTED + + pre_vote_option_id : 사전 투표 선택지 (nullable) + post_vote_option_id : 사후 투표 선택지 (nullable) + + mind_changed: + pre_vote_option_id ≠ post_vote_option_id 이면 true +end note + +@enduml diff --git a/hs_err_pid172944.log b/hs_err_pid172944.log new file mode 100644 index 0000000..8707abc --- /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 0000000..12c25b9 --- /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 0000000..cd8d3a5 --- /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 0cdaa80..0a7aaf2 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 11ec00c..0000000 --- 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 0000000..c309634 --- /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/AdminPickeController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java new file mode 100644 index 0000000..ea89b32 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java @@ -0,0 +1,47 @@ +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"; + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java new file mode 100644 index 0000000..b115abc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java @@ -0,0 +1,51 @@ +package com.swyp.picke.domain.battle.controller; + +import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.battle.dto.response.AdminBattleDeleteResponse; +import com.swyp.picke.domain.battle.dto.response.AdminBattleDetailResponse; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "배틀 API (관리자)", description = "배틀 생성/수정/삭제 (관리자 전용)") +@RestController +@RequestMapping("/api/v1/admin/battles") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminBattleController { + + private final BattleService battleService; + + @Operation(summary = "배틀 생성") + @PostMapping + public ApiResponse createBattle( + @RequestBody @Valid AdminBattleCreateRequest request, + @AuthenticationPrincipal Long adminUserId + ) { + return ApiResponse.onSuccess(battleService.createBattle(request, adminUserId)); + } + + @Operation(summary = "배틀 수정 (변경 필드만 포함)") + @PatchMapping("/{battleId}") + public ApiResponse updateBattle( + @PathVariable Long battleId, + @RequestBody @Valid AdminBattleUpdateRequest request + ) { + return ApiResponse.onSuccess(battleService.updateBattle(battleId, request)); + } + + @Operation(summary = "배틀 삭제") + @DeleteMapping("/{battleId}") + public ApiResponse deleteBattle( + @PathVariable Long battleId + ) { + return ApiResponse.onSuccess(battleService.deleteBattle(battleId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java b/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java new file mode 100644 index 0000000..9450a07 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java @@ -0,0 +1,55 @@ +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.*; + +@Tag(name = "배틀 API (사용자)", description = "배틀 조회") +@RestController +@RequestMapping("/api/v1/battles") +@RequiredArgsConstructor +public class BattleController { + + private final BattleService battleService; + + @Operation(summary = "오늘의 배틀 목록 조회 (스와이프 UI용, 최대 5개)") + @GetMapping("/today") + public ApiResponse getTodayBattles() { + return ApiResponse.onSuccess(battleService.getTodayBattles()); + } + + @Operation(summary = "배틀 전체 목록 조회", description = "페이징 및 타입별(ALL, BATTLE, QUIZ, VOTE) 필터링된 배틀 목록을 조회합니다.") + @GetMapping + public ApiResponse getBattles( + @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") + @RequestParam(value = "page", defaultValue = "1") int page, + @Parameter(description = "페이지 크기", example = "10") + @RequestParam(value = "size", defaultValue = "10") int size, + @Parameter(description = "콘텐츠 타입 (ALL, BATTLE, QUIZ, VOTE)", example = "ALL") + @RequestParam(value = "type", required = false, defaultValue = "ALL") String type + ) { + return ApiResponse.onSuccess(battleService.getBattles(page, size, type)); + } + + @Operation(summary = "배틀 상세 조회") + @GetMapping("/{battleId}") + public ApiResponse getBattleDetail( + @PathVariable Long battleId + ) { + return ApiResponse.onSuccess(battleService.getBattleDetail(battleId)); + } + + @Operation(summary = "사용자 배틀 진행 상태 조회 (사전투표/TTS/사후투표)") + @GetMapping("/{battleId}/status") + public ApiResponse getUserBattleStatus(@PathVariable Long battleId) { + return ApiResponse.onSuccess(battleService.getUserBattleStatus(battleId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java new file mode 100644 index 0000000..3511d52 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java @@ -0,0 +1,191 @@ +package com.swyp.picke.domain.battle.converter; + +import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.battle.dto.response.*; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.user.enums.PhilosopherType; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.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.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/"; + + public Battle toEntity(AdminBattleCreateRequest request, User admin) { + return Battle.builder() + .title(request.title()) + .titlePrefix(request.titlePrefix()) + .titleSuffix(request.titleSuffix()) + .itemA(request.itemA()) + .itemADesc(request.itemADesc()) + .itemB(request.itemB()) + .itemBDesc(request.itemBDesc()) + .summary(request.summary()) + .description(request.description()) + .thumbnailUrl(request.thumbnailUrl()) + .type(request.type()) + .targetDate(request.targetDate()) + .status(request.status()) + .creatorType(BattleCreatorType.ADMIN) + .creator(admin) + .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.getType(), + battle.getViewCount() == null ? 0 : battle.getViewCount(), + battle.getTotalParticipantsCount() == null ? 0L : battle.getTotalParticipantsCount(), + battle.getAudioDuration() == null ? 0 : battle.getAudioDuration(), + toTagResponses(tags, null), + toTodayOptionResponses(options), + battle.getTitlePrefix(), + battle.getTitleSuffix(), + battle.getItemA(), + battle.getItemADesc(), + battle.getItemB(), + battle.getItemBDesc() + ); + } + + public BattleSimpleResponse toSimpleResponse(Battle battle) { + return new BattleSimpleResponse( + battle.getId(), + battle.getTitle(), + urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), + battle.getType() != null ? battle.getType().name() : "BATTLE", + 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.getTitlePrefix(), + battle.getTitleSuffix(), + battle.getSummary(), + battle.getDescription(), + urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), + battle.getType(), + battle.getItemA(), + battle.getItemADesc(), + battle.getItemB(), + battle.getItemBDesc(), + 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.getType(), + 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.getTitlePrefix(), + battle.getTitleSuffix(), + battle.getItemA(), + battle.getItemADesc(), + battle.getItemB(), + battle.getItemBDesc(), + 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(), + opt.getQuote(), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, PhilosopherType.resolveImageKey(opt.getRepresentative())) + )).toList(); + + return new BattleScenarioResponse(battle.getTitle(), profiles); + } + + private List toOptionResponses(List options, Map> optionTagsMap) { + if (options == null) return List.of(); + return options.stream() + .map(option -> { + List optionTags = optionTagsMap.getOrDefault(option.getId(), List.of()); + return new BattleOptionResponse( + option.getId(), + option.getLabel(), + option.getTitle(), + option.getStance(), + option.getRepresentative(), + option.getQuote(), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, PhilosopherType.resolveImageKey(option.getRepresentative())), + toTagResponses(optionTags, null) + ); + }).toList(); + } + + private List toTodayOptionResponses(List options) { + if (options == null) return List.of(); + return options.stream().map(option -> new TodayOptionResponse( + option.getId(), + option.getLabel(), + option.getTitle(), + option.getRepresentative(), + option.getStance(), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()), + option.getIsCorrect() + )).toList(); + } + + 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(); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java new file mode 100644 index 0000000..48aa5b4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java @@ -0,0 +1,24 @@ +package com.swyp.picke.domain.battle.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.enums.BattleType; +import java.time.LocalDate; +import java.util.List; + +public record AdminBattleCreateRequest( + String title, + String titlePrefix, + String titleSuffix, + String summary, + String description, + String thumbnailUrl, + BattleType type, + BattleStatus status, + String itemA, + String itemADesc, + String itemB, + String itemBDesc, + LocalDate targetDate, + List tagIds, + List options +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java new file mode 100644 index 0000000..36c1c21 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.battle.dto.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 quote, + String imageUrl, + Boolean isCorrect, + List tagIds // 옵션 전용 태그 (철학자, 가치관 - 추후 사용자 유형 분석에 사용) +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java new file mode 100644 index 0000000..aa5e447 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java @@ -0,0 +1,23 @@ +package com.swyp.picke.domain.battle.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import java.time.LocalDate; +import java.util.List; + +public record AdminBattleUpdateRequest( + String title, + String titlePrefix, + String titleSuffix, + String summary, + String description, + String thumbnailUrl, + String itemA, + String itemADesc, + String itemB, + String itemBDesc, + LocalDate targetDate, + Integer audioDuration, + BattleStatus status, + List tagIds, + List options +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java new file mode 100644 index 0000000..43c64d6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.time.LocalDateTime; + +/** + * 관리자 - 배틀 삭제 응답 + * 역할: 배틀이 성공적으로 소프트 딜리트 되었는지 확인하고 삭제 시점을 반환합니다. + */ + +public record AdminBattleDeleteResponse( + Boolean success, // 삭제 성공 여부 + LocalDateTime deletedAt // 삭제 처리된 일시 (Soft Delete) +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java new file mode 100644 index 0000000..fd38233 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.enums.BattleType; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 관리자 - 배틀 상세 상세 조회 응답 + * 역할: 관리자가 배틀의 모든 설정 값(상태, 생성자 타입, 수정일 등)을 확인하고 수정할 때 사용합니다. + */ + +public record AdminBattleDetailResponse( + Long battleId, + String title, + String titlePrefix, + String titleSuffix, + String summary, + String description, + String thumbnailUrl, + BattleType type, + String itemA, + String itemADesc, + String itemB, + String itemBDesc, + LocalDate targetDate, + BattleStatus status, + BattleCreatorType creatorType, + List tags, + List options, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} \ No newline at end of file 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 0000000..02e6d72 --- /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 0000000..51ca176 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java @@ -0,0 +1,16 @@ +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 quote, + String imageUrl, + List tags +) {} 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 0000000..de611ff --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java @@ -0,0 +1,16 @@ +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 quote, + String imageUrl + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java new file mode 100644 index 0000000..feef39f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.battle.dto.response; + +import java.time.LocalDateTime; + +public record BattleSimpleResponse( + Long battleId, + String title, + String thumbnailUrl, + String type, + String status, + LocalDateTime createdAt +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java new file mode 100644 index 0000000..cd39f4d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java @@ -0,0 +1,23 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleType; + +import java.util.List; + +/** + * 유저 - 배틀 요약 정보 응답 + * 역할: 홈 화면의 각 섹션 카드나 리스트에서 '미리보기' 형태로 보여줄 데이터입니다. + */ + +public record BattleSummaryResponse( + Long battleId, // 배틀 고유 ID + String title, // 배틀 제목 + String summary, // 배틀 요약 (누군가는 이것을...) + String thumbnailUrl, // 카드 배경 이미지 URL + BattleType type, // 배틀 타입 태그 (#BATTLE, #VOTE 등) + Integer viewCount, // 조회수 + Long participantsCount, // 누적 참여자 수 + Integer audioDuration, // 오디오 소요 시간 + List tags, // 카테고리/인물 태그 리스트 + List options // 선택지 요약 (A vs B) +) {} \ No newline at end of file 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 0000000..ac09b19 --- /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 0000000..b08b945 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java @@ -0,0 +1,27 @@ +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; + +/** + * 유저 - 배틀 상세 페이지 응답 (시안 4, 5번) + * 역할: 배틀 클릭 시 진입하는 상세 화면의 모든 정보를 담습니다. 투표 여부에 따라 UI가 변합니다. + */ +public record BattleUserDetailResponse( + BattleSummaryResponse battleInfo, // 기본적인 배틀 정보 (요약 DTO 재사용) + String titlePrefix, + String titleSuffix, + String itemA, + String itemADesc, + String itemB, + String itemBDesc, + String description, // 상세 본문 설명 + String shareUrl, // 공유하기 버튼용 링크 + VoteSide userVoteStatus, // 현재 유저의 투표 상태 + UserBattleStep currentStep, + List categoryTags, // UI 상단용 카테고리 태그 + List philosopherTags, // UI 하단용 철학자 태그 + List valueTags // 성향 분석용 가치관 태그 +) {} \ No newline at end of file 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 0000000..64720c5 --- /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 // 옵션별 득표 현황 리스트 +) {} \ No newline at end of file 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 0000000..990e412 --- /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 0000000..26e9567 --- /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 // 목록 총 개수 +) {} 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 0000000..8b14041 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java @@ -0,0 +1,29 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleType; + +import java.util.List; + +/** + * 유저 - 오늘의 배틀 상세 응답 (시안 6번) + * 역할: 어두운 배경의 풀스크린 UI에 필요한 배경 이미지, 시간 등을 담습니다. + */ +public record TodayBattleResponse( + Long battleId, // 배틀 고유 ID + String title, // 배틀 제목 + String summary, // 중간 요약 문구 + String thumbnailUrl, // 풀스크린 배경 이미지 URL + BattleType type, // 타입 태그 + Integer viewCount, // 조회수 + Long participantsCount, // 누적 참여자 수 + Integer audioDuration, // 소요 시간 (분:초 변환용 데이터) + List tags, // 상단 태그 리스트 + List options, // 중앙 세로형 대결 카드 데이터 + // 퀴즈·투표 전용 필드 + String titlePrefix, // 투표 접두사 (예: "도덕의 기준은") + String titleSuffix, // 투표 접미사 (예: "이다") + String itemA, // 퀴즈 O 선택지 + String itemADesc, // 퀴즈 O 설명 + String itemB, // 퀴즈 X 선택지 + String itemBDesc // 퀴즈 X 설명 +) {} 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 0000000..2fd1587 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; + +/** + * 유저 - 오늘의 배틀 전용 옵션 응답 + * 역할: 오늘의 배틀 시안의 세로형 카드에 들어가는 인물, 입장, 아바타 정보를 담습니다. + */ + +public record TodayOptionResponse( + Long optionId, // 옵션 ID + BattleOptionLabel label,// 라벨 (A, B) + String title, // 제목 (예: 찬성한다) + String representative, // 인물 (예: 피터 싱어) + String stance, // 한 줄 입장 (예: 고통을 끝낼 권리는..) + String imageUrl, // 아바타 이미지 URL + Boolean isCorrect // 퀴즈 정답 여부 +) {} 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 0000000..7a3ac8d --- /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.battle.enums.BattleType; +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; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@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; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private BattleType type; + + @Column(name = "title_prefix") + private String titlePrefix; + + @Column(name = "title_suffix") + private String titleSuffix; + + @Column(name = "item_a") + private String itemA; + + @Column(name = "item_a_desc") + private String itemADesc; + + @Column(name = "item_b") + private String itemB; + + @Column(name = "item_b_desc") + private String itemBDesc; + + @Column(name = "view_count") + private Integer viewCount = 0; + + @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; + + // 홈 화면 5단 기획을 위한 필드들 + + @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, + BattleType type, String titlePrefix, String titleSuffix, + String itemA, String itemADesc, String itemB, String itemBDesc, + LocalDate targetDate, Integer audioDuration, BattleStatus status, + BattleCreatorType creatorType, User creator) { + this.title = title; + this.summary = summary; + this.description = description; + this.thumbnailUrl = thumbnailUrl; + this.type = type; + this.titlePrefix = titlePrefix; + this.titleSuffix = titleSuffix; + this.itemA = itemA; + this.itemADesc = itemADesc; + this.itemB = itemB; + this.itemBDesc = itemBDesc; + this.targetDate = targetDate; + this.audioDuration = audioDuration; + this.status = status; + 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 titlePrefix, String titleSuffix, + String itemA, String itemADesc, String itemB, String itemBDesc, + String summary, String description, + String thumbnailUrl, LocalDate targetDate, + Integer audioDuration, BattleStatus status) { + if (title != null) this.title = title; + if (titlePrefix != null) this.titlePrefix = titlePrefix; + if (titleSuffix != null) this.titleSuffix = titleSuffix; + + if (itemA != null) this.itemA = itemA; + if (itemADesc != null) this.itemADesc = itemADesc; + if (itemB != null) this.itemB = itemB; + if (itemBDesc != null) this.itemBDesc = itemBDesc; + + if (summary != null) this.summary = summary; + if (description != null) this.description = description; + if (thumbnailUrl != null) this.thumbnailUrl = thumbnailUrl; + if (targetDate != null) this.targetDate = targetDate; + if (audioDuration != null) this.audioDuration = audioDuration; + if (status != null) this.status = status; + } + + public void 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; + } +} \ 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 0000000..8be17ce --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java @@ -0,0 +1,78 @@ +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.*; +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(columnDefinition = "TEXT") + private String quote; + + @Column(name = "vote_count") + private Long voteCount = 0L; + + @Column(name = "is_correct") + private Boolean isCorrect = false; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @Builder + public BattleOption(Battle battle, BattleOptionLabel label, String title, String stance, + String representative, String quote, String imageUrl, Boolean isCorrect) { + this.battle = battle; + this.label = label; + this.title = title; + this.stance = stance; + this.representative = representative; + this.quote = quote; + this.imageUrl = imageUrl; + this.isCorrect = (isCorrect != null) && isCorrect; + this.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 quote, String imageUrl, Boolean isCorrect) { + if (title != null) this.title = title; + if (stance != null) this.stance = stance; + if (representative != null) this.representative = representative; + if (quote != null) this.quote = quote; + if (imageUrl != null) this.imageUrl = imageUrl; + if (isCorrect != null) this.isCorrect = isCorrect; + } +} \ No newline at end of file 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 0000000..42a63cd --- /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/BattleTag.java b/src/main/java/com/swyp/picke/domain/battle/entity/BattleTag.java new file mode 100644 index 0000000..4d8f29f --- /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/BattleCreatorType.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleCreatorType.java new file mode 100644 index 0000000..5c09d46 --- /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 0000000..bcc2bb0 --- /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/BattleStatus.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleStatus.java new file mode 100644 index 0000000..f43148c --- /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/enums/BattleType.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java new file mode 100644 index 0000000..648e1ef --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.battle.enums; + +public enum BattleType { + BATTLE, QUIZ, VOTE +} diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java new file mode 100644 index 0000000..d30f2a8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java @@ -0,0 +1,16 @@ +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 java.util.List; +import java.util.Optional; + +public interface BattleOptionRepository extends JpaRepository { + + List findByBattle(Battle battle); + Optional findByBattleAndLabel(Battle battle, BattleOptionLabel label); + List findByBattleIn(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 0000000..fb2ffce --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java @@ -0,0 +1,20 @@ +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 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); + + @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/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java new file mode 100644 index 0000000..c4aa3d8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -0,0 +1,101 @@ +package com.swyp.picke.domain.battle.repository; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.enums.BattleType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Page; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public interface BattleRepository extends JpaRepository { + + // 1. EDITOR PICK - type 파라미터 추가 + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.isEditorPick = true AND battle.status = :status " + + "AND battle.type = :type AND battle.deletedAt IS NULL " + + "ORDER BY battle.createdAt DESC") + List findEditorPicks(@Param("status") BattleStatus status, @Param("type") BattleType type, Pageable pageable); + + // 2. 지금 뜨는 배틀 - type 파라미터 추가 + @Query("SELECT battle FROM Battle battle JOIN Vote vote ON vote.battle = battle " + + "WHERE vote.createdAt >= :yesterday AND battle.type = :type " + + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + + "GROUP BY battle ORDER BY COUNT(vote) DESC") + List findTrendingBattles(@Param("yesterday") LocalDateTime yesterday, @Param("type") BattleType type, Pageable pageable); + + // 3. Best 배틀 - type 파라미터 추가 + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.status = 'PUBLISHED' AND battle.type = :type AND battle.deletedAt IS NULL " + + "ORDER BY (battle.totalParticipantsCount + (battle.commentCount * 5)) DESC") + List findBestBattles(@Param("type") BattleType type, Pageable pageable); + + // 4. 오늘의 Pické + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.type = :type AND battle.targetDate = :today " + + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL") + List findTodayPicks(@Param("type") BattleType type, @Param("today") LocalDate today, Pageable pageable); + + // 5. 새로운 배틀 - type 파라미터 추가 + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.id NOT IN :excludeIds AND battle.type = :type " + + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + + "ORDER BY battle.createdAt DESC") + List findNewBattlesExcluding(@Param("excludeIds") List excludeIds, @Param("type") BattleType type, Pageable pageable); + + // 6. 전체 배틀 목록 조회 (페이징, 삭제된 항목 제외, 최신순) + Page findByDeletedAtIsNullOrderByCreatedAtDesc(Pageable pageable); + Page findByTypeAndDeletedAtIsNullOrderByCreatedAtDesc(BattleType type, Pageable pageable); + + // 기본 조회용 + List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); + + List findByTargetDateAndStatusAndTypeAndDeletedAtIsNull(LocalDate targetDate, BattleStatus status, BattleType type); + + // 탐색 탭: 전체 배틀 검색 (정렬은 Pageable Sort로 처리) + @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + List searchAll(Pageable pageable); + + @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + long countSearchAll(); + + // 탐색 탭: 카테고리 태그 필터 배틀 검색 + @Query("SELECT DISTINCT b FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + List searchByCategory(@Param("categoryName") String categoryName, Pageable pageable); + + @Query("SELECT COUNT(DISTINCT b) FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + long countSearchByCategory(@Param("categoryName") String categoryName); + + // 추천 폴백용: 전체 배틀 대상 인기 점수순 조회 (철학자 유형 로직 미구현 시 사용) + // Score = V*1.0 + C*1.5 + Vw*0.2 + @Query("SELECT b FROM Battle b " + + "WHERE b.id NOT IN :excludeBattleIds " + + "AND b.type = 'BATTLE' " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL " + + "ORDER BY (b.totalParticipantsCount * 1.0 + b.commentCount * 1.5 + b.viewCount * 0.2) DESC") + List findPopularBattlesExcluding( + @Param("excludeBattleIds") List excludeBattleIds, + Pageable pageable); + + // 추천용: 특정 유저들이 참여한 배틀 중 이미 참여한 배틀 제외하고 인기 점수순 조회 + // Score = V*1.0 + C*1.5 + Vw*0.2 (R은 추후 반영 예정) + @Query("SELECT b FROM Battle b " + + "WHERE b.id IN :candidateBattleIds " + + "AND b.id NOT IN :excludeBattleIds " + + "AND b.type = 'BATTLE' " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL " + + "ORDER BY (b.totalParticipantsCount * 1.0 + b.commentCount * 1.5 + b.viewCount * 0.2) DESC") + List findRecommendedBattles( + @Param("candidateBattleIds") List candidateBattleIds, + @Param("excludeBattleIds") List excludeBattleIds, + Pageable pageable); +} \ No newline at end of file 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 0000000..a455916 --- /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/BattleQueryService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleQueryService.java new file mode 100644 index 0000000..0cb8ef5 --- /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 0000000..baf96eb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java @@ -0,0 +1,67 @@ +package com.swyp.picke.domain.battle.service; + +import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.battle.dto.response.*; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +import com.swyp.picke.domain.battle.enums.BattleType; +import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; + +import java.util.List; + +public interface BattleService { + + // === [내부 공통/조회 메서드] === + Battle findById(Long battleId); + BattleOption findOptionById(Long optionId); + BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabel label); + + // === [사용자용 - 홈 화면 5단 로직 지원 API] === + + // 1. 에디터 픽 조회 (isEditorPick = true) + List getEditorPicks(int limit); + + // 2. 지금 뜨는 배틀 조회 (최근 24시간 투표 급증순) + List getTrendingBattles(int limit); + + // 3. Best 배틀 조회 (누적 지표 랭킹) + List getBestBattles(int limit); + + // 4. 오늘의 Pické 조회 (단일 타입 매칭) + List getTodayPicks(BattleType type, int limit); + + // 5. 새로운 배틀 조회 (중복 제외 리스트) + List getNewBattles(List excludeIds, int limit); + + + // === [사용자용 - 기본 API] === + + // 전체 배틀 목록 페이징 조회 + BattleListResponse getBattles(int page, int size, String type); + + // 오늘의 배틀 (기존 로직 유지용) + TodayBattleListResponse getTodayBattles(); + + // 배틀 상세 정보 + BattleUserDetailResponse getBattleDetail(Long battleId); + + // 투표 실행 및 실시간 통계 결과 반환 + BattleVoteResponse vote(Long battleId, Long optionId); + + BattleScenarioResponse getBattleScenario(Long battleId); + + UserBattleStatusResponse getUserBattleStatus(Long battleId); + + // === [관리자용 API] === + + // 배틀 생성 + AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId); + + // 배틀 수정 + AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request); + + // 배틀 삭제 (DB에서 지우지 않고 소프트 딜리트/상태변경을 수행합니다) + AdminBattleDeleteResponse deleteBattle(Long battleId); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java new file mode 100644 index 0000000..5956d71 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -0,0 +1,381 @@ +package com.swyp.picke.domain.battle.service; + +import com.swyp.picke.domain.battle.converter.BattleConverter; +import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.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.battle.enums.BattleType; +import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +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.repository.TagRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.VoteSide; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.service.UserBattleService; +import com.swyp.picke.domain.vote.entity.Vote; +import com.swyp.picke.domain.vote.repository.VoteRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +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.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BattleServiceImpl implements BattleService { + + private final BattleRepository battleRepository; + private final BattleOptionRepository battleOptionRepository; + private final BattleTagRepository battleTagRepository; + private final BattleOptionTagRepository battleOptionTagRepository; + private final TagRepository tagRepository; + private final UserRepository userRepository; + private final VoteRepository voteRepository; + private final BattleConverter battleConverter; + private final S3UploadService s3UploadService; + 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(int limit) { + List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, BattleType.BATTLE, PageRequest.of(0, limit)); + return convertToTodayResponses(battles); + } + + @Override + public List getTrendingBattles(int limit) { + LocalDateTime yesterday = LocalDateTime.now().minusDays(1); + List battles = battleRepository.findTrendingBattles(yesterday, BattleType.BATTLE, PageRequest.of(0, limit)); + return convertToTodayResponses(battles); + } + + @Override + public List getBestBattles(int limit) { + List battles = battleRepository.findBestBattles(BattleType.BATTLE, PageRequest.of(0, limit)); + return convertToTodayResponses(battles); + } + + @Override + public List getTodayPicks(BattleType type, int limit) { + List battles = battleRepository.findTodayPicks(type, LocalDate.now(), PageRequest.of(0, limit)); + return convertToTodayResponses(battles); + } + + @Override + public List getNewBattles(List excludeIds, int limit) { + List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) + ? List.of(-1L) : excludeIds; + List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, BattleType.BATTLE, PageRequest.of(0, limit)); + return convertToTodayResponses(battles); + } + + @Override + public BattleListResponse getBattles(int page, int size, String type) { + int pageNumber = Math.max(0, page - 1); + PageRequest pageRequest = PageRequest.of(pageNumber, size); + Page battlePage; + + if (type == null || type.equals("ALL")) { + battlePage = battleRepository.findByDeletedAtIsNullOrderByCreatedAtDesc(pageRequest); + } else { + battlePage = battleRepository.findByTypeAndDeletedAtIsNullOrderByCreatedAtDesc( + BattleType.valueOf(type), pageRequest); + } + + List items = battlePage.getContent().stream() + .map(battleConverter::toSimpleResponse) + .toList(); + + return new BattleListResponse( + items, + battlePage.getNumber() + 1, + battlePage.getTotalPages(), + battlePage.getTotalElements() + ); + } + + @Override + public TodayBattleListResponse getTodayBattles() { + List battles = battleRepository.findByTargetDateAndStatusAndTypeAndDeletedAtIsNull( + LocalDate.now(), BattleStatus.PUBLISHED, BattleType.BATTLE); + + List limitedBattles = battles.stream() + .limit(5) + .collect(Collectors.toList()); + + List items = convertToTodayResponses(limitedBattles); + + return new TodayBattleListResponse(items, items.size()); + } + + @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 = voteRepository.findByBattleIdAndUserIdWithOption(battleId, currentUserId); + VoteSide voteStatus = optionalVote + .map(vote -> { + if (vote.getPostVoteOption() != null) { + return vote.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 vote(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)); + + voteRepository.save(Vote.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)); + + Battle battle = battleRepository.save(battleConverter.toEntity(request, admin)); + + if (request.tagIds() != null) { + saveBattleTags(battle, request.tagIds().stream().distinct().toList()); + } + + List savedOptions = new ArrayList<>(); + for (var optionRequest : request.options()) { + BattleOption option = battleOptionRepository.save(BattleOption.builder() + .battle(battle) + .label(optionRequest.label()) + .title(optionRequest.title()) + .stance(optionRequest.stance()) + .representative(optionRequest.representative()) + .quote(optionRequest.quote()) + .imageUrl(optionRequest.imageUrl()) + .isCorrect(optionRequest.isCorrect()) + .build()); + + if (optionRequest.tagIds() != null) { + saveBattleOptionTags(option, optionRequest.tagIds().stream().distinct().toList()); + } + 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 + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request) { + Battle battle = findById(battleId); + + if (battle.getThumbnailUrl() != null && !battle.getThumbnailUrl().equals(request.thumbnailUrl())) { + s3UploadService.deleteFile(battle.getThumbnailUrl()); + } + + battle.update( + request.title(), request.titlePrefix(), request.titleSuffix(), + request.itemA(), request.itemADesc(), request.itemB(), request.itemBDesc(), + request.summary(), request.description(), request.thumbnailUrl(), + request.targetDate(), request.audioDuration(), request.status() + ); + + 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); + for (var optionRequest : request.options()) { + existingOptions.stream() + .filter(option -> option.getLabel() == optionRequest.label()) + .findFirst() + .ifPresent(option -> { + if (option.getImageUrl() != null && !option.getImageUrl().equals(optionRequest.imageUrl())) { + s3UploadService.deleteFile(option.getImageUrl()); + } + option.update(optionRequest.title(), optionRequest.stance(), + optionRequest.representative(), optionRequest.quote(), optionRequest.imageUrl(), optionRequest.isCorrect()); + }); + } + } + + 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) + .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) + .forEach(tag -> battleOptionTagRepository.save( + BattleOptionTag.builder().battleOption(option).tag(tag).build())); + } + + @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)); + } +} \ No newline at end of file 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 0000000..2cfddac --- /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 0000000..b9c6d6b --- /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 0000000..efdc9bf --- /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 0000000..a8c6d59 --- /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 0000000..a63f331 --- /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 0000000..85eb079 --- /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 0000000..510c59d --- /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 0000000..0f8ea05 --- /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 0000000..a8066b3 --- /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 0000000..6aa4f55 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/home/service/HomeService.java @@ -0,0 +1,185 @@ +package com.swyp.picke.domain.home.service; + +import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; +import com.swyp.picke.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.picke.domain.battle.dto.response.TodayOptionResponse; +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +import com.swyp.picke.domain.battle.enums.BattleType; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.home.dto.response.*; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HomeService { + + private final BattleService battleService; + 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); + } + // DB 쿼리 단계에서 LIMIT을 걸어 필요한 개수만 깔끔하게 조회! + List editorPickRaw = battleService.getEditorPicks(10); + List trendingRaw = battleService.getTrendingBattles(4); + List bestRaw = battleService.getBestBattles(3); + List voteRaw = battleService.getTodayPicks(BattleType.VOTE, 1); + List quizRaw = battleService.getTodayPicks(BattleType.QUIZ, 1); + + List excludeIds = collectBattleIds(editorPickRaw, trendingRaw, bestRaw, voteRaw, quizRaw); + List newRaw = battleService.getNewBattles(excludeIds, 3); + + 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(), + voteRaw.stream().map(this::toTodayVote).toList(), + newRaw.stream().map(this::toNewBattle).toList() + ); + } + + // 에디터픽 썸네일 Presigned URL 적용 + private HomeEditorPickResponse toEditorPick(TodayBattleResponse b) { + String optionA = findOptionTitle(b.options(), BattleOptionLabel.A); + String optionB = findOptionTitle(b.options(), BattleOptionLabel.B); + + String secureThumb = b.thumbnailUrl(); + + return new HomeEditorPickResponse( + b.battleId(), secureThumb, + optionA, optionB, + b.title(), b.summary(), + b.tags(), b.viewCount() + ); + } + + private HomeTrendingResponse toTrending(TodayBattleResponse b) { + return new HomeTrendingResponse( + b.battleId(), b.thumbnailUrl(), + b.title(), b.tags(), + b.audioDuration(), b.viewCount() + ); + } + + private HomeBestBattleResponse toBestBattle(TodayBattleResponse b) { + String philoA = findOptionRepresentative(b.options(), BattleOptionLabel.A); + String philoB = findOptionRepresentative(b.options(), BattleOptionLabel.B); + + return new HomeBestBattleResponse( + b.battleId(), + philoA, philoB, + b.title(), b.tags(), + b.audioDuration(), b.viewCount() + ); + } + + private HomeTodayQuizResponse toTodayQuiz(TodayBattleResponse b) { + return new HomeTodayQuizResponse( + b.battleId(), b.title(), b.summary(), + b.participantsCount(), + b.itemA(), b.itemADesc(), + findOptionIsCorrect(b.options(), BattleOptionLabel.A), + b.itemB(), b.itemBDesc(), + findOptionIsCorrect(b.options(), BattleOptionLabel.B) + ); + } + + private HomeTodayVoteResponse toTodayVote(TodayBattleResponse b) { + List options = Optional.ofNullable(b.options()).orElse(List.of()).stream() + .map(o -> new HomeTodayVoteOptionResponse(o.label(), o.title())) + .toList(); + return new HomeTodayVoteResponse( + b.battleId(), + b.titlePrefix(), b.titleSuffix(), + b.summary(), b.participantsCount(), + options + ); + } + + // newBattle 썸네일 Presigned URL 적용 + private HomeNewBattleResponse toNewBattle(TodayBattleResponse b) { + String philoA = findOptionRepresentative(b.options(), BattleOptionLabel.A); + String philoB = findOptionRepresentative(b.options(), BattleOptionLabel.B); + + String optionA = findOptionTitle(b.options(), BattleOptionLabel.A); + String optionB = findOptionTitle(b.options(), BattleOptionLabel.B); + + String imageA = findRepresentativeImageUrl(b.options(), BattleOptionLabel.A); + String imageB = findRepresentativeImageUrl(b.options(), BattleOptionLabel.B); + + return new HomeNewBattleResponse( + b.battleId(), b.thumbnailUrl(), + b.title(), b.summary(), + philoA, optionA, imageA, + philoB, optionB, imageB, + b.tags(), b.audioDuration(), b.viewCount() + ); + } + + private Boolean findOptionIsCorrect(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(o -> o.label() == label) + .map(TodayOptionResponse::isCorrect) + .findFirst() + .map(Boolean.TRUE::equals) + .orElse(false); + } + + private String findOptionTitle(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(o -> o.label() == label) + .map(TodayOptionResponse::title) + .filter(Objects::nonNull) + .findFirst().orElse(null); + } + + // 옵션에서 철학자 이름(Representative)을 추출하는 메서드 + private String findOptionRepresentative(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(o -> o.label() == label) + .map(TodayOptionResponse::representative) + .filter(Objects::nonNull) + .findFirst().orElse(null); + } + + private List findPhilosopherNames(List tags) { + return Optional.ofNullable(tags).orElse(List.of()).stream() + .filter(t -> t.type() == TagType.PHILOSOPHER) + .map(BattleTagResponse::name) + .toList(); + } + + private String findRepresentativeImageUrl(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(o -> o.label() == label) + .map(TodayOptionResponse::imageUrl) + .filter(Objects::nonNull) + .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 0000000..2cb20df --- /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 0000000..3dbbe82 --- /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 0000000..bab5de2 --- /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 0000000..f03e052 --- /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 0000000..a94c16c --- /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 0000000..92d72ab --- /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 0000000..d242556 --- /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 0000000..630e728 --- /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 0000000..6104d5c --- /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 0000000..9165eb5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,48 @@ +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); +} 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 0000000..93b730f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java @@ -0,0 +1,183 @@ +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) { + Notification notification = Notification.builder() + .user(null) + .category(detailCode.getCategory()) + .detailCode(detailCode) + .title(detailCode.getDefaultTitle()) + .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 0000000..6de6acb --- /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 0000000..3abcb55 --- /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 0000000..b715050 --- /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 = "인증 (Auth)", description = "인증 API") +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 0000000..ae83067 --- /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 0000000..a9872d2 --- /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 0000000..3ed43b7 --- /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 0000000..35f75fe --- /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 0000000..176ff38 --- /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 0000000..024f8a9 --- /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 0000000..b5438c1 --- /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 0000000..104257b --- /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 0000000..2002817 --- /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 0000000..30d74de --- /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 0000000..afe8ee3 --- /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 0000000..23cfed6 --- /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 0000000..0f3477e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -0,0 +1,129 @@ +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", // 공유 링크 리다이렉트 + "/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); + } +} \ No newline at end of file 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 0000000..58d2749 --- /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 0000000..acc24e9 --- /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 0000000..e1a75c9 --- /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 0000000..97d1ffc --- /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/CommentLikeController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java new file mode 100644 index 0000000..7654153 --- /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 = "댓글 좋아요 (Comment Like)", description = "댓글 좋아요 등록, 취소 API") +@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 0000000..d702d8a --- /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 = "관점 댓글 (Comment)", description = "관점 댓글 생성, 조회, 수정, 삭제 API") +@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 0000000..545f814 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java @@ -0,0 +1,102 @@ +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 = "관점 (Perspective)", description = "관점 생성, 조회, 수정, 삭제 API") +@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)); + } + + // TODO: Prevote 의 여부를 Vote 도메인 개발 이후 교체 + @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 = "특정 배틀의 관점 목록을 커서 기반 페이지네이션으로 조회합니다. optionLabel(A/B)로 필터링, sort(latest/popular)로 정렬 가능합니다.") + @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 = "특정 배틀에서 내가 작성한 관점을 조회합니다. 상태(PENDING/PUBLISHED/REJECTED 등)와 무관하게 반환하며, 작성한 관점이 없으면 404를 반환합니다.") + @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 = "검수 실패(MODERATION_FAILED) 상태의 관점에 대해 GPT 검수를 다시 요청합니다.") + @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 0000000..75a6a1b --- /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 = "관점 좋아요 (Like)", description = "관점 좋아요 조회, 등록, 취소 API") +@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 0000000..438cc00 --- /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 = "신고 (Report)", description = "관점/댓글 신고 API") +@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 0000000..85d7397 --- /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 0000000..f578a75 --- /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 0000000..32ed5e1 --- /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 0000000..d3a35e2 --- /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 0000000..bdf1de6 --- /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 0000000..bc68b97 --- /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 0000000..5c8f541 --- /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 0000000..6310f7b --- /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 0000000..46d52dd --- /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 0000000..2ae271e --- /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 0000000..7a5c5f4 --- /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 0000000..fa84424 --- /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 0000000..8bc16ff --- /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 0000000..d2be1b9 --- /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 0000000..ec86265 --- /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 0000000..e8101c9 --- /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 0000000..4456b0a --- /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 0000000..0f71d91 --- /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 0000000..2fab3c8 --- /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 0000000..e5fd1ba --- /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 0000000..31a261d --- /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 0000000..5d866fb --- /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 0000000..988183b --- /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 0000000..0f0e0d3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveCommentRepository.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.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); +} 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 0000000..4dd3b20 --- /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 0000000..92692db --- /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 0000000..c2a683e --- /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/service/CommentLikeService.java b/src/main/java/com/swyp/picke/domain/perspective/service/CommentLikeService.java new file mode 100644 index 0000000..e1d49e3 --- /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 0000000..e639fdc --- /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 0000000..ac22570 --- /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.VoteService; +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 VoteService voteService; + 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 = voteService.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 = voteService.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 = voteService.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)); + } +} 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 0000000..85e51d6 --- /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 0000000..e1defc9 --- /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 0000000..e366aa6 --- /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.VoteService; +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 VoteService voteService; + 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 = voteService.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)); + } +} 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 0000000..604614e --- /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/recommendation/controller/RecommendationController.java b/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java new file mode 100644 index 0000000..c05a07c --- /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 = "추천 (Recommendation)", description = "배틀 추천 API") +@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 0000000..b62c405 --- /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 0000000..1a37f32 --- /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.VoteRepository; +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 VoteRepository voteRepository; + 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 = voteRepository.findParticipatedBattleIdsByUserId(userId); + if (excludeBattleIds.isEmpty()) excludeBattleIds = List.of(-1L); + + List sameTypeUserIds = findUserIdsByPhilosopherType(myType); + List oppositeTypeUserIds = findUserIdsByPhilosopherType(oppositeType); + + // 같은 유형 유저들이 참여한 배틀 후보 ID + List sameCandidateIds = sameTypeUserIds.isEmpty() + ? List.of() + : voteRepository.findParticipatedBattleIdsByUserIds(sameTypeUserIds); + + // 반대 유형 유저들이 참여한 배틀 후보 ID + List oppositeCandidateIds = oppositeTypeUserIds.isEmpty() + ? List.of() + : voteRepository.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(); + } +} 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 0000000..723be0d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java @@ -0,0 +1,42 @@ +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.springdoc.core.annotations.ParameterObject; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "보상 (Reward)", description = "AdMob 광고 보상 관련 API") +@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 0000000..af4b871 --- /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 0000000..8cb639f --- /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 0000000..fb3c23b --- /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 0000000..6760544 --- /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 0000000..90406c7 --- /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 0000000..a97ac62 --- /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 0000000..6f8b993 --- /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 0000000..6ac63f3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/controller/ScenarioController.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.scenario.controller; + +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.scenario.dto.request.ScenarioCreateRequest; +import com.swyp.picke.domain.scenario.dto.request.ScenarioStatusUpdateRequest; +import com.swyp.picke.domain.scenario.dto.response.AdminDeleteResponse; +import com.swyp.picke.domain.scenario.dto.response.AdminScenarioDetailResponse; +import com.swyp.picke.domain.scenario.dto.response.AdminScenarioResponse; +import com.swyp.picke.domain.scenario.dto.response.UserScenarioResponse; +import com.swyp.picke.domain.scenario.service.ScenarioService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Tag(name = "시나리오 (Scenario)", description = "시나리오 API") +@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 + ) { + // 1. 배틀 데이터 조회 (제목, 철학자 리스트) + var battleInfo = battleService.getBattleScenario(battleId); + + // 2. 시나리오 데이터 조회 (노드, 대사, 오디오 등) + var scenarioInfo = scenarioService.getScenarioForUser(battleId, userId); + + // 3. UserScenarioResponse 최상단에 바로 값 세팅 + UserScenarioResponse response = scenarioInfo.toBuilder() + .title(battleInfo.title()) + .philosophers(battleInfo.philosophers()) + .build(); + + return ApiResponse.onSuccess(response); + } + + @Operation(summary = "관리자용 배틀 시나리오 조회 (수정용)") + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/admin/battles/{battleId}/scenario") + public ApiResponse getAdminBattleScenario( + @PathVariable Long battleId) { + return ApiResponse.onSuccess(scenarioService.getScenarioForAdmin(battleId)); + } + + @Operation(summary = "시나리오 생성") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/admin/scenarios") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse> createScenario( + @RequestBody ScenarioCreateRequest request) { + + Long scenarioId = scenarioService.createScenario(request); + + // Map.of 대신 null에도 안전한 HashMap 사용 + Map response = new java.util.HashMap<>(); + response.put("scenarioId", scenarioId); + + // 고정값 대신 프론트에서 보낸 상태값(PENDING 등)을 그대로 반환! + response.put("status", request.status()); + + return ApiResponse.onSuccess(response); + } + + @Operation(summary = "시나리오 내용 수정") + @PreAuthorize("hasRole('ADMIN')") + @PutMapping("/admin/scenarios/{scenarioId}") + public ApiResponse updateScenarioContent( + @PathVariable Long scenarioId, + @RequestBody ScenarioCreateRequest request) { + + scenarioService.updateScenarioContent(scenarioId, request); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "시나리오 상태 수정 (PUBLISHED 변경 시 자동 오디오 처리)") + @PreAuthorize("hasRole('ADMIN')") + @PatchMapping("/admin/scenarios/{scenarioId}") + public ApiResponse updateScenarioStatus( + @PathVariable Long scenarioId, + @RequestBody ScenarioStatusUpdateRequest request) { + + return ApiResponse.onSuccess(scenarioService.updateScenarioStatus(scenarioId, request.status())); + } + + @Operation(summary = "시나리오 삭제 (Soft Delete)") + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/admin/scenarios/{scenarioId}") + public ApiResponse deleteScenario( + @PathVariable Long scenarioId) { + + return ApiResponse.onSuccess(scenarioService.deleteScenario(scenarioId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java new file mode 100644 index 0000000..53213bd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java @@ -0,0 +1,137 @@ +package com.swyp.picke.domain.scenario.converter; + +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.beans.factory.annotation.Value; +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; + private static final String BASE_SHARE_URL = "https://pique.app/battles/"; + + /** + * [유저용] Scenario 엔티티를 프론트엔드 전달용 DTO로 변환합니다. + */ + 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()) + .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::toOptionResponse) + .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 NodeResponse toAdminNodeResponse(ScenarioNode node) { + return NodeResponse.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::toOptionResponse) + .collect(Collectors.toList())) + .build(); + } + + private ScriptResponse toAdminScriptResponse(Script script) { + return ScriptResponse.builder() + .scriptId(script.getId()) + .startTimeMs(script.getStartTimeMs()) + .speakerType(script.getSpeakerType()) + .speakerName(script.getSpeakerName()) + .text(script.getText()) + .build(); + } + + private OptionResponse toOptionResponse(InteractiveOption option) { + return OptionResponse.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 0000000..0029cb5 --- /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 0000000..bb95c11 --- /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 0000000..ff1d74d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScenarioCreateRequest.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.scenario.dto.request; + +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; +import java.util.List; + +public record ScenarioCreateRequest( + Long battleId, + Boolean isInteractive, + ScenarioStatus status, + List nodes +) {} \ 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 0000000..9e1d388 --- /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 0000000..3698436 --- /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 0000000..6112ed8 --- /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 0000000..f457986 --- /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 0000000..cf67556 --- /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 0000000..7a26bb3 --- /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 0000000..e5e8c4b --- /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 +) {} \ No newline at end of file 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 0000000..7833ed2 --- /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 0000000..4129a44 --- /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 0000000..0b81480 --- /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 0000000..a30d9ab --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/Scenario.java @@ -0,0 +1,71 @@ +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.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); + + @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(); + } +} \ No newline at end of file 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 0000000..997e876 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/ScenarioNode.java @@ -0,0 +1,79 @@ +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; + + @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/components/form-battle.html b/src/main/resources/templates/admin/components/form-battle.html new file mode 100644 index 0000000..b98e850 --- /dev/null +++ b/src/main/resources/templates/admin/components/form-battle.html @@ -0,0 +1,210 @@ +
+ +
+
+

1 기본 정보

+ BASIC INFO +
+ +
+
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+

2 대결 설정

+ CHARACTERS +
+ +
+ + +
+

등장인물 A

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

등장인물 B

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

3 대본 설정

+ SCRIPT BUILDER +
+ +
+
+
+

🎬 오프닝

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

🎬 클로징

+ + +
+ +
+
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/admin/components/form-quiz.html b/src/main/resources/templates/admin/components/form-quiz.html new file mode 100644 index 0000000..36f1f07 --- /dev/null +++ b/src/main/resources/templates/admin/components/form-quiz.html @@ -0,0 +1,89 @@ +
+
+
+

1 퀴즈 등록

+ QUIZ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/admin/components/form-vote.html b/src/main/resources/templates/admin/components/form-vote.html new file mode 100644 index 0000000..0a074c7 --- /dev/null +++ b/src/main/resources/templates/admin/components/form-vote.html @@ -0,0 +1,54 @@ +
+
+
+

1 투표 등록

+ VOTE +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ + +
+
+ 1 + +
+
+ 2 + +
+
+ 3 + +
+
+ 4 + +
+
+
+
+
+
\ No newline at end of file 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 0000000..88e2691 --- /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 0000000..ec99dbe --- /dev/null +++ b/src/main/resources/templates/admin/fragments/header.html @@ -0,0 +1,8 @@ +
+
+ Pické + Admin +
+ +
ADMIN
+
\ No newline at end of file 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 0000000..0cac253 --- /dev/null +++ b/src/main/resources/templates/admin/fragments/preview.html @@ -0,0 +1,202 @@ +
+ +
+ 실시간 미리보기 + BRANCH MODE +
+ +
+
+ +
+ 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 0000000..0941766 --- /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 0000000..012960e --- /dev/null +++ b/src/main/resources/templates/admin/picke-list.html @@ -0,0 +1,251 @@ + + + + + + Pické Admin - 콘텐츠 관리 + + + + + + + +
+ +
+
+
+

콘텐츠 관리

+

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

+
+ +
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + +
ID유형콘텐츠 제목상태등록일관리
+
+
+ 데이터를 불러오는 중... +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/share/result.html b/src/main/resources/templates/share/result.html new file mode 100644 index 0000000..b75c451 --- /dev/null +++ b/src/main/resources/templates/share/result.html @@ -0,0 +1,92 @@ + + + + + + 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 d55ce25..0000000 --- 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 0000000..622891a --- /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/home/service/HomeServiceTest.java b/src/test/java/com/swyp/picke/domain/home/service/HomeServiceTest.java new file mode 100644 index 0000000..c4c2f04 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/home/service/HomeServiceTest.java @@ -0,0 +1,197 @@ +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.enums.BattleType; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.home.dto.response.*; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.service.NotificationService; +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 java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static com.swyp.picke.domain.battle.enums.BattleType.BATTLE; +import static com.swyp.picke.domain.battle.enums.BattleType.QUIZ; +import static com.swyp.picke.domain.battle.enums.BattleType.VOTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HomeServiceTest { + + @Mock + private BattleService battleService; + @Mock + private NotificationService notificationService; + @Mock + private S3PresignedUrlService s3PresignedUrlService; + + @InjectMocks + private HomeService homeService; + + private final AtomicLong idGenerator = new AtomicLong(1L); + + private Long generateId() { + return idGenerator.getAndIncrement(); + } + + @Test + @DisplayName("명세기준으로 섹션별 데이터를 조합한다") + void getHome_aggregates_sections_by_spec() { + Long userId = 1L; + TodayBattleResponse editorPick = battle("editor-id", BATTLE); + TodayBattleResponse trendingBattle = battle("trending-id", BATTLE); + TodayBattleResponse bestBattle = battle("best-id", BATTLE); + TodayBattleResponse todayVote = vote("vote-id"); + TodayBattleResponse todayQuiz = quiz("quiz-id"); + TodayBattleResponse newBattle = battle("new-id", BATTLE); + + when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(true); + when(battleService.getEditorPicks(10)).thenReturn(List.of(editorPick)); + when(battleService.getTrendingBattles(4)).thenReturn(List.of(trendingBattle)); + when(battleService.getBestBattles(3)).thenReturn(List.of(bestBattle)); + when(battleService.getTodayPicks(VOTE, 1)).thenReturn(List.of(todayVote)); + when(battleService.getTodayPicks(QUIZ, 1)).thenReturn(List.of(todayQuiz)); + + when(battleService.getNewBattles(List.of( + editorPick.battleId(), + trendingBattle.battleId(), + bestBattle.battleId(), + todayVote.battleId(), + todayQuiz.battleId() + ), 3)).thenReturn(List.of(newBattle)); + + var response = homeService.getHome(userId); + + assertThat(response.newNotice()).isTrue(); + assertThat(response.editorPicks()).extracting(HomeEditorPickResponse::title).containsExactly("editor-id"); + assertThat(response.trendingBattles()).extracting(HomeTrendingResponse::title).containsExactly("trending-id"); + assertThat(response.bestBattles()).extracting(HomeBestBattleResponse::title).containsExactly("best-id"); + assertThat(response.todayQuizzes()).extracting(HomeTodayQuizResponse::title).containsExactly("quiz-id"); + assertThat(response.todayVotes()).hasSize(1); + assertThat(response.todayVotes().get(0).titlePrefix()).isEqualTo("도덕의 기준은"); + assertThat(response.todayVotes().get(0).options()).extracting(HomeTodayVoteOptionResponse::title) + .containsExactly("결과", "의도", "규칙", "덕"); + assertThat(response.todayQuizzes().get(0).itemA()).isEqualTo("정답"); + assertThat(response.newBattles()).extracting(HomeNewBattleResponse::title).containsExactly("new-id"); + assertThat(response.newBattles().getFirst().optionATitle()).isEqualTo("A"); + assertThat(response.newBattles().getFirst().optionBTitle()).isEqualTo("B"); + + verify(battleService).getNewBattles(argThat(ids -> ids.equals(List.of( + editorPick.battleId(), + trendingBattle.battleId(), + bestBattle.battleId(), + todayVote.battleId(), + todayQuiz.battleId() + ))), eq(3)); + } + + @Test + @DisplayName("데이터가 없으면 false와 빈리스트를 반환한다") + void getHome_returns_false_and_empty_lists_when_no_data() { + Long userId = 1L; + + when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(false); + when(battleService.getEditorPicks(10)).thenReturn(List.of()); + when(battleService.getTrendingBattles(4)).thenReturn(List.of()); + when(battleService.getBestBattles(3)).thenReturn(List.of()); + when(battleService.getTodayPicks(VOTE, 1)).thenReturn(List.of()); + when(battleService.getTodayPicks(QUIZ, 1)).thenReturn(List.of()); + when(battleService.getNewBattles(List.of(), 3)).thenReturn(List.of()); + + 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(); + } + + @Test + @DisplayName("에디터픽만 있을때 제외목록이 정확하다") + void getHome_excludes_only_editor_pick_ids() { + Long userId = 1L; + TodayBattleResponse editorPick = battle("editor-only", BATTLE); + + when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(false); + when(battleService.getEditorPicks(10)).thenReturn(List.of(editorPick)); + when(battleService.getTrendingBattles(4)).thenReturn(List.of()); + when(battleService.getBestBattles(3)).thenReturn(List.of()); + when(battleService.getTodayPicks(VOTE, 1)).thenReturn(List.of()); + when(battleService.getTodayPicks(QUIZ, 1)).thenReturn(List.of()); + when(battleService.getNewBattles(List.of(editorPick.battleId()), 3)).thenReturn(List.of()); + + homeService.getHome(userId); + + verify(battleService).getNewBattles(List.of(editorPick.battleId()), 3); + } + + @Test + @DisplayName("공지 브로드캐스트가 있으면 newNotice는 true이다") + void getHome_newNotice_true_with_broadcast() { + Long userId = 1L; + when(notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE)).thenReturn(true); + when(battleService.getEditorPicks(10)).thenReturn(List.of()); + when(battleService.getTrendingBattles(4)).thenReturn(List.of()); + when(battleService.getBestBattles(3)).thenReturn(List.of()); + when(battleService.getTodayPicks(VOTE, 1)).thenReturn(List.of()); + when(battleService.getTodayPicks(QUIZ, 1)).thenReturn(List.of()); + when(battleService.getNewBattles(List.of(), 3)).thenReturn(List.of()); + + var response = homeService.getHome(userId); + + assertThat(response.newNotice()).isTrue(); + } + + private TodayBattleResponse battle(String title, BattleType type) { + return new TodayBattleResponse( + generateId(), title, "summary", "thumbnail", type, + 10, 20L, 90, + List.of(), + List.of( + new TodayOptionResponse(generateId(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a", null), + new TodayOptionResponse(generateId(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b", null) + ), + null, null, null, null, null, null + ); + } + + private TodayBattleResponse quiz(String title) { + return new TodayBattleResponse( + generateId(), title, "summary", "thumbnail", QUIZ, + 30, 40L, 60, + List.of(), + List.of(), + null, null, "정답", "정답 설명", "오답", "오답 설명" + ); + } + + private TodayBattleResponse vote(String title) { + return new TodayBattleResponse( + generateId(), title, "summary", "thumbnail", VOTE, + 50, 60L, 0, + List.of(), + List.of( + new TodayOptionResponse(generateId(), BattleOptionLabel.A, "결과", null, null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.B, "의도", null, null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.C, "규칙", null, null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.D, "덕", null, null, null, null) + ), + "도덕의 기준은", "이다", null, null, null, null + ); + } +} 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 0000000..d952b47 --- /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 0000000..ed392bc --- /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 0000000..88acd72 --- /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.AD_REWARD), 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" + ); + } +} \ No newline at end of file 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 0000000..52796a9 --- /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 0000000..e3cb097 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java @@ -0,0 +1,116 @@ +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.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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CreditServiceTest { + + @Mock + private CreditHistoryRepository creditHistoryRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserService userService; + + @InjectMocks + private CreditService creditService; + + @Test + @DisplayName("현재 로그인 유저에게 기본 크레딧을 적립한다") + void addCredit_forCurrentUser_savesDefaultAmount() { + User user = org.mockito.Mockito.mock(User.class); + when(user.getId()).thenReturn(1L); + when(userService.findCurrentUser()).thenReturn(user); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + 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); + } + + @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 = org.mockito.Mockito.mock(User.class); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) + .thenThrow(new DataIntegrityViolationException("duplicate")); + when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L)) + .thenReturn(true); + + creditService.addCredit(1L, CreditType.BATTLE_VOTE, 10, 10L); + + verify(creditHistoryRepository).existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L); + } + + @Test + @DisplayName("중복이 아닌 데이터 무결성 오류는 그대로 던진다") + void addCredit_nonDuplicateIntegrityFailure_rethrows() { + User user = org.mockito.Mockito.mock(User.class); + 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); + } + + @Test + @DisplayName("누적 포인트로 티어를 계산한다") + void getTier_returnsTierFromTotalPoints() { + when(creditHistoryRepository.sumAmountByUserId(eq(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 0000000..ffc644c --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java @@ -0,0 +1,392 @@ +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.enums.BattleType; +import com.swyp.picke.domain.battle.service.BattleQueryService; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveComment; +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.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.enums.ActivityType; +import com.swyp.picke.domain.user.enums.CharacterType; +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.Vote; +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.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); + Vote vote = Vote.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); + Vote vote = Vote.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"); + 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(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("알림설정을 반환한다") + 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") + .type(BattleType.BATTLE) + .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; + } +} 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 0000000..e083c64 --- /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 0000000..c740d20 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/UserServiceTest.java @@ -0,0 +1,191 @@ +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.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.math.BigDecimal; +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; + + @Test + @DisplayName("가장 최근 사용자를 반환한다") + void findCurrentUser_returns_latest_user() { + User user = createUser(1L, "testTag"); + when(userRepository.findTopByOrderByIdDesc()).thenReturn(Optional.of(user)); + + User result = userService.findCurrentUser(); + + assertThat(result.getUserTag()).isEqualTo("testTag"); + } + + @Test + @DisplayName("사용자가 없으면 예외를 던진다") + void findCurrentUser_throws_when_no_user() { + when(userRepository.findTopByOrderByIdDesc()).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); + + when(userRepository.findTopByOrderByIdDesc()).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(); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..b637bc1 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,78 @@ +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 + +media: + ffmpeg: + path: ffmpeg + ffprobe: + path: ffprobe \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ef11cb9 --- /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