416ade4842
- 新增 rbac_permissions 统一权限/组织归属聚合端点 - sync_event_name_to_nickname 脚本:事件名同步用户昵称 - im router/client、rbac_enterprise/opc/org/public 增强 - compute_catalog、nginx 配置、env.example 更新
233 lines
9.9 KiB
Python
233 lines
9.9 KiB
Python
#!/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)))
|