04-01-dedupe_sys_dept.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 04-01-dedupe_sys_dept.py
  5. 目的:
  6. 在 sys_dept 表中,对于同一学院(parent_id)下的相同专业名称(dept_name)只保留一条记录,删除重复记录。
  7. 行为:
  8. - 默认 dry-run:打印将要执行的 SQL 操作,不做修改。
  9. - 传入 --apply 会执行修改(在事务中)。
  10. - 对于重复记录,会保留 dept_id 最小的那条作为主记录(keep_id),
  11. 并将重复记录的子部门(parent_id 指向被删除 id) 统一改为 keep_id,
  12. 同时将 sys_user 表中引用的 dept_id 更新为 keep_id。
  13. - 会打印处理摘要。
  14. 使用:
  15. python3 04-01-dedupe_sys_dept.py --host ... --user ... --password ... --database ... [--apply]
  16. 注意:
  17. 在运行前请先备份数据库。对非数字或特殊 dept_name 不做额外过滤,脚本基于 parent_id + dept_name 进行分组。
  18. """
  19. from __future__ import annotations
  20. import argparse
  21. import getpass
  22. import logging
  23. import sys
  24. import os
  25. import importlib.util
  26. from typing import List, Tuple
  27. import pymysql
  28. from pymysql.connections import Connection
  29. logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
  30. logger = logging.getLogger("dedupe_sys_dept")
  31. def parse_args():
  32. """
  33. Parse arguments. If a local db_config.py exists in the same directory and defines DB_CONFIG dict,
  34. its values are used as defaults (same behavior as 04-insert_majors.py).
  35. """
  36. # try load local db_config.py for defaults
  37. local_db = load_local_db_config()
  38. host_default = local_db.get("host", os.getenv("DB_HOST", "localhost"))
  39. port_default = int(local_db.get("port", os.getenv("DB_PORT", 3306)))
  40. user_default = local_db.get("user", os.getenv("DB_USER", "root"))
  41. password_default = local_db.get("password", os.getenv("DB_PASSWORD", ""))
  42. database_default = local_db.get("database", os.getenv("DB_NAME", "hzdyjs_xg"))
  43. p = argparse.ArgumentParser(description="Deduplicate sys_dept entries by (parent_id, dept_name)")
  44. p.add_argument("--host", default=host_default, help="DB host")
  45. p.add_argument("--port", type=int, default=port_default, help="DB port")
  46. p.add_argument("--user", default=user_default, help="DB user")
  47. p.add_argument("--password", default=password_default, help="DB password (if omitted, prompted)")
  48. p.add_argument("--database", default=database_default, help="DB name")
  49. p.add_argument("--charset", default="utf8mb4", help="DB charset")
  50. p.add_argument("--apply", action="store_true", help="Actually apply deletions and updates. Default is dry-run.")
  51. p.add_argument("--limit", type=int, default=0, help="If >0, limit number of groups processed (for testing)")
  52. return p.parse_args()
  53. def load_local_db_config() -> dict:
  54. """
  55. Attempt to load DB_CONFIG dict from db_config.py in the current script directory.
  56. Returns empty dict if not present or on error.
  57. """
  58. cfg_path = os.path.join(os.path.dirname(__file__), "db_config.py")
  59. if not os.path.exists(cfg_path):
  60. return {}
  61. try:
  62. spec = importlib.util.spec_from_file_location("local_db_config", cfg_path)
  63. module = importlib.util.module_from_spec(spec)
  64. spec.loader.exec_module(module) # type: ignore
  65. return getattr(module, "DB_CONFIG", {}) or {}
  66. except Exception as e:
  67. logger.warning("加载本地 db_config.py 失败: %s", e)
  68. return {}
  69. def get_db_conn(args) -> Connection:
  70. """
  71. Return a pymysql connection. Naming aligned with 04-insert_majors.py (`get_db_conn`).
  72. """
  73. pwd = args.password or getpass.getpass("DB password: ")
  74. return pymysql.connect(
  75. host=args.host,
  76. port=args.port,
  77. user=args.user,
  78. password=pwd,
  79. database=args.database,
  80. charset=args.charset,
  81. cursorclass=pymysql.cursors.DictCursor,
  82. autocommit=False,
  83. )
  84. def find_duplicate_groups(conn: Connection, limit: int = 0) -> List[Tuple[int, str, List[int]]]:
  85. """
  86. 返回列表,每项为 (parent_id, dept_name, [dept_id1, dept_id2, ...]),且 dept_id 列表长度 >=2
  87. """
  88. sql = """
  89. SELECT parent_id, dept_name, GROUP_CONCAT(dept_id ORDER BY dept_id ASC) AS ids, COUNT(*) AS cnt
  90. FROM sys_dept
  91. GROUP BY parent_id, dept_name
  92. HAVING cnt > 1
  93. ORDER BY parent_id, dept_name
  94. """
  95. if limit and limit > 0:
  96. sql = sql + f" LIMIT {limit}"
  97. with conn.cursor() as cur:
  98. cur.execute(sql)
  99. rows = cur.fetchall()
  100. groups = []
  101. for r in rows:
  102. ids = [int(x) for x in str(r["ids"]).split(",") if x.strip()]
  103. groups.append((r["parent_id"], r["dept_name"], ids))
  104. return groups
  105. def reparent_children_and_users(conn: Connection, keep_id: int, delete_ids: List[int], dry_run: bool):
  106. if not delete_ids:
  107. return
  108. # 1) Reparent child depts whose parent_id in delete_ids -> set parent_id = keep_id
  109. sql_children = "SELECT dept_id FROM sys_dept WHERE parent_id IN ({ids})".format(
  110. ids=",".join(str(i) for i in delete_ids)
  111. )
  112. with conn.cursor() as cur:
  113. cur.execute(sql_children)
  114. children = cur.fetchall()
  115. if children:
  116. children_ids = [r["dept_id"] for r in children]
  117. logger.info(" Found %d child departments to reparent to %s: %s", len(children_ids), keep_id, children_ids)
  118. if not dry_run:
  119. sql_upd = "UPDATE sys_dept SET parent_id = %s WHERE parent_id IN ({ids})".format(
  120. ids=",".join(str(i) for i in delete_ids)
  121. )
  122. with conn.cursor() as cur:
  123. cur.execute(sql_upd, (keep_id,))
  124. else:
  125. logger.debug(" No child departments referencing deleted ids.")
  126. # 2) Update sys_user.dept_id referencing deleted ids -> keep_id
  127. sql_users = "SELECT count(*) as cnt FROM sys_user WHERE dept_id IN ({ids})".format(
  128. ids=",".join(str(i) for i in delete_ids)
  129. )
  130. with conn.cursor() as cur:
  131. cur.execute(sql_users)
  132. cnt_row = cur.fetchone()
  133. user_cnt = int(cnt_row["cnt"])
  134. if user_cnt > 0:
  135. logger.info(" Found %d users referencing deleted dept ids -> will update to %s", user_cnt, keep_id)
  136. if not dry_run:
  137. sql_upd_users = "UPDATE sys_user SET dept_id = %s WHERE dept_id IN ({ids})".format(
  138. ids=",".join(str(i) for i in delete_ids)
  139. )
  140. with conn.cursor() as cur:
  141. cur.execute(sql_upd_users, (keep_id,))
  142. def delete_dept_ids(conn: Connection, delete_ids: List[int], dry_run: bool):
  143. if not delete_ids:
  144. return
  145. logger.info(" Will delete dept ids: %s", delete_ids)
  146. if dry_run:
  147. return
  148. sql = "DELETE FROM sys_dept WHERE dept_id IN ({ids})".format(ids=",".join(str(i) for i in delete_ids))
  149. with conn.cursor() as cur:
  150. cur.execute(sql)
  151. def process_groups(conn: Connection, groups, apply: bool):
  152. dry_run = not apply
  153. summary = {"groups": 0, "deleted_ids_total": 0}
  154. for parent_id, dept_name, ids in groups:
  155. keep_id = min(ids)
  156. delete_ids = [i for i in ids if i != keep_id]
  157. summary["groups"] += 1
  158. summary["deleted_ids_total"] += len(delete_ids)
  159. logger.info("Group parent_id=%s dept_name=%r => keep=%s delete=%s", parent_id, dept_name, keep_id, delete_ids)
  160. # Reparent children and users first
  161. reparent_children_and_users(conn, keep_id, delete_ids, dry_run=dry_run)
  162. # Then delete
  163. delete_dept_ids(conn, delete_ids, dry_run=dry_run)
  164. return summary
  165. def main():
  166. args = parse_args()
  167. conn = get_db_conn(args)
  168. try:
  169. groups = find_duplicate_groups(conn, limit=args.limit)
  170. if not groups:
  171. logger.info("No duplicate groups found.")
  172. return 0
  173. logger.info("Found %d duplicate groups (parent_id, dept_name with count>1).", len(groups))
  174. for idx, (parent_id, dept_name, ids) in enumerate(groups, start=1):
  175. logger.info(" %d) parent_id=%s dept_name=%r ids=%s", idx, parent_id, dept_name, ids)
  176. if args.apply:
  177. logger.info("Applying changes inside a transaction...")
  178. try:
  179. summary = process_groups(conn, groups, apply=True)
  180. conn.commit()
  181. logger.info("Committed. Processed %d groups, total deleted ids %d", summary["groups"], summary["deleted_ids_total"])
  182. except Exception as e:
  183. conn.rollback()
  184. logger.exception("Error during apply, rolled back. Error: %s", e)
  185. return 2
  186. else:
  187. logger.info("Dry-run mode (no changes will be made). Showing what would be executed:")
  188. _ = process_groups(conn, groups, apply=False)
  189. logger.info("Dry-run finished. Re-run with --apply to execute.")
  190. return 0
  191. finally:
  192. conn.close()
  193. if __name__ == "__main__":
  194. sys.exit(main())