#!/usr/bin/env python3 # -*- coding: utf-8 -*- """同步活动报名「真实姓名」→ 用户昵称(增量脚本,手动不定时运行)。 背景 ---- 活动报名时多数用户未在 bookings.name 填真实姓名(默认占位「微信用户」), 真实姓名存于报名表单 form_data_json 的「姓名」字段(events.form_fields_json 中 label=「姓名」的字段)。本脚本按 bookings.username = users.username 关联用户,把报名真实姓名写入 users.nickname。 增量机制 -------- - state 文件(默认 scripts/.sync_event_name_state.json)记录每个活动上次 同步到的最大 created_at;本次只处理 created_at 大于游标的新增报名。 - 同一用户多条报名只取最新一条。 - 幂等:昵称已等于目标姓名的不重复写。 - --full:忽略游标全量重扫(新增历史报名补同步)。 覆盖策略 -------- - 默认只填充「空 / 占位昵称」(空串、等于 username、等于『微信用户』)的 用户,不覆盖用户已设置的真实昵称。 - --overwrite:强制把昵称改为报名姓名(覆盖已有昵称)。 生产连接 -------- 优先用命令行参数;否则依次读取以下 .env 的 PINEAGENTS_DEMO_DATABASE_URL(或 PINEAGENTS_MYSQL_* 分字段): 1. serverrun/.env(生产部署目录,TenXun-Ubuntu2) 2. .env(项目根) 用法 ---- python scripts/sync_event_name_to_nickname.py --dry-run python scripts/sync_event_name_to_nickname.py python scripts/sync_event_name_to_nickname.py --full --overwrite --dry-run """ from __future__ import annotations import argparse import asyncio import json import os import re import sys from datetime import datetime, timezone from pathlib import Path try: from dotenv import load_dotenv except ImportError: # 无 dotenv 时跳过(连接参数必须显式给出) def load_dotenv(*_a, **_k): # noqa: ARG001 return False try: import asyncmy except ImportError: print("缺少依赖 asyncmy,请先安装:pip install asyncmy", file=sys.stderr) raise SystemExit(1) SCRIPT_DIR = Path(__file__).resolve().parent PROJECT_ROOT = SCRIPT_DIR.parent DEFAULT_EVENT_ID = "ETL33LOE" # OPC青年数字电商创训营 DEFAULT_STATE_FILE = SCRIPT_DIR / ".sync_event_name_state.json" PLACEHOLDER_NAMES = {"微信用户", "游客", "未填写", ""} def _load_db_config(args: argparse.Namespace) -> dict: """解析生产数据库连接:参数 > serverrun/.env > .env。""" if args.host: return {"host": args.host, "port": args.port, "user": args.user, "password": args.password, "db": args.db} for env_path in (PROJECT_ROOT / "serverrun" / ".env", PROJECT_ROOT / ".env"): if not env_path.exists(): continue load_dotenv(env_path) url = os.environ.get("PINEAGENTS_DEMO_DATABASE_URL", "") m = re.match(r"^\w+\+?\w*://([^:]+):([^@]+)@([^:/]+):(\d+)/([^?]+)", url) if m: return {"host": m.group(3), "port": int(m.group(4)), "user": m.group(1), "password": m.group(2), "db": m.group(5)} host = os.environ.get("PINEAGENTS_MYSQL_HOST", "") if host: return {"host": host, "port": int(os.environ.get("PINEAGENTS_MYSQL_PORT", "3306")), "user": os.environ.get("PINEAGENTS_MYSQL_USER", ""), "password": os.environ.get("PINEAGENTS_MYSQL_PASSWORD", ""), "db": os.environ.get("PINEAGENTS_MYSQL_DBNAME", "opc")} break # 只在第一个存在的 .env 中取配置 print("未找到生产数据库配置:请用 --host/--port/--user/--password/--db 指定," "或确认 serverrun/.env / .env 存在。", file=sys.stderr) raise SystemExit(1) def _parse_iso(s: str) -> datetime: return datetime.fromisoformat(s.replace("Z", "+00:00")) def _is_placeholder_name(name: str) -> bool: return name.strip() in PLACEHOLDER_NAMES async def sync(args: argparse.Namespace) -> int: cfg = _load_db_config(args) print(f"[连接] 生产库 mysql://{cfg['user']}@{cfg['host']}:{cfg['port']}/{cfg['db']}") conn = await asyncmy.connect(host=cfg["host"], port=cfg["port"], user=cfg["user"], password=cfg["password"], db=cfg["db"], charset="utf8mb4") try: cur = await conn.cursor() # 1. 活动表单:定位「姓名」字段 await cur.execute("SELECT form_fields_json FROM events WHERE id=%s", (args.event_id,)) row = await cur.fetchone() if not row: print(f"活动不存在: {args.event_id}", file=sys.stderr) return 2 fields = json.loads(row[0] or "[]") name_field = next((f.get("id") for f in fields if f.get("label") == "姓名"), None) if not name_field: print(f"活动 {args.event_id} 未定义「姓名」字段", file=sys.stderr) return 2 print(f"[字段] 姓名字段: {name_field}") # 2. 增量游标 state = {} if args.state_file.exists(): try: state = json.loads(args.state_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): state = {} cursor_ts = "" if args.full else (state.get(args.event_id) or {}).get("last_created_at", "") if cursor_ts: print(f"[增量] 游标 last_created_at = {cursor_ts}(--full 可全量重扫)") else: print("[增量] 无历史游标,全量扫描") # 3. 取报名(只取该活动、created_at > 游标) sql = ("SELECT id, username, name, form_data_json, created_at FROM bookings " "WHERE event_id=%s") params = [args.event_id] if cursor_ts: sql += " AND created_at > %s" params.append(cursor_ts) sql += " ORDER BY created_at ASC" await cur.execute(sql, params) rows = await cur.fetchall() print(f"[扫描] 待处理报名 {len(rows)} 条") # 4. 同一用户多条报名只保留最新一条 latest: dict[str, dict] = {} for bid, username, name, form_json, created_at in rows: if not username: continue fd = json.loads(form_json or "{}") real_name = str(fd.get(name_field) or "").strip() latest[username] = {"booking_id": bid, "name": name or "", "real_name": real_name, "created_at": created_at} # 5. 关联用户并执行 updated, skipped_no_name, skipped_placeholder, skipped_missing, skipped_same = 0, 0, 0, 0, 0 max_created = cursor_ts for username, info in latest.items(): if info["created_at"] > max_created: max_created = info["created_at"] real = info["real_name"] if not real: skipped_no_name += 1 continue if _is_placeholder_name(real) or real == username or len(real) < 2: skipped_placeholder += 1 continue await cur.execute("SELECT username, nickname FROM users WHERE username=%s", (username,)) u = await cur.fetchone() if not u: skipped_missing += 1 print(f" [跳过] 用户不存在 {username}") continue _u, nickname = u[0], u[1] or "" if nickname == real: skipped_same += 1 continue if not args.overwrite and not ( not nickname.strip() or nickname.strip() == username or _is_placeholder_name(nickname.strip()) ): skipped_same += 1 # 已有真实昵称且未开 --overwrite,不动 print(f" [保留] {username} 已有昵称「{nickname}」,不覆盖") continue print(f" [{'DRY' if args.dry_run else 'SET '}] {username} 昵称: " f"{nickname or '(空)'} -> {real} (报名 {info['booking_id']})") if not args.dry_run: await cur.execute("UPDATE users SET nickname=%s WHERE username=%s", (real, username)) updated += 1 if not args.dry_run: await conn.commit() # 更新游标(幂等,重复跑安全) state.setdefault(args.event_id, {})["last_created_at"] = max_created args.state_file.parent.mkdir(parents=True, exist_ok=True) args.state_file.write_text( json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[游标] 已更新 -> {max_created}") print(f"[结果] {'(dry-run 未写库)' if args.dry_run else '已写库'}" f" 更新 {updated};跳过:无姓名 {skipped_no_name}、占位/无效 {skipped_placeholder}、" f"用户不存在 {skipped_missing}、无需变更 {skipped_same}") return 0 finally: conn.close() if __name__ == "__main__": parser = argparse.ArgumentParser(description="同步活动报名真实姓名到用户昵称(增量)") parser.add_argument("--event-id", default=DEFAULT_EVENT_ID, help="活动 ID") parser.add_argument("--dry-run", action="store_true", help="只预览不写库") parser.add_argument("--full", action="store_true", help="忽略增量游标全量重扫") parser.add_argument("--overwrite", action="store_true", help="覆盖用户已有昵称") parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_FILE, help="增量游标文件路径") parser.add_argument("--host", help="MySQL 主机(默认读 .env)") parser.add_argument("--port", type=int, default=0, help="MySQL 端口") parser.add_argument("--user", help="MySQL 用户") parser.add_argument("--password", help="MySQL 密码") parser.add_argument("--db", default="opc", help="MySQL 库名") args = parser.parse_args() raise SystemExit(asyncio.run(sync(args)))