From 3f40f3f47adc72c38503fae48327922ee9dfe9db Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sat, 25 Apr 2026 23:42:36 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20+=20UX=20?=
=?UTF-8?q?+=20=EA=B2=B0=EA=B3=BC=20=EB=B0=B1=EC=97=85=20=EB=B3=B4?=
=?UTF-8?q?=EA=B0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#14 이미 투표한 사람 UX
- selectbox에 ✅ 마크
- 선택 시 친절한 에러 (재투표 불가 안내, 진행자 문의 가이드)
#15 SQLite WAL 모드
- get_conn에서 PRAGMA journal_mode = WAL
- synchronous = NORMAL (성능 + 안전 균형)
- 동시 read/write 충돌 방지 (35명 동시 제출 안전)
- timeout 10초 (busy 시 retry)
#17 시상 결과 archive
- ceremony 진입 시 1회 winners를 results_.json 저장
- DB 손실 보험. 위치: /data 볼륨
Co-Authored-By: Claude Opus 4.7 (1M context)
---
app.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 60 insertions(+), 1 deletion(-)
diff --git a/app.py b/app.py
index bf4f3c8..500773b 100644
--- a/app.py
+++ b/app.py
@@ -43,7 +43,10 @@ PRIZE_PRIORITY = ["utility_team", "polish_team", "fun_team"]
def get_conn():
- conn = sqlite3.connect(DB_PATH)
+ conn = sqlite3.connect(DB_PATH, timeout=10)
+ # WAL 모드: 동시 read/write 충돌 ↓
+ conn.execute("PRAGMA journal_mode = WAL")
+ conn.execute("PRAGMA synchronous = NORMAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS votes (
@@ -269,11 +272,22 @@ def render_voter():
st.error("참가자 명단이 없습니다. `assign_teams.py` 먼저 실행하세요.")
return
+ # 이미 투표한 사람 미리 조회 → selectbox에 ✅ 표시
+ conn = get_conn()
+ voted_set = {
+ r[0] for r in conn.execute("SELECT voter_name FROM votes").fetchall()
+ }
+ conn.close()
+
+ def fmt_name(n):
+ return f"{n} ✅ (이미 투표함)" if n in voted_set else n
+
name = st.selectbox(
"본인 이름",
options=sorted(PARTICIPANTS.keys()),
index=None,
placeholder="이름 선택",
+ format_func=fmt_name,
)
employee_id = st.text_input(
"사번",
@@ -284,6 +298,12 @@ def render_voter():
if not name:
st.info("이름을 선택하면 본인 팀이 자동 표시됩니다.")
return
+ if name in voted_set:
+ st.error(
+ f"❌ {name}님은 이미 투표하셨습니다. 한 번만 가능합니다. "
+ "다른 사람으로 잘못 누른 경우 진행자에게 문의하세요."
+ )
+ return
if not employee_id.strip():
st.warning("사번 입력 후 진행하세요.")
return
@@ -559,6 +579,39 @@ def render_admin():
conn.close()
+def archive_results():
+ """결과를 timestamped JSON 파일로 저장 (DB 손실 보험)."""
+ winners, _ = compute_winners()
+ if any(w.get("status") != "ok" for w in winners.values()):
+ return None
+ titles = get_titles()
+ data = {
+ "timestamp": datetime.now().isoformat(timespec="seconds"),
+ "results": [],
+ }
+ for col, label, prize in CATEGORIES:
+ w = winners.get(col)
+ if w and w.get("status") == "ok":
+ data["results"].append(
+ {
+ "category": label,
+ "prize": prize,
+ "team": w["team"],
+ "title": titles.get(w["team"], ""),
+ "votes": w["votes"],
+ "diff_2nd": w["diff"],
+ "method": w.get("method") or "majority",
+ }
+ )
+ archive_dir = os.path.dirname(DB_PATH) or "."
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+ path = os.path.join(archive_dir, f"results_{ts}.json")
+ Path(path).write_text(
+ json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ return path
+
+
def render_ceremony():
"""시상식 reveal 페이지. 진행자가 클릭으로 단계별 공개."""
token = st.query_params.get("token", "")
@@ -595,6 +648,12 @@ def render_ceremony():
st.warning("⏳ 시상 준비 중입니다. 잠시만 기다려주세요.")
return
+ # 진입 시 1회 archive (DB 손실 보험)
+ if not st.session_state.get("ceremony_archived"):
+ archived_path = archive_results()
+ if archived_path:
+ st.session_state.ceremony_archived = archived_path
+
# CATEGORIES 순서로 reveal (손선풍기 → 양우산 → 팜레스트)
results = []
for col, label, prize in CATEGORIES: