From aac609eb59a93ff667d3fdc01fe82c3172f270df Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sat, 25 Apr 2026 20:07:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EB=B2=88=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20+=20=EA=B0=90=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20(=EC=82=AC?= =?UTF-8?q?=EC=B9=AD=20=EC=B6=94=EC=A0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 투표 폼에 사번 입력 필수 추가 - DB votes 테이블에 employee_id 컬럼 (마이그레이션 자동) - 어드민 감사 로그 expander: 시각/이름/사번/본인팀/투표내역 표 - CSV 내려받기 버튼 - 같은 사번이 여러 이름으로 투표 시 자동 의심 마크 - 안내 expander에 사번 입력/추적 설명 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- app.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 248deb4..172cf9e 100644 --- a/app.py +++ b/app.py @@ -49,6 +49,7 @@ def get_conn(): CREATE TABLE IF NOT EXISTS votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, voter_name TEXT NOT NULL UNIQUE, + employee_id TEXT, voter_team TEXT NOT NULL, fun_team TEXT NOT NULL, polish_team TEXT NOT NULL, @@ -57,6 +58,10 @@ def get_conn(): ) """ ) + # 기존 DB 마이그레이션 + cols = [r[1] for r in conn.execute("PRAGMA table_info(votes)").fetchall()] + if "employee_id" not in cols: + conn.execute("ALTER TABLE votes ADD COLUMN employee_id TEXT") conn.execute( """ CREATE TABLE IF NOT EXISTS team_titles ( @@ -131,9 +136,11 @@ def render_voter(): st.markdown( """ **투표 방식** -- 본인 이름 선택 → 본인 팀 자동 매핑 +- 본인 이름 선택 + **본인 사번 입력** +- 본인 팀 자동 매핑 - 본인 팀 **제외**, 다른 6팀 중 각 분야별 1팀씩 투표 (총 3표) - **한 번만 제출 가능** (이름 unique) +- 사번은 사칭 의심 시 추적용으로 기록됨 (본인 이름이 아닌데 투표한 경우 사번 주인에게 확인) **수상 결정 — 1팀 1상 한정** - 우선순위: 🛠 **실용성상 (팜레스트)** > 🏆 **완성도상 (양우산)** > 🎉 **재미상 (손선풍기)** @@ -158,10 +165,17 @@ def render_voter(): index=None, placeholder="이름 선택", ) + employee_id = st.text_input( + "사번", + placeholder="본인 사번 입력 (사칭 추적용으로 기록됨)", + ) if not name: st.info("이름을 선택하면 본인 팀이 자동 표시됩니다.") return + if not employee_id.strip(): + st.warning("사번 입력 후 진행하세요.") + return my_team = PARTICIPANTS[name] titles = get_titles() @@ -191,11 +205,12 @@ def render_voter(): conn.execute( """ INSERT INTO votes - (voter_name, voter_team, fun_team, polish_team, utility_team, created_at) - VALUES (?, ?, ?, ?, ?, ?) + (voter_name, employee_id, voter_team, fun_team, polish_team, utility_team, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) """, ( name, + employee_id.strip(), my_team, picks["fun_team"], picks["polish_team"], @@ -300,6 +315,50 @@ def render_admin(): st.subheader("🎤 시상식 발표용 (복사해서 화면 공유)") st.code("\n".join(public_lines), language="markdown") + st.divider() + with st.expander("🔍 감사 로그 (사칭 추적용)"): + st.caption("이름, 사번, 시각, 투표 내역. 의심 시 사번 주인에게 확인.") + rows = conn.execute( + "SELECT created_at, voter_name, employee_id, voter_team, " + "fun_team, polish_team, utility_team FROM votes ORDER BY created_at" + ).fetchall() + if not rows: + st.caption("투표 없음") + else: + import io + import csv + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(["시각", "이름", "사번", "본인팀", "재미", "완성도", "실용성"]) + for r in rows: + w.writerow(r) + st.download_button("CSV 내려받기", buf.getvalue(), file_name="audit.csv") + st.dataframe( + { + "시각": [r[0] for r in rows], + "이름": [r[1] for r in rows], + "사번": [r[2] or "" for r in rows], + "본인팀": [r[3] for r in rows], + "재미": [r[4] for r in rows], + "완성도": [r[5] for r in rows], + "실용성": [r[6] for r in rows], + }, + use_container_width=True, + hide_index=True, + ) + + # 사번 중복 의심 (같은 사번이 여러 이름으로 투표) + from collections import defaultdict + by_emp = defaultdict(list) + for r in rows: + if r[2]: + by_emp[r[2]].append(r[1]) + dups = {emp: names for emp, names in by_emp.items() if len(set(names)) > 1} + if dups: + st.error("⚠️ 같은 사번이 여러 이름으로 투표한 케이스:") + for emp, names in dups.items(): + st.write(f"- 사번 `{emp}`: {', '.join(names)}") + st.divider() with st.expander("⚠️ 위험 작업"): if st.button("모든 투표 삭제 (되돌릴 수 없음)"):