feat: 동시성 + UX + 결과 백업 보강

#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_<timestamp>.json 저장
- DB 손실 보험. 위치: /data 볼륨

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-04-25 23:42:36 +09:00
parent 638f0b36c8
commit 3f40f3f47a

61
app.py
View File

@@ -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: