Files
server-core/scripts/sync_event_name_to_nickname.py
T

233 lines
9.9 KiB
Python
Raw Normal View History

#!/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)))