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:
61
app.py
61
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:
|
||||
|
||||
Reference in New Issue
Block a user