diff --git a/docs/plan/vote-implementation-plan.md b/docs/plan/vote-implementation-plan.md new file mode 100644 index 0000000..f71ab3a --- /dev/null +++ b/docs/plan/vote-implementation-plan.md @@ -0,0 +1,766 @@ +# 투표 기능 구현 계획 +> 헥사고날 아키텍처 + DDD 기반. 채팅 도메인이 mock으로 만든 Vote 관련 코드를 흡수하여 정식 구현. 서비스 함수는 inbound port (UseCase)로만 노출. +> 알림 / PUSH 기능은 본 명세에서 제외 (PWA 환경 정책 확정 후 별도 명세). + +--- + +## 1. 패키지 구조 + +``` +com.ject.vs +├── common +│ └── domain +│ ├── BaseEntity.java # 채팅 명세에서 정의됨 — 재사용 +│ ├── BaseTimeEntity.java # 채팅 명세에서 정의됨 — 재사용 +│ └── TimeTrackable.java # 채팅 명세에서 정의됨 — 재사용 +│ +├── vote # Vote Bounded Context (본 문서 범위) +│ ├── domain +│ │ ├── Vote.java # 채팅 mock 흡수 + 확장 +│ │ ├── VoteOption.java +│ │ ├── VoteParticipation.java # 채팅 mock 흡수 + 확장 +│ │ ├── VoteEmojiReaction.java +│ │ ├── GuestFreeVote.java +│ │ ├── VoteStatus.java # enum +│ │ ├── VoteType.java # enum +│ │ ├── VoteDuration.java # enum (HOURS_12, HOURS_24) +│ │ ├── VoteEmoji.java # enum +│ │ ├── VoteRepository.java # Spring Data JPA +│ │ ├── VoteOptionRepository.java +│ │ ├── VoteParticipationRepository.java # Spring Data JPA +│ │ ├── VoteEmojiReactionRepository.java +│ │ └── GuestFreeVoteRepository.java +│ ├── port +│ │ ├── in +│ │ │ ├── VoteQueryUseCase.java # 채팅이 의존할 inbound port +│ │ │ ├── VoteCommandUseCase.java # 업로드 / 참여 / 취소 +│ │ │ ├── ImmersiveVoteQueryUseCase.java +│ │ │ ├── ImmersiveVoteCommandUseCase.java +│ │ │ ├── VoteResultQueryUseCase.java +│ │ │ └── VoteEmojiCommandUseCase.java +│ │ ├── VoteQueryService.java +│ │ ├── VoteCommandService.java +│ │ ├── ImmersiveVoteQueryService.java +│ │ ├── ImmersiveVoteCommandService.java +│ │ ├── VoteResultQueryService.java +│ │ └── VoteEmojiCommandService.java +│ ├── adapter +│ │ └── web +│ │ ├── VoteController.java +│ │ ├── ImmersiveVoteController.java +│ │ ├── VoteResultController.java +│ │ ├── VoteEmojiController.java +│ │ └── GuestFreeVoteController.java +│ └── scheduler +│ └── VoteCloseScheduler.java # ENDED 상태 전환만 (알림 발송 X) +│ +└── config + ├── WebSocketConfig.java # 채팅 명세에서 정의됨 — 재사용 + ├── WebSocketAuthInterceptor.java # 채팅 명세에서 정의됨 — 재사용 + └── AnonymousIdResolver.java # 비회원 쿠키 식별 (신규) +``` + +--- + +## 2. 공통 기반 + +채팅 명세에서 정의한 `BaseEntity` / `BaseTimeEntity` / `TimeTrackable`을 그대로 재사용. + +``` +BaseEntity (id) + └── BaseTimeEntity (id + createdAt + updatedAt) implements TimeTrackable +``` + +각 엔티티별 상속 결정: + +| 엔티티 | 상속 | 이유 | +|--------|------|------| +| `Vote` | `BaseTimeEntity` | createdAt 응답 노출 + 종료 처리에 endAt 비교 필요 | +| `VoteOption` | `BaseEntity` | 시간 추적 불필요. position만 관리 | +| `VoteParticipation` | `BaseTimeEntity` | createdAt = 투표 시각. 분석 쿼리에 필요 | +| `VoteEmojiReaction` | `BaseTimeEntity` | updatedAt = 마지막 이모지 교체 시각 | +| `GuestFreeVote` | `BaseTimeEntity` | last_consumed_at 추적 | + +--- + +## 3. 단계별 구현 + +### STEP 1 — Vote Bounded Context 흡수 + 확장 + +**목표:** 채팅 도메인이 mock으로 만든 Vote 관련 코드를 정식 구현으로 대체. inbound port (`VoteQueryUseCase`) 노출. + +**구현 파일** + +| 파일 | 내용 | +|------|------| +| `vote/domain/Vote.java` | extends BaseTimeEntity. type, title, content, thumbnailUrl, imageUrl, duration, status, endAt | +| `vote/domain/VoteOption.java` | extends BaseEntity. voteId FK + label + position | +| `vote/domain/VoteParticipation.java` | extends BaseTimeEntity. (voteId, userId / anonymousId, optionId) | +| `vote/domain/VoteStatus.java` | enum (ONGOING, ENDED) | +| `vote/domain/VoteType.java` | enum (GENERAL, IMMERSIVE) | +| `vote/domain/VoteDuration.java` | enum (HOURS_12=12, HOURS_24=24) | +| `vote/domain/VoteRepository.java` | Spring Data JPA | +| `vote/domain/VoteOptionRepository.java` | Spring Data JPA | +| `vote/domain/VoteParticipationRepository.java` | 채팅 mock 인터페이스 흡수 + 확장 | +| `vote/port/in/VoteQueryUseCase.java` | inbound port — Chat이 의존 | +| `vote/port/VoteQueryService.java` | implements VoteQueryUseCase | +| `db/migration/V3__vote_schema.sql` | vote, vote_option, vote_participation 테이블 + 채팅 mock 마이그레이션 | + +**Vote 도메인 핵심 함수** + +```java +@Entity +public class Vote extends BaseTimeEntity { + @Enumerated(EnumType.STRING) + private VoteType type; + private String title; + private String content; + private String thumbnailUrl; + private String imageUrl; + @Enumerated(EnumType.STRING) + private VoteDuration duration; + @Enumerated(EnumType.STRING) + private VoteStatus status; + private LocalDateTime endAt; + + // 업로드 팩토리 — endAt 자체 계산 + public static Vote create(VoteType type, String title, String content, + String thumbnailUrl, String imageUrl, + VoteDuration duration) { + if (type == VoteType.IMMERSIVE && imageUrl == null) { + throw new ImageRequiredException(); + } + Vote vote = new Vote(); + vote.type = type; + vote.title = title; + vote.content = content; + vote.thumbnailUrl = thumbnailUrl; + vote.imageUrl = imageUrl; + vote.duration = duration; + vote.status = VoteStatus.ONGOING; + vote.endAt = LocalDateTime.now().plusHours(duration.getHours()); + return vote; + } + + public boolean isEnded() { + return status == VoteStatus.ENDED || endAt.isBefore(LocalDateTime.now()); + } + + public boolean isOngoing() { + return !isEnded(); + } + + public void close() { + this.status = VoteStatus.ENDED; + } +} +``` + +**VoteDuration enum** + +```java +@Getter +@RequiredArgsConstructor +public enum VoteDuration { + HOURS_12(12), + HOURS_24(24); + + private final int hours; + + public static VoteDuration from(int hours) { + return Arrays.stream(values()) + .filter(d -> d.hours == hours) + .findFirst() + .orElseThrow(InvalidDurationException::new); + } +} +``` + +`durationHours` 12/24 검증을 enum의 `from(int)` 팩토리에 위임. Service / Controller에서 if-else 체크 안 함. + +**VoteParticipation 도메인 핵심 함수** + +```java +@Entity +public class VoteParticipation extends BaseTimeEntity { + private Long voteId; + private Long userId; // 회원이면 set + private String anonymousId; // 비회원이면 set + private Long optionId; + + public static VoteParticipation ofMember(Long voteId, Long userId, Long optionId) { ... } + public static VoteParticipation ofGuest(Long voteId, String anonymousId, Long optionId) { ... } + + public boolean isGuest() { return anonymousId != null; } + + public void changeOption(Long optionId) { this.optionId = optionId; } +} +``` + +**VoteQueryUseCase (inbound port — Chat 도메인이 의존)** + +```java +public interface VoteQueryUseCase { + boolean isParticipated(Long voteId, Long userId); + Optional getSelectedOptionId(Long voteId, Long userId); + VoteSummary getVoteSummary(Long voteId); + VoteRatio getRatio(Long voteId); + int getParticipantCount(Long voteId); +} +``` + +**테스트** +- `VoteTest`: `create()` 팩토리 (endAt 계산, IMMERSIVE면 imageUrl 필수), `isEnded()`, `close()` 검증 +- `VoteDurationTest`: `from(12)`, `from(24)`, `from(13)` 시 예외 검증 +- `VoteParticipationTest`: `ofMember()`, `ofGuest()` 팩토리 + `changeOption()` 검증 +- `VoteQueryServiceTest`: 참여 여부 / 선택 옵션 조회 / 비율 계산 검증 +- `VoteParticipationRepositoryTest`: `@DataJpaTest` — 회원/비회원 식별 컬럼 분기, unique constraint 검증 + +--- + +### STEP 2 — 비회원 쿠키 식별 인프라 + +**목표:** 모든 API 진입점에서 회원 / 비회원 식별 가능하도록 인프라 구축. + +**구현 파일** + +| 파일 | 내용 | +|------|------| +| `config/AnonymousIdResolver.java` | `HandlerMethodArgumentResolver` — 쿠키에서 anonymous_id 추출, 없으면 신규 발급 + Set-Cookie | +| `vote/domain/GuestFreeVote.java` | extends BaseTimeEntity. anonymousId PK + consumedCount | +| `vote/domain/GuestFreeVoteRepository.java` | Spring Data JPA | + +**AnonymousIdResolver 동작 흐름** + +```java +@Component +public class AnonymousIdResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AnonymousId.class); + } + + @Override + public String resolveArgument(...) { + HttpServletRequest req = ...; + HttpServletResponse res = ...; + + // 1. 쿠키에서 anonymous_id 추출 + String existingId = extractFromCookie(req, "anonymous_id"); + if (existingId != null) return existingId; + + // 2. 없으면 신규 발급 + Set-Cookie + String newId = UUID.randomUUID().toString(); + ResponseCookie cookie = ResponseCookie.from("anonymous_id", newId) + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(Duration.ofDays(365)) + .path("/") + .build(); + res.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + return newId; + } +} +``` + +**Controller 사용 예시** + +```java +@PostMapping("/api/votes/{voteId}/participate") +public ParticipateResponse participate( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId, // 회원이면 not null + @AnonymousId String anonymousId, // 비회원이면 사용 + @RequestBody ParticipateRequest request) { + if (userId != null) { + return voteCommandUseCase.participateAsMember(voteId, userId, request.optionId()); + } + return voteCommandUseCase.participateAsGuest(voteId, anonymousId, request.optionId()); +} +``` + +**테스트** +- `AnonymousIdResolverTest`: 쿠키 존재 / 부재 케이스, Set-Cookie 헤더 발급 검증 +- `GuestFreeVoteRepositoryTest`: upsert 동시성, 5회 소진 시 차감 차단 검증 + +--- + +### STEP 3 — 일반형 투표 Application Services + +**목표:** 일반형 투표의 업로드 / 조회 / 참여 / 취소 / 이모지 비즈니스 로직. + +**VoteCommandUseCase (inbound port)** + +```java +public interface VoteCommandUseCase { + VoteCreateResult create(VoteCreateCommand command); + ParticipateResult participateAsMember(Long voteId, Long userId, Long optionId); + ParticipateResult participateAsGuest(Long voteId, String anonymousId, Long optionId); + void cancel(Long voteId, Long userId); +} + +public record VoteCreateCommand( + VoteType type, + String title, + String content, + String thumbnailUrl, + String imageUrl, + int durationHours, + String optionA, + String optionB +) { } +``` + +**VoteCommandService 핵심 로직 — 업로드** + +```java +@Service +@Transactional +@RequiredArgsConstructor +public class VoteCommandService implements VoteCommandUseCase { + + private final VoteRepository voteRepository; + private final VoteOptionRepository voteOptionRepository; + + @Override + public VoteCreateResult create(VoteCreateCommand cmd) { + // 1. duration 검증 (enum 팩토리에 위임) + VoteDuration duration = VoteDuration.from(cmd.durationHours()); + + // 2. Vote 생성 (도메인 팩토리에서 endAt 계산 + IMMERSIVE면 imageUrl 검증) + Vote vote = Vote.create( + cmd.type(), cmd.title(), cmd.content(), + cmd.thumbnailUrl(), cmd.imageUrl(), duration + ); + Vote saved = voteRepository.save(vote); + + // 3. 옵션 A / B 생성 + voteOptionRepository.save(VoteOption.of(saved.getId(), cmd.optionA(), 0)); + voteOptionRepository.save(VoteOption.of(saved.getId(), cmd.optionB(), 1)); + + return VoteCreateResult.from(saved); + } +} +``` + +**VoteCommandService 핵심 로직 — 비회원 참여** + +```java +@Override +public ParticipateResult participateAsGuest(Long voteId, String anonymousId, Long optionId) { + Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); + if (vote.isEnded()) throw new VoteEndedException(); + + if (!voteOptionRepository.existsByIdAndVoteId(optionId, voteId)) + throw new InvalidOptionException(); + + Optional existing = + voteParticipationRepository.findByVoteIdAndAnonymousId(voteId, anonymousId); + + if (existing.isPresent()) { + // 옵션 변경 — 차감하지 않음 + existing.get().changeOption(optionId); + return ParticipateResult.from(existing.get(), getRemaining(anonymousId)); + } + + // 신규 — GuestFreeVote 차감 + guestFreeVoteService.consume(anonymousId); // 5회 초과 시 VoteFreeLimitExceededException + + VoteParticipation saved = voteParticipationRepository.save( + VoteParticipation.ofGuest(voteId, anonymousId, optionId)); + return ParticipateResult.from(saved, getRemaining(anonymousId)); +} +``` + +**구현 파일** + +| 파일 | 내용 | +|------|------| +| `vote/port/in/VoteCommandUseCase.java` | inbound port (create + participate + cancel) | +| `vote/port/VoteCommandService.java` | 비즈니스 로직 | +| `vote/port/in/VoteEmojiCommandUseCase.java` | 이모지 반응 | +| `vote/port/VoteEmojiCommandService.java` | 이모지 upsert / 취소 | + +**테스트** +- `VoteCommandServiceTest`: + - `create`: 정상 / IMMERSIVE인데 imageUrl 누락 / durationHours 13 → 예외 + - `participate`: 신규 / 옵션 변경 / 5회 소진 시 차단 / ENDED 차단 / 옵션 무효 +- `VoteEmojiCommandServiceTest`: 다른 이모지 교체 / 동일 이모지 재선택 시 취소 / null 전송 시 취소 + +--- + +### STEP 4 — 몰입형 투표 Application Services + +**목표:** 몰입형 피드 + 단일 엔드포인트 참여 / 취소. + +> 몰입형 투표 업로드도 `POST /api/votes` 엔드포인트 하나로 처리. `type: IMMERSIVE`로 분기. + +**ImmersiveVoteCommandUseCase (inbound port)** + +```java +public interface ImmersiveVoteCommandUseCase { + ImmersiveParticipateResult participateOrCancel(Long voteId, Long userId, String anonymousId, Long optionId); +} + +public record ImmersiveParticipateResult( + ImmersiveVoteAction action, // VOTED | CANCELED + Long selectedOptionId, + List options, + Integer remainingFreeVotes +) { } +``` + +**핵심 로직** + +```java +@Override +public ImmersiveParticipateResult participateOrCancel(Long voteId, Long userId, String anonymousId, Long optionId) { + Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); + if (vote.isEnded()) throw new VoteEndedException(); + + Optional existing = userId != null + ? voteParticipationRepository.findByVoteIdAndUserId(voteId, userId) + : voteParticipationRepository.findByVoteIdAndAnonymousId(voteId, anonymousId); + + if (existing.isPresent() && existing.get().getOptionId().equals(optionId)) { + // 같은 옵션 재클릭 → 취소 + voteParticipationRepository.delete(existing.get()); + return ImmersiveParticipateResult.canceled(...); + } + + if (existing.isPresent()) { + // 옵션 변경 → 차감 X + existing.get().changeOption(optionId); + return ImmersiveParticipateResult.voted(...); + } + + // 신규 → 비회원이면 차감 + if (userId == null) guestFreeVoteService.consume(anonymousId); + voteParticipationRepository.save(...); + return ImmersiveParticipateResult.voted(...); +} +``` + +**구현 파일** + +| 파일 | 내용 | +|------|------| +| `vote/port/in/ImmersiveVoteQueryUseCase.java` | 피드 조회 (cursor 페이지네이션) | +| `vote/port/in/ImmersiveVoteCommandUseCase.java` | 참여 / 취소 단일 메서드 | +| `vote/port/ImmersiveVoteQueryService.java` | 피드 + currentViewerCount 집계 | +| `vote/port/ImmersiveVoteCommandService.java` | 위 로직 구현 | + +**테스트** +- `ImmersiveVoteCommandServiceTest`: VOTED / CANCELED 분기, 회원 / 비회원별 차감 정책, ENDED 차단 + +--- + +### STEP 5 — 결과 화면 (Insight) + +**목표:** 마감된 투표의 결과 + 인사이트 분석 + AI Insight. + +**VoteResultQueryUseCase (inbound port)** + +```java +public interface VoteResultQueryUseCase { + VoteResultDetail getResult(Long voteId, Long userId, String anonymousId); +} +``` + +**Insight 분기 로직** + +```java +@Override +public VoteResultDetail getResult(Long voteId, Long userId, String anonymousId) { + Vote vote = voteRepository.findById(voteId).orElseThrow(VoteNotFoundException::new); + if (vote.isOngoing()) throw new VoteNotEndedException(); + + // 결과 옵션 + 비율 + VoteResult result = computeResult(voteId); + + // 인사이트 분기 + Insight insight; + if (userId == null) { + // 비회원 — 잠금 + insight = Insight.locked(); + } else { + Optional myVote = + voteParticipationRepository.findByVoteIdAndUserId(voteId, userId); + insight = myVote.isPresent() + ? buildMySelectionInsight(voteId, myVote.get(), userId) // scope=MY_SELECTION + : buildTotalInsight(voteId); // scope=TOTAL + } + + AiInsight aiInsight = (userId != null && hasParticipated(voteId, userId)) + ? aiInsightService.generateOrFetch(voteId, userId) + : AiInsight.unavailable(); + + return VoteResultDetail.of(vote, result, myVote, insight, aiInsight); +} +``` + +**Insight 분석 쿼리 — `genderDistribution`, `ageDistribution`** + +User 테이블의 gender / birthDate 컬럼이 필요. (분석 데이터 수집 항목 추가 필요 — 회의에서 이미 리스크로 식별됨.) + +```sql +-- MY_SELECTION 케이스 — 본인이 선택한 옵션 기준 성별 분포 +SELECT u.gender, COUNT(*) AS count +FROM vote_participation vp +JOIN users u ON vp.user_id = u.id +WHERE vp.vote_id = :voteId + AND vp.option_id = :myOptionId +GROUP BY u.gender; +``` + +**추가 예정 : AI Insight 캐싱 전략** +- 첫 호출 시 LLM 호출 → vote 테이블의 `ai_insight_headline`, `ai_insight_body` 컬럼에 저장 +- 이후 호출은 컬럼에서 직접 반환 +- LLM 호출 실패 시 `aiInsight.available: false`로 응답 (에러 아님) +- 본인이 만든 youth-policy AI 에이전트 (LangChain4j + Gemini 2.5 Flash) 인프라 재활용 검토 + +**구현 파일** + +| 파일 | 내용 | +|------|------| +| `vote/port/in/VoteResultQueryUseCase.java` | 결과 조회 inbound port | +| `vote/port/VoteResultQueryService.java` | Insight 분기 + AI Insight 캐싱 | +| `vote/adapter/web/VoteResultController.java` | `/result`, `/share` | + +--- + +### STEP 6 — 투표 종료 스케줄러 + +**목표:** 마감 시각 도래한 투표를 ENDED 상태로 전환. + +> 본 명세에서는 알림 발송 없이 **상태 전환만** 처리. 알림 발송은 PWA 푸시 명세 확정 후 별도 단계로 추가. + +**구현 파일** + +| 파일 | 내용 | +|------|------| +| `vote/scheduler/VoteCloseScheduler.java` | `@Scheduled` 로 ENDED 전환 | + +**스케줄러 흐름** + +```java +@Component +@RequiredArgsConstructor +public class VoteCloseScheduler { + + private final VoteRepository voteRepository; + + @Scheduled(cron = "0 * * * * *") // 매분 + @Transactional + public void closeExpiredVotes() { + List expired = voteRepository.findExpiredOngoing(LocalDateTime.now()); + for (Vote vote : expired) { + vote.close(); + } + } + + // 서버 재시작 직후 누락 보정용 1회 실행 + @PostConstruct + public void closeExpiredOnStartup() { + closeExpiredVotes(); + } +} +``` + +> 회의에서 이미 식별된 리스크: 서버 재시작 중 스케줄러 누락. `@PostConstruct`로 시작 시 1회 실행하여 미전환 vote 일괄 처리. + +**테스트** +- `VoteCloseSchedulerTest`: 마감 시각 도래한 vote만 close 검증 +- 시작 시 미전환 vote 보정 검증 + +--- + +### STEP 7 — REST Controllers + +**목표:** REST 엔드포인트 노출. UseCase 주입 + DTO 변환. + +**Controller 구조 — VoteController 예시** + +```java +@RestController +@RequestMapping("/api/votes") +@RequiredArgsConstructor +public class VoteController { + + private final VoteQueryUseCase voteQueryUseCase; + private final VoteCommandUseCase voteCommandUseCase; + + @PostMapping + public VoteCreateResponse create( + @AuthenticationPrincipal Long userId, + @RequestBody @Valid VoteCreateRequest request) { + if (userId == null) throw new UnauthorizedException(); + return VoteCreateResponse.from( + voteCommandUseCase.create(request.toCommand())); + } + + @GetMapping("/{voteId}") + public VoteDetailResponse getDetail( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId, + @AnonymousId String anonymousId) { ... } + + @PostMapping("/{voteId}/participate") + public ParticipateResponse participate( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId, + @AnonymousId String anonymousId, + @RequestBody ParticipateRequest request) { ... } + + @DeleteMapping("/{voteId}/participate") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void cancel( + @PathVariable Long voteId, + @AuthenticationPrincipal Long userId) { ... } +} +``` + +**Controller 분리** + +| 파일 | 엔드포인트 | +|------|------------| +| `VoteController` | `POST /api/votes`, `/api/votes/{voteId}/...` (일반형) | +| `ImmersiveVoteController` | `/api/immersive-votes/...` | +| `VoteResultController` | `/api/votes/{voteId}/result`, `/share` | +| `VoteEmojiController` | `PUT /api/votes/{voteId}/emoji`, `PUT /api/immersive-votes/{voteId}/emoji` | +| `GuestFreeVoteController` | `GET /api/me/free-votes` | + +**테스트** +- `*ControllerTest`: `@WebMvcTest` — 각 엔드포인트 요청/응답 검증, 401/403 케이스, anonymousId 쿠키 발급 검증 + +--- + +### STEP 8 — WebSocket 몰입형 실시간 비율 + +**목표:** `/topic/immersive-vote/{voteId}/live` broadcast. + +채팅 명세에서 정의된 `WebSocketConfig` / `WebSocketAuthInterceptor` 그대로 재사용. 단 본 broadcast는 익명 수신 허용 (인증 불필요). + +**broadcast 흐름** + +``` +POST /api/immersive-votes/{voteId}/participate + │ + ▼ +ImmersiveVoteCommandService.participateOrCancel() + │ + ▼ +[비율 변경 후] +SimpMessagingTemplate.convertAndSend("/topic/immersive-vote/" + voteId + "/live", payload) +``` + +**도입 시점** +- 1차: REST 폴링 (`GET /live`) 으로 시작 +- 2차: 동시 접속자 증가 시 WebSocket 전환. 클라이언트 측 분기 필요 + +**테스트** +- `ImmersiveVoteWebSocketIntegrationTest`: REST POST 후 `/topic/immersive-vote/{voteId}/live` 수신 검증 + +--- + +## 4. Flyway 마이그레이션 + +| 파일 | 내용 | +|------|------| +| `V1__init_schema.sql` | 기존 (users, token) | +| `V2__chat_schema.sql` | 채팅 명세 — vote (mock), vote_participation (mock), chat_message, chat_room_unread | +| `V3__vote_schema.sql` | **본 명세** — vote 테이블 컬럼 추가 (type, title, content, thumbnail_url, image_url, duration, status, end_at) + vote_option, vote_emoji_reaction, guest_free_vote 신규 + vote_participation 컬럼 확장 (anonymous_id, option_id) + users.gender, users.birthDate 컬럼 추가 (Insight 분석용) | + +> V2에서 mock으로 만든 vote / vote_participation 테이블은 V3에서 ALTER로 컬럼 추가. 데이터 백워드 호환성 유지. + +--- + +## 5. 테스트 전략 요약 + +| 레이어 | 도구 | 대상 | +|--------|------|------| +| Domain | JUnit 5 (순수 단위) | 팩토리 메서드, 도메인 검증 함수 (`Vote.create`, `isEnded`, `VoteDuration.from` 등) | +| Application | JUnit 5 + Mockito | UseCase 비즈니스 로직, 차감 정책, 예외 케이스 | +| Persistence | `@DataJpaTest` | 회원 / 비회원 식별 분기, unique constraint, cursor 페이지네이션 | +| Controller | `@WebMvcTest` | 요청/응답 직렬화, 인증 / 비회원 쿠키, 401/403 케이스 | +| Scheduler | `@SpringBootTest` | 마감 시각 도래한 vote ENDED 전환 | +| WebSocket | `StompClient` 통합 | REST POST → `/topic/immersive-vote/{voteId}/live` 수신 end-to-end | + +--- + +## 6. 구현 순서 요약 + +``` +STEP 1 Vote BC 흡수 + 확장 (VoteQueryUseCase 노출 — Chat이 의존) + + Vote 도메인 팩토리 (create) — endAt 자체 계산, IMMERSIVE 검증 + + VoteDuration enum (HOURS_12 / HOURS_24) + +STEP 2 비회원 쿠키 식별 인프라 (AnonymousIdResolver + GuestFreeVote) + +STEP 3 일반형 투표 Application Services (업로드 / 조회 / 참여 / 취소 / 이모지) + +STEP 4 몰입형 투표 Application Services (피드 / 단일 엔드포인트 참여·취소) + 업로드는 STEP 3의 POST /api/votes 엔드포인트 재사용 (type: IMMERSIVE) + +STEP 5 결과 화면 + Insight (MY_SELECTION / TOTAL / locked) + AI Insight 캐싱 + +STEP 6 투표 종료 스케줄러 (ENDED 상태 전환만. 알림 발송은 별도 명세) + +STEP 7 REST Controllers (5개) + +STEP 8 WebSocket 몰입형 실시간 비율 (폴링 → 실시간 전환) +``` + +--- + +## 7. 채팅 도메인 협업 사항 + +채팅 명세에서 mock으로 만든 Vote 관련 코드를 본 도메인이 흡수. `ChatCommandService`는 outbound port (`VoteParticipationRepository`) 직접 의존에서 → 본 도메인이 노출하는 inbound port (`VoteQueryUseCase`) 의존으로 변경. + +**코드 변경 예시** + +```java +// Before +@RequiredArgsConstructor +public class ChatCommandService { + private final VoteParticipationRepository voteParticipationRepository; + + public void sendMessage(...) { + if (!voteParticipationRepository.existsByVoteIdAndUserId(voteId, senderId)) + throw new ChatForbiddenException(); + } +} + +// After +@RequiredArgsConstructor +public class ChatCommandService { + private final VoteQueryUseCase voteQueryUseCase; // ← inbound port 의존 + + public void sendMessage(...) { + if (!voteQueryUseCase.isParticipated(voteId, senderId)) + throw new ChatForbiddenException(); + } +} +``` + +흡수 / 삭제 대상 (mock으로 만든 것): +- `vote/domain/Vote.java` → 본 명세 Vote로 대체 (확장) +- `vote/domain/VoteParticipation.java` → 본 명세 VoteParticipation으로 대체 (확장) +- `vote/domain/VoteRepository.java` → 본 명세 Repository로 대체 +- `vote/domain/VoteParticipationRepository.java` → 본 명세 Repository로 대체 + +> 별도 PR로 진행 권장. STEP 1 완료 시점에 import 경로 변경 PR 요청. + +--- + +## 8. 별도 명세로 분리된 항목 + +다음 기능은 본 구현 계획에 포함되지 않음: + +- **알림 / PUSH** — PWA Web Push API + VAPID 기반 재설계 필요. 기획자 / 프론트와 정책 합의 후 별도 명세로 분리 + - 합의 대상: iOS 16.4 미만 사용자 처리, "홈 화면 추가" 유도 UI, 권한 요청 타이밍, 라이브러리 (`nl.martijndwars:web-push` 검토) + - 백엔드 구현 범위: VAPID 키 발급 / 관리, `web_push_subscription` 테이블, 발송 시 만료 subscription 자동 정리 + - Vote 도메인 연결 지점: 투표 종료 스케줄러 (`VoteCloseScheduler`)에서 알림 도메인 inbound port 호출로 트리거 +- **이미지 업로드** (`POST /api/uploads/image`) — Pre-signed URL 방식 / 직접 업로드 방식 결정 후 명세 diff --git a/docs/spec/vote-api-spec.md b/docs/spec/vote-api-spec.md new file mode 100644 index 0000000..c30fbe2 --- /dev/null +++ b/docs/spec/vote-api-spec.md @@ -0,0 +1,753 @@ +# 투표 기능 도메인 & API 설계 계획 +> 기반 디자인: VS 앱 투표 페이지 (Figma — 일반형 / 몰입형 / 투표 결과) +> 채팅 도메인은 별도 담당자가 설계. 본 문서에서는 `vote_id` FK 참조 관계만 명시하며, 메시지 / 채팅방 / 게이지 등 채팅 기능은 채팅 명세 참조. +> 알림 / PUSH 기능은 PWA 환경 푸시 정책 (iOS 16.4 + 홈 화면 추가 제약 등) 사전 합의 후 별도 명세로 분리. +--- + +## 1. 신규 도메인 + +> 채팅 도메인이 mock으로 만들어둔 `vote`, `vote_participation` 테이블은 본 명세에서 **정식 스키마로 흡수**한다. +> 친구가 만든 `Vote`, `VoteParticipation` 엔티티 / Repository 인터페이스는 본인이 인수받아 확장한다. + +### Vote (투표) + +```sql +CREATE TABLE vote ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + type VARCHAR(20) NOT NULL, -- GENERAL | IMMERSIVE + title VARCHAR(100) NOT NULL, + content VARCHAR(1000), + thumbnail_url VARCHAR(512) NOT NULL, + image_url VARCHAR(512), -- 몰입형 전용. 일반형은 NULL + duration VARCHAR(20) NOT NULL, -- HOURS_12 | HOURS_24 + status VARCHAR(20) NOT NULL, -- ONGOING | ENDED + end_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_vote_type_end_at ON vote (type, end_at DESC); +CREATE INDEX idx_vote_status_end_at ON vote (status, end_at DESC); +``` + +> `end_at`은 클라이언트가 직접 보내지 않고 서버가 `now() + duration`으로 계산하여 저장. + +### VoteOption (투표 선택지) + +```sql +CREATE TABLE vote_option ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + vote_id BIGINT NOT NULL REFERENCES vote(id), + label VARCHAR(50) NOT NULL, + position INT NOT NULL -- 0(A), 1(B) +); + +CREATE INDEX idx_vote_option_vote_id ON vote_option (vote_id, position); +``` + +### VoteParticipation (투표 참여) + +```sql +CREATE TABLE vote_participation ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + vote_id BIGINT NOT NULL REFERENCES vote(id), + user_id BIGINT REFERENCES users(id), -- 회원 + anonymous_id VARCHAR(36), -- 비회원 (쿠키 UUID) + option_id BIGINT NOT NULL REFERENCES vote_option(id), + created_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT chk_voter CHECK ( + (user_id IS NOT NULL AND anonymous_id IS NULL) OR + (user_id IS NULL AND anonymous_id IS NOT NULL) + ), + CONSTRAINT uq_member_vote UNIQUE (vote_id, user_id), + CONSTRAINT uq_guest_vote UNIQUE (vote_id, anonymous_id) +); + +CREATE INDEX idx_vote_participation_vote_id ON vote_participation (vote_id); +CREATE INDEX idx_vote_participation_user_id ON vote_participation (user_id); +CREATE INDEX idx_vote_participation_anonymous_id ON vote_participation (anonymous_id); +``` + +> 회원/비회원 식별 컬럼을 분리. 채팅 mock에서는 `user_id`만 있었으나, 비회원 무료 5회 정책을 위해 `anonymous_id` 추가. + +### VoteEmojiReaction (이모지 반응) + +```sql +CREATE TABLE vote_emoji_reaction ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + vote_id BIGINT NOT NULL REFERENCES vote(id), + user_id BIGINT REFERENCES users(id), + anonymous_id VARCHAR(36), + emoji VARCHAR(20) NOT NULL, -- LIKE | SAD | ANGRY | WOW + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT chk_reactor CHECK ( + (user_id IS NOT NULL AND anonymous_id IS NULL) OR + (user_id IS NULL AND anonymous_id IS NOT NULL) + ), + CONSTRAINT uq_member_emoji UNIQUE (vote_id, user_id), + CONSTRAINT uq_guest_emoji UNIQUE (vote_id, anonymous_id) +); +``` + +> 1인 1이모지. 다른 이모지 선택 시 row 자체를 update. 취소 시 row delete. + +### GuestFreeVote (비회원 무료 투표 카운트) + +```sql +CREATE TABLE guest_free_vote ( + anonymous_id VARCHAR(36) PRIMARY KEY, + consumed_count INT NOT NULL DEFAULT 0, + last_consumed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT now() +); +``` + +> 비회원 무료 투표권은 **voteId별이 아닌 전역 카운트**. 한 비회원이 5개 투표 참여 시 소진. 동일 투표 옵션 변경 / 취소는 차감하지 않음. + +--- + +## 2. REST API + +> 모든 시간 필드는 **KST (UTC+9)** 기준 ISO 8601 형식. (예: `2026-04-26T14:23:00+09:00`) +> 회원 식별: `Authorization: Bearer {accessToken}` 헤더. +> 비회원 식별: `anonymous_id` 쿠키 (HttpOnly, Secure, SameSite=None, Max-Age 1년). 첫 응답 시 백엔드가 자동 발급. +> 우선순위: JWT 헤더 → 회원, JWT 없음 + 쿠키 → 비회원, 둘 다 없음 → 비회원 신규 발급. + +--- + +### 2-1. 일반형 투표 + +#### POST `/api/votes` — 투표 업로드 (일반형 / 몰입형 공통) + +**Request** + +| 구분 | 파라미터 | 타입 | 필수 | 설명 | +|------|----------|------|------|------| +| Body | `type` | `GENERAL \| IMMERSIVE` | 필수 | 투표 타입 | +| Body | `title` | `String` | 필수 | 투표 제목 (max 100자) | +| Body | `content` | `String` | 선택 | 본문 (max 1000자) | +| Body | `thumbnailUrl` | `String` | 필수 | 썸네일 이미지 URL (별도 업로드 API로 선업로드 후 URL 전달) | +| Body | `imageUrl` | `String` | 조건부 | 몰입형 메인 이미지 URL. `type === IMMERSIVE`이면 필수 | +| Body | `durationHours` | `Int` | 필수 | `12` 또는 `24` | +| Body | `optionA` | `String` | 필수 | A 옵션 텍스트 (max 50자) | +| Body | `optionB` | `String` | 필수 | B 옵션 텍스트 (max 50자) | + +```json +{ + "type": "GENERAL", + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "content": "저는 혼자 밥 먹는 게 편한데 회사라서 막내라 혼자 밥 먹겠다고 하기 눈치보여요ㅠㅠ", + "thumbnailUrl": "https://cdn.example.com/uploads/abc.jpg", + "imageUrl": null, + "durationHours": 24, + "optionA": "혼밥이 편하다", + "optionB": "그래도 같이 먹는 게 맞다" +} +``` + +**Response** + +```json +{ + "voteId": 1, + "status": "ONGOING", + "endAt": "2026-04-15T13:49:00+09:00" +} +``` + +> `endAt`은 서버에서 `now() + durationHours`로 계산하여 저장. 클라이언트가 직접 시각을 보내지 않음 (시계 조작 방지). +> `durationHours`는 서버에서 enum (`HOURS_12`, `HOURS_24`)으로 검증. 다른 값이면 `400 INVALID_DURATION`. +> `type === IMMERSIVE`인데 `imageUrl`이 누락되면 `400 IMAGE_REQUIRED`. +> 이미지 업로드는 별도 `POST /api/uploads/image` 엔드포인트로 분리. 본 엔드포인트는 URL만 받음. + +> 회원만 호출 가능. 비회원은 `401`. + +--- + +#### GET `/api/votes/{voteId}` — 투표 상세 + +**Request** + +| 구분 | 파라미터 | 타입 | 필수 | 설명 | +|------|----------|------|------|------| +| Path | `voteId` | `Long` | 필수 | 투표 ID | + +**Response (회원 / 비회원 - 투표 전)** + +```json +{ + "voteId": 1, + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "createdAt": "2026-04-14T13:49:00+09:00", + "content": "저는 혼자 밥 먹는 게 편한데 회사라서 막내라 혼자 밥 먹겠다고 하기 눈치보여요ㅠㅠ 혼밥하고 싶다고 말씀드려도 될까요?", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg", + "status": "ONGOING", + "endAt": "2026-04-14T23:59:00+09:00", + "participantCount": 31, + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": null, "ratio": null }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": null, "ratio": null } + ], + "myVote": { "voted": false, "selectedOptionId": null }, + "emojiSummary": { "LIKE": 21, "SAD": 3, "ANGRY": 8, "WOW": 36 }, + "myEmoji": null, + "commentCount": 81 +} +``` + +**Response (회원 / 비회원 - 투표 후)** + +```json +{ + "voteId": 1, + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "createdAt": "2026-04-14T13:49:00+09:00", + "content": "...", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg", + "status": "ONGOING", + "endAt": "2026-04-14T23:59:00+09:00", + "participantCount": 31, + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": 22, "ratio": 70 }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": 9, "ratio": 30 } + ], + "myVote": { "voted": true, "selectedOptionId": 10 }, + "emojiSummary": { "LIKE": 21, "SAD": 3, "ANGRY": 8, "WOW": 36 }, + "myEmoji": "WOW", + "commentCount": 81 +} +``` + +> `myVote.voted = false`일 때 `voteCount`/`ratio`는 `null`. 결과 비공개. 프론트는 옵션 버튼 형태로 노출. +> `myVote.voted = true`일 때만 결과 표시. `다시투표하기` 버튼은 프론트가 `voted` 기준으로 분기. +> 비회원도 동일 응답. `myVote.voted`는 비회원이 무료 투표권으로 투표한 경우 `true`. + +--- + +#### POST `/api/votes/{voteId}/participate` — 투표 참여 + +**Request** + +| 구분 | 파라미터 | 타입 | 필수 | 설명 | +|------|----------|------|------|------| +| Path | `voteId` | `Long` | 필수 | 투표 ID | +| Body | `optionId` | `Long` | 필수 | 선택한 옵션 ID | + +```json +{ "optionId": 10 } +``` + +**Response** + +```json +{ + "voteId": 1, + "selectedOptionId": 10, + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": 22, "ratio": 70 }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": 9, "ratio": 30 } + ], + "participantCount": 31, + "remainingFreeVotes": 4 +} +``` + +> `remainingFreeVotes`는 비회원에게만 의미 있는 값. 회원은 `null`. +> 비회원이 5회 소진 후 추가 시도 시 `403 VOTE_FREE_LIMIT_EXCEEDED`. + +--- + +#### DELETE `/api/votes/{voteId}/participate` — 다시투표하기 (투표 취소) + +**Request** + +| 구분 | 파라미터 | 타입 | 필수 | 설명 | +|------|----------|------|------|------| +| Path | `voteId` | `Long` | 필수 | 투표 ID | + +**Response:** `204 No Content` + +> 결과 화면에서 `다시투표하기` 클릭 시 호출. 호출 후 프론트는 `GET /api/votes/{voteId}` 재조회하여 옵션 선택 상태로 복귀. +> ENDED 상태에서는 `403 VOTE_ENDED`. + +--- + +#### PUT `/api/votes/{voteId}/emoji` — 이모지 반응 + +**Request** + +| 구분 | 파라미터 | 타입 | 필수 | 설명 | +|------|----------|------|------|------| +| Path | `voteId` | `Long` | 필수 | 투표 ID | +| Body | `emoji` | `LIKE \| SAD \| ANGRY \| WOW \| null` | 필수 | 선택한 이모지. `null`이면 취소 | + +```json +{ "emoji": "WOW" } +``` + +**Response** + +```json +{ + "emojiSummary": { + "LIKE": 21, + "SAD": 3, + "ANGRY": 8, + "WOW": 37, + "total": 69 + }, + "myEmoji": "WOW" +} +``` + +> 1인 1이모지. 다른 이모지 선택 시 자동 교체. 같은 이모지 재클릭 또는 `emoji: null` → 취소. 회원 / 비회원 모두 호출 가능. + +--- + +### 2-2. 투표 결과 (마감 후) + +#### GET `/api/votes/{voteId}/result` — 투표 결과 + +**Request** + +| 구분 | 파라미터 | 타입 | 필수 | 설명 | +|------|----------|------|------|------| +| Path | `voteId` | `Long` | 필수 | 투표 ID | + +**Response (회원 - 참여O)** + +```json +{ + "voteId": 1, + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "createdAt": "2026-04-14T13:49:00+09:00", + "content": "...", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg", + "status": "ENDED", + "endAt": "2026-04-14T23:59:00+09:00", + "participantCount": 520, + "result": { + "options": [ + { "optionId": 10, "label": "혼밥이 편하다", "voteCount": 364, "ratio": 70 }, + { "optionId": 11, "label": "그래도 밥은 같이 먹는 게 맞다", "voteCount": 156, "ratio": 30 } + ] + }, + "myVote": { "voted": true, "selectedOptionId": 11 }, + "insight": { + "locked": false, + "scope": "MY_SELECTION", + "selectionCount": 156, + "genderDistribution": { + "female": { "count": 96, "ratio": 62 }, + "male": { "count": 60, "ratio": 38 } + }, + "ageDistribution": [ + { "ageGroup": "20s", "ratio": 28, "isMyGroup": true }, + { "ageGroup": "30s", "ratio": 52, "isMyGroup": false }, + { "ageGroup": "40s", "ratio": 20, "isMyGroup": false } + ] + }, + "aiInsight": { + "available": true, + "headline": "20대 여성 그룹에서 \"같이 밥먹기\"를 선택한 비율이 71%로 가장 높게 나타났어요.", + "body": "MZ 세대를 중심으로 혼밥 문화가 확산되는 트렌드가 반영된 결과예요." + } +} +``` + +**Response (회원 - 참여X)**: `myVote.voted: false`, `insight.scope: TOTAL`, `aiInsight.available: false` +**Response (비회원)**: `insight.locked: true`, 모든 분석 필드 `null` + +> `insight.scope` 분기 규칙 +> - `MY_SELECTION`: 회원 + 참여O → 본인이 선택한 옵션 기준 분석. 본인 연령대 `isMyGroup: true`로 강조 +> - `TOTAL`: 회원 + 참여X → 전체 참여자 기준 분석. 다수 선택 옵션 강조는 프론트가 `result.options` 비교해서 처리 +> - `null` + `locked: true`: 비회원 → 잠금 컴포넌트 노출 + +> `aiInsight.available`은 회원 + 참여O일 때만 `true` 가능. +> `status: ONGOING`인 voteId로 호출 시 `403 VOTE_NOT_ENDED`. + +--- + +#### GET `/api/votes/{voteId}/share` — 공유 링크 생성 + +**Response** + +```json +{ + "shareUrl": "https://vs.app/poll/result/12345", + "title": "직장인 점심시간 혼밥 vs 같이 먹기", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg" +} +``` + +> 공유하기 버튼 → 팝업 노출 시 호출. 프론트는 `shareUrl`을 클립보드 복사 후 토스트 노출. 공유 링크로 진입한 비회원도 결과 페이지를 잠금 상태로 확인 가능. + +--- + +### 2-3. 몰입형 투표 + +#### GET `/api/immersive-votes` — 몰입형 투표 피드 + +**Request** + +| 구분 | 파라미터 | 타입 | 필수 | 설명 | +|------|----------|------|------|------| +| Query | `cursor` | `Long` | 선택 | 이전 페이지 마지막 voteId | +| Query | `size` | `Int` | 선택 | 페이지 크기 (기본 10) | + +**Response** + +```json +{ + "votes": [ + { + "voteId": 1, + "title": "논쟁 끝판왕 밸런스게임", + "content": "자기 전에 갑자기 생각난 밸런스 게임인데 한 번 골라봐...", + "imageUrl": "https://cdn.example.com/votes/1/main.jpg", + "endAt": "2026-04-27T23:59:00+09:00", + "options": [ + { "optionId": 10, "label": "스윙칩만 3달 먹기", "voteCount": null, "ratio": null }, + { "optionId": 11, "label": "스윙스한테 30만원 주기", "voteCount": null, "ratio": null } + ], + "myVote": { "voted": false, "selectedOptionId": null }, + "emojiSummary": { "LIKE": 21, "SAD": 3, "ANGRY": 8, "WOW": 36, "total": 68 }, + "myEmoji": null, + "commentCount": 27, + "currentViewerCount": 13 + } + ], + "nextCursor": 980, + "hasNext": true +} +``` + +> 위/아래 스와이프로 다음 투표 이동 → 프론트는 `cursor` 기반 prefetch 권장 (현재 인덱스 기준 ±2개 미리 로딩). +> 회원/비회원 동일 응답. 비회원이 5회 소진 후에도 피드 자체는 계속 조회 가능. + +--- + +#### POST `/api/immersive-votes/{voteId}/participate` — 투표 참여 / 취소 + +**Request** + +| 구분 | 파라미터 | 타입 | 필수 | 설명 | +|------|----------|------|------|------| +| Path | `voteId` | `Long` | 필수 | 투표 ID | +| Body | `optionId` | `Long` | 필수 | 선택할 옵션 ID. 이미 같은 옵션 선택 상태면 취소 처리 | + +**Response (참여 / 변경)** + +```json +{ + "voteId": 1, + "action": "VOTED", + "selectedOptionId": 10, + "options": [ + { "optionId": 10, "label": "스윙칩만 3달 먹기", "voteCount": 99, "ratio": 76 }, + { "optionId": 11, "label": "스윙스한테 30만원 주기", "voteCount": 32, "ratio": 24 } + ], + "remainingFreeVotes": 2 +} +``` + +**Response (취소 — 같은 옵션 재클릭)** + +```json +{ + "voteId": 1, + "action": "CANCELED", + "selectedOptionId": null, + "options": [ + { "optionId": 10, "label": "스윙칩만 3달 먹기", "voteCount": null, "ratio": null }, + { "optionId": 11, "label": "스윙스한테 30만원 주기", "voteCount": null, "ratio": null } + ], + "remainingFreeVotes": 2 +} +``` + +> `action` 분기 +> - `VOTED`: 신규 투표 또는 다른 옵션으로 변경 (기존 자동 해제 후 신규 카운트) +> - `CANCELED`: 같은 옵션 재클릭 → 취소 → `voteCount`/`ratio` 다시 `null` 응답 + +> 비회원 무료 투표 차감 정책 +> - 신규 투표 (`VOTED` + 기존 미참여) → 차감 +> - 옵션 변경 (`VOTED` + 기존 참여) → 차감하지 않음 +> - 취소 (`CANCELED`) → 차감하지 않음. 단 재참여 시 다시 차감 +> - `remainingFreeVotes === 0` 상태에서 신규 투표 시도 → `403 VOTE_FREE_LIMIT_EXCEEDED` → 로그인 유도 팝업 + +--- + +#### GET `/api/immersive-votes/{voteId}/live` — 실시간 비율 폴링 + +**Response** + +```json +{ + "options": [ + { "optionId": 10, "voteCount": 102, "ratio": 78 }, + { "optionId": 11, "voteCount": 29, "ratio": 22 } + ], + "currentViewerCount": 14, + "totalParticipantCount": 131 +} +``` + +> 투표 후 (`myVote.voted: true`) 에만 호출. 옵션 비율 filled bar 애니메이션 갱신용. 폴링 주기는 프론트와 5초 합의. +> `currentViewerCount` 10명 이상일 때 2분마다 5초간 토스트 ("현재 N명이 참여중이에요!") 노출. 토스트 노출 조건 판단은 프론트. + +--- + +#### PUT `/api/immersive-votes/{voteId}/emoji` — 이모지 반응 + +일반형 `PUT /api/votes/{voteId}/emoji`와 동일한 시그니처. 응답에 `total` 필드 포함. + +> 플로팅 애니메이션은 프론트 처리 (선택 즉시 화면 하단 → 상단 이동, 3초 후 자동 사라짐). 백엔드는 카운트만 갱신. + +--- + +#### GET `/api/immersive-votes/{voteId}/share` — 공유 링크 생성 + +```json +{ + "shareUrl": "https://vs.app/poll/12345", + "title": "논쟁 끝판왕 밸런스게임", + "thumbnailUrl": "https://cdn.example.com/votes/1/thumb.jpg" +} +``` + +--- + +#### GET `/api/me/free-votes` — 비회원 잔여 무료 투표권 + +**Response** + +```json +{ "remainingFreeVotes": 2, "totalFreeVotes": 5 } +``` + +> 비회원 무료 투표권은 voteId별이 아닌 전역 카운트로 관리. 앱 진입 시 1회 호출하여 잔여 횟수 캐싱. 이후는 `participate` 응답값으로 동기화. 회원이 호출 시 `remainingFreeVotes: null`. + +--- + +## 3. WebSocket (STOMP) + +> 채팅 명세와 동일 엔드포인트 재사용: `ws://.../ws` + +| 구분 | 경로 | 방향 | 설명 | +|------|------|------|------| +| 몰입형 실시간 비율 | `/topic/immersive-vote/{voteId}/live` | 수신 ← 서버 | 비율 / 뷰어 수 변동 시 푸시 | + +**수신 Payload (`/topic/immersive-vote/{voteId}/live`):** + +```json +{ + "options": [ + { "optionId": 10, "voteCount": 102, "ratio": 78 }, + { "optionId": 11, "voteCount": 29, "ratio": 22 } + ], + "currentViewerCount": 14, + "totalParticipantCount": 131 +} +``` + +> 초기 구현은 `GET /api/immersive-votes/{voteId}/live` 폴링으로 시작. 동시접속자 증가 시 WebSocket 전환. + +--- + +## 4. 상수 정의 + +> 프론트 요청 사항 반영. `status` 외 응답에 등장하는 모든 enum 상수 정리. + +### VoteStatus — 투표 진행 상태 + +| 값 | 설명 | +|----|------| +| `ONGOING` | 진행 중. `now() < endAt` | +| `ENDED` | 종료됨. `now() >= endAt` | + +> 응답 예시: `GET /api/votes/{voteId}` 의 `status` 필드, `GET /api/chats?status=ONGOING` 쿼리 파라미터 등에서 동일하게 사용. + +### VoteType — 투표 타입 + +| 값 | 설명 | +|----|------| +| `GENERAL` | 일반형 투표 | +| `IMMERSIVE` | 몰입형 투표 | + +> `POST /api/votes` 요청 body에서 사용. 이외에 응답에는 직접 노출되지 않음 (URL prefix로 구분: `/api/votes` vs `/api/immersive-votes`). + +### VoteDuration — 투표 기간 + +| 값 | 시간 | 설명 | +|----|------|------| +| `HOURS_12` | 12시간 | 생성 시점부터 12시간 후 종료 | +| `HOURS_24` | 24시간 | 생성 시점부터 24시간 후 종료 | + +> `POST /api/votes` 요청에서는 `durationHours`로 `12` 또는 `24` int 값 전달. 서버 내부에서 enum 변환. + +### VoteEmoji — 이모지 반응 종류 + +| 값 | 설명 | +|----|------| +| `LIKE` | 좋아요 | +| `SAD` | 슬픔 | +| `ANGRY` | 분노 | +| `WOW` | 놀람 | + +> `PUT /emoji` 요청 body에서 `null` 전송 시 반응 취소. + +### ImmersiveVoteAction — 몰입형 참여 응답 액션 + +| 값 | 설명 | +|----|------| +| `VOTED` | 신규 투표 또는 옵션 변경 | +| `CANCELED` | 같은 옵션 재클릭으로 인한 취소 | + +### InsightScope — 결과 인사이트 분석 범위 + +| 값 | 설명 | +|----|------| +| `MY_SELECTION` | 회원 + 참여O. 본인이 선택한 옵션 기준 분석 | +| `TOTAL` | 회원 + 참여X. 전체 참여자 기준 분석 | +| `null` | 비회원. `insight.locked: true`와 함께 응답 | + +### AgeGroup — 결과 인사이트 연령대 + +| 값 | 설명 | +|----|------| +| `10s` | 10대 | +| `20s` | 20대 | +| `30s` | 30대 | +| `40s` | 40대 | +| `50s_PLUS` | 50대 이상 | + +--- + +## 5. 비회원 식별 (쿠키) + +본 프로젝트는 서비스 확장성과 배포 유연성을 위해 **크로스 도메인(CORS) 환경에서도 동작하는 비회원 식별 로직**을 채택. + +### 5-1. 식별 메커니즘 + +- 방식: 백엔드에서 발급하는 `HttpOnly` 쿠키 기반 UUID 식별 +- 식별자: `anonymous_id` (UUID v4) +- 도메인이 분리된 환경 (Vercel ↔ EC2)과 통합 환경 모두에서 쿠키 정상 송수신 + +### 5-2. 쿠키 스펙 + +| 항목 | 설정값 | 비고 | +|------|--------|------| +| 이름 | `anonymous_id` | 비회원 식별용 고유 ID | +| HttpOnly | `true` | JS에서 쿠키 접근 불가 | +| Secure | `true` | HTTPS 환경에서만 전송 | +| SameSite | `None` | 도메인이 달라도 쿠키 전송 허용 | +| Max-Age | 1년 | 재방문 시에도 동일 사용자 식별 | + +### 5-3. 프론트엔드 필수 설정 + +도메인이 다른 환경에서도 쿠키가 저장 / 전달되려면 `credentials` 옵션 활성화 필수. + +```javascript +// Axios — 전역 설정 권장 +axios.defaults.withCredentials = true; + +// Fetch +fetch('https://api.example.com/data', { credentials: 'include' }); +``` + +### 5-4. 비즈니스 로직 + +- **자동 발급:** 프론트는 별도 호출 불필요. 모든 API 응답에서 백엔드가 체크 후 발급 +- **우선순위:** `Authorization` 헤더 (JWT) 있으면 회원 처리. JWT 없으면 `anonymous_id` 쿠키로 비회원 처리 +- **투표 제한:** 비회원은 UUID 기준 최대 5회 무료 투표. 초과 시 `403 VOTE_FREE_LIMIT_EXCEEDED` + +> HTTPS 필수. `SameSite=None`은 보안상 HTTPS 환경에서만 작동. 로컬 (`localhost`)은 브라우저 예외 허용. + +--- + +## 6. 권한 정책 + +| 상황 | 처리 | +|------|------| +| 비회원 → 투표 업로드 | `401` | +| 비회원 → 투표 상세 조회 | 허용 | +| 비회원 → 투표 참여 (1~5회, 신규) | 허용. 매 회 차감 후 응답에 잔여 횟수 포함 | +| 비회원 → 옵션 변경 / 취소 | 허용. 차감하지 않음 | +| 비회원 → 투표 참여 (5회 소진 후 신규) | `403 VOTE_FREE_LIMIT_EXCEEDED` → 로그인 유도 팝업 | +| 비회원 → 이모지 반응 | 허용 | +| 비회원 → 공유 | 허용 | +| 비회원 → 결과 조회 | 허용 (`insight.locked: true`) | +| 비회원 → 잠금 해제 | 로그인 페이지 랜딩 (프론트 처리). 로그인 후 재진입 시 `insight.locked: false` | +| 비회원 → 채팅 진입 | 채팅 명세 권한 정책 참조 | +| 회원 → 투표 업로드 | 허용 | +| 회원 → 미참여 결과 조회 | 허용 (`scope: TOTAL`) | +| 회원 → 참여O 결과 조회 | 허용 (`scope: MY_SELECTION`) | +| 회원 → 투표 후 다시투표하기 | 허용. ENDED 상태에서는 `403 VOTE_ENDED` | +| 진행 중 투표 → `/result` 호출 | `403 VOTE_NOT_ENDED` | +| 투표 종료 (ENDED) → 참여 / 취소 | `403 VOTE_ENDED` | + +--- + +## 7. 에러 코드 + +| 코드 | HTTP | 설명 | +|------|------|------| +| `VOTE_NOT_FOUND` | 404 | 존재하지 않는 투표 | +| `VOTE_ENDED` | 403 | 종료된 투표에 대한 참여 / 취소 시도 | +| `VOTE_NOT_ENDED` | 403 | 진행 중 투표에 결과 API 호출 | +| `VOTE_FREE_LIMIT_EXCEEDED` | 403 | 비회원 무료 투표 5회 초과 | +| `INVALID_OPTION` | 400 | 해당 투표에 속하지 않은 optionId | +| `INVALID_EMOJI` | 400 | 정의되지 않은 이모지 타입 | +| `INVALID_DURATION` | 400 | `durationHours` 값이 12 또는 24가 아님 | +| `IMAGE_REQUIRED` | 400 | `type === IMMERSIVE`인데 `imageUrl` 누락 | +| `IMAGE_LOAD_FAILED` | - | 백엔드 에러 아님. 프론트가 placeholder 노출 | +| `VOTE_SUBMIT_FAILED` | 500 | "투표에 실패했어요" 토스트 (2초) | +| `EMOJI_SUBMIT_FAILED` | 500 | "이모지 반응에 실패했어요" 토스트 (2초) | +| `SHARE_LINK_GENERATION_FAILED` | 500 | "공유에 실패했어요" 토스트 (2초) | +| `AI_INSIGHT_GENERATION_FAILED` | - | 에러 아님. `aiInsight.available: false`로 응답 | + +--- + +## 8. 구현 우선순위 + +| 단계 | 내용 | +|------|------| +| 1단계 | Vote / VoteOption / VoteParticipation 도메인 흡수 (채팅 mock 대체) + 비회원 쿠키 식별 인프라 | +| 2단계 | 일반형 투표 REST API (업로드 / 조회 / 참여 / 취소 / 이모지) | +| 3단계 | 몰입형 투표 REST API (피드 / 참여·취소 / 이모지 / 공유) + GuestFreeVote 카운트 | +| 4단계 | 결과 화면 REST API (`/result`, `/share`) + Insight 분석 쿼리 | +| 5단계 | 투표 종료 스케줄러 (ENDED 상태 전환만. 알림 발송은 별도 명세) | +| 6단계 | WebSocket 몰입형 실시간 비율 (`/topic/immersive-vote/{voteId}/live`) — 폴링 → 실시간 전환 | +| 7단계 | AI Insight 캐싱 / 비동기 생성 | + +--- + +## 9. 채팅 도메인 협업 사항 + +> =채팅 명세 (`chat-api-spec.md`, `chat-implementation-plan.md`)에서 본 도메인을 참조하는 부분 정리. + +- mock으로 만든 `vote/domain/Vote.java`, `VoteParticipation.java`, `VoteRepository.java`, `VoteParticipationRepository.java`는 **본인이 흡수**하여 본 명세의 실제 구현으로 대체 +- 채팅 도메인은 본인이 노출하는 inbound port `VoteQueryUseCase`에 의존 (헥사고날 + DDD 정합성 유지) +- 노출 시그니처: + ```java + public interface VoteQueryUseCase { + boolean isParticipated(Long voteId, Long userId); + VoteSummary getVoteSummary(Long voteId); + VoteRatio getRatio(Long voteId); + int getParticipantCount(Long voteId); + } + ``` +- `ChatCommandService`는 `VoteParticipationRepository.existsByVoteIdAndUserId(...)` 직접 호출에서 → `VoteQueryUseCase.isParticipated(...)`로 의존성 변경 (자세한 협의 내역은 PR 코멘트 참조) + +--- + +## 10. 별도 명세로 분리된 항목 + +다음 기능은 사전 합의 후 별도 명세로 진행: + +- **알림 / PUSH 토큰 등록** — 본 서비스가 PWA 기반이므로 네이티브 FCM/APNs 토큰 방식이 아닌 **Web Push API + VAPID** 기반으로 재설계 필요. iOS 16.4 + 홈 화면 추가 제약 등 사용자 경험 정책을 기획자 / 프론트와 사전 합의 후 명세 작성. +- **이미지 업로드** (`POST /api/uploads/image`) — Pre-signed URL 방식 / 직접 업로드 방식 결정 후 명세.