| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- #!/usr/bin/env python3
- """将「用户-角色」迁移为「用户-职位 + 职位-角色」,并清理直接绑定。
- 步骤(与产品约定一致):
- 1. 软删除清空 sys_post / sys_user_post / sys_post_role(指定租户)
- 2. 按 sys_role(未删除)逐条创建职位:post_name=角色名,post_code=名称拼音首字母缩写;
- 并为职位绑定该角色(sys_post_role 一对一)
- 3. 按 sys_user_role 为用户绑定对应职位(sys_user_post)
- 4. 软删除清空 sys_user_role(指定租户)
- 用法(在 backend 目录):
- # 务必指向测试库,勿连生产
- export ENV_FILE=./.env.test # 或确认 .env 已是测试库
- source venv/bin/activate
- pip install pypinyin # 若尚未安装
- # 只预览,不写库
- python scripts/migrate_user_role_to_post.py --dry-run
- # 真正执行(需同时传 --execute 与库名确认)
- python scripts/migrate_user_role_to_post.py --execute --confirm-database zkxw-sc
- 注意:
- - 菜单/数据权限运行时已走有效角色并集,迁移后可由职位继承。
- - 仍有少量代码只查 sys_user_role(如「实验专员」步骤负责人校验、
- 质检金额授权列表、工作流按角色选人)。若业务依赖这些入口,需另改代码或
- 保留对应直接绑定(可用 --preserve-role-ids)。
- """
- from __future__ import annotations
- import argparse
- import os
- import re
- import sys
- from datetime import datetime
- from pathlib import Path
- from typing import Dict, List, Optional, Set, Tuple
- from urllib.parse import urlparse
- ROOT = Path(__file__).resolve().parents[1]
- if str(ROOT) not in sys.path:
- sys.path.insert(0, str(ROOT))
- try:
- from pypinyin import Style, lazy_pinyin
- except ImportError as exc: # pragma: no cover
- raise SystemExit(
- "缺少依赖 pypinyin。请先执行: pip install pypinyin\n" + str(exc)
- ) from exc
- from sqlalchemy import and_
- from app.core.config import settings
- from app.core.database import SessionLocal
- from app.core.utils import generate_uuid
- from app.models.models import SysPost, SysPostRole, SysRole, SysUserPost, SysUserRole
- # 质检物料金额查看:授权管理模块仍读写 sys_user_role;默认迁移后一并清理,
- # 若需保留其直接绑定,执行时加:
- # --preserve-role-ids ROLE000000000000000000000000000011
- DEFAULT_PRESERVE_ROLE_IDS: Tuple[str, ...] = ()
- def _mask_database_url(url: str) -> str:
- try:
- u = urlparse(url)
- user = u.username or ""
- host = u.hostname or ""
- port = f":{u.port}" if u.port else ""
- db = (u.path or "").lstrip("/")
- return f"{u.scheme}://{user}:***@{host}{port}/{db}"
- except Exception:
- return "***"
- def _database_name(url: str) -> str:
- try:
- return (urlparse(url).path or "").lstrip("/").split("?")[0]
- except Exception:
- return ""
- def role_name_to_post_code(role_name: str, used: Set[str]) -> str:
- """职位编码 = 角色名拼音首字母缩写(大写),冲突则追加序号。"""
- name = (role_name or "").strip() or "POST"
- letters = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER))
- base = re.sub(r"[^A-Za-z0-9]", "", letters).upper()
- if not base:
- base = "POST"
- # 编码不宜过长
- base = base[:48]
- code = base
- n = 2
- while code in used:
- suffix = f"-{n}"
- code = (base[: 48 - len(suffix)] + suffix).upper()
- n += 1
- used.add(code)
- return code
- def soft_clear_posts(db, tenant_no: str, now: datetime, dry_run: bool) -> Dict[str, int]:
- counts = {}
- for model, key in (
- (SysPostRole, "sys_post_role"),
- (SysUserPost, "sys_user_post"),
- (SysPost, "sys_post"),
- ):
- q = db.query(model).filter(model.tenant_no == tenant_no, model.del_flag == 0)
- n = q.count()
- counts[key] = n
- if not dry_run and n:
- q.update(
- {"del_flag": 1, "update_time": now},
- synchronize_session=False,
- )
- return counts
- def load_roles(db, tenant_no: str) -> List[SysRole]:
- return (
- db.query(SysRole)
- .filter(SysRole.tenant_no == tenant_no, SysRole.del_flag == 0)
- .order_by(SysRole.order_num.asc(), SysRole.create_time.asc())
- .all()
- )
- def create_posts_for_roles(
- db,
- roles: List[SysRole],
- tenant_no: str,
- operator_id: Optional[str],
- now: datetime,
- dry_run: bool,
- ) -> Dict[str, str]:
- """返回 role_id -> post_id。"""
- used_codes: Set[str] = set()
- role_to_post: Dict[str, str] = {}
- for idx, role in enumerate(roles, start=1):
- post_id = generate_uuid()
- code = role_name_to_post_code(role.role_name, used_codes)
- status = (role.status or "1").strip() or "1"
- print(
- f" [{idx}/{len(roles)}] 角色「{role.role_name}」({role.id}) "
- f"→ 职位 {code} / {role.role_name}"
- )
- role_to_post[role.id] = post_id
- if dry_run:
- continue
- db.add(
- SysPost(
- id=post_id,
- post_name=role.role_name,
- post_code=code,
- status=status,
- tenant_no=tenant_no,
- order_num=role.order_num if role.order_num is not None else idx,
- remark=f"由角色迁移生成,role_id={role.id}",
- create_by=operator_id,
- create_dept_id="",
- create_time=now,
- update_by=operator_id,
- update_time=now,
- del_flag=0,
- )
- )
- db.add(
- SysPostRole(
- id=generate_uuid(),
- post_id=post_id,
- role_id=role.id,
- tenant_no=tenant_no,
- order_num=0,
- remark="角色一对一迁移",
- create_by=operator_id,
- create_dept_id="",
- create_time=now,
- update_by=operator_id,
- update_time=now,
- del_flag=0,
- )
- )
- return role_to_post
- def migrate_user_posts(
- db,
- tenant_no: str,
- role_to_post: Dict[str, str],
- operator_id: Optional[str],
- now: datetime,
- dry_run: bool,
- ) -> Tuple[int, int]:
- """按用户-角色生成用户-职位。返回 (将写入条数, 跳过条数)。"""
- rows = (
- db.query(SysUserRole)
- .filter(SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0)
- .all()
- )
- # 同一用户同一职位只绑一次
- seen: Set[Tuple[str, str]] = set()
- written = 0
- skipped = 0
- for ur in rows:
- post_id = role_to_post.get(ur.role_id)
- if not post_id:
- skipped += 1
- continue
- key = (ur.user_id, post_id)
- if key in seen:
- skipped += 1
- continue
- seen.add(key)
- written += 1
- if dry_run:
- continue
- db.add(
- SysUserPost(
- id=generate_uuid(),
- user_id=ur.user_id,
- post_id=post_id,
- tenant_no=tenant_no,
- order_num=ur.order_num if ur.order_num is not None else 0,
- remark=f"由用户角色迁移,role_id={ur.role_id}",
- create_by=operator_id,
- create_dept_id="",
- create_time=now,
- update_by=operator_id,
- update_time=now,
- del_flag=0,
- )
- )
- return written, skipped
- def clear_user_roles(
- db,
- tenant_no: str,
- preserve_role_ids: Set[str],
- now: datetime,
- dry_run: bool,
- ) -> Tuple[int, int]:
- q = db.query(SysUserRole).filter(
- SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0
- )
- if preserve_role_ids:
- q = q.filter(~SysUserRole.role_id.in_(list(preserve_role_ids)))
- n = q.count()
- preserved = 0
- if preserve_role_ids:
- preserved = (
- db.query(SysUserRole)
- .filter(
- SysUserRole.tenant_no == tenant_no,
- SysUserRole.del_flag == 0,
- SysUserRole.role_id.in_(list(preserve_role_ids)),
- )
- .count()
- )
- if not dry_run and n:
- q.update({"del_flag": 1, "update_time": now}, synchronize_session=False)
- return n, preserved
- def run(
- *,
- tenant_no: str,
- dry_run: bool,
- execute: bool,
- confirm_database: Optional[str],
- operator_id: Optional[str],
- preserve_role_ids: Set[str],
- ) -> None:
- db_url = settings.DATABASE_URL
- db_name = _database_name(db_url)
- print("=== 用户角色 → 职位 迁移 ===")
- print(f"ENV_FILE : {os.getenv('ENV_FILE', '(default backend/.env)')}")
- print(f"DATABASE_URL : {_mask_database_url(db_url)}")
- print(f"database : {db_name}")
- print(f"tenant_no : {tenant_no}")
- print(f"mode : {'DRY-RUN' if dry_run or not execute else 'EXECUTE'}")
- if preserve_role_ids:
- print(f"preserve UR : {', '.join(sorted(preserve_role_ids))}")
- print()
- if execute and not dry_run:
- if not confirm_database:
- raise SystemExit(
- "执行写库必须传 --confirm-database <库名>,"
- f"当前检测到库名为「{db_name}」。"
- )
- if confirm_database != db_name:
- raise SystemExit(
- f"--confirm-database={confirm_database!r} 与当前连接库名 "
- f"{db_name!r} 不一致,已中止。"
- )
- db = SessionLocal()
- now = datetime.now()
- try:
- roles = load_roles(db, tenant_no)
- user_role_count = (
- db.query(SysUserRole)
- .filter(SysUserRole.tenant_no == tenant_no, SysUserRole.del_flag == 0)
- .count()
- )
- print(f"当前有效角色数 : {len(roles)}")
- print(f"当前用户-角色绑定数: {user_role_count}")
- print()
- print("步骤1:清空职位相关表(软删除)…")
- cleared = soft_clear_posts(db, tenant_no, now, dry_run=dry_run or not execute)
- for k, v in cleared.items():
- print(f" {k}: {v}")
- print("步骤2:按角色创建职位并绑定角色…")
- role_to_post = create_posts_for_roles(
- db,
- roles,
- tenant_no,
- operator_id,
- now,
- dry_run=dry_run or not execute,
- )
- print("步骤3:按用户-角色生成用户-职位…")
- written, skipped = migrate_user_posts(
- db,
- tenant_no,
- role_to_post,
- operator_id,
- now,
- dry_run=dry_run or not execute,
- )
- print(f" 将写入用户-职位: {written},跳过: {skipped}")
- print("步骤4:清理用户-角色直接绑定(软删除)…")
- cleared_ur, preserved_ur = clear_user_roles(
- db,
- tenant_no,
- preserve_role_ids,
- now,
- dry_run=dry_run or not execute,
- )
- print(f" 将清理用户-角色: {cleared_ur},保留: {preserved_ur}")
- if dry_run or not execute:
- db.rollback()
- print()
- print("DRY-RUN 完成,未写入数据库。确认无误后执行:")
- print(
- f" ENV_FILE=... python scripts/migrate_user_role_to_post.py "
- f"--execute --confirm-database {db_name}"
- )
- else:
- db.commit()
- print()
- print("迁移已提交。")
- print(
- "请在测试环境登录验证:菜单权限、数据权限、职位管理、用户查看中的有效角色。"
- )
- except Exception:
- db.rollback()
- raise
- finally:
- db.close()
- def main() -> None:
- parser = argparse.ArgumentParser(
- description="将用户-角色迁移为用户-职位(测试库专用)"
- )
- parser.add_argument("--tenant-no", default="zkxw", help="租户编号,默认 zkxw")
- parser.add_argument(
- "--dry-run",
- action="store_true",
- help="仅预览(默认若不带 --execute 也等同预览)",
- )
- parser.add_argument(
- "--execute",
- action="store_true",
- help="真正写库(必须同时传 --confirm-database)",
- )
- parser.add_argument(
- "--confirm-database",
- default=None,
- help="当前连接的数据库名(与 DATABASE_URL 路径段一致)",
- )
- parser.add_argument(
- "--operator-id",
- default=None,
- help="create_by / update_by,可选",
- )
- parser.add_argument(
- "--preserve-role-ids",
- default="",
- help="清理 sys_user_role 时保留的角色 ID,逗号分隔",
- )
- args = parser.parse_args()
- preserve = set(DEFAULT_PRESERVE_ROLE_IDS)
- for part in (args.preserve_role_ids or "").split(","):
- pid = part.strip()
- if pid:
- preserve.add(pid)
- # 未显式 --execute 时一律 dry-run
- dry_run = bool(args.dry_run) or not bool(args.execute)
- run(
- tenant_no=args.tenant_no,
- dry_run=dry_run,
- execute=bool(args.execute),
- confirm_database=args.confirm_database,
- operator_id=args.operator_id,
- preserve_role_ids=preserve,
- )
- if __name__ == "__main__":
- main()
|