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("모든 투표 삭제 (되돌릴 수 없음)"):