Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions backend/app/routers/rooms.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from fastapi import APIRouter, HTTPException, Query, Request

from app.models.schemas import RoomCreate, VoteRequest, PasswordVerifyRequest, SortOrder, RoomListResponse, CommentCreate, Comment
from app.services.room import create_room, get_room, get_vote_results, get_room_list, is_room_expired
from app.services.room import (
create_room,
get_remaining_participants,
get_room,
get_room_list,
get_vote_results,
is_room_expired,
)
from app.services.vote import has_voted, cast_vote
from app.services.comment import create_comment, get_comments
from app.utils.security import verify_password
Expand All @@ -11,6 +18,24 @@
router = APIRouter(prefix="/rooms", tags=["rooms"])


def serialize_room_response(room: dict) -> dict:
"""클라이언트에는 제한 투표의 남은 참여자만 노출한다."""
response = room.copy()
response.pop("password_hash", None)
# Do not expose share_token on GET room
response.pop("share_token", None)
response["is_expired"] = is_room_expired(room)

is_restricted = bool(room.get("participants", []))
response["is_restricted"] = is_restricted
if is_restricted:
remaining_participants = get_remaining_participants(room)
response["participants"] = remaining_participants
response["remaining_participants"] = remaining_participants

return response


@router.get("", response_model=RoomListResponse)
async def list_rooms(
search: str | None = Query(None, description="제목 검색"),
Expand Down Expand Up @@ -52,12 +77,7 @@ async def get_room_info(room_uuid: str):
if not room:
raise HTTPException(status_code=404, detail="투표방을 찾을 수 없습니다")

response = room.copy()
response.pop("password_hash", None)
# Do not expose share_token on GET room
response.pop("share_token", None)
response["is_expired"] = is_room_expired(room)
return response
return serialize_room_response(room)


@router.post("/{room_uuid}/verify")
Expand Down Expand Up @@ -100,13 +120,18 @@ async def vote(room_uuid: str, vote_request: VoteRequest, request: Request):
raise HTTPException(status_code=400, detail="이 투표는 복수 선택이 허용되지 않습니다")

participants = room.get("participants", [])
participant_to_remove = None
if participants:
if not vote_request.participant:
raise HTTPException(status_code=400, detail="참여자를 선택해주세요")

if vote_request.participant not in participants:
raise HTTPException(status_code=400, detail="참여 인원에 없는 이름입니다")

remaining_participants = get_remaining_participants(room)
if vote_request.participant not in remaining_participants:
raise HTTPException(status_code=409, detail="이미 투표한 참여자입니다")

option_allowed_participants = room.get("option_allowed_participants", [])
for option in vote_request.options:
option_index = room["options"].index(option)
Expand All @@ -120,12 +145,19 @@ async def vote(room_uuid: str, vote_request: VoteRequest, request: Request):
status_code=403,
detail=f"{vote_request.participant}님은 선택할 수 없는 옵션입니다: {option}",
)
participant_to_remove = vote_request.participant

client_ip = request.client.host
if await has_voted(room_uuid, vote_request.fingerprint, client_ip):
raise HTTPException(status_code=409, detail="이미 투표하셨습니다")

await cast_vote(room_uuid, vote_request.options, vote_request.fingerprint, client_ip)
await cast_vote(
room_uuid,
vote_request.options,
vote_request.fingerprint,
client_ip,
participant_to_remove,
)
await broadcast_results(room_uuid)

return {"success": True, "message": "투표가 완료되었습니다"}
Expand Down
29 changes: 29 additions & 0 deletions backend/app/services/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ async def create_room(
"title": title,
"options": options,
"participants": participants,
"remaining_participants": participants.copy(),
"option_allowed_participants": option_allowed_participants,
"created_at": created_at.isoformat(),
"expires_at": expires_at.isoformat(),
Expand Down Expand Up @@ -86,6 +87,14 @@ async def get_room(room_uuid: str) -> dict | None:
return None


def get_remaining_participants(room: dict) -> list[str]:
"""아직 투표하지 않은 제한 투표 참여자 목록"""
remaining_participants = room.get("remaining_participants")
if isinstance(remaining_participants, list):
return remaining_participants
return room.get("participants", [])


def is_room_expired(room: dict) -> bool:
"""expires_at 기준으로 투표 마감 여부 확인"""
expires_at_str = room.get("expires_at")
Expand Down Expand Up @@ -199,6 +208,26 @@ async def _cleanup_expired_rooms(expired_uuids: list[str]) -> None:
# (방 정보가 이미 삭제되어 태그 정보를 알 수 없음)


async def record_room_vote(room_uuid: str, participant: str | None = None) -> None:
"""방의 총 투표수와 제한 투표 남은 참여자 목록 업데이트"""
redis = get_redis()
room = await get_room(room_uuid)
if room:
room["total_votes"] = room.get("total_votes", 0) + 1

if participant and room.get("participants"):
remaining_participants = get_remaining_participants(room)
room["remaining_participants"] = [
name for name in remaining_participants if name != participant
]

ttl = await redis.ttl(f"room:{room_uuid}")
if ttl > 0:
await redis.setex(f"room:{room_uuid}", ttl, json.dumps(room))
# 인기순 인덱스 업데이트
await redis.zadd("rooms:popular", {room_uuid: room["total_votes"]})


async def update_room_total_votes(room_uuid: str, increment: int = 1) -> None:
"""방의 총 투표수 업데이트"""
redis = get_redis()
Expand Down
14 changes: 10 additions & 4 deletions backend/app/services/vote.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from app.database import get_redis
from app.utils.security import generate_vote_hash
from app.services.room import update_room_total_votes
from app.services.room import record_room_vote


async def has_voted(room_uuid: str, fingerprint: str, ip: str) -> bool:
Expand All @@ -11,7 +11,13 @@ async def has_voted(room_uuid: str, fingerprint: str, ip: str) -> bool:
return await redis.exists(voted_key)


async def cast_vote(room_uuid: str, options: list[str], fingerprint: str, ip: str) -> None:
async def cast_vote(
room_uuid: str,
options: list[str],
fingerprint: str,
ip: str,
participant: str | None = None,
) -> None:
"""투표 기록 (복수 선택 지원)"""
redis = get_redis()

Expand All @@ -26,5 +32,5 @@ async def cast_vote(room_uuid: str, options: list[str], fingerprint: str, ip: st
if room_ttl > 0:
await redis.setex(voted_key, room_ttl, "1")

# 인기순 인덱스 업데이트 (참여자 수 기준)
await update_room_total_votes(room_uuid, 1)
# 인기순 인덱스 업데이트 및 제한 투표 참여자 소진 처리
await record_room_vote(room_uuid, participant)
55 changes: 55 additions & 0 deletions backend/tests/test_restricted_vote_participants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import json
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from app.services import room as room_service


class FakeRedis:
def __init__(self, room_data: dict):
self.store = {"room:room-1": json.dumps(room_data)}
self.ttls = {"room:room-1": 3600}
self.zsets = {}

async def get(self, key: str):
return self.store.get(key)

async def ttl(self, key: str):
return self.ttls.get(key, -1)

async def setex(self, key: str, ttl: int, value: str):
self.store[key] = value
self.ttls[key] = ttl

async def zadd(self, key: str, mapping: dict):
self.zsets[key] = mapping


def test_get_remaining_participants_falls_back_to_original_list():
room = {"participants": ["김철수", "이영희"]}

assert room_service.get_remaining_participants(room) == ["김철수", "이영희"]


@pytest.mark.asyncio
async def test_record_room_vote_removes_participant_from_remaining_list(monkeypatch):
room_data = {
"uuid": "room-1",
"participants": ["김철수", "이영희"],
"remaining_participants": ["김철수", "이영희"],
"total_votes": 0,
}
redis = FakeRedis(room_data)
monkeypatch.setattr(room_service, "get_redis", lambda: redis)

await room_service.record_room_vote("room-1", "김철수")

saved_room = json.loads(redis.store["room:room-1"])
assert saved_room["participants"] == ["김철수", "이영희"]
assert saved_room["remaining_participants"] == ["이영희"]
assert saved_room["total_votes"] == 1
assert redis.zsets["rooms:popular"] == {"room-1": 1}
77 changes: 58 additions & 19 deletions frontend/app/vote/[uuid]/vote-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export function VoteClient({ params }: PageProps) {

const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];

const getSelectableParticipants = (targetRoom: VoteRoom | null) =>
targetRoom?.remaining_participants ?? targetRoom?.participants ?? [];

const isRestrictedRoom = (targetRoom: VoteRoom | null) =>
Boolean(targetRoom?.is_restricted || (targetRoom?.participants?.length ?? 0) > 0);

// Toast helper
const showToast = (message: string) => {
setToast({ message, visible: true });
Expand All @@ -64,9 +70,10 @@ export function VoteClient({ params }: PageProps) {
try {
const data = await api.getRoom(uuid);
setRoom(data);
const hasParticipants = (data.participants?.length ?? 0) > 0;
const selectableParticipants = data.remaining_participants ?? data.participants ?? [];
const isRestrictedPoll = Boolean(data.is_restricted || selectableParticipants.length > 0);
setSelectedParticipant('');
setIsParticipantModalOpen(hasParticipants);
setIsParticipantModalOpen(isRestrictedPoll);

const params = new URLSearchParams(window.location.search);
const shareToken = params.get('share_token');
Expand Down Expand Up @@ -237,14 +244,27 @@ export function VoteClient({ params }: PageProps) {
setVoteError('');
const fingerprint = getFingerprint();

if ((room?.participants?.length ?? 0) > 0 && !selectedParticipant) {
if (isRestrictedRoom(room) && !selectedParticipant) {
setVoteError('참여자를 선택해주세요.');
setIsParticipantModalOpen(true);
return;
}

try {
await api.vote(uuid, selectedOptions, fingerprint, selectedParticipant || undefined);
setRoom((currentRoom) => {
if (!currentRoom || !selectedParticipant) return currentRoom;

const remainingParticipants = getSelectableParticipants(currentRoom).filter(
(participant) => participant !== selectedParticipant
);

return {
...currentRoom,
participants: remainingParticipants,
remaining_participants: remainingParticipants,
};
});
setState('voted');
showToast(t.voteCompleted);
// Confetti effect
Expand All @@ -256,7 +276,19 @@ export function VoteClient({ params }: PageProps) {
});
} catch (err) {
if (err instanceof APIError && err.status === 409) {
setVoteError(t.voteErrors.alreadyVoted);
setVoteError(err.message || t.voteErrors.alreadyVoted);
if (err.message === '이미 투표한 참여자입니다') {
try {
const latestRoom = await api.getRoom(uuid);
setRoom(latestRoom);
} catch (loadErr) {
console.error('Failed to refresh room after participant conflict:', loadErr);
}
setSelectedParticipant('');
setSelectedOptions([]);
setIsParticipantModalOpen(true);
return;
}
setState('voted');
} else if (err instanceof APIError) {
setVoteError(err.message || t.voteErrors.submitFailed);
Expand Down Expand Up @@ -335,11 +367,11 @@ export function VoteClient({ params }: PageProps) {
participant: string,
optionIndex: number
): boolean => {
if (!room || (room.participants?.length ?? 0) === 0) return true;
if (!room || !isRestrictedRoom(room)) return true;
if (!participant) return false;

const allowedParticipants = room.option_allowed_participants?.[optionIndex];
const fallbackParticipants = room.participants ?? [];
const fallbackParticipants = getSelectableParticipants(room);
const optionParticipants = Array.isArray(allowedParticipants)
? allowedParticipants
: fallbackParticipants;
Expand Down Expand Up @@ -482,7 +514,8 @@ export function VoteClient({ params }: PageProps) {
const winnerOption = getWinnerOption();
const isVoting = state === "voting";
const isVoted = state === "voted";
const isRestrictedPoll = (room.participants?.length ?? 0) > 0;
const selectableParticipants = getSelectableParticipants(room);
const isRestrictedPoll = isRestrictedRoom(room);
const shouldShowParticipantModal =
isVoting && isRestrictedPoll && isParticipantModalOpen;

Expand All @@ -502,19 +535,25 @@ export function VoteClient({ params }: PageProps) {
</p>
</div>
<div className="grid gap-2">
{room.participants?.map((participant) => (
<button
key={participant}
type="button"
onClick={() => handleParticipantSelect(participant)}
className="flex items-center justify-between rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left text-sm font-semibold text-zinc-800 transition-colors hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700 dark:border-white/10 dark:bg-white/5 dark:text-zinc-100 dark:hover:border-emerald-400/40 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200"
>
<span>{participant}</span>
<User size={16} className="text-zinc-400" />
</button>
))}
{selectableParticipants.length > 0 ? (
selectableParticipants.map((participant) => (
<button
key={participant}
type="button"
onClick={() => handleParticipantSelect(participant)}
className="flex items-center justify-between rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left text-sm font-semibold text-zinc-800 transition-colors hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700 dark:border-white/10 dark:bg-white/5 dark:text-zinc-100 dark:hover:border-emerald-400/40 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200"
>
<span>{participant}</span>
<User size={16} className="text-zinc-400" />
</button>
))
) : (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-5 text-sm font-medium text-zinc-500 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300">
남은 참여자가 없습니다.
</div>
)}
</div>
{selectedParticipant && (
{(selectedParticipant || selectableParticipants.length === 0) && (
<Button
type="button"
variant="ghost"
Expand Down
2 changes: 2 additions & 0 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export interface VoteRoom {
title: string;
options: string[];
participants?: string[];
remaining_participants?: string[];
option_allowed_participants?: string[][];
is_restricted?: boolean;
has_password: boolean;
created_at: string;
tags?: string[];
Expand Down
Loading