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