diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md new file mode 100644 index 00000000..433197fb --- /dev/null +++ b/BACKEND_ARCHITECTURE.md @@ -0,0 +1,868 @@ +# 픽케 클래스 기능 백엔드 아키텍처 (Funding 구현 범위) + +> **2026-10-21 origin/dev 통합 안내**: 현재 API·스키마·발급 정책은 [FUNDING_API.md](FUNDING_API.md)를 기준으로 확인합니다. +> 아래 본문은 기존 PRD 분석과 최초 설계를 보존한 자료이며, `[구현 완료]` 등 당시 표기가 현재 코드와 다를 수 있습니다. +> 공통 주문·이용권·발급·등록·환불은 최신 `domain.ticket`/`domain.admin`으로 통합했습니다. +> 기존 `/tickets/me`, `/ticket-codes/*`, 별도 Funding 이용권 테이블과 `V20261015_01` 설계는 대체되었습니다. +> `FundingTicketAccess`, `FundingTicketPolicy`는 유지하며 기존 클래스 구현은 `ClassTicket`을 계속 참조합니다. + +> 분석 기준: 픽케 클래스 기능 PRD v1.2(37쪽), 2026-10-14 현재 UI 폴더의 PNG 12장, 현재 Server 소스 +> 결과물: 화면과 요구사항에 대응하는 Spring Boot 백엔드 설계 및 현재 API 구현 여부 +> 표기: **[존재]** 현재 코드에 경로/기능 확인, **[부분 존재]** 일반 콘텐츠 기능은 있으나 클래스 범위 기능은 없음, **[신규 제안]** 현재 코드에서 확인되지 않음 +> 요구 상태: **확정**은 PRD의 확정 요구, **구현 기본안**은 PRD가 제안한 정책, **확인 필요**는 출시 전 합의 항목 + +--- + +## 0. 전체 시스템 요약 + +### 전체 흐름 + +~~~text +Mobile App + ├─ 기존 로그인/JWT + └─ 이용권 코드 등록 및 이용권 상태 확인 (이번 구현 범위) + ↓ +User REST API (/api/v1/**) + ↓ +Controller → Service → Repository → PostgreSQL + +Admin Dashboard (현재 별도 Next.js 앱) + ├─ 펀딩 구매자·이용권 코드 운영 + └─ 클래스·멤버·결과·변경 이력 조회/내보내기 (이번 구현은 별도 Classroom 범위) + ↓ +Admin REST API (/api/v1/admin/**, ROLE_ADMIN) + ↓ +Admin Controller → 도메인 Service → Repository → PostgreSQL + +Funding Domain + ├─ 구매자/주문 등록·결제 확인 + ├─ 이용권/등록 코드 발급·전달·등록 + └─ Classroom 생성 시 이용권 유효성 조회 계약 제공 + +Classroom Domain (최소 연동만) + └─ 클래스 담당자가 소유권을 유지하며 ticket 참조와 funding 유효성 확인만 연결 +~~~ + +### 핵심 도메인 + +| 도메인 | 역할 | 현재 상태 | +|---|---|---| +| User/Auth | 로그인한 이용권 소유자·운영자 계정 및 JWT | 기존 구현 | +| Funding | 펀딩 주문, 클래스 이용권, 등록 코드, 전달 상태 | 이번 구현 대상 | +| Classroom | 이용권 연결과 생성 시 유효성 확인만 | 다른 개발자 소유, 최소 연동만 | +| Participation | 투표·논쟁·댓글·완료 상태 | 이번 범위 제외 | +| AI | AI 주제·논쟁·리포트 기능 | 구현하지 않음 | +| Admin Operations | Funding 구매자·코드 운영 | 기존 일반 관리자 API 일부만 존재; 클래스 운영은 별도 범위 | + +현재 공통 기반에는 BaseEntity, ApiResponse, PageResponse, GlobalExceptionHandler, PostgreSQL, JWT 인증과 ADMIN 권한 경로가 있습니다. 관리자 화면은 별도 Next.js 앱으로 이관된 것으로 소스 주석에서 확인됩니다. 현재 UserRole에는 ADMIN이 있고 SecurityConfig가 /api/v1/admin/**를 ADMIN 전용으로 보호합니다. + +일반 배틀·투표·관점·댓글 API는 기존 자산으로 기록합니다. 이번 Funding 구현은 이를 수정하거나 클래스 활동 데이터로 확장하지 않습니다. + +### 인증과 계정 경계 + +- Funding 사용자: 기존 Bearer Access Token으로 이용권 조회·등록을 수행합니다. +- Funding 관리자: 기존 ADMIN 권한으로 구매자/주문 등록과 코드 발급·전달 상태를 관리합니다. +- 클래스 게스트/멤버 인증 및 클래스 운영자 권한은 별도 Classroom 구현 범위입니다. 이번에는 해당 인증 구조를 추가하지 않습니다. +- 텀블벅 결제는 앱에서 처리하지 않습니다. 펀딩 결제 확인과 리워드 코드는 관리자 운영 흐름으로 처리합니다. + +--- + +## 1. Spring Boot 패키지 구조 + +현재 프로젝트의 domain별 패키지 구조와 Controller / Service / Repository / Entity / DTO 분리를 따릅니다. 이번 문서의 구현 범위는 Funding 도메인과 Classroom 최소 연동 계약입니다. + +펀딩 주문에서 클래스 이용권(`Ticket`)과 등록 코드(`TicketCode`)를 발급하므로 주 도메인 패키지는 `domain.funding`로 둡니다. 클래스 쪽에는 Funding 연동 계약만 `domain.classroom`에 둡니다. API와 DB도 `/tickets`, `/ticket-codes`, `funding_tickets`, `funding_ticket_codes`로 명칭을 맞춥니다. + +~~~text +domain/ + ├─ funding/ # 이번 구현 대상: 주문, 이용권, 코드, 전달 이력 + │ ├─ controller/ + │ ├─ service/ + │ ├─ repository/ + │ ├─ entity/ + │ ├─ dto/request/ + │ ├─ dto/response/ + │ └─ enums/ + │ + ├─ classroom/ # 별도 개발 영역; Funding 유효성 계약만 추가 + │ ├─ FundingTicketPolicy.java + │ └─ FundingTicketAccess.java + │ + ├─ user/ # 기존 User, 프로필, 사용자 기록 + ├─ oauth/ # 기존 로그인/JWT + ├─ battle/ # 기존 배틀 콘텐츠, 클래스에서는 읽기 전용 참조 + ├─ vote/ # 기존 일반 배틀 투표, 클래스 투표는 분리 + ├─ perspective/ # 기존 일반 관점/댓글, 클래스 댓글은 분리 + └─ admin/ # 기존 사용자·대시보드 등 일반 관리자 API + +global/ + ├─ common/BaseEntity.java # 기존 공통 필드 사용 + ├─ common/response/ # 기존 ApiResponse, PageResponse 사용 + ├─ common/exception/ # 공통 예외 구조 재사용 + └─ config/SecurityConfig.java # /api/v1/admin/** 기존 ADMIN 보호 규칙 사용 +~~~ + +### 다른 개발자와의 코드 소유 경계 + +- Funding 구현은 `domain/funding/**`의 주문·이용권·등록 코드 파일을 소유합니다. 기존 사용자/auth와 battle 등 타 도메인 파일은 필요한 변경 없이 조회/참조만 합니다. +- Classroom 담당자는 클래스·멤버십·활동·관리자 화면을 소유합니다. 이번 변경은 `domain/classroom`에 Funding 정책 interface와 반환 record만 둡니다. Classroom Entity/Controller/Repository는 구현하지 않습니다. +- Classroom의 최소 연동은 `Classroom.ticketId` 참조(기존 필드가 없다면 FK만 추가)와 클래스 생성 시 Funding의 `FundingTicketPolicy.requireUsableBy(userId, ticketId)` 호출입니다. Funding은 소유자·ACTIVE 상태·만료 시각을 검증하고 `capacity`, `expiresAt`을 포함한 얇은 `FundingTicketAccess` 값을 반환합니다. 같은 Spring 애플리케이션 내부 서비스 계약이며 별도 HTTP API를 추가하지 않습니다. +- AI 도메인/기능은 이번 구현에서 제외합니다. AI Controller, Entity, Repository, Client, Worker, API를 만들거나 변경하지 않습니다. +- 공통 `ErrorCode` 추가가 필요하면 Funding 접두어 오류만 통합 담당자가 반영합니다. SecurityConfig의 `/api/v1/admin/**` ADMIN 규칙은 기존 것을 사용합니다. + +### 이번 코드 반영 범위 + +- `funding`: FundingCampaign, FundingOrder, Ticket, TicketCode, DeliveryAttempt, AdminAuditLog Entity와 Repository, Service/ServiceImpl, 설명이 포함된 Enum, record DTO, 사용자/관리자 Controller를 구현합니다. +- `classroom`: Funding ticket 검사 interface와 `FundingTicketAccess` record만 추가합니다. 클래스 기능 Entity/API는 다른 개발자 작업 영역으로 남깁니다. +- 구매자 연락처는 `FUNDING_CONTACT_ENCRYPTION_KEY` 환경 변수(Base64 인코딩 32바이트)로 AES-256-GCM 암호화합니다. 관리자 목록은 마스킹 연락처만 반환하고 검색은 연락처 전체 값의 HMAC 일치 검색을 지원합니다. +- Funding 테이블 DDL은 `src/main/resources/db/migration/V20261015_01__create_fundings_domain.sql`에 추가합니다. 프로젝트의 기존 DB 반영 절차에서 적용해야 합니다. +- 운영자는 텀블벅 명단 Excel을 직접 내려받고 서버에 업로드합니다. 서버는 선택 시트에서 화면의 9개 열을 자동 판별하고 미리보기로 확인합니다. 확정 시 유효한 후원 행 전체를 저장하고 결제 완료 행에만 코드를 발급해 텀블벅 업로드용 Excel을 만듭니다. 텀블벅 업로드와 완료 확인은 관리자가 수행합니다. + +이 경계에서는 Funding이 구현 대상이고 Classroom은 이용권 참조와 생성 전 검증만 연동합니다. Classroom 파일을 수정해야 한다면 `ticketId`와 단일 유효성 호출만 클래스 담당자가 반영합니다. AI 기능은 구현하지 않습니다. + +--- + +## 2. 화면별 설계 + +UI 폴더에는 총 12장의 화면 이미지가 있습니다. 앱 화면은 8개 이미지/상태 묶음, 관리자 화면은 4개 화면으로 분석했습니다. PRD의 S01~S22 클래스 참여·생성 화면은 이 폴더에 시각 시안이 없으므로, 아래에서 **PRD 전용 화면 흐름**으로 명시합니다. + +이 절은 전체 자료에서 확인한 화면과 데이터 요구를 보존합니다. 다만 이번 구현은 Funding 화면/기능에 한정합니다. Classroom 화면 중 이용권 유효성 연결 외 기능과 AI 관련 화면·기능은 각각 다른 개발 범위이며, 여기서 구현하지 않습니다. + +### 사용자 앱 + +#### 1) 마이페이지의 클래스 이용권 진입 화면 +이미지: 2026-10-14 232458 + +- 목적: 사용자가 클래스 이용권 메뉴로 이동합니다. +- 액션: 클래스 이용권 메뉴 탭. +- 데이터: 기존 사용자 프로필은 기존 마이페이지 API에서 받고, 이용권 존재/상태 표시는 별도 이용권 API에서 받습니다. +- 프론트 처리: 메뉴명·아이콘·고정 문구. +- 백엔드 처리: 이용권 상태와 표시할 정원·기간. +- API: [존재] GET /api/v1/me/mypage, [구현 완료] GET /api/v1/tickets/me. +- 인증: 로그인 필요. +- 예외/이동: 미보유면 코드 등록 안내, 보유면 이용권 상세. 로딩·네트워크 오류는 앱 상태. +- 논의: 기존 mypage 응답에 이용권 필드를 합칠지 별도 endpoint로 조회할지. 본 설계는 별도 endpoint를 권장해 User 도메인 변경을 피합니다. + +#### 2) 이용권 등록 코드 입력 화면 +이미지: 2026-10-14 232009, 232203 + +- 목적: 펀딩 리워드로 받은 이용권 등록 코드를 입력합니다. +- 액션: 코드 입력, 코드 확인, FAQ 열기. +- 표시 데이터: 코드 입력값은 앱 상태. 이용권 정원과 등록기한은 검증 후 백엔드 응답. +- 프론트 처리: 라벨·고정 안내·입력 형식·버튼 활성 조건. +- 백엔드 처리: 코드 존재/상태/등록기한/다른 코드 유형 여부. +- API: [구현 완료] POST /api/v1/ticket-codes/preview. +- 인증: 로그인 필요, 미리보기는 코드를 소비하지 않습니다. +- 예외/이동: 잘못된 코드면 입력을 보존하고 오류 안내, 유효하면 등록 확인 화면. +- 논의: 코드 입력의 하이픈·공백 정규화 및 복사/붙여넣기 동작. + +#### 3) 이용권 FAQ 펼침 화면 +이미지: 2026-10-14 232243 + +- 목적: 코드를 어디서 받는지와 클래스 참여 코드와의 차이를 안내합니다. +- 표시 데이터: “펀딩 리워드 안내에서 확인”, “참여 코드는 모임 입장에 사용하는 별도 코드” 문구. +- 프론트 처리 권장: FAQ 문구는 고정 안내이므로 App에서 관리합니다. +- 백엔드: 불필요. +- 논의: 운영 중 환불/전달 경로가 바뀌면 문구 배포를 누가 담당할지 정합니다. + +#### 4) 코드 확인 결과 및 오류 모달 +이미지: 2026-10-14 232144 + +- 목적: 이미 등록된 이용권, 잘못된 코드, 네트워크 문제, 만료 코드를 구분합니다. +- 백엔드 구분: CODE_INVALID, CODE_ALREADY_CLAIMED, CODE_EXPIRED, CODE_REVOKED, NETWORK_ERROR. +- 프론트 처리: 오류 아이콘, 재시도 버튼, 입력 보존, 확인/뒤로가기. +- 인증: 코드 미리보기는 로그인 필요. +- 예외 문구: 코드 오류는 다음 문구로 통일합니다. “코드가 올바르지 않아요. 입력한 코드를 다시 확인해 주세요.” +- 주의: 네트워크 실패는 서버 ErrorCode로 가장하지 않고 앱 네트워크 상태로 처리합니다. + +#### 5) 이용권 등록 확인 화면 +이미지: 2026-10-14 232220 + +- 목적: 상품 정원, 기간, 등록 시 시작되는 이용 조건을 확인한 뒤 사용자가 등록을 확정합니다. +- 백엔드 데이터: 상품명/정원, 등록기한, 등록 즉시 시작될 활성화·종료 시각, 클래스 생성 제한 없음. +- 프론트 처리: 확인 문구·버튼·등록 후 기간이 되돌릴 수 없다는 설명. +- API: [구현 완료] POST /api/v1/ticket-codes/claim. +- 인증: 로그인 필요. Idempotency-Key 헤더 권장. +- 이동: 성공 시 등록 완료, 중복 탭은 첫 결과 반환. +- PRD/UI 차이: UI 예시는 30인 상품 하나지만 PRD는 5/15/30인 3종을 정의합니다. + +#### 6) 이용권 등록 완료 화면 +이미지: 2026-10-14 232308 + +- 목적: 활성화 결과와 공통 이용 종료일을 보여줍니다. +- 데이터: 정원, activatedAt, expiresAt, 이용권 상태. +- 프론트: “이용권이 등록됐어요” 등 고정 문구와 버튼. +- 백엔드: 날짜 계산과 이용권 활성 상태. +- 이동: 이용권 확인하기 또는 마이페이지. +- 예외: 재요청 시 종료일을 다시 계산하거나 연장하지 않습니다. + +#### 7) 클래스 이용권 확인 화면 +이미지: 2026-10-14 232329 + +- 목적: 현재 활성 이용권의 상품·기간·정원을 확인하고 추가 등록 흐름을 엽니다. +- API: [구현 완료] GET /api/v1/tickets/me. +- 인증: 로그인 필요. +- 표시 데이터: 상태, 정원, 사용 가능 기간, 클래스 생성 가능 여부. +- PRD/UI 차이: PRD의 기본안은 펀딩 캠페인 기준 1인 1개지만 UI에는 “이용권 추가등록하기”가 있습니다. 추가 캠페인/복수 보유 의미인지 출시 전 확정이 필요합니다. UI가 보유하지 않은 등록 코드를 확인해 주는지 역시 현재 계약에 없습니다. + +#### 8) 앱 진입 및 이용권 상태별 분기 +이미지: 2026-10-14 232458 및 이용권 화면 3종 + +- 메뉴는 서버 상태를 바탕으로 등록 안내/활성 이용권/기간 종료 상태로 분기합니다. +- “현재 이용권 없음”은 서버에서 NONE으로 내려주며, Loading/Network Error는 앱이 처리합니다. +- UI에 미등록·이용 중·등록만료·이용종료가 표시되지만, PRD의 구매자 정보는 코드 등록 전 앱 계정과 연결되지 않습니다. 등록 전 이용권 상태를 특정 사용자의 P03 화면에서 보여주는 방법은 정책 확인이 필요합니다. + +### 관리자 대시보드 + +현재 관리자 UI는 별도 Next.js 앱을 사용합니다. 아래는 첨부 이미지에서 확인된 클래스 관리 화면 4종입니다. + +#### 1) 클래스 관리 목록 +이미지: 2026-10-14 233753 + +- 목적: 전체 클래스와 정원·인원·선택 콘텐츠·운영 상태를 검색합니다. +- 데이터: 전체/진행 중/멤버 수/만료 임박 집계 카드, 클래스명·생성자·이용권·인원·선택 콘텐츠·상태. +- 검색/필터: 클래스명 또는 생성자, 상태, 이용권 정원, 배틀 유형. +- 정렬/페이지: 페이지 번호 UI 확인, 정렬 조건은 시안에 없음. 서버 pagination을 권장합니다. +- 액션: 조회, CSV 내보내기, 행을 눌러 상세. +- API: [별도 Classroom 구현 범위] GET /api/v1/admin/classes, GET /api/v1/admin/classes/export.csv. 이번 Funding 구현에서는 추가하지 않습니다. +- 권한: ADMIN. +- 논의: 필터 변경 시 서버 조회, 기본 정렬(createdAt desc), CSV 내보내기 적용 필터. + +#### 2) 이용권·등록자 목록 +이미지: 2026-10-14 233819 + +- 목적: 발급된 코드, 상품 정원, 등록 사용자, 이용 기간, 연결 클래스 수를 관리합니다. +- 데이터: 전체 이용권/이용 중/미등록/만료 임박 카드, 코드, 구매자 정보, 이용권·등록자, 구분, 기간, 생성 클래스 수. +- 검색/필터: 코드·구매자·등록자 키워드, 상품 정원(5/15/30), 상태, 등록 기간. +- 액션: 이용권 코드 발급, 조회, 페이지 이동. +- API: [구현 완료] GET /api/v1/admin/tickets, POST /api/v1/admin/tickets/{ticketId}/codes. +- 권한: ADMIN. +- 개인정보: 이메일 등은 기본 마스킹. 발급 완료 직후 코드 원문은 제한된 일회 노출로 전달하는 안을 권장합니다. +- 논의: UI 표에 표시된 전체 코드 노출과 코드 원문 비저장 원칙 간 조정. + +#### 3) 펀딩 구매자 목록 +이미지: 2026-10-14 233945 + +- 목적: 외부 구매자, 리워드, 코드 발급·전달·앱 등록 상태를 운영합니다. +- 데이터: 구매자 수/코드 발급/전달 완료/미전달, 구매자, 연락처, 펀딩 주문, 구매 상품, 전달 상태, 등록 상태. +- 검색/필터: 이름·이메일·전화번호, 상품 정원, 전달 상태, 등록 상태. +- 액션: 구매자 등록, 조회, 전달 결과 갱신. +- API: [구현 완료] GET /api/v1/admin/funding-orders, POST /api/v1/admin/funding-orders, 텀블벅 명단 시트 확인/미리보기/코드 파일 생성, PATCH 전달 결과. +- 권한: ADMIN. +- 개인정보: 구매자 연락처는 암호화 저장·응답 마스킹, 열람은 관리자 권한 및 감사 로그 대상. +- UI 차이: 첨부 이미지는 개별 등록 버튼과 목록만 보여줍니다. 엑셀 매핑·미리보기·디지털 선물 파일 다운로드 화면은 별도 관리자 프론트엔드 구현이 필요합니다. + +#### 4) 클래스 상세와 탭 +이미지: 2026-10-14 234004 + +- 목적: 선택한 클래스의 이용권 연결, 운영 설정, 참여 현황, 결과와 변경 이력을 봅니다. +- 기본 정보: 클래스 ID/생성자/생성 시각, 이용권 연결과 유효기간, 선택 콘텐츠, 정원/현재 인원, 참여 마감, 댓글 필수, 운영자 참여. +- 탭: 기본 정보, 멤버·참여, 결과·AI, 변경 이력. +- 액션: CSV 내보내기, 목록 복귀. 시안에서 편집/삭제 버튼은 확인되지 않습니다. +- API: [별도 Classroom 구현 범위] GET /api/v1/admin/classes/{id}, GET /members, GET /results, GET /history, CSV export. 이번 Funding 구현에서는 추가하지 않습니다. +- 권한: ADMIN. +- 논의: 관리자 수정/삭제 UI는 이미지에 없어 기본 기능으로 추가하지 않습니다. 운영상 필요하면 별도 티켓으로 정합니다. + +### PRD에서만 확인되는 앱 화면 흐름 (첨부 UI 이미지 없음) + +| PRD 화면 ID | 정의된 화면/흐름 | 백엔드 핵심 | +|---|---|---| +| S01 | 클래스 홈 | 이용권/내 클래스 요약, 생성·코드 입장 진입 | +| S02~03 | 방식 선택·주제 조건 | LIBRARY/CUSTOM, 키워드·수준·카테고리 | +| S04 | 기존 콘텐츠 목록 | 배틀 카탈로그 검색, 미리듣기 정보 | +| S05 | AI 주제 제안 | 후보 8개 기본 계약, 직접 수정 검증 | +| S06~07 | 클래스 설정·공유 완료 | 생성, 정원·마감·옵션, 참여 코드 | +| S08~09 | 코드 입장·이름 입력 | 코드 미리검증, 정원 경쟁 제어, 게스트 세션 | +| S10~11 | 내 클래스·상세 | 진행/종료 목록, 구성원 권한별 상세 | +| S12 | 멤버 목록·이름 수정 | 명단, 본인 이름 변경, 운영자 내보내기 | +| S13 | 클래스 관리 | 운영자 수정/공유/삭제, 참가자 공유/나가기 | +| S14 | 클래스 요약 | 운영자 통계와 공개 의견, AI 인사이트 | +| S15 | 내 리포트 | 참여자 본인 리포트만 조회 | +| S16~20 | 기존 콘텐츠 배틀 | 클래스 격리 투표·댓글·완료 | +| S21~22 | 커스텀 AI 논쟁 | 3라운드, AI Job, 본인 반박 저장 | + +기본 정책은 PRD의 권한/역할과 데이터 범위를 따릅니다. 별도 관리자 웹 UI가 없는 S01~S22 흐름을 관리자 화면으로 가정하지 않습니다. + +--- + +## 3. 필요한 백엔드 구성요소 + +| 구성요소 | 책임 | +|---|---| +| FundingAccessController | 내 이용권 조회, 코드 미리 확인·등록 | +| FundingTicketService | 코드 상태/기한/소유자 검증, 원자적 활성화 | +| FundingAdminController | 구매자·주문 수동 등록, 코드 발급, 전달 상태 관리 | +| FundingAdminService | PAID 확인, 상품 정원 매핑, 재발급·환불 상태 처리, 감사 기록 | +| FundingTicketPolicy | 클래스 담당자가 클래스 생성 전에 호출하는 내부 서비스 계약. 소유자·ACTIVE·기간 검증 후 정원/만료 정보를 반환 | +| Funding repositories/entities | 캠페인·주문·이용권·코드·전달 이력 영속화와 동시성 제어 | +| Existing User/Auth | 기존 사용자와 JWT 권한 재사용; User 도메인은 Funding 목적으로 확장하지 않음 | +| Classroom domain | 별도 담당자 소유. 이번 범위에서 클래스 기능 구현 없이 위 정책 호출 및 ticketId 연결만 통합 | + +Funding 사용자 Controller와 Admin Controller는 인증·응답 DTO를 분리하고, 업무 불변식은 Funding Service에서 수행합니다. 관리자 액션은 같은 핵심 Service의 상태 변경 메서드를 호출하되, 일반 사용자가 임의의 userId를 전달해 실행하지 못하게 합니다. Classroom/AI Controller와 Service는 이번 구현 대상이 아닙니다. + +--- + +## 4. API 명세 + +### 공통 계약 + +프로젝트에는 공통 응답이 이미 있습니다. + +~~~json +{ + "statusCode": 200, + "data": {}, + "error": null +} +~~~ + +- 성공 래퍼: 기존 ApiResponse를 사용합니다. 현재 onSuccess는 statusCode 200으로 생성합니다. 새 API의 HTTP status와 body statusCode를 일치시키려면 작은 overload가 필요합니다. +- 실패 래퍼: 기존 형식은 {statusCode, data:null, error:{code,message}}입니다. HTTP status는 의미를 표현하고 ErrorCode는 화면 분기용 안정 키로 씁니다. +- 목록: 기존 PageResponse를 사용합니다. 요청 page는 0부터, 응답 page는 현재 구현처럼 1부터 맞추거나 기존 AdminUserSearchResponse처럼 hasNext 계약을 명시합니다. 이 기능에서는 PageResponse로 통일을 권장합니다. +- 인증: 로그인 사용자 API는 Authorization: Bearer {accessToken}. Refresh는 현재 X-Refresh-Token을 사용합니다. 게스트 API는 별도 signed guest token을 사용합니다. +- 쓰기 중복 방지: Funding 코드 claim과 구매자/주문 등록은 Idempotency-Key 또는 Funding 고유 키로 중복 처리를 보장합니다. Classroom 쓰기와 AI turn은 해당 담당 범위입니다. + +### 기존 API 및 Funding 사용자 API + +| 상태 | Method / URL | 목적 및 입력 | 응답 | Auth | HTTP / 주요 오류 | +|---|---|---|---|---|---| +| [존재] | POST /api/v1/auth/login/{provider} | 기존 소셜 로그인 | 기존 LoginResponse | 없음 | 200, AUTH_* | +| [존재] | POST /api/v1/auth/refresh | X-Refresh-Token 갱신 | 기존 LoginResponse | Refresh Token | 200, AUTH_REFRESH_TOKEN_EXPIRED | +| [존재] | POST /api/v1/auth/logout | 로그아웃 | {success:true} | USER | 200 | +| [존재] | GET /api/v1/me/mypage | 기존 마이페이지 | 기존 MypageResponse | USER | 200 | +| [부분 존재] | GET /api/v1/battles?page=1&size=10 | 공개 배틀 카탈로그 기본 목록 | 기존 BattleListResponse | 기존 정책 | 200 | +| [부분 존재] | GET /api/v1/battles/{battleId} | 배틀 상세/콘텐츠 조회 | 기존 상세 응답 | 기존 정책 | 200, BATTLE_NOT_FOUND | +| [구현 완료] | GET /api/v1/tickets/me | 내 이용권 상태 조회 | NONE/ACTIVE/EXPIRED/REVOKED와 기간 | USER | 200, 401 | +| [구현 완료] | POST /api/v1/ticket-codes/preview | {code}; 코드를 소비하지 않는 검증 | 상품·정원·registerBy·등록 시 종료 예정일 | USER | 200, 422 TICKET_CODE_INVALID, 410 TICKET_CODE_EXPIRED, 409 TICKET_CODE_UNAVAILABLE | +| [구현 완료] | POST /api/v1/ticket-codes/claim | {code}; 같은 코드의 같은 사용자 재요청은 최초 결과 반환 | ticketId·capacity·activatedAt·expiresAt | USER | 200, 409 TICKET_ALREADY_ACTIVE, 409 TICKET_CODE_UNAVAILABLE | + +Classroom 생성 시 내부 연동 계약은 `FundingTicketPolicy.requireUsableBy(userId, ticketId)`이며, `capacity`와 `expiresAt`을 반환합니다. 별도 HTTP endpoint는 추가하지 않습니다. 클래스 생성·입장·활동 및 AI API는 다른 구현 범위이며 이번 Funding API 목록에는 포함하지 않습니다. 기존 battle API는 그대로 둡니다. + +### 관리자 API + +| 상태 | Method / URL | 입력/조회 | 응답 | Auth | HTTP / 주요 오류 | +|---|---|---|---|---|---| +| [존재] | GET /api/v1/admin/users/search?keyword=&page=0&size=20 | 기존 앱 사용자 검색 | items·hasNext | ADMIN | 200 | +| [존재] | GET /api/v1/admin/dashboard/summary | 기존 일반 대시보드 요약 | 기존 통계 DTO | ADMIN | 200 | +| [구현 완료] | GET /api/v1/admin/tickets?keyword=&capacity=&status=®isteredFrom=®isteredTo=&page=0&size=20 | 코드/등록자 목록 | PageResponse | ADMIN | 200 | +| [구현 완료] | POST /api/v1/admin/funding-orders | 단건 구매자·주문·상품·결제확인 수동 등록 | fundingOrderId, ticketIds | ADMIN | 201, 409 FUNDING_ORDER_DUPLICATE | +| [구현 완료] | POST /api/v1/admin/funding-orders/tumblbug/inspect | 후원자 XLSX의 전체 시트에서 헤더 자동 판별 및 샘플 행 확인 | 시트별 인식/누락 열, 샘플 행 | ADMIN | 200, 400 형식 오류, 413 10MB 초과 | +| [구현 완료] | POST /api/v1/admin/funding-orders/tumblbug/preview | 선택 시트에서 9개 열 자동 판별, 결제·중복·수량 검토 | 행별 저장/코드 발급 가능 여부 | ADMIN | 200, 400 옵션 오류, 422 필수 열 누락 | +| [구현 완료] | POST /api/v1/admin/funding-orders/tumblbug/digital-gifts | 유효한 행 저장, 결제 완료 행에 코드 발급, 업로드용 XLSX 반환 (최대 5,000행) | 후원번호와 코드 텍스트 파일 | ADMIN | 200, 409 중복, 413 파일/행 제한, 422 필수 열/행 오류 | +| [구현 완료] | PATCH /api/v1/admin/funding-orders/tumblbug/digital-gifts/delivery | 텀블벅 업로드 결과를 후원번호 목록으로 기록 | 반영/미매칭 건수 | ADMIN | 200, 400 상태 오류 | +| [구현 완료] | GET /api/v1/admin/funding-orders?keyword=&capacity=&deliveryStatus=&claimStatus=&page=0&size=20 | 구매자 목록 | 마스킹 연락처·상품·전달/등록 상태 | ADMIN | 200 | +| [구현 완료] | POST /api/v1/admin/tickets/{ticketId}/codes | 등록 코드 발급 | 1회 노출 code·발급 상태 | ADMIN | 201, 409 TICKET_CODE_ALREADY_ISSUED | +| [구현 완료] | PATCH /api/v1/admin/deliveries/{id} | {channel,status,failedReason} 전달 결과 기록 | 전달 상태·시각 | ADMIN | 200 | +| [구현 완료] | POST /api/v1/admin/tickets/{ticketId}/reissue | 미등록 코드 폐기 후 재발급 | 새 코드·기존 registerBy | ADMIN | 201, 409 FUNDING_REISSUE_NOT_ALLOWED | +| [구현 완료] | POST /api/v1/admin/tickets/{ticketId}/refund | 실제 외부 환불을 확인한 후 주문·연결 이용권 취소 | FundingOrder REFUNDED, Ticket/TicketCode REVOKED | ADMIN | 200, 409 FUNDING_REFUND_NOT_CONFIRMED | + +### 주요 JSON 예시 + +코드 미리보기: + +~~~json +{ + "code": "PICKE-3E8T-U3AB-C5D6-F7GH-JKLM-NPQR-STUV" +} +~~~ + +~~~json +{ + "statusCode": 200, + "data": { + "codeStatus": "VALID", + "capacity": 30, + "registerBy": "2027-10-10T05:00:00Z", + "activatedAtIfClaimed": "2026-10-15T03:00:00Z", + "expiresAtIfClaimed": "2027-04-15T03:00:00Z" + } +} +~~~ + +코드 등록: + +~~~json +{ + "statusCode": 200, + "data": { + "ticketId": 4102, + "status": "ACTIVE", + "capacity": 30, + "activatedAt": "2026-10-15T03:00:00Z", + "expiresAt": "2027-04-15T03:00:00Z" + } +} +~~~ + +클래스 생성 API request/response는 이번 Funding 설계에서 정의하지 않습니다. Classroom 담당자가 위 내부 `FundingTicketPolicy` 계약과 `ticketId`만 연결합니다. + +공통 오류 예시: + +~~~json +{ + "statusCode": 422, + "data": null, + "error": { + "code": "TICKET_CODE_INVALID", + "message": "코드가 올바르지 않아요. 입력한 코드를 다시 확인해 주세요." + } +} +~~~ + +권장: ErrorCode만 전달하고 앱이 표시 문구를 매핑합니다. 기존 GlobalExceptionHandler는 ErrorCode의 message도 응답하므로, 위 문구를 서버에서 제공할지 앱 상수로 관리할지는 앱 개발자와 합의합니다. + +--- + +## 5. DB / Entity 설계 + +모든 주요 Entity는 기존 BaseEntity를 상속해 id, createdAt, updatedAt을 재사용합니다. 이 필드들을 도메인별로 다시 정의하지 않습니다. 현재 데이터베이스는 PostgreSQL입니다. 현재 application.yml은 hibernate ddl-auto: update를 설정하고 SQL migration 파일도 저장소에 있으므로, 실제 배포용 스키마 변경 절차는 개발자가 기존 배포 규칙을 확인한 후 한 방식으로 적용해야 합니다. + +| Entity | 핵심 필드 | 관계/제약 | +|---|---|---| +| User (기존) | 기존 userTag, role, status | 기존 엔티티 수정 최소화. owner/claim 연결 FK | +| FundingCampaign | platform, externalCampaignId, name, status, registerPolicyVersion | platform+externalCampaignId UNIQUE | +| FundingOrder | campaign, externalOrderId, rewardLineId, rewardType, quantity, capacity, paymentStatus, paidAt, buyerName, buyerContactEncrypted, buyerContactSearchHash, sourceVersion | campaign+externalOrder+rewardLine UNIQUE; paidAt 필수 발급 조건 | +| Ticket | fundingOrder, ownerUser, capacity, registerBy, activatedAt, expiresAt, status | fundingOrder+unitIndex UNIQUE; ownerUser nullable until claim; 한 사용자 활성/미등록 권한 제한 | +| TicketCode | ticket, codeHash, status, issuedAt, deliveredAt, claimedAt, revokedAt | codeHash UNIQUE; 원문 코드 저장 금지 | +| DeliveryAttempt | ticketCode, channel, status, maskedDestination, failedReason, attemptedAt, completedAt | 현재는 코드 발급 시 전달 대기 행을 만들고 관리자가 결과를 기록; 코드 원문/전체 연락처 로그 금지 | +| Classroom (별도 담당) | ticketId | Funding 연동에 필요한 비고유 FK만 합의·연결. 클래스 Entity/기능 구현은 이번 범위 아님 | +| AdminAuditLog | adminUser, action, targetType, targetId, reason, metadata | 개인정보 최소화, 관리자 상세 이력에 사용 | + +### 관계 요약 + +~~~text +FundingCampaign 1 ── N FundingOrder 1 ── N Ticket + ├── 0..1 User (claim 전에는 미연결) + ├── N TicketCode + └── 0..N Classroom (별도 Classroom 담당이 ticketId로 참조) +~~~ + +Membership, Participation, TopicVersion, ClassVote, Report, AIJob 등은 이번 Funding 범위에서 Entity/테이블로 추가하지 않습니다. + +### Enum 후보 + +- FundingPaymentStatus: RESERVED, PAYMENT_PENDING, PAID_CONFIRMED, PAYMENT_FAILED, CANCELED, REFUNDED +- TicketStatus: UNREGISTERED, ACTIVE, EXPIRED, REGISTER_EXPIRED, REVOKED +- TicketCodeStatus: ISSUED, DELIVERED, CLAIMED, REVOKED + +### 수동 운영 기준 엔티티 검토 + +운영자가 명단 Excel을 내려받아 서버에서 미리보기·검토한 뒤 발급을 확정하는 MVP에는 현재 여섯 엔티티만으로 핵심 업무를 표현할 수 있습니다. Excel 수집 배치용 Entity는 이번 범위에 필요하지 않습니다. 다만 운영 전 다음 정의를 확정해야 합니다. + +- 텀블벅 가져오기는 `externalSupporterNumber`와 `rewardLineId` 조합을 후원자·리워드의 중복 방지 키로 사용하고, 일반 수동 등록은 `externalOrderId`와 `rewardLineId`를 사용합니다. 실제 텀블벅 Excel 샘플로 자동 판별 별칭을 검증해야 합니다. 현재 환불은 해당 Ticket이 속한 FundingOrder의 Ticket 전체를 취소하며, 같은 외부 후원번호의 다른 rewardLine까지 취소하지는 않습니다. +- `FundingCampaign.registerPolicyVersion`은 현재 발급 정책 선택에 사용되지 않습니다. 정책 버전을 보존하지 않을 계획이면 제거하고, 유지한다면 버전별로 고정할 정책 값을 정의합니다. 캠페인 상태를 변경하는 운영 경로도 필요한지 결정합니다. +- 현재 `DeliveryAttempt`는 코드 발급 시 전달 채널(`MANUAL` 또는 `TUMBLBUG_DIGITAL_GIFT`)과 `PENDING`으로 생성되고 관리자가 결과를 한 번 기록합니다. 실제 발송 시도·재시도 이력은 아닙니다. 수동 MVP에서는 전달 결과 기록으로 사용하고, 재시도마다 이력이 필요해질 때 시도 단위로 확장합니다. +- `AdminAuditLog`는 주문 등록·코드 발급·전달 결과·환불 변경을 기록합니다. 구매자 정보 조회·내보내기도 감사 대상으로 포함할지 정하고, `metadata`에는 연락처 원문이나 코드 원문을 저장하지 않습니다. + +이 항목들은 추가 Excel 배치 Entity가 필요하다는 뜻이 아니라, 수동 운영에서도 외부 ID 매핑·환불 범위·정책·전달 기록·감사 범위를 확정해야 한다는 뜻입니다. + +### 제약과 JPA 주의점 + +- UNIQUE: 외부 주문 라인, 후원번호·리워드 라인, TicketCode.codeHash. Classroom.ticketId에는 UNIQUE를 두지 않아 하나의 이용권을 여러 클래스에서 사용할 수 있습니다. +- INDEX: Funding 상태/생성일, ticket owner/status, funding order paidAt/status. 검색 키워드와 마스킹 연락처는 원문 전체 인덱스를 피하고 필요한 검색 해시/정규화 키를 별도 검토합니다. +- Nullable: Ticket.ownerUser는 코드 등록 전 null입니다. Classroom FK의 필수 여부와 삭제/환불 시 동작은 Classroom 담당자와 합의합니다. +- Cascade: User 삭제에 funding 기록을 광범위 Cascade하지 않습니다. 서비스에서 비공개 처리·파기 큐를 제어합니다. +- Fetch: 모든 연관은 기본 LAZY. 목록 API는 projection/DTO query를 사용하고 Entity를 직접 JSON 직렬화하지 않습니다. +- 이용권 코드는 `PICKE-` 뒤에 O 제외 A-Z와 1-9로 만든 난수 28자를 4자씩 고정 그룹으로 표시합니다(약 142비트). 서버에는 SHA-256 해시만 저장하고, 구 PK 형식도 기존 코드 호환을 위해 등록을 허용합니다. Classroom 참여 코드 설계는 별도 Classroom 담당 범위입니다. +- code 원문은 관리자 발급 직후 한 번만 반환합니다. 분실 시 기존 미등록 코드를 폐기하고 같은 이용권·등록기한으로 재발급합니다. + +--- + +## 6. 비즈니스 로직 + +### 펀딩 주문에서 이용권 발급 + +1. ADMIN이 구매자/주문/상품/결제 완료 상태를 수동 등록합니다. 모집 종료나 목표 달성만으로 결제완료 처리하지 않습니다. +2. 플랫폼·캠페인·주문·리워드 행 고유 키로 중복 주문을 확인합니다. +3. 리워드 ID를 5/15/30인 capacity로 매핑합니다. 불명확한 리워드는 보류합니다. +4. 확인된 결제완료 행에만 Ticket와 TicketCode를 발급합니다. +5. registerBy는 최종 결제 확인일 + 달력 기준 1년입니다. +6. 전달 상태는 발급과 분리하여 관리자 액션/전달 시도 기록으로 관리합니다. + +### 코드 미리보기/등록 + +1. 로그인과 입력 형식을 확인하고 코드 정규화 후 codeHash로 조회합니다. +2. Preview는 등록기한, 결제 상태, 코드 상태, 소유 상태만 검사하고 코드를 소비하지 않습니다. +3. Claim 트랜잭션은 TicketCode 행을 PESSIMISTIC_WRITE로 잠급니다. +4. User 행도 잠그고 보유 제한·기존 동일 요청을 확인합니다. +5. 조건 통과 시 Ticket.ownerUser를 현재 사용자로 연결하고 activatedAt/ expiresAt을 저장합니다. expiresAt은 activatedAt + 달력 기준 6개월입니다. +6. TicketCode는 CLAIMED로 전이합니다. code·소유자·활성·종료 시각은 한 트랜잭션으로 커밋합니다. +7. 같은 사용자의 재시도는 기존 결과 반환. 타 계정 경쟁은 한 요청만 성공. +8. 환불 확정은 Funding에서 REVOKED 처리합니다. Classroom 데이터 변경이나 활동 차단 구현은 별도 소유 범위이며, 클래스 생성 시 Funding 유효성 확인으로 신규 연결을 막습니다. 오래된 결제 업로드가 환불을 되돌리지 않도록 상태 전이 우선순위를 둡니다. + +### Classroom 최소 연동 + +1. Funding은 코드 등록 후 `Ticket.ownerUser`, `capacity`, `activatedAt`, `expiresAt`, `status`를 보유합니다. +2. Classroom 담당자는 클래스 생성 시 같은 애플리케이션의 `FundingTicketPolicy.requireUsableBy(userId, ticketId)`를 호출합니다. +3. 정책은 소유자 일치, `ACTIVE`, 현재 시각이 `expiresAt` 이전인지 검사하고 `FundingTicketAccess(capacity, expiresAt)`을 반환합니다. +4. Classroom은 자체 Entity에 `ticketId`를 비고유 FK로 연결합니다. 이용권 하나로 복수 클래스를 허용하므로 unique 제약은 두지 않습니다. +5. Funding은 Classroom의 테이블/상태/멤버십을 직접 변경하지 않습니다. 환불 이후 기존 클래스에서 쓰기를 막는 동작은 별도 Class 담당자가 Funding 상태를 재검증하는 방식으로 결정합니다. + +이번 범위에서 클래스 생성 API, 클래스 CRUD, 입장/멤버/투표/댓글, 배틀 카탈로그, 요약·리포트 API와 관련 Entity/Service/Repository를 구현하지 않습니다. AI 기능, AI API, AI 테이블, Provider Client와 Worker도 구현하지 않습니다. + +--- + +## 7. 예외 처리 + +| 조건 | HTTP | ErrorCode 제안 | 화면/처리 | +|---|---:|---|---| +| 코드 문자열 누락, 형식 오류, 미발급 코드 | 422 | TICKET_CODE_INVALID | “코드가 올바르지 않아요. 입력한 코드를 다시 확인해 주세요.”, 입력 유지 | +| 코드 등록기한 경과 | 410 | TICKET_CODE_EXPIRED | 등록 기한 안내 | +| 다른 사용자가 먼저 등록했거나 사용 불가 | 409 | TICKET_CODE_UNAVAILABLE | 계정/소유자 정보 미노출 | +| 코드가 운영자에 의해 취소됨 | 409 | TICKET_CODE_REVOKED | 관리자 문의 안내 | +| 사용자가 이미 활성 이용권을 보유 | 409 | FUNDING_TICKET_ALREADY_ACTIVE | 기존 이용권 안내 | +| 결제 미확정·취소·환불 | 409 | FUNDING_NOT_PAYABLE | 관리자 확인 안내 | +| 취소된 이용권을 Classroom에서 사용 | 409 | FUNDING_TICKET_REVOKED | 클래스 생성 불가 안내 | +| 만료/미등록/다른 사람 소유 이용권을 Classroom에서 사용 | 403/409 | FUNDING_TICKET_FORBIDDEN / FUNDING_TICKET_NOT_ACTIVE | 소유자·이용 기간을 확인 | +| 중복 주문 또는 중복 캠페인 | 409 | FUNDING_ORDER_DUPLICATE / FUNDING_CAMPAIGN_DUPLICATE | 주문 키 확인 후 기존 주문 조회 | +| 필수 필드 누락/형식 오류, 지원하지 않는 정원·수량·연락처 | 400 | FUNDING_INVALID_REQUEST / FUNDING_REWARD_UNSUPPORTED / FUNDING_QUANTITY_INVALID / FUNDING_CONTACT_INVALID | 관리자 입력값 교정 | +| 존재하지 않는 주문, 이용권, 전달 기록 | 404 | FUNDING_ORDER_NOT_FOUND / FUNDING_TICKET_NOT_FOUND / FUNDING_DELIVERY_NOT_FOUND | 운영자 검색 조건 확인 | +| 이미 코드가 발급되었거나 재발급 조건 위반 | 409 | TICKET_CODE_ALREADY_ISSUED / FUNDING_REISSUE_NOT_ALLOWED | 기존 코드 상태 확인 | +| 환불 외부 확인 누락 또는 주문 환불 불가 | 409 | FUNDING_REFUND_NOT_CONFIRMED | 외부 플랫폼 환불 증빙 확인 | +| 전달 상태가 잘못되었거나 실패 사유 누락 | 400 | FUNDING_INVALID_DELIVERY_STATUS / COMMON_400 | 전달 결과와 실패 사유 입력 | +| 등록/검색 날짜 범위가 역전됨 | 400 | FUNDING_INVALID_DATE_RANGE | 시작일·종료일 수정 | +| 관리자 API 인증/권한 실패 | 401/403 | AUTH_UNAUTHORIZED / AUTH_FORBIDDEN | 로그인 또는 관리자 권한 확인 | +| 연락처 암호화 키 설정 누락/오류 | 500 | FUNDING_ENCRYPTION_NOT_CONFIGURED / FUNDING_ENCRYPTION_FAILED | 운영 설정 점검, 응답에서 키/원문 미노출 | +| 네트워크 끊김 | 서버 응답 없음 | 앱 로컬 NETWORK_ERROR | 입력 보존, 재조회 후 재시도 | + +Funding 업무 예외는 `ErrorCode`에 각각 추가하며, 공통 Bean Validation 실패는 기존 `COMMON_INVALID_PARAMETER` 형식을 유지합니다. 중복 주문/캠페인 DB 제약은 GlobalExceptionHandler에서 Funding 충돌 코드로 바꿉니다. Classroom 계약은 Funding의 ticket 오류를 그대로 전달하며 자체 ErrorCode를 추가하지 않습니다. AI 오류는 구현하지 않습니다. HTTP status는 전송 수준, ErrorCode는 앱/관리자 화면 분기 수준입니다. + +--- + +## 8. 이용권 기능 상세 설계 + +### 상품/기간 + +| 상품 | 가격 | 클래스별 정원 | +|---|---:|---:| +| 5인 | 9,900원 | 최대 5명 | +| 15인 | 19,900원 | 최대 15명 | +| 30인 | 29,900원 | 최대 30명 | + +- 펀딩 결제 확정 후 1년 이내 코드 등록. +- 등록 확인 성공 시 6개월 활성화. +- 활성 이용권 하나로 클래스 수 제한 없이 생성. +- 상품 정원은 클래스별로 재적용되며 좌석은 이용권 전체에서 차감하지 않습니다. +- 인앱결제, 구매 복원, 앱 결제 SDK는 구현 범위가 아닙니다. +- 월/주별 생성 제한을 두지 않습니다. +- 코드 전달과 코드 등록은 별도 상태입니다. + +### 코드 종류 + +| 종류 | 용도 | 정책 | +|---|---|---| +| TicketCode | 유료 펀딩 리워드를 계정 이용권으로 연결 | 충분한 난수, 해시 저장, 로그인 사용자만 등록 | +클래스 참여 코드(ParticipationCode)는 별도 Classroom 담당 범위입니다. Funding은 TicketCode만 발급·조회·등록하며 참여 코드 API/Entity를 추가하지 않습니다. + +### 관리자 수동 발급 MVP + +1. 관리자가 공식 펀딩 명단을 대조해 구매 주문과 결제 확정 여부를 기록합니다. +2. 상품 리워드 ID를 정원 5/15/30으로 매핑합니다. +3. ADMIN이 이용권과 등록 코드를 발급합니다. +4. 관리자 화면에서 발급 코드를 일회 확인해 사용자가 수동 전달하거나, 전달 채널/완료 상태만 기록합니다. +5. 결제 실패·취소·환불은 발급 보류 또는 폐기합니다. +6. 미사용 코드 재발급은 기존 코드를 폐기하고 registerBy를 유지합니다. 이미 등록된 이용권은 코드 재발급 대신 계정 복구로 처리합니다. + +### 상태 + +- FundingOrder: RESERVED → PAYMENT_PENDING → PAID_CONFIRMED 또는 PAYMENT_FAILED/CANCELED/REFUNDED +- Ticket: UNREGISTERED → ACTIVE → EXPIRED, 별도 REGISTER_EXPIRED/REVOKED +- TicketCode: ISSUED → DELIVERED → CLAIMED, 별도 REVOKED +- 코드 발급 또는 전달만으로 이용 기간을 시작하지 않습니다. +- 환불 요청과 환불 확정을 구분하고 확정된 환불만 REVOKED 처리합니다. +- 만료 후 30일 결과 읽기 유예와 이후 삭제는 PRD 구현 기본안이며 보관 정책 승인이 필요합니다. + +--- + +## 9. 이용권 화면 분기 + +GET /api/v1/tickets/me 응답 예시: + +~~~json +{ + "statusCode": 200, + "data": { + "state": "ACTIVE", + "ticketId": 4102, + "capacity": 30, + "activatedAt": "2026-10-15T03:00:00Z", + "expiresAt": "2027-04-15T03:00:00Z", + } +} +~~~ + +| 응답 상태 | 앱 화면 | +|---|---| +| NONE | 이용권 코드 등록 안내 | +| ACTIVE | 클래스 이용권 상세, 클래스 생성 진입 | +| EXPIRED | 종료일·읽기 유예 정책 표시, 신규 생성/활동 잠금 | +| REVOKED | 환불/취소 관련 문의 안내, 신규 활동 잠금 | + +UNREGISTERED는 아직 계정에 claim되지 않은 펀딩 주문 상태이므로 기본적으로 관리자 화면에만 표시합니다. 등록 코드가 계정에 사전 연결되는 정책을 채택하면 이 분기를 확장합니다. “이용권 추가등록하기” UI와 PRD의 캠페인별 1인 1개 제한 간 의미는 앱/기획 확인 항목입니다. + +--- + +## 10. 텀블벅 연동 방안 조사 + +확인일은 2026-10-15입니다. 텀블벅 공식 도움말에서 창작자는 후원자 관리의 데이터를 Excel로 메일 발송 받을 수 있고, 디지털 선물 전달에서는 템플릿에 후원번호를 적은 xlsx 업로드가 가능한 것을 확인했습니다. 공식 공개 자료에서 창작자용 후원자/결제 조회 API의 endpoint, 인증 방식, webhook, rate limit은 확인되지 않았습니다. 따라서 API 제공을 단정하지 않고, 텀블벅 지원/계약을 통해 API 이용 가능성과 범위를 별도 확인해야 합니다. + +- [후원자 명단 다운로드 공식 안내](https://help.tumblbug.com/ko/categories/%EC%86%8C%ED%86%B5%ED%95%98%EA%B3%A0-%EC%A0%84%EB%8B%AC%ED%95%98%EA%B8%B0-92f62bf3) +- [디지털 선물 Excel 전달 공식 안내](https://help.tumblbug.com/ko/articles/7717bf63) + +| 방식 | 현재 결정 | 운영 방식 | +|---|---|---| +| 텀블벅 API | 보류 | 공식 공개 자료에서 창작자 후원·결제 조회 API를 확인하지 못했습니다. 접근 권한과 데이터 범위를 공식 제휴 문의로 확인하기 전에는 연동을 전제하지 않습니다. | +| 명단 Excel | 사람 검토 + 서버 파싱 | 운영자가 텀블벅에서 직접 요청·다운로드합니다. 관리자는 리워드 시트만 선택하고 서버가 헤더 행과 화면의 9개 열을 자동 판별합니다. 미리보기에서 결제 상태·중복·수량을 검토합니다. | +| 픽케 주문 등록·발급 | 서버 처리, 관리자 승인 | 확정 작업에서 유효 행만 주문·티켓·코드로 생성하고, 후원번호와 코드 텍스트를 연결한 XLSX를 반환합니다. 원문 코드는 DB에 저장하지 않습니다. | +| 텀블벅 전달·상태 동기화 | 사람 처리 | 운영자가 텀블벅 디지털 선물 관리에 텍스트 파일을 업로드하고, 업로드 결과를 서버에 후원번호 목록으로 기록합니다. 실시간 API 동기화는 없습니다. | + +현재 운영 흐름은 `텀블벅 Excel 다운로드 → 서버 시트 선택·열 자동 판별 및 미리보기 → 관리자 확인 → 유효 후원 행 저장·결제 완료 행 코드 발급 및 텀블벅 업로드 파일 다운로드 → 텀블벅 디지털 선물 업로드 → 서버에 전달 결과 기록`입니다. 플랫폼과 서버 사이의 API 자동 동기화는 포함하지 않습니다. + +주의: 공식 도움말의 디지털 선물 Excel 기능은 텀블벅 내부에서 선물을 전달하는 기능입니다. 픽케 자체 구매자/코드 DB와 자동 동기화되는 API로 취급하지 않습니다. + +--- + +## 11. 관리자 대시보드 설계 + +### 화면에서 확인된 기능 + +| 기능 | 확인 결과 | +|---|---| +| 클래스 목록/검색/상태·상품·배틀 유형 필터 | 있음 (별도 Classroom 구현 범위) | +| 클래스 운영 지표 카드 | 있음 (별도 Classroom 구현 범위) | +| 클래스 CSV 내보내기 | 있음 (별도 Classroom 구현 범위) | +| 이용권/등록자 목록·상태 카드 | 있음 | +| 이용권 코드 발급 | 있음 | +| 펀딩 구매자 등록/검색 | 있음 | +| 구매자 연락처·주문·상품·전달 상태 | 있음 | +| 클래스 상세와 기본 정보/멤버·참여/결과·AI/변경 이력 탭 | 있음 (별도 Classroom/AI 범위) | +| 별도 사용자 전체 목록 화면 | 첨부 이미지에는 없음 | +| 코드 상세 화면/수정/비활성 UI | 별도 이미지에 없음 | +| 텀블벅 Excel 처리 | 백엔드 API 있음, 첨부 시안에는 UI 없음 | +| 구매자 편집/삭제 | 없음 | +| 코드 재발급 버튼 | 없음 | +| 사용자 서비스 내부 ADMIN 로그인 화면 | 없음; 헤더만 표시 | + +화면에 없는 운영 요구는 기본 CRUD로 추가하지 않습니다. 결제 환불/재발급/명단 재처리가 꼭 필요하면 API는 설계에 표시하되 UI에 **운영상 필요 가능성 있음 - 추가 논의 필요**로 둡니다. + +위 표의 클래스 화면은 UI 분석 결과이며 이번 Funding 기능에서 구현하지 않습니다. 이번 관리자 구현은 이용권/등록자 목록, 구매자 등록·검색, 코드 발급 및 전달 상태에 한정합니다. + +### 관리자 목록 처리 기준 + +- server-side paging/filtering 권장. 요청은 page=0, size 기본 20, sort는 allowlist. +- 클래스 검색: name 또는 owner display name/tag, status, capacity, LIBRARY/CUSTOM. +- 이용권 검색: code 일부/구매자/등록자, capacity, ticket 상태, registered date range. +- 펀딩 구매자 검색: 이름 및 연락처는 암호화/검색용 별도 설계를 거친 뒤 최소 필드만 검색. +- 날짜 범위는 Asia/Seoul 입력, 서버 UTC 저장. 조회 범위 종료일은 화면과 백엔드에서 inclusive/exclusive 중 하나로 합의. +- CSV는 ADMIN만 다운로드, 현재 검색 필터와 동일한 조건 적용, PII 최소화/마스킹. +- 코드 원문은 목록에서 기본 마스킹하고 발급 때만 텀블벅 업로드용 다운로드 파일에 담습니다. 파일 응답은 캐시하지 않습니다. 화면에서 개별 코드 발급 시에는 응답에 한 번 표시합니다. + +--- + +## 12. 관리자 API + +관리자 화면 구현에 필요한 API는 §4의 [신규 제안] endpoint를 사용합니다. + +| 화면 | 필요한 API | 검색/페이지 조건 | +|---|---|---| +| 이용권·등록자 | GET /admin/tickets, POST /admin/tickets/{ticketId}/codes | keyword, capacity, status, registeredFrom, registeredTo, page, size | +| 펀딩 구매자 | GET/POST /admin/funding-orders, Tumblbug inspect/preview/digital-gifts, PATCH 전달 결과 | keyword, capacity, deliveryStatus, claimStatus, page, size | +| 환불·재발급 운영 | POST /admin/tickets/{ticketId}/refund, POST /admin/tickets/{ticketId}/reissue | reason 필수, 외부 확인 기록 | + +모든 실제 URL은 /api/v1 접두어를 붙입니다. 관리자 화면 검색 결과의 구매자 연락처는 전체값을 기본 응답에 담지 않습니다. 관리자 클래스 목록/상세/CSV API는 별도 Classroom 담당 범위입니다. + +--- + +## 13. 관리자 인증/인가 + +- 현재 UserRole에 ADMIN이 있고 SecurityConfig에서 /api/v1/admin/**는 hasRole("ADMIN")입니다. +- 신규 관리자 API도 해당 prefix에 놓고 메서드 단위 @PreAuthorize("hasRole('ADMIN')")를 병행합니다. +- 일반 로그인 API는 기존 OAuth/JWT를 사용합니다. AdminPickeController의 GET /api/v1/admin/login은 현재 외부 admin-web 로그인 페이지로 redirect하는 경로이며, 백엔드 로그인 API로 표시하지 않습니다. +- 클래스 운영자는 서비스 내 클래스 owner 권한으로 본인 클래스만 관리합니다. ROLE_ADMIN이 클래스 소유 권한을 대체하지 않습니다. +- 개인정보 응답은 관리자에서도 필요한 컬럼만 제공합니다. 열람/코드 발급/환불/재발급은 AdminAuditLog에 관리자 id, action, target, reason, 시각을 기록합니다. + +--- + +## 14. 앱 개발자와 논의할 사항 + +### 화면/UI + +- 고정 텍스트/FAQ/이미지는 App Asset으로 관리합니다. 이용권 상태·정원·날짜·코드 결과는 서버 응답을 씁니다. +- 버튼 활성 조건: 코드 입력 non-empty, claim 요청 진행 중 중복 제출 방지. +- 이용권 상태와 “이용권 추가등록하기” 의미(캠페인별 복수 보유 허용 여부). +- 날짜 표시 KST, 서버 ISO-8601/UTC, 달력 기준 1년/6개월 계산과 말일 경계. +- Enum→한국어 라벨 매핑은 App에서 하고 API message를 UI 분기로 쓰지 않습니다. +- Empty/Loading/Error/Network Error/Unauthorized/Forbidden 상태에서 입력 보존과 재시도 UX. +- 이용권 코드 형식, FAQ 문구, 화면에서 쓸 이용권 상태/코드 명칭. + +### API/Auth + +- 공통 성공 응답의 HTTP status와 body statusCode 일치 방식. +- ErrorCode만 내려줄지, message를 앱에서 매핑할지. +- nullable 필드와 이용권 상태 enum 표시. +- Refresh Token은 현재 X-Refresh-Token 헤더 사용. +- Version prefix는 기존 /api/v1을 유지. +- Pagination: 요청 0-based/응답 1-based인 현재 혼합 계약을 신규 API에서 어떻게 통일할지. +- claim용 Idempotency-Key 보존 기간과 앱의 네트워크 복구 후 재전송 방식. +- 클래스 참여 코드/게스트 입장, AI 작업 UX는 각각 Classroom/AI 담당 범위에서 논의합니다. + +--- + +## 15. 관리자 프론트엔드 개발자와 논의할 사항 + +- 서버 pagination/filter/sort 채택 및 size 상한, 기본 정렬. +- 검색 버튼 기반 조회인지 debounce 검색인지. 현 시안은 조회 버튼을 보이므로 명시적 조회를 기본안으로 둡니다. +- 날짜 검색의 시간대/끝 날짜 포함 여부. +- 이용권 상태/결제 상태/전달 상태 Enum 표시명. +- 구매자 연락처·이용권 코드 마스킹 및 상세 열람 감사. +- “구매자 등록” 입력 필드와 결제 완료 증빙 방식. +- 코드 발급 직후 원문을 일회 표시·복사하는 모달. +- 전달 실패 기록과 재전달/재발급을 구별. +- 취소/환불은 물리 삭제 대신 REVOKED 상태로 보존. +- CSV 필터 적용, 다운로드 인가, 포함 컬럼/PII. +- 명단 Excel은 운영자가 텀블벅에서 직접 내려받아 관리자 기능에 업로드합니다. 시트를 선택하면 서버가 9개 열을 자동 판별하고, 운영자는 미리보기에서 결제 상태를 확인한 뒤 저장·코드 발급을 진행합니다. +- 클래스 상세/운영 화면은 별도 Classroom 담당 범위입니다. + +--- + +## 16. 화면 상태 정의 + +| 상태 | 백엔드 표현 | 화면 처리 | +|---|---|---| +| Loading | Funding API 응답 전 | 진행 표시; AI job 상태는 구현 범위 밖 | +| Empty | 목록 API content=[] 또는 ticket state=NONE | App/관리자 Empty 안내 | +| Success | HTTP 2xx + data | 결과 렌더링 | +| Error | HTTP 4xx/5xx + ErrorCode | 업무 오류별 매핑 | +| Network Error | 서버 응답 없음 | 앱/브라우저 자체 감지, 입력 유지 | +| Unauthorized | 401 AUTH_UNAUTHORIZED/토큰 만료 | 재로그인/refresh | +| Forbidden | 403 AUTH_FORBIDDEN/범위 권한 없음 | 접근 거절, 민감 데이터 미반환 | +| Classroom 만료 | 별도 Classroom 도메인에서 처리 | Funding 구현은 ticket 만료 상태와 조회만 제공 | + +서버가 업무 상태와 오류 코드를 구분해서 주고, Loading·네트워크 단절·토스트·정적 안내 문구는 각 프론트엔드에서 처리합니다. + +--- + +## 17. UI 문구 수정사항 + +오류 화면에 표시된 구 문구는 사용하지 않습니다. 문서와 API 예시에 아래 문구만 사용합니다. + +> 코드가 올바르지 않아요. +> 입력한 코드를 다시 확인해 주세요. + +권장 API는 안정적인 Funding ErrorCode를 반환하고 App이 문구를 매핑하는 방식입니다. 현재 공통 오류 응답은 message도 포함하므로, server message를 표시할지 App 번역 상수로 관리할지는 앱 개발자 협의 항목입니다. + +--- + +## 18. 전체 API 목록 + +이번 구현 API 목록은 Funding 범위와 현재 구현된 공통 API만 포함합니다. Classroom 화면에서 보인 클래스 API와 PRD의 AI API는 분석 참고사항이며 신규 제안/구현 대상이 아닙니다. Classroom 연동은 내부 `FundingTicketPolicy` 호출이므로 HTTP API를 추가하지 않습니다. + +### 사용자 + +| Type | Domain | Method | Endpoint | 상태 | 인증 | +|---|---|---|---|---|---| +| USER | Auth | POST | /api/v1/auth/login/{provider} | 존재 | 없음 | +| USER | Auth | POST | /api/v1/auth/refresh | 존재 | Refresh Token | +| USER | Auth | POST | /api/v1/auth/logout | 존재 | USER | +| USER | Mypage | GET | /api/v1/me/mypage | 존재 | USER | +| USER | Battle Catalog | GET | /api/v1/battles | 부분 존재, 일반 목록 | 기존 정책 | +| USER | Battle Catalog | GET | /api/v1/battles/{battleId} | 부분 존재, 일반 상세 | 기존 정책 | +| USER | Fundings | GET | /api/v1/tickets/me | 구현 완료 | USER | +| USER | Fundings | POST | /api/v1/ticket-codes/preview | 구현 완료 | USER | +| USER | Fundings | POST | /api/v1/ticket-codes/claim | 구현 완료 | USER | + +### 관리자 + +| Type | Domain | Method | Endpoint | 상태 | 인증 | +|---|---|---|---|---|---| +| ADMIN | Users | GET | /api/v1/admin/users/search | 존재 | ADMIN | +| ADMIN | Dashboard | GET | /api/v1/admin/dashboard/summary | 존재 | ADMIN | +| ADMIN | Funding | GET | /api/v1/admin/tickets | 구현 완료 | ADMIN | +| ADMIN | Funding | GET | /api/v1/admin/funding-orders | 구현 완료 | ADMIN | +| ADMIN | Funding | POST | /api/v1/admin/funding-orders | 구현 완료, 수동 P0 | ADMIN | +| ADMIN | Funding | POST | /api/v1/admin/tickets/{ticketId}/codes | 구현 완료 | ADMIN | +| ADMIN | Funding | PATCH | /api/v1/admin/deliveries/{id} | 구현 완료 | ADMIN | +| ADMIN | Funding | POST | /api/v1/admin/tickets/{ticketId}/reissue | 구현 완료 | ADMIN | +| ADMIN | Funding | POST | /api/v1/admin/tickets/{ticketId}/refund | 구현 완료 | ADMIN | + +일반 관리자 대시보드의 기존 통계 endpoint는 클래스 지표를 제공하지 않습니다. Funding 범위에 포함되지 않은 클래스 화면 API는 구현 완료 또는 신규 Funding API로 계산하지 않습니다. + +--- + +## 19. 최종 Entity 목록 + +- User (기존): 로그인 사용자 및 시스템 역할. +- FundingCampaign: 펀딩 플랫폼 캠페인과 상품 매핑. +- FundingOrder: 결제 확정 주문/리워드 행과 최소 구매자 정보. +- Ticket: 정원·등록 기한·계정 귀속·활성/만료 권한. +- TicketCode: 이용권 claim용 비밀 코드의 해시와 상태. +- DeliveryAttempt: 코드 전달 시도 및 실패 결과. +- AdminAuditLog: Funding 관련 민감한 관리자 변경 이력. + +Classroom은 별도 개발자가 소유합니다. Funding 구현은 별도 클래스 Entity를 만들지 않고, 기존 Classroom에 `ticketId` 참조와 생성 시 Funding 유효성 확인만 연결합니다. AI 관련 Entity는 만들지 않습니다. + +--- + +## 20. 최종 아키텍처 요약 + +### 핵심 도메인 + +Funding, Classroom 최소 연동, 기존 User/Auth/Admin 공통 기반. + +### 핵심 Entity + +User(기존), FundingCampaign, FundingOrder, Ticket, TicketCode, DeliveryAttempt, AdminAuditLog. Classroom은 별도 담당의 기존 Entity에 `ticketId` 연결만 합의합니다. + +### 핵심 Service + +FundingTicketService, FundingAdminService, FundingTicketPolicy (내부 연동 계약). + +### 핵심 API + +이용권: GET /api/v1/tickets/me, POST /api/v1/ticket-codes/preview, POST /api/v1/ticket-codes/claim. +클래스 연동: 클래스 담당자가 생성 시 `FundingTicketPolicy`를 내부 호출합니다 (HTTP endpoint 추가 없음). +관리자: /api/v1/admin/tickets, /funding-orders, 텀블벅 엑셀 미리보기·코드파일 생성과 전달 기록. + +### 외부 연동 + +현재 운영은 관리자가 텀블벅 엑셀을 직접 내려받아 확인하고, 서버에 업로드해 결제 완료 행의 코드와 텀블벅 디지털 선물 업로드 파일을 생성하는 방식입니다. 텀블벅 API를 이용한 자동 동기화는 포함하지 않습니다. + +### 관리자 기능 + +펀딩 구매자 수동 등록, 이용권 코드 발급과 전달 상태, 이용권/등록자 조회와 CSV export. 클래스 대시보드는 별도 구현 범위입니다. + +### 먼저 합의할 사항 + +- 앱: “추가 등록”과 1인 1개 제한, 코드 오류 문구 전달 방식, 기간 계산/시간대. +- Classroom 담당자: `ticketId` FK 소유권, 클래스 생성 시 Funding 정책 호출 지점과 환불 상태 재검증 방식. +- 관리자 프론트엔드: 코드 원문 노출/마스킹, 구매자 연락처 접근, 전달 실패/재발급 흐름, CSV 필드. +- 기획/운영: 플랫폼 선택, 결제 확정 증빙, 환불 정책, 구매자 데이터 보유 기간, Excel 시점. + +### 구현 우선순위 + +- **P0**: 구매자/주문 수동 등록, 결제 상태 확인, 이용권·코드 발급과 claim, 전달 상태, 관리자 Funding 조회, Classroom 유효성 정책 계약 및 `ticketId` 연결. +- **P1**: Funding 운영 알림·만료 자동화, 재발급/환불 보조 흐름, 개인정보 보관·파기 자동화. +- **보류**: 텀블벅 공식 플랫폼 API 연동/자동 동기화와 자동 전달. 명단 파일 기반의 사람 검토형 가져오기/코드 파일 생성 API는 구현 대상에 포함합니다. + +## 21. 구현 완료 및 미포함 항목 + +### 구현 완료 + +- 사용자 API: 이용권 상태 조회, 등록 코드 미리보기, 코드 등록. +- 관리자 API: 단건 주문 등록, 텀블벅 XLSX 시트 확인/미리보기/후원번호 중복 검사, 결제 완료 행 코드 발급 및 디지털 선물 업로드 파일 생성, 후원번호별 전달 결과 기록, 주문/이용권 검색, 코드 발급·재발급, 환불 확정 처리. +- 저장소 계층: 캠페인, 주문, 이용권, 코드, 전달 시도, 관리자 변경 로그 Entity/Repository 및 DB migration SQL. +- 동시성/보안: 코드와 사용자 행 잠금 기반 claim, 고유 주문 키, 코드 원문 미저장, 구매자 연락처 AES-GCM 암호화·HMAC 검색, 응답 마스킹. +- API 표에서 `[존재]`로 표시된 로그인·마이페이지·일반 관리자 API는 원래 있던 API입니다. `[구현 완료]`는 이번 변경으로 추가된 Funding API입니다. +- Swagger: 사용자 Funding API는 사용자 API 그룹 화이트리스트에 등록했고, 사용자/관리자 API 모두 요약·설명·인증·주요 응답 코드를 명시했습니다. +- API별 Method, URL, 요청 옵션, 저장 필드와 주요 오류는 [Funding API 빠른 참조](FUNDING_API.md)에 정리했습니다. Funding 스키마 변경은 단일 파일 [V20261015_01__create_fundings_domain.sql](src/main/resources/db/migration/V20261015_01__create_fundings_domain.sql)에 통합했습니다. + +### 이 변경에 포함하지 않음 + +- `classroom`에는 Funding의 유효 이용권 조회 계약만 추가했습니다. 클래스 Entity, `ticketId` 필드, 클래스 API/화면은 클래스 담당자가 연결해야 합니다. +- AI 도메인, AI API 및 AI Provider 연동은 구현하지 않았습니다. +- 텀블벅 명단과 텍스트 선물 Excel은 운영자가 텀블벅에서 직접 내려받고 업로드합니다. 서버는 운영자가 선택한 시트의 화면 9개 열을 자동 판별하고 유효한 행을 저장하며, 결제 완료 행만 코드화합니다. 텀블벅 사이트 업로드와 완료 결과 입력은 운영자가 수행하며 플랫폼 API 자동 동기화·이메일/SMS 자동 발송은 없습니다. +- 새 코드 형식은 `PICKE-` 뒤에 O 제외 A-Z와 1-9에서 뽑은 28개 난수를 4자씩 고정 그룹으로 출력합니다. 접두부는 엔트로피에 포함되지 않으며 난수 공간은 약 142비트입니다. 기존 PK 코드는 하위 호환을 위해 claim 허용합니다. +- 구매자 연락처 암호화 기능은 배포 환경에 `FUNDING_CONTACT_ENCRYPTION_KEY`를 Base64 형식의 32바이트 키로 설정해야 주문 등록과 연락처 조회가 작동합니다. + +Classroom 구현과 AI 도메인은 이번 범위에 포함하지 않습니다. Funding의 중복·권한·정원·기간·코드 claim 보장은 DB 제약과 트랜잭션을 기준으로 합니다. + diff --git a/FUNDING_API.md b/FUNDING_API.md new file mode 100644 index 00000000..6d45791c --- /dev/null +++ b/FUNDING_API.md @@ -0,0 +1,86 @@ +# 펀딩 API — origin/dev 통합 기준 + +공통 주문·이용권 처리는 최신 `domain.ticket`과 `domain.admin`을 사용합니다. +`domain.funding`은 텀블벅 XLSX, 원본 행 보관, 상세 검색과 감사 로그를 확장합니다. +모든 아래 경로의 접두사는 `/api/v1`입니다. + +| 기능 | Method / 경로 | +| --- | --- | +| CSV 주문 등록·갱신 | `POST /admin/funding/imports` | +| 주문·이용권 목록 및 텀블벅 원본 정보 | `GET /admin/funding/orders` | +| 캠페인별 후원자 목록 | `GET /admin/funding/orders?campaignId=...` | +| 텀블벅 캠페인 대시보드 요약 | `GET /admin/funding/orders/tumblbug/dashboard?campaignId=...` | +| 펀딩 현황 | `GET /admin/funding/summary` | +| 코드 발급 | `POST /admin/funding/tickets/issue` | +| 관리자 코드 조회·복사 | `GET /admin/funding/tickets/{ticketId}/code` | +| 전달 기록 | `POST /admin/funding/tickets/{ticketId}/deliveries` | +| 미등록 코드 재발급 | `POST /admin/funding/tickets/{ticketId}/reissue` | +| 주문 환불·이용권 취소 | `POST /admin/funding/refunds` | +| 사용자 코드 확인 | `GET /class-tickets/preview?code=...` | +| 사용자 코드 등록 | `POST /class-tickets/register` | +| 내 이용권 | `GET /class-tickets/me` | +| XLSX 시트·열·샘플 검사 | `POST /admin/funding-orders/tumblbug/inspect` | +| 선택 시트 검토 | `POST /admin/funding-orders/tumblbug/preview` | +| Start·Run 공통 엑셀 등록 및 발급 | `POST /admin/funding-orders/tumblbug/import` | +| Start·Run 공통 구매자 수동 등록 | `POST /admin/funding-orders/tumblbug/manual` | +| 주문 저장·발급·디지털 선물 XLSX 생성 | `POST /admin/funding-orders/tumblbug/digital-gifts` | +| 텀블벅 전달 결과 기록 | `PATCH /admin/funding-orders/tumblbug/digital-gifts/delivery` | +| 이용권·등록자·등록일 검색 | `GET /admin/tickets` | + +## 텀블벅 XLSX + +관리자 API는 `ROLE_ADMIN`이 필요합니다. 검사는 multipart `file`, 미리보기와 발급은 `file`과 JSON `options` 파트를 사용합니다. +파일은 XLSX 10MB 이하, 최대 5,000개 데이터 행입니다. 선물 수량은 1~7이며 코드도 수량만큼 발급합니다. + +```json +{ + "campaignId": "picke-class-2026", + "campaignName": "픽케 클래스 펀딩", + "sheetName": "15인 이용권", + "rewardLineId": "class-ticket-15", + "capacity": 15, + "sourceVersion": "export-20261021", + "plan": "START" +} +``` + +`campaignId`는 50자 이하이며 `rewardLineId`를 생략하면 시트명을 사용합니다. `plan`은 `START` 또는 `RUN`이며 생략 시 기존 API와의 호환을 위해 `RUN`으로 처리합니다. 캠페인을 처음 등록할 때 선택한 요금제는 고정됩니다. +필수 열은 후원번호, 이름, 선물, 추가 후원금, 총 후원 금액, 후원 일시, 알림 신청, 결제 상태, 선물 전달입니다. +미리보기는 저장하지 않습니다. 중복 또는 잘못된 행이 있으면 발급 요청 전체를 거부합니다. + +**결제 완료·후원일시 존재**인 주문은 구매 수량만큼 코드를 발급합니다. +결제 예약 상태는 저장만 하고 코드를 발급하지 않습니다. `codeEligibleRows`는 결제 완료된 행 수입니다. +등록기한은 결제일 + 1년, 이용기간은 등록일 + 6개월입니다. +재발급은 기존 이용권을 무효화하고 같은 등록기한으로 새 행을 생성합니다. +코드 형식은 `XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX`입니다. SHA-256 해시로 코드를 검증하고 AES-GCM 암호문으로 관리자 코드 보기를 지원합니다. + +발급 주문은 최신 `FundingOrder`에 저장하고 `AdminFundingService`로 `ClassTicket`을 발급합니다. +후원번호·리워드 키·금액·알림·원본 전달 상태·원본 행 JSON은 `TumblbugImportRecord`에 보관합니다. +내부 주문번호는 캠페인·후원번호·리워드 키의 SHA-256으로 만든 50자 식별자 `TB-...`입니다. +기존 CSV에 동일 후원번호의 주문이 있는 경우에도 중복으로 취급합니다. + +전달 결과 요청 필드는 `campaignId`, `rewardLineId`, `supporterNumbers`, `status`(`SENT`/`FAILED`), `failedReason`입니다. +실패에는 사유가 필요합니다. 공통 이력에는 `TUMBLBUG_DIGITAL_GIFT` 채널 및 `SUCCESS`/`FAILED` 상태로 기록합니다. +이미 등록·취소되었거나 결과를 기록한 코드는 건너뛰고, 재발급 코드는 새 이용권 ID로 구분합니다. +Start는 공통 `import` 또는 `manual` 경로로 등록하고 1:1 메시지로 직접 전달합니다. Run은 같은 경로에 더해 `digital-gifts` XLSX 경로를 쓸 수 있습니다. 실제 텀블벅 전달은 관리자가 수행합니다. + +## 검색과 클래스 연동 + +주문 검색은 기존 `/admin/funding/orders` 한 곳에서 `campaignId`, `keyword`, `capacity`, `paymentStatus`, `deliveryStatus`, `ticketStatus`, `codeStatus`, `page`, `size`를 지원합니다. 캠페인 대시보드는 후원 주문 수·결제 완료·전달 완료·미전달 수를 캠페인 단위로 집계합니다. +주문의 `deliveryStatus`는 살아 있는 이용권의 마지막 전달 기록 기준입니다. 모두 전달 완료면 `SENT`, 하나라도 실패면 `FAILED`, 그 밖에는 `PENDING`이며 같은 값으로 필터합니다. +기존 주문 응답 필드를 유지하고 `tumblbug`에 요금제·후원일시·상품·금액·후원번호 등을, `tickets`에 발급된 티켓 목록과 전달 상태를 추가합니다. 대시보드는 관리자 전용 `GET /admin/funding/tickets/{ticketId}/code`를 코드 전체 보기·복사 시 호출합니다. 코드 조회는 감사 로그에 기록되고 응답은 캐시되지 않습니다. +코드 암호화를 위해 `TICKET_CODE_ENCRYPTION_KEY`에 Base64 인코딩한 32바이트 키를 설정해야 합니다. 기존 코드 행은 해시만 저장되어 있어 코드 원문을 복구할 수 없습니다. 미등록 기존 코드는 재발급 후 새 코드 조회가 가능합니다. +이용권 검색은 `keyword`, `capacity`, `status`, `registeredFrom`, `registeredTo`, `page`, `size`를 지원합니다. +이용권 검색 응답의 연락처·코드를 마스킹하며 전체 코드 검색은 해시로 비교합니다. +끝 4자리 검색은 통합 이후 발급·재발급한 코드에서 지원합니다. 기존 해시로 접미사를 복원할 수는 없습니다. + +클래스는 기존 `class_tickets`와 기존 이용권 확인 로직을 계속 사용합니다. +스테이징된 `FundingTicketAccess`, `FundingTicketPolicy`는 계약 파일로만 유지합니다. +현재 호출자가 없는 별도 정책 구현은 제공하지 않습니다. + +## 추가 스키마 + +`V20261021_02__tumblbug_import_extensions.sql`과 `V20261025_01__funding_plan_and_ticket_code_vault.sql`을 기존 클래스 스키마 이후 적용합니다. +`funding_campaigns`, `tumblbug_import_records`, `funding_admin_audit_logs`, 코드 접미사, +전달 채널 길이 확장, 요금제, 암호화 코드 보관, 주문 수량당 복수 코드 제약 변경을 포함합니다. 별도 Funding 주문·이용권·코드·전달 테이블은 생성하지 않습니다. +이 저장소에는 Flyway 의존성이 없으므로 기존 DB 반영 절차에서 SQL을 적용해야 합니다. diff --git a/build.gradle b/build.gradle index f15bd9c1..a67cbc5d 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,8 @@ dependencies { // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Tumblbug supporter and digital-gift XLSX processing + implementation 'org.apache.poi:poi-ooxml:5.4.1' // Security implementation 'org.springframework.boot:spring-boot-starter-security' @@ -90,4 +92,4 @@ dependencies { tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index 6d918e9a..5299571b 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -86,6 +86,8 @@ - `page` (기본값: `1`) - `size` (기본값: `10`) - `status` (선택) +- 항목(`BattleSimpleResponse`): `battleId`, `title`, `thumbnailUrl`, `status`, `publishAt`, `createdAt`, `difficulty` + - `difficulty`: 대상 수준 (`BEGINNER` 초급 / `INTERMEDIATE` 중급 / `ADVANCED` 고급). 새로 만든 배틀은 초급으로 저장되고, [클래스 설정 수정](./class-api.md#91-배틀-클래스-설정-수정)으로 바꾼다. ### 2.4 배틀 상세 - `GET /api/v1/admin/battles/{battleId}` diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md new file mode 100644 index 00000000..16be017e --- /dev/null +++ b/docs/api-specs/class-api.md @@ -0,0 +1,1440 @@ +# 클래스 API 명세 + +기준 코드: +`src/main/java/com/swyp/picke/domain/ticket/controller/ClassTicketController.java` +`src/main/java/com/swyp/picke/domain/classroom/controller/ClassroomController.java` +`src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationController.java` +`src/main/java/com/swyp/picke/domain/classroom/controller/ClassTopicController.java` +`src/main/java/com/swyp/picke/domain/classroom/controller/ClassMemberController.java` +`src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleController.java` +`src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java` +`src/main/java/com/swyp/picke/domain/classroom/controller/ClassResultController.java` +`src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java` +`src/main/java/com/swyp/picke/domain/admin/controller/AdminFundingController.java` + +## 목차 + +0. 공통 +1. 이용권 +2. 클래스 생성·조회·수정·삭제 +3. Picke 배틀 추천 +4. 커스텀 배틀 주제 +5. 참여·멤버 +6. 클래스 배틀 진행 (투표·TTS·의견·댓글) +7. AI 철학자 토론 (커스텀 배틀) +8. 결과·AI 리포트 +9. 어드민 (배틀 클래스 설정, 펀딩·이용권) +10. 에러 코드 모음 + +--- + +## 0. 공통 + +### 0.1 요청·응답 + +- 어드민을 포함한 모든 API는 로그인이 필요하다 (`Authorization: Bearer {accessToken}`). 어드민 API는 ADMIN 권한도 필요하다. +- 응답 필드는 camelCase다. +- 시각은 KST 기준 `LocalDateTime`이고 `2026-12-31T23:59:59` 형식이다. +- 필수값 누락, 잘못된 enum 값, JSON 형식 오류는 400 `COMMON_400`이다. 각 API 표에는 따로 적지 않는다. +- 응답은 아래 형식으로 감싸서 내려간다. 에러는 `error.code`로 판별한다. + +```json +{ "statusCode": 200, "data": { } } +``` + +```json +{ "statusCode": 409, "error": { "code": "CLASS_409_FULL", "message": "정원이 가득 찬 클래스입니다." } } +``` + +### 0.2 공통 값 + +| 값 | 종류 | +|---|---| +| `difficulty` | `BEGINNER` / `INTERMEDIATE` / `ADVANCED` | +| `category` | 한글 이름으로 주고받는다: `철학` / `사회` / `문학` / `과학` / `예술` / `역사`. 추천 API 쿼리는 enum 이름(`PHILOSOPHY`)도 받는다 | +| `philosopher` | 철학자 코드 (`KANT` 등, `PhilosopherType`) | +| `myRole` / `role` | `OWNER`(운영자) / `MEMBER`(참여자) | +| `battleType` | `TTS`(기존 콘텐츠 배틀) / `CUSTOM`(AI 주제로 만든 클래스 전용 배틀) | +| `status` (투표 진행 단계) | `NONE` → `PRE_VOTE` → `TTS_LISTENING` → `POST_VOTE` → `COMPLETED` | + +### 0.3 클래스 권한 규칙 + +| 구분 | 누가 | 언제 | 거절 시 응답 | +|---|---|---|---| +| 읽기 (상세·명단·투표 통계·의견·댓글 목록·토론 조회) | 운영자 또는 참여 중인 멤버 | 마감 후에도 가능 | 403 `CLASS_403_MEMBER` | +| 쓰기 (투표·의견·댓글·토론) | 참여 중인 멤버만. `ownerParticipates=false`인 운영자는 쓸 수 없다 | 실제 마감 시각(`closeAt`) 전까지 | 403 `CLASS_403_MEMBER`, 이용권 종료 409 `CLASS_409_TICKET_ENDED`, 마감일 경과 409 `CLASS_409_CLOSED` | +| 결과·리포트 | 읽기 권한이 있는 사람 | 이용권 종료 + 30일까지 | 409 `CLASS_409_RESULT_EXPIRED` | + +- 클래스가 없거나 삭제됐으면 404 `CLASS_404`다. +- 실제 마감 시각 `closeAt`은 운영자가 정한 마감일과 이용권 종료일 중 이른 쪽이다. 마감일이 없으면 이용권 종료일이다. + +### 0.4 화면 흐름 + +**운영자: 이용권 등록** +1. 코드 확인: `GET /class-tickets/preview` +2. 등록: `POST /class-tickets/register` + +**운영자: Picke 배틀로 클래스 만들기** +1. 추천: `GET /class-battle-recommendations` +2. 클래스 생성: `POST /classes`에 `battleId`를 보낸다. + +**운영자: 커스텀 배틀로 클래스 만들기** +1. 초안 생성: `POST /class-topic-drafts` +2. AI 제안: `POST /class-topic-drafts/{draftId}/suggestions` +3. 다음 둘 중 하나로 주제 버전을 만든다. + - 후보 선택: `POST /class-topic-drafts/{draftId}/selection` + - 직접 수정 검증: `POST /class-topic-drafts/{draftId}/validations` +4. 클래스 생성: `POST /classes`에 `topicVersionId`를 보낸다. + +**참여자: 참여** +1. 코드 확인: `GET /classes/join/preview` +2. 이름 입력 뒤 참여: `POST /classes/join` + +**참여자: TTS 배틀 진행** +1. 사전투표: `POST /classes/{id}/battle/votes/pre` +2. (중간투표가 있는 콘텐츠만) 선택 지점마다 콘텐츠 중 선택: `POST /classes/{id}/battle/votes/mid` +3. TTS 청취 완료: `POST /classes/{id}/battle/votes/tts-complete` +4. 사후투표: `POST /classes/{id}/battle/votes/post` +5. 의견 작성: `POST /classes/{id}/battle/perspectives` + +**참여자: 커스텀 배틀 진행** +1. 사전투표: `POST /classes/{id}/battle/votes/pre` +2. AI 토론 시작: `POST /classes/{id}/battle/debate` +3. 반박 제출 ×3: `POST /classes/{id}/battle/debate/replies` +4. 사후투표: `POST /classes/{id}/battle/votes/post` +5. 의견 작성: `POST /classes/{id}/battle/perspectives` + +**마감 후: 결과 확인** +- 결과: `GET /classes/{id}/result` +- 내 AI 피드백: `GET /classes/{id}/result/my-report` +- 인사이트(운영자): `GET /classes/{id}/result/insight` + +--- + +## 1. 이용권 + +- 펀딩 후원자가 받은 이용권 코드를 등록하면 클래스를 만들 수 있다. +- 이용 기간은 등록 시점부터 6개월이다. +- 정원은 리워드 티어(5 / 15 / 30석)를 따른다. +- 계정당 사용 중인(ACTIVE) 이용권은 1개다. +- 코드의 공백·하이픈·대소문자는 무시한다. + +### 1.1 이용권 코드 확인 (미리보기) +- `GET /api/v1/class-tickets/preview?code=PICKE-AB12-CD34` +- 코드를 소비하지 않고 정원과 등록 기한을 보여준다. + +```json +{ + "statusCode": 200, + "data": { "capacity": 30, "registerBy": "2027-09-01T00:00:00", "usageMonths": 6, "registeredByMe": false } +} +``` + +- `registerBy`: 이 코드를 등록할 수 있는 마지막 시각 (결제 확정일 + 1년) +- `registeredByMe`: 내가 이미 등록한 코드면 true다. 이때는 등록 대신 내 이용권 조회(1.3)로 보내면 된다. + +| 상황 | 응답 | +|---|---| +| 빈 코드 | 400 `COMMON_400` | +| 없는 코드 | 404 `TICKET_404_CODE` | +| 재발급·환불로 무효화된 코드 | 409 `TICKET_409_REVOKED` | +| 등록 기한 경과 | 409 `TICKET_409_REG_EXP` | +| 다른 계정이 이미 등록 | 409 `TICKET_409_CLAIMED` | +| 내가 이미 사용 중인 이용권이 있음 | 409 `TICKET_409_ACTIVE` | + +### 1.2 이용권 코드 등록 +- `POST /api/v1/class-tickets/register` + +```json +{ "code": "PICKE-AB12-CD34" } +``` + +- 등록하면 즉시 ACTIVE가 되고 6개월 뒤 만료된다. +- 응답: `ClassTicketResponse` (1.3과 같다) +- 내가 이미 등록한 코드를 다시 보내면 거절하지 않고 기존 결과를 돌려준다. 이용 기간은 바뀌지 않는다. +- 에러는 1.1과 같다. + +### 1.3 내 이용권 조회 +- `GET /api/v1/class-tickets/me` +- 사용 중(ACTIVE) 이용권을 돌려준다. 없으면 가장 최근에 만료된(EXPIRED) 이용권을 돌려준다. +- 둘 다 없으면 404 `TICKET_404`다. + +```json +{ + "statusCode": 200, + "data": { + "ticketId": 3, + "capacity": 30, + "status": "ACTIVE", + "activatedAt": "2026-10-01T10:00:00", + "expiresAt": "2027-04-01T10:00:00", + "remainingDays": 162, + "readGraceEndsAt": "2027-05-01T10:00:00" + } +} +``` + +- `remainingDays`: 만료까지 남은 일수. 만료됐으면 0이다. +- `readGraceEndsAt`: 결과를 읽을 수 있는 마지막 시각 (`expiresAt` + 30일) + +--- + +## 2. 클래스 생성·조회·수정·삭제 + +### 2.1 클래스 생성 +- `POST /api/v1/classes` + +```json +{ + "name": "3학년 2반 철학 토론", + "battleId": 12, + "topicVersionId": null, + "deadline": "2026-12-31T23:59:59", + "commentRequired": true, + "ownerParticipates": true, + "ownerDisplayName": "김선생", + "requestKey": "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b" +} +``` + +| 필드 | 필수 | 설명 | +|---|---|---| +| `name` | O | 클래스 이름 1~20자 | +| `battleId` | △ | 기존 콘텐츠(TTS) 배틀 ID. `topicVersionId`와 둘 중 **하나만** 보낸다 | +| `topicVersionId` | △ | 검증된 커스텀 주제 버전 ID (4장). 클래스 전용 커스텀 배틀을 새로 만든다 | +| `deadline` | X | 참여 마감일. null이면 이용권 종료일까지. 지금 이후이고 이용권 종료일 이전이어야 한다 | +| `commentRequired` | O | 의견 작성 필수 여부. 참여 완료 판정에 쓴다 | +| `ownerParticipates` | O | 운영자도 참여자로 들어갈지. true면 운영자가 1석을 차지하고 투표·의견을 쓸 수 있다 | +| `ownerDisplayName` | X | 운영자가 참여할 때 클래스 안에서 쓸 이름 (1~20자). 비우면 닉네임을 쓴다 | +| `requestKey` | X (권장) | 중복 생성 방지 키 (1~64자, 영문·숫자·`-`·`_`). 생성 화면을 열 때 UUID를 하나 만들고, 재전송할 때도 같은 값을 보낸다 | + +- 정원은 이용권 정원을 그대로 쓴다. +- 초대코드 6자는 서버가 만든다. +- 커스텀 배틀의 썸네일은 환경변수 `CLASS_CUSTOM_BATTLE_THUMBNAIL_URL`의 고정 이미지 1종이다. +- 응답: `ClassDetailResponse` (2.3 참고) +- **중복 요청 방지**: 버튼 연타·네트워크 재전송 대비. 같은 운영자의 생성 요청은 이용권 행 잠금으로 하나씩 처리한다. + - `requestKey`를 보내면: 같은 키·같은 내용이면 새로 만들지 않고 그 클래스를 200으로 돌려준다. 같은 키·다른 내용이면 409 `CLASS_409_REQUEST_KEY`다. 키가 다르면 내용이 같아도 새로 만든다. + - `requestKey`가 없으면: 10초 안에 같은 이름·같은 배틀(또는 주제 버전)로 다시 보낸 요청을 중복으로 보고 직전 클래스를 200으로 돌려준다. + - 이름·운영자 표시 이름의 앞뒤 공백은 같은 내용으로 본다. + +| 상황 | 응답 | +|---|---| +| 사용 중인 이용권 없음 | 403 `CLASS_403_TICKET` | +| `battleId`·`topicVersionId`를 둘 다 보내거나 둘 다 비움 | 400 `CLASS_400_BATTLE_SOURCE` | +| 없는 배틀 | 404 `BATTLE_404` | +| 공개되지 않았거나 삭제된 배틀 | 400 `CLASS_400_BATTLE` | +| 없는 주제 버전이거나 본인 초안의 버전이 아님 | 404 `CLASS_404_TOPIC_VERSION` | +| 같은 초안으로 이미 클래스를 만듦 (초안 1개당 클래스 1개) | 409 `CLASS_409_TOPIC_DRAFT_USED` | +| 마감일이 지금 이전이거나 이용권 종료일 이후 | 400 `CLASS_400_DEADLINE` | +| 같은 `requestKey`로 다른 내용을 보냄 | 409 `CLASS_409_REQUEST_KEY` | +| `requestKey`로 만든 클래스가 이미 삭제됨 | 409 `CLASS_409_DELETED` | +| `requestKey` 형식 오류 | 400 `COMMON_400` | +| 초대코드 생성 실패 (드묾) | 500 `CLASS_500_CODE` | + +### 2.2 내 클래스 목록 +- `GET /api/v1/classes` +- 내가 운영하는 클래스와 참여 중인 클래스를 최신 생성순으로 돌려준다. 삭제된 클래스는 빠진다. + +```json +{ + "statusCode": 200, + "data": [ + { + "classId": 5, + "name": "3학년 2반 철학 토론", + "myRole": "OWNER", + "battleId": 12, + "battleTitle": "AI가 쓴 글도 창작일까?", + "battleThumbnailUrl": "https://...", + "battleType": "TTS", + "memberCount": 12, + "capacity": 30, + "closeAt": "2026-12-31T23:59:59", + "closed": false, + "createdAt": "2026-10-21T10:00:00" + } + ] +} +``` + +- `memberCount`: 좌석을 차지한 인원이다. 운영자가 참여하면 포함되고, 나갔지만 활동 이력이 있어 좌석을 유지한 멤버도 포함된다. + +### 2.3 클래스 상세 +- `GET /api/v1/classes/{classId}` +- 운영자 또는 참여 중인 멤버만 볼 수 있다. 아니면 403 `CLASS_403_MEMBER`다. + +```json +{ + "statusCode": 200, + "data": { + "classId": 5, + "name": "3학년 2반 철학 토론", + "inviteCode": "A7K2MP", + "myRole": "OWNER", + "ownerId": 1, + "battle": { "battleId": 12, "title": "...", "summary": "...", "thumbnailUrl": "https://..." }, + "battleType": "TTS", + "topicVersionId": null, + "memberCount": 12, + "capacity": 30, + "deadline": "2026-12-31T23:59:59", + "ticketExpiresAt": "2027-04-01T10:00:00", + "closeAt": "2026-12-31T23:59:59", + "closed": false, + "commentRequired": true, + "ownerParticipates": true, + "createdAt": "2026-10-21T10:00:00" + } +} +``` + +- `inviteCode`: 운영자·멤버 모두에게 내려간다. 멤버도 공유하기 버튼으로 코드를 공유할 수 있다. +- `topicVersionId`: 커스텀 배틀의 주제 버전 ID다. TTS 클래스는 null이다. +- `deadline`: 운영자가 정한 마감일이다. null이면 마감일이 없다. + +### 2.4 클래스 수정 (운영자) +- `PATCH /api/v1/classes/{classId}` +- 이름 변경과 마감일 연장만 할 수 있다. null 필드는 변경하지 않는다. + +```json +{ "name": "3학년 2반 철학 토론", "deadline": "2027-01-31T23:59:59" } +``` + +| 상황 | 응답 | +|---|---| +| 운영자가 아님 | 403 `CLASS_403_OWNER` | +| 이미 마감됨 | 409 `CLASS_409_CLOSED` | +| 이름이 공백뿐 | 400 `COMMON_400` | +| 마감일이 기존 마감일보다 앞임 (연장만 가능) | 400 `CLASS_400_DEADLINE_EXT` | +| 마감일이 이용권 종료일 이후 | 400 `CLASS_400_DEADLINE` | + +### 2.5 클래스 삭제 (운영자) +- `DELETE /api/v1/classes/{classId}` +- 소프트 삭제다. 이후 목록·상세에서 보이지 않는다. +- 삭제된 클래스의 초대코드로 참여하면 409 `CLASS_409_DELETED`다. +- 운영자가 아니면 403 `CLASS_403_OWNER`다. + +--- + +## 3. Picke 배틀 추천 + +### 3.1 조건에 맞는 배틀 추천 +- `GET /api/v1/class-battle-recommendations` +- 쿼리 파라미터: + +| 이름 | 필수 | 설명 | +|---|---|---| +| `difficulty` | O | 대상 수준 | +| `categories` | O | 카테고리 1~3개. `categories=철학,사회` 또는 `categories=철학&categories=사회` | +| `keyword` | X | 찾고 싶은 주제. 50자 이하이고 쉼표·공백으로 여러 단어를 넣을 수 있다 (앞 5단어만 사용) | + +- **후보:** 공개(PUBLISHED) 상태이고 삭제되지 않았으며, 어드민이 클래스 추천을 켠 배틀이다 (9.1). 그중 수준이 같고 카테고리가 하나라도 겹치는 배틀만 남긴다. +- **keyword를 넣으면:** 제목·요약·설명에 단어가 하나 이상 들어간 배틀만 남긴다. +- **정렬:** 일치한 단어 수 → 에디터 픽 → 참여자 수 → 최신순 +- **개수:** 최대 3개다. 조건에 맞는 배틀이 적으면 채우지 않고 0~2개를 그대로 돌려준다. 빈 결과일 때 보여줄 안내 화면이 필요하다. + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "battleId": 12, + "title": "AI가 쓴 글도 창작일까?", + "summary": "...", + "thumbnailUrl": "https://...", + "category": "철학", + "difficulty": "BEGINNER", + "audioDuration": 180, + "options": [ + { "optionId": 31, "position": "A", "title": "창작이다", "stance": "...", "representative": "니체", "imageUrl": "https://..." }, + { "optionId": 32, "position": "B", "title": "창작이 아니다", "stance": "...", "representative": "칸트", "imageUrl": "https://..." } + ] + } + ] + } +} +``` + +- `audioDuration`은 초 단위다. +- 미리듣기 음성은 `GET /api/v1/battles/{battleId}/scenario`로 받는다. + +| 상황 | 응답 | +|---|---| +| 수준 누락, 카테고리 0개·4개 이상·잘못된 값, 주제 50자 초과 | 400 `CLASS_400_RECOMMEND_CONDITION` | + +--- + +## 4. 커스텀 배틀 주제 + +AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 고친다. 고른 결과는 검증된 "주제 버전"으로 저장되고, 클래스 생성(2.1)에는 이 `topicVersionId`만 쓸 수 있다. + +### 4.1 주제 초안 생성 +- `POST /api/v1/class-topic-drafts` +- AI를 호출하지 않는다. 초안마다 제안 3회와 수정 검증 3회가 새로 주어진다. + +```json +{ + "topicSeed": "카뮈 『이방인』의 뫼르소처럼 어머니 장례식에서 울지 않은 사람을 비난할 수 있을까", + "difficulty": "INTERMEDIATE", + "categories": ["철학", "문학"] +} +``` + +| 필드 | 필수 | 설명 | +|---|---|---| +| `topicSeed` | O | 주제·관심사. 앞뒤 공백 제거 후 2~200자 | +| `difficulty` | O | 대상 수준 | +| `categories` | O | 카테고리 1~3개 (중복은 제거) | + +- 응답: `ClassTopicDraftResponse` (4.6 참고). 처음에는 `suggestStatus=NONE`이고 `batchNo=0`이다. + +| 상황 | 응답 | +|---|---| +| 사용 중인 이용권 없음 | 403 `CLASS_403_TICKET` | +| 주제 길이·카테고리 개수 위반 | 400 `CLASS_400_TOPIC_CONDITION` | + +### 4.2 주제 초안 조회 +- `GET /api/v1/class-topic-drafts/{draftId}` +- 저장된 후보, 상태, 남은 횟수를 돌려준다. AI는 호출하지 않는다. +- 제안 중에 화면을 나갔다가 돌아오면 이 API로 결과를 이어서 본다. +- 본인 초안이 아니거나 없는 초안이면 404 `CLASS_404_TOPIC_DRAFT`다. + +### 4.3 AI 주제 제안 (최초·재추천) +- `POST /api/v1/class-topic-drafts/{draftId}/suggestions` +- 본문은 생략할 수 있다. 조건을 바꿔 재추천할 때만 바꿀 필드를 채운다. null 필드는 초안의 기존 조건을 쓴다. + +```json +{ "topicSeed": "...", "difficulty": "BEGINNER", "categories": ["사회"] } +``` + +- 응답은 `ClassTopicDraftResponse`다. 결과는 `suggestStatus`로 구분한다. + +| suggestStatus | 의미 | 화면 처리 | +|---|---|---| +| `SUCCEEDED` | 후보 3개 생성. `batchNo`가 1 늘고 제안 횟수가 1 줄어든다 | 후보 표시 | +| `NEEDS_CLARIFICATION` | 맥락이 부족함 | `clarificationMessage` 표시 | +| `FAILED` | AI 생성 실패. 기존 후보와 횟수는 그대로다. 이유는 `failureReason` | 재시도 안내 | +| `GENERATING` | 생성 중 | "생각을 정리하고 있어요" | + +| failureReason (FAILED 일 때만) | 의미 | 화면 처리 | +|---|---|---| +| `AI_UNAVAILABLE` | AI 서비스 장애 (연결 오류·시간 초과·5xx·크레딧 소진 등) | "잠시 후 다시 시도해 주세요" | +| `INVALID_OUTPUT` | AI 응답이 1회 보정 후에도 후보 규칙을 지키지 못함 | 다시 시도하거나 조건 변경 안내 | + +| 상황 | 응답 | +|---|---| +| 성공 배치 3회 소진 | 409 `CLASS_409_TOPIC_SUGGEST_LIMIT` | +| 같은 초안이 이미 생성 중 (3분 지나면 다시 허용) | 409 `CLASS_409_TOPIC_GENERATING` | +| 같은 이용권으로 분당 2회 초과 | 429 `CLASS_429_TOPIC` | +| 조건 위반 | 400 `CLASS_400_TOPIC_CONDITION` | +| 초안 없음 | 404 `CLASS_404_TOPIC_DRAFT` | + +### 4.4 후보 선택 +- `POST /api/v1/class-topic-drafts/{draftId}/selection` +- 고른 후보를 검증된 주제 버전으로 저장한다. AI는 호출하지 않는다. + +```json +{ "batchNo": 1, "candidateId": 3 } +``` + +- `batchNo`에는 화면에 보인 배치 번호를 보낸다. +- 같은 후보를 다시 고르면 기존 버전을 그대로 돌려준다. +- 응답: `ClassTopicVersionResponse` (4.6 참고) + +| 상황 | 응답 | +|---|---| +| `batchNo`가 현재 배치와 다름 (그 사이 재추천됨) | 409 `CLASS_409_TOPIC_CANDIDATE` | +| 없는 후보 ID | 404 `CLASS_404_TOPIC_CANDIDATE` | + +### 4.5 직접 수정 검증 +- `POST /api/v1/class-topic-drafts/{draftId}/validations` +- 저장된 버전을 바탕으로 질문·설명·양측 선택지를 함께 보낸다. 카테고리·수준은 바탕 버전 값을 그대로 쓴다. + +```json +{ + "baseVersionId": 12, + "question": "표현하지 않은 감정까지 타인이 판단해도 될까요?", + "description": "감정 표현의 개인적 자유와 공동체의 기대를 비교해 봐요.", + "options": [ + { "label": "판단할 수 있다", "rationale": "표현은 관계에 대한 책임의 일부다", "philosopher": "KANT" }, + { "label": "판단할 수 없다", "rationale": "...", "philosopher": null } + ] +} +``` + +| 필드 | 제한 | +|---|---| +| `question` | 60자 이하 | +| `description` | 80자 이하 | +| `options` | 2개 (A, B 순서) | +| `options[].label` | 20자 이하 | +| `options[].rationale` | 40자 이하 | +| `options[].philosopher` | 비우면 바탕 버전의 철학자를 그대로 쓴다 | + +- **길이·필수값 오류:** AI를 호출하지 않고 `issues`로 돌려준다. 검증 횟수는 쓰지 않는다. +- **통과:** `valid=true`가 되고 새 주제 버전이 만들어진다. +- **실패:** `valid=false`와 `issues`를 돌려준다. 마지막으로 검증된 버전은 그대로 유지된다. +- AI는 수정안을 고쳐 쓰지 않고 판정만 한다. + +```json +{ + "statusCode": 200, + "data": { + "valid": false, + "issues": [ + { "field": "options[1].label", "code": "TOO_LONG", "message": "선택 문구은(는) 20자 이하로 적어 주세요. (현재 23자)" } + ], + "validatedTopicVersionId": null, + "remainingValidations": 3, + "version": null + } +} +``` + +- **`issues[].field` 값:** `question`, `description`, `options[i].label`, `options[i].rationale`, `options[i].philosopher`, `topic` +- **`issues[].code` 값** + - 입력 오류 (횟수 차감 없음): `REQUIRED`, `TOO_LONG`, `DUPLICATE_OPTION`, `SAME_PHILOSOPHER` + - AI 판정: `OPTIONS_NOT_SAME_AXIS`, `OPTION_MISMATCH_QUESTION`, `DESCRIPTION_MISMATCH`, `RATIONALE_MISMATCH`, `PHILOSOPHER_MISMATCH`, `IMPLIES_ANSWER`, `DIGNITY`, `OTHER` + +| 상황 | 응답 | +|---|---| +| 검증 3회 소진 | 409 `CLASS_409_TOPIC_VALIDATION_LIMIT` | +| AI 장애 (횟수 차감 없음) | 503 `CLASS_503_TOPIC_AI` | +| `baseVersionId`가 이 초안의 버전이 아님 | 404 `CLASS_404_TOPIC_VERSION` | + +### 4.6 응답 객체 + +`ClassTopicDraftResponse` + +| 필드 | 설명 | +|---|---| +| `draftId` | 초안 ID | +| `topicSeed`, `difficulty`, `categories` | 마지막 성공 배치 기준 조건 | +| `suggestStatus` | `NONE` / `GENERATING` / `SUCCEEDED` / `NEEDS_CLARIFICATION` / `FAILED` | +| `clarificationMessage` | 맥락 부족 안내 (nullable) | +| `failureReason` | `AI_UNAVAILABLE` / `INVALID_OUTPUT`. `FAILED`가 아니면 null | +| `batchNo` | 현재 후보의 배치 번호 (0이면 후보 없음) | +| `remainingSuggestions` | 남은 제안 횟수 (최대 3) | +| `remainingValidations` | 남은 수정 검증 횟수 (최대 3) | +| `candidates[]` | `candidateId`(1~3), `question`, `description`, `category`, `difficulty`, `options[]` | +| `latestVersion` | 가장 최근 검증된 주제 버전 (nullable) | + +`ClassTopicVersionResponse` + +| 필드 | 설명 | +|---|---| +| `topicVersionId` | 클래스 생성에 쓰는 ID | +| `draftId`, `versionNo` | 초안 ID, 초안 안의 버전 번호 (1부터) | +| `source` | `CANDIDATE`(AI 후보 선택) / `EDITED`(직접 수정 후 검증 통과) | +| `question`, `description`, `category`, `difficulty`, `createdAt` | | +| `options[]` | `position`(A/B), `label`, `rationale`, `philosopher`, `philosopherName`, `philosopherImageKey` | + +--- + +## 5. 참여·멤버 + +### 5.1 참여 코드 확인 +- `GET /api/v1/classes/join/preview?inviteCode=A7K2MP` +- 참여 처리 없이 코드만 확인한다 (코드 입력 단계). +- 코드에 들어간 공백·하이픈과 대소문자는 서버가 정리한다. + +```json +{ + "statusCode": 200, + "data": { + "inviteCode": "A7K2MP", + "name": "3학년 2반 철학 토론", + "battleTitle": "AI가 쓴 글도 창작일까?", + "battleThumbnailUrl": "https://...", + "battleType": "TTS", + "memberCount": 12, + "capacity": 30, + "closeAt": "2026-12-31T23:59:59" + } +} +``` + +- `inviteCode`는 정리된 6자 코드다. 참여 API에 이 값을 그대로 보낸다. + +| 상황 (판정 순서) | 응답 | +|---|---| +| 코드 형식 오류 (공백·하이픈 제거 후 6자가 아님) | 400 `COMMON_400` | +| 없는 코드 | 404 `CLASS_404_CODE` | +| 삭제된 클래스 | 409 `CLASS_409_DELETED` | +| 내가 만든 클래스 | 409 `CLASS_409_OWNER` | +| 이미 참여 중 | 409 `CLASS_409_JOINED` | +| 내보내진 클래스 | 403 `CLASS_403_REMOVED` | +| 이용권 종료 | 409 `CLASS_409_TICKET_ENDED` | +| 마감일 경과 | 409 `CLASS_409_CLOSED` | +| 정원 초과 | 409 `CLASS_409_FULL` | + +- 이름 중복은 이 API가 아니라 참여 API에서 판정한다. + +### 5.2 초대코드로 참여 +- `POST /api/v1/classes/join` + +```json +{ "inviteCode": "A7K2MP", "displayName": "김학생" } +``` + +- `displayName`: 클래스 안에서 쓸 이름이다. 앞뒤 공백 제거 후 1~20자이고, 같은 클래스 안에서 겹칠 수 없다. +- 응답: `ClassDetailResponse` (2.3) +- **이미 참여 중:** 거절하지 않고 200과 기존 상세를 돌려준다. 다시 요청해도 안전하게 하려는 동작이다. 그래서 "이미 참여 중" 팝업은 코드 확인(5.1)에서 띄운다. +- **나갔던 멤버:** 같은 코드로 다시 참여할 수 있다. 활동 이력으로 좌석을 유지하고 있었으면 좌석을 새로 쓰지 않는다. +- **같은 검사를 한 번 더 함:** 코드 확인 뒤 이름을 입력하는 사이에 정원이 차거나 마감될 수 있다. 그래서 5.1의 검사를 여기서도 다시 한다. + +| 상황 | 응답 | +|---|---| +| 5.1의 에러 (이미 참여 중 제외) | 5.1과 같음 | +| 이름이 비었거나 20자 초과 | 400 `COMMON_400` | +| 이름 중복 | 409 `CLASS_409_NAME` | + +### 5.3 클래스 명단 +- `GET /api/v1/classes/{classId}/members` +- 운영자 행이 먼저 오고, 이후 참여 순이다. +- 표시 이름·역할·아바타만 내려가고 계정 식별자는 내려가지 않는다. + +```json +{ + "statusCode": 200, + "data": [ + { "memberId": 10, "displayName": "김선생", "role": "OWNER", "characterUrl": "https://...", "me": true, "joinedAt": "2026-10-21T10:00:00" }, + { "memberId": 11, "displayName": "김학생", "role": "MEMBER", "characterUrl": "https://...", "me": false, "joinedAt": "2026-10-21T11:00:00" } + ] +} +``` + +- `memberId`: 내보내기(5.6)에 쓴다. +- `me`: 조회한 사람 본인이면 true다 (이름 수정 버튼 노출용). +- 권한이 없으면 403 `CLASS_403_MEMBER`다. + +### 5.4 내 표시 이름 변경 +- `PATCH /api/v1/classes/{classId}/members/me` + +```json +{ "displayName": "김학생2" } +``` + +- 응답: `ClassMemberResponse` (5.3의 항목 하나) + +| 상황 | 응답 | +|---|---| +| 참여 중인 멤버가 아님 | 403 `CLASS_403_MEMBER` | +| 이름이 비었거나 20자 초과 | 400 `COMMON_400` | +| 이름 중복 | 409 `CLASS_409_NAME` | + +### 5.5 클래스 나가기 +- `DELETE /api/v1/classes/{classId}/members/me` +- 나간 뒤에도 같은 코드로 다시 참여할 수 있다. +- 투표·의견 등 활동 이력이 있으면 좌석은 반환되지 않는다. + +계정 탈퇴 시에도 같은 좌석 규칙을 적용한다. 탈퇴자가 운영자인 ACTIVE 클래스는 소프트 삭제하고, 참여 중인 멤버십은 `LEFT`로 바꾼다. 탈퇴자가 쓴 의견·댓글은 숨김 처리하며 이력 참조를 위해 DB 행은 보존한다. + +| 상황 | 응답 | +|---|---| +| 운영자 (나갈 수 없고 클래스를 삭제해야 함) | 403 `CLASS_403_OWNER_LEAVE` | +| 참여 중인 멤버가 아님 | 403 `CLASS_403_MEMBER` | + +### 5.6 멤버 내보내기 (운영자) +- `DELETE /api/v1/classes/{classId}/members/{memberId}` +- 내보내진 멤버는 같은 클래스에 다시 참여할 수 없다. +- 좌석 반환 규칙은 나가기(5.5)와 같다. + +| 상황 | 응답 | +|---|---| +| 운영자가 아님 | 403 `CLASS_403_OWNER` | +| 없는 멤버이거나 이미 나간 멤버 | 404 `CLASS_404_MEMBER` | +| 운영자 본인 행 | 403 `CLASS_403_OWNER_LEAVE` | + +--- + +## 6. 클래스 배틀 진행 + +- 경로에 배틀 ID는 없다. 클래스에 붙은 배틀을 쓴다. +- 투표·참여자 수·의견은 이 클래스 안에서만 집계하고, 일반 배틀과 섞이지 않는다. +- 크레딧 차감, 성향 점수, 일반 참여자 수에는 영향이 없다. +- 권한은 0.3의 읽기·쓰기 규칙을 따른다. + +### 6.1 클래스 배틀 상세 +- `GET /api/v1/classes/{classId}/battle` +- 마감 후에도 조회할 수 있다. + +```json +{ + "statusCode": 200, + "data": { + "classId": 5, + "myRole": "MEMBER", + "closeAt": "2026-12-31T23:59:59", + "closed": false, + "commentRequired": true, + "canParticipate": true, + "participationCompleted": false, + "midVoteEnabled": false, + "battle": { } + } +} +``` + +- `canParticipate`: 지금 투표·의견을 쓸 수 있는지다. 참여 중인 멤버이고 마감 전일 때 true다. 참여하지 않는 운영자는 false다. false면 투표·의견 버튼을 막는다. +- `participationCompleted`: 참여 완료 여부다. 사후투표를 마쳤고, 의견이 필수면 공개된 내 의견이 1건 이상일 때 true다. +- `midVoteEnabled`: 중간투표가 있는 기존 콘텐츠(TTS)인지다. 배틀의 중간투표 설정이 켜져 있고 시나리오에 선택 지점(선택지가 있는 노드)이 있을 때 true다. true면 콘텐츠의 선택 화면에서 고른 값을 6.3.1로 보낸다. 커스텀 배틀은 항상 false다. +- `battle`: 일반 배틀 상세(`BattleUserDetailResponse`)와 같은 구조다. 단 `userVoteStatus`, `currentStep`, `participantsCount`는 이 클래스 기준이다. 구조는 `battle-api.md`를 참고한다. + +### 6.2 사전투표 +- `POST /api/v1/classes/{classId}/battle/votes/pre` + +```json +{ "optionId": 31 } +``` + +```json +{ "statusCode": 200, "data": { "voteId": 100, "status": "PRE_VOTE" } } +``` + +- 배틀 시작 전(`NONE`/`PRE_VOTE`)에는 다시 보내서 바꿀 수 있다. + +| 상황 | 응답 | +|---|---| +| TTS 청취 이후 또는 AI 토론 시작 이후 변경 | 409 `CLASS_409_PRE_VOTE_LOCKED` | +| 이 배틀의 선택지가 아님 | 404 `BATTLE_OPT_404` | + +### 6.3 TTS 청취 완료 (TTS 클래스만) +- `POST /api/v1/classes/{classId}/battle/votes/tts-complete` +- 사전투표 후 오디오 재생이 끝났을 때 호출한다. +- 단계가 `POST_VOTE`로 넘어가고, 이후 사전투표가 잠긴다. 이 호출이 있어야 사후투표를 할 수 있다. +- 이미 완료(`COMPLETED`)된 진행은 되돌리지 않는다. + +| 상황 | 응답 | +|---|---| +| 커스텀 배틀 클래스 | 409 `CLASS_409_TTS` | +| 사전투표 전 | 404 `VOTE_404` | + +### 6.3.1 중간투표 (중간투표가 있는 TTS 콘텐츠만) +- `POST /api/v1/classes/{classId}/battle/votes/mid` +- 콘텐츠 재생 중 "선택의 시간"에서 고른 값을 선택 지점마다 기록한다. 클래스용 별도 화면은 없고, 기존 콘텐츠 선택 결과를 수집하는 용도다. +- 선택 지점(checkpoint)은 선택지가 있는 시나리오 노드다. 값은 시나리오 조회(`GET /battles/{battleId}/scenario`)의 노드 ID와 `interactiveOptions[].optionId`를 쓴다. + +```json +{ "checkpointId": 801, "optionId": 9001 } +``` + +```json +{ "statusCode": 200, "data": { "voteId": 100, "status": "TTS_LISTENING" } } +``` + +- 지점마다 1건만 저장한다. 같은 지점을 다시 보내면 덮어쓴다. 사후투표 전까지 바꿀 수 있다(콘텐츠를 다시 재생한 경우). +- 처음 보내면 단계가 `TTS_LISTENING`으로 바뀌고, 이후 사전투표가 잠긴다. 이미 `POST_VOTE`면 단계는 그대로 둔다. +- 필수 중간투표: 시작 노드부터 내가 고른 선택지를 따라가며 만나는 선택 지점에 모두 답해야 사후투표(6.4)를 할 수 있다. +- 선택 당시 선택지 문구를 함께 저장한다. 시나리오를 수정해도 결과·리포트에는 고른 문구가 남는다. + +| 상황 | 응답 | +|---|---| +| 중간투표가 없는 배틀(설정 꺼짐·선택 지점 없음)이거나 커스텀 배틀 클래스 | 409 `CLASS_409_MID_VOTE` | +| 없는 선택 지점이거나 그 지점의 선택지가 아님 | 404 `CLASS_404_MID_VOTE_OPTION` | +| 사전투표 전 | 404 `VOTE_404` | +| 사후투표 완료 후 | 409 `CLASS_409_MID_VOTE_LOCKED` | + +### 6.4 사후투표 +- `POST /api/v1/classes/{classId}/battle/votes/post` +- 요청은 6.2와 같고, 응답의 `status`는 `COMPLETED`다. +- 제출한 뒤에는 바꿀 수 없다. + +| 상황 | 응답 | +|---|---| +| 사전투표 기록 없음 | 404 `VOTE_404` | +| 진행 단계가 `NONE` | 409 `VOTE_409_PRE` | +| TTS 클래스인데 청취 완료(6.3) 전 | 409 `CLASS_409_TTS_INCOMPLETE` | +| 중간투표 콘텐츠인데 고른 경로의 선택 지점에 답하지 않음 | 409 `CLASS_409_MID_VOTE_REQUIRED` | +| 이미 제출 | 409 `VOTE_409_SUB` | +| 커스텀 배틀인데 AI 토론을 마치지 않음 | 409 `CLASS_409_DEBATE_INCOMPLETE` | +| 이 배틀의 선택지가 아님 | 404 `BATTLE_OPT_404` | + +### 6.5 내 투표 내역 +- `GET /api/v1/classes/{classId}/battle/votes/me` + +```json +{ + "statusCode": 200, + "data": { + "battleTitle": "AI가 쓴 글도 창작일까?", + "preVote": { "optionId": 31, "label": "A", "title": "창작이다" }, + "midVotes": [ { "checkpointId": 801, "optionId": 9002, "label": "무죄" } ], + "postVote": { "optionId": 32, "label": "B", "title": "창작이 아니다" }, + "status": "COMPLETED", + "opinionChanged": true + } +} +``` + +- 투표 전이면 404 `VOTE_404`다. +- `midVotes`는 선택 지점별 중간투표(6.3.1)다. 지점 ID 순이고, 중간투표가 없는 배틀이거나 아직 고르지 않았으면 빈 배열이다. +- `opinionChanged`는 사전·사후만 비교한다. + +### 6.6 투표 통계 +- `GET /api/v1/classes/{classId}/battle/vote-stats` +- 이 클래스에 지금 참여 중인(ACTIVE) 멤버들의 사후투표만 집계한다. 나가거나 내보내진 멤버의 투표는 빠지고, 다시 참여하면 다시 포함된다. + +```json +{ + "statusCode": 200, + "data": { + "options": [ + { "optionId": 31, "title": "창작이다", "imageUrl": "https://...", "voteCount": 7, "ratio": 58.3 }, + { "optionId": 32, "title": "창작이 아니다", "imageUrl": "https://...", "voteCount": 5, "ratio": 41.7 } + ], + "totalCount": 12, + "updatedAt": "2026-10-21T12:00:00" + } +} +``` + +### 6.7 의견 등록 +- `POST /api/v1/classes/{classId}/battle/perspectives` + +```json +{ "content": "저는 ... 라고 생각해요." } +``` + +```json +{ "statusCode": 200, "data": { "perspectiveId": 50, "status": "PUBLISHED", "createdAt": "2026-10-21T12:00:00" } } +``` + +- 사후투표한 선택지에 귀속된다. +- 의견 작성이 필수인 클래스(`commentRequired=true`)의 의견은 삭제할 수 없고 수정만 할 수 있다. 리포트 자료로 쓰이기 때문이다. 삭제하면 409 `CLASS_409_PERSPECTIVE_REQUIRED`다. +- 바로 `PUBLISHED`로 저장된다. +- 사후투표 전이면 404 `BATTLE_OPT_404`다. + +### 6.8 의견 목록 +- `GET /api/v1/classes/{classId}/battle/perspectives` +- 이 클래스 안에서 작성된 의견만 커서 기반으로 조회한다. + +| 쿼리 | 필수 | 설명 | +|---|---|---| +| `cursor` | X | 이전 응답의 `nextCursor` | +| `size` | X | 기본 20 | +| `optionId` | X | 선택지 필터 | +| `sort` | X | `latest`(기본) / `popular` | + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { + "perspectiveId": 50, + "user": { "userTag": "...", "nickname": "...", "characterType": "...", "characterImageUrl": "https://..." }, + "option": { "optionId": 31, "label": "A", "title": "창작이다", "stance": "..." }, + "content": "...", + "likeCount": 3, + "commentCount": 1, + "isLiked": false, + "isMyPerspective": true, + "createdAt": "2026-10-21T12:00:00" + } + ], + "nextCursor": "2026-10-21T12:00:00", + "hasNext": false + } +} +``` + +- `user.nickname`에는 전체 서비스 닉네임 대신 이 클래스의 표시 이름(5.2·5.4)이 내려간다. +- `user.userTag`는 클래스에서는 null이다. 계정 식별자를 클래스 안에 노출하지 않는다. +- 나가거나 내보내진 멤버의 의견은 목록에서 빠진다. 다시 참여하면 다시 보인다. + +### 6.9 내 의견 +- `GET /api/v1/classes/{classId}/battle/perspectives/me` +- 이 클래스 안에서 내가 쓴 의견 목록이다. +- 항목 구조는 6.8과 같고, `isMyPerspective` 대신 `status`가 들어간다. + +### 6.10 의견의 댓글·수정·삭제·좋아요 +클래스 의견도 일반 관점 API를 그대로 쓴다. 구조는 `comments-api.md`, `perspectives-api.md`, `likes-api.md`를 참고한다. + +클래스 의견·댓글이면 의견 상세, 댓글 목록, 댓글 등록 응답의 `user.nickname`도 클래스 표시 이름이고 `user.userTag`는 null이다. +나가거나 내보내진 멤버의 댓글은 댓글 목록에서 빠진다. 그 멤버의 의견은 상세 조회·댓글 조회·댓글 등록 모두 404 `PERSPECTIVE_404`다. + +| 기능 | API | 클래스 권한 검사 | +|---|---|---| +| 의견 상세·수정·삭제 | `GET` / `PATCH` / `DELETE /api/v1/perspectives/{perspectiveId}` | 조회는 읽기 규칙, 수정·삭제는 쓰기 규칙 (0.3) | +| 댓글 목록 | `GET /api/v1/perspectives/{perspectiveId}/comments` (`/labeled` 포함) | 읽기 규칙 | +| 댓글 작성·수정·삭제 | `POST` / `PATCH` / `DELETE /api/v1/perspectives/{perspectiveId}/comments/...` | 쓰기 규칙 | +| 의견 좋아요 수 | `GET /api/v1/perspectives/{perspectiveId}/likes` | 읽기 규칙 | +| 의견 좋아요 등록·취소 | `POST` / `DELETE /api/v1/perspectives/{perspectiveId}/likes` | 쓰기 규칙 | +| 댓글 좋아요 등록·취소 | `POST` / `DELETE /api/v1/comments/{commentId}/likes` | 쓰기 규칙 | +| 의견·댓글 신고 | `POST /api/v1/perspectives/{perspectiveId}/reports`, `POST /api/v1/perspectives/{perspectiveId}/comments/{commentId}/reports` | 읽기 규칙 (마감 후에도 신고 가능). 댓글이 경로의 관점에 속하지 않으면 404 `COMMENT_NOT_FOUND` | + +--- + +## 7. AI 철학자 토론 (커스텀 배틀) + +커스텀 배틀 클래스에서 사전투표를 한 뒤, 반대 입장의 AI 철학자와 3라운드 텍스트 토론을 한다. + +- **권한** + - 조회: 클래스를 볼 수 있으면 된다. 마감 후에도 읽을 수 있다. + - 시작·반박·재시도: 0.3의 쓰기 규칙을 따른다. +- **라운드 진행:** 1라운드는 AI 주장으로 시작하고, 이어서 참가자가 반박한다. 3라운드 반박을 내면 토론이 끝나고 `COMPLETED`가 된다. +- **AI 주장 길이:** 프롬프트로 200~500자를 요구하고, 서버는 120~500자를 통과시킨다 (말풍선 사이 줄바꿈은 세지 않는다) +- **AI 주장 말풍선:** 한 주장을 의미 단위로 2~3개 말풍선으로 나눠 `bubbles`로 내려준다. 말풍선마다 AI를 따로 부르지 않는다. +- **반박 길이:** 앞뒤 공백 제거 후 50~500자. 저장한 뒤에는 수정할 수 없다. + +### 7.1 토론 조회 (재접속) +- `GET /api/v1/classes/{classId}/battle/debate` +- 저장된 대화와 상태를 돌려준다. AI는 호출하지 않는다. +- 시작 전이면 404 `CLASS_404_DEBATE`다. + +### 7.2 토론 시작 +- `POST /api/v1/classes/{classId}/battle/debate` +- **세션 구성:** 사전투표한 선택지의 반대편, 그리고 그 반대편 선택지의 철학자로 세션을 만든다. 이어서 1라운드 AI 주장을 생성한다. +- **사전투표 잠금:** 시작한 뒤에는 사전투표를 바꿀 수 없다. +- **중복 호출:** 이미 시작했으면 저장된 대화를 그대로 돌려준다. +- **AI 생성 실패:** 200과 함께 `status=FAILED`로 응답한다. 이때는 재시도 API를 쓴다. + +| 상황 | 응답 | +|---|---| +| 커스텀 클래스가 아님 | 409 `CLASS_409_DEBATE_TYPE` | +| 사전투표 전 | 409 `CLASS_409_DEBATE_PRE_VOTE` | + +### 7.3 반박 제출 +- `POST /api/v1/classes/{classId}/battle/debate/replies` + +```json +{ + "roundIndex": 1, + "content": "속으로 슬퍼해도 밖으로 표현하지 않을 수 있어요. ...", + "requestKey": "4f0b5c3e-6a8d-4f6b-9a51-2f3d1c7e9b10" +} +``` + +- `roundIndex`에는 화면에 보인 현재 라운드를 보낸다. +- 저장 전에 차단 대상인지 검사한다. 차단되면 저장하지 않고 라운드도 쓰지 않으므로, 고쳐서 같은 라운드로 다시 보내면 된다. + - 개인정보는 서버 규칙으로 검사한다. + - 위협·위협성 혐오·미성년자 성적 표현·자해 방법·폭력적 불법행위는 OpenAI Moderation으로 검사한다. 폭력·범죄를 다루는 정상 논증은 막지 않는다. + - 검사 API에 장애가 나면 막지 않고 통과시킨다. +- `requestKey`는 중복 전송을 막는 키다. 64자 이하이고 UUID를 권장한다. + - 같은 반박을 다시 보낼 때는 같은 키를 쓴다. 같은 키로 다시 보내면 저장된 결과를 그대로 돌려준다. +- 반박을 저장한 뒤 다음 라운드 AI 주장을 생성한다. + - 3라운드 반박이면 AI 주장 없이 `COMPLETED`가 된다. + +| 상황 | 응답 | +|---|---| +| 반박 길이 위반 | 400 `CLASS_400_DEBATE_REPLY` | +| 개인정보(전화번호·이메일·주민등록번호·카드번호) 포함 | 400 `CLASS_400_DEBATE_PERSONAL_INFO` | +| 위협 등 차단 대상 표현 | 400 `CLASS_400_DEBATE_BLOCKED` | +| 다른 키로 같은 라운드를 다시 제출 | 409 `CLASS_409_DEBATE_ROUND` | +| 반박을 받을 수 없는 상태 (생성 중·실패·완료) | 409 `CLASS_409_DEBATE_STATE` | +| 시작 전 | 404 `CLASS_404_DEBATE` | + +### 7.4 AI 주장 재시도 +- `POST /api/v1/classes/{classId}/battle/debate/retry` +- 같은 라운드의 AI 주장을 다시 생성한다. +- `status=FAILED`이거나 생성이 3분 넘게 멈췄을 때만 할 수 있다. 그 외에는 409 `CLASS_409_DEBATE_RETRY`다. + +### 7.5 응답 `ClassDebateResponse` + +```json +{ + "statusCode": 200, + "data": { + "sessionId": 7, + "status": "WAITING_USER", + "round": 1, + "canReply": true, + "canRetry": false, + "philosopher": { "code": "KANT", "name": "칸트", "typeName": "...", "imageKey": "..." }, + "participantOption": { "optionId": 31, "position": "A", "title": "...", "stance": "..." }, + "opponentOption": { "optionId": 32, "position": "B", "title": "...", "stance": "..." }, + "messages": [ + { + "messageId": 101, "round": 1, "speaker": "AI", "content": "첫 말풍선\n둘째 말풍선", + "bubbles": ["첫 말풍선", "둘째 말풍선"], + "focus": "ASSUMPTION", "focusLabel": "가정", "replyToMessageId": null, + "createdAt": "2026-10-21T10:00:00" + } + ], + "aiNotice": "철학자의 관점을 응용한 AI의 주장이에요. 실제 인물이 한 말이 아니에요.", + "replyMinLength": 50, + "replyMaxLength": 500 + } +} +``` + +| status | 의미 | 화면 | +|---|---|---| +| `GENERATING` | AI 주장 생성 중 | "생각을 정리하고 있어요" | +| `WAITING_USER` | 반박 대기 | 입력창 | +| `FAILED` | AI 주장 생성 실패 | 재시도 버튼 (`canRetry`) | +| `COMPLETED` | 3라운드 종료 | 대화 다시보기 / 사후투표 | + +- `canReply`: `WAITING_USER`이고 마감 전일 때 true +- `canRetry`: 실패했거나 생성이 오래 멈췄고 마감 전일 때 true +- `content`: 전체 글이다. AI 주장은 말풍선 사이에 줄바꿈이 들어 있다. +- `bubbles`: 화면에 말풍선으로 보여 줄 글 목록이다. AI 주장은 2~3개, 참가자 반박은 1개다. 줄바꿈 없이 저장된 예전 AI 주장도 서버가 문장 기준으로 나눠 준다. +- `focus`: AI 주장의 초점이다. 값은 `EVIDENCE`(근거) / `ASSUMPTION`(가정) / `COUNTEREXAMPLE`(반례) / `CONDITION`(조건) / `VALUE_CONFLICT`(가치충돌)이고, 참가자 반박이면 null이다. +- `replyToMessageId`: AI 주장이 응답한 참가자 반박의 ID + +--- + +## 8. 결과·AI 리포트 + +- 정량 통계는 언제든 볼 수 있다. 마감 전에는 현재 데이터, 마감 후에는 마감 시점 스냅샷이다. +- AI 리포트는 마감 후 스케줄러가 한 번 만든다. API는 저장된 결과를 읽기만 한다. +- 이용권 종료 + 30일이 지나면 409 `CLASS_409_RESULT_EXPIRED`다. + +리포트 상태 `ClassReportViewState`: + +| 값 | 의미 | 화면 문구 | +|---|---|---| +| `BEFORE_CLOSE` | 마감 전 | "{closeAt} 이후 AI 피드백이 제공돼요" | +| `NOT_ELIGIBLE` | 참여 미완료라 개인 리포트 없음 | | +| `PENDING` / `GENERATING` | 생성 대기 / 생성 중 | "생각을 정리하고 있어요" | +| `SUCCEEDED` | 생성 완료 | | +| `LIMITED` | 자료가 부족해 템플릿으로 대신함 | "판단의 이유를 살펴볼 글이 아직 충분하지 않아요" | +| `FAILED` | 생성 실패 | "AI 요약을 준비하지 못했어요" | + +### 8.1 클래스 결과 (정량 통계) +- `GET /api/v1/classes/{classId}/result` + +```json +{ + "statusCode": 200, + "data": { + "classId": 5, + "myRole": "OWNER", + "closeAt": "2026-12-31T23:59:59", + "closed": true, + "viewableUntil": "2027-05-01T10:00:00", + "statsBasis": "SNAPSHOT", + "cutoffAt": "2026-12-31T23:59:59", + "stats": { + "activeMemberCount": 12, + "completedCount": 10, + "participationRate": 83.3, + "commentWriterCount": 9, + "commentRate": 75.0, + "postVoteCount": 11, + "midVoteEnabled": true, + "midVoteCount": 10, + "options": [ + { "optionId": 31, "title": "창작이다", "stance": "...", "preVoteCount": 8, "postVoteCount": 6, "postVoteRatio": 54.5 } + ], + "midVoteCheckpoints": [ + { + "checkpointId": 801, + "voteCount": 10, + "options": [ + { "optionId": 9001, "label": "유죄", "voteCount": 4, "ratio": 40.0 }, + { "optionId": 9002, "label": "무죄", "voteCount": 6, "ratio": 60.0 } + ] + } + ] + }, + "myReportState": "SUCCEEDED", + "insightState": "SUCCEEDED" + } +} +``` + +- `statsBasis`: `LIVE`(현재 데이터) / `SNAPSHOT`(마감 스냅샷). `LIVE`이면 `cutoffAt`은 null이다. +- 비율은 소수 첫째 자리 %이고, 분모가 0이면 0이다. +- `midVoteCount`는 한 지점이라도 답한 활성 참여자 수다. `midVoteCheckpoints`는 선택 지점별 분포이고, `ratio`는 그 지점 응답자 중 비율이다. +- `midVoteEnabled`가 false면 `midVoteCount`는 0, `midVoteCheckpoints`는 빈 배열이다. 화면에서 중간투표 칸을 숨긴다. 중간투표 도입 전에 만든 스냅샷도 false로 내려간다. +- `myReportState`: 참여 멤버만 있다. 참여하지 않는 운영자는 null이다. +- `insightState`: 운영자에게만 있다. 그 외에는 null이다. +- 이 두 값으로 리포트 탭을 보여줄지 정한다. + +### 8.2 내 AI 피드백 +- `GET /api/v1/classes/{classId}/result/my-report` +- 참여 완료자에게 마감 후 제공한다. +- 글 없이 투표만 했으면 `LIMITED`이고, 서버 템플릿으로 채워진다. + +```json +{ + "statusCode": 200, + "data": { + "classId": 5, + "state": "SUCCEEDED", + "closeAt": "2026-12-31T23:59:59", + "preOption": { "optionId": 31, "title": "창작이다", "stance": "..." }, + "midVotes": [], + "postOption": { "optionId": 32, "title": "창작이 아니다", "stance": "..." }, + "perspectiveCount": 1, + "commentCount": 2, + "feedback": { + "headline": "...", + "judgmentBasis": "...", + "strength": { "title": "...", "body": "..." }, + "nextFocus": { "title": "...", "body": "..." }, + "nextQuestion": "...", + "evidence": [ + { "field": "judgment_basis", "sourceType": "PERSPECTIVE", "sourceId": 50, "quote": "...", "content": "...", "createdAt": "2026-10-21T12:00:00" } + ], + "analysisStatus": "SUFFICIENT", + "coverage": "full", + "excludedCount": 0 + }, + "generatedAt": "2027-01-01T00:01:00" + } +} +``` + +- `feedback`은 `state`가 `SUCCEEDED`나 `LIMITED`일 때만 있다. 그 외에는 null이다. +- `strength`, `nextFocus`는 기록으로 확인되지 않으면 null이다. +- `midVotes`는 중간투표가 있는 콘텐츠에서 선택 지점마다 고른 선택지다(6.5와 같은 형태). 없으면 빈 배열이다. 중간 선택은 AI 피드백 입력에도 들어간다. +- 글 없이 투표만 한 `LIMITED` 템플릿은 중간 선택이 있으면 "… 콘텐츠 중간에는 '무죄' 쪽을 골랐어요."를 덧붙인다. +- `evidence[].content`는 현재 기준 원문이다. 삭제·숨김된 글은 목록에서 빠진다. +- `coverage=partial`은 분량 제한으로 일부 글을 빼고 분석했다는 뜻이다. 뺀 글 수는 `excludedCount`에 있다. +- 참여하지 않는 운영자는 403 `CLASS_403_MEMBER`다. + +### 8.2.1 내 활동 (리포트 화면) +- `GET /api/v1/classes/{classId}/result/my-activity?cursor={createdAt}&size=20` +- 참여 중인 멤버만 조회할 수 있다. 기본 페이지 크기는 20, 최대 100이다. +- `receivedLikeCount`는 공개된 내 의견·댓글에 받은 좋아요 수 합계다. `comments`는 공개 상태인 내 댓글 목록이며, 최신순 커서 페이지네이션을 사용한다. +- 잘못된 `cursor`는 400 `COMMON_400`이다. + +```json +{ + "statusCode": 200, + "data": { + "classId": 5, + "receivedLikeCount": 12, + "commentCount": 2, + "comments": [ + { "commentId": 61, "perspectiveId": 50, "perspectiveTitle": "창작이다", "stance": "...", "content": "...", "likeCount": 2, "createdAt": "2026-10-21T12:00:00" } + ], + "nextCursor": null, + "hasNext": false + } +} +``` + +### 8.3 클래스 인사이트 (운영자) +- `GET /api/v1/classes/{classId}/result/insight` +- 공개 의견을 요약한 AI 인사이트와 근거·대표 의견이다. + +```json +{ + "statusCode": 200, + "data": { + "classId": 5, + "state": "SUCCEEDED", + "retryable": false, + "cutoffAt": "2026-12-31T23:59:59", + "generatedAt": "2027-01-01T00:01:00", + "insightText": "...", + "coverage": "full", + "excludedCount": 0, + "evidence": [ + { "sourceType": "PERSPECTIVE", "id": 50, "optionId": 31, "stance": "...", "content": "...", "createdAt": "2026-10-21T12:00:00" } + ], + "highlights": [] + } +} +``` + +- `insightText`: 200자 이하다. +- `evidence`: 인사이트가 근거로 삼은 공개 글 1~5건이다. +- `highlights`: 운영자에게 추천하는 대표 의견 최대 2건이다. 작성자 정보는 넣지 않는다. +- 의견 작성자가 3명 미만이면 `LIMITED`다. +- 생성에 실패하면 `FAILED`이고 `retryable`이 true다. +- 운영자가 아니면 403 `CLASS_403_OWNER`다. + +### 8.4 클래스 인사이트 재시도 (운영자) +- `POST /api/v1/classes/{classId}/result/insight/retry` +- `FAILED` 상태일 때만 할 수 있다. +- 대기열에 다시 넣고 `PENDING`을 돌려준다. 1분 안에 다시 생성된다. + +| 상황 | 응답 | +|---|---| +| 다시 시도할 수 없는 상태 | 409 `CLASS_409_REPORT_RETRY` | +| 운영자가 아님 | 403 `CLASS_403_OWNER` | + +--- + +## 9. 어드민 + +### 9.1 배틀 클래스 설정 수정 +- `PATCH /api/v1/admin/battles/{battleId}/class-settings` +- null 필드는 변경하지 않는다. + +```json +{ "difficulty": "BEGINNER", "category": "철학", "classRecommendable": true, "midVoteEnabled": false } +``` + +| 필드 | 설명 | +|---|---| +| `difficulty` | 추천 필터용 대상 수준 | +| `category` | 추천 필터용 카테고리 1개 | +| `classRecommendable` | 클래스 추천(3.1)에 노출할지 | +| `midVoteEnabled` | 중간투표 콘텐츠 여부 | + +- 응답: `AdminBattleDetailResponse`. 끝에 `difficulty`, `category`, `classRecommendable`, `midVoteEnabled`가 추가됐다. 어드민 배틀 상세 조회에도 같은 필드가 내려간다. +- 추천 API는 이 설정이 켜진 배틀만 돌려준다. 그래서 운영에서 배틀마다 설정해야 추천 결과가 나온다. +- 수준은 기획 확정 전까지 임시로 초급이다. 수준이 비어 있던 기존 배틀은 SQL로 초급을 채웠고, 어드민이 새로 만드는 배틀도 초급으로 저장된다. 어드민 배틀 목록에도 `difficulty`가 내려간다. + +| 상황 | 응답 | +|---|---| +| 추천을 켜는데 수준·카테고리가 비어 있음 | 400 `CLASS_400_RECOMMEND_SETTINGS` | +| 사용자가 만든(커스텀) 배틀 | 400 `CLASS_400_BATTLE` | + +### 9.2 펀딩 명단 CSV 가져오기 +- `POST /api/v1/admin/funding/imports` (`multipart/form-data`, 파일 필드 `file`) +- 파일은 UTF-8 CSV다. 헤더는 다음 순서다: `platform, campaign_id, order_id, reward_tier, quantity, supporter_name, supporter_email, supporter_phone, payment_status, paid_at` +- `order_id` 기준으로 추가하거나 갱신한다. 같은 파일을 여러 번 올려도 안전하다. +- 행 단위 오류는 `errors`로 돌려주고, 나머지 행은 반영한다. +- 환불·취소(`REFUNDED`/`CANCELED`)된 주문은 결제 확정(`PAID_CONFIRMED`)으로 되돌리지 않는다. + +```json +{ + "statusCode": 200, + "data": { + "fileHash": "sha256...", + "totalRows": 120, + "created": 100, + "updated": 5, + "unchanged": 13, + "errorCount": 2, + "issuableCount": 98, + "heldCount": 4, + "errors": [ { "line": 7, "orderId": "T-0007", "reason": "..." } ] + } +} +``` + +- `platform`: `TUMBLBUG` / `WADIZ` / `MANUAL` +- `reward_tier`: `SEAT_5` / `SEAT_15` / `SEAT_30` +- `payment_status`: `RESERVED` / `PAYMENT_PENDING` / `PAID_CONFIRMED` / `PAYMENT_FAILED` / `CANCELED` / `REFUNDED` +- 헤더가 다르거나 파일을 읽을 수 없으면 400 `FUNDING_400_CSV`다. + +### 9.3 이용권 코드 발급 +- `POST /api/v1/admin/funding/tickets/issue` + +```json +{ "fundingOrderIds": [1, 2, 3] } +``` + +- 본문을 비우거나 `fundingOrderIds`를 비우면 발급 가능한 전체 주문에 발급한다. 발급 가능한 주문은 결제 확정, 수량 1, 미발급인 주문이다. +- 이미 살아 있는 이용권이 있는 주문은 건너뛴다. 그래서 다시 실행해도 중복 발급되지 않는다. + +```json +{ + "statusCode": 200, + "data": { + "issued": [ + { + "ticketId": 3, "fundingOrderId": 1, "orderId": "T-0001", + "supporterName": "홍길동", "supporterEmail": "...", "supporterPhone": "...", + "capacity": 30, "registerBy": "2027-09-01T00:00:00", + "code": "XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX" + } + ], + "skipped": [ { "fundingOrderId": 2, "orderId": "T-0002", "reason": "ALREADY_ISSUED" } ] + } +} +``` + +- **`code` 원문은 이 응답에서만 볼 수 있다.** 서버는 해시만 저장한다. 그래서 전달 전까지 어드민 화면에서 보관해야 한다. +- `skipped[].reason`: `ALREADY_ISSUED` / `NOT_ISSUABLE` / `NOT_FOUND` + +### 9.4 이용권 전달 기록 +- `POST /api/v1/admin/funding/tickets/{ticketId}/deliveries` + +```json +{ "channel": "EMAIL", "status": "SUCCESS", "failureReason": null } +``` + +- `channel`: `EMAIL` / `SMS` / `MANUAL` +- `status`: `SUCCESS` / `FAILED`. `SUCCESS`면 코드 상태가 `DELIVERED`로 바뀐다. +- `failureReason`: 실패 사유. 255자 이하다. +- 응답: `attemptId`, `ticketId`, `channel`, `status`, `failureReason`, `attemptedAt`, `codeStatus`, `deliveredAt` +- 이미 등록됐거나(CLAIMED) 무효화된(REVOKED) 이용권이면 409 `TICKET_409_STATE`다. + +### 9.5 이용권 코드 재발급 +- `POST /api/v1/admin/funding/tickets/{ticketId}/reissue` +- 코드 분실 등에 쓴다. 기존 코드를 무효화(REVOKED)하고, 같은 주문·정원·등록 기한으로 새 코드를 발급한다. +- 응답: 9.3의 `issued[]` 항목 하나. 새 `code`도 이 응답에서만 볼 수 있다. + +| 상황 | 응답 | +|---|---| +| 이미 등록됨 | 409 `TICKET_409_CLAIMED` | +| 이미 무효화됨 | 409 `TICKET_409_REVOKED` | +| 등록 기한 경과 | 409 `TICKET_409_REG_EXP` | + +### 9.6 펀딩 환불 처리 +- `POST /api/v1/admin/funding/refunds` + +```json +{ "fundingOrderIds": [1, 2] } +``` + +```json +{ "statusCode": 200, "data": { "refunded": 1, "revokedTickets": 1, "alreadyRefunded": [2] } } +``` + +- 주문을 `REFUNDED`로 바꾸고, 살아 있는 이용권을 무효화(REVOKED)한다. +- 이미 환불된 주문은 `alreadyRefunded`로 돌려준다. +- 없는 주문 ID가 섞여 있으면 404 `FUNDING_404_ORDER`다. + +### 9.7 펀딩 주문·이용권 목록 +- `GET /api/v1/admin/funding/orders` + +| 쿼리 | 설명 | +|---|---| +| `paymentStatus` | 결제 상태 필터 | +| `ticketStatus` | `UNREGISTERED` / `ACTIVE` / `EXPIRED` / `REGISTER_EXPIRED` / `REVOKED` | +| `codeStatus` | `ISSUED` / `DELIVERED` / `CLAIMED` / `REVOKED` | +| `keyword` | order_id / 후원자명 / 이메일 부분 일치 | +| `page`, `size` | 기본 0, 20 | + +- 응답: `items[]`, `page`, `size`, `totalElements`, `hasNext` +- `items[]`에는 주문 정보가 들어간다: `fundingOrderId`, `platform`, `campaignId`, `orderId`, `rewardTier`, `quantity`, 후원자 정보, `paymentStatus`, `paidAt`, `issuable` +- `items[].ticket`은 살아 있는 이용권 요약이고, 없으면 null이다. 필드는 `ticketId`, `capacity`, `codeStatus`, `status`, `deliveredAt`, `registerBy`, `activatedAt`, `expiresAt`, `registeredUserId`, `registeredNickname`다. +- 코드 원문과 해시는 내려가지 않는다. + +### 9.8 펀딩·이용권 현황 요약 +- `GET /api/v1/admin/funding/summary` +- 응답 필드: + - 주문: `totalOrders`, `paidConfirmed`, `issuable`, `held`, `refunded`, `canceled` + - 이용권: `issued`, `undelivered`, `delivered`, `registered` + - 등록기한 알림 대상 수: `registerReminderD30`, `registerReminderD7` (9.9와 같은 기준) +- 이용권 수치는 살아 있는(REVOKED가 아닌) 행 기준이다. + +### 9.9 등록기한 임박 알림 +PRD "등록기한 30일·7일 전 알림은 허용된 연락 수단에서만 발송". 미등록 이용권은 앱 계정에 연결되지 않아 푸시를 보낼 수 없다. 그래서 코드 전달(9.4)처럼 운영자가 후원자 연락처로 보내고 결과를 기록한다. 대상은 조회 시점에 계산하므로 별도 배치가 없다. + +**대상 조회** +- `GET /api/v1/admin/funding/register-reminders?stage=D30` + +| `stage` | 대상 등록기한 | +|---|---| +| `D30` | 지금부터 7일 초과 ~ 30일 이내 | +| `D7` | 지금부터 7일 이내 (지난 건 제외) | + +- 대상: 전달 완료(`codeStatus=DELIVERED`)·미등록(`status=UNREGISTERED`)이고, 그 단계 알림을 아직 `SUCCESS`로 기록하지 않은 이용권이다. 등록기한이 빠른 순이다. +- 30일 알림을 놓치고 7일 안으로 들어온 이용권은 `D7` 대상만 된다. +- 미전달(`ISSUED`) 이용권은 대상이 아니다. 코드 전달(9.4)부터 해야 한다. + +```json +{ + "statusCode": 200, + "data": { + "stage": "D30", + "count": 1, + "items": [ + { + "ticketId": 12, "fundingOrderId": 3, "platform": "TUMBLBUG", "orderId": "ORD-30", + "supporterName": "홍길동", "supporterEmail": "a@test.com", "supporterPhone": "010-0000-0000", + "capacity": 15, "registerBy": "2026-11-17T10:00:00", "daysLeft": 20 + } + ] + } +} +``` + +- `daysLeft`: 등록기한까지 남은 일수 (KST 날짜 기준) + +**발송 기록** +- `POST /api/v1/admin/funding/tickets/{ticketId}/register-reminders` + +```json +{ "stage": "D30", "channel": "EMAIL", "status": "SUCCESS", "failureReason": null } +``` + +- `channel`: `EMAIL` / `SMS` / `MANUAL` +- `status`: `SUCCESS`면 그 단계 대상에서 빠진다. `FAILED`는 이력만 남고 대상에 그대로 있다(재시도 가능). +- 응답: `reminderId`, `ticketId`, `stage`, `channel`, `status`, `failureReason`, `attemptedAt` + +| 상황 | 응답 | +|---|---| +| 전달 완료·미등록 이용권이 아님 (미전달·등록 완료·무효화) | 409 `TICKET_409_STATE` | +| 등록기한 경과 | 409 `TICKET_409_REG_EXP` | +| `stage`·`channel`·`status` 누락 | 400 `COMMON_400` | + +--- + +## 10. 에러 코드 모음 + +### 10.1 클래스 + +| 코드 | HTTP | 메시지 | +|---|---|---| +| `CLASS_400_BATTLE` | 400 | 클래스에 사용할 수 없는 배틀입니다. | +| `CLASS_400_BATTLE_SOURCE` | 400 | 기존 배틀 ID 와 주제 버전 ID 중 하나만 보내 주세요. | +| `CLASS_400_DEADLINE` | 400 | 마감일은 현재 시각 이후, 이용권 종료일 이전이어야 합니다. | +| `CLASS_400_DEADLINE_EXT` | 400 | 마감일은 기존 마감일보다 뒤로 연장만 할 수 있습니다. | +| `CLASS_400_RECOMMEND_CONDITION` | 400 | 대상 수준과 카테고리 1~3개를 골라 주세요. 주제는 50자까지 입력할 수 있어요. | +| `CLASS_400_RECOMMEND_SETTINGS` | 400 | 클래스 추천을 켜려면 수준과 카테고리를 먼저 정해 주세요. | +| `CLASS_400_TOPIC_CONDITION` | 400 | 주제는 2~200자, 카테고리는 1~3개로 입력해 주세요. | +| `CLASS_400_DEBATE_REPLY` | 400 | 반박은 50자 이상 500자 이하로 적어 주세요. | +| `CLASS_400_DEBATE_PERSONAL_INFO` | 400 | 전화번호·이메일 같은 개인정보는 지우고 보내 주세요. | +| `CLASS_400_DEBATE_BLOCKED` | 400 | 다른 사람을 위협하거나 해칠 수 있는 표현은 보낼 수 없어요. 내용을 고쳐 주세요. | +| `CLASS_403_TICKET` | 403 | 사용 중인 이용권이 없어 클래스를 만들 수 없습니다. | +| `CLASS_403_OWNER` | 403 | 클래스 운영자만 할 수 있습니다. | +| `CLASS_403_MEMBER` | 403 | 클래스 멤버만 볼 수 있습니다. | +| `CLASS_403_REMOVED` | 403 | 내보내진 클래스에는 다시 참여할 수 없습니다. | +| `CLASS_403_OWNER_LEAVE` | 403 | 운영자는 클래스를 나가거나 내보내질 수 없습니다. 클래스를 삭제해 주세요. | +| `CLASS_404` | 404 | 존재하지 않는 클래스입니다. | +| `CLASS_404_CODE` | 404 | 존재하지 않는 초대코드입니다. | +| `CLASS_404_MEMBER` | 404 | 존재하지 않는 멤버입니다. | +| `CLASS_404_TOPIC_DRAFT` | 404 | 존재하지 않는 주제 초안입니다. | +| `CLASS_404_TOPIC_CANDIDATE` | 404 | 존재하지 않는 주제 후보입니다. | +| `CLASS_404_TOPIC_VERSION` | 404 | 존재하지 않는 주제 버전입니다. | +| `CLASS_404_DEBATE` | 404 | 시작한 토론이 없습니다. | +| `CLASS_409_CLOSED` | 409 | 마감된 클래스입니다. | +| `CLASS_409_TICKET_ENDED` | 409 | 이용권 이용이 종료된 클래스입니다. | +| `CLASS_409_FULL` | 409 | 정원이 가득 찬 클래스입니다. | +| `CLASS_409_DELETED` | 409 | 삭제되어 더 이상 이용할 수 없는 클래스입니다. | +| `CLASS_409_REQUEST_KEY` | 409 | 같은 요청 키로 다른 내용의 클래스를 만들 수 없습니다. 새 요청 키로 다시 시도해 주세요. | +| `CLASS_409_JOINED` | 409 | 이미 참여 중인 클래스입니다. | +| `CLASS_409_NAME` | 409 | 이미 사용 중인 이름입니다. 다른 이름을 입력해 주세요. | +| `CLASS_409_OWNER` | 409 | 내가 운영하는 클래스에는 참여자로 들어갈 수 없습니다. | +| `CLASS_409_PRE_VOTE_LOCKED` | 409 | 배틀이 시작된 뒤에는 사전투표를 변경할 수 없습니다. | +| `CLASS_409_TTS` | 409 | 커스텀 배틀 클래스에는 음성(TTS) 단계가 없습니다. | +| `CLASS_409_TTS_INCOMPLETE` | 409 | 오디오를 끝까지 들은 뒤 사후투표를 할 수 있어요. | +| `CLASS_409_MID_VOTE` | 409 | 중간투표가 없는 배틀입니다. | +| `CLASS_409_MID_VOTE_LOCKED` | 409 | 사후투표를 마친 뒤에는 중간투표를 바꿀 수 없어요. | +| `CLASS_409_MID_VOTE_REQUIRED` | 409 | 콘텐츠 중간의 선택을 마친 뒤 사후투표를 할 수 있어요. | +| `CLASS_404_MID_VOTE_OPTION` | 404 | 존재하지 않는 중간투표 선택지입니다. | +| `CLASS_409_PERSPECTIVE_REQUIRED` | 409 | 의견 작성이 필수인 클래스의 의견은 삭제할 수 없어요. 내용을 수정해 주세요. | +| `CLASS_409_RESULT_EXPIRED` | 409 | 결과 열람 기간이 끝났습니다. | +| `CLASS_409_REPORT_RETRY` | 409 | 지금은 AI 요약을 다시 시도할 수 없습니다. | +| `CLASS_409_TOPIC_DRAFT_USED` | 409 | 이 주제 초안으로 이미 만든 클래스가 있습니다. | +| `CLASS_409_TOPIC_GENERATING` | 409 | 주제를 만들고 있어요. 잠시 후 다시 확인해 주세요. | +| `CLASS_409_TOPIC_SUGGEST_LIMIT` | 409 | 이 초안의 주제 추천 횟수를 모두 사용했습니다. | +| `CLASS_409_TOPIC_VALIDATION_LIMIT` | 409 | 이 초안의 수정 검증 횟수를 모두 사용했습니다. | +| `CLASS_409_TOPIC_CANDIDATE` | 409 | 후보가 새로 추천되었습니다. 최신 후보에서 다시 골라 주세요. | +| `CLASS_409_DEBATE_TYPE` | 409 | 커스텀 배틀 클래스에서만 토론할 수 있습니다. | +| `CLASS_409_DEBATE_PRE_VOTE` | 409 | 사전투표를 먼저 해 주세요. | +| `CLASS_409_DEBATE_STATE` | 409 | 지금은 반박을 보낼 수 없어요. | +| `CLASS_409_DEBATE_ROUND` | 409 | 이 라운드의 반박은 이미 제출되었습니다. | +| `CLASS_409_DEBATE_RETRY` | 409 | 지금은 다시 시도할 수 없어요. | +| `CLASS_409_DEBATE_INCOMPLETE` | 409 | AI 철학자와의 토론을 마친 뒤 사후투표를 할 수 있어요. | +| `CLASS_429_TOPIC` | 429 | 요청이 많아 잠시 기다리고 있어요. 잠시 후 다시 시도해 주세요. | +| `CLASS_500_CODE` | 500 | 초대코드 생성에 실패했습니다. 다시 시도해 주세요. | +| `CLASS_503_TOPIC_AI` | 503 | 응답을 준비하지 못했어요. 다시 시도해 주세요. | + +### 10.2 이용권·펀딩 + +| 코드 | HTTP | 메시지 | +|---|---|---| +| `TICKET_404_CODE` | 404 | 유효하지 않은 이용권 코드입니다. | +| `TICKET_404` | 404 | 등록된 이용권이 없습니다. | +| `TICKET_409_CLAIMED` | 409 | 이미 등록된 이용권 코드입니다. | +| `TICKET_409_REVOKED` | 409 | 재발급되어 더 이상 사용할 수 없는 이용권 코드입니다. | +| `TICKET_409_REG_EXP` | 409 | 등록 기한이 지난 이용권 코드입니다. | +| `TICKET_409_ACTIVE` | 409 | 이미 사용 중인 이용권이 있습니다. 만료 후 등록할 수 있습니다. | +| `TICKET_409_STATE` | 409 | 현재 상태에서는 처리할 수 없는 이용권입니다. | +| `FUNDING_400_CSV` | 400 | CSV 파일을 읽을 수 없습니다. 템플릿 헤더와 UTF-8 인코딩을 확인해 주세요. | +| `FUNDING_404_ORDER` | 404 | 존재하지 않는 펀딩 주문입니다. | + +### 10.3 클래스에서 함께 쓰는 공통 코드 + +| 코드 | HTTP | 클래스에서 나오는 곳 | +|---|---|---| +| `COMMON_400` | 400 | 초대코드·이용권 코드 형식, 표시 이름·클래스 이름 공백 | +| `BATTLE_404` | 404 | 클래스 생성 시 없는 배틀 | +| `BATTLE_OPT_404` | 404 | 이 배틀의 선택지가 아닌 투표, 사후투표 전 의견 등록 | +| `VOTE_404` | 404 | 사전투표 전 TTS 완료·사후투표·내 투표 조회 | +| `VOTE_409_PRE` | 409 | 진행 단계가 `NONE`인 상태의 사후투표 | +| `VOTE_409_SUB` | 409 | 사후투표 재제출 | diff --git a/docs/api-specs/comments-api.md b/docs/api-specs/comments-api.md index 0980f5f5..fbe7386f 100644 --- a/docs/api-specs/comments-api.md +++ b/docs/api-specs/comments-api.md @@ -6,6 +6,7 @@ - 관점에서의 댓글 관련한 API 입니다. - 대댓글은 존재하지 않고 같은 최상위 뎁스의 댓글만 존재합니다. +- 의견·댓글 작성 및 수정 시 본문은 1~200자여야 합니다. 초과하면 400 `PERSPECTIVE_400_LEN`입니다. --- @@ -262,4 +263,4 @@ |------------|:-----------:|-----------| | `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 index 9719ecdf..b4a770cc 100644 --- a/docs/api-specs/home-api.md +++ b/docs/api-specs/home-api.md @@ -13,6 +13,7 @@ - `editorPicks`: 에디터 픽 배틀 목록 - `trendingBattles`: 트렌딩 배틀 목록 - `bestBattles`: 베스트 배틀 목록 +- `mostDiscussedBattles`: 댓글 수가 많은 배틀 목록 - `todayQuizzes`: 오늘의 퀴즈 목록 - `todayVotes`: 오늘의 투표(Poll) 목록 - `newBattles`: 신규 배틀 목록 @@ -56,3 +57,6 @@ - 오늘의 퀴즈/투표는 서버에서 조회 및 정렬을 확정해 응답 - 옵션 순서는 `displayOrder -> label -> id` 기준 오름차순으로 고정 +- `bestBattles[].badgeLabel`은 `BEST`, `trendingBattles[].badgeLabel`은 `TRENDING`, `mostDiscussedBattles[].badgeLabel`은 `최다 토론`이다. +- 배틀 카드 응답은 태그와 소요 시간(초)을 포함한다. 에디터 픽에도 `audioDuration`을 제공한다. +- `mostDiscussedBattles`는 공개 배틀 중 댓글 수 내림차순으로 최대 3건이며 댓글이 없는 배틀은 제외한다. diff --git a/docs/api-specs/oauth-api.md b/docs/api-specs/oauth-api.md index e665ff06..37141459 100644 --- a/docs/api-specs/oauth-api.md +++ b/docs/api-specs/oauth-api.md @@ -153,6 +153,9 @@ - `users`, `user_social_accounts`, `auth_refresh_tokens` 연관 데이터를 함께 처리합니다. - 사용자 도메인 상세 정리는 `user` 정책에 따라 함께 처리합니다. - 탈퇴 사유는 별도 이력 테이블에 저장합니다. +- 운영 중인 클래스는 소프트 삭제해 새 참여·운영을 막고, 참여 중 클래스 멤버십은 `LEFT`로 바꿉니다. +- 투표 등 활동 이력이 있는 멤버의 좌석은 유지하고, 활동이 없으면 좌석을 반환합니다. +- 작성한 의견·댓글은 공개 화면에서 숨기며, 리포트·이력 참조를 위해 행은 보존합니다. 요청 헤더: diff --git a/docs/api-specs/perspectives-api.md b/docs/api-specs/perspectives-api.md index d33acfd8..ec976cd7 100644 --- a/docs/api-specs/perspectives-api.md +++ b/docs/api-specs/perspectives-api.md @@ -6,6 +6,7 @@ - 관점 API 입니다. - 현재 Creator 뱃지 부분이 ERD 상에선 보이지 않는데 확인 필요 +- 의견·댓글 본문은 서버에서 최대 200자로 검증합니다. ### 관점 상태(status) 흐름 diff --git a/docs/api-specs/scenario-api.md b/docs/api-specs/scenario-api.md index b1b9d47a..461ab5b4 100644 --- a/docs/api-specs/scenario-api.md +++ b/docs/api-specs/scenario-api.md @@ -9,6 +9,7 @@ ### 1.1 배틀 시나리오 조회 - `GET /api/v1/battles/{battleId}/scenario` - 설명: 배틀 상세에서 시나리오 노드/스크립트/분기 옵션 조회 +- `nodes[].interactiveOptions[]`에 `optionId`(선택지 ID)가 내려간다. 클래스 중간투표(class-api 6.3.1)는 `nodeId`와 이 `optionId`를 보낸다. --- diff --git a/docs/erd/class.puml b/docs/erd/class.puml new file mode 100644 index 00000000..357febc2 --- /dev/null +++ b/docs/erd/class.puml @@ -0,0 +1,574 @@ +@startuml class +hide circle +hide methods +skinparam linetype ortho + +' ===== 기존 테이블 (클래스 관련 컬럼만 표시) ===== + +entity "users" as users { + * id : BIGINT <> + -- + nickname : VARCHAR(50) +} + +entity "battles" as battles { + * id : BIGINT <> + -- + title : VARCHAR(255) + difficulty : VARCHAR(20) <> + category : VARCHAR(20) <> + class_recommendable : BOOLEAN <> + mid_vote_enabled : BOOLEAN <> +} + +entity "battle_options" as battle_options { + * id : BIGINT <> + -- + battle_id : BIGINT <> + title : VARCHAR(255) +} + +entity "votes" as votes { + * id : BIGINT <> + -- + user_id : BIGINT <> + battle_id : BIGINT <> + class_id : BIGINT <> + pre_vote_option_id : BIGINT <> + mid_vote_option_id : BIGINT <> + post_vote_option_id : BIGINT <> + is_tts_listened : BOOLEAN +} + +entity "perspectives" as perspectives { + * id : BIGINT <> + -- + battle_id : BIGINT <> + user_id : BIGINT <> + option_id : BIGINT <> + class_id : BIGINT <> + content : TEXT + like_count : INTEGER + comment_count : INTEGER +} + +entity "perspective_comments" as perspective_comments { + * id : BIGINT <> + -- + perspective_id : BIGINT <> + user_id : BIGINT <> + class_id : BIGINT <> + content : TEXT +} + +entity "user_battles" as user_battles { + * id : BIGINT <> + -- + user_id : BIGINT <> + battle_id : BIGINT <> + class_id : BIGINT <> + step : VARCHAR(20) +} + +' ===== 신규 테이블 : 펀딩 / 이용권 ===== + +entity "funding_orders" as funding_orders { + * id : BIGINT <> + -- + platform : VARCHAR(20) + campaign_id : VARCHAR(50) + order_id : VARCHAR(50) <> + reward_tier : VARCHAR(10) + quantity : INTEGER + supporter_name : VARCHAR(50) + supporter_email : VARCHAR(255) <> + supporter_phone : VARCHAR(20) <> + payment_status : VARCHAR(20) + paid_at : TIMESTAMP <> + import_file_hash : VARCHAR(64) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "class_tickets" as class_tickets { + * id : BIGINT <> + -- + funding_order_id : BIGINT <> + capacity : INTEGER + code_hash : VARCHAR(64) <> + code_status : VARCHAR(20) + delivered_at : TIMESTAMP <> + register_by : TIMESTAMP + registered_user_id : BIGINT <> + activated_at : TIMESTAMP <> + expires_at : TIMESTAMP <> + status : VARCHAR(20) + revoked_at : TIMESTAMP <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "ticket_register_reminders" as ticket_register_reminders { + * id : BIGINT <> + -- + ticket_id : BIGINT <> + stage : VARCHAR(10) <> + channel : VARCHAR(20) + status : VARCHAR(20) + failure_reason : VARCHAR(255) <> + attempted_at : TIMESTAMP + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "ticket_delivery_attempts" as ticket_delivery_attempts { + * id : BIGINT <> + -- + ticket_id : BIGINT <> + channel : VARCHAR(20) + status : VARCHAR(20) + failure_reason : VARCHAR(255) <> + attempted_at : TIMESTAMP + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +' ===== 신규 테이블 : 클래스 ===== + +entity "classes" as classes { + * id : BIGINT <> + -- + name : VARCHAR(20) + invite_code : VARCHAR(6) <> + owner_id : BIGINT <> + ticket_id : BIGINT <> + battle_id : BIGINT <> + battle_type : VARCHAR(10) + topic_version_id : BIGINT <> + topic_draft_id : BIGINT <> + capacity : INTEGER + deadline : TIMESTAMP <> + comment_required : BOOLEAN + owner_participates : BOOLEAN + status : VARCHAR(20) + deleted_at : TIMESTAMP <> + purged_at : TIMESTAMP <> + create_request_key : VARCHAR(64) <> + create_request_hash : VARCHAR(64) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "class_members" as class_members { + * id : BIGINT <> + -- + class_id : BIGINT <> + user_id : BIGINT <> + display_name : VARCHAR(20) + role : VARCHAR(10) + status : VARCHAR(10) + seat_held : BOOLEAN + joined_at : TIMESTAMP + left_at : TIMESTAMP <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +' ===== 신규 테이블 : 커스텀 주제 (#545) ===== + +entity "class_topic_drafts" as class_topic_drafts { + * id : BIGINT <> + -- + owner_id : BIGINT <> + ticket_id : BIGINT <> + topic_seed : VARCHAR(200) + difficulty : VARCHAR(20) + categories : JSONB + suggest_status : VARCHAR(30) + candidates : JSONB <> + batch_no : INTEGER + suggest_request_count : INTEGER + suggest_started_at : TIMESTAMP <> + clarification_message : VARCHAR(200) <> + suggest_failure_reason : VARCHAR(30) <> + validation_count : INTEGER + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "class_topic_versions" as class_topic_versions { + * id : BIGINT <> + -- + draft_id : BIGINT <> + version_no : INTEGER + source : VARCHAR(20) + source_ref : VARCHAR(20) <> + question : VARCHAR(60) + description : VARCHAR(80) + category : VARCHAR(30) + difficulty : VARCHAR(20) + option_a_label : VARCHAR(20) + option_a_rationale : VARCHAR(40) + option_a_philosopher : VARCHAR(20) + option_b_label : VARCHAR(20) + option_b_rationale : VARCHAR(40) + option_b_philosopher : VARCHAR(20) + profile_version : VARCHAR(30) + prompt_version : VARCHAR(30) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +' ===== 신규 테이블 : AI 철학자 토론 (#548) ===== + +entity "class_debate_sessions" as class_debate_sessions { + * id : BIGINT <> + -- + class_id : BIGINT <> + member_id : BIGINT <> + participant_option_id : BIGINT <> + opponent_option_id : BIGINT <> + philosopher : VARCHAR(20) + status : VARCHAR(20) + current_round : INTEGER + generating_started_at : TIMESTAMP <> + completed_at : TIMESTAMP <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "class_debate_messages" as class_debate_messages { + * id : BIGINT <> + -- + session_id : BIGINT <> + round_index : INTEGER + speaker : VARCHAR(10) + content : TEXT + focus : VARCHAR(20) <> + reply_to_message_id : BIGINT <> + request_key : VARCHAR(64) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +' ===== 신규 테이블 : AI ===== + +entity "ai_jobs" as ai_jobs { + * id : BIGINT <> + -- + type : VARCHAR(30) + status : VARCHAR(20) + idempotency_key : VARCHAR(100) <> + ticket_id : BIGINT <> + class_id : BIGINT <> + member_id : BIGINT <> + model : VARCHAR(50) <> + input_tokens : INTEGER <> + output_tokens : INTEGER <> + attempt : INTEGER + error_code : VARCHAR(50) <> + started_at : TIMESTAMP <> + finished_at : TIMESTAMP <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "class_reports" as class_reports { + * id : BIGINT <> + -- + class_id : BIGINT <> + member_id : BIGINT <> + type : VARCHAR(20) + status : VARCHAR(20) + content : JSONB <> + ai_job_id : BIGINT <> + generated_at : TIMESTAMP <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +' ===== 관계 ===== + +funding_orders ||--o{ class_tickets : issues +users |o--o{ class_tickets : registers +class_tickets ||--o{ ticket_delivery_attempts : delivered by +class_tickets ||--o{ ticket_register_reminders : reminded by + +users ||--o{ classes : owns +class_tickets ||--o{ classes : issued with +battles ||--o{ classes : uses +battles ||--o{ battle_options : has + +classes ||--o{ class_members : has +users ||--o{ class_members : joins + +classes ||--o{ class_reports : summarized by +class_members |o--o{ class_reports : personal report +ai_jobs |o--o| class_reports : produced by + +users ||--o{ class_topic_drafts : drafts +class_tickets ||--o{ class_topic_drafts : under +class_topic_drafts ||--o{ class_topic_versions : validated as +class_topic_versions |o--o{ classes : custom battle of +class_topic_drafts |o--o| classes : one class per draft + +classes ||--o{ class_debate_sessions : debates +class_members ||--o| class_debate_sessions : one per member +battle_options ||--o{ class_debate_sessions : participant / opponent +class_debate_sessions ||--o{ class_debate_messages : turns + +class_tickets |o--o{ ai_jobs : cost of +classes |o--o{ ai_jobs : scoped +class_members |o--o{ ai_jobs : scoped + +classes |o--o{ votes : scoped +classes |o--o{ perspectives : scoped +classes |o--o{ perspective_comments : scoped +classes |o--o{ user_battles : scoped + +' ===== 노트 ===== + +note bottom of funding_orders + 펀딩 플랫폼(텀블벅/와디즈, 미정) 결제 명단을 어드민이 CSV 업로드한 원본. + reward_tier : SEAT_5 | SEAT_15 | SEAT_30 (9,900 / 19,900 / 29,900원, PRD 확정) + payment_status : RESERVED | PAYMENT_PENDING | PAID_CONFIRMED + | PAYMENT_FAILED | CANCELED | REFUNDED + quantity != 1 인 행은 발급 보류 (PRD §30) + 같은 명단 재업로드 시 order_id 기준 멱등 (CF01) + 구매자(supporter_*)와 등록자(class_tickets.registered_user_id)는 다를 수 있다. + -- #543 구현 반영 -- + 어드민 CSV: POST /api/v1/admin/funding/imports (헤더 platform, campaign_id, order_id, reward_tier, + quantity, supporter_name, supporter_email, supporter_phone, payment_status, paid_at; reward_tier 는 SEAT_x 또는 5/15/30) + 행 단위 오류는 응답 errors 로 모아 돌려주고 나머지 행은 반영, import_file_hash = 업로드 파일 SHA-256 + REFUNDED/CANCELED 는 종착 상태: 재업로드로 PAID_CONFIRMED 등 비종착 상태로 되돌리지 않음 (CF07, 오류 행) + 이용권이 살아 있는 주문의 reward_tier 변경은 거절 (환불 후 재업로드) + 환불 = POST /refunds: payment_status REFUNDED (paid_at 유지) + 살아 있는 이용권 REVOKED +end note + +note bottom of class_tickets + 이용권(PRD Entitlement + RewardCode 통합). + 코드 : 128bit 랜덤 → Base32 표시 (티어 등 의미 없음, 디자인 PICKE-30-DEMO 는 예시일 뿐) + 원문은 발급 시 1회만 전달, DB 에는 code_hash(SHA-256) 만 보관, 조회도 해시로 + 등록 확인 화면의 인원/기간은 해시로 찾은 행에서 읽어 보여줌 + code_status : ISSUED | DELIVERED | CLAIMED | REVOKED + status : UNREGISTERED | ACTIVE | EXPIRED | REGISTER_EXPIRED | REVOKED + register_by = funding_orders.paid_at + 1년 (결제 확정일 기준 확정, 발급 시 계산, NOT NULL) + 경과 시 "등록기간이 만료되었습니다" (디자인 확인) + activated_at = 등록 버튼 누른 시각 (D01, 디자인 "등록하는 순간 이용 기간이 시작") + expires_at = activated_at + 6개월 (디자인 예시 2026.10.09 ~ 2027.04.08) + 읽기 유예 종료 = expires_at + 30일 (계산값, 컬럼 없음) + 클래스 생성 수 무제한 → max_classes 없음 + 계정당 이용권 : 보유 이력은 여러 행 허용, 동시에 ACTIVE 는 1개만 + UK(registered_user_id) WHERE status = 'ACTIVE' (partial) + ACTIVE 보유 중 추가 등록 → 거절 ("사용 중인 이용권이 있어요"), 만료 후 재등록 허용 + 본인이 등록한 코드 재입력 → 거절 대신 기존 결과 반환, 기간 불변 (PRD §4, #535) + 코드 확인(preview) 은 소비 없이 정원·등록기한만 반환 (PRD §17, #535) + "이용권 추가등록하기" 버튼은 만료/유예 상태에서만 활성 + 재발급 = 기존 행 REVOKED + 새 행, register_by 는 그대로 복사 (CF06) + -- #543 구현 반영 -- + UK(funding_order_id) 는 제거하고 partial UK(funding_order_id) WHERE status <> 'REVOKED' 로 교체 + → 재발급·환불 이력(REVOKED 행)은 그대로 남고, 살아 있는 행은 주문당 1개 (V20261019_02) + 코드 = SecureRandom 130bit → Crockford Base32 26자, 표시 XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX + 발급(POST /admin/funding/tickets/issue)·재발급(POST /tickets/{id}/reissue) 응답에서만 1회 노출, 로그 금지 + 발급 대상 = PAID_CONFIRMED + quantity 1 + paid_at 있음 + 살아 있는 행 없음 (재실행 시 ALREADY_ISSUED 스킵, CF08) + 재발급은 ISSUED/DELIVERED 만: CLAIMED 409 TICKET_409_CLAIMED, REVOKED 409 TICKET_409_REVOKED + 전달 기록(POST /tickets/{id}/deliveries) → ticket_delivery_attempts 행, SUCCESS 면 code_status DELIVERED + delivered_at + 자동 이메일/SMS 발송·코드 임시 보관·업로드 배치 테이블은 후속 +end note + +note bottom of classes + battle_type : TTS | CUSTOM (CUSTOM 은 2차, 1차는 TTS 만) + status : ACTIVE | DELETED (마감 여부는 effective_close_at 으로 조회 시 판정) + effective_close_at = LEAST(deadline, ticket.expires_at) (계산값) + 이용권이 REVOKED(환불·재발급) 면 revoked_at 기준으로 즉시 마감 (PRD §18 ENTITLEMENT_REVOKED, #535) + deadline 은 "현재 시각 이후 ~ 이용권 종료일" 범위만 허용 (디자인 에러 문구) + deadline null = 마감 없음 (디자인 "참여 마감일" 토글 OFF) + 마감 후 : 투표·댓글 차단, 결과 보기만 가능 + capacity 는 생성 시점 ticket.capacity 복사 (운영자 참여 시 운영자 1석 포함) + 개설 후 수정 가능 항목 : name, deadline 연장만 + name 길이 : 1~20자 (기획 Q&A 확정) + battle_type = CUSTOM 썸네일은 디자인 고정 이미지 1종, 생성 안 함 (기획 Q&A 확정) + 참여는 로그인(가입) 필수, 비회원 참여 없음 (기획 Q&A 확정) +end note + +note bottom of class_members + display_name : 클래스 안에서만 보이는 별칭, 본인이 수정 가능, 1~20자 + role : OWNER | MEMBER + status : ACTIVE | LEFT | REMOVED (REMOVED 는 되돌릴 수 없음, 디자인 확인) + seat_held : 좌석 소비 여부. 사전투표 이후 활동이 있으면 나가도 true 유지 (D04) + → 정원 판정 = COUNT(seat_held = true) + UK(class_id, user_id) + UK(class_id, display_name) WHERE status = 'ACTIVE' (partial, 동명이인 불가 - 기획 Q&A 확정) + 운영자는 owner_participates = true 일 때만 행 생성 + 참여 단계는 user_battles.step (class_id 스코프) 로 추적 + 참여는 클래스 행 잠금(FOR UPDATE) 뒤 정원 검사 → 멤버 생성 한 트랜잭션 (PRD F06, #537) + 같은 계정 재참여 요청 → 거절 대신 기존 상세 반환 (PRD §15 "멤버 수를 늘리지 않고 기존 상세로", #537) + LEFT 재참여 → 같은 행 되살림 (UK 유지), seat_held 였으면 좌석 추가 소비 없음 (#537) + 운영자는 자기 코드로 참여 불가, 나가기·내보내기 대상 아님 → 클래스 삭제로 종료 (PRD §15, #537) + 참여 거절은 이용권 종료(REVOKED·만료, CLASS_409_TICKET_ENDED) 와 마감일 경과(CLASS_409_CLOSED) 를 구분 (PRD §15 "이용 종료"/"입장 마감", #537) + 초대코드 입력 정규화 = 공백·하이픈 제거 + 대문자(Locale.ROOT), 이용권 코드(TicketCodeHasher)와 동일 규칙 +end note + +note bottom of ai_jobs + AI 호출 1건 = 1행. 비용은 ticket_id 기준 합산 (PRD §21). + type : TOPIC_CANDIDATES | TOPIC_VALIDATION | DEBATE_ARGUMENT + | MEMBER_REPORT | CLASS_INSIGHT + status : QUEUED | RUNNING | SUCCEEDED | FAILED | REJECTED + idempotency_key 로 연속 탭 중복 생성 방지 (UX04) + 불완전 JSON·거절·한도 종료는 SUCCEEDED 로 저장하지 않음 (A13) + DEBATE_ARGUMENT(AI 철학자 반박) 길이 : 프롬프트 200~500자, 검증 120~500자 (#602). 위반 시 절삭하지 않고 1회 보정 호출 후 REJECTED +end note + +note bottom of class_reports + GPT 생성 결과 캐시. 클래스 마감 후 1회 생성 (D06). + type : CLASS_SUMMARY (member_id null) | MEMBER_FEEDBACK + status : PENDING | GENERATING | SUCCEEDED | LIMITED | FAILED + content(MEMBER_FEEDBACK) : headline, judgment_basis, strength{title,body}|null, + next_focus{title,body}|null, next_question, evidence_refs[], analysis_status, coverage + LIMITED(글 없음)은 서버 템플릿, AI 호출 없음 + UK(class_id, member_id, type) + -- #541 구현 반영 -- + content(CLASS_SUMMARY) : cutoff_at, stats{active_member_count, completed_count, participation_rate, + comment_writer_count, comment_rate, post_vote_count, options[]}, insight{insight_text, evidence_ids[], + highlight_ids[], analysis_status, coverage, excluded_count} + CLASS_SUMMARY 행 존재 = 마감 스냅샷 생성됨. 마감 후 통계는 이 stats 로 고정(SNAPSHOT), 마감 전은 실시간(LIVE) + 생성: 스케줄러(ClassReportScheduler, 60초 fixedDelay)가 effective_close_at 경과·CLASS_SUMMARY 없는 클래스를 찾아 + CLASS_SUMMARY + 참여 완료자별 MEMBER_FEEDBACK(PENDING) 을 한 트랜잭션에 만들고, PENDING 을 순차 생성 + MEMBER_FEEDBACK 입력: 본인 PUBLISHED 의견 + 본인 댓글 최신 20건, 8,000자 내 (초과 시 오래된 댓글부터 제외 → coverage=partial) + 글 없음 → LIMITED 템플릿(AI·ai_jobs 없음). evidence_refs 의 source_id 는 본인 글 id, quote 는 원문 포함 검증 + judgment_basis 근거 없음 → REJECTED(EVIDENCE_MISSING), strength/next_focus 근거 없음 → null + CLASS_INSIGHT 입력: 공개 의견·댓글 작성자별 최신 2건, 입장 교차, 최대 60건·12,000자, 작성자 익명 번호 + 유효 작성자 3명 미만 → LIMITED 템플릿. evidence_ids 1~5, highlight_ids ≤2 (조회 시 최신 의견으로 보충) + 실패: 자동 2회(attempt<2 면 PENDING 복귀) → FAILED. 운영자 재시도 POST /result/insight/retry 는 attempt<3 일 때만 + 같은 ai_jobs 행(idempotency_key = TYPE:classId:memberId|class)에 attempt 누적, 토큰은 succeed 시 기록 (§21) + 열람: 운영자·ACTIVE 멤버, 이용권 종료 + 30일(ClassTicket.getReadGraceEndsAt) 까지, 이후 409 CLASS_409_RESULT_EXPIRED + 근거 글은 조회 시점에 다시 읽어 삭제·숨김이면 제외 (저장된 content 는 id·quote 만) +end note + +note bottom of votes + 클래스 중간투표는 votes.mid_vote_option_id 대신 class_mid_votes 에 선택 지점별로 저장 (#577) + 기존 콘텐츠 중 중간투표가 있는 것만 (battles.mid_vote_enabled = true, 예: 뒤샹의 변기) + 클래스용 별도 중간투표 UI 없음, 기존 콘텐츠 결과 수집 용도만 (기획 Q&A 확정) + 클래스 내 배틀 화면은 일반 배틀과 동일 (사전투표·채팅방·최종투표·의견 목록, 좋아요/정렬) + → votes / perspectives / perspective_comments 를 class_id 로 스코프만 분리해 재사용 + 클래스 의견·댓글 작성자는 class_members.display_name 으로 노출, userTag 는 null (#575) + class_members.status 가 LEFT/REMOVED 인 작성자의 의견·댓글·사후투표는 클래스 목록·vote-stats 에서 제외 (재참여 시 다시 노출) + class_id 추가에 따라 유니크 변경: + votes UK(user_id, battle_id) → UK(user_id, battle_id, class_id) + user_battles UK(user_id, battle_id) → UK(user_id, battle_id, class_id) + 일반 배틀 조회는 class_id IS NULL 조건 필요 + PostgreSQL은 UK에서 NULL을 서로 다른 값으로 취급하므로 + 일반 배틀(class_id NULL)은 partial unique index 로 기존 1행 보장 유지 + → UK(user_id, battle_id) WHERE class_id IS NULL 추가 + -- #539 구현 반영 -- + 클래스 진행 API: /api/v1/classes/{classId}/battle/** (배틀 ID 는 classes.battle_id 사용) + 접근: ACTIVE 멤버 또는 운영자만 조회, 쓰기는 ACTIVE 멤버만(미참여 운영자 403) + + 이용권 종료 409 CLASS_TICKET_ENDED, effective_close_at 경과 409 CLASS_CLOSED + 투표 잠금(PRD §7): 사전투표는 user_battles.step 이 NONE/PRE_VOTE 일 때만 변경(이후 CLASS_PRE_VOTE_LOCKED), + 사후투표는 1회(이후 VOTE_ALREADY_SUBMITTED), TTS 재청취는 COMPLETED 를 되돌리지 않음 + 클래스 투표는 크레딧 차감·성향 점수·battles.total_participants_count·SSE·새 댓글 알림 없음 + 참여 완료(F10) = step COMPLETED + (classes.comment_required 면) 내 PUBLISHED perspective 1건 이상 + class_members.has_activity 판정 = user_battles(user_id, class_id) 존재 여부 (F15 좌석 유지) + -- 중간투표 구현 반영 (#577, PRD §7 checkpoint_id 별 기록) -- + POST /api/v1/classes/{classId}/battle/votes/mid {checkpointId, optionId} + 대상: classes.battle_type=TTS AND battles.mid_vote_enabled AND 시나리오에 선택지 있는 노드 존재 (아니면 409 CLASS_409_MID_VOTE) + checkpointId = scenario_nodes.id, optionId = scenario_options.id (없으면 404 CLASS_404_MID_VOTE_OPTION) + 사전투표 행 필요(404 VOTE_404), 사후투표 후 409 CLASS_409_MID_VOTE_LOCKED + 지점별 1건, 사후투표 전까지 변경 가능, 첫 기록 시 step NONE/PRE_VOTE → TTS_LISTENING (사전투표 잠금) + 필수: 시작 노드부터 고른 선택지를 따라 만나는 지점에 모두 답해야 사후투표 (아니면 409 CLASS_409_MID_VOTE_REQUIRED) + 결과·리포트: 통계 스냅샷에 mid_vote_enabled·mid_vote_count·mid_vote_checkpoints(지점별 선택지 분포, 활성 멤버만), + 개인 리포트 입력·content 에 mid_choices, LIMITED 템플릿은 중간 선택 문구를 덧붙임 +end note + +entity "class_mid_votes" as class_mid_votes { + * id : BIGINT <> + -- + vote_id : BIGINT <> + checkpoint_id : BIGINT + option_id : BIGINT + option_label : VARCHAR(255) + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +votes ||--o{ class_mid_votes + +note bottom of class_mid_votes + UK(vote_id, checkpoint_id) : 선택 지점별 1건 (PRD "투표는 phase+checkpoint_id 고유") + checkpoint_id = scenario_nodes.id, option_id = scenario_options.id + 시나리오 재저장 시 id 가 바뀔 수 있어 FK 없음, 선택 당시 문구(option_label)를 함께 저장 + 중간투표 저장은 votes 행을 비관적 락으로 잡아 연속 요청을 직렬화 +end note + +note bottom of class_topic_drafts + -- #545 커스텀 배틀 AI 주제 제안 (PRD §10 AI-01) -- + 운영자의 사용 중 이용권 아래 초안 1개 = 제안 3배치(최초+재추천, 조건 변경 포함) + 수정 검증 3회 제공 + 조건: topic_seed 2~200자, difficulty, categories 1~3개(enum 이름) + API: /api/v1/class-topic-drafts (생성·조회), /{id}/suggestions, /{id}/selection, /{id}/validations + 제안: 후보 정확히 3개 (S05 화면 기준, 기획 확정)(question ≤60, description ≤80, options 2개: label ≤20, rationale ≤40, 철학자 = PhilosopherType 코드) + 서버 검사(개수·길이·조건 일치·철학자 허용 목록·두 철학자 다름·질문 중복) 실패 → 문제 목록으로 1회 보정 → 계속 실패면 REJECTED + 실패·맥락 부족(NEEDS_CLARIFICATION)은 기존 후보 유지, batch_no 증가 없음 + FAILED 이유는 suggest_failure_reason (#608): AI_UNAVAILABLE = 제공자 장애, INVALID_OUTPUT = 보정 후에도 규칙 위반. 새 제안을 시작하면 비운다 + 분당 2회/계정 = ai_jobs(TOPIC_CANDIDATES, ticket_id, started_at) 1분 내 건수 (계정당 ACTIVE 이용권 1개) + GENERATING 3분 경과는 멈춘 작업으로 보고 새 요청 허용 + ai_jobs 멱등키: TOPIC_CANDIDATES:{draft_id}:{suggest_request_count}, TOPIC_VALIDATION:{draft_id}:{validation_count} + 제공자 호출은 작업당 최대 3회(연결 오류·429·5xx 재시도 + 보정 1회 포함), attempt 에 누적 +end note + +note bottom of class_topic_versions + 클래스 개설에 쓸 수 있는 검증된 주제 (topic_version_id). 만든 뒤 수정하지 않는다 + CANDIDATE = 후보 선택(AI 호출 없음, source_ref "b{batch}:c{candidate}", 같은 후보 재선택은 기존 행 반환) + EDITED = 직접 수정 → 서버 형식 검사(AI 없음, 횟수 미차감) → AI 의미 검증 통과 시에만 생성 (source_ref "v{바탕 version_no}") + AI 는 valid / issues[{field, code, message}] 만 돌려주고 수정안을 고쳐 쓰지 않는다 + 제공자 장애로 판정을 못 받으면 validation_count 환불 + profile_version: 철학자 허용 목록 버전 (debate-profile-v1), prompt_version: topic-v3 + AI-02 토론이 선택지별 철학자 매핑을 사용한다 + -- #547 커스텀 배틀 클래스 -- + POST /api/v1/classes 는 battleId(TTS) 또는 topicVersionId(CUSTOM) 중 하나 (둘 다/둘 다 없음 400 CLASS_400_BATTLE_SOURCE) + 본인 초안의 버전만, 같은 초안으로 클래스 1개 (classes.topic_draft_id UK, 초안 행 잠금 후 검사 → 409 CLASS_409_TOPIC_DRAFT_USED) + 주제 버전 → 클래스 전용 battles 1행 + battle_options 2행 (title=질문, summary·description=설명, difficulty·category 복사, + option title=label, stance=rationale, representative=철학자 이름, image_url=철학자 이미지 키, display_order 1/2) + status PENDING + creator_type USER + creator_id 운영자 → 앱 목록·추천·검색(PUBLISHED 만)과 어드민 배틀 목록(USER 제외)에 안 나옴 + 썸네일 = picke.class.custom-battle-thumbnail-url (고정 1종, 비어 있으면 null) + CUSTOM 클래스는 TTS 완료 API 409 CLASS_409_TTS. 사후투표 연결은 #548 토론 완료 후 +end note + +note bottom of class_debate_sessions + -- #548 AI 철학자 3라운드 토론 (PRD §8, §11 AI-02) -- + API: /api/v1/classes/{classId}/battle/debate (GET 재접속, POST 시작, POST /replies, POST /retry) + CUSTOM 클래스 + 사전투표 후 시작. 참가자 선택지의 반대편 = opponent, 철학자 = 주제 버전의 그 선택지 매핑 (3라운드 고정) + 시작하면 user_battles.step = TTS_LISTENING (사전투표 잠금, 커스텀에서는 "토론 중" 의미) + 상태: GENERATING(n) → WAITING_USER(n) → ... → 3라운드 반박 저장 시 COMPLETED + step POST_VOTE. 실패 FAILED(n) → /retry + 단계 이동은 저장된 턴과 상태만 믿는다(모델의 round·done 필드는 거절). 4라운드 없음 + UK(class_id, member_id), 반박·재시도는 세션 행 잠금(FOR UPDATE) + 커스텀 클래스 사후투표는 COMPLETED 세션이 있어야 함 (409 CLASS_409_DEBATE_INCOMPLETE) + ai_jobs: DEBATE_ARGUMENT:{session_id}:{round}, 라운드당 제공자 호출 최대 3회(재시도·보정 1회 포함), 재시도는 같은 행 attempt 누적 +end note + +note bottom of class_debate_messages + (session_id, round_index, speaker) UK → 라운드·화자당 1개, 저장 후 수정 없음 + USER: 앞뒤 공백 제거 후 50~500자 (기획 확인 10/23), request_key 로 중복 전송 방지 + 같은 키 재전송 = 기존 결과, 다른 키로 같은 라운드 = 409 CLASS_409_DEBATE_ROUND + AI: 검증 통과한 주장만 저장 (argument_text 120~500자, focus EVIDENCE/ASSUMPTION/COUNTEREXAMPLE/CONDITION/VALUE_CONFLICT), + reply_to_message_id = 직전 라운드 USER 메시지 (1라운드 null) +end note + +note bottom of battles + 클래스 Picke 배틀 추천 (#551, GET /api/v1/class-battle-recommendations) + 후보: status PUBLISHED + deleted_at NULL + class_recommendable = true + + difficulty 일치 + category IN (선택 1~3개) + 주제 단어(쉼표·공백, 최대 5개)가 title·summary·description 에 많이 들어간 순 + → 에디터픽 → 참여자 수 → 최신 id 순, 최대 3개 (모자라면 채우지 않음) + difficulty·category·class_recommendable 는 어드민 PATCH /admin/battles/{id}/class-settings 로 설정 + 추천을 켜려면 difficulty·category 필수, 커스텀 배틀(creator_type USER)은 설정 불가 + difficulty 는 임시로 초급 기본값 (#571: 기존 NULL → BEGINNER, 어드민 신규 생성도 BEGINNER) +end note + +note bottom of classes + 데이터 파기 (#565, PRD §18): 이용권 expires_at + 30일(읽기 유예)이 지난 클래스 (삭제된 클래스 포함) + 파기: perspectives·perspective_comments(class_id) 와 그 좋아요·신고, class_debate_sessions·messages, class_reports + 유지: votes·user_battles·class_members·classes 행, ai_jobs (원문 없음) + 클래스 단위 트랜잭션, 끝나면 purged_at 기록. 스케줄러 매일 04:00 KST, picke.class-retention.purge-enabled=true 일 때만 +end note + +@enduml diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java index 335d0198..c2ea4811 100644 --- a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.ad.link; +import com.swyp.picke.domain.ad.entity.AdCreative; import org.springframework.web.util.UriComponentsBuilder; /** @@ -24,7 +25,7 @@ static String merge(String landingUrl, String paramName, String value) { } /** 지면별 성과를 가르기 위한 추적값. 영문·숫자·밑줄만 쓰므로 인코딩이 필요 없다. */ - static String subIdOf(com.swyp.picke.domain.ad.entity.AdCreative creative) { + static String subIdOf(AdCreative creative) { return creative.getSlot().name() + "_" + creative.getCode(); } } diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java index e24fc754..1a06d623 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java @@ -1,6 +1,10 @@ package com.swyp.picke.domain.admin.controller; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse; +import com.swyp.picke.domain.battle.thumbnail.BattleThumbnailCandidateService; import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseRequest; import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -32,6 +36,7 @@ public class AdminBattleController { private final AdminBattleService adminBattleService; + private final BattleThumbnailCandidateService battleThumbnailCandidateService; @Operation(summary = "배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함)") @PostMapping("/parse") @@ -41,6 +46,14 @@ public ApiResponse parseBattleScript( return ApiResponse.onSuccess(adminBattleService.parseBattleScript(request.rawText())); } + @Operation(summary = "배틀 썸네일 AI 후보 생성 (S3 업로드만, 배틀에는 저장 안 함)") + @PostMapping("/thumbnail-candidates") + public ApiResponse generateThumbnailCandidates( + @RequestBody @Valid AdminBattleThumbnailCandidateRequest request + ) { + return ApiResponse.onSuccess(battleThumbnailCandidateService.generate(request)); + } + @Operation(summary = "배틀 생성") @PostMapping public ApiResponse createBattle( @@ -65,6 +78,17 @@ public ApiResponse updateBattle( return ApiResponse.onSuccess(adminBattleService.updateBattle(battleId, request)); } + @Operation(summary = "배틀 클래스 설정 수정", + description = "클래스 추천 필터용 수준·카테고리, 클래스 추천 노출, 중간투표 여부를 바꿉니다. null 필드는 변경하지 않습니다. " + + "추천을 켤 때 수준·카테고리가 비어 있으면 400 CLASS_400_RECOMMEND_SETTINGS, 커스텀 배틀은 400 CLASS_400_BATTLE.") + @PatchMapping("/{battleId}/class-settings") + public ApiResponse updateClassSettings( + @PathVariable Long battleId, + @RequestBody AdminBattleClassSettingsRequest request + ) { + return ApiResponse.onSuccess(adminBattleService.updateClassSettings(battleId, request)); + } + @Operation(summary = "배틀 삭제") @DeleteMapping("/{battleId}") public ApiResponse deleteBattle( diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminFundingController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminFundingController.java new file mode 100644 index 00000000..076302c3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminFundingController.java @@ -0,0 +1,165 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderTargetResponse; +import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderResponse; +import com.swyp.picke.domain.admin.dto.funding.request.RegisterReminderRequest; +import com.swyp.picke.domain.admin.dto.funding.request.FundingRefundRequest; +import com.swyp.picke.domain.admin.dto.funding.request.TicketDeliveryRequest; +import com.swyp.picke.domain.admin.dto.funding.request.TicketIssueRequest; +import com.swyp.picke.domain.admin.dto.funding.response.FundingImportResponse; +import com.swyp.picke.domain.admin.dto.funding.response.FundingOrderPageResponse; +import com.swyp.picke.domain.admin.dto.funding.response.FundingRefundResponse; +import com.swyp.picke.domain.admin.dto.funding.response.FundingSummaryResponse; +import com.swyp.picke.domain.admin.dto.funding.response.AdminTicketCodeResponse; +import com.swyp.picke.domain.admin.dto.funding.response.TicketDeliveryResponse; +import com.swyp.picke.domain.admin.dto.funding.response.TicketIssueResponse; +import com.swyp.picke.domain.admin.dto.funding.response.TumblbugCampaignDashboardResponse; +import com.swyp.picke.domain.admin.service.AdminFundingService; +import com.swyp.picke.domain.funding.enums.DeliveryStatus; +import com.swyp.picke.domain.ticket.enums.PaymentStatus; +import com.swyp.picke.domain.ticket.enums.TicketCodeStatus; +import com.swyp.picke.domain.ticket.enums.TicketStatus; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +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.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; +import org.springframework.web.multipart.MultipartFile; + +/** + * 어드민 펀딩 명단·이용권 운영. 모든 API 는 ROLE_ADMIN. + * 이용권 코드 원문은 발급 응답 또는 별도 관리자 코드 조회 API 로만 노출됩니다. + */ +@Tag(name = "관리자 펀딩·이용권 API", description = "펀딩 명단 CSV 가져오기, 이용권 코드 발급·전달 기록·재발급·환불, 주문/이용권 현황") +@RestController +@RequestMapping("/api/v1/admin/funding") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminFundingController { + + private final AdminFundingService adminFundingService; + + @Operation(summary = "펀딩 명단 CSV 가져오기", + description = "UTF-8 CSV (헤더: platform, campaign_id, order_id, reward_tier, quantity, supporter_name, supporter_email, " + + "supporter_phone, payment_status, paid_at). order_id 기준으로 추가/갱신하며 여러 번 올려도 안전합니다. " + + "행 단위 오류는 errors 로 돌려주고 나머지는 반영합니다. REFUNDED/CANCELED 주문은 PAID_CONFIRMED 로 되돌리지 않습니다. " + + "헤더가 다르거나 파일을 읽을 수 없으면 400 FUNDING_400_CSV.") + @PostMapping(value = "/imports", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse importCsv(@RequestParam("file") MultipartFile file) { + return ApiResponse.onSuccess(adminFundingService.importCsv(file)); + } + + @Operation(summary = "이용권 코드 발급", + description = "fundingOrderIds 를 비우면 발급 가능한 전체 주문(결제 완료·미발급 수량 있음)에 발급합니다. " + + "주문 수량만큼 발급하며 이미 수량을 모두 발급한 주문은 건너뛰므로 재실행해도 중복 발급되지 않습니다. " + + "응답의 code 를 반환하고 서버는 해시와 암호문을 저장합니다.") + @PostMapping("/tickets/issue") + public ApiResponse issueTickets(@RequestBody(required = false) TicketIssueRequest request) { + return ApiResponse.onSuccess(adminFundingService.issueTickets(request)); + } + + @Operation(summary = "관리자 이용권 코드 조회", description = "대시보드에서 코드 전체 보기·복사할 때 호출합니다. ROLE_ADMIN 전용이며 조회 이력을 남깁니다.") + @GetMapping("/tickets/{ticketId}/code") + public org.springframework.http.ResponseEntity> getTicketCode( + @PathVariable Long ticketId, + @AuthenticationPrincipal Long adminUserId + ) { + return org.springframework.http.ResponseEntity.ok() + .cacheControl(org.springframework.http.CacheControl.noStore()) + .body(ApiResponse.onSuccess(adminFundingService.getTicketCode(ticketId, adminUserId))); + } + + @Operation(summary = "이용권 전달 기록", + description = "이메일/SMS/수동으로 코드를 전달한 결과를 기록합니다. SUCCESS 면 codeStatus 가 DELIVERED 로 바뀝니다. " + + "이미 등록(CLAIMED)됐거나 무효화(REVOKED)된 이용권이면 409 TICKET_409_STATE.") + @PostMapping("/tickets/{ticketId}/deliveries") + public ApiResponse recordDelivery( + @PathVariable Long ticketId, + @Valid @RequestBody TicketDeliveryRequest request + ) { + return ApiResponse.onSuccess(adminFundingService.recordDelivery(ticketId, request)); + } + + @Operation(summary = "이용권 코드 재발급", + description = "코드 분실 등으로 기존 코드를 무효화(REVOKED)하고 같은 주문·정원·등록기한으로 새 코드를 발급합니다. " + + "등록 전(ISSUED/DELIVERED)인 이용권만 가능. 이미 등록됨 409 TICKET_409_CLAIMED, 이미 무효화 409 TICKET_409_REVOKED, " + + "등록기한 경과 409 TICKET_409_REG_EXP.") + @PostMapping("/tickets/{ticketId}/reissue") + public ApiResponse reissue(@PathVariable Long ticketId) { + return ApiResponse.onSuccess(adminFundingService.reissue(ticketId)); + } + + @Operation(summary = "펀딩 환불 처리", + description = "주문을 REFUNDED 로 바꾸고 살아 있는 이용권을 무효화(REVOKED)합니다. 이미 환불된 주문은 alreadyRefunded 로 돌려줍니다. " + + "없는 주문 ID 가 섞여 있으면 404 FUNDING_404_ORDER.") + @PostMapping("/refunds") + public ApiResponse refund(@Valid @RequestBody FundingRefundRequest request) { + return ApiResponse.onSuccess(adminFundingService.refund(request)); + } + + @Operation(summary = "펀딩 주문·이용권 목록", + description = "주문과 살아 있는 이용권 요약 및 텀블벅 원본 정보(코드 원문·해시 제외). 주문번호·후원자명·이메일·후원번호 검색과 정원·전달 결과 필터를 지원합니다.") + @GetMapping("/orders") + public ApiResponse getOrders( + @RequestParam(required = false) String campaignId, + @RequestParam(required = false) PaymentStatus paymentStatus, + @RequestParam(required = false) TicketStatus ticketStatus, + @RequestParam(required = false) TicketCodeStatus codeStatus, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Integer capacity, + @RequestParam(required = false) DeliveryStatus deliveryStatus, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess( + adminFundingService.getOrders(campaignId, paymentStatus, ticketStatus, codeStatus, keyword, capacity, deliveryStatus, page, size)); + } + + @Operation(summary = "텀블벅 캠페인 대시보드 통계", + description = "캠페인의 후원 건수, 결제 완료, 전달 완료, 미전달 수를 조회합니다. 전달 완료는 최근 전달 기록이 성공인 주문 기준입니다.") + @GetMapping("/orders/tumblbug/dashboard") + public ApiResponse getTumblbugCampaignDashboard( + @RequestParam String campaignId + ) { + return ApiResponse.onSuccess(adminFundingService.getTumblbugCampaignDashboard(campaignId)); + } + + @Operation(summary = "등록기한 임박 알림 대상", + description = "전달 완료(DELIVERED)·미등록 이용권 중 등록기한이 D30(7일 초과~30일 이내) 또는 D7(7일 이내)에 들고, " + + "해당 단계 알림을 아직 SUCCESS 로 기록하지 않은 건을 등록기한 빠른 순으로 돌려줍니다. " + + "미등록 이용권은 앱 계정이 없어 푸시가 불가하므로, 운영자가 후원자 연락처로 보낸 뒤 결과를 기록합니다.") + @GetMapping("/register-reminders") + public ApiResponse getRegisterReminderTargets( + @RequestParam RegisterReminderStage stage + ) { + return ApiResponse.onSuccess(adminFundingService.getRegisterReminderTargets(stage)); + } + + @Operation(summary = "등록기한 임박 알림 발송 기록", + description = "운영자가 보낸 등록기한 알림 결과를 단계별로 기록합니다. SUCCESS 로 기록한 단계는 대상 목록에서 빠집니다. " + + "전달 완료·미등록 이용권이 아니면 409 TICKET_409_STATE, 등록기한이 지났으면 409 TICKET_409_REG_EXP.") + @PostMapping("/tickets/{ticketId}/register-reminders") + public ApiResponse recordRegisterReminder( + @PathVariable Long ticketId, + @Valid @RequestBody RegisterReminderRequest request + ) { + return ApiResponse.onSuccess(adminFundingService.recordRegisterReminder(ticketId, request)); + } + + @Operation(summary = "펀딩·이용권 현황 요약", description = "결제 확정/발급 가능/보류/발급/미전달/전달/등록/환불/취소 카운트, 등록기한 알림 대상 수") + @GetMapping("/summary") + public ApiResponse getSummary() { + return ApiResponse.onSuccess(adminFundingService.getSummary()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleClassSettingsRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleClassSettingsRequest.java new file mode 100644 index 00000000..8264f1cf --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleClassSettingsRequest.java @@ -0,0 +1,23 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** null 인 필드는 변경하지 않는다. 클래스 추천을 켜려면 수준·카테고리가 모두 정해져 있어야 한다. */ +@Schema(description = "배틀 클래스 설정 수정 요청 (null 필드는 변경 없음)") +public record AdminBattleClassSettingsRequest( + + @Schema(description = "대상 수준", example = "BEGINNER", nullable = true) + BattleDifficulty difficulty, + + @Schema(description = "카테고리 (철학·사회·문학·과학·예술·역사)", example = "철학", nullable = true) + BattleCategory category, + + @Schema(description = "클래스 추천 배틀로 노출할지", example = "true", nullable = true) + Boolean classRecommendable, + + @Schema(description = "중간투표 콘텐츠 여부", example = "false", nullable = true) + Boolean midVoteEnabled +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleThumbnailCandidateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleThumbnailCandidateRequest.java new file mode 100644 index 00000000..44793e7f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleThumbnailCandidateRequest.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import jakarta.validation.constraints.NotBlank; + +/** + * 저장 전 폼 내용으로도 후보를 만들 수 있게 battleId 대신 배틀 내용을 받는다. + * 썸네일은 제목(주제)을 그리므로 A/B 입장은 받지 않는다. 요약/설명은 제목의 맥락으로만 쓴다. + */ +public record AdminBattleThumbnailCandidateRequest( + @NotBlank String title, + String summary, + String description +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java index 9defbdf4..89d63b5a 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java @@ -2,7 +2,9 @@ import com.swyp.picke.domain.battle.dto.response.BattleOptionResponse; import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; +import com.swyp.picke.domain.battle.enums.BattleCategory; import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.battle.enums.BattleStatus; import java.time.LocalDate; @@ -26,5 +28,9 @@ public record AdminBattleDetailResponse( List tags, List options, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + BattleDifficulty difficulty, + BattleCategory category, + boolean classRecommendable, + boolean midVoteEnabled ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleThumbnailCandidatesResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleThumbnailCandidatesResponse.java new file mode 100644 index 00000000..4d4314fd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleThumbnailCandidatesResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.admin.dto.battle.response; + +import java.util.List; + +/** + * 썸네일 후보 목록. + * key 는 배틀 저장 시 thumbnailUrl 로 그대로 넘기고, previewUrl 은 화면 미리보기용(presigned)이다. + */ +public record AdminBattleThumbnailCandidatesResponse(List candidates) { + + public record Candidate(String key, String previewUrl) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/FundingRefundRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/FundingRefundRequest.java new file mode 100644 index 00000000..12a491a3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/FundingRefundRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.funding.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record FundingRefundRequest( + @Schema(description = "환불 처리할 펀딩 주문 ID 목록") + @NotEmpty List fundingOrderIds +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/ManualTumblbugBuyerRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/ManualTumblbugBuyerRequest.java new file mode 100644 index 00000000..64fd0222 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/ManualTumblbugBuyerRequest.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.admin.dto.funding.request; + +import com.swyp.picke.domain.funding.enums.DeliveryStatus; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; +import com.swyp.picke.domain.ticket.enums.PaymentStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +@Schema(description = "텀블벅 펀딩 구매자 수동 등록. 코드는 입력하지 않고 결제 완료 시 PICKE가 발급합니다.") +public record ManualTumblbugBuyerRequest( + @NotNull TumblbugPlan plan, + @NotBlank @Size(max = 50) String campaignId, + @NotBlank @Size(max = 120) String campaignName, + @NotBlank @Size(max = 120) String supporterNumber, + @NotBlank @Size(max = 120) String rewardLineId, + @NotBlank @Size(max = 50) String buyerName, + @NotBlank @Size(max = 120) String rewardName, + @Size(max = 300) String giftText, + @NotNull @Min(5) @Max(30) Integer capacity, + @NotNull @Min(1) @Max(7) Integer quantity, + @NotNull LocalDateTime supportedAt, + @NotNull @PositiveOrZero Long rewardAmount, + @NotNull @PositiveOrZero Long additionalSupportAmount, + @NotNull @PositiveOrZero Long totalSupportAmount, + @NotNull PaymentStatus paymentStatus, + @NotNull DeliveryStatus giftDeliveryStatus +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/RegisterReminderRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/RegisterReminderRequest.java new file mode 100644 index 00000000..86e9560c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/RegisterReminderRequest.java @@ -0,0 +1,19 @@ +package com.swyp.picke.domain.admin.dto.funding.request; + +import com.swyp.picke.domain.ticket.enums.DeliveryChannel; +import com.swyp.picke.domain.ticket.enums.DeliveryStatus; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record RegisterReminderRequest( + @Schema(description = "알림 단계 (D30: 등록기한 30일 전, D7: 7일 전)", example = "D30") + @NotNull RegisterReminderStage stage, + @Schema(description = "발송 수단", example = "EMAIL") + @NotNull DeliveryChannel channel, + @Schema(description = "발송 결과. SUCCESS 로 기록한 단계는 알림 대상에서 빠진다", example = "SUCCESS") + @NotNull DeliveryStatus status, + @Schema(description = "실패 사유 (FAILED 일 때)", nullable = true) + @Size(max = 255) String failureReason +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketDeliveryRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketDeliveryRequest.java new file mode 100644 index 00000000..ea05adbe --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketDeliveryRequest.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.admin.dto.funding.request; + +import com.swyp.picke.domain.ticket.enums.DeliveryChannel; +import com.swyp.picke.domain.ticket.enums.DeliveryStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record TicketDeliveryRequest( + @Schema(description = "전달 수단", example = "EMAIL") + @NotNull DeliveryChannel channel, + @Schema(description = "전달 결과. SUCCESS 면 이용권 코드 상태가 DELIVERED 로 바뀐다", example = "SUCCESS") + @NotNull DeliveryStatus status, + @Schema(description = "실패 사유 (FAILED 일 때)", nullable = true) + @Size(max = 255) String failureReason +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketIssueRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketIssueRequest.java new file mode 100644 index 00000000..eb55c2b5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketIssueRequest.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.funding.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record TicketIssueRequest( + @Schema(description = "발급할 펀딩 주문 ID 목록. 비우면 결제 완료된 전체 주문의 미발급 수량을 대상으로 한다", nullable = true) + List fundingOrderIds +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/AdminTicketCodeResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/AdminTicketCodeResponse.java new file mode 100644 index 00000000..f55a0b15 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/AdminTicketCodeResponse.java @@ -0,0 +1,4 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +public record AdminTicketCodeResponse(Long ticketId, String code) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingImportResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingImportResponse.java new file mode 100644 index 00000000..344564e1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingImportResponse.java @@ -0,0 +1,23 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** CSV 업로드 결과. 행 단위 오류는 errors 로 모아 돌려주고, 나머지 행은 정상 반영된다. */ +public record FundingImportResponse( + @Schema(description = "업로드한 파일의 SHA-256 (import_file_hash 에 기록)") String fileHash, + @Schema(description = "빈 줄을 제외한 데이터 행 수") int totalRows, + @Schema(description = "새로 추가된 주문 수") int created, + @Schema(description = "값이 바뀌어 갱신된 주문 수") int updated, + @Schema(description = "값 변화가 없던 주문 수") int unchanged, + @Schema(description = "반영하지 못한 행 수 (= errors.size())") int errorCount, + @Schema(description = "업로드 후 기준, 지금 발급 가능한 주문 수 (결제 완료·남은 수량 있음)") long issuableCount, + @Schema(description = "보류 주문 수 (결제 확정이지만 수량이 1이 아니거나 결제일 없음, 결제 대기·예약)") long heldCount, + List errors +) { + public record RowError( + @Schema(description = "CSV 행 번호 (헤더 = 1)") int line, + @Schema(nullable = true) String orderId, + String reason + ) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderPageResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderPageResponse.java new file mode 100644 index 00000000..41b9e7e4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderPageResponse.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import java.util.List; + +public record FundingOrderPageResponse( + List items, + int page, + int size, + long totalElements, + boolean hasNext +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderSummaryResponse.java new file mode 100644 index 00000000..c199e509 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderSummaryResponse.java @@ -0,0 +1,48 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import com.swyp.picke.domain.ticket.enums.FundingPlatform; +import com.swyp.picke.domain.ticket.enums.PaymentStatus; +import com.swyp.picke.domain.ticket.enums.RewardTier; +import com.swyp.picke.domain.ticket.enums.TicketCodeStatus; +import com.swyp.picke.domain.ticket.enums.TicketStatus; +import com.swyp.picke.domain.funding.dto.response.TumblbugOrderDetailResponse; +import com.swyp.picke.domain.funding.enums.DeliveryStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +/** 주문 + 살아 있는 이용권 요약. 코드 원문은 별도 관리자 조회 API로만 반환합니다. */ +public record FundingOrderSummaryResponse( + Long fundingOrderId, + FundingPlatform platform, + String campaignId, + String orderId, + RewardTier rewardTier, + int quantity, + String supporterName, + @Schema(nullable = true) String supporterEmail, + @Schema(nullable = true) String supporterPhone, + PaymentStatus paymentStatus, + @Schema(nullable = true) LocalDateTime paidAt, + @Schema(description = "결제 완료·결제일 존재·미발급 수량이 남아 있는지") boolean issuable, + @Schema(description = "살아 있는(REVOKED 아닌) 이용권. 없으면 null", nullable = true) Ticket ticket, + @Schema(description = "주문 수량만큼 발급된 살아 있는 이용권 목록") List tickets, + @Schema(description = "텀블벅 XLSX 원본 정보. CSV 주문은 null", nullable = true) TumblbugOrderDetailResponse tumblbug, + @Schema(description = "최근 전달 결과. 이력이 없으면 PENDING") + DeliveryStatus deliveryStatus +) { + public record Ticket( + Long ticketId, + int capacity, + TicketCodeStatus codeStatus, + TicketStatus status, + @Schema(nullable = true) LocalDateTime deliveredAt, + LocalDateTime registerBy, + @Schema(nullable = true) LocalDateTime activatedAt, + @Schema(nullable = true) LocalDateTime expiresAt, + @Schema(nullable = true) Long registeredUserId, + @Schema(nullable = true) String registeredNickname, + @Schema(nullable = true, description = "관리자 조회용 화면에서 코드를 찾을 때 보조하는 끝 4자리") String codeSuffix, + DeliveryStatus deliveryStatus + ) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingRefundResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingRefundResponse.java new file mode 100644 index 00000000..709dda40 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingRefundResponse.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record FundingRefundResponse( + @Schema(description = "이번 요청으로 REFUNDED 가 된 주문 수") int refunded, + @Schema(description = "함께 무효화(REVOKED)된 이용권 수") int revokedTickets, + @Schema(description = "이미 REFUNDED 였던 주문 ID") List alreadyRefunded +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingSummaryResponse.java new file mode 100644 index 00000000..441966fe --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingSummaryResponse.java @@ -0,0 +1,19 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** 어드민 펀딩 대시보드 카운트. 이용권 수치는 살아 있는(REVOKED 아닌) 행 기준. */ +public record FundingSummaryResponse( + @Schema(description = "전체 주문 수") long totalOrders, + @Schema(description = "결제 확정 주문 수") long paidConfirmed, + @Schema(description = "발급 가능 (결제 완료·남은 수량 있음)") long issuable, + @Schema(description = "보류 (결제 확정이지만 수량이 1이 아니거나 결제일 없음, 결제 대기·예약)") long held, + @Schema(description = "발급된 이용권 수 (살아 있는 행)") long issued, + @Schema(description = "미전달 (codeStatus=ISSUED)") long undelivered, + @Schema(description = "전달 완료 (codeStatus=DELIVERED, 아직 미등록)") long delivered, + @Schema(description = "등록 완료 (ACTIVE + EXPIRED)") long registered, + @Schema(description = "환불 주문 수") long refunded, + @Schema(description = "취소 주문 수") long canceled, + @Schema(description = "등록기한 30일 전 알림 대상 수") long registerReminderD30, + @Schema(description = "등록기한 7일 전 알림 대상 수") long registerReminderD7 +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/ManualTumblbugBuyerResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/ManualTumblbugBuyerResponse.java new file mode 100644 index 00000000..56e15167 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/ManualTumblbugBuyerResponse.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import com.swyp.picke.domain.funding.enums.DeliveryStatus; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; +import com.swyp.picke.domain.ticket.enums.PaymentStatus; +import java.time.LocalDateTime; +import java.util.List; + +/** 수동 등록 결과. 발급 코드는 관리자 응답에만 포함하고 로그에는 기록하지 않습니다. */ +public record ManualTumblbugBuyerResponse( + Long fundingOrderId, + TumblbugPlan plan, + String campaignId, + String supporterNumber, + String buyerName, + String rewardName, + int quantity, + LocalDateTime supportedAt, + long rewardAmount, + long additionalSupportAmount, + long totalSupportAmount, + PaymentStatus paymentStatus, + DeliveryStatus giftDeliveryStatus, + List issuedCodes +) { + public record IssuedCode(Long ticketId, String code) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderResponse.java new file mode 100644 index 00000000..dba55578 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import com.swyp.picke.domain.ticket.enums.DeliveryChannel; +import com.swyp.picke.domain.ticket.enums.DeliveryStatus; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record RegisterReminderResponse( + Long reminderId, + Long ticketId, + RegisterReminderStage stage, + DeliveryChannel channel, + DeliveryStatus status, + @Schema(nullable = true) String failureReason, + LocalDateTime attemptedAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderTargetResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderTargetResponse.java new file mode 100644 index 00000000..40c5fd38 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderTargetResponse.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import com.swyp.picke.domain.ticket.enums.FundingPlatform; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "등록기한 임박 알림 대상. 전달 완료·미등록이고 이 단계 알림을 아직 성공 기록하지 않은 이용권, 등록기한 빠른 순") +public record RegisterReminderTargetResponse( + RegisterReminderStage stage, + @Schema(description = "대상 수") int count, + List items +) { + public record Item( + Long ticketId, + Long fundingOrderId, + FundingPlatform platform, + @Schema(description = "플랫폼 주문 번호") String orderId, + String supporterName, + @Schema(nullable = true) String supporterEmail, + @Schema(nullable = true) String supporterPhone, + int capacity, + LocalDateTime registerBy, + @Schema(description = "등록기한까지 남은 일수 (KST 날짜 기준)") long daysLeft + ) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketDeliveryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketDeliveryResponse.java new file mode 100644 index 00000000..61026a43 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketDeliveryResponse.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import com.swyp.picke.domain.ticket.enums.DeliveryChannel; +import com.swyp.picke.domain.ticket.enums.DeliveryStatus; +import com.swyp.picke.domain.ticket.enums.TicketCodeStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record TicketDeliveryResponse( + Long attemptId, + Long ticketId, + DeliveryChannel channel, + DeliveryStatus status, + @Schema(nullable = true) String failureReason, + LocalDateTime attemptedAt, + @Schema(description = "기록 후 이용권 코드 상태") TicketCodeStatus codeStatus, + @Schema(nullable = true) LocalDateTime deliveredAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketIssueResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketIssueResponse.java new file mode 100644 index 00000000..135909fe --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketIssueResponse.java @@ -0,0 +1,32 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 발급 결과. code 원문은 이 응답에서만 볼 수 있다 (서버는 해시만 저장, 로그 금지). + * 창을 닫으면 복구할 수 없으므로 전달 전까지 어드민 화면에서 보관해야 한다. + */ +public record TicketIssueResponse( + List issued, + List skipped +) { + public record IssuedTicket( + Long ticketId, + Long fundingOrderId, + String orderId, + String supporterName, + @Schema(nullable = true) String supporterEmail, + @Schema(nullable = true) String supporterPhone, + int capacity, + @Schema(description = "등록 기한 (결제 확정일 + 1년)") LocalDateTime registerBy, + @Schema(description = "관리자에게 표시되는 이용권 코드 (XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX)") String code + ) {} + + public record Skipped( + Long fundingOrderId, + @Schema(nullable = true) String orderId, + @Schema(description = "ALREADY_ISSUED | NOT_ISSUABLE | NOT_FOUND") String reason + ) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TumblbugCampaignDashboardResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TumblbugCampaignDashboardResponse.java new file mode 100644 index 00000000..af1678f7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TumblbugCampaignDashboardResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "텀블벅 캠페인 관리자 대시보드 통계") +public record TumblbugCampaignDashboardResponse( + String campaignId, + @Schema(description = "등록된 후원 주문 수") long supporterCount, + @Schema(description = "결제 완료 주문 수") long paidConfirmed, + @Schema(description = "최근 전달 결과가 성공인 주문 수") long delivered, + @Schema(description = "최근 전달 결과가 성공이 아닌 주문 수") long undelivered +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java index 0e082f3b..be4c136f 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.admin.service; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -33,6 +34,10 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe return battleService.updateBattle(battleId, request); } + public AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleClassSettingsRequest request) { + return battleService.updateClassSettings(battleId, request); + } + public AdminBattleDeleteResponse deleteBattle(Long battleId) { return battleService.deleteBattle(battleId); } diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminFundingService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminFundingService.java new file mode 100644 index 00000000..9dd76175 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminFundingService.java @@ -0,0 +1,516 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.funding.request.FundingRefundRequest; +import com.swyp.picke.domain.admin.dto.funding.request.TicketDeliveryRequest; +import com.swyp.picke.domain.admin.dto.funding.request.RegisterReminderRequest; +import com.swyp.picke.domain.admin.dto.funding.request.TicketIssueRequest; +import com.swyp.picke.domain.admin.dto.funding.response.FundingImportResponse; +import com.swyp.picke.domain.admin.dto.funding.response.FundingOrderPageResponse; +import com.swyp.picke.domain.admin.dto.funding.response.FundingOrderSummaryResponse; +import com.swyp.picke.domain.admin.dto.funding.response.FundingRefundResponse; +import com.swyp.picke.domain.admin.dto.funding.response.FundingSummaryResponse; +import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderResponse; +import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderTargetResponse; +import com.swyp.picke.domain.admin.dto.funding.response.TumblbugCampaignDashboardResponse; +import com.swyp.picke.domain.admin.dto.funding.response.TicketDeliveryResponse; +import com.swyp.picke.domain.admin.dto.funding.response.TicketIssueResponse; +import com.swyp.picke.domain.admin.dto.funding.response.AdminTicketCodeResponse; +import com.swyp.picke.domain.ticket.entity.ClassTicket; +import com.swyp.picke.domain.ticket.entity.FundingOrder; +import com.swyp.picke.domain.ticket.entity.TicketDeliveryAttempt; +import com.swyp.picke.domain.ticket.entity.TicketRegisterReminder; +import com.swyp.picke.domain.ticket.enums.DeliveryStatus; +import com.swyp.picke.domain.ticket.enums.PaymentStatus; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import com.swyp.picke.domain.ticket.enums.FundingPlatform; +import com.swyp.picke.domain.ticket.enums.RewardTier; +import com.swyp.picke.domain.funding.dto.response.TumblbugOrderDetailResponse; +import com.swyp.picke.domain.funding.entity.AdminAuditLog; +import com.swyp.picke.domain.funding.repository.AdminAuditLogRepository; +import com.swyp.picke.domain.funding.service.TumblbugImportRecordService; +import com.swyp.picke.domain.ticket.enums.TicketCodeStatus; +import com.swyp.picke.domain.ticket.enums.TicketStatus; +import com.swyp.picke.domain.ticket.repository.ClassTicketRepository; +import com.swyp.picke.domain.ticket.repository.FundingOrderRepository; +import com.swyp.picke.domain.ticket.repository.TicketDeliveryAttemptRepository; +import com.swyp.picke.domain.ticket.repository.TicketRegisterReminderRepository; +import com.swyp.picke.domain.ticket.util.FundingCsvParser; +import com.swyp.picke.domain.ticket.util.TicketCodeGenerator; +import com.swyp.picke.domain.ticket.util.TicketCodeHasher; +import com.swyp.picke.domain.ticket.service.TicketCodeVault; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +/** + * 어드민 펀딩 명단·이용권 운영 (PRD §30~§33). + *
    + *
  • 명단 CSV 업로드: order_id 기준 upsert (CF01). 환불·취소는 종착 상태라 되살리지 않는다 (CF07).
  • + *
  • 발급: 결제 확정·미발급 수량만큼 코드 생성. SHA-256 해시와 AES-GCM 암호문을 저장합니다.
  • + *
  • 재발급: 기존 행 REVOKED + 같은 등록기한의 새 행 (CF06). 환불: 주문 REFUNDED + 살아 있는 이용권 REVOKED.
  • + *
+ * 코드 원문은 어떤 경로로도 로그에 남기지 않는다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminFundingService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + private static final int CODE_RETRY_LIMIT = 5; + + private final FundingOrderRepository fundingOrderRepository; + private final ClassTicketRepository classTicketRepository; + private final TicketDeliveryAttemptRepository ticketDeliveryAttemptRepository; + private final TicketRegisterReminderRepository ticketRegisterReminderRepository; + private final TumblbugImportRecordService tumblbugImportRecordService; + private final TicketCodeVault ticketCodeVault; + private final AdminAuditLogRepository adminAuditLogRepository; + + // ───────────── 1. 명단 가져오기 ───────────── + + @Transactional + public FundingImportResponse importCsv(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new CustomException(ErrorCode.FILE_NOT_FOUND); + } + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (IOException e) { + throw new CustomException(ErrorCode.FUNDING_CSV_INVALID); + } + String fileHash = sha256Hex(bytes); + FundingCsvParser.Result parsed = FundingCsvParser.parse(new ByteArrayInputStream(bytes)); + + List errors = new ArrayList<>(); + parsed.errors().forEach(e -> errors.add(new FundingImportResponse.RowError(e.line(), e.orderId(), e.reason()))); + + List orderIds = parsed.rows().stream().map(FundingCsvParser.Row::orderId).toList(); + Map existingByOrderId = fundingOrderRepository.findByOrderIdIn(orderIds).stream() + .collect(Collectors.toMap(FundingOrder::getOrderId, Function.identity())); + Map liveTicketCountByOrder = classTicketRepository + .findByFundingOrderIdInAndStatusNot( + existingByOrderId.values().stream().map(FundingOrder::getId).toList(), TicketStatus.REVOKED) + .stream().collect(Collectors.groupingBy(t -> t.getFundingOrder().getId(), Collectors.counting())); + + int created = 0; + int updated = 0; + int unchanged = 0; + for (FundingCsvParser.Row row : parsed.rows()) { + FundingOrder existing = existingByOrderId.get(row.orderId()); + if (existing == null) { + fundingOrderRepository.save(FundingOrder.builder() + .platform(row.platform()) + .campaignId(row.campaignId()) + .orderId(row.orderId()) + .rewardTier(row.rewardTier()) + .quantity(row.quantity()) + .supporterName(row.supporterName()) + .supporterEmail(row.supporterEmail()) + .supporterPhone(row.supporterPhone()) + .paymentStatus(row.paymentStatus()) + .paidAt(row.paidAt()) + .importFileHash(fileHash) + .build()); + created++; + continue; + } + if (existing.isTerminal() && !isTerminal(row.paymentStatus())) { + errors.add(new FundingImportResponse.RowError(row.line(), row.orderId(), + "이미 " + existing.getPaymentStatus() + " 처리된 주문은 " + row.paymentStatus() + " 로 되돌릴 수 없습니다.")); + continue; + } + long liveTicketCount = liveTicketCountByOrder.getOrDefault(existing.getId(), 0L); + if (liveTicketCount > 0 && existing.getRewardTier() != row.rewardTier()) { + errors.add(new FundingImportResponse.RowError(row.line(), row.orderId(), + "이용권이 이미 발급된 주문의 reward_tier 는 변경할 수 없습니다. 환불 처리 후 다시 올려 주세요.")); + continue; + } + if (liveTicketCount > row.quantity()) { + errors.add(new FundingImportResponse.RowError(row.line(), row.orderId(), + "이미 발급된 코드 수보다 quantity 를 작게 변경할 수 없습니다.")); + continue; + } + boolean changed = existing.applyImport(row.platform(), row.campaignId(), row.rewardTier(), row.quantity(), + row.supporterName(), row.supporterEmail(), row.supporterPhone(), + row.paymentStatus(), row.paidAt(), fileHash); + if (changed) { + updated++; + } else { + unchanged++; + } + } + fundingOrderRepository.flush(); + + return new FundingImportResponse(fileHash, parsed.totalRows(), created, updated, unchanged, errors.size(), + fundingOrderRepository.countIssuable(), fundingOrderRepository.countHeld(), errors); + } + + private static boolean isTerminal(PaymentStatus status) { + return status == PaymentStatus.REFUNDED || status == PaymentStatus.CANCELED; + } + + // ───────────── 2. 발급 ───────────── + + @Transactional + public TicketIssueResponse issueTickets(TicketIssueRequest request) { + List issued = new ArrayList<>(); + List skipped = new ArrayList<>(); + + List targetIds = new ArrayList<>(); + if (request == null || request.fundingOrderIds() == null || request.fundingOrderIds().isEmpty()) { + targetIds.addAll(fundingOrderRepository.findIssuable().stream().map(FundingOrder::getId).toList()); + } else { + List ids = request.fundingOrderIds().stream().distinct().sorted().toList(); + Map found = fundingOrderRepository.findAllById(ids).stream() + .collect(Collectors.toMap(FundingOrder::getId, Function.identity())); + for (Long id : ids) { + FundingOrder order = found.get(id); + if (order == null) { + skipped.add(new TicketIssueResponse.Skipped(id, null, "NOT_FOUND")); + } else if (!order.isIssuable() || order.getPaidAt() == null) { + skipped.add(new TicketIssueResponse.Skipped(id, order.getOrderId(), "NOT_ISSUABLE")); + } else { + targetIds.add(id); + } + } + } + + if (targetIds.isEmpty()) return new TicketIssueResponse(issued, skipped); + targetIds.sort(Long::compareTo); + List lockedTargets = fundingOrderRepository.findAllByIdForUpdate(targetIds); + Map liveCountByOrder = classTicketRepository.findByFundingOrderIdInAndStatusNot( + lockedTargets.stream().map(FundingOrder::getId).toList(), TicketStatus.REVOKED) + .stream().collect(Collectors.groupingBy(ticket -> ticket.getFundingOrder().getId(), Collectors.counting())); + for (FundingOrder order : lockedTargets) { + if (!order.isIssuable()) { + skipped.add(new TicketIssueResponse.Skipped(order.getId(), order.getOrderId(), "NOT_ISSUABLE")); + continue; + } + long liveCount = liveCountByOrder.getOrDefault(order.getId(), 0L); + if (liveCount >= order.getQuantity()) { + skipped.add(new TicketIssueResponse.Skipped(order.getId(), order.getOrderId(), "ALREADY_ISSUED")); + continue; + } + for (long i = liveCount; i < order.getQuantity(); i++) { + String rawCode = generateUniqueCode(); + ClassTicket ticket = classTicketRepository.save(ClassTicket.issue(order, TicketCodeHasher.hash(rawCode), + ticketCodeVault.encrypt(TicketCodeGenerator.format(rawCode)))); + ticket.recordCodeSuffix(rawCode); + issued.add(toIssued(ticket, rawCode)); + } + } + return new TicketIssueResponse(issued, skipped); + } + + /** 해시 충돌(사실상 0에 가깝지만)은 재생성으로 흡수. 원문은 반환값으로만 흐른다 */ + private String generateUniqueCode() { + for (int i = 0; i < CODE_RETRY_LIMIT; i++) { + String raw = TicketCodeGenerator.generate(); + if (!classTicketRepository.existsByCodeHash(TicketCodeHasher.hash(raw))) { + return raw; + } + } + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + private static TicketIssueResponse.IssuedTicket toIssued(ClassTicket ticket, String rawCode) { + FundingOrder order = ticket.getFundingOrder(); + return new TicketIssueResponse.IssuedTicket(ticket.getId(), order.getId(), order.getOrderId(), + order.getSupporterName(), order.getSupporterEmail(), order.getSupporterPhone(), + ticket.getCapacity(), ticket.getRegisterBy(), TicketCodeGenerator.format(rawCode)); + } + + // ───────────── 3. 전달 기록 ───────────── + + @Transactional + public TicketDeliveryResponse recordDelivery(Long ticketId, TicketDeliveryRequest request) { + ClassTicket ticket = getTicket(ticketId); + if (!ticket.isUnclaimed()) { + throw new CustomException(ErrorCode.TICKET_STATE_CONFLICT); + } + LocalDateTime now = LocalDateTime.now(KST); + TicketDeliveryAttempt attempt = ticketDeliveryAttemptRepository.save(TicketDeliveryAttempt.builder() + .ticket(ticket) + .channel(request.channel()) + .status(request.status()) + .failureReason(request.status() == DeliveryStatus.FAILED ? request.failureReason() : null) + .attemptedAt(now) + .build()); + if (request.status() == DeliveryStatus.SUCCESS) { + ticket.markDelivered(now); + } + return new TicketDeliveryResponse(attempt.getId(), ticket.getId(), attempt.getChannel(), attempt.getStatus(), + attempt.getFailureReason(), attempt.getAttemptedAt(), ticket.getCodeStatus(), ticket.getDeliveredAt()); + } + + // ───────────── 4. 재발급 ───────────── + + @Transactional + public TicketIssueResponse.IssuedTicket reissue(Long ticketId) { + ClassTicket previous = getTicket(ticketId); + if (previous.isRevoked()) { + throw new CustomException(ErrorCode.TICKET_CODE_REVOKED); + } + if (previous.getCodeStatus() == TicketCodeStatus.CLAIMED) { + throw new CustomException(ErrorCode.TICKET_CODE_ALREADY_CLAIMED); + } + if (previous.getStatus() == TicketStatus.REGISTER_EXPIRED) { + throw new CustomException(ErrorCode.TICKET_REGISTER_EXPIRED); + } + LocalDateTime now = LocalDateTime.now(KST); + previous.revoke(now); + // 살아 있는 행은 주문당 1개(partial unique). Hibernate 는 INSERT 를 UPDATE 보다 먼저 내보내므로 먼저 flush 한다. + classTicketRepository.flush(); + + String rawCode = generateUniqueCode(); + ClassTicket reissued = classTicketRepository.save( + ClassTicket.reissueFrom(previous, TicketCodeHasher.hash(rawCode), + ticketCodeVault.encrypt(TicketCodeGenerator.format(rawCode)))); + reissued.recordCodeSuffix(rawCode); + return toIssued(reissued, rawCode); + } + + // ───────────── 5. 환불 ───────────── + + @Transactional + public FundingRefundResponse refund(FundingRefundRequest request) { + List ids = request.fundingOrderIds().stream().distinct().toList(); + List orders = fundingOrderRepository.findAllById(ids); + if (orders.size() != ids.size()) { + throw new CustomException(ErrorCode.FUNDING_ORDER_NOT_FOUND); + } + Map> ticketsByOrder = classTicketRepository.findByFundingOrderIdInAndStatusNot(ids, TicketStatus.REVOKED) + .stream().collect(Collectors.groupingBy(ticket -> ticket.getFundingOrder().getId())); + LocalDateTime now = LocalDateTime.now(KST); + int refunded = 0; + int revokedTickets = 0; + List alreadyRefunded = new ArrayList<>(); + for (FundingOrder order : orders) { + if (order.getPaymentStatus() == PaymentStatus.REFUNDED) { + alreadyRefunded.add(order.getId()); + continue; + } + order.updatePayment(PaymentStatus.REFUNDED, order.getPaidAt()); + refunded++; + for (ClassTicket live : ticketsByOrder.getOrDefault(order.getId(), List.of())) { + live.revoke(now); + revokedTickets++; + } + } + return new FundingRefundResponse(refunded, revokedTickets, alreadyRefunded); + } + + // ───────────── 5-1. 등록기한 임박 알림 ───────────── + + /** + * 미등록 이용권은 앱 계정에 연결되지 않아 푸시를 보낼 수 없다. 코드 전달과 같이 운영자가 허용된 연락 수단으로 보내고 결과를 기록한다. + * 대상은 조회 시점 기준으로 계산하므로 별도 배치가 없다. + */ + public RegisterReminderTargetResponse getRegisterReminderTargets(RegisterReminderStage stage) { + LocalDateTime now = LocalDateTime.now(KST); + List items = classTicketRepository + .findRegisterReminderTargets(stage.windowStart(now), stage.windowEnd(now), stage).stream() + .map(ticket -> toReminderTarget(ticket, now)) + .toList(); + return new RegisterReminderTargetResponse(stage, items.size(), items); + } + + @Transactional + public RegisterReminderResponse recordRegisterReminder(Long ticketId, RegisterReminderRequest request) { + ClassTicket ticket = getTicket(ticketId); + if (ticket.getStatus() != TicketStatus.UNREGISTERED || ticket.getCodeStatus() != TicketCodeStatus.DELIVERED) { + throw new CustomException(ErrorCode.TICKET_STATE_CONFLICT); + } + LocalDateTime now = LocalDateTime.now(KST); + if (!ticket.isRegisterWindowOpen(now)) { + throw new CustomException(ErrorCode.TICKET_REGISTER_EXPIRED); + } + TicketRegisterReminder reminder = ticketRegisterReminderRepository.save(TicketRegisterReminder.builder() + .ticket(ticket) + .stage(request.stage()) + .channel(request.channel()) + .status(request.status()) + .failureReason(request.status() == DeliveryStatus.FAILED ? request.failureReason() : null) + .attemptedAt(now) + .build()); + return new RegisterReminderResponse(reminder.getId(), ticket.getId(), reminder.getStage(), reminder.getChannel(), + reminder.getStatus(), reminder.getFailureReason(), reminder.getAttemptedAt()); + } + + private static RegisterReminderTargetResponse.Item toReminderTarget(ClassTicket ticket, LocalDateTime now) { + FundingOrder order = ticket.getFundingOrder(); + return new RegisterReminderTargetResponse.Item(ticket.getId(), order.getId(), order.getPlatform(), + order.getOrderId(), order.getSupporterName(), order.getSupporterEmail(), order.getSupporterPhone(), + ticket.getCapacity(), ticket.getRegisterBy(), + ChronoUnit.DAYS.between(now.toLocalDate(), ticket.getRegisterBy().toLocalDate())); + } + + private long countRegisterReminderTargets(RegisterReminderStage stage, LocalDateTime now) { + return classTicketRepository.countRegisterReminderTargets(stage.windowStart(now), stage.windowEnd(now), stage); + } + + // ───────────── 6. 조회 ───────────── + + public FundingOrderPageResponse getOrders(String campaignId, PaymentStatus paymentStatus, TicketStatus ticketStatus, + TicketCodeStatus codeStatus, String keyword, Integer capacity, + com.swyp.picke.domain.funding.enums.DeliveryStatus deliveryStatus, + int page, int size) { + int pageNumber = Math.max(page, 0); + int pageSize = size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); + String normalizedKeyword = (keyword == null || keyword.isBlank()) ? null : keyword.trim(); + String normalizedCampaignId = (campaignId == null || campaignId.isBlank()) ? null : campaignId.trim(); + RewardTier rewardTier = null; + if (capacity != null) { + try { + rewardTier = RewardTier.valueOf("SEAT_" + capacity); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.FUNDING_REWARD_UNSUPPORTED); + } + } + // 전달 상태는 boolean 으로 넘긴다. enum 파라미터를 상수와 비교하면 Hibernate 가 파라미터 타입을 정하지 못해 500 이 난다 (#612) + boolean pending = deliveryStatus == com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING; + boolean sent = deliveryStatus == com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT; + boolean failed = deliveryStatus == com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED; + + Page result = fundingOrderRepository.searchOrders( + paymentStatus, ticketStatus, codeStatus, normalizedCampaignId, normalizedKeyword, rewardTier, sent, failed, pending, + PageRequest.of(pageNumber, pageSize)); + List orderIds = result.getContent().stream().map(FundingOrder::getId).toList(); + Map tumblbugDetails = tumblbugImportRecordService.getDetails(orderIds); + Map> liveTicketsByOrder = orderIds.isEmpty() ? Map.of() + : classTicketRepository.findByFundingOrderIdInAndStatusNotOrderByIdAsc(orderIds, TicketStatus.REVOKED) + .stream().collect(Collectors.groupingBy(t -> t.getFundingOrder().getId())); + List ticketIds = liveTicketsByOrder.values().stream().flatMap(List::stream).map(ClassTicket::getId).toList(); + Map deliveries = ticketIds.isEmpty() ? Map.of() + : ticketDeliveryAttemptRepository.findByTicketIdInOrderByIdDesc(ticketIds).stream() + .collect(Collectors.toMap(a -> a.getTicket().getId(), TicketDeliveryAttempt::getStatus, + (latest, ignored) -> latest)); + List items = result.getContent().stream() + .map(order -> toSummary(order, liveTicketsByOrder.getOrDefault(order.getId(), List.of()), + tumblbugDetails.get(order.getId()), deliveries)) + .toList(); + return new FundingOrderPageResponse(items, pageNumber, pageSize, result.getTotalElements(), result.hasNext()); + } + + public FundingSummaryResponse getSummary() { + LocalDateTime now = LocalDateTime.now(KST); + return new FundingSummaryResponse( + fundingOrderRepository.count(), + fundingOrderRepository.countByPaymentStatus(PaymentStatus.PAID_CONFIRMED), + fundingOrderRepository.countIssuable(), + fundingOrderRepository.countHeld(), + classTicketRepository.countByStatusNot(TicketStatus.REVOKED), + classTicketRepository.countByCodeStatus(TicketCodeStatus.ISSUED), + classTicketRepository.countByCodeStatus(TicketCodeStatus.DELIVERED), + classTicketRepository.countByStatusIn(List.of(TicketStatus.ACTIVE, TicketStatus.EXPIRED)), + fundingOrderRepository.countByPaymentStatus(PaymentStatus.REFUNDED), + fundingOrderRepository.countByPaymentStatus(PaymentStatus.CANCELED), + countRegisterReminderTargets(RegisterReminderStage.D30, now), + countRegisterReminderTargets(RegisterReminderStage.D7, now)); + } + + public TumblbugCampaignDashboardResponse getTumblbugCampaignDashboard(String campaignId) { + String normalizedCampaignId = campaignId == null ? "" : campaignId.trim(); + if (normalizedCampaignId.isBlank() || normalizedCampaignId.length() > 50) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + long total = fundingOrderRepository.countByPlatformAndCampaignId( + FundingPlatform.TUMBLBUG, normalizedCampaignId); + long paid = fundingOrderRepository.countByPlatformAndCampaignIdAndPaymentStatus( + FundingPlatform.TUMBLBUG, normalizedCampaignId, PaymentStatus.PAID_CONFIRMED); + long delivered = fundingOrderRepository.countDeliveredByTumblbugCampaign(normalizedCampaignId); + return new TumblbugCampaignDashboardResponse(normalizedCampaignId, total, paid, delivered, total - delivered); + } + + private static FundingOrderSummaryResponse toSummary(FundingOrder order, List tickets, + TumblbugOrderDetailResponse tumblbug, + Map deliveries) { + List ticketViews = new ArrayList<>(); + for (ClassTicket ticket : tickets) { + User registrant = ticket.getRegisteredUser(); + ticketViews.add(new FundingOrderSummaryResponse.Ticket(ticket.getId(), ticket.getCapacity(), + ticket.getCodeStatus(), ticket.getStatus(), ticket.getDeliveredAt(), ticket.getRegisterBy(), + ticket.getActivatedAt(), ticket.getExpiresAt(), + registrant == null ? null : registrant.getId(), + registrant == null ? null : registrant.getNickname(), ticket.getCodeSuffix(), + toDeliveryView(deliveries.get(ticket.getId())))); + } + boolean issuable = order.isIssuable() && ticketViews.size() < order.getQuantity(); + return new FundingOrderSummaryResponse(order.getId(), order.getPlatform(), order.getCampaignId(), + order.getOrderId(), order.getRewardTier(), order.getQuantity(), order.getSupporterName(), + order.getSupporterEmail(), order.getSupporterPhone(), order.getPaymentStatus(), order.getPaidAt(), + issuable, ticketViews.isEmpty() ? null : ticketViews.getFirst(), List.copyOf(ticketViews), tumblbug, + orderDeliveryView(ticketViews)); + } + + /** 이용권의 마지막 전달 기록. 기록이 없으면 PENDING */ + private static com.swyp.picke.domain.funding.enums.DeliveryStatus toDeliveryView(DeliveryStatus latest) { + if (latest == DeliveryStatus.SUCCESS) return com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT; + if (latest == DeliveryStatus.FAILED) return com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED; + return com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING; + } + + /** 주문 단위 전달 상태. 목록 필터(searchOrders)와 같은 기준: 모두 SENT 면 SENT, 하나라도 FAILED 면 FAILED (#612) */ + private static com.swyp.picke.domain.funding.enums.DeliveryStatus orderDeliveryView( + List tickets) { + if (!tickets.isEmpty() && tickets.stream().allMatch(t -> + t.deliveryStatus() == com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT)) { + return com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT; + } + if (tickets.stream().anyMatch(t -> t.deliveryStatus() == com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED)) { + return com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED; + } + return com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING; + } + + @Transactional + public AdminTicketCodeResponse getTicketCode(Long ticketId, Long adminUserId) { + ClassTicket ticket = getTicket(ticketId); + if (ticket.getEncryptedCode() == null || ticket.getEncryptedCode().isBlank()) { + throw new CustomException(ErrorCode.TICKET_CODE_NOT_RETRIEVABLE); + } + adminAuditLogRepository.save(new AdminAuditLog(adminUserId, "TICKET_CODE_VIEWED", "CLASS_TICKET", + ticketId, null, "{}")); + return new AdminTicketCodeResponse(ticketId, ticketCodeVault.decrypt(ticket.getEncryptedCode())); + } + + // ───────────── 공통 ───────────── + + private ClassTicket getTicket(Long ticketId) { + return classTicketRepository.findById(ticketId) + .orElseThrow(() -> new CustomException(ErrorCode.TICKET_NOT_FOUND)); + } + + private static String sha256Hex(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java index cb0280df..2c6fd18a 100644 --- a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java @@ -47,6 +47,12 @@ public Battle toEntity(AdminBattleCreateRequest request, User admin) { } public TodayBattleResponse toTodayResponse(Battle battle, List tags, List options) { + return toTodayResponse(battle, tags, options, + battle.getCommentCount() == null ? 0L : battle.getCommentCount()); + } + + public TodayBattleResponse toTodayResponse(Battle battle, List tags, List options, + Long commentCount) { return new TodayBattleResponse( battle.getId(), battle.getTitle(), @@ -56,7 +62,8 @@ public TodayBattleResponse toTodayResponse(Battle battle, List tags, List toTagResponses(tags, null), toOptionResponses(options, optionTagsMap), battle.getCreatedAt(), - battle.getUpdatedAt() + battle.getUpdatedAt(), + battle.getDifficulty(), + battle.getCategory(), + battle.isClassRecommendable(), + battle.isMidVoteEnabled() ); } diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java index b0e653fa..9156a958 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.battle.dto.response; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import java.time.LocalDateTime; public record BattleSimpleResponse( @@ -8,5 +9,6 @@ public record BattleSimpleResponse( String thumbnailUrl, String status, LocalDateTime publishAt, - LocalDateTime createdAt + LocalDateTime createdAt, + BattleDifficulty difficulty ) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java index 097a0061..5167c4b9 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java @@ -11,5 +11,13 @@ public record TodayBattleResponse( Long participantsCount, Integer audioDuration, List tags, - List options -) {} + List options, + Long commentCount +) { + public TodayBattleResponse(Long battleId, String title, String summary, String thumbnailUrl, + Integer viewCount, Long participantsCount, Integer audioDuration, + List tags, List options) { + this(battleId, title, summary, thumbnailUrl, viewCount, participantsCount, audioDuration, + tags, options, 0L); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java index 0ee280a3..808903aa 100644 --- a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java +++ b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java @@ -1,6 +1,8 @@ package com.swyp.picke.domain.battle.entity; +import com.swyp.picke.domain.battle.enums.BattleCategory; import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.global.common.BaseEntity; @@ -22,6 +24,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; @Getter @Entity @@ -79,6 +82,28 @@ public class Battle extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + // ===== 클래스 기능 (#528) ===== + + /** 클래스 추천 필터용 난이도. 기존 콘텐츠는 null → 어드민이 부여 */ + @Enumerated(EnumType.STRING) + @Column(length = 20) + private BattleDifficulty difficulty; + + /** 클래스 추천 필터용 주제 카테고리 */ + @Enumerated(EnumType.STRING) + @Column(length = 30) + private BattleCategory category; + + /** 클래스 추천 배틀 노출 여부. 커스텀 배틀은 항상 false */ + @ColumnDefault("false") + @Column(name = "class_recommendable", nullable = false) + private boolean classRecommendable; + + /** 중간투표 콘텐츠 여부 (예: 뒤샹의 변기). true 인 배틀만 votes.mid_vote_option_id 수집 */ + @ColumnDefault("false") + @Column(name = "mid_vote_enabled", nullable = false) + private boolean midVoteEnabled; + @Builder public Battle( String title, @@ -107,6 +132,29 @@ public Battle( this.isEditorPick = false; this.commentCount = 0L; this.deletedAt = null; + this.classRecommendable = false; + this.midVoteEnabled = false; + } + + /** 어드민 클래스 설정. null 은 "변경 없음" */ + public void updateClassSettings( + BattleDifficulty difficulty, + BattleCategory category, + Boolean classRecommendable, + Boolean midVoteEnabled + ) { + if (difficulty != null) { + this.difficulty = difficulty; + } + if (category != null) { + this.category = category; + } + if (classRecommendable != null) { + this.classRecommendable = classRecommendable; + } + if (midVoteEnabled != null) { + this.midVoteEnabled = midVoteEnabled; + } } public void update( diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleDifficulty.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleDifficulty.java new file mode 100644 index 00000000..733950f7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleDifficulty.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.battle.enums; + +/** + * 클래스에서 콘텐츠를 고를 때 쓰는 난이도. 기존 콘텐츠에는 어드민이 사후 부여한다. + */ +public enum BattleDifficulty { + BEGINNER, // 초급 + INTERMEDIATE, // 중급 + ADVANCED // 고급 +} diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index b0d6dc9b..3587280a 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -1,10 +1,14 @@ package com.swyp.picke.domain.battle.repository; import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -34,6 +38,18 @@ public interface BattleRepository extends JpaRepository { "ORDER BY (battle.totalParticipantsCount + (battle.commentCount * 5)) DESC") List findBestBattles(Pageable pageable); + @Query(""" + SELECT battle, COUNT(comment) FROM Battle battle + LEFT JOIN Perspective perspective ON perspective.battle = battle + AND perspective.status = com.swyp.picke.domain.perspective.enums.PerspectiveStatus.PUBLISHED + LEFT JOIN PerspectiveComment comment ON comment.perspective = perspective AND comment.hidden = false + WHERE battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL + GROUP BY battle + HAVING COUNT(comment) > 0 + ORDER BY COUNT(comment) DESC, battle.totalParticipantsCount DESC, battle.createdAt DESC + """) + List findMostDiscussedBattles(Pageable pageable); + // 4. 오늘의 Pické @Query("SELECT battle FROM Battle battle " + "WHERE battle.targetDate = :today " + @@ -57,6 +73,11 @@ public interface BattleRepository extends JpaRepository { // 6. 전체 배틀 목록 조회 Page findByDeletedAtIsNullOrderByCreatedAtDesc(Pageable pageable); Page findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(BattleStatus status, Pageable pageable); + + /** 어드민 배틀 목록: 클래스 전용 커스텀 배틀(creator_type = USER)은 제외한다 (#547) */ + Page findByCreatorTypeNotAndDeletedAtIsNullOrderByCreatedAtDesc(BattleCreatorType creatorType, Pageable pageable); + Page findByStatusAndCreatorTypeNotAndDeletedAtIsNullOrderByCreatedAtDesc( + BattleStatus status, BattleCreatorType creatorType, Pageable pageable); List findByStatusAndDeletedAtIsNull(BattleStatus status); Page findByStatusAndPublishAtLessThanEqualAndDeletedAtIsNull( BattleStatus status, @@ -142,4 +163,14 @@ BattleParticipationStats findParticipationStats( @Param("to") LocalDate to, @Param("totalUsers") long totalUsers ); + + /** 클래스 Picke 배틀 추천 후보: 공개·미삭제·클래스 추천이 켜진 배틀 중 수준이 같고 카테고리가 겹치는 배틀 */ + @Query("SELECT b FROM Battle b " + + "WHERE b.status = :status AND b.deletedAt IS NULL AND b.classRecommendable = true " + + "AND b.difficulty = :difficulty AND b.category IN :categories") + List findClassRecommendCandidates( + @Param("status") BattleStatus status, + @Param("difficulty") BattleDifficulty difficulty, + @Param("categories") Collection categories + ); } diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java index 5d48224c..7e3db748 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java @@ -20,6 +20,7 @@ import com.swyp.picke.domain.battle.parser.EmotionClassifier.ScriptLine; import com.swyp.picke.domain.battle.parser.ScriptToneExtractor; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService; import com.swyp.picke.domain.tag.entity.Tag; @@ -109,7 +110,7 @@ public AdminBattleParseResponse parse(String rawText) { Map voiceSettings = buildVoiceSettings(binding, warnings); AdminScenarioCreateRequest scenarioPayload = new AdminScenarioCreateRequest( - null, doc.interactive, com.swyp.picke.domain.scenario.enums.ScenarioStatus.PENDING, + null, doc.interactive, ScenarioStatus.PENDING, nodes, voiceSettings); Map speakerNames = new LinkedHashMap<>(); diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java index f0bbe581..e26c7a32 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.battle.service; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -13,6 +14,8 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.enums.VoteSide; import java.time.LocalDateTime; import java.util.List; @@ -28,6 +31,8 @@ public interface BattleService { List getBestBattles(); + List getMostDiscussedBattles(); + List getTodayPicks(); List getNewBattles(List excludeIds); @@ -40,6 +45,9 @@ public interface BattleService { BattleUserDetailResponse getBattleDetail(Long battleId); + /** 조회수·현재 로그인 사용자와 무관하게, 주어진 투표 상태·단계·참여자 수로 배틀 상세를 조립한다 (클래스 배틀 상세에서 재사용) */ + BattleUserDetailResponse buildUserDetail(Battle battle, Long participantsCount, VoteSide voteSide, UserBattleStep step); + BattleVoteResponse BattleVote(Long battleId, Long optionId); BattleScenarioResponse getBattleScenario(Long battleId); @@ -52,6 +60,8 @@ public interface BattleService { AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request); + AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleClassSettingsRequest request); + AdminBattleDeleteResponse deleteBattle(Long battleId); void seedBattle(Long battleId, int botCount); diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index 38dd2757..9b4f3718 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.battle.service; import com.swyp.picke.domain.battle.converter.BattleConverter; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; @@ -11,6 +12,8 @@ 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.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.battle.util.BattleOptionDisplay; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; @@ -65,6 +68,7 @@ public class BattleServiceImpl implements BattleService { private static final int HOME_EDITOR_PICK_LIMIT = 10; private static final int HOME_TRENDING_LIMIT = 4; private static final int HOME_BEST_LIMIT = 3; + private static final int HOME_MOST_DISCUSSION_LIMIT = 3; private static final int HOME_TODAY_PICK_LIMIT = 1; private static final int HOME_NEW_LIMIT = 3; private static final int PUBLISH_BATCH_SIZE = 100; @@ -119,6 +123,15 @@ public List getBestBattles() { return loadBestBattles(HOME_BEST_LIMIT); } + @Override + public List getMostDiscussedBattles() { + List rows = battleRepository.findMostDiscussedBattles(PageRequest.of(0, HOME_MOST_DISCUSSION_LIMIT)); + List battles = rows.stream().map(row -> (Battle) row[0]).toList(); + Map commentCounts = rows.stream().collect(Collectors.toMap( + row -> ((Battle) row[0]).getId(), row -> ((Number) row[1]).longValue())); + return convertToTodayResponses(battles, commentCounts); + } + @Override @Transactional public List getTodayPicks() { @@ -184,11 +197,14 @@ public BattleListResponse getBattles(int page, int size, String status) { BattleStatus battleStatusFilter = parseBattleStatus(status); Page battlePage; + // 클래스 전용 커스텀 배틀(creator_type = USER)은 어드민 목록에서 뺀다 if (battleStatusFilter == null) { - battlePage = battleRepository.findByDeletedAtIsNullOrderByCreatedAtDesc(pageRequest); + battlePage = battleRepository.findByCreatorTypeNotAndDeletedAtIsNullOrderByCreatedAtDesc( + BattleCreatorType.USER, pageRequest); } else { - battlePage = battleRepository.findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc( + battlePage = battleRepository.findByStatusAndCreatorTypeNotAndDeletedAtIsNullOrderByCreatedAtDesc( battleStatusFilter, + BattleCreatorType.USER, pageRequest ); } @@ -229,14 +245,6 @@ private void ensureTodayPicks(LocalDate today, int requiredCount) { public BattleUserDetailResponse getBattleDetail(Long battleId) { Battle battle = findPublishedById(battleId); battle.increaseViewCount(); - 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)); @@ -254,12 +262,20 @@ public BattleUserDetailResponse getBattleDetail(Long battleId) { }) .orElse(null); - return battleConverter.toUserDetailResponse( - battle, tags, options, optionTagsMap, - battle.getTotalParticipantsCount(), - voteStatus, - currentStep - ); + return buildUserDetail(battle, battle.getTotalParticipantsCount(), voteStatus, currentStep); + } + + @Override + public BattleUserDetailResponse buildUserDetail(Battle battle, Long participantsCount, VoteSide voteSide, UserBattleStep step) { + 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()) + )); + return battleConverter.toUserDetailResponse(battle, tags, options, optionTagsMap, participantsCount, voteSide, step); } @Override @@ -332,6 +348,8 @@ public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, request.audioDuration(), request.status() ); + // 수준은 기획 확정 전까지 초급으로 두고, 어드민 상세에서 바꾼다 (#571) + battle.updateClassSettings(BattleDifficulty.BEGINNER, null, null, null); battle = battleRepository.save(battle); if (request.tagIds() != null) { @@ -392,6 +410,25 @@ public AdminBattleDetailResponse getAdminBattleDetail(Long battleId) { return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), options, optionTagsMap); } + /** + * 클래스 추천 필터용 수준·카테고리·추천 여부·중간투표 여부 설정. null 은 변경 없음. + * 사용자가 만든 커스텀 배틀은 클래스 전용이라 바꿀 수 없고, 추천을 켜면 수준·카테고리가 모두 있어야 한다. + */ + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleClassSettingsRequest request) { + Battle battle = findById(battleId); + if (battle.getCreatorType() == BattleCreatorType.USER) { + throw new CustomException(ErrorCode.CLASS_BATTLE_NOT_AVAILABLE); + } + battle.updateClassSettings(request.difficulty(), request.category(), request.classRecommendable(), request.midVoteEnabled()); + if (battle.isClassRecommendable() && (battle.getDifficulty() == null || battle.getCategory() == null)) { + throw new CustomException(ErrorCode.CLASS_RECOMMEND_SETTINGS_INCOMPLETE); + } + return getAdminBattleDetail(battleId); + } + @Override @Transactional @PreAuthorize("hasRole('ADMIN')") @@ -613,6 +650,10 @@ public void afterCommit() { } private List convertToTodayResponses(List battles) { + return convertToTodayResponses(battles, Map.of()); + } + + private List convertToTodayResponses(List battles, Map commentCountOverrides) { if (battles == null || battles.isEmpty()) return Collections.emptyList(); Map> optionsMap = battleOptionRepository.findByBattleIn(battles) @@ -627,7 +668,10 @@ private List convertToTodayResponses(List battles) 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); + Long commentCount = commentCountOverrides.get(battle.getId()); + return commentCount == null + ? battleConverter.toTodayResponse(battle, tags, options) + : battleConverter.toTodayResponse(battle, tags, options, commentCount); }).toList(); } diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java new file mode 100644 index 00000000..951998b4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java @@ -0,0 +1,147 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse.Candidate; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 배틀 내용 + 공통 스타일로 썸네일 후보를 여러 장 만들어 S3 에 올린다. + * 배틀 원문은 먼저 장면 묘사로 바꿔 후보마다 다른 장면을 쓰고, 후보는 동시에 생성한다. + * 일부가 실패해도 성공한 것만 돌려준다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BattleThumbnailCandidateService { + + static final int CANDIDATE_COUNT = 3; + + private static final String REFERENCE_GUIDE = """ + - 첨부한 이미지 %d장은 모두 스타일 예시입니다. + - 면 처리, 명암, 형태를 단순화하는 정도만 참고합니다. + - 예시의 소재(인물, 사물), 구도, 색상은 따라 하지 않습니다. 색상은 [색상]의 팔레트를, 구도는 [구도]를 따릅니다."""; + private static final String CAPTION_GUIDE = + "- 아래는 각 예시가 어떤 주제를 어떤 장면으로 옮긴 것인지에 대한 설명입니다. 주제를 상징으로 옮기는 방식만 참고합니다."; + private static final String NO_REFERENCE_GUIDE = "- 첨부한 예시 이미지는 없습니다. [스타일]의 설명만으로 그립니다."; + + private final BattleThumbnailStyleSource styleSource; + private final ThumbnailSceneWriter sceneWriter; + private final OpenAiImageClient imageClient; + private final S3UploadService s3UploadService; + private final S3PresignedUrlService s3PresignedUrlService; + + public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandidateRequest request) { + String template = styleSource.promptTemplate(); + String palette = styleSource.palette(); + List references = styleSource.referenceImages(); + List prompts = scenesFor(request, references).stream() + .map(scene -> buildPrompt(template, palette, references, scene)) + .toList(); + + AtomicInteger blockedCount = new AtomicInteger(); + List candidates; + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = prompts.stream() + .map(prompt -> CompletableFuture.supplyAsync( + () -> generateOne(prompt, references, blockedCount), executor)) + .toList(); + candidates = futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList(); + } + + if (candidates.isEmpty()) { + throw new CustomException(blockedCount.get() > 0 + ? ErrorCode.BATTLE_THUMBNAIL_MODERATION_BLOCKED + : ErrorCode.BATTLE_THUMBNAIL_GENERATION_FAILED); + } + return new AdminBattleThumbnailCandidatesResponse(candidates); + } + + /** 후보 수만큼 장면을 만든다. 장면 묘사에 실패하면 배틀 원문을 그대로 쓴다. */ + private List scenesFor(AdminBattleThumbnailCandidateRequest request, List references) { + List scenes = sceneWriter.write(request, CANDIDATE_COUNT, references); + if (scenes.isEmpty()) { + scenes = List.of(ThumbnailSceneWriter.describeBattle(request)); + } + List source = scenes; + return IntStream.range(0, CANDIDATE_COUNT) + .mapToObj(i -> source.get(i % source.size())) + .toList(); + } + + private Candidate generateOne(String prompt, List references, AtomicInteger blockedCount) { + try { + byte[] image = imageClient.generate(prompt, references); + String key = upload(image); + return new Candidate(key, s3PresignedUrlService.generatePresignedUrl(key)); + } catch (ImageModerationBlockedException e) { + blockedCount.incrementAndGet(); + log.warn("[Thumbnail] 썸네일 후보 1장이 안전 필터에 막힘. prompt={}", prompt, e); + return null; + } catch (Exception e) { + log.warn("[Thumbnail] 썸네일 후보 1장 생성 실패", e); + return null; + } + } + + private String upload(byte[] image) throws IOException { + File temp = Files.createTempFile("battle-thumbnail-", ".png").toFile(); + try { + Files.write(temp.toPath(), image); + String key = FileCategory.BATTLE.getPath() + "/ai-" + UUID.randomUUID() + ".png"; + return s3UploadService.uploadFile(key, temp); + } finally { + Files.deleteIfExists(temp.toPath()); + } + } + + static String buildPrompt(String template, String palette, List references, String scene) { + return template + .replace("{palette}", palette) + .replace("{references}", referenceGuide(references)) + .replace("{scene}", scene); + } + + /** 첨부 순서대로 번호를 매겨, 설명이 있는 예시는 주제와 장면을 함께 알려준다. */ + private static String referenceGuide(List references) { + if (references.isEmpty()) { + return NO_REFERENCE_GUIDE; + } + StringBuilder guide = new StringBuilder(REFERENCE_GUIDE.formatted(references.size())); + if (references.stream().anyMatch(reference -> reference.caption().hasTopic())) { + guide.append('\n').append(CAPTION_GUIDE); + } + for (int i = 0; i < references.size(); i++) { + ReferenceCaption caption = references.get(i).caption(); + if (!caption.hasTopic()) { + continue; + } + guide.append("\n - ").append(i + 1).append("번째 예시: 주제 \"").append(caption.topic()).append('"'); + if (caption.hasScene()) { + guide.append(" → 장면 \"").append(caption.scene()).append('"'); + } + } + return guide.toString(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java new file mode 100644 index 00000000..f3c51fbd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java @@ -0,0 +1,21 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import java.util.List; + +/** + * 썸네일 생성에 쓰는 공통 스타일(프롬프트 템플릿 + 색상 팔레트 + 예시 이미지) 제공처. + * 지금은 리소스 파일 구현만 있고, 어드민에서 수정하게 되면 DB 구현으로 교체한다. + */ +public interface BattleThumbnailStyleSource { + + /** + * 프롬프트 템플릿. {palette} 에 색상 팔레트, {references} 에 예시 이미지 안내, + * {scene} 에 배틀 내용으로 만든 장면 묘사가 들어간다. + */ + String promptTemplate(); + + /** 사용할 색상 목록. 헥사 코드와 용도를 한 줄씩 적은 텍스트. */ + String palette(); + + List referenceImages(); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java new file mode 100644 index 00000000..025093cc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +/** + * resources/battle-thumbnail/ 아래 파일로 스타일을 읽는다. + * - prompt.txt : 프롬프트 템플릿 + * - palette.txt : 색상 팔레트(헥사 코드) + * - references/ : 스타일 예시 썸네일(png/jpg/webp). 파일명 순서대로 전달된다. + * 같은 이름의 txt(reference1.png ↔ reference1.txt)가 있으면 그 예시의 주제·장면 설명으로 읽는다. + */ +@Slf4j +@Component +public class ClasspathBattleThumbnailStyleSource implements BattleThumbnailStyleSource { + + private static final String PROMPT_PATH = "battle-thumbnail/prompt.txt"; + private static final String PALETTE_PATH = "battle-thumbnail/palette.txt"; + private static final String REFERENCES_PATTERN = "classpath*:battle-thumbnail/references/*"; + // OpenAI edits 는 레퍼런스 이미지를 최대 16장까지 받는다. + private static final int MAX_REFERENCES = 16; + + private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + + @Override + public String promptTemplate() { + return readText(PROMPT_PATH); + } + + @Override + public String palette() { + return readText(PALETTE_PATH).strip(); + } + + private String readText(String path) { + try (InputStream in = new ClassPathResource(path).getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("썸네일 스타일 파일을 읽을 수 없습니다: " + path, e); + } + } + + @Override + public List referenceImages() { + try { + Resource[] resources = resolver.getResources(REFERENCES_PATTERN); + Map captions = Arrays.stream(resources) + .filter(r -> r.getFilename() != null && r.getFilename().toLowerCase(Locale.ROOT).endsWith(".txt")) + .collect(Collectors.toMap(r -> baseName(r.getFilename()), r -> r, (first, second) -> first)); + List images = new ArrayList<>(); + Arrays.stream(resources) + .filter(r -> r.getFilename() != null && mediaTypeOf(r.getFilename()) != null) + .sorted(Comparator.comparing(Resource::getFilename)) + .limit(MAX_REFERENCES) + .forEach(r -> images.add(read(r, captions.get(baseName(r.getFilename()))))); + return images; + } catch (IOException e) { + log.warn("[Thumbnail] 예시 이미지 목록 조회 실패 - 레퍼런스 없이 생성", e); + return List.of(); + } + } + + private ReferenceImage read(Resource resource, Resource captionResource) { + try (InputStream in = resource.getInputStream()) { + String fileName = resource.getFilename(); + return new ReferenceImage(fileName, in.readAllBytes(), mediaTypeOf(fileName), readCaption(captionResource)); + } catch (IOException e) { + throw new IllegalStateException("예시 이미지를 읽을 수 없습니다: " + resource.getFilename(), e); + } + } + + private ReferenceCaption readCaption(Resource resource) throws IOException { + if (resource == null) { + return ReferenceCaption.NONE; + } + try (InputStream in = resource.getInputStream()) { + return ReferenceCaption.parse(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + private static String baseName(String fileName) { + int dot = fileName.lastIndexOf('.'); + return dot < 0 ? fileName : fileName.substring(0, dot); + } + + private static MediaType mediaTypeOf(String fileName) { + String lower = fileName.toLowerCase(Locale.ROOT); + if (lower.endsWith(".png")) return MediaType.IMAGE_PNG; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return MediaType.IMAGE_JPEG; + if (lower.endsWith(".webp")) return MediaType.parseMediaType("image/webp"); + return null; + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ImageModerationBlockedException.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ImageModerationBlockedException.java new file mode 100644 index 00000000..b9348b30 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ImageModerationBlockedException.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.battle.thumbnail; + +/** OpenAI 안전 필터(moderation_blocked)가 이미지 생성 요청을 거절했다. */ +public class ImageModerationBlockedException extends RuntimeException { + + public ImageModerationBlockedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/OpenAiImageClient.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/OpenAiImageClient.java new file mode 100644 index 00000000..79e2d914 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/OpenAiImageClient.java @@ -0,0 +1,130 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import java.util.Base64; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +/** + * OpenAI Images API 로 이미지 한 장을 생성한다. + * 레퍼런스 이미지가 있으면 edits(이미지 + 프롬프트), 없으면 generations(프롬프트만)를 호출한다. + */ +@Slf4j +@Component +public class OpenAiImageClient { + + private static final int CONNECT_TIMEOUT_MS = 5000; + // 이미지 생성은 수십 초가 걸릴 수 있어 넉넉히 둔다. + private static final int READ_TIMEOUT_MS = 180000; + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.image.generations-url}") + private String generationsUrl; + + @Value("${openai.image.edits-url}") + private String editsUrl; + + @Value("${openai.image.model}") + private String model; + + @Value("${openai.image.quality}") + private String quality; + + @Value("${openai.image.size}") + private String size; + + public byte[] generate(String prompt, List references) { + try { + Map response = references.isEmpty() + ? callGenerations(prompt) + : callEdits(prompt, references); + return decodeFirstImage(response); + } catch (HttpClientErrorException e) { + if (e.getResponseBodyAsString().contains("moderation_blocked")) { + throw new ImageModerationBlockedException("OpenAI 안전 필터가 이미지 생성을 거절했습니다.", e); + } + throw e; + } + } + + private Map callGenerations(String prompt) { + Map body = Map.of( + "model", model, + "prompt", prompt, + "n", 1, + "size", size, + "quality", quality); + + return restClient().post() + .uri(generationsUrl) + .header("Authorization", "Bearer " + apiKey) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(Map.class); + } + + private Map callEdits(String prompt, List references) { + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("model", model); + builder.part("prompt", prompt); + builder.part("n", "1"); + builder.part("size", size); + builder.part("quality", quality); + for (ReferenceImage reference : references) { + builder.part("image[]", new NamedByteArrayResource(reference.bytes(), reference.fileName())) + .contentType(reference.mediaType()); + } + + return restClient().post() + .uri(editsUrl) + .header("Authorization", "Bearer " + apiKey) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(builder.build()) + .retrieve() + .body(Map.class); + } + + private byte[] decodeFirstImage(Map response) { + if (response == null || !(response.get("data") instanceof List data) || data.isEmpty()) { + throw new IllegalStateException("OpenAI 이미지 응답에 data 가 없습니다."); + } + Object b64 = ((Map) data.get(0)).get("b64_json"); + if (!(b64 instanceof String encoded) || encoded.isBlank()) { + throw new IllegalStateException("OpenAI 이미지 응답에 b64_json 이 없습니다."); + } + return Base64.getDecoder().decode(encoded); + } + + private RestClient restClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + return RestClient.builder().requestFactory(factory).build(); + } + + /** multipart 파트로 보낼 때 파일명이 있어야 OpenAI 가 파일로 인식한다. */ + private static class NamedByteArrayResource extends ByteArrayResource { + private final String fileName; + + NamedByteArrayResource(byte[] bytes, String fileName) { + super(bytes); + this.fileName = fileName; + } + + @Override + public String getFilename() { + return fileName; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceCaption.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceCaption.java new file mode 100644 index 00000000..21b41524 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceCaption.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.battle.thumbnail; + +/** + * 예시 이미지가 어떤 배틀 주제를 어떤 장면으로 옮긴 것인지에 대한 설명. + * 예시 이미지와 같은 이름의 txt 파일에 "주제: ...", "장면: ..." 을 한 줄씩 적는다. 없으면 화풍 예시로만 쓰인다. + */ +public record ReferenceCaption(String topic, String scene) { + + public static final ReferenceCaption NONE = new ReferenceCaption("", ""); + + private static final String TOPIC_PREFIX = "주제:"; + private static final String SCENE_PREFIX = "장면:"; + + public static ReferenceCaption parse(String text) { + String topic = ""; + String scene = ""; + for (String line : text.split("\\R")) { + String trimmed = line.trim(); + if (trimmed.startsWith(TOPIC_PREFIX)) { + topic = trimmed.substring(TOPIC_PREFIX.length()).trim(); + } else if (trimmed.startsWith(SCENE_PREFIX)) { + scene = trimmed.substring(SCENE_PREFIX.length()).trim(); + } + } + return new ReferenceCaption(topic, scene); + } + + public boolean hasTopic() { + return !topic.isEmpty(); + } + + /** 주제와 장면이 모두 있어야 "주제 → 장면" 예시로 쓸 수 있다. */ + public boolean hasScene() { + return hasTopic() && !scene.isEmpty(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java new file mode 100644 index 00000000..595e8e24 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java @@ -0,0 +1,6 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import org.springframework.http.MediaType; + +/** 썸네일 스타일 레퍼런스로 함께 보내는 예시 이미지. */ +public record ReferenceImage(String fileName, byte[] bytes, MediaType mediaType, ReferenceCaption caption) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java new file mode 100644 index 00000000..5d22d948 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java @@ -0,0 +1,127 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * 배틀 제목(주제)을 썸네일로 그릴 상징적인 장면 묘사로 바꾼다. A/B 입장이 아니라 제목 자체를 그린다. + * 토론 원문(안락사, 전쟁 등)을 이미지 프롬프트에 그대로 넣지 않고, 위해를 직접 그리지 않는 은유적 장면으로 옮겨 + * 후보마다 서로 다른 장면을 쓰게 한다. {@code OpenAiEmotionClassifier} 와 같은 방식(RestClient + openai.* 설정)을 쓴다. + */ +@Slf4j +@Component +public class ThumbnailSceneWriter { + + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TIMEOUT_MS = 30000; + + private static final String SYSTEM = """ + 너는 철학 토론 배틀의 썸네일 일러스트 장면을 기획하는 아트 디렉터다. + 배틀 제목이 다루는 주제(대상, 상황, 질문) 자체를 상징적으로 보여주는 장면을 서로 다르게 %d개 묘사해라. + 한 줄 요약과 설명은 제목의 의미를 파악하는 맥락으로만 쓴다. + 규칙: + - 찬반 두 입장을 나눠 그리거나, 화면을 좌우로 분할하거나, 두 대상이 맞서는 대립 구도로 만들지 않는다. + - 각 장면은 2~3문장의 한국어로, 화면에 무엇이 어디에 어떻게 보이는지 구체적으로 쓴다. + - 실루엣, 사물, 빛과 그림자, 공간, 자연물 같은 은유로 표현한다. + - 장면은 미니멀한 플랫 그래픽 포스터로 그려진다. 실루엣만으로 알아볼 수 있는 크고 단순한 형태로 구성한다. + - 중심 피사체는 하나로 잡아 화면 중앙에 모으고, 보조 요소는 한두 개까지만 둔다. + - 여러 인물이나 군중이 필요하면 하나의 덩어리로 읽히는 단순한 실루엣 무리로 둔다. + - 배경은 비워 두거나 단순한 면, 빛줄기, 지평선 정도로만 쓴다. 복잡한 풍경, 자잘한 소품은 넣지 않는다. + - 장면끼리는 중심 소재와 시점을 서로 다르게 잡는다. + - 부상, 피, 시신, 무기 사용, 자해, 죽어가는 사람 등 위해를 직접 묘사하지 않는다. + - 텍스트, 글자, 숫자, 로고는 장면에 넣지 않는다. + - 화풍이나 색상은 쓰지 않는다(별도로 지정된다). + 반드시 JSON 만 출력: {"scenes":["...","..."]} + """; + + private static final String EXAMPLES_HEADER = """ + 아래는 기존 썸네일이 주제를 장면으로 옮긴 예시다. 상징화하는 수준과 방식만 참고하고, 예시의 소재를 그대로 쓰지 않는다. + """; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.url}") + private String openaiUrl; + + @Value("${openai.model}") + private String model; + + /** 실패하면 빈 목록을 돌려준다. 호출부는 배틀 원문으로 대체한다. */ + public List write( + AdminBattleThumbnailCandidateRequest request, int count, List references) { + try { + JsonNode root = call(SYSTEM.formatted(count) + examples(references), describeBattle(request)); + List scenes = new ArrayList<>(); + for (JsonNode scene : root.path("scenes")) { + String text = scene.asText("").trim(); + if (!text.isEmpty()) { + scenes.add(text); + } + } + return scenes; + } catch (Exception e) { + log.warn("[Thumbnail] 장면 묘사 생성 실패 - 배틀 원문으로 생성", e); + return List.of(); + } + } + + /** 주제와 장면 설명이 모두 있는 예시만 "제목 → 장면" 예시로 쓴다. */ + static String examples(List references) { + String lines = references.stream() + .map(ReferenceImage::caption) + .filter(ReferenceCaption::hasScene) + .map(caption -> "- 제목: " + caption.topic() + " → 장면: " + caption.scene()) + .collect(Collectors.joining("\n")); + return lines.isEmpty() ? "" : EXAMPLES_HEADER + lines; + } + + static String describeBattle(AdminBattleThumbnailCandidateRequest request) { + return "제목: " + orEmpty(request.title()) + + "\n한 줄 요약: " + orEmpty(request.summary()) + + "\n설명: " + orEmpty(request.description()); + } + + private static String orEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private JsonNode call(String systemPrompt, String userPrompt) throws Exception { + 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, + "response_format", Map.of("type", "json_object"), + "messages", List.of( + Map.of("role", "system", "content", systemPrompt), + Map.of("role", "user", "content", userPrompt))); + + 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 objectMapper.readTree((String) message.get("content")); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/FundingTicketAccess.java b/src/main/java/com/swyp/picke/domain/classroom/FundingTicketAccess.java new file mode 100644 index 00000000..88d1507a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/FundingTicketAccess.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.classroom; + +import java.time.LocalDateTime; + +// Funding 도메인이 클래스 생성 시 제공하는 최소 이용권 정보입니다. +public record FundingTicketAccess(Long ticketId, int capacity, LocalDateTime expiresAt) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/FundingTicketPolicy.java b/src/main/java/com/swyp/picke/domain/classroom/FundingTicketPolicy.java new file mode 100644 index 00000000..9dd4c603 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/FundingTicketPolicy.java @@ -0,0 +1,6 @@ +package com.swyp.picke.domain.classroom; + +/** Classroom은 클래스 생성 전 이 계약만 호출하고, Funding 데이터 변경은 수행하지 않습니다. */ +public interface FundingTicketPolicy { + FundingTicketAccess requireUsableBy(Long userId, Long ticketId); +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleController.java new file mode 100644 index 00000000..57697073 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleController.java @@ -0,0 +1,150 @@ +package com.swyp.picke.domain.classroom.controller; + +import com.swyp.picke.domain.classroom.dto.request.ClassMidVoteRequest; +import com.swyp.picke.domain.classroom.dto.response.ClassBattleDetailResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassMyVoteResponse; +import com.swyp.picke.domain.classroom.service.ClassBattleService; +import com.swyp.picke.domain.perspective.dto.request.CreatePerspectiveRequest; +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.PerspectiveListResponse; +import com.swyp.picke.domain.vote.dto.request.VoteRequest; +import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; +import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; +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 java.util.List; +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.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; + +/** + * 클래스 안에서의 배틀 진행. 배틀 ID 는 경로에 없고 클래스에 붙은 배틀을 쓴다. + * 공통 에러: 없는/삭제된 클래스 404 CLASS_404, 멤버 아님 403 CLASS_403_MEMBER. + * 쓰기 공통: 참여하지 않는 운영자 403 CLASS_403_MEMBER, 이용권 종료 409 CLASS_409_TICKET_ENDED, 마감일 경과 409 CLASS_409_CLOSED. + */ +@Tag(name = "클래스 배틀 API", description = "클래스 안에서 배틀 상세·투표·의견 진행 (일반 배틀과 별도 집계)") +@RestController +@RequestMapping("/api/v1/classes/{classId}/battle") +@RequiredArgsConstructor +public class ClassBattleController { + + private final ClassBattleService classBattleService; + + @Operation(summary = "클래스 배틀 상세", + description = "배틀 상세 + 이 클래스 안에서의 내 투표 상태·진행 단계·참여자 수. 마감 후에도 조회 가능. " + + "canParticipate 가 false 면 투표·의견 버튼을 막고, participationCompleted 로 완료 표시를 결정합니다.") + @GetMapping + public ApiResponse getDetail( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classBattleService.getDetail(userId, classId)); + } + + @Operation(summary = "클래스 사전투표", + description = "배틀 시작 전(NONE/PRE_VOTE)에는 변경 가능, TTS 청취 이후에는 409 CLASS_409_PRE_VOTE_LOCKED. " + + "크레딧 차감·성향 점수·일반 참여자 수에는 영향 없음.") + @PostMapping("/votes/pre") + public ApiResponse preVote( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @RequestBody VoteRequest request + ) { + return ApiResponse.onSuccess(classBattleService.preVote(userId, classId, request)); + } + + @Operation(summary = "클래스 TTS 청취 완료", description = "사전투표 후 오디오 재생이 끝났을 때 호출. 이미 완료(COMPLETED)된 진행은 되돌리지 않습니다.") + @PostMapping("/votes/tts-complete") + public ApiResponse completeTts( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + classBattleService.completeTts(userId, classId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "클래스 중간투표", + description = "중간투표가 있는 기존 콘텐츠(상세의 midVoteEnabled=true)에서 선택 지점(선택지가 있는 시나리오 노드)마다 고른 선택지를 기록합니다. " + + "checkpointId = 시나리오 nodeId, optionId = 시나리오 interactiveOptions[].optionId. 사후투표 전까지 다시 보낼 수 있고, 처음 보내면 사전투표는 잠깁니다. " + + "고른 경로의 선택 지점에 모두 답해야 사후투표할 수 있습니다(아니면 사후투표가 409 CLASS_409_MID_VOTE_REQUIRED). " + + "중간투표가 없는 배틀이면 409 CLASS_409_MID_VOTE, 없는 지점·선택지면 404 CLASS_404_MID_VOTE_OPTION, 사전투표 전이면 404 VOTE_NOT_FOUND, " + + "사후투표 후면 409 CLASS_409_MID_VOTE_LOCKED.") + @PostMapping("/votes/mid") + public ApiResponse midVote( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @RequestBody ClassMidVoteRequest request + ) { + return ApiResponse.onSuccess(classBattleService.midVote(userId, classId, request)); + } + + @Operation(summary = "클래스 사후투표", + description = "사전투표 없이 호출하면 400 PRE_VOTE_REQUIRED, 이미 제출했으면 409 VOTE_ALREADY_SUBMITTED. 제출 후에는 변경 불가.") + @PostMapping("/votes/post") + public ApiResponse postVote( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @RequestBody VoteRequest request + ) { + return ApiResponse.onSuccess(classBattleService.postVote(userId, classId, request)); + } + + @Operation(summary = "클래스 내 투표 내역", description = "이 클래스 안에서의 내 사전/중간/사후 투표와 진행 단계. 투표 전이면 404 VOTE_NOT_FOUND.") + @GetMapping("/votes/me") + public ApiResponse getMyVote( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classBattleService.getMyVote(userId, classId)); + } + + @Operation(summary = "클래스 투표 통계", description = "이 클래스 멤버들의 사후투표만 집계한 옵션별 수·비율.") + @GetMapping("/vote-stats") + public ApiResponse getVoteStats( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classBattleService.getVoteStats(userId, classId)); + } + + @Operation(summary = "클래스 의견 등록", description = "클래스 안에서 사후투표한 선택지에 귀속되는 의견을 등록합니다. 사후투표 전이면 404 BATTLE_OPTION_NOT_FOUND.") + @PostMapping("/perspectives") + public ApiResponse createPerspective( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @RequestBody @Valid CreatePerspectiveRequest request + ) { + return ApiResponse.onSuccess(classBattleService.createPerspective(userId, classId, request)); + } + + @Operation(summary = "클래스 의견 목록", description = "이 클래스 안에서 작성된 의견만 커서 기반으로 조회합니다. sort=latest|popular, optionId 로 선택지 필터.") + @GetMapping("/perspectives") + public ApiResponse getPerspectives( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) Integer size, + @RequestParam(required = false) Long optionId, + @RequestParam(required = false, defaultValue = "latest") String sort + ) { + return ApiResponse.onSuccess(classBattleService.getPerspectives(userId, classId, cursor, size, optionId, sort)); + } + + @Operation(summary = "클래스 내 의견", description = "이 클래스 안에서 내가 작성한 의견 목록.") + @GetMapping("/perspectives/me") + public ApiResponse> getMyPerspectives( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classBattleService.getMyPerspectives(userId, classId)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationController.java new file mode 100644 index 00000000..be8f9771 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationController.java @@ -0,0 +1,40 @@ +package com.swyp.picke.domain.classroom.controller; + +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import com.swyp.picke.domain.classroom.dto.response.ClassBattleRecommendationResponse; +import com.swyp.picke.domain.classroom.service.ClassBattleRecommendationService; +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 java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "클래스 Picke 배틀 추천 API", description = "새 클래스 만들기에서 조건에 맞는 기존 배틀(TTS 포함) 추천") +@RestController +@RequestMapping("/api/v1/class-battle-recommendations") +@RequiredArgsConstructor +public class ClassBattleRecommendationController { + + private final ClassBattleRecommendationService recommendationService; + + @Operation(summary = "조건에 맞는 Picke 배틀 추천", + description = "공개된 배틀 중 어드민이 클래스 추천을 켠 배틀에서 수준이 같고 카테고리가 겹치는 배틀을 최대 3개 돌려줍니다. " + + "조건에 맞는 배틀이 적으면 적은 대로(0개 포함) 내려갑니다. 수준 누락, 카테고리 0개·4개 이상·잘못된 값, " + + "주제 50자 초과는 400 CLASS_400_RECOMMEND_CONDITION.") + @GetMapping + public ApiResponse recommend( + @Parameter(description = "찾고 싶은 주제 (선택, 50자 이하). 쉼표·공백으로 여러 단어", example = "AI, 범죄") + @RequestParam(required = false) String keyword, + @Parameter(description = "대상 수준 (필수)", example = "BEGINNER") + @RequestParam(required = false) BattleDifficulty difficulty, + @Parameter(description = "카테고리 1~3개 (필수). 한글 이름 또는 enum 이름, 반복 또는 쉼표로 전달", example = "철학,사회") + @RequestParam(required = false) List categories + ) { + return ApiResponse.onSuccess(recommendationService.recommend(keyword, difficulty, categories)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java new file mode 100644 index 00000000..fe50d5c0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassDebateController.java @@ -0,0 +1,74 @@ +package com.swyp.picke.domain.classroom.controller; + +import com.swyp.picke.domain.classroom.dto.request.ClassDebateReplyRequest; +import com.swyp.picke.domain.classroom.dto.response.ClassDebateResponse; +import com.swyp.picke.domain.classroom.service.debate.ClassDebateService; +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.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "클래스 AI 철학자 토론 API", description = "커스텀 배틀 클래스에서 사전투표 후 반대 입장의 AI 철학자와 3라운드 텍스트 토론 (PRD §8, §11)") +@RestController +@RequestMapping("/api/v1/classes/{classId}/battle/debate") +@RequiredArgsConstructor +public class ClassDebateController { + + private final ClassDebateService debateService; + + @Operation(summary = "토론 조회 (재접속)", + description = "저장된 대화와 상태를 돌려줍니다(AI 호출 없음). 마감 후에도 읽을 수 있지만 canReply/canRetry 는 false 입니다. " + + "시작 전이면 404 CLASS_404_DEBATE.") + @GetMapping + public ApiResponse get( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(debateService.get(userId, classId)); + } + + @Operation(summary = "토론 시작", + description = "사전투표한 선택지의 반대편과 그 선택지의 철학자로 세션을 만들고 1라운드 AI 주장을 생성합니다. 시작하면 사전투표는 바꿀 수 없습니다. " + + "이미 시작했으면 저장된 대화를 그대로 돌려줍니다. 커스텀 클래스가 아니면 409 CLASS_409_DEBATE_TYPE, 사전투표 전이면 409 CLASS_409_DEBATE_PRE_VOTE. " + + "AI 생성에 실패하면 status=FAILED 로 응답하고 재시도 API 를 씁니다.") + @PostMapping + public ApiResponse start( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(debateService.start(userId, classId)); + } + + @Operation(summary = "반박 제출", + description = "현재 라운드의 반박(50~500자)을 저장하고 다음 라운드 AI 주장을 생성합니다. 3라운드 반박이면 토론이 끝나고(COMPLETED) 사후투표를 할 수 있습니다. " + + "같은 requestKey 재전송은 저장된 결과를 그대로 돌려주고, 다른 키로 같은 라운드를 다시 내면 409 CLASS_409_DEBATE_ROUND. " + + "반박을 받을 수 없는 상태면 409 CLASS_409_DEBATE_STATE, 길이 위반 400 CLASS_400_DEBATE_REPLY. " + + "개인정보(전화번호·이메일·주민등록번호·카드번호)가 있으면 400 CLASS_400_DEBATE_PERSONAL_INFO, " + + "위협 등 차단 대상 표현이면 400 CLASS_400_DEBATE_BLOCKED (둘 다 저장하지 않고 라운드도 쓰지 않음).") + @PostMapping("/replies") + public ApiResponse reply( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @Valid @RequestBody ClassDebateReplyRequest request + ) { + return ApiResponse.onSuccess(debateService.reply(userId, classId, request)); + } + + @Operation(summary = "AI 주장 재시도", + description = "status=FAILED(또는 3분 넘게 멈춘 생성)일 때 같은 라운드의 AI 주장을 같은 작업으로 다시 생성합니다. 그 외에는 409 CLASS_409_DEBATE_RETRY.") + @PostMapping("/retry") + public ApiResponse retry( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(debateService.retry(userId, classId)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassMemberController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassMemberController.java new file mode 100644 index 00000000..7376846c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassMemberController.java @@ -0,0 +1,105 @@ +package com.swyp.picke.domain.classroom.controller; + +import com.swyp.picke.domain.classroom.dto.request.ClassJoinRequest; +import com.swyp.picke.domain.classroom.dto.request.ClassMemberNameUpdateRequest; +import com.swyp.picke.domain.classroom.dto.response.ClassDetailResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassJoinPreviewResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassMemberResponse; +import com.swyp.picke.domain.classroom.service.ClassMemberService; +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 java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "클래스 멤버 API", description = "초대코드로 참여, 명단 조회, 내 이름 변경, 나가기, 운영자 내보내기") +@RestController +@RequestMapping("/api/v1/classes") +@RequiredArgsConstructor +public class ClassMemberController { + + private final ClassMemberService classMemberService; + + @Operation(summary = "참여 코드 확인", + description = "참여 처리 없이 코드만 확인합니다(코드 입력 단계). 참여할 수 있으면 클래스 요약을, 아니면 사유별 에러를 돌려줍니다. " + + "형식 오류 400, 없는 코드 404 CLASS_404_CODE, 삭제된 클래스 409 CLASS_409_DELETED, 내 클래스 409 CLASS_409_OWNER, " + + "이미 참여 중 409 CLASS_409_JOINED, 내보내진 클래스 403 CLASS_403_REMOVED, 이용권 종료 409 CLASS_409_TICKET_ENDED, " + + "마감일 경과 409 CLASS_409_CLOSED, 정원 초과 409 CLASS_409_FULL. 이름 중복은 참여 API 에서 판정합니다.") + @GetMapping("/join/preview") + public ApiResponse previewJoin( + @AuthenticationPrincipal Long userId, + @RequestParam String inviteCode + ) { + return ApiResponse.onSuccess(classMemberService.previewJoin(userId, inviteCode)); + } + + @Operation(summary = "초대코드로 클래스 참여", + description = "코드는 공백·하이픈 제거·대문자 변환 후 6자여야 합니다(아니면 400). " + + "없는 코드 404 CLASS_404_CODE, 삭제된 클래스 409 CLASS_409_DELETED, 이용권 종료 409 CLASS_409_TICKET_ENDED, 마감일 경과 409 CLASS_409_CLOSED, 정원 초과 409 CLASS_409_FULL, " + + "이름 중복 409 CLASS_409_NAME, 내 클래스 409 CLASS_409_OWNER, 내보내진 클래스 403 CLASS_403_REMOVED. " + + "이미 참여 중이면 거절하지 않고 기존 상세를 그대로 돌려줍니다.") + @PostMapping("/join") + public ApiResponse join( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody ClassJoinRequest request + ) { + return ApiResponse.onSuccess(classMemberService.join(userId, request)); + } + + @Operation(summary = "클래스 명단", + description = "운영자와 참여 중인 멤버만 볼 수 있습니다(403 CLASS_403_MEMBER). 운영자 행이 먼저, 이후 참여 순. " + + "표시 이름·역할·아바타만 내려가고 계정 식별자는 내려가지 않습니다.") + @GetMapping("/{classId}/members") + public ApiResponse> getMembers( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classMemberService.getMembers(userId, classId)); + } + + @Operation(summary = "내 표시 이름 변경", + description = "참여 중인 멤버 본인만. 같은 클래스의 다른 멤버와 같은 이름은 409 CLASS_409_NAME.") + @PatchMapping("/{classId}/members/me") + public ApiResponse changeMyDisplayName( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @Valid @RequestBody ClassMemberNameUpdateRequest request + ) { + return ApiResponse.onSuccess(classMemberService.changeMyDisplayName(userId, classId, request)); + } + + @Operation(summary = "클래스 나가기", + description = "운영자는 나갈 수 없습니다(403 CLASS_403_OWNER_LEAVE, 삭제로 종료). " + + "나간 뒤에는 같은 코드로 다시 참여할 수 있고, 활동 이력이 있으면 좌석은 반환되지 않습니다.") + @DeleteMapping("/{classId}/members/me") + public ApiResponse leave( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + classMemberService.leave(userId, classId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "멤버 내보내기 (운영자)", + description = "내보내진 멤버는 같은 클래스에 다시 참여할 수 없습니다. 운영자 본인 행은 내보낼 수 없습니다(403 CLASS_403_OWNER_LEAVE).") + @DeleteMapping("/{classId}/members/{memberId}") + public ApiResponse removeMember( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @PathVariable Long memberId + ) { + classMemberService.removeMember(userId, classId, memberId); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassResultController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassResultController.java new file mode 100644 index 00000000..88379df9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassResultController.java @@ -0,0 +1,85 @@ +package com.swyp.picke.domain.classroom.controller; + +import com.swyp.picke.domain.classroom.dto.response.ClassInsightResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassMyReportResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassMyActivityResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassResultResponse; +import com.swyp.picke.domain.classroom.service.ClassResultService; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 클래스 결과·AI 리포트. 리포트는 마감(effective_close_at) 후 스케줄러가 1회 생성하며, API 는 저장된 결과를 읽기만 한다. + * 공통 에러: 없는 클래스 404 CLASS_404, 멤버 아님 403 CLASS_403_MEMBER, 이용권 종료 + 30일 경과 409 CLASS_409_RESULT_EXPIRED. + */ +@Tag(name = "클래스 결과 API", description = "정량 통계·개인 AI 피드백·운영자 인사이트 (마감 후 생성, 이용권 종료 + 30일까지 열람)") +@RestController +@RequestMapping("/api/v1/classes/{classId}/result") +@RequiredArgsConstructor +public class ClassResultController { + + private final ClassResultService classResultService; + + @Operation(summary = "클래스 결과 (정량 통계)", + description = "참여율·댓글 작성률·사전/사후 분포. 마감 전에는 현재 데이터(statsBasis=LIVE), 마감 후에는 마감 스냅샷(SNAPSHOT). " + + "myReportState / insightState 로 리포트 탭 노출을 결정합니다.") + @GetMapping + public ApiResponse getResult( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classResultService.getResult(userId, classId)); + } + + @Operation(summary = "내 AI 피드백", + description = "참여 완료자에게 마감 후 제공. 마감 전 BEFORE_CLOSE(\"{closeAt} 이후 AI 피드백이 제공돼요\"), 참여 미완료 NOT_ELIGIBLE, " + + "글 없이 투표만 했으면 LIMITED(서버 템플릿). 참여하지 않는 운영자는 403 CLASS_403_MEMBER.") + @GetMapping("/my-report") + public ApiResponse getMyReport( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classResultService.getMyReport(userId, classId)); + } + + @Operation(summary = "내 활동 (리포트 화면)", + description = "공개된 의견·댓글에 받은 좋아요 수와 내가 쓴 공개 댓글 목록을 커서 기반으로 조회합니다. 참여 멤버 전용이며, 기본 20건·최대 100건입니다.") + @GetMapping("/my-activity") + public ApiResponse getMyActivity( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @org.springframework.web.bind.annotation.RequestParam(required = false) String cursor, + @org.springframework.web.bind.annotation.RequestParam(required = false) Integer size + ) { + return ApiResponse.onSuccess(classResultService.getMyActivity(userId, classId, cursor, size)); + } + + @Operation(summary = "클래스 인사이트 (운영자)", + description = "공개 의견을 요약한 AI 인사이트와 근거·대표 의견. 운영자가 아니면 403 CLASS_403_OWNER. " + + "작성자 3명 미만이면 LIMITED, 생성 실패면 FAILED + retryable.") + @GetMapping("/insight") + public ApiResponse getInsight( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classResultService.getInsight(userId, classId)); + } + + @Operation(summary = "클래스 인사이트 재시도 (운영자)", + description = "FAILED 상태일 때만. 대기열에 다시 넣고 PENDING 을 돌려주며 1분 안에 재생성됩니다. 재시도 불가면 409 CLASS_409_REPORT_RETRY.") + @PostMapping("/insight/retry") + public ApiResponse retryInsight( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classResultService.retryInsight(userId, classId)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassTopicController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassTopicController.java new file mode 100644 index 00000000..9f443689 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassTopicController.java @@ -0,0 +1,97 @@ +package com.swyp.picke.domain.classroom.controller; + +import com.swyp.picke.domain.classroom.dto.request.ClassTopicDraftCreateRequest; +import com.swyp.picke.domain.classroom.dto.request.ClassTopicSelectRequest; +import com.swyp.picke.domain.classroom.dto.request.ClassTopicSuggestRequest; +import com.swyp.picke.domain.classroom.dto.request.ClassTopicValidationRequest; +import com.swyp.picke.domain.classroom.dto.response.ClassTopicDraftResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassTopicValidationResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassTopicVersionResponse; +import com.swyp.picke.domain.classroom.service.topic.ClassTopicAiService; +import com.swyp.picke.domain.classroom.service.topic.ClassTopicDraftService; +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 java.time.LocalDateTime; +import java.time.ZoneId; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "클래스 커스텀 주제 API", description = "커스텀 배틀 클래스를 만들기 위한 AI 주제 제안·선택·직접 수정 검증 (PRD §10)") +@RestController +@RequestMapping("/api/v1/class-topic-drafts") +@RequiredArgsConstructor +public class ClassTopicController { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final ClassTopicDraftService draftService; + private final ClassTopicAiService aiService; + + @Operation(summary = "주제 초안 생성", + description = "사용 중인 이용권이 있어야 합니다(403 CLASS_403_TICKET). 주제 2~200자, 카테고리 1~3개(400 CLASS_400_TOPIC_CONDITION). " + + "AI 를 호출하지 않으며, 초안마다 제안 3회·수정 검증 3회가 새로 주어집니다.") + @PostMapping + public ApiResponse create( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody ClassTopicDraftCreateRequest request + ) { + return ApiResponse.onSuccess(draftService.create(userId, request, LocalDateTime.now(KST))); + } + + @Operation(summary = "주제 초안 조회", + description = "저장된 후보·상태·남은 횟수를 그대로 돌려줍니다(AI 호출 없음). 제안 중 화면을 나갔다 돌아오면 이 API 로 결과를 이어 봅니다.") + @GetMapping("/{draftId}") + public ApiResponse get( + @AuthenticationPrincipal Long userId, + @PathVariable Long draftId + ) { + return ApiResponse.onSuccess(draftService.get(userId, draftId)); + } + + @Operation(summary = "AI 주제 제안 (최초·재추천)", + description = "후보 3개를 만들어 초안에 저장하고 초안을 돌려줍니다. 본문으로 조건을 바꿀 수 있고 같은 초안의 새 배치로 셉니다. " + + "실패하면 suggestStatus=FAILED 로 응답하고 기존 후보와 횟수는 그대로입니다. " + + "맥락이 부족하면 NEEDS_CLARIFICATION + clarificationMessage. " + + "성공 배치 3회 소진 409 CLASS_409_TOPIC_SUGGEST_LIMIT, 생성 중 409 CLASS_409_TOPIC_GENERATING, 분당 2회 초과 429 CLASS_429_TOPIC.") + @PostMapping("/{draftId}/suggestions") + public ApiResponse suggest( + @AuthenticationPrincipal Long userId, + @PathVariable Long draftId, + @RequestBody(required = false) ClassTopicSuggestRequest request + ) { + return ApiResponse.onSuccess(aiService.suggest(userId, draftId, request)); + } + + @Operation(summary = "후보 선택", + description = "후보를 검증된 주제 버전으로 저장합니다(AI 호출 없음). 화면의 batchNo 가 현재 배치와 다르면 409 CLASS_409_TOPIC_CANDIDATE. " + + "같은 후보를 다시 고르면 기존 버전을 돌려줍니다.") + @PostMapping("/{draftId}/selection") + public ApiResponse select( + @AuthenticationPrincipal Long userId, + @PathVariable Long draftId, + @Valid @RequestBody ClassTopicSelectRequest request + ) { + return ApiResponse.onSuccess(draftService.select(userId, draftId, request)); + } + + @Operation(summary = "직접 수정 검증", + description = "저장된 버전을 바탕으로 고친 질문·설명·선택지를 검증합니다. 길이·필수값 오류는 AI 호출 없이 issues 로 돌려주고 횟수를 쓰지 않습니다. " + + "통과하면 새 주제 버전을 만들고, 실패하면 issues 와 함께 마지막 검증 버전이 유지됩니다. AI 는 수정안을 고쳐 쓰지 않습니다. " + + "검증 3회 소진 409 CLASS_409_TOPIC_VALIDATION_LIMIT, AI 장애 503 CLASS_503_TOPIC_AI(횟수 차감 없음).") + @PostMapping("/{draftId}/validations") + public ApiResponse validate( + @AuthenticationPrincipal Long userId, + @PathVariable Long draftId, + @Valid @RequestBody ClassTopicValidationRequest request + ) { + return ApiResponse.onSuccess(aiService.validate(userId, draftId, request)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassroomController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassroomController.java new file mode 100644 index 00000000..f8e91ae6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassroomController.java @@ -0,0 +1,80 @@ +package com.swyp.picke.domain.classroom.controller; + +import com.swyp.picke.domain.classroom.dto.request.ClassCreateRequest; +import com.swyp.picke.domain.classroom.dto.request.ClassUpdateRequest; +import com.swyp.picke.domain.classroom.dto.response.ClassDetailResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassSummaryResponse; +import com.swyp.picke.domain.classroom.service.ClassroomService; +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 java.util.List; +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.RestController; + +@Tag(name = "클래스 API", description = "이용권 보유자가 만드는 클래스의 생성/조회/수정/삭제") +@RestController +@RequestMapping("/api/v1/classes") +@RequiredArgsConstructor +public class ClassroomController { + + private final ClassroomService classroomService; + + @Operation(summary = "클래스 생성", + description = "사용 중인 이용권이 있어야 합니다(없으면 403 CLASS_403_TICKET). 정원은 이용권 인원을 그대로 씁니다. " + + "마감일은 비우면 이용권 종료일까지이고, 설정 시 현재 이후 ~ 이용권 종료일 범위만 허용합니다(400 CLASS_400_DEADLINE).") + @PostMapping + public ApiResponse create( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody ClassCreateRequest request + ) { + return ApiResponse.onSuccess(classroomService.create(userId, request)); + } + + @Operation(summary = "내 클래스 목록", description = "내가 운영하는 클래스 + 참여 중인 클래스. 최신 생성순.") + @GetMapping + public ApiResponse> getMyClasses(@AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(classroomService.getMyClasses(userId)); + } + + @Operation(summary = "클래스 상세", + description = "운영자 또는 참여 중인 멤버만 조회할 수 있습니다(403 CLASS_403_MEMBER). 초대코드는 운영자에게만 내려갑니다.") + @GetMapping("/{classId}") + public ApiResponse getDetail( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + return ApiResponse.onSuccess(classroomService.getDetail(userId, classId)); + } + + @Operation(summary = "클래스 수정 (운영자)", + description = "이름 변경, 마감일 연장만 가능합니다. null 필드는 변경하지 않습니다. " + + "마감된 클래스는 수정할 수 없습니다(409 CLASS_409_CLOSED).") + @PatchMapping("/{classId}") + public ApiResponse update( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId, + @Valid @RequestBody ClassUpdateRequest request + ) { + return ApiResponse.onSuccess(classroomService.update(userId, classId, request)); + } + + @Operation(summary = "클래스 삭제 (운영자)", description = "소프트 삭제. 이후 목록·상세에서 보이지 않습니다.") + @DeleteMapping("/{classId}") + public ApiResponse delete( + @AuthenticationPrincipal Long userId, + @PathVariable Long classId + ) { + classroomService.delete(userId, classId); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/converter/ClassMemberConverter.java b/src/main/java/com/swyp/picke/domain/classroom/converter/ClassMemberConverter.java new file mode 100644 index 00000000..219cdea7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/converter/ClassMemberConverter.java @@ -0,0 +1,24 @@ +package com.swyp.picke.domain.classroom.converter; + +import com.swyp.picke.domain.classroom.dto.response.ClassMemberResponse; +import com.swyp.picke.domain.classroom.entity.ClassMember; +import com.swyp.picke.domain.user.entity.User; +import java.util.Objects; + +public final class ClassMemberConverter { + + private ClassMemberConverter() { + } + + public static ClassMemberResponse toResponse(ClassMember member, Long viewerId) { + User user = member.getUser(); + return new ClassMemberResponse( + member.getId(), + member.getDisplayName(), + member.getRole(), + user.getCharacterUrl(), + Objects.equals(user.getId(), viewerId), + member.getJoinedAt() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/converter/ClassTopicConverter.java b/src/main/java/com/swyp/picke/domain/classroom/converter/ClassTopicConverter.java new file mode 100644 index 00000000..d665fb93 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/converter/ClassTopicConverter.java @@ -0,0 +1,81 @@ +package com.swyp.picke.domain.classroom.converter; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.classroom.dto.response.ClassTopicDraftResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassTopicValidationResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassTopicVersionResponse; +import com.swyp.picke.domain.classroom.entity.ClassTopicDraft; +import com.swyp.picke.domain.classroom.entity.ClassTopicVersion; +import com.swyp.picke.domain.classroom.service.topic.TopicContent; +import com.swyp.picke.domain.classroom.service.topic.TopicIssue; +import com.swyp.picke.domain.user.enums.PhilosopherType; +import java.util.List; +import java.util.Map; + +public final class ClassTopicConverter { + + private ClassTopicConverter() { + } + + public static ClassTopicDraftResponse toDraft(ClassTopicDraft draft, ClassTopicVersion latestVersion) { + List> candidates = draft.getCandidates() == null ? List.of() : draft.getCandidates(); + return new ClassTopicDraftResponse( + draft.getId(), + draft.getTopicSeed(), + draft.getDifficulty(), + draft.getCategories().stream().map(BattleCategory::valueOf).toList(), + draft.getSuggestStatus(), + draft.getClarificationMessage(), + draft.getSuggestFailureReason(), + draft.getBatchNo(), + draft.remainingSuggestions(), + draft.remainingValidations(), + candidates.stream().map(ClassTopicConverter::toCandidate).toList(), + latestVersion == null ? null : toVersion(latestVersion)); + } + + public static ClassTopicVersionResponse toVersion(ClassTopicVersion version) { + return new ClassTopicVersionResponse( + version.getId(), + version.getDraft().getId(), + version.getVersionNo(), + version.getSource(), + version.getQuestion(), + version.getDescription(), + version.getCategory(), + version.getDifficulty(), + List.of( + option("A", version.getOptionALabel(), version.getOptionARationale(), version.getOptionAPhilosopher()), + option("B", version.getOptionBLabel(), version.getOptionBRationale(), version.getOptionBPhilosopher())), + version.getCreatedAt()); + } + + public static ClassTopicValidationResponse toValidation(List issues, ClassTopicVersion version, + int remainingValidations) { + return new ClassTopicValidationResponse( + version != null, + issues.stream().map(i -> new ClassTopicValidationResponse.Issue(i.field(), i.code(), i.message())).toList(), + version == null ? null : version.getId(), + remainingValidations, + version == null ? null : toVersion(version)); + } + + private static ClassTopicDraftResponse.Candidate toCandidate(Map map) { + TopicContent content = TopicContent.fromMap(map); + return new ClassTopicDraftResponse.Candidate( + TopicContent.candidateIdOf(map), + content.question(), + content.description(), + content.category(), + content.difficulty(), + List.of( + option("A", content.optionA().label(), content.optionA().rationale(), content.optionA().philosopher()), + option("B", content.optionB().label(), content.optionB().rationale(), content.optionB().philosopher()))); + } + + private static ClassTopicVersionResponse.Option option(String position, String label, String rationale, + PhilosopherType philosopher) { + return new ClassTopicVersionResponse.Option( + position, label, rationale, philosopher, philosopher.getLabel(), philosopher.getImageKey()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/converter/ClassroomConverter.java b/src/main/java/com/swyp/picke/domain/classroom/converter/ClassroomConverter.java new file mode 100644 index 00000000..68b04f9d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/converter/ClassroomConverter.java @@ -0,0 +1,59 @@ +package com.swyp.picke.domain.classroom.converter; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.classroom.dto.response.ClassDetailResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassSummaryResponse; +import com.swyp.picke.domain.classroom.entity.Classroom; +import com.swyp.picke.domain.classroom.enums.ClassMemberRole; +import java.time.LocalDateTime; + +public final class ClassroomConverter { + + private ClassroomConverter() { + } + + public static ClassSummaryResponse toSummary(Classroom classroom, ClassMemberRole myRole, long memberCount, + LocalDateTime now) { + Battle battle = classroom.getBattle(); + return new ClassSummaryResponse( + classroom.getId(), + classroom.getName(), + myRole, + battle.getId(), + battle.getTitle(), + battle.getThumbnailUrl(), + classroom.getBattleType(), + memberCount, + classroom.getCapacity(), + classroom.getEffectiveCloseAt(), + classroom.isClosed(now), + classroom.getCreatedAt() + ); + } + + /** 초대코드는 공유하기 버튼용으로 운영자·멤버 모두에게 내려간다. */ + public static ClassDetailResponse toDetail(Classroom classroom, ClassMemberRole myRole, long memberCount, + LocalDateTime now) { + Battle battle = classroom.getBattle(); + return new ClassDetailResponse( + classroom.getId(), + classroom.getName(), + classroom.getInviteCode(), + myRole, + classroom.getOwner().getId(), + new ClassDetailResponse.BattleInfo( + battle.getId(), battle.getTitle(), battle.getSummary(), battle.getThumbnailUrl()), + classroom.getBattleType(), + classroom.getTopicVersion() == null ? null : classroom.getTopicVersion().getId(), + memberCount, + classroom.getCapacity(), + classroom.getDeadline(), + classroom.getTicket().getExpiresAt(), + classroom.getEffectiveCloseAt(), + classroom.isClosed(now), + classroom.isCommentRequired(), + classroom.isOwnerParticipates(), + classroom.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassCreateRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassCreateRequest.java new file mode 100644 index 00000000..86e29db0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassCreateRequest.java @@ -0,0 +1,48 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import com.swyp.picke.domain.classroom.entity.ClassMember; +import com.swyp.picke.domain.classroom.entity.Classroom; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +@Schema(description = "클래스 생성 요청") +public record ClassCreateRequest( + + @Schema(description = "클래스 이름 (1~20자)", example = "3학년 2반 철학 토론") + @NotBlank + @Size(max = Classroom.NAME_MAX_LENGTH) + String name, + + @Schema(description = "기존 콘텐츠(TTS) 배틀 ID. topicVersionId 와 둘 중 하나만 보낸다", example = "12", nullable = true) + Long battleId, + + @Schema(description = "커스텀 배틀로 만들 때 검증된 주제 버전 ID (주제 초안 API 의 topicVersionId). battleId 와 둘 중 하나만 보낸다", + example = "3", nullable = true) + Long topicVersionId, + + @Schema(description = "참여 마감일. null 이면 마감 없음(이용권 종료일까지). 현재 이후 ~ 이용권 종료일 범위만 허용", + example = "2026-12-31T23:59:59", nullable = true) + LocalDateTime deadline, + + @Schema(description = "댓글 작성 필수 여부", example = "true") + boolean commentRequired, + + @Schema(description = "운영자 본인도 참여자로 들어갈지 여부. true 면 운영자가 1석을 차지한다", example = "true") + boolean ownerParticipates, + + @Schema(description = "운영자가 참여할 때 클래스 안에서 쓸 이름 (1~20자). 비우면 닉네임 사용", + example = "김선생", nullable = true) + @Size(max = ClassMember.DISPLAY_NAME_MAX_LENGTH) + String ownerDisplayName, + + @Schema(description = "중복 생성 방지용 요청 키 (1~64자, 영문·숫자·-·_). 생성 화면을 열 때 UUID 하나를 만들어 재전송에도 같은 값을 보낸다. " + + "같은 키로 같은 내용을 다시 보내면 기존 클래스를 돌려주고, 다른 내용이면 409", + example = "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b", nullable = true) + @Size(max = Classroom.CREATE_REQUEST_KEY_MAX_LENGTH) + @Pattern(regexp = "^[A-Za-z0-9_-]+$") + String requestKey +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassDebateReplyRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassDebateReplyRequest.java new file mode 100644 index 00000000..4526b470 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassDebateReplyRequest.java @@ -0,0 +1,25 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** 반박 길이(앞뒤 공백 제거 후 50~500자)는 서비스에서 검사한다. */ +@Schema(description = "토론 반박 제출 요청") +public record ClassDebateReplyRequest( + + @Schema(description = "반박할 라운드 (1~3). 화면에 보인 현재 라운드를 보낸다", example = "1") + @NotNull + Integer roundIndex, + + @Schema(description = "반박 내용 (앞뒤 공백 제거 후 50자 이상 500자 이하). 저장 후 수정할 수 없다", + example = "속으로 슬퍼해도 밖으로 표현하지 않을 수 있어요. 표현 방식은 사람마다 달라서 겉모습만으로 감정을 판단하면 안 된다고 생각해요.") + String content, + + @Schema(description = "중복 전송 방지 키. 같은 반박을 다시 보낼 때는 같은 값을 쓴다 (UUID 권장)", example = "4f0b5c3e-6a8d-4f6b-9a51-2f3d1c7e9b10") + @NotBlank + @Size(max = 64) + String requestKey +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassJoinRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassJoinRequest.java new file mode 100644 index 00000000..dfc0211f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassJoinRequest.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +/** 초대코드는 공백 제거·대문자 변환 후 6자, 표시 이름은 앞뒤 공백 제거 후 1~20자 (서비스에서 검사). */ +@Schema(description = "클래스 참여 요청") +public record ClassJoinRequest( + + @Schema(description = "초대코드 6자. 공백·대소문자는 서버가 정리한다", example = "A7K2MP") + @NotBlank + String inviteCode, + + @Schema(description = "클래스 안에서 쓸 이름 (앞뒤 공백 제거 후 1~20자, 같은 클래스 안에서 중복 불가)", example = "김학생") + @NotBlank + String displayName +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassMemberNameUpdateRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassMemberNameUpdateRequest.java new file mode 100644 index 00000000..fb5971a8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassMemberNameUpdateRequest.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "내 표시 이름 변경 요청") +public record ClassMemberNameUpdateRequest( + + @Schema(description = "새 표시 이름 (앞뒤 공백 제거 후 1~20자, 같은 클래스 안에서 중복 불가)", example = "김학생2") + @NotBlank + String displayName +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassMidVoteRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassMidVoteRequest.java new file mode 100644 index 00000000..e7ee966c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassMidVoteRequest.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "클래스 중간투표 요청") +public record ClassMidVoteRequest( + + @Schema(description = "선택 지점 ID. 시나리오 응답에서 선택지(interactiveOptions)가 있는 노드의 nodeId", example = "15") + Long checkpointId, + + @Schema(description = "고른 선택지 ID. 시나리오 응답 interactiveOptions[].optionId", example = "3") + Long optionId +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicDraftCreateRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicDraftCreateRequest.java new file mode 100644 index 00000000..4fcac8a2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicDraftCreateRequest.java @@ -0,0 +1,28 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** 키워드는 앞뒤 공백 제거 후 2~200자, 카테고리는 중복 제거 후 1~3개 (서비스에서 검사). */ +@Schema(description = "커스텀 배틀 주제 초안 생성 요청") +public record ClassTopicDraftCreateRequest( + + @Schema(description = "다루고 싶은 주제·관심사 (2~200자). 작품을 다루면 필요한 맥락도 함께 적는다", + example = "카뮈 『이방인』의 뫼르소처럼 어머니 장례식에서 울지 않은 사람을 비난할 수 있을까") + @NotBlank + String topicSeed, + + @Schema(description = "대상 수준", example = "INTERMEDIATE") + @NotNull + BattleDifficulty difficulty, + + @Schema(description = "카테고리 1~3개 (철학·사회·문학·과학·예술·역사)", example = "[\"철학\", \"문학\"]") + @NotEmpty + List categories +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicSelectRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicSelectRequest.java new file mode 100644 index 00000000..9a7bc592 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicSelectRequest.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "AI 후보 선택 요청. 화면에 보인 배치 번호를 함께 보내 이미 바뀐 후보를 고르지 않게 한다") +public record ClassTopicSelectRequest( + + @Schema(description = "후보가 속한 배치 번호 (초안 응답의 batchNo)", example = "1") + @NotNull + Integer batchNo, + + @Schema(description = "후보 ID (1~3)", example = "3") + @NotNull + Integer candidateId +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicSuggestRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicSuggestRequest.java new file mode 100644 index 00000000..cf4bcebc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicSuggestRequest.java @@ -0,0 +1,21 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** 재추천 시 조건을 바꿀 때만 채운다. null 필드는 초안의 기존 조건을 쓴다. */ +@Schema(description = "AI 주제 제안(재추천) 요청. 본문을 생략하면 초안의 조건 그대로 제안한다") +public record ClassTopicSuggestRequest( + + @Schema(description = "바꿀 주제·관심사 (2~200자)", nullable = true) + String topicSeed, + + @Schema(description = "바꿀 대상 수준", nullable = true) + BattleDifficulty difficulty, + + @Schema(description = "바꿀 카테고리 1~3개", nullable = true) + List categories +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicValidationRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicValidationRequest.java new file mode 100644 index 00000000..105135aa --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassTopicValidationRequest.java @@ -0,0 +1,45 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import com.swyp.picke.domain.user.enums.PhilosopherType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * 길이·필수값은 bean validation 대신 서비스가 필드별 issues 로 돌려준다 (부록 C "필드별 오류"). + * 카테고리·수준은 바탕 버전 값을 그대로 쓴다. + */ +@Schema(description = "주제 직접 수정 검증 요청. 저장된 주제 버전을 바탕으로 질문·설명·양측 선택지를 함께 보낸다") +public record ClassTopicValidationRequest( + + @Schema(description = "수정의 바탕이 된 주제 버전 ID", example = "12") + @NotNull + Long baseVersionId, + + @Schema(description = "질문 (60자 이하)", example = "표현하지 않은 감정까지 타인이 판단해도 될까요?") + String question, + + @Schema(description = "설명 (80자 이하)", example = "감정 표현의 개인적 자유와 공동체의 기대를 비교해 봐요.") + String description, + + @Schema(description = "선택지 2개 (A, B 순서)") + @NotNull + @Size(min = 2, max = 2) + List<@Valid Option> options +) { + + public record Option( + + @Schema(description = "선택 문구 (20자 이하)", example = "판단할 수 있다") + String label, + + @Schema(description = "관점 설명 (40자 이하)", example = "표현은 관계에 대한 책임의 일부다") + String rationale, + + @Schema(description = "관점을 대변할 철학자. 비우면 바탕 버전의 철학자를 그대로 쓴다", example = "KANT", nullable = true) + PhilosopherType philosopher + ) { + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassUpdateRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassUpdateRequest.java new file mode 100644 index 00000000..d2482ab2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassUpdateRequest.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.classroom.dto.request; + +import com.swyp.picke.domain.classroom.entity.Classroom; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +/** 개설 후 수정 가능한 항목은 이름과 마감일 연장뿐. null 인 필드는 변경하지 않는다. */ +@Schema(description = "클래스 수정 요청 (운영자 전용). null 필드는 변경 없음") +public record ClassUpdateRequest( + + @Schema(description = "변경할 클래스 이름 (1~20자)", example = "3학년 2반 철학 토론", nullable = true) + @Size(min = 1, max = Classroom.NAME_MAX_LENGTH) + String name, + + @Schema(description = "연장할 마감일. 기존 마감일보다 뒤, 이용권 종료일 이전이어야 한다", + example = "2027-01-31T23:59:59", nullable = true) + LocalDateTime deadline +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleDetailResponse.java b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleDetailResponse.java new file mode 100644 index 00000000..7f8143ae --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleDetailResponse.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.classroom.dto.response; + +import com.swyp.picke.domain.battle.dto.response.BattleUserDetailResponse; +import com.swyp.picke.domain.classroom.enums.ClassMemberRole; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "클래스 안에서 보는 배틀 상세. 투표 상태·단계·참여자 수는 이 클래스 안의 것만 반영한다") +public record ClassBattleDetailResponse( + @Schema(description = "클래스 ID") Long classId, + @Schema(description = "내 역할 (OWNER / MEMBER)") ClassMemberRole myRole, + @Schema(description = "실제 마감 시각 = min(마감일, 이용권 종료일)") LocalDateTime closeAt, + @Schema(description = "마감 여부. true 면 투표·의견·댓글 쓰기가 막힌다") boolean closed, + @Schema(description = "의견 작성 필수 여부") boolean commentRequired, + @Schema(description = "지금 투표·의견을 쓸 수 있는지 (참여 중인 멤버이고 마감 전). 참여하지 않는 운영자는 false") boolean canParticipate, + @Schema(description = "참여 완료 여부. 사후투표 완료 + (의견 필수면) 내 의견 1건 이상") boolean participationCompleted, + @Schema(description = "중간투표가 있는 기존 콘텐츠인지. true 면 콘텐츠의 선택 화면에서 고른 선택지를 POST /votes/mid 로 보낸다") boolean midVoteEnabled, + @Schema(description = "배틀 상세. userVoteStatus / currentStep / participantsCount 는 클래스 기준") BattleUserDetailResponse battle +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleRecommendationResponse.java b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleRecommendationResponse.java new file mode 100644 index 00000000..0df3c8d2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleRecommendationResponse.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.classroom.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "클래스 Picke 배틀 추천 결과. 최대 3개이고 조건에 맞는 배틀이 적으면 적은 대로 내려간다") +public record ClassBattleRecommendationResponse( + + @Schema(description = "추천 배틀 (0~3개)") List items +) { + + @Schema(description = "추천 배틀 카드") + public record Item( + Long battleId, + String title, + String summary, + String thumbnailUrl, + @Schema(description = "카테고리 (철학·사회·문학·과학·예술·역사)") BattleCategory category, + @Schema(description = "대상 수준") BattleDifficulty difficulty, + @Schema(description = "소요 시간(초)") Integer audioDuration, + @Schema(description = "선택지 2개 (A, B 순서). 미리듣기 음성은 GET /api/v1/battles/{battleId}/scenario 로 받는다") List