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: