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) && (