#!/usr/bin/env python3 """数苗打卡抽奖 — 开奖结果独立复算脚本(不依赖服务端任何代码)。 用法: 1. 打开验证页,下载「验证数据 .json」 2. python3 verify_draw.py shumiao-verify-*.json 复算三件事: - 快照哈希: sha256(canonical_json({"period": 期次, "groups": 组号, "members": [[组号,姓名,学号],...]})) (v1 老快照无 members 字段,自动兼容) - 承诺哈希: sha256("shumiao-commit-v1|期次|seed|快照哈希") 与冻结时公布的一致(防 seed 被换) - 中奖顺序: seed 经 HMAC-SHA256 熵流 + 拒绝采样无偏 Fisher-Yates 洗牌(以个人为奖池单位) """ import hashlib import hmac import json import sys def mask_name(n: str) -> str: n = (n or "").strip() if len(n) <= 1: return n if len(n) == 2: return n[0] + "*" return n[0] + "*" * (len(n) - 2) + n[-1] def canonical_snapshot(period_id: int, groups: list, members: list | None = None) -> str: d = {"period": period_id, "groups": sorted(groups)} if members is not None: d["members"] = sorted(members) return json.dumps(d, ensure_ascii=False, separators=(",", ":")) def snapshot_hash(period_id: int, groups: list, members: list | None = None) -> str: return hashlib.sha256(canonical_snapshot(period_id, groups, members).encode("utf-8")).hexdigest() def make_commitment(period_id: int, seed_hex: str, snap_hash: str) -> str: msg = f"shumiao-commit-v1|{period_id}|{seed_hex}|{snap_hash}" return hashlib.sha256(msg.encode("utf-8")).hexdigest() def _stream(seed_hex: str): seed = bytes.fromhex(seed_hex) i = 0 while True: block = hmac.new(seed, f"shumiao-draw-{i}".encode("utf-8"), hashlib.sha256).digest() for off in range(0, 32, 8): yield int.from_bytes(block[off:off + 8], "big") i += 1 def _rand_below(gen, n: int) -> int: limit = (1 << 64) - ((1 << 64) % n) while True: v = next(gen) if v < limit: return v % n def permutation(items: list, seed_hex: str) -> list: a = sorted(items) gen = _stream(seed_hex) for i in range(len(a) - 1, 0, -1): j = _rand_below(gen, i + 1) a[i], a[j] = a[j], a[i] return a def main(path: str) -> int: d = json.load(open(path, encoding="utf-8")) need = ["period_id", "groups", "seed_hex", "commitment", "snapshot_hash"] missing = [k for k in need if k not in d] if missing: print(f"缺少字段: {missing}"); return 2 pid = d["period_id"] groups = [g["group_no"] if isinstance(g, dict) else g for g in d["groups"]] members = d.get("snapshot", {}).get("members") ok = True h = snapshot_hash(pid, groups, members) if h == d["snapshot_hash"]: print(f"[PASS] 快照哈希一致: {h}") else: print(f"[FAIL] 快照哈希不一致: 本地 {h} vs 公布 {d['snapshot_hash']}"); ok = False if not d["seed_hex"]: print("[INFO] 本期尚未开奖: seed 未公布,暂时只能核对快照哈希。开奖后重跑本脚本即可完整验证。") print("结论: " + ("快照核对通过 ✔" if ok else "快照不一致 ✘")) return 0 if ok else 1 seed_hex = d["seed_hex"] c = make_commitment(pid, seed_hex, d["snapshot_hash"]) if c == d["commitment"]: print(f"[PASS] 承诺哈希一致(冻结前公布的 commitment 确由该 seed 生成): {c}") else: print(f"[FAIL] 承诺哈希不一致: seed 与冻结时公布的 commitment 对不上"); ok = False pool = members if members else groups # v1 老快照:奖池单位是组 order = permutation(pool, seed_hex) total = sum(t["count"] for t in d.get("tiers", [])) print(f"[INFO] 洗牌后完整顺序(前 {max(total, len(order))} 名):") for i, item in enumerate(order): if members: g, name, sid = item print(f" {i + 1:>3}. 第{g}组 {mask_name(name)}({sid})") else: print(f" {i + 1:>3}. {item}号组") if d.get("winners"): expect = order[:len(d["winners"])] if members: expect = [(g, sid) for g, _n, sid in expect] got = [(w["group_no"], w.get("student_id", "")) for w in d["winners"]] else: expect = list(expect) got = [w["group_no"] for w in d["winners"]] if expect == got: print(f"[PASS] 中奖顺序与公布结果完全一致") else: print(f"[FAIL] 中奖顺序不一致: 本地 {expect} vs 公布 {got}"); ok = False print("结论: " + ("全部通过,开奖结果可信 ✔" if ok else "存在不一致,请向管理员反映 ✘")) return 0 if ok else 1 if __name__ == "__main__": if len(sys.argv) != 2: print(__doc__) sys.exit(2) sys.exit(main(sys.argv[1]))