Bundle pending backend/web-admin feature updates, data-migration scripts, and related docs so the V1.5 branch on the Tencent Cloud Gogs matches the current workspace (excluding local secrets). Co-authored-by: Cursor <cursoragent@cursor.com>
845 lines
28 KiB
Python
845 lines
28 KiB
Python
#!/usr/bin/env python3
|
||
"""将 sx 库报销/CRM 用户迁入 shanxia2.0 门户 sys_user。
|
||
|
||
规则(2026-07-12 确认):
|
||
- 跳过 admin(门户已存在)
|
||
- 不迁移地磅 users
|
||
- 登录账号 user_no 使用中文姓名
|
||
- 初始密码:每人随机 4 位数字,bcrypt 加密(与门户 get_password_hash 一致)
|
||
- 支持 --dept-mapping-csv 按人指定部门
|
||
- 可选同步角色到 sys_role / sys_user_role
|
||
|
||
用法:
|
||
cd backend && source venv/bin/activate
|
||
ENV_FILE=.env.production python scripts/migrate_users.py --dry-run --check-infra
|
||
|
||
ENV_FILE=.env.production python scripts/migrate_users.py \\
|
||
--source-url mysql+pymysql://root:***@121.43.228.102:3306/sx \\
|
||
--mapping-out ../docs/data-migration/mappings/user_mapping_result.json
|
||
|
||
环境变量:
|
||
ENV_FILE 目标库配置(默认 backend/.env,生产用 .env.production)
|
||
DATABASE_URL 目标库 shanxia2.0(由 Settings 读取)
|
||
SX_SOURCE_DATABASE_URL 源库 sx(未传 --source-url 时优先使用)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||
from urllib.parse import urlparse, urlunparse
|
||
|
||
from sqlalchemy import create_engine, text
|
||
from sqlalchemy.engine import Engine
|
||
|
||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||
if str(BACKEND_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(BACKEND_ROOT))
|
||
|
||
from app.core.security import get_password_hash
|
||
from app.core.utils import generate_uuid
|
||
|
||
# 门户默认部门(销售部,见 migration_20260712_reset_sys_dept.sql)
|
||
DEFAULT_DEPT_ID = "D65CAAE057914CCCAE2A81BE627A43EE"
|
||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||
ADMIN_USER_NO = "admin"
|
||
|
||
# 可选部门(供 --dept-mapping-csv 填写 dept_id 或 dept_name)
|
||
DEPT_BY_ID = {
|
||
"b13c2be2-4e71-11f1-9f47-00163e3bdd96": "安庆市山峡化工有限公司",
|
||
"E65CAAE057914CCCAE2A81BE627A43EA": "销售部",
|
||
"F65CAAE057914CCCAE2A81BE627A43EF": "财务部门",
|
||
"C65CAAE057914CCCAE2A81BE627A43ED": "人力资源部",
|
||
"D65CAAE057914CCCAE2A81BE627A43EE": "生产现场部",
|
||
}
|
||
DEPT_BY_NAME = {
|
||
"安庆市山峡化工有限公司": "b13c2be2-4e71-11f1-9f47-00163e3bdd96",
|
||
"销售部": "E65CAAE057914CCCAE2A81BE627A43EA",
|
||
"销售部门": "E65CAAE057914CCCAE2A81BE627A43EA",
|
||
"财务部门": "F65CAAE057914CCCAE2A81BE627A43EF",
|
||
"财务部": "F65CAAE057914CCCAE2A81BE627A43EF",
|
||
"人力资源部": "C65CAAE057914CCCAE2A81BE627A43ED",
|
||
"人力资源部门": "C65CAAE057914CCCAE2A81BE627A43ED",
|
||
"生产现场部": "D65CAAE057914CCCAE2A81BE627A43EE",
|
||
}
|
||
|
||
|
||
# 业务确认的手机号覆盖(中文名 -> 手机)
|
||
PHONE_OVERRIDES: Dict[str, str] = {
|
||
"吴九华": "18656008189",
|
||
}
|
||
|
||
# bx 角色 -> 门户角色名
|
||
BX_ROLE_MAP = {
|
||
1: "管理员",
|
||
2: "员工",
|
||
3: "财务人员",
|
||
4: "总经理",
|
||
}
|
||
|
||
# CRM 角色 ID -> 门户角色名(sx.sql 生产快照)
|
||
CRM_ROLE_MAP = {
|
||
1975370859695071234: "销售人员",
|
||
1979711425949528066: "销售主管",
|
||
1984885915415953409: "行政",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class SourceUser:
|
||
user_no: str
|
||
nick_name: str
|
||
phone_no: str
|
||
email: str
|
||
sex: str
|
||
bx_user_id: Optional[int] = None
|
||
crm_user_id: Optional[int] = None
|
||
bx_role_ids: Set[int] = field(default_factory=set)
|
||
crm_role_ids: Set[int] = field(default_factory=set)
|
||
sources: List[str] = field(default_factory=list)
|
||
dept_id: str = DEFAULT_DEPT_ID
|
||
|
||
|
||
@dataclass
|
||
class MigrationStats:
|
||
inserted_users: int = 0
|
||
skipped_users: int = 0
|
||
updated_mapping: int = 0
|
||
roles_created: int = 0
|
||
user_roles_inserted: int = 0
|
||
warnings: List[str] = field(default_factory=list)
|
||
|
||
|
||
def _has_chinese(text_value: str) -> bool:
|
||
return bool(re.search(r"[\u4e00-\u9fff]", text_value or ""))
|
||
|
||
|
||
def _clip(value: Any, max_len: int) -> str:
|
||
s = (value or "").strip() if value is not None else ""
|
||
return s[:max_len]
|
||
|
||
|
||
def _normalize_phone(value: Any) -> str:
|
||
s = re.sub(r"\D", "", str(value or ""))
|
||
return s[:11] if s else ""
|
||
|
||
|
||
def generate_initial_password(used: Set[str]) -> str:
|
||
"""生成不重复的 4 位数字初始密码(0000–9999)。"""
|
||
for _ in range(200):
|
||
plain = f"{secrets.randbelow(10000):04d}"
|
||
if plain not in used:
|
||
used.add(plain)
|
||
return plain
|
||
raise RuntimeError("无法生成唯一 4 位初始密码,请扩大位数或手动指定")
|
||
|
||
|
||
def bx_role_labels(role_ids: Set[int]) -> str:
|
||
return "、".join(BX_ROLE_MAP[r] for r in sorted(role_ids) if r in BX_ROLE_MAP)
|
||
|
||
|
||
def crm_role_labels(role_ids: Set[int]) -> str:
|
||
return "、".join(CRM_ROLE_MAP[r] for r in sorted(role_ids) if r in CRM_ROLE_MAP)
|
||
|
||
|
||
def load_dept_mapping_csv(path: Path, default_dept_id: str) -> Dict[str, str]:
|
||
"""读取 user_import_roster 填好的部门:user_no -> dept_id。"""
|
||
if not path.is_file():
|
||
return {}
|
||
mapping: Dict[str, str] = {}
|
||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
user_no = (row.get("user_no") or "").strip()
|
||
if not user_no:
|
||
continue
|
||
dept_id = (row.get("dept_id") or "").strip()
|
||
dept_name = (row.get("dept_name") or "").strip()
|
||
if not dept_id and dept_name:
|
||
dept_id = DEPT_BY_NAME.get(dept_name, "")
|
||
if dept_id:
|
||
if dept_id not in DEPT_BY_ID:
|
||
raise ValueError(f"用户「{user_no}」部门 ID 无效: {dept_id}")
|
||
mapping[user_no] = dept_id
|
||
else:
|
||
mapping[user_no] = default_dept_id
|
||
return mapping
|
||
|
||
|
||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||
parsed = urlparse(target_url)
|
||
# /shanxia2.0 -> /sx
|
||
new_path = f"/{source_db}"
|
||
return urlunparse(parsed._replace(path=new_path))
|
||
|
||
|
||
def load_runtime_settings():
|
||
"""在 load_dotenv 之后实例化,确保读到 ENV_FILE / .env.production。"""
|
||
from app.core.config import Settings
|
||
|
||
return Settings()
|
||
|
||
|
||
def load_settings_env(env_file: Optional[str]) -> None:
|
||
if env_file:
|
||
os.environ["ENV_FILE"] = env_file
|
||
candidates: List[Path] = []
|
||
if env_file:
|
||
p = Path(env_file)
|
||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||
candidates.append(BACKEND_ROOT / ".env.production")
|
||
candidates.append(BACKEND_ROOT / ".env")
|
||
|
||
from dotenv import load_dotenv
|
||
|
||
for path in candidates:
|
||
if path.is_file():
|
||
load_dotenv(path, override=True)
|
||
break
|
||
|
||
|
||
def check_mysql(engine: Engine, label: str) -> None:
|
||
with engine.connect() as conn:
|
||
conn.execute(text("SELECT 1"))
|
||
|
||
|
||
def check_redis(runtime_settings) -> Tuple[bool, str]:
|
||
try:
|
||
import redis
|
||
|
||
client = redis.Redis(
|
||
host=runtime_settings.REDIS_HOST or "localhost",
|
||
port=runtime_settings.REDIS_PORT,
|
||
db=runtime_settings.REDIS_DB,
|
||
password=runtime_settings.REDIS_PASSWORD or None,
|
||
socket_connect_timeout=runtime_settings.REDIS_CONNECT_TIMEOUT_SEC,
|
||
socket_timeout=runtime_settings.REDIS_OP_TIMEOUT_SEC,
|
||
)
|
||
client.ping()
|
||
return True, f"{runtime_settings.REDIS_HOST}:{runtime_settings.REDIS_PORT}/{runtime_settings.REDIS_DB}"
|
||
except Exception as exc:
|
||
return False, str(exc)
|
||
|
||
|
||
def check_minio(runtime_settings) -> Tuple[bool, str]:
|
||
try:
|
||
from minio import Minio
|
||
|
||
if not runtime_settings.MINIO_ENDPOINT:
|
||
return False, "MINIO_ENDPOINT 未配置"
|
||
client = Minio(
|
||
runtime_settings.MINIO_ENDPOINT,
|
||
access_key=runtime_settings.MINIO_ACCESS_KEY,
|
||
secret_key=runtime_settings.MINIO_SECRET_KEY,
|
||
secure=runtime_settings.MINIO_SECURE,
|
||
)
|
||
client.bucket_exists(runtime_settings.MINIO_BUCKET)
|
||
return True, f"{runtime_settings.MINIO_ENDPOINT} bucket={runtime_settings.MINIO_BUCKET}"
|
||
except Exception as exc:
|
||
return False, str(exc)
|
||
|
||
|
||
def fetch_bx_users(engine: Engine) -> List[dict]:
|
||
sql = """
|
||
SELECT id, username, password, real_name, phone, email, status, del_flag
|
||
FROM bx_user
|
||
WHERE del_flag = 0 AND username <> :admin
|
||
ORDER BY id
|
||
"""
|
||
with engine.connect() as conn:
|
||
rows = conn.execute(text(sql), {"admin": ADMIN_USER_NO}).mappings().all()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def fetch_crm_users(engine: Engine) -> List[dict]:
|
||
sql = """
|
||
SELECT user_id, user_name, nick_name, phonenumber, email, password,
|
||
sex, status, del_flag
|
||
FROM sys_user
|
||
WHERE del_flag = '0' AND status = '0' AND user_name <> :admin
|
||
ORDER BY user_id
|
||
"""
|
||
with engine.connect() as conn:
|
||
rows = conn.execute(text(sql), {"admin": ADMIN_USER_NO}).mappings().all()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def fetch_bx_role_users(engine: Engine) -> Dict[int, Set[int]]:
|
||
sql = "SELECT user_id, role_id FROM bx_role_user"
|
||
out: Dict[int, Set[int]] = {}
|
||
with engine.connect() as conn:
|
||
for row in conn.execute(text(sql)).mappings():
|
||
uid = int(row["user_id"])
|
||
out.setdefault(uid, set()).add(int(row["role_id"]))
|
||
return out
|
||
|
||
|
||
def fetch_crm_user_roles(engine: Engine) -> Dict[int, Set[int]]:
|
||
sql = "SELECT user_id, role_id FROM sys_user_role"
|
||
out: Dict[int, Set[int]] = {}
|
||
with engine.connect() as conn:
|
||
for row in conn.execute(text(sql)).mappings():
|
||
uid = int(row["user_id"])
|
||
out.setdefault(uid, set()).add(int(row["role_id"]))
|
||
return out
|
||
|
||
|
||
def build_merged_users(
|
||
source_engine: Engine,
|
||
stats: MigrationStats,
|
||
dept_mapping: Optional[Dict[str, str]] = None,
|
||
default_dept_id: str = DEFAULT_DEPT_ID,
|
||
) -> List[SourceUser]:
|
||
bx_rows = fetch_bx_users(source_engine)
|
||
crm_rows = fetch_crm_users(source_engine)
|
||
bx_roles = fetch_bx_role_users(source_engine)
|
||
crm_roles = fetch_crm_user_roles(source_engine)
|
||
|
||
merged: Dict[str, SourceUser] = {}
|
||
|
||
def upsert(key: str, **kwargs: Any) -> SourceUser:
|
||
key = key.strip()
|
||
if key not in merged:
|
||
merged[key] = SourceUser(
|
||
user_no=key,
|
||
nick_name=key,
|
||
phone_no="",
|
||
email="",
|
||
sex="0",
|
||
)
|
||
item = merged[key]
|
||
for k, v in kwargs.items():
|
||
if v is None:
|
||
continue
|
||
if k == "sources":
|
||
values = v if isinstance(v, (list, tuple, set)) else [v]
|
||
for s in values:
|
||
if s and s not in item.sources:
|
||
item.sources.append(s)
|
||
elif k == "bx_role_ids" or k == "crm_role_ids":
|
||
getattr(item, k).update(v)
|
||
elif k in ("phone_no", "email") and getattr(item, k):
|
||
continue
|
||
else:
|
||
setattr(item, k, v)
|
||
return item
|
||
|
||
for row in bx_rows:
|
||
name = (row.get("real_name") or "").strip()
|
||
if not name:
|
||
stats.warnings.append(f"bx_user id={row['id']} 缺少 real_name,已跳过")
|
||
continue
|
||
upsert(
|
||
name,
|
||
phone_no=_normalize_phone(row.get("phone")),
|
||
email=_clip(row.get("email"), 50),
|
||
bx_user_id=int(row["id"]),
|
||
bx_role_ids=bx_roles.get(int(row["id"]), set()),
|
||
sources="bx",
|
||
)
|
||
|
||
for row in crm_rows:
|
||
nick = (row.get("nick_name") or "").strip()
|
||
uname = (row.get("user_name") or "").strip()
|
||
name = nick if _has_chinese(nick) else (uname if _has_chinese(uname) else nick or uname)
|
||
if not name or not _has_chinese(name):
|
||
continue
|
||
upsert(
|
||
name,
|
||
phone_no=_normalize_phone(row.get("phonenumber")),
|
||
email=_clip(row.get("email"), 50),
|
||
sex=str(row.get("sex") or "0"),
|
||
crm_user_id=int(row["user_id"]),
|
||
crm_role_ids=crm_roles.get(int(row["user_id"]), set()),
|
||
sources="crm",
|
||
)
|
||
|
||
# 手机号业务覆盖
|
||
for name, phone in PHONE_OVERRIDES.items():
|
||
if name in merged:
|
||
merged[name].phone_no = phone
|
||
|
||
dept_mapping = dept_mapping or {}
|
||
result = []
|
||
for item in merged.values():
|
||
item.dept_id = dept_mapping.get(item.user_no, default_dept_id)
|
||
result.append(item)
|
||
|
||
result.sort(key=lambda u: u.user_no)
|
||
return result
|
||
|
||
|
||
def target_user_exists_conn(conn, user_no: str, tenant_no: str) -> Optional[str]:
|
||
sql = """
|
||
SELECT id FROM sys_user
|
||
WHERE user_no = :user_no AND tenant_no = :tenant AND del_flag = 0
|
||
LIMIT 1
|
||
"""
|
||
row = conn.execute(text(sql), {"user_no": user_no, "tenant": tenant_no}).first()
|
||
return str(row[0]) if row else None
|
||
|
||
|
||
def ensure_portal_role(
|
||
conn,
|
||
role_name: str,
|
||
tenant_no: str,
|
||
admin_id: str,
|
||
dept_id: str,
|
||
dry_run: bool,
|
||
cache: Dict[str, str],
|
||
stats: MigrationStats,
|
||
) -> str:
|
||
if role_name in cache:
|
||
return cache[role_name]
|
||
row = conn.execute(
|
||
text(
|
||
"""
|
||
SELECT id FROM sys_role
|
||
WHERE role_name = :name AND tenant_no = :tenant AND del_flag = 0
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"name": role_name, "tenant": tenant_no},
|
||
).first()
|
||
if row:
|
||
cache[role_name] = str(row[0])
|
||
return cache[role_name]
|
||
|
||
role_id = generate_uuid()
|
||
if not dry_run:
|
||
conn.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO sys_role (
|
||
id, role_name, data_scope, status, tenant_no, order_num,
|
||
remark, create_by, create_dept_id, create_time, update_by,
|
||
update_time, del_flag
|
||
) VALUES (
|
||
:id, :role_name, '5', '1', :tenant_no, 10,
|
||
'用户迁移脚本自动创建', :create_by, :dept_id, :now, :create_by,
|
||
:now, 0
|
||
)
|
||
"""
|
||
),
|
||
{
|
||
"id": role_id,
|
||
"role_name": role_name,
|
||
"tenant_no": tenant_no,
|
||
"create_by": admin_id,
|
||
"dept_id": dept_id,
|
||
"now": datetime.now(),
|
||
},
|
||
)
|
||
cache[role_name] = role_id
|
||
stats.roles_created += 1
|
||
return role_id
|
||
|
||
|
||
def portal_role_names_for_user(user: SourceUser) -> Set[str]:
|
||
names: Set[str] = set()
|
||
for rid in user.bx_role_ids:
|
||
name = BX_ROLE_MAP.get(rid)
|
||
if name and name != "管理员":
|
||
names.add(name)
|
||
for rid in user.crm_role_ids:
|
||
name = CRM_ROLE_MAP.get(rid)
|
||
if name:
|
||
names.add(name)
|
||
if not names:
|
||
names.add("员工")
|
||
return names
|
||
|
||
|
||
def insert_user_role(
|
||
conn,
|
||
user_id: str,
|
||
role_id: str,
|
||
tenant_no: str,
|
||
admin_id: str,
|
||
dept_id: str,
|
||
dry_run: bool,
|
||
stats: MigrationStats,
|
||
) -> None:
|
||
exists = conn.execute(
|
||
text(
|
||
"""
|
||
SELECT id FROM sys_user_role
|
||
WHERE user_id = :uid AND role_id = :rid AND tenant_no = :tenant AND del_flag = 0
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"uid": user_id, "rid": role_id, "tenant": tenant_no},
|
||
).first()
|
||
if exists:
|
||
return
|
||
if not dry_run:
|
||
conn.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO sys_user_role (
|
||
id, user_id, role_id, tenant_no, order_num, remark,
|
||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||
) VALUES (
|
||
:id, :user_id, :role_id, :tenant_no, 0, 'migrate_users',
|
||
:create_by, :dept_id, :now, :create_by, :now, 0
|
||
)
|
||
"""
|
||
),
|
||
{
|
||
"id": generate_uuid(),
|
||
"user_id": user_id,
|
||
"role_id": role_id,
|
||
"tenant_no": tenant_no,
|
||
"create_by": admin_id,
|
||
"dept_id": dept_id,
|
||
"now": datetime.now(),
|
||
},
|
||
)
|
||
stats.user_roles_inserted += 1
|
||
|
||
|
||
def migrate_users(
|
||
source_engine: Engine,
|
||
target_engine: Engine,
|
||
*,
|
||
tenant_no: str,
|
||
dept_id: str,
|
||
dept_mapping: Optional[Dict[str, str]],
|
||
dry_run: bool,
|
||
with_roles: bool,
|
||
stats: MigrationStats,
|
||
) -> List[dict]:
|
||
users = build_merged_users(
|
||
source_engine, stats, dept_mapping=dept_mapping, default_dept_id=dept_id
|
||
)
|
||
mapping_rows: List[dict] = []
|
||
used_passwords: Set[str] = set()
|
||
|
||
with target_engine.connect() as raw_conn:
|
||
admin_row = raw_conn.execute(
|
||
text("SELECT id FROM sys_user WHERE user_no = :u AND del_flag = 0 LIMIT 1"),
|
||
{"u": ADMIN_USER_NO},
|
||
).first()
|
||
admin_id = str(admin_row[0]) if admin_row else ""
|
||
|
||
with target_engine.begin() as conn:
|
||
role_cache: Dict[str, str] = {}
|
||
|
||
for user in users:
|
||
existing_id = target_user_exists_conn(conn, user.user_no, tenant_no)
|
||
plain_password = ""
|
||
password_hash = ""
|
||
if existing_id:
|
||
stats.skipped_users += 1
|
||
stats.warnings.append(f"已存在,跳过: {user.user_no} (id={existing_id})")
|
||
new_id = existing_id
|
||
else:
|
||
plain_password = generate_initial_password(used_passwords)
|
||
password_hash = get_password_hash(plain_password)
|
||
new_id = generate_uuid()
|
||
user_dept_id = user.dept_id or dept_id
|
||
if not dry_run:
|
||
conn.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO sys_user (
|
||
id, dept_id, user_no, nick_name, email, phone_no, sex,
|
||
avatar, password, status, tenant_no, order_num, remark,
|
||
create_by, create_dept_id, create_time, update_by,
|
||
update_time, del_flag
|
||
) VALUES (
|
||
:id, :dept_id, :user_no, :nick_name, :email, :phone_no, :sex,
|
||
NULL, :password, '1', :tenant_no, 0, :remark,
|
||
:create_by, :dept_id, :now, :create_by, :now, 0
|
||
)
|
||
"""
|
||
),
|
||
{
|
||
"id": new_id,
|
||
"dept_id": user_dept_id,
|
||
"user_no": _clip(user.user_no, 30),
|
||
"nick_name": _clip(user.nick_name, 30),
|
||
"email": _clip(user.email, 50),
|
||
"phone_no": _clip(user.phone_no, 11),
|
||
"sex": _clip(user.sex, 1) or "0",
|
||
"password": password_hash,
|
||
"tenant_no": tenant_no,
|
||
"remark": f"migrate_users:{','.join(user.sources)}",
|
||
"create_by": admin_id or None,
|
||
"now": datetime.now(),
|
||
},
|
||
)
|
||
stats.inserted_users += 1
|
||
|
||
user_dept_id = user.dept_id or dept_id
|
||
if with_roles and admin_id:
|
||
for role_name in portal_role_names_for_user(user):
|
||
role_id = ensure_portal_role(
|
||
conn,
|
||
role_name,
|
||
tenant_no,
|
||
admin_id,
|
||
user_dept_id,
|
||
dry_run,
|
||
role_cache,
|
||
stats,
|
||
)
|
||
insert_user_role(
|
||
conn,
|
||
new_id,
|
||
role_id,
|
||
tenant_no,
|
||
admin_id,
|
||
user_dept_id,
|
||
dry_run,
|
||
stats,
|
||
)
|
||
|
||
mapping_rows.append(
|
||
{
|
||
"new_user_id": new_id,
|
||
"user_no": user.user_no,
|
||
"nick_name": user.nick_name,
|
||
"phone_no": user.phone_no,
|
||
"dept_id": user_dept_id,
|
||
"dept_name": DEPT_BY_ID.get(user_dept_id, ""),
|
||
"bx_user_id": user.bx_user_id or "",
|
||
"crm_user_id": user.crm_user_id or "",
|
||
"dibang_user_id": "",
|
||
"initial_password": plain_password,
|
||
"password_hash_algo": "bcrypt",
|
||
"status": "1",
|
||
"sources": ",".join(user.sources),
|
||
"bx_roles": bx_role_labels(user.bx_role_ids),
|
||
"crm_roles": crm_role_labels(user.crm_role_ids),
|
||
"portal_roles": ",".join(sorted(portal_role_names_for_user(user))),
|
||
}
|
||
)
|
||
|
||
return mapping_rows
|
||
|
||
|
||
def write_mapping_outputs(
|
||
mapping_rows: List[dict],
|
||
json_path: Optional[Path],
|
||
csv_path: Optional[Path],
|
||
credentials_path: Optional[Path],
|
||
) -> None:
|
||
safe_rows = []
|
||
for row in mapping_rows:
|
||
item = dict(row)
|
||
item.pop("initial_password", None)
|
||
safe_rows.append(item)
|
||
|
||
if json_path:
|
||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||
payload = {
|
||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||
"count": len(safe_rows),
|
||
"users": safe_rows,
|
||
"bx_id_to_user": {
|
||
str(r["bx_user_id"]): r["new_user_id"]
|
||
for r in safe_rows
|
||
if r.get("bx_user_id")
|
||
},
|
||
"crm_id_to_user": {
|
||
str(r["crm_user_id"]): r["new_user_id"]
|
||
for r in safe_rows
|
||
if r.get("crm_user_id")
|
||
},
|
||
}
|
||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
if csv_path:
|
||
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||
fieldnames = [
|
||
"new_user_id",
|
||
"user_no",
|
||
"nick_name",
|
||
"phone_no",
|
||
"dept_id",
|
||
"dept_name",
|
||
"bx_user_id",
|
||
"crm_user_id",
|
||
"dibang_user_id",
|
||
"password_hash_algo",
|
||
"status",
|
||
"sources",
|
||
"bx_roles",
|
||
"crm_roles",
|
||
"portal_roles",
|
||
]
|
||
with csv_path.open("w", encoding="utf-8", newline="") as f:
|
||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||
writer.writeheader()
|
||
writer.writerows(safe_rows)
|
||
|
||
if credentials_path:
|
||
credentials_path.parent.mkdir(parents=True, exist_ok=True)
|
||
cred_fields = [
|
||
"user_no",
|
||
"nick_name",
|
||
"phone_no",
|
||
"dept_name",
|
||
"initial_password",
|
||
"new_user_id",
|
||
]
|
||
with credentials_path.open("w", encoding="utf-8", newline="") as f:
|
||
writer = csv.DictWriter(f, fieldnames=cred_fields, extrasaction="ignore")
|
||
writer.writeheader()
|
||
for row in mapping_rows:
|
||
if row.get("initial_password"):
|
||
writer.writerow(row)
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description="迁移 sx 报销/CRM 用户到 shanxia2.0")
|
||
parser.add_argument("--dry-run", action="store_true", help="只演练不写库")
|
||
parser.add_argument("--env-file", default=".env.production", help="如 .env.production")
|
||
parser.add_argument(
|
||
"--source-url",
|
||
default=os.getenv("SX_SOURCE_DATABASE_URL", ""),
|
||
help="源库 sx 连接串",
|
||
)
|
||
parser.add_argument(
|
||
"--source-db",
|
||
default="sx",
|
||
help="从目标 DATABASE_URL 推导源库时的库名",
|
||
)
|
||
parser.add_argument(
|
||
"--tenant-no",
|
||
default=DEFAULT_TENANT_NO,
|
||
help="目标租户编号",
|
||
)
|
||
parser.add_argument(
|
||
"--dept-id",
|
||
default=DEFAULT_DEPT_ID,
|
||
help="默认部门 ID",
|
||
)
|
||
parser.add_argument(
|
||
"--dept-mapping-csv",
|
||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_import_roster.csv"),
|
||
help="按人指定部门的 CSV(填 dept_id 或 dept_name)",
|
||
)
|
||
parser.add_argument(
|
||
"--credentials-out",
|
||
default=str(
|
||
BACKEND_ROOT.parent / "docs/data-migration/mappings/user_import_credentials.csv"
|
||
),
|
||
help="初始明文密码清单(仅新建用户,勿提交版本库)",
|
||
)
|
||
parser.add_argument(
|
||
"--mapping-out",
|
||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.json"),
|
||
help="输出 JSON 映射",
|
||
)
|
||
parser.add_argument(
|
||
"--mapping-csv",
|
||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||
help="输出 CSV 映射",
|
||
)
|
||
parser.add_argument(
|
||
"--no-roles",
|
||
action="store_true",
|
||
help="不写入 sys_role / sys_user_role",
|
||
)
|
||
parser.add_argument(
|
||
"--check-infra",
|
||
action="store_true",
|
||
help="检查 MySQL/Redis/MinIO 连通性",
|
||
)
|
||
return parser.parse_args()
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
load_settings_env(args.env_file or None)
|
||
runtime_settings = load_runtime_settings()
|
||
|
||
target_url = runtime_settings.DATABASE_URL
|
||
source_url = args.source_url or derive_source_url(target_url, args.source_db)
|
||
|
||
print("=== 用户迁移 migrate_users ===")
|
||
print(f"模式: {'DRY-RUN' if args.dry_run else 'EXECUTE'}")
|
||
print(f"源库: {source_url}")
|
||
print(f"目标库: {target_url}")
|
||
print(f"租户: {args.tenant_no}")
|
||
print("跳过: admin、地磅账号")
|
||
print()
|
||
|
||
source_engine = create_engine(source_url, pool_pre_ping=True)
|
||
target_engine = create_engine(target_url, pool_pre_ping=True)
|
||
|
||
if args.check_infra:
|
||
check_mysql(source_engine, "source")
|
||
check_mysql(target_engine, "target")
|
||
ok, msg = check_redis(runtime_settings)
|
||
print(f"Redis: {'OK' if ok else 'FAIL'} — {msg}")
|
||
ok, msg = check_minio(runtime_settings)
|
||
print(f"MinIO: {'OK' if ok else 'FAIL'} — {msg}")
|
||
print()
|
||
|
||
dept_mapping_path = Path(args.dept_mapping_csv)
|
||
dept_mapping = load_dept_mapping_csv(dept_mapping_path, args.dept_id)
|
||
if dept_mapping_path.is_file():
|
||
filled = sum(
|
||
1
|
||
for k, v in dept_mapping.items()
|
||
if v and v != args.dept_id
|
||
)
|
||
print(f"部门映射: {dept_mapping_path.name}({filled} 人已指定非默认部门,其余默认生产现场部)")
|
||
else:
|
||
print(f"部门映射: 未找到 {dept_mapping_path},全部使用默认部门")
|
||
print("密码策略: 每人随机 4 位数字 + bcrypt(与门户一致)")
|
||
print()
|
||
|
||
stats = MigrationStats()
|
||
mapping_rows = migrate_users(
|
||
source_engine,
|
||
target_engine,
|
||
tenant_no=args.tenant_no,
|
||
dept_id=args.dept_id,
|
||
dept_mapping=dept_mapping or None,
|
||
dry_run=args.dry_run,
|
||
with_roles=not args.no_roles,
|
||
stats=stats,
|
||
)
|
||
|
||
if not args.dry_run:
|
||
write_mapping_outputs(
|
||
mapping_rows,
|
||
Path(args.mapping_out),
|
||
Path(args.mapping_csv),
|
||
Path(args.credentials_out),
|
||
)
|
||
else:
|
||
print("(dry-run 不写库;映射预览见下方统计,正式执行后写入 JSON/CSV)")
|
||
|
||
print(f"待迁移/处理用户数: {len(mapping_rows)}")
|
||
print(f"新增用户: {stats.inserted_users}")
|
||
print(f"跳过已存在: {stats.skipped_users}")
|
||
print(f"新建角色: {stats.roles_created}")
|
||
print(f"用户-角色关联: {stats.user_roles_inserted}")
|
||
if stats.warnings:
|
||
print("警告:")
|
||
for w in stats.warnings:
|
||
print(f" - {w}")
|
||
if not args.dry_run:
|
||
print(f"映射 JSON: {args.mapping_out}")
|
||
print(f"映射 CSV: {args.mapping_csv}")
|
||
print(f"密码清单: {args.credentials_out}")
|
||
print("完成.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|