migrate_users.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844
  1. #!/usr/bin/env python3
  2. """将 sx 库报销/CRM 用户迁入 shanxia2.0 门户 sys_user。
  3. 规则(2026-07-12 确认):
  4. - 跳过 admin(门户已存在)
  5. - 不迁移地磅 users
  6. - 登录账号 user_no 使用中文姓名
  7. - 初始密码:每人随机 4 位数字,bcrypt 加密(与门户 get_password_hash 一致)
  8. - 支持 --dept-mapping-csv 按人指定部门
  9. - 可选同步角色到 sys_role / sys_user_role
  10. 用法:
  11. cd backend && source venv/bin/activate
  12. ENV_FILE=.env.production python scripts/migrate_users.py --dry-run --check-infra
  13. ENV_FILE=.env.production python scripts/migrate_users.py \\
  14. --source-url mysql+pymysql://root:***@121.43.228.102:3306/sx \\
  15. --mapping-out ../docs/data-migration/mappings/user_mapping_result.json
  16. 环境变量:
  17. ENV_FILE 目标库配置(默认 backend/.env,生产用 .env.production)
  18. DATABASE_URL 目标库 shanxia2.0(由 Settings 读取)
  19. SX_SOURCE_DATABASE_URL 源库 sx(未传 --source-url 时优先使用)
  20. """
  21. from __future__ import annotations
  22. import argparse
  23. import csv
  24. import json
  25. import os
  26. import re
  27. import secrets
  28. import sys
  29. from dataclasses import dataclass, field
  30. from datetime import datetime
  31. from pathlib import Path
  32. from typing import Any, Dict, List, Optional, Set, Tuple
  33. from urllib.parse import urlparse, urlunparse
  34. from sqlalchemy import create_engine, text
  35. from sqlalchemy.engine import Engine
  36. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  37. if str(BACKEND_ROOT) not in sys.path:
  38. sys.path.insert(0, str(BACKEND_ROOT))
  39. from app.core.security import get_password_hash
  40. from app.core.utils import generate_uuid
  41. # 门户默认部门(销售部,见 migration_20260712_reset_sys_dept.sql)
  42. DEFAULT_DEPT_ID = "D65CAAE057914CCCAE2A81BE627A43EE"
  43. DEFAULT_TENANT_NO = "shanxia2.0"
  44. ADMIN_USER_NO = "admin"
  45. # 可选部门(供 --dept-mapping-csv 填写 dept_id 或 dept_name)
  46. DEPT_BY_ID = {
  47. "b13c2be2-4e71-11f1-9f47-00163e3bdd96": "安庆市山峡化工有限公司",
  48. "E65CAAE057914CCCAE2A81BE627A43EA": "销售部",
  49. "F65CAAE057914CCCAE2A81BE627A43EF": "财务部门",
  50. "C65CAAE057914CCCAE2A81BE627A43ED": "人力资源部",
  51. "D65CAAE057914CCCAE2A81BE627A43EE": "生产现场部",
  52. }
  53. DEPT_BY_NAME = {
  54. "安庆市山峡化工有限公司": "b13c2be2-4e71-11f1-9f47-00163e3bdd96",
  55. "销售部": "E65CAAE057914CCCAE2A81BE627A43EA",
  56. "销售部门": "E65CAAE057914CCCAE2A81BE627A43EA",
  57. "财务部门": "F65CAAE057914CCCAE2A81BE627A43EF",
  58. "财务部": "F65CAAE057914CCCAE2A81BE627A43EF",
  59. "人力资源部": "C65CAAE057914CCCAE2A81BE627A43ED",
  60. "人力资源部门": "C65CAAE057914CCCAE2A81BE627A43ED",
  61. "生产现场部": "D65CAAE057914CCCAE2A81BE627A43EE",
  62. }
  63. # 业务确认的手机号覆盖(中文名 -> 手机)
  64. PHONE_OVERRIDES: Dict[str, str] = {
  65. "吴九华": "18656008189",
  66. }
  67. # bx 角色 -> 门户角色名
  68. BX_ROLE_MAP = {
  69. 1: "管理员",
  70. 2: "员工",
  71. 3: "财务人员",
  72. 4: "总经理",
  73. }
  74. # CRM 角色 ID -> 门户角色名(sx.sql 生产快照)
  75. CRM_ROLE_MAP = {
  76. 1975370859695071234: "销售人员",
  77. 1979711425949528066: "销售主管",
  78. 1984885915415953409: "行政",
  79. }
  80. @dataclass
  81. class SourceUser:
  82. user_no: str
  83. nick_name: str
  84. phone_no: str
  85. email: str
  86. sex: str
  87. bx_user_id: Optional[int] = None
  88. crm_user_id: Optional[int] = None
  89. bx_role_ids: Set[int] = field(default_factory=set)
  90. crm_role_ids: Set[int] = field(default_factory=set)
  91. sources: List[str] = field(default_factory=list)
  92. dept_id: str = DEFAULT_DEPT_ID
  93. @dataclass
  94. class MigrationStats:
  95. inserted_users: int = 0
  96. skipped_users: int = 0
  97. updated_mapping: int = 0
  98. roles_created: int = 0
  99. user_roles_inserted: int = 0
  100. warnings: List[str] = field(default_factory=list)
  101. def _has_chinese(text_value: str) -> bool:
  102. return bool(re.search(r"[\u4e00-\u9fff]", text_value or ""))
  103. def _clip(value: Any, max_len: int) -> str:
  104. s = (value or "").strip() if value is not None else ""
  105. return s[:max_len]
  106. def _normalize_phone(value: Any) -> str:
  107. s = re.sub(r"\D", "", str(value or ""))
  108. return s[:11] if s else ""
  109. def generate_initial_password(used: Set[str]) -> str:
  110. """生成不重复的 4 位数字初始密码(0000–9999)。"""
  111. for _ in range(200):
  112. plain = f"{secrets.randbelow(10000):04d}"
  113. if plain not in used:
  114. used.add(plain)
  115. return plain
  116. raise RuntimeError("无法生成唯一 4 位初始密码,请扩大位数或手动指定")
  117. def bx_role_labels(role_ids: Set[int]) -> str:
  118. return "、".join(BX_ROLE_MAP[r] for r in sorted(role_ids) if r in BX_ROLE_MAP)
  119. def crm_role_labels(role_ids: Set[int]) -> str:
  120. return "、".join(CRM_ROLE_MAP[r] for r in sorted(role_ids) if r in CRM_ROLE_MAP)
  121. def load_dept_mapping_csv(path: Path, default_dept_id: str) -> Dict[str, str]:
  122. """读取 user_import_roster 填好的部门:user_no -> dept_id。"""
  123. if not path.is_file():
  124. return {}
  125. mapping: Dict[str, str] = {}
  126. with path.open(encoding="utf-8-sig", newline="") as f:
  127. reader = csv.DictReader(f)
  128. for row in reader:
  129. user_no = (row.get("user_no") or "").strip()
  130. if not user_no:
  131. continue
  132. dept_id = (row.get("dept_id") or "").strip()
  133. dept_name = (row.get("dept_name") or "").strip()
  134. if not dept_id and dept_name:
  135. dept_id = DEPT_BY_NAME.get(dept_name, "")
  136. if dept_id:
  137. if dept_id not in DEPT_BY_ID:
  138. raise ValueError(f"用户「{user_no}」部门 ID 无效: {dept_id}")
  139. mapping[user_no] = dept_id
  140. else:
  141. mapping[user_no] = default_dept_id
  142. return mapping
  143. def derive_source_url(target_url: str, source_db: str = "sx") -> str:
  144. parsed = urlparse(target_url)
  145. # /shanxia2.0 -> /sx
  146. new_path = f"/{source_db}"
  147. return urlunparse(parsed._replace(path=new_path))
  148. def load_runtime_settings():
  149. """在 load_dotenv 之后实例化,确保读到 ENV_FILE / .env.production。"""
  150. from app.core.config import Settings
  151. return Settings()
  152. def load_settings_env(env_file: Optional[str]) -> None:
  153. if env_file:
  154. os.environ["ENV_FILE"] = env_file
  155. candidates: List[Path] = []
  156. if env_file:
  157. p = Path(env_file)
  158. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  159. candidates.append(BACKEND_ROOT / ".env.production")
  160. candidates.append(BACKEND_ROOT / ".env")
  161. from dotenv import load_dotenv
  162. for path in candidates:
  163. if path.is_file():
  164. load_dotenv(path, override=True)
  165. break
  166. def check_mysql(engine: Engine, label: str) -> None:
  167. with engine.connect() as conn:
  168. conn.execute(text("SELECT 1"))
  169. def check_redis(runtime_settings) -> Tuple[bool, str]:
  170. try:
  171. import redis
  172. client = redis.Redis(
  173. host=runtime_settings.REDIS_HOST or "localhost",
  174. port=runtime_settings.REDIS_PORT,
  175. db=runtime_settings.REDIS_DB,
  176. password=runtime_settings.REDIS_PASSWORD or None,
  177. socket_connect_timeout=runtime_settings.REDIS_CONNECT_TIMEOUT_SEC,
  178. socket_timeout=runtime_settings.REDIS_OP_TIMEOUT_SEC,
  179. )
  180. client.ping()
  181. return True, f"{runtime_settings.REDIS_HOST}:{runtime_settings.REDIS_PORT}/{runtime_settings.REDIS_DB}"
  182. except Exception as exc:
  183. return False, str(exc)
  184. def check_minio(runtime_settings) -> Tuple[bool, str]:
  185. try:
  186. from minio import Minio
  187. if not runtime_settings.MINIO_ENDPOINT:
  188. return False, "MINIO_ENDPOINT 未配置"
  189. client = Minio(
  190. runtime_settings.MINIO_ENDPOINT,
  191. access_key=runtime_settings.MINIO_ACCESS_KEY,
  192. secret_key=runtime_settings.MINIO_SECRET_KEY,
  193. secure=runtime_settings.MINIO_SECURE,
  194. )
  195. client.bucket_exists(runtime_settings.MINIO_BUCKET)
  196. return True, f"{runtime_settings.MINIO_ENDPOINT} bucket={runtime_settings.MINIO_BUCKET}"
  197. except Exception as exc:
  198. return False, str(exc)
  199. def fetch_bx_users(engine: Engine) -> List[dict]:
  200. sql = """
  201. SELECT id, username, password, real_name, phone, email, status, del_flag
  202. FROM bx_user
  203. WHERE del_flag = 0 AND username <> :admin
  204. ORDER BY id
  205. """
  206. with engine.connect() as conn:
  207. rows = conn.execute(text(sql), {"admin": ADMIN_USER_NO}).mappings().all()
  208. return [dict(r) for r in rows]
  209. def fetch_crm_users(engine: Engine) -> List[dict]:
  210. sql = """
  211. SELECT user_id, user_name, nick_name, phonenumber, email, password,
  212. sex, status, del_flag
  213. FROM sys_user
  214. WHERE del_flag = '0' AND status = '0' AND user_name <> :admin
  215. ORDER BY user_id
  216. """
  217. with engine.connect() as conn:
  218. rows = conn.execute(text(sql), {"admin": ADMIN_USER_NO}).mappings().all()
  219. return [dict(r) for r in rows]
  220. def fetch_bx_role_users(engine: Engine) -> Dict[int, Set[int]]:
  221. sql = "SELECT user_id, role_id FROM bx_role_user"
  222. out: Dict[int, Set[int]] = {}
  223. with engine.connect() as conn:
  224. for row in conn.execute(text(sql)).mappings():
  225. uid = int(row["user_id"])
  226. out.setdefault(uid, set()).add(int(row["role_id"]))
  227. return out
  228. def fetch_crm_user_roles(engine: Engine) -> Dict[int, Set[int]]:
  229. sql = "SELECT user_id, role_id FROM sys_user_role"
  230. out: Dict[int, Set[int]] = {}
  231. with engine.connect() as conn:
  232. for row in conn.execute(text(sql)).mappings():
  233. uid = int(row["user_id"])
  234. out.setdefault(uid, set()).add(int(row["role_id"]))
  235. return out
  236. def build_merged_users(
  237. source_engine: Engine,
  238. stats: MigrationStats,
  239. dept_mapping: Optional[Dict[str, str]] = None,
  240. default_dept_id: str = DEFAULT_DEPT_ID,
  241. ) -> List[SourceUser]:
  242. bx_rows = fetch_bx_users(source_engine)
  243. crm_rows = fetch_crm_users(source_engine)
  244. bx_roles = fetch_bx_role_users(source_engine)
  245. crm_roles = fetch_crm_user_roles(source_engine)
  246. merged: Dict[str, SourceUser] = {}
  247. def upsert(key: str, **kwargs: Any) -> SourceUser:
  248. key = key.strip()
  249. if key not in merged:
  250. merged[key] = SourceUser(
  251. user_no=key,
  252. nick_name=key,
  253. phone_no="",
  254. email="",
  255. sex="0",
  256. )
  257. item = merged[key]
  258. for k, v in kwargs.items():
  259. if v is None:
  260. continue
  261. if k == "sources":
  262. values = v if isinstance(v, (list, tuple, set)) else [v]
  263. for s in values:
  264. if s and s not in item.sources:
  265. item.sources.append(s)
  266. elif k == "bx_role_ids" or k == "crm_role_ids":
  267. getattr(item, k).update(v)
  268. elif k in ("phone_no", "email") and getattr(item, k):
  269. continue
  270. else:
  271. setattr(item, k, v)
  272. return item
  273. for row in bx_rows:
  274. name = (row.get("real_name") or "").strip()
  275. if not name:
  276. stats.warnings.append(f"bx_user id={row['id']} 缺少 real_name,已跳过")
  277. continue
  278. upsert(
  279. name,
  280. phone_no=_normalize_phone(row.get("phone")),
  281. email=_clip(row.get("email"), 50),
  282. bx_user_id=int(row["id"]),
  283. bx_role_ids=bx_roles.get(int(row["id"]), set()),
  284. sources="bx",
  285. )
  286. for row in crm_rows:
  287. nick = (row.get("nick_name") or "").strip()
  288. uname = (row.get("user_name") or "").strip()
  289. name = nick if _has_chinese(nick) else (uname if _has_chinese(uname) else nick or uname)
  290. if not name or not _has_chinese(name):
  291. continue
  292. upsert(
  293. name,
  294. phone_no=_normalize_phone(row.get("phonenumber")),
  295. email=_clip(row.get("email"), 50),
  296. sex=str(row.get("sex") or "0"),
  297. crm_user_id=int(row["user_id"]),
  298. crm_role_ids=crm_roles.get(int(row["user_id"]), set()),
  299. sources="crm",
  300. )
  301. # 手机号业务覆盖
  302. for name, phone in PHONE_OVERRIDES.items():
  303. if name in merged:
  304. merged[name].phone_no = phone
  305. dept_mapping = dept_mapping or {}
  306. result = []
  307. for item in merged.values():
  308. item.dept_id = dept_mapping.get(item.user_no, default_dept_id)
  309. result.append(item)
  310. result.sort(key=lambda u: u.user_no)
  311. return result
  312. def target_user_exists_conn(conn, user_no: str, tenant_no: str) -> Optional[str]:
  313. sql = """
  314. SELECT id FROM sys_user
  315. WHERE user_no = :user_no AND tenant_no = :tenant AND del_flag = 0
  316. LIMIT 1
  317. """
  318. row = conn.execute(text(sql), {"user_no": user_no, "tenant": tenant_no}).first()
  319. return str(row[0]) if row else None
  320. def ensure_portal_role(
  321. conn,
  322. role_name: str,
  323. tenant_no: str,
  324. admin_id: str,
  325. dept_id: str,
  326. dry_run: bool,
  327. cache: Dict[str, str],
  328. stats: MigrationStats,
  329. ) -> str:
  330. if role_name in cache:
  331. return cache[role_name]
  332. row = conn.execute(
  333. text(
  334. """
  335. SELECT id FROM sys_role
  336. WHERE role_name = :name AND tenant_no = :tenant AND del_flag = 0
  337. LIMIT 1
  338. """
  339. ),
  340. {"name": role_name, "tenant": tenant_no},
  341. ).first()
  342. if row:
  343. cache[role_name] = str(row[0])
  344. return cache[role_name]
  345. role_id = generate_uuid()
  346. if not dry_run:
  347. conn.execute(
  348. text(
  349. """
  350. INSERT INTO sys_role (
  351. id, role_name, data_scope, status, tenant_no, order_num,
  352. remark, create_by, create_dept_id, create_time, update_by,
  353. update_time, del_flag
  354. ) VALUES (
  355. :id, :role_name, '5', '1', :tenant_no, 10,
  356. '用户迁移脚本自动创建', :create_by, :dept_id, :now, :create_by,
  357. :now, 0
  358. )
  359. """
  360. ),
  361. {
  362. "id": role_id,
  363. "role_name": role_name,
  364. "tenant_no": tenant_no,
  365. "create_by": admin_id,
  366. "dept_id": dept_id,
  367. "now": datetime.now(),
  368. },
  369. )
  370. cache[role_name] = role_id
  371. stats.roles_created += 1
  372. return role_id
  373. def portal_role_names_for_user(user: SourceUser) -> Set[str]:
  374. names: Set[str] = set()
  375. for rid in user.bx_role_ids:
  376. name = BX_ROLE_MAP.get(rid)
  377. if name and name != "管理员":
  378. names.add(name)
  379. for rid in user.crm_role_ids:
  380. name = CRM_ROLE_MAP.get(rid)
  381. if name:
  382. names.add(name)
  383. if not names:
  384. names.add("员工")
  385. return names
  386. def insert_user_role(
  387. conn,
  388. user_id: str,
  389. role_id: str,
  390. tenant_no: str,
  391. admin_id: str,
  392. dept_id: str,
  393. dry_run: bool,
  394. stats: MigrationStats,
  395. ) -> None:
  396. exists = conn.execute(
  397. text(
  398. """
  399. SELECT id FROM sys_user_role
  400. WHERE user_id = :uid AND role_id = :rid AND tenant_no = :tenant AND del_flag = 0
  401. LIMIT 1
  402. """
  403. ),
  404. {"uid": user_id, "rid": role_id, "tenant": tenant_no},
  405. ).first()
  406. if exists:
  407. return
  408. if not dry_run:
  409. conn.execute(
  410. text(
  411. """
  412. INSERT INTO sys_user_role (
  413. id, user_id, role_id, tenant_no, order_num, remark,
  414. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  415. ) VALUES (
  416. :id, :user_id, :role_id, :tenant_no, 0, 'migrate_users',
  417. :create_by, :dept_id, :now, :create_by, :now, 0
  418. )
  419. """
  420. ),
  421. {
  422. "id": generate_uuid(),
  423. "user_id": user_id,
  424. "role_id": role_id,
  425. "tenant_no": tenant_no,
  426. "create_by": admin_id,
  427. "dept_id": dept_id,
  428. "now": datetime.now(),
  429. },
  430. )
  431. stats.user_roles_inserted += 1
  432. def migrate_users(
  433. source_engine: Engine,
  434. target_engine: Engine,
  435. *,
  436. tenant_no: str,
  437. dept_id: str,
  438. dept_mapping: Optional[Dict[str, str]],
  439. dry_run: bool,
  440. with_roles: bool,
  441. stats: MigrationStats,
  442. ) -> List[dict]:
  443. users = build_merged_users(
  444. source_engine, stats, dept_mapping=dept_mapping, default_dept_id=dept_id
  445. )
  446. mapping_rows: List[dict] = []
  447. used_passwords: Set[str] = set()
  448. with target_engine.connect() as raw_conn:
  449. admin_row = raw_conn.execute(
  450. text("SELECT id FROM sys_user WHERE user_no = :u AND del_flag = 0 LIMIT 1"),
  451. {"u": ADMIN_USER_NO},
  452. ).first()
  453. admin_id = str(admin_row[0]) if admin_row else ""
  454. with target_engine.begin() as conn:
  455. role_cache: Dict[str, str] = {}
  456. for user in users:
  457. existing_id = target_user_exists_conn(conn, user.user_no, tenant_no)
  458. plain_password = ""
  459. password_hash = ""
  460. if existing_id:
  461. stats.skipped_users += 1
  462. stats.warnings.append(f"已存在,跳过: {user.user_no} (id={existing_id})")
  463. new_id = existing_id
  464. else:
  465. plain_password = generate_initial_password(used_passwords)
  466. password_hash = get_password_hash(plain_password)
  467. new_id = generate_uuid()
  468. user_dept_id = user.dept_id or dept_id
  469. if not dry_run:
  470. conn.execute(
  471. text(
  472. """
  473. INSERT INTO sys_user (
  474. id, dept_id, user_no, nick_name, email, phone_no, sex,
  475. avatar, password, status, tenant_no, order_num, remark,
  476. create_by, create_dept_id, create_time, update_by,
  477. update_time, del_flag
  478. ) VALUES (
  479. :id, :dept_id, :user_no, :nick_name, :email, :phone_no, :sex,
  480. NULL, :password, '1', :tenant_no, 0, :remark,
  481. :create_by, :dept_id, :now, :create_by, :now, 0
  482. )
  483. """
  484. ),
  485. {
  486. "id": new_id,
  487. "dept_id": user_dept_id,
  488. "user_no": _clip(user.user_no, 30),
  489. "nick_name": _clip(user.nick_name, 30),
  490. "email": _clip(user.email, 50),
  491. "phone_no": _clip(user.phone_no, 11),
  492. "sex": _clip(user.sex, 1) or "0",
  493. "password": password_hash,
  494. "tenant_no": tenant_no,
  495. "remark": f"migrate_users:{','.join(user.sources)}",
  496. "create_by": admin_id or None,
  497. "now": datetime.now(),
  498. },
  499. )
  500. stats.inserted_users += 1
  501. user_dept_id = user.dept_id or dept_id
  502. if with_roles and admin_id:
  503. for role_name in portal_role_names_for_user(user):
  504. role_id = ensure_portal_role(
  505. conn,
  506. role_name,
  507. tenant_no,
  508. admin_id,
  509. user_dept_id,
  510. dry_run,
  511. role_cache,
  512. stats,
  513. )
  514. insert_user_role(
  515. conn,
  516. new_id,
  517. role_id,
  518. tenant_no,
  519. admin_id,
  520. user_dept_id,
  521. dry_run,
  522. stats,
  523. )
  524. mapping_rows.append(
  525. {
  526. "new_user_id": new_id,
  527. "user_no": user.user_no,
  528. "nick_name": user.nick_name,
  529. "phone_no": user.phone_no,
  530. "dept_id": user_dept_id,
  531. "dept_name": DEPT_BY_ID.get(user_dept_id, ""),
  532. "bx_user_id": user.bx_user_id or "",
  533. "crm_user_id": user.crm_user_id or "",
  534. "dibang_user_id": "",
  535. "initial_password": plain_password,
  536. "password_hash_algo": "bcrypt",
  537. "status": "1",
  538. "sources": ",".join(user.sources),
  539. "bx_roles": bx_role_labels(user.bx_role_ids),
  540. "crm_roles": crm_role_labels(user.crm_role_ids),
  541. "portal_roles": ",".join(sorted(portal_role_names_for_user(user))),
  542. }
  543. )
  544. return mapping_rows
  545. def write_mapping_outputs(
  546. mapping_rows: List[dict],
  547. json_path: Optional[Path],
  548. csv_path: Optional[Path],
  549. credentials_path: Optional[Path],
  550. ) -> None:
  551. safe_rows = []
  552. for row in mapping_rows:
  553. item = dict(row)
  554. item.pop("initial_password", None)
  555. safe_rows.append(item)
  556. if json_path:
  557. json_path.parent.mkdir(parents=True, exist_ok=True)
  558. payload = {
  559. "generated_at": datetime.now().isoformat(timespec="seconds"),
  560. "count": len(safe_rows),
  561. "users": safe_rows,
  562. "bx_id_to_user": {
  563. str(r["bx_user_id"]): r["new_user_id"]
  564. for r in safe_rows
  565. if r.get("bx_user_id")
  566. },
  567. "crm_id_to_user": {
  568. str(r["crm_user_id"]): r["new_user_id"]
  569. for r in safe_rows
  570. if r.get("crm_user_id")
  571. },
  572. }
  573. json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  574. if csv_path:
  575. csv_path.parent.mkdir(parents=True, exist_ok=True)
  576. fieldnames = [
  577. "new_user_id",
  578. "user_no",
  579. "nick_name",
  580. "phone_no",
  581. "dept_id",
  582. "dept_name",
  583. "bx_user_id",
  584. "crm_user_id",
  585. "dibang_user_id",
  586. "password_hash_algo",
  587. "status",
  588. "sources",
  589. "bx_roles",
  590. "crm_roles",
  591. "portal_roles",
  592. ]
  593. with csv_path.open("w", encoding="utf-8", newline="") as f:
  594. writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
  595. writer.writeheader()
  596. writer.writerows(safe_rows)
  597. if credentials_path:
  598. credentials_path.parent.mkdir(parents=True, exist_ok=True)
  599. cred_fields = [
  600. "user_no",
  601. "nick_name",
  602. "phone_no",
  603. "dept_name",
  604. "initial_password",
  605. "new_user_id",
  606. ]
  607. with credentials_path.open("w", encoding="utf-8", newline="") as f:
  608. writer = csv.DictWriter(f, fieldnames=cred_fields, extrasaction="ignore")
  609. writer.writeheader()
  610. for row in mapping_rows:
  611. if row.get("initial_password"):
  612. writer.writerow(row)
  613. def parse_args() -> argparse.Namespace:
  614. parser = argparse.ArgumentParser(description="迁移 sx 报销/CRM 用户到 shanxia2.0")
  615. parser.add_argument("--dry-run", action="store_true", help="只演练不写库")
  616. parser.add_argument("--env-file", default=".env.production", help="如 .env.production")
  617. parser.add_argument(
  618. "--source-url",
  619. default=os.getenv("SX_SOURCE_DATABASE_URL", ""),
  620. help="源库 sx 连接串",
  621. )
  622. parser.add_argument(
  623. "--source-db",
  624. default="sx",
  625. help="从目标 DATABASE_URL 推导源库时的库名",
  626. )
  627. parser.add_argument(
  628. "--tenant-no",
  629. default=DEFAULT_TENANT_NO,
  630. help="目标租户编号",
  631. )
  632. parser.add_argument(
  633. "--dept-id",
  634. default=DEFAULT_DEPT_ID,
  635. help="默认部门 ID",
  636. )
  637. parser.add_argument(
  638. "--dept-mapping-csv",
  639. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_import_roster.csv"),
  640. help="按人指定部门的 CSV(填 dept_id 或 dept_name)",
  641. )
  642. parser.add_argument(
  643. "--credentials-out",
  644. default=str(
  645. BACKEND_ROOT.parent / "docs/data-migration/mappings/user_import_credentials.csv"
  646. ),
  647. help="初始明文密码清单(仅新建用户,勿提交版本库)",
  648. )
  649. parser.add_argument(
  650. "--mapping-out",
  651. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.json"),
  652. help="输出 JSON 映射",
  653. )
  654. parser.add_argument(
  655. "--mapping-csv",
  656. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
  657. help="输出 CSV 映射",
  658. )
  659. parser.add_argument(
  660. "--no-roles",
  661. action="store_true",
  662. help="不写入 sys_role / sys_user_role",
  663. )
  664. parser.add_argument(
  665. "--check-infra",
  666. action="store_true",
  667. help="检查 MySQL/Redis/MinIO 连通性",
  668. )
  669. return parser.parse_args()
  670. def main() -> int:
  671. args = parse_args()
  672. load_settings_env(args.env_file or None)
  673. runtime_settings = load_runtime_settings()
  674. target_url = runtime_settings.DATABASE_URL
  675. source_url = args.source_url or derive_source_url(target_url, args.source_db)
  676. print("=== 用户迁移 migrate_users ===")
  677. print(f"模式: {'DRY-RUN' if args.dry_run else 'EXECUTE'}")
  678. print(f"源库: {source_url}")
  679. print(f"目标库: {target_url}")
  680. print(f"租户: {args.tenant_no}")
  681. print("跳过: admin、地磅账号")
  682. print()
  683. source_engine = create_engine(source_url, pool_pre_ping=True)
  684. target_engine = create_engine(target_url, pool_pre_ping=True)
  685. if args.check_infra:
  686. check_mysql(source_engine, "source")
  687. check_mysql(target_engine, "target")
  688. ok, msg = check_redis(runtime_settings)
  689. print(f"Redis: {'OK' if ok else 'FAIL'} — {msg}")
  690. ok, msg = check_minio(runtime_settings)
  691. print(f"MinIO: {'OK' if ok else 'FAIL'} — {msg}")
  692. print()
  693. dept_mapping_path = Path(args.dept_mapping_csv)
  694. dept_mapping = load_dept_mapping_csv(dept_mapping_path, args.dept_id)
  695. if dept_mapping_path.is_file():
  696. filled = sum(
  697. 1
  698. for k, v in dept_mapping.items()
  699. if v and v != args.dept_id
  700. )
  701. print(f"部门映射: {dept_mapping_path.name}({filled} 人已指定非默认部门,其余默认生产现场部)")
  702. else:
  703. print(f"部门映射: 未找到 {dept_mapping_path},全部使用默认部门")
  704. print("密码策略: 每人随机 4 位数字 + bcrypt(与门户一致)")
  705. print()
  706. stats = MigrationStats()
  707. mapping_rows = migrate_users(
  708. source_engine,
  709. target_engine,
  710. tenant_no=args.tenant_no,
  711. dept_id=args.dept_id,
  712. dept_mapping=dept_mapping or None,
  713. dry_run=args.dry_run,
  714. with_roles=not args.no_roles,
  715. stats=stats,
  716. )
  717. if not args.dry_run:
  718. write_mapping_outputs(
  719. mapping_rows,
  720. Path(args.mapping_out),
  721. Path(args.mapping_csv),
  722. Path(args.credentials_out),
  723. )
  724. else:
  725. print("(dry-run 不写库;映射预览见下方统计,正式执行后写入 JSON/CSV)")
  726. print(f"待迁移/处理用户数: {len(mapping_rows)}")
  727. print(f"新增用户: {stats.inserted_users}")
  728. print(f"跳过已存在: {stats.skipped_users}")
  729. print(f"新建角色: {stats.roles_created}")
  730. print(f"用户-角色关联: {stats.user_roles_inserted}")
  731. if stats.warnings:
  732. print("警告:")
  733. for w in stats.warnings:
  734. print(f" - {w}")
  735. if not args.dry_run:
  736. print(f"映射 JSON: {args.mapping_out}")
  737. print(f"映射 CSV: {args.mapping_csv}")
  738. print(f"密码清单: {args.credentials_out}")
  739. print("完成.")
  740. return 0
  741. if __name__ == "__main__":
  742. raise SystemExit(main())