feat: add backend rule group and permission support
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate legacy users from docauditai into leaudit_platform.
|
||||
|
||||
Default mode is dry-run. Use --apply to write data.
|
||||
|
||||
What gets migrated:
|
||||
- sso_users
|
||||
- user_role
|
||||
|
||||
What gets reused:
|
||||
- target roles already seeded in leaudit_platform
|
||||
|
||||
Rules:
|
||||
- preserve legacy user id when inserting into the new database
|
||||
- map missing legacy roles to `common`
|
||||
- normalize area with trim and alias mapping
|
||||
- never overwrite target area with empty value
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_TOML = ROOT / "app.toml"
|
||||
|
||||
ALLOWED_ROLE_KEYS = {"provincial_admin", "admin", "common", "super_admin"}
|
||||
DEFAULT_ROLE_KEY = "common"
|
||||
AREA_ALIASES = {
|
||||
"省": "省局",
|
||||
"省厅": "省局",
|
||||
"省公司": "省局",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegacyUser:
|
||||
id: int
|
||||
sub: str
|
||||
username: str
|
||||
nick_name: str
|
||||
phone_number: str | None
|
||||
email: str | None
|
||||
ou_id: str
|
||||
ou_name: str
|
||||
status: int
|
||||
is_leader: bool
|
||||
created_at: Any
|
||||
updated_at: Any
|
||||
deleted_at: Any
|
||||
password: str | None
|
||||
try_count: int | None
|
||||
try_login_time: Any
|
||||
area: str | None
|
||||
mq_person_uuid: str | None
|
||||
mq_account_uuid: str | None
|
||||
mq_synced_at: Any
|
||||
tenant_name: str | None
|
||||
dep_short_name: str | None
|
||||
dep_name: str | None
|
||||
|
||||
|
||||
def load_target_dsn() -> str:
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover
|
||||
import tomli as tomllib
|
||||
|
||||
with APP_TOML.open("rb") as fh:
|
||||
config = tomllib.load(fh)
|
||||
db = config["DB"]
|
||||
return (
|
||||
f"postgresql://{db['USER']}:{db['PASSWORD']}"
|
||||
f"@{db['HOST']}:{db['PORT']}/{db['NAME']}"
|
||||
)
|
||||
|
||||
|
||||
def build_legacy_dsn(args: argparse.Namespace) -> str:
|
||||
return (
|
||||
f"postgresql://{args.legacy_user}:{args.legacy_password}"
|
||||
f"@{args.legacy_host}:{args.legacy_port}/{args.legacy_db}"
|
||||
)
|
||||
|
||||
|
||||
def normalize_area(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return AREA_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
def choose_roles(role_keys: list[str]) -> list[str]:
|
||||
cleaned: list[str] = []
|
||||
for role_key in role_keys:
|
||||
if role_key in ALLOWED_ROLE_KEYS:
|
||||
cleaned.append(role_key)
|
||||
if not cleaned:
|
||||
cleaned = [DEFAULT_ROLE_KEY]
|
||||
return sorted(set(cleaned))
|
||||
|
||||
|
||||
async def fetch_legacy_users(conn: asyncpg.Connection) -> dict[int, LegacyUser]:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, sub, username, nick_name, phone_number, email,
|
||||
ou_id, ou_name, status, is_leader, created_at, updated_at,
|
||||
deleted_at, password, try_count, try_login_time, area,
|
||||
mq_person_uuid, mq_account_uuid, mq_synced_at,
|
||||
tenant_name, dep_short_name, dep_name
|
||||
FROM sso_users
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
return {
|
||||
row["id"]: LegacyUser(**dict(row))
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
async def fetch_legacy_user_roles(conn: asyncpg.Connection) -> dict[int, list[str]]:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT ur.user_id, r.role_key
|
||||
FROM user_role ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
ORDER BY ur.user_id, r.id
|
||||
"""
|
||||
)
|
||||
result: dict[int, list[str]] = defaultdict(list)
|
||||
for row in rows:
|
||||
result[row["user_id"]].append(row["role_key"])
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_target_roles(conn: asyncpg.Connection) -> dict[str, int]:
|
||||
rows = await conn.fetch("SELECT id, role_key FROM roles ORDER BY id")
|
||||
return {row["role_key"]: row["id"] for row in rows}
|
||||
|
||||
|
||||
async def fetch_target_users(conn: asyncpg.Connection) -> tuple[dict[str, dict[str, Any]], dict[int, dict[str, Any]]]:
|
||||
rows = await conn.fetch("SELECT id, sub, area FROM sso_users")
|
||||
normalized = [dict(row) for row in rows]
|
||||
by_sub = {row["sub"]: row for row in normalized}
|
||||
by_id = {row["id"]: row for row in normalized}
|
||||
return by_sub, by_id
|
||||
|
||||
|
||||
async def ensure_target_ready(conn: asyncpg.Connection) -> None:
|
||||
missing = []
|
||||
for table_name in (
|
||||
"sso_users",
|
||||
"roles",
|
||||
"user_role",
|
||||
"permissions",
|
||||
"role_permissions",
|
||||
"sys_routes",
|
||||
"role_route",
|
||||
):
|
||||
exists = await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
)
|
||||
""",
|
||||
table_name,
|
||||
)
|
||||
if not exists:
|
||||
missing.append(table_name)
|
||||
if missing:
|
||||
raise RuntimeError(f"target database is missing required tables: {', '.join(missing)}")
|
||||
|
||||
|
||||
async def upsert_user(conn: asyncpg.Connection, user: LegacyUser, existing_sub_row: dict[str, Any] | None) -> int:
|
||||
area = normalize_area(user.area)
|
||||
if existing_sub_row:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE sso_users
|
||||
SET username = $2,
|
||||
nick_name = $3,
|
||||
phone_number = $4,
|
||||
email = $5,
|
||||
ou_id = $6,
|
||||
ou_name = $7,
|
||||
status = $8,
|
||||
is_leader = $9,
|
||||
updated_at = $10,
|
||||
deleted_at = $11,
|
||||
password = $12,
|
||||
try_count = $13,
|
||||
try_login_time = $14,
|
||||
area = COALESCE($15, area),
|
||||
mq_person_uuid = $16,
|
||||
mq_account_uuid = $17,
|
||||
mq_synced_at = $18,
|
||||
tenant_name = $19,
|
||||
dep_short_name = $20,
|
||||
dep_name = $21
|
||||
WHERE sub = $1
|
||||
""",
|
||||
user.sub,
|
||||
user.username,
|
||||
user.nick_name,
|
||||
user.phone_number,
|
||||
user.email,
|
||||
user.ou_id,
|
||||
user.ou_name,
|
||||
user.status,
|
||||
user.is_leader,
|
||||
user.updated_at,
|
||||
user.deleted_at,
|
||||
user.password,
|
||||
user.try_count,
|
||||
user.try_login_time,
|
||||
area,
|
||||
user.mq_person_uuid,
|
||||
user.mq_account_uuid,
|
||||
user.mq_synced_at,
|
||||
user.tenant_name,
|
||||
user.dep_short_name,
|
||||
user.dep_name,
|
||||
)
|
||||
return int(existing_sub_row["id"])
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO sso_users (
|
||||
id, sub, username, nick_name, phone_number, email, ou_id, ou_name,
|
||||
status, is_leader, created_at, updated_at, deleted_at, password,
|
||||
try_count, try_login_time, area, mq_person_uuid, mq_account_uuid,
|
||||
mq_synced_at, tenant_name, dep_short_name, dep_name
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
$9, $10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18, $19,
|
||||
$20, $21, $22, $23
|
||||
)
|
||||
""",
|
||||
user.id,
|
||||
user.sub,
|
||||
user.username,
|
||||
user.nick_name,
|
||||
user.phone_number,
|
||||
user.email,
|
||||
user.ou_id,
|
||||
user.ou_name,
|
||||
user.status,
|
||||
user.is_leader,
|
||||
user.created_at,
|
||||
user.updated_at,
|
||||
user.deleted_at,
|
||||
user.password,
|
||||
user.try_count,
|
||||
user.try_login_time,
|
||||
area,
|
||||
user.mq_person_uuid,
|
||||
user.mq_account_uuid,
|
||||
user.mq_synced_at,
|
||||
user.tenant_name,
|
||||
user.dep_short_name,
|
||||
user.dep_name,
|
||||
)
|
||||
return user.id
|
||||
|
||||
|
||||
async def assign_roles(
|
||||
conn: asyncpg.Connection,
|
||||
target_user_id: int,
|
||||
role_keys: list[str],
|
||||
role_key_to_id: dict[str, int],
|
||||
) -> None:
|
||||
for role_key in role_keys:
|
||||
role_id = role_key_to_id.get(role_key)
|
||||
if role_id is None:
|
||||
raise RuntimeError(f"target role not found: {role_key}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO user_role (user_id, role_id, created_at, updated_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
ON CONFLICT (user_id, role_id) DO NOTHING
|
||||
""",
|
||||
target_user_id,
|
||||
role_id,
|
||||
)
|
||||
|
||||
|
||||
async def sync_user_sequence(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('sso_users', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM sso_users), 1),
|
||||
true
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def main_async(args: argparse.Namespace) -> int:
|
||||
legacy_conn = await asyncpg.connect(build_legacy_dsn(args))
|
||||
target_conn = await asyncpg.connect(load_target_dsn())
|
||||
try:
|
||||
await ensure_target_ready(target_conn)
|
||||
|
||||
legacy_users = await fetch_legacy_users(legacy_conn)
|
||||
legacy_user_roles = await fetch_legacy_user_roles(legacy_conn)
|
||||
target_role_map = await fetch_target_roles(target_conn)
|
||||
target_by_sub, target_by_id = await fetch_target_users(target_conn)
|
||||
|
||||
missing_target_roles = sorted(set(ALLOWED_ROLE_KEYS) - set(target_role_map))
|
||||
if missing_target_roles:
|
||||
raise RuntimeError(
|
||||
"target database is missing seeded roles: " + ", ".join(missing_target_roles)
|
||||
)
|
||||
|
||||
summary = Counter()
|
||||
role_summary = Counter()
|
||||
id_conflicts: list[tuple[int, str, int, str]] = []
|
||||
|
||||
async def process_all() -> None:
|
||||
for user_id, user in legacy_users.items():
|
||||
source_roles = legacy_user_roles.get(user_id, [])
|
||||
desired_roles = choose_roles(source_roles)
|
||||
role_summary.update(desired_roles)
|
||||
if not source_roles:
|
||||
summary["default_common_role"] += 1
|
||||
|
||||
existing_sub_row = target_by_sub.get(user.sub)
|
||||
existing_id_row = target_by_id.get(user.id)
|
||||
if existing_id_row and (existing_sub_row is None or existing_sub_row["sub"] != user.sub):
|
||||
id_conflicts.append((user.id, user.sub, int(existing_id_row["id"]), existing_id_row["sub"]))
|
||||
summary["id_conflict"] += 1
|
||||
continue
|
||||
|
||||
if existing_sub_row:
|
||||
summary["update_user"] += 1
|
||||
else:
|
||||
summary["insert_user"] += 1
|
||||
|
||||
if args.apply:
|
||||
target_user_id = await upsert_user(target_conn, user, existing_sub_row)
|
||||
await assign_roles(target_conn, target_user_id, desired_roles, target_role_map)
|
||||
target_by_sub[user.sub] = {"id": target_user_id, "sub": user.sub, "area": normalize_area(user.area)}
|
||||
target_by_id[user.id] = {"id": target_user_id, "sub": user.sub, "area": normalize_area(user.area)}
|
||||
|
||||
if args.apply:
|
||||
async with target_conn.transaction():
|
||||
await process_all()
|
||||
await sync_user_sequence(target_conn)
|
||||
else:
|
||||
await process_all()
|
||||
|
||||
print("=== Migration Summary ===")
|
||||
print(f"mode: {'APPLY' if args.apply else 'DRY_RUN'}")
|
||||
print(f"legacy_users_total: {len(legacy_users)}")
|
||||
for key in sorted(summary):
|
||||
print(f"{key}: {summary[key]}")
|
||||
print("role_assignment_plan:")
|
||||
for role_key, count in sorted(role_summary.items()):
|
||||
print(f" {role_key}: {count}")
|
||||
print(f"id_conflicts: {len(id_conflicts)}")
|
||||
if id_conflicts:
|
||||
print("sample_id_conflicts:")
|
||||
for conflict in id_conflicts[:20]:
|
||||
print(f" legacy_id={conflict[0]} legacy_sub={conflict[1]} target_id={conflict[2]} target_sub={conflict[3]}")
|
||||
|
||||
if not args.apply:
|
||||
print("dry-run complete; rerun with --apply to write data")
|
||||
return 0
|
||||
finally:
|
||||
await legacy_conn.close()
|
||||
await target_conn.close()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Migrate legacy users into leaudit_platform")
|
||||
parser.add_argument("--legacy-host", default=os.getenv("LEGACY_DB_HOST", "172.16.0.81"))
|
||||
parser.add_argument("--legacy-port", type=int, default=int(os.getenv("LEGACY_DB_PORT", "54302")))
|
||||
parser.add_argument("--legacy-db", default=os.getenv("LEGACY_DB_NAME", "docauditai"))
|
||||
parser.add_argument("--legacy-user", default=os.getenv("LEGACY_DB_USER", "docauditai_admin"))
|
||||
parser.add_argument("--legacy-password", default=os.getenv("LEGACY_DB_PASSWORD", "zhfw*123*"))
|
||||
parser.add_argument("--apply", action="store_true", help="write data into target database")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
return asyncio.run(main_async(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user