#!/usr/bin/env python # -*- coding: utf-8 -*- """ 从 his_xssj 表把历史学生数据同步到 xg 系统表 主要写入表: - xg_student_msg - sys_user - sys_user_role 特性: - 同步前提示是否清空 xg_student_msg(需用户确认) - 跳过 班级编号 为 '#YDQX#' 或 空字符串 的记录 - 批量插入并分批提交,包含简单的重试机制 - 通过 sys_dept 表查找班级/专业/学院信息;通过 xg_class_msg 获取班级关联字段 - 字段映射/清洗遵循项目要求(见任务描述) 注意:此脚本依赖同目录下的 `db_config.py` 提供 `DB_CONFIG` 与 `TABLE_MAPPING` """ from __future__ import annotations import argparse import logging import sys import time from datetime import datetime from typing import Any, Dict, List, Optional, Tuple import pymysql from pymysql.cursors import DictCursor from db_config import DB_CONFIG, TABLE_MAPPING # 常量配置(可根据需要改为从外部配置) STUDENT_ROLE_ID = 1938120227459309569 DEFAULT_TENANT_ID = "000000" DEFAULT_CREATE_BY = 1 DEFAULT_UPDATE_BY = 1 DEFAULT_PASSWORD_HASH = "$2a$10$Vm51QDgTteXRQKpERT2okumJh1sVm5YpvkiVQdaih/rgaOtHrm6yi" BATCH_SIZE = 200 DB_RETRY_MAX = 3 logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=[logging.StreamHandler(sys.stdout)], ) logger = logging.getLogger("sync_his_xssj") def get_db_connection(): return pymysql.connect( **DB_CONFIG, cursorclass=DictCursor, autocommit=False, connect_timeout=30, read_timeout=60, write_timeout=60, ) def parse_enrollment_date(raw_value: Optional[str]) -> Optional[datetime]: if not raw_value: return None text = str(raw_value).strip() if not text: return None # 常见格式 "9/1/2009 0:0:0" 或 "2009-09-01 00:00:00" fmts = ["%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M", "%m/%d/%Y", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] for fmt in fmts: try: return datetime.strptime(text, fmt) except Exception: continue # 作为最后手段尝试截取日期部分 try: return datetime.strptime(text.split()[0], "%m/%d/%Y") except Exception: logger.warning("无法解析入学日期 -> raw='%s'", raw_value) return None def normalize_phone(raw_value: Optional[str], max_len: int = 11) -> Optional[str]: if not raw_value: return None s = str(raw_value).strip() return s[:max_len] if s else None def map_gender(text: Optional[str]) -> str: if not text: return "0" t = str(text).strip() if t == "女": return "1" return "0" def map_sys_user_status(his_status: Optional[str]) -> str: """sys_user.status: 毕业、结业、分流、停学、退学、休学 -> 1 ; 复学、在读 -> 0""" if not his_status: return "0" s = str(his_status).strip() if s in ("毕业", "结业", "分流", "停学", "退学", "休学"): return "1" return "0" def map_student_status(his_status: Optional[str]) -> str: """xg_student_msg.student_status 映射""" if not his_status: return "1" mapping = { "毕业": "5", "结业": "7", "分流": "9", "复学": "1", "停学": "8", "退学": "4", "在读": "1", "休学": "2", } return mapping.get(str(his_status).strip(), "1") def find_class_dept(connection, class_code: Optional[str], class_name: Optional[str]) -> Tuple[Optional[int], Optional[str], Optional[int], Optional[str], Optional[int], Optional[str]]: """ 根据 his_xssj 的班级编号与班级名称,查找 sys_dept 中对应的班级记录, 并向上查找专业(zy-)与学院(xy-)节点,返回 (class_id,class_name, major_id,major_name, college_id,college_name) """ if not class_code or not class_name: return None, None, None, None, None, None dept_category = f"bj-{class_code}" with connection.cursor() as cursor: cursor.execute( "SELECT dept_id, dept_name, parent_id, dept_category FROM `sys_dept` WHERE dept_category = %s AND dept_name = %s LIMIT 1", (dept_category, class_name), ) row = cursor.fetchone() if not row: logger.warning("未在 sys_dept 中找到班级 -> dept_category=%s, dept_name=%s", dept_category, class_name) return None, None, None, None, None, None class_id = row["dept_id"] class_name_ret = row["dept_name"] parent_id = row.get("parent_id") major_id = None major_name = None college_id = None college_name = None # 向上遍历父级查找 zy- 与 xy- 前缀 current_parent = parent_id while current_parent: cursor.execute("SELECT dept_id, dept_name, parent_id, dept_category FROM `sys_dept` WHERE dept_id = %s LIMIT 1", (current_parent,)) parent_row = cursor.fetchone() if not parent_row: break cat = parent_row.get("dept_category") or "" if cat.startswith("zy-") and major_id is None: major_id = parent_row["dept_id"] major_name = parent_row["dept_name"] if cat.startswith("xy-") and college_id is None: college_id = parent_row["dept_id"] college_name = parent_row["dept_name"] current_parent = parent_row.get("parent_id") return class_id, class_name_ret, major_id, major_name, college_id, college_name def load_class_msg(connection, class_id: Optional[int]) -> Dict[str, Any]: """ 从 xg_class_msg 表读取与班级相关的字段,用于补齐 xg_student_msg 的若干字段 """ if not class_id: return {} with connection.cursor() as cursor: cursor.execute("SELECT * FROM `xg_class_msg` WHERE id = %s LIMIT 1", (class_id,)) return cursor.fetchone() or {} def sync_from_his_xssj(truncate_before_sync: bool = False): table_his = "his_xssj" table_xg_student = TABLE_MAPPING.get("xg_student_msg", "xg_student_msg") table_sys_user = "sys_user" table_sys_user_role = "sys_user_role" connection = get_db_connection() try: with connection.cursor() as cursor: # 1) 是否清空目标表 if truncate_before_sync: logger.info("正在清空表 %s ...", table_xg_student) cursor.execute(f"TRUNCATE TABLE `{table_xg_student}`") connection.commit() logger.info("已清空 %s", table_xg_student) # 2) 读取需同步的数据(跳过 班级编号 = '#YDQX#' 或 '') select_sql = f"SELECT * FROM `{table_his}` WHERE `班级编号` IS NOT NULL AND `班级编号` NOT IN (%s, %s)" cursor.execute(select_sql, ("#YDQX#", "")) rows = cursor.fetchall() logger.info("从 %s 读取到 %d 条候选记录(已过滤 '#YDQX#' 与空班级编号)", table_his, len(rows)) if not rows: logger.info("没有符合条件的历史学生数据,退出。") return # 预加载已存在的学号集合与 sys_user user_id 以便去重 cursor.execute(f"SELECT student_no FROM `{table_xg_student}`") existing_student_nos = {r["student_no"] for r in cursor.fetchall()} cursor.execute("SELECT user_id FROM `sys_user`") existing_user_ids = {int(r["user_id"]) for r in cursor.fetchall()} base_datetime = datetime.now() timestamp_str = base_datetime.strftime("%Y%m%d%H%M%S") sequence = 1 inserted_student_count = 0 inserted_user_count = 0 inserted_role_count = 0 batch_counter = 0 # Prepare SQL statements (只插入必要字段) insert_xg_student_sql = f""" INSERT INTO `{table_xg_student}` ( id, tenant_id, create_dept, create_by, create_time, update_by, update_time, deleted, student_no, id_card, name, gender, birth_date, political_status, phone, is_boarder, home_address, guardian_phone, admission_category, student_status, enrollment_year, enrollment_date, school_system, academic_level, training_type, major_id, major_name, class_id, class_name, grade, college_id, college_name, counselor_id, counselor_name, class_teacher_id, class_teacher_name ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) """ insert_sys_user_sql = f""" INSERT INTO `{table_sys_user}` ( user_id, dept_id, user_name, nick_name, user_type, email, phonenumber, sex, avatar, password, status, del_flag, login_ip, login_date, create_by, create_time, update_by, update_time, remark, tenant_id, account_type ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) """ insert_user_role_sql = f""" INSERT INTO `{table_sys_user_role}` (`user_id`, `role_id`) VALUES (%s, %s) """ for row in rows: try: student_no = row.get("学号") or row.get("学生学号") or row.get("xh") or row.get("xs_xh") or row.get("xs_xh_x") or row.get("学号".strip()) # 如果无法从通用列名获取学号,尝试直接使用字段名 '学号'(上面多重尝试尽量兼容) if not student_no: logger.warning("跳过无学号记录: %s", row) continue student_no = str(student_no).strip() if student_no in existing_student_nos: logger.info("跳过已存在学号: %s", student_no) continue name = row.get("姓名") or row.get("xm") or row.get("学生姓名") if not name: logger.warning("跳过无姓名记录: %s", row) continue # 跳过指定班级编号的记录(再次防护) class_code = row.get("班级编号") if not class_code or str(class_code).strip() in ("", "#YDQX#"): continue class_name = row.get("班级名称") or row.get("班级") class_id, class_name_val, major_id, major_name, college_id, college_name = find_class_dept(connection, str(class_code).strip(), class_name) # 从 xg_class_msg 读取额外字段 class_msg = load_class_msg(connection, class_id) # 处理字段映射 id_card = row.get("身份证号") or row.get("sfzjh") gender = map_gender(row.get("性别") or row.get("xs_xb")) birth_date = None try: raw_birth = row.get("出生日期") or row.get("csrq") if raw_birth: birth_date = datetime.strptime(str(raw_birth).split()[0], "%Y-%m-%d").date() except Exception: birth_date = None political = row.get("政治面貌") political_status = None if political: # 用户提供的映射示例: 群众 - 13 、 预备党员 - 02 、 团员 - 03 mapping_pol = {"群众": "13", "预备党员": "02", "团员": "03"} political_status = mapping_pol.get(str(political).strip()) phone = normalize_phone(row.get("联系方式") or row.get("联系电话") or row.get("lxdh")) guardian_phone = normalize_phone(row.get("家长电话") or row.get("guardian_phone"), max_len=20) is_boarder = 1 if str(row.get("是否住校") or "").strip() == "是" else 0 home_address = row.get("家庭住址") or row.get("jtdz") admission_category = "统招" student_status_mapped = map_student_status(row.get("学生状态") or row.get("xsdqzt") or row.get("状态")) enrollment_year = None try: enr = row.get("入学年份") or row.get("rxnj") if enr: enrollment_year = int(str(enr).strip()) except Exception: enrollment_year = None enrollment_date_dt = None try: enr_date_raw = row.get("入学日期") or row.get("rxrq") if enr_date_raw: parsed = parse_enrollment_date(str(enr_date_raw)) if parsed: enrollment_date_dt = parsed.date() except Exception: enrollment_date_dt = None # 生成唯一 id (与 sys_user.user_id 一致) if sequence > 99999: logger.error("单次同步ID序列超过99999,停止以避免ID溢出") break unique_id = int(f"{timestamp_str}{sequence:05d}") sequence += 1 # 插入 xg_student_msg params_xg_student = ( unique_id, DEFAULT_TENANT_ID, class_id or 0, DEFAULT_CREATE_BY, base_datetime, DEFAULT_UPDATE_BY, base_datetime, 0, student_no, id_card, name, gender, birth_date, political_status, phone, is_boarder, home_address, guardian_phone, admission_category, student_status_mapped, enrollment_year, enrollment_date_dt, class_msg.get("school_system"), class_msg.get("class_type"), class_msg.get("training_type"), major_id, major_name, class_id, class_name_val, class_msg.get("grade"), college_id, college_name, class_msg.get("counselor_id"), class_msg.get("counselor_name"), class_msg.get("class_teacher_id"), class_msg.get("class_teacher_name"), ) # 插入 sys_user (如果不存在) params_sys_user = ( unique_id, class_id, student_no, name, "sys_user", None, phone, gender, None, DEFAULT_PASSWORD_HASH, map_sys_user_status(row.get("学生状态") or row.get("xsdqzt")), "0", None, None, DEFAULT_CREATE_BY, base_datetime, DEFAULT_UPDATE_BY, base_datetime, "历史数据导入", DEFAULT_TENANT_ID, "2", ) # 执行插入(带重试) attempts = 0 while attempts < DB_RETRY_MAX: try: with connection.cursor() as c: c.execute(insert_xg_student_sql, params_xg_student) # sys_user 可能已经存在(通过 user_id 判断) if unique_id not in existing_user_ids: c.execute(insert_sys_user_sql, params_sys_user) existing_user_ids.add(unique_id) inserted_user_count += 1 # sys_user_role c.execute("SELECT 1 FROM `sys_user_role` WHERE user_id = %s AND role_id = %s LIMIT 1", (unique_id, STUDENT_ROLE_ID)) if not c.fetchone(): c.execute(insert_user_role_sql, (unique_id, STUDENT_ROLE_ID)) inserted_role_count += 1 inserted_student_count += 1 existing_student_nos.add(student_no) batch_counter += 1 if batch_counter >= BATCH_SIZE: connection.commit() logger.info("已批量提交 %d 条记录", batch_counter) batch_counter = 0 break except pymysql.err.OperationalError as e: attempts += 1 logger.warning("数据库操作失败(尝试 %d/%d): %s", attempts, DB_RETRY_MAX, e) time.sleep(attempts * 2) # 重新获取连接 try: connection.ping(reconnect=True) except Exception: connection = get_db_connection() except Exception as e: logger.exception("插入记录失败: %s; 数据: %s", e, params_xg_student) # 不重试一般数据错误,跳过此记录 break except Exception as outer_exc: logger.exception("处理单条 his_xssj 记录出错: %s", outer_exc) # 提交剩余 if batch_counter > 0: connection.commit() logger.info("已提交剩余 %d 条记录", batch_counter) logger.info("同步完成:插入学生 %d 条,新增 sys_user %d 条,新增角色绑定 %d 条", inserted_student_count, inserted_user_count, inserted_role_count) finally: try: connection.close() except Exception: pass def prompt_confirm_truncate() -> bool: answer = input("是否先清空 `xg_student_msg` 表?输入 y 确认,其他任意键取消: ").strip().lower() return answer == "y" def prompt_confirm_delete_students() -> bool: answer = input("是否删除 `sys_user` 中 account_type=2 的学生用户及其 sys_user_role 关联?输入 y 确认,其他任意键取消: ").strip().lower() return answer == "y" def split_batches(values: List[int], size: int = 1000) -> List[List[int]]: if not values: return [] return [values[i : i + size] for i in range(0, len(values), size)] def delete_student_accounts() -> Tuple[int, int]: """ 删除 sys_user 中 account_type='2' 的用户以及其在 sys_user_role 中的关联记录。 返回 (deleted_users_count, deleted_roles_count) """ deleted_users = 0 deleted_roles = 0 conn = get_db_connection() try: with conn.cursor() as cursor: cursor.execute("SELECT user_id FROM `sys_user` WHERE account_type = %s", ("2",)) rows = cursor.fetchall() user_ids = [int(r["user_id"]) for r in rows] if not user_ids: logger.info("sys_user 中无 account_type=2 的用户,无需删除。") return 0, 0 logger.info("找到 %d 个 student sys_user 将被删除(包括其 sys_user_role 关联)", len(user_ids)) for batch in split_batches(user_ids, size=500): placeholders = ",".join(["%s"] * len(batch)) # 删除角色关联 sql_roles = f"DELETE FROM `sys_user_role` WHERE user_id IN ({placeholders})" cursor.execute(sql_roles, batch) deleted_roles += cursor.rowcount or 0 # 删除用户 sql_users = f"DELETE FROM `sys_user` WHERE user_id IN ({placeholders})" cursor.execute(sql_users, batch) deleted_users += cursor.rowcount or 0 conn.commit() logger.info("已删除 %d users, %d role rows", deleted_users, deleted_roles) return deleted_users, deleted_roles finally: try: conn.close() except Exception: pass def main(): parser = argparse.ArgumentParser(description="从 his_xssj 同步历史学生到 xg 系统") parser.add_argument("--yes-delete-students", action="store_true", help="跳过提示,直接删除 sys_user 中 account_type=2 的学生及其 sys_user_role 关联") parser.add_argument("--yes-truncate", action="store_true", help="跳过提示,直接清空 xg_student_msg 表后同步") args = parser.parse_args() # 询问是否删除 sys_user 中 account_type=2 的学生用户(以及其 sys_user_role) if getattr(args, "yes_delete_students", False): do_delete_students = True else: do_delete_students = prompt_confirm_delete_students() if do_delete_students: deleted_users, deleted_roles = delete_student_accounts() logger.info("删除操作完成:删除用户 %d,删除角色关联 %d", deleted_users, deleted_roles) # 询问是否清空表 if args.yes_truncate: do_truncate = True else: do_truncate = prompt_confirm_truncate() sync_from_his_xssj(truncate_before_sync=do_truncate) if __name__ == "__main__": main()