| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549 |
- #!/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()
|