migrate_user_role_to_post.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. #!/usr/bin/env python3
  2. """将「用户-角色」迁移为「用户-职位 + 职位-角色」,并清理直接绑定。
  3. 步骤(与产品约定一致):
  4. 1. 软删除清空 sys_post / sys_user_post / sys_post_role(指定租户)
  5. 2. 按 sys_role(未删除)逐条创建职位:post_name=角色名,post_code=名称拼音首字母缩写;
  6. 并为职位绑定该角色(sys_post_role 一对一)
  7. 3. 按 sys_user_role 为用户绑定对应职位(sys_user_post)
  8. 4. 软删除清空 sys_user_role(指定租户)
  9. 用法(在 backend 目录):
  10. # 务必指向测试库,勿连生产
  11. export ENV_FILE=./.env.test # 或确认 .env 已是测试库
  12. source venv/bin/activate
  13. pip install pypinyin # 若尚未安装
  14. # 只预览,不写库
  15. python scripts/migrate_user_role_to_post.py --dry-run
  16. # 真正执行(需同时传 --execute 与库名确认)
  17. python scripts/migrate_user_role_to_post.py --execute --confirm-database zkxw-sc
  18. 注意:
  19. - 菜单/数据权限运行时已走有效角色并集,迁移后可由职位继承。
  20. - 仍有少量代码只查 sys_user_role(如「实验专员」步骤负责人校验、
  21. 质检金额授权列表、工作流按角色选人)。若业务依赖这些入口,需另改代码或
  22. 保留对应直接绑定(可用 --preserve-role-ids)。
  23. """
  24. from __future__ import annotations
  25. import argparse
  26. import os
  27. import re
  28. import sys
  29. from datetime import datetime
  30. from pathlib import Path
  31. from typing import Dict, List, Optional, Set, Tuple
  32. from urllib.parse import urlparse
  33. ROOT = Path(__file__).resolve().parents[1]
  34. if str(ROOT) not in sys.path:
  35. sys.path.insert(0, str(ROOT))
  36. try:
  37. from pypinyin import Style, lazy_pinyin
  38. except ImportError as exc: # pragma: no cover
  39. raise SystemExit(
  40. "缺少依赖 pypinyin。请先执行: pip install pypinyin\n" + str(exc)
  41. ) from exc
  42. from sqlalchemy import and_
  43. from app.core.config import settings
  44. from app.core.database import SessionLocal
  45. from app.core.utils import generate_uuid
  46. from app.models.models import SysPost, SysPostRole, SysRole, SysUserPost, SysUserRole
  47. # 质检物料金额查看:授权管理模块仍读写 sys_user_role;默认迁移后一并清理,
  48. # 若需保留其直接绑定,执行时加:
  49. # --preserve-role-ids ROLE000000000000000000000000000011
  50. DEFAULT_PRESERVE_ROLE_IDS: Tuple[str, ...] = ()
  51. def _mask_database_url(url: str) -> str:
  52. try:
  53. u = urlparse(url)
  54. user = u.username or ""
  55. host = u.hostname or ""
  56. port = f":{u.port}" if u.port else ""
  57. db = (u.path or "").lstrip("/")
  58. return f"{u.scheme}://{user}:***@{host}{port}/{db}"
  59. except Exception:
  60. return "***"
  61. def _database_name(url: str) -> str:
  62. try:
  63. return (urlparse(url).path or "").lstrip("/").split("?")[0]
  64. except Exception:
  65. return ""
  66. def role_name_to_post_code(role_name: str, used: Set[str]) -> str:
  67. """职位编码 = 角色名拼音首字母缩写(大写),冲突则追加序号。"""
  68. name = (role_name or "").strip() or "POST"
  69. letters = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER))
  70. base = re.sub(r"[^A-Za-z0-9]", "", letters).upper()
  71. if not base:
  72. base = "POST"
  73. # 编码不宜过长
  74. base = base[:48]
  75. code = base
  76. n = 2
  77. while code in used:
  78. suffix = f"-{n}"
  79. code = (base[: 48 - len(suffix)] + suffix).upper()
  80. n += 1
  81. used.add(code)
  82. return code
  83. def soft_clear_posts(db, tenant_no: str, now: datetime, dry_run: bool) -> Dict[str, int]:
  84. counts = {}
  85. for model, key in (
  86. (SysPostRole, "sys_post_role"),
  87. (SysUserPost, "sys_user_post"),
  88. (SysPost, "sys_post"),
  89. ):
  90. q = db.query(model).filter(model.tenant_no == tenant_no, model.del_flag == 0)
  91. n = q.count()
  92. counts[key] = n
  93. if not dry_run and n:
  94. q.update(
  95. {"del_flag": 1, "update_time": now},
  96. synchronize_session=False,
  97. )
  98. return counts
  99. def load_roles(db, tenant_no: str) -> List[SysRole]:
  100. return (
  101. db.query(SysRole)
  102. .filter(SysRole.tenant_no == tenant_no, SysRole.del_flag == 0)
  103. .order_by(SysRole.order_num.asc(), SysRole.create_time.asc())
  104. .all()
  105. )
  106. def create_posts_for_roles(
  107. db,
  108. roles: List[SysRole],
  109. tenant_no: str,
  110. operator_id: Optional[str],
  111. now: datetime,
  112. dry_run: bool,
  113. ) -> Dict[str, str]:
  114. """返回 role_id -> post_id。"""
  115. used_codes: Set[str] = set()
  116. role_to_post: Dict[str, str] = {}
  117. for idx, role in enumerate(roles, start=1):
  118. post_id = generate_uuid()
  119. code = role_name_to_post_code(role.role_name, used_codes)
  120. status = (role.status or "1").strip() or "1"
  121. print(
  122. f" [{idx}/{len(roles)}] 角色「{role.role_name}」({role.id}) "
  123. f"→ 职位 {code} / {role.role_name}"
  124. )
  125. role_to_post[role.id] = post_id
  126. if dry_run:
  127. continue
  128. db.add(
  129. SysPost(
  130. id=post_id,
  131. post_name=role.role_name,
  132. post_code=code,
  133. status=status,
  134. tenant_no=tenant_no,
  135. order_num=role.order_num if role.order_num is not None else idx,
  136. remark=f"由角色迁移生成,role_id={role.id}",
  137. create_by=operator_id,
  138. create_dept_id="",
  139. create_time=now,
  140. update_by=operator_id,
  141. update_time=now,
  142. del_flag=0,
  143. )
  144. )
  145. db.add(
  146. SysPostRole(
  147. id=generate_uuid(),
  148. post_id=post_id,
  149. role_id=role.id,
  150. tenant_no=tenant_no,
  151. order_num=0,
  152. remark="角色一对一迁移",
  153. create_by=operator_id,
  154. create_dept_id="",
  155. create_time=now,
  156. update_by=operator_id,
  157. update_time=now,
  158. del_flag=0,
  159. )
  160. )
  161. return role_to_post
  162. def migrate_user_posts(
  163. db,
  164. tenant_no: str,
  165. role_to_post: Dict[str, str],
  166. operator_id: Optional[str],
  167. now: datetime,
  168. dry_run: bool,
  169. ) -> Tuple[int, int]:
  170. """按用户-角色生成用户-职位。返回 (将写入条数, 跳过条数)。"""
  171. rows = (
  172. db.query(SysUserRole)
  173. .filter(SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0)
  174. .all()
  175. )
  176. # 同一用户同一职位只绑一次
  177. seen: Set[Tuple[str, str]] = set()
  178. written = 0
  179. skipped = 0
  180. for ur in rows:
  181. post_id = role_to_post.get(ur.role_id)
  182. if not post_id:
  183. skipped += 1
  184. continue
  185. key = (ur.user_id, post_id)
  186. if key in seen:
  187. skipped += 1
  188. continue
  189. seen.add(key)
  190. written += 1
  191. if dry_run:
  192. continue
  193. db.add(
  194. SysUserPost(
  195. id=generate_uuid(),
  196. user_id=ur.user_id,
  197. post_id=post_id,
  198. tenant_no=tenant_no,
  199. order_num=ur.order_num if ur.order_num is not None else 0,
  200. remark=f"由用户角色迁移,role_id={ur.role_id}",
  201. create_by=operator_id,
  202. create_dept_id="",
  203. create_time=now,
  204. update_by=operator_id,
  205. update_time=now,
  206. del_flag=0,
  207. )
  208. )
  209. return written, skipped
  210. def clear_user_roles(
  211. db,
  212. tenant_no: str,
  213. preserve_role_ids: Set[str],
  214. now: datetime,
  215. dry_run: bool,
  216. ) -> Tuple[int, int]:
  217. q = db.query(SysUserRole).filter(
  218. SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0
  219. )
  220. if preserve_role_ids:
  221. q = q.filter(~SysUserRole.role_id.in_(list(preserve_role_ids)))
  222. n = q.count()
  223. preserved = 0
  224. if preserve_role_ids:
  225. preserved = (
  226. db.query(SysUserRole)
  227. .filter(
  228. SysUserRole.tenant_no == tenant_no,
  229. SysUserRole.del_flag == 0,
  230. SysUserRole.role_id.in_(list(preserve_role_ids)),
  231. )
  232. .count()
  233. )
  234. if not dry_run and n:
  235. q.update({"del_flag": 1, "update_time": now}, synchronize_session=False)
  236. return n, preserved
  237. def run(
  238. *,
  239. tenant_no: str,
  240. dry_run: bool,
  241. execute: bool,
  242. confirm_database: Optional[str],
  243. operator_id: Optional[str],
  244. preserve_role_ids: Set[str],
  245. ) -> None:
  246. db_url = settings.DATABASE_URL
  247. db_name = _database_name(db_url)
  248. print("=== 用户角色 → 职位 迁移 ===")
  249. print(f"ENV_FILE : {os.getenv('ENV_FILE', '(default backend/.env)')}")
  250. print(f"DATABASE_URL : {_mask_database_url(db_url)}")
  251. print(f"database : {db_name}")
  252. print(f"tenant_no : {tenant_no}")
  253. print(f"mode : {'DRY-RUN' if dry_run or not execute else 'EXECUTE'}")
  254. if preserve_role_ids:
  255. print(f"preserve UR : {', '.join(sorted(preserve_role_ids))}")
  256. print()
  257. if execute and not dry_run:
  258. if not confirm_database:
  259. raise SystemExit(
  260. "执行写库必须传 --confirm-database <库名>,"
  261. f"当前检测到库名为「{db_name}」。"
  262. )
  263. if confirm_database != db_name:
  264. raise SystemExit(
  265. f"--confirm-database={confirm_database!r} 与当前连接库名 "
  266. f"{db_name!r} 不一致,已中止。"
  267. )
  268. db = SessionLocal()
  269. now = datetime.now()
  270. try:
  271. roles = load_roles(db, tenant_no)
  272. user_role_count = (
  273. db.query(SysUserRole)
  274. .filter(SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0)
  275. .count()
  276. )
  277. print(f"当前有效角色数 : {len(roles)}")
  278. print(f"当前用户-角色绑定数: {user_role_count}")
  279. print()
  280. print("步骤1:清空职位相关表(软删除)…")
  281. cleared = soft_clear_posts(db, tenant_no, now, dry_run=dry_run or not execute)
  282. for k, v in cleared.items():
  283. print(f" {k}: {v}")
  284. print("步骤2:按角色创建职位并绑定角色…")
  285. role_to_post = create_posts_for_roles(
  286. db,
  287. roles,
  288. tenant_no,
  289. operator_id,
  290. now,
  291. dry_run=dry_run or not execute,
  292. )
  293. print("步骤3:按用户-角色生成用户-职位…")
  294. written, skipped = migrate_user_posts(
  295. db,
  296. tenant_no,
  297. role_to_post,
  298. operator_id,
  299. now,
  300. dry_run=dry_run or not execute,
  301. )
  302. print(f" 将写入用户-职位: {written},跳过: {skipped}")
  303. print("步骤4:清理用户-角色直接绑定(软删除)…")
  304. cleared_ur, preserved_ur = clear_user_roles(
  305. db,
  306. tenant_no,
  307. preserve_role_ids,
  308. now,
  309. dry_run=dry_run or not execute,
  310. )
  311. print(f" 将清理用户-角色: {cleared_ur},保留: {preserved_ur}")
  312. if dry_run or not execute:
  313. db.rollback()
  314. print()
  315. print("DRY-RUN 完成,未写入数据库。确认无误后执行:")
  316. print(
  317. f" ENV_FILE=... python scripts/migrate_user_role_to_post.py "
  318. f"--execute --confirm-database {db_name}"
  319. )
  320. else:
  321. db.commit()
  322. print()
  323. print("迁移已提交。")
  324. print(
  325. "请在测试环境登录验证:菜单权限、数据权限、职位管理、用户查看中的有效角色。"
  326. )
  327. except Exception:
  328. db.rollback()
  329. raise
  330. finally:
  331. db.close()
  332. def main() -> None:
  333. parser = argparse.ArgumentParser(
  334. description="将用户-角色迁移为用户-职位(测试库专用)"
  335. )
  336. parser.add_argument("--tenant-no", default="zkxw", help="租户编号,默认 zkxw")
  337. parser.add_argument(
  338. "--dry-run",
  339. action="store_true",
  340. help="仅预览(默认若不带 --execute 也等同预览)",
  341. )
  342. parser.add_argument(
  343. "--execute",
  344. action="store_true",
  345. help="真正写库(必须同时传 --confirm-database)",
  346. )
  347. parser.add_argument(
  348. "--confirm-database",
  349. default=None,
  350. help="当前连接的数据库名(与 DATABASE_URL 路径段一致)",
  351. )
  352. parser.add_argument(
  353. "--operator-id",
  354. default=None,
  355. help="create_by / update_by,可选",
  356. )
  357. parser.add_argument(
  358. "--preserve-role-ids",
  359. default="",
  360. help="清理 sys_user_role 时保留的角色 ID,逗号分隔",
  361. )
  362. args = parser.parse_args()
  363. preserve = set(DEFAULT_PRESERVE_ROLE_IDS)
  364. for part in (args.preserve_role_ids or "").split(","):
  365. pid = part.strip()
  366. if pid:
  367. preserve.add(pid)
  368. # 未显式 --execute 时一律 dry-run
  369. dry_run = bool(args.dry_run) or not bool(args.execute)
  370. run(
  371. tenant_no=args.tenant_no,
  372. dry_run=dry_run,
  373. execute=bool(args.execute),
  374. confirm_database=args.confirm_database,
  375. operator_id=args.operator_id,
  376. preserve_role_ids=preserve,
  377. )
  378. if __name__ == "__main__":
  379. main()