Files
server-core/scripts/sync_event_name_to_nickname.py
T
Pine 416ade4842 feat: 统一权限聚合端点 + 事件名同步昵称 + IM/企业路由补强
- 新增 rbac_permissions 统一权限/组织归属聚合端点
- sync_event_name_to_nickname 脚本:事件名同步用户昵称
- im router/client、rbac_enterprise/opc/org/public 增强
- compute_catalog、nginx 配置、env.example 更新
2026-09-11 21:15:36 +08:00

233 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)))