From e3f5087a0ea62fb416ae9a1e0d3f78dfb01a50e1 Mon Sep 17 00:00:00 2001 From: leesumok Date: Sat, 2 May 2026 03:11:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(backend):=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=B0=B8=EC=97=AC=EC=9E=90=20=EC=86=8C?= =?UTF-8?q?=EC=A7=84=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/rooms.py | 48 +++++++++++++--- backend/app/services/room.py | 29 ++++++++++ backend/app/services/vote.py | 14 +++-- .../test_restricted_vote_participants.py | 55 +++++++++++++++++++ 4 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_restricted_vote_participants.py diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index 2315e4f..97f60a3 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -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 @@ -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="제목 검색"), @@ -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") @@ -100,6 +120,7 @@ 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="참여자를 선택해주세요") @@ -107,6 +128,10 @@ async def vote(room_uuid: str, vote_request: VoteRequest, request: Request): 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) @@ -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": "투표가 완료되었습니다"} diff --git a/backend/app/services/room.py b/backend/app/services/room.py index ce653c1..0e5e805 100644 --- a/backend/app/services/room.py +++ b/backend/app/services/room.py @@ -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(), @@ -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") @@ -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() diff --git a/backend/app/services/vote.py b/backend/app/services/vote.py index dbb9d67..4369757 100644 --- a/backend/app/services/vote.py +++ b/backend/app/services/vote.py @@ -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: @@ -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() @@ -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) diff --git a/backend/tests/test_restricted_vote_participants.py b/backend/tests/test_restricted_vote_participants.py new file mode 100644 index 0000000..b12d673 --- /dev/null +++ b/backend/tests/test_restricted_vote_participants.py @@ -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} From 76205e1a2d3216f6febb04e863a6d45818871afe Mon Sep 17 00:00:00 2001 From: leesumok Date: Sat, 2 May 2026 03:11:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(frontend):=20=EB=82=A8=EC=9D=80=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=EC=9E=90=20=EA=B8=B0=EC=A4=80=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=EC=84=A0=ED=83=9D=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/vote/[uuid]/vote-client.tsx | 77 ++++++++++++++++++------ frontend/lib/api.ts | 2 + 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/frontend/app/vote/[uuid]/vote-client.tsx b/frontend/app/vote/[uuid]/vote-client.tsx index b2f6dee..f3f6e23 100644 --- a/frontend/app/vote/[uuid]/vote-client.tsx +++ b/frontend/app/vote/[uuid]/vote-client.tsx @@ -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 }); @@ -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'); @@ -237,7 +244,7 @@ export function VoteClient({ params }: PageProps) { setVoteError(''); const fingerprint = getFingerprint(); - if ((room?.participants?.length ?? 0) > 0 && !selectedParticipant) { + if (isRestrictedRoom(room) && !selectedParticipant) { setVoteError('참여자를 선택해주세요.'); setIsParticipantModalOpen(true); return; @@ -245,6 +252,19 @@ export function VoteClient({ params }: PageProps) { 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 @@ -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); @@ -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; @@ -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; @@ -502,19 +535,25 @@ export function VoteClient({ params }: PageProps) {

- {room.participants?.map((participant) => ( - - ))} + {selectableParticipants.length > 0 ? ( + selectableParticipants.map((participant) => ( + + )) + ) : ( +
+ 남은 참여자가 없습니다. +
+ )}
- {selectedParticipant && ( + {(selectedParticipant || selectableParticipants.length === 0) && (