| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- 04-01-dedupe_sys_dept.py
- 目的:
- 在 sys_dept 表中,对于同一学院(parent_id)下的相同专业名称(dept_name)只保留一条记录,删除重复记录。
- 行为:
- - 默认 dry-run:打印将要执行的 SQL 操作,不做修改。
- - 传入 --apply 会执行修改(在事务中)。
- - 对于重复记录,会保留 dept_id 最小的那条作为主记录(keep_id),
- 并将重复记录的子部门(parent_id 指向被删除 id) 统一改为 keep_id,
- 同时将 sys_user 表中引用的 dept_id 更新为 keep_id。
- - 会打印处理摘要。
- 使用:
- python3 04-01-dedupe_sys_dept.py --host ... --user ... --password ... --database ... [--apply]
- 注意:
- 在运行前请先备份数据库。对非数字或特殊 dept_name 不做额外过滤,脚本基于 parent_id + dept_name 进行分组。
- """
- from __future__ import annotations
- import argparse
- import getpass
- import logging
- import sys
- import os
- import importlib.util
- from typing import List, Tuple
- import pymysql
- from pymysql.connections import Connection
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
- logger = logging.getLogger("dedupe_sys_dept")
- def parse_args():
- """
- Parse arguments. If a local db_config.py exists in the same directory and defines DB_CONFIG dict,
- its values are used as defaults (same behavior as 04-insert_majors.py).
- """
- # try load local db_config.py for defaults
- local_db = load_local_db_config()
- host_default = local_db.get("host", os.getenv("DB_HOST", "localhost"))
- port_default = int(local_db.get("port", os.getenv("DB_PORT", 3306)))
- user_default = local_db.get("user", os.getenv("DB_USER", "root"))
- password_default = local_db.get("password", os.getenv("DB_PASSWORD", ""))
- database_default = local_db.get("database", os.getenv("DB_NAME", "hzdyjs_xg"))
- p = argparse.ArgumentParser(description="Deduplicate sys_dept entries by (parent_id, dept_name)")
- p.add_argument("--host", default=host_default, help="DB host")
- p.add_argument("--port", type=int, default=port_default, help="DB port")
- p.add_argument("--user", default=user_default, help="DB user")
- p.add_argument("--password", default=password_default, help="DB password (if omitted, prompted)")
- p.add_argument("--database", default=database_default, help="DB name")
- p.add_argument("--charset", default="utf8mb4", help="DB charset")
- p.add_argument("--apply", action="store_true", help="Actually apply deletions and updates. Default is dry-run.")
- p.add_argument("--limit", type=int, default=0, help="If >0, limit number of groups processed (for testing)")
- return p.parse_args()
- def load_local_db_config() -> dict:
- """
- Attempt to load DB_CONFIG dict from db_config.py in the current script directory.
- Returns empty dict if not present or on error.
- """
- cfg_path = os.path.join(os.path.dirname(__file__), "db_config.py")
- if not os.path.exists(cfg_path):
- return {}
- try:
- spec = importlib.util.spec_from_file_location("local_db_config", cfg_path)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module) # type: ignore
- return getattr(module, "DB_CONFIG", {}) or {}
- except Exception as e:
- logger.warning("加载本地 db_config.py 失败: %s", e)
- return {}
- def get_db_conn(args) -> Connection:
- """
- Return a pymysql connection. Naming aligned with 04-insert_majors.py (`get_db_conn`).
- """
- pwd = args.password or getpass.getpass("DB password: ")
- return pymysql.connect(
- host=args.host,
- port=args.port,
- user=args.user,
- password=pwd,
- database=args.database,
- charset=args.charset,
- cursorclass=pymysql.cursors.DictCursor,
- autocommit=False,
- )
- def find_duplicate_groups(conn: Connection, limit: int = 0) -> List[Tuple[int, str, List[int]]]:
- """
- 返回列表,每项为 (parent_id, dept_name, [dept_id1, dept_id2, ...]),且 dept_id 列表长度 >=2
- """
- sql = """
- SELECT parent_id, dept_name, GROUP_CONCAT(dept_id ORDER BY dept_id ASC) AS ids, COUNT(*) AS cnt
- FROM sys_dept
- GROUP BY parent_id, dept_name
- HAVING cnt > 1
- ORDER BY parent_id, dept_name
- """
- if limit and limit > 0:
- sql = sql + f" LIMIT {limit}"
- with conn.cursor() as cur:
- cur.execute(sql)
- rows = cur.fetchall()
- groups = []
- for r in rows:
- ids = [int(x) for x in str(r["ids"]).split(",") if x.strip()]
- groups.append((r["parent_id"], r["dept_name"], ids))
- return groups
- def reparent_children_and_users(conn: Connection, keep_id: int, delete_ids: List[int], dry_run: bool):
- if not delete_ids:
- return
- # 1) Reparent child depts whose parent_id in delete_ids -> set parent_id = keep_id
- sql_children = "SELECT dept_id FROM sys_dept WHERE parent_id IN ({ids})".format(
- ids=",".join(str(i) for i in delete_ids)
- )
- with conn.cursor() as cur:
- cur.execute(sql_children)
- children = cur.fetchall()
- if children:
- children_ids = [r["dept_id"] for r in children]
- logger.info(" Found %d child departments to reparent to %s: %s", len(children_ids), keep_id, children_ids)
- if not dry_run:
- sql_upd = "UPDATE sys_dept SET parent_id = %s WHERE parent_id IN ({ids})".format(
- ids=",".join(str(i) for i in delete_ids)
- )
- with conn.cursor() as cur:
- cur.execute(sql_upd, (keep_id,))
- else:
- logger.debug(" No child departments referencing deleted ids.")
- # 2) Update sys_user.dept_id referencing deleted ids -> keep_id
- sql_users = "SELECT count(*) as cnt FROM sys_user WHERE dept_id IN ({ids})".format(
- ids=",".join(str(i) for i in delete_ids)
- )
- with conn.cursor() as cur:
- cur.execute(sql_users)
- cnt_row = cur.fetchone()
- user_cnt = int(cnt_row["cnt"])
- if user_cnt > 0:
- logger.info(" Found %d users referencing deleted dept ids -> will update to %s", user_cnt, keep_id)
- if not dry_run:
- sql_upd_users = "UPDATE sys_user SET dept_id = %s WHERE dept_id IN ({ids})".format(
- ids=",".join(str(i) for i in delete_ids)
- )
- with conn.cursor() as cur:
- cur.execute(sql_upd_users, (keep_id,))
- def delete_dept_ids(conn: Connection, delete_ids: List[int], dry_run: bool):
- if not delete_ids:
- return
- logger.info(" Will delete dept ids: %s", delete_ids)
- if dry_run:
- return
- sql = "DELETE FROM sys_dept WHERE dept_id IN ({ids})".format(ids=",".join(str(i) for i in delete_ids))
- with conn.cursor() as cur:
- cur.execute(sql)
- def process_groups(conn: Connection, groups, apply: bool):
- dry_run = not apply
- summary = {"groups": 0, "deleted_ids_total": 0}
- for parent_id, dept_name, ids in groups:
- keep_id = min(ids)
- delete_ids = [i for i in ids if i != keep_id]
- summary["groups"] += 1
- summary["deleted_ids_total"] += len(delete_ids)
- logger.info("Group parent_id=%s dept_name=%r => keep=%s delete=%s", parent_id, dept_name, keep_id, delete_ids)
- # Reparent children and users first
- reparent_children_and_users(conn, keep_id, delete_ids, dry_run=dry_run)
- # Then delete
- delete_dept_ids(conn, delete_ids, dry_run=dry_run)
- return summary
- def main():
- args = parse_args()
- conn = get_db_conn(args)
- try:
- groups = find_duplicate_groups(conn, limit=args.limit)
- if not groups:
- logger.info("No duplicate groups found.")
- return 0
- logger.info("Found %d duplicate groups (parent_id, dept_name with count>1).", len(groups))
- for idx, (parent_id, dept_name, ids) in enumerate(groups, start=1):
- logger.info(" %d) parent_id=%s dept_name=%r ids=%s", idx, parent_id, dept_name, ids)
- if args.apply:
- logger.info("Applying changes inside a transaction...")
- try:
- summary = process_groups(conn, groups, apply=True)
- conn.commit()
- logger.info("Committed. Processed %d groups, total deleted ids %d", summary["groups"], summary["deleted_ids_total"])
- except Exception as e:
- conn.rollback()
- logger.exception("Error during apply, rolled back. Error: %s", e)
- return 2
- else:
- logger.info("Dry-run mode (no changes will be made). Showing what would be executed:")
- _ = process_groups(conn, groups, apply=False)
- logger.info("Dry-run finished. Re-run with --apply to execute.")
- return 0
- finally:
- conn.close()
- if __name__ == "__main__":
- sys.exit(main())
|