02_parse_score_data.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 学生操行分历史数据迁移 - 脚本2:数据解析处理
  5. 功能说明:
  6. - 解析学年格式:2015-2016 → 2015
  7. - 解析学期格式:第一学期→1,第二学期→2
  8. - 解析月度分数:一月:90,九月:85 → 多条月度记录
  9. - 结果存储到temp_conduct_score_parsed中间表
  10. 执行方式:
  11. python 02_parse_score_data.py
  12. 注意事项:
  13. - 依赖脚本1已执行完成
  14. - 支持断点续传(已解析的数据会跳过)
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import logging
  19. import re
  20. import sys
  21. import time
  22. from datetime import datetime
  23. from typing import Any, Dict, List, Optional, Tuple
  24. import pymysql
  25. from pymysql.cursors import DictCursor
  26. from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS, MONTH_MAPPING, SEMESTER_MAPPING
  27. # 配置日志
  28. logging.basicConfig(
  29. level=logging.INFO,
  30. format="%(asctime)s %(levelname)s %(message)s",
  31. handlers=[
  32. logging.StreamHandler(sys.stdout),
  33. logging.FileHandler("logs/02_parse_data.log", mode='a', encoding='utf-8')
  34. ]
  35. )
  36. logger = logging.getLogger("parse_data")
  37. class DataParser:
  38. """数据解析处理器"""
  39. def __init__(self):
  40. self.db_config = DB_CONFIG
  41. self.temp_import_table = TABLE_CONFIG["temp_import"]
  42. self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
  43. self.batch_size = CONSTANTS["BATCH_SIZE"]
  44. self.connection = None
  45. def connect_db(self) -> None:
  46. """连接数据库(带重试机制)"""
  47. max_retries = self.db_config.get('max_retries', 3)
  48. retry_delay = 5 # 每次重试间隔5秒
  49. for attempt in range(max_retries + 1):
  50. try:
  51. self.connection = pymysql.connect(**self.db_config)
  52. logger.info("数据库连接成功")
  53. return
  54. except Exception as e:
  55. if attempt < max_retries:
  56. logger.warning(f"数据库连接失败 (尝试 {attempt + 1}/{max_retries + 1}): {e}")
  57. logger.info(f"等待 {retry_delay} 秒后重试...")
  58. time.sleep(retry_delay)
  59. retry_delay *= 2 # 指数退避
  60. else:
  61. logger.error(f"数据库连接失败,已达到最大重试次数: {e}")
  62. raise
  63. def disconnect_db(self) -> None:
  64. """断开数据库连接"""
  65. if self.connection:
  66. self.connection.close()
  67. logger.info("数据库连接已断开")
  68. def check_connection_health(self) -> bool:
  69. """检查数据库连接健康状态"""
  70. if not self.connection:
  71. return False
  72. try:
  73. with self.connection.cursor() as cursor:
  74. cursor.execute("SELECT 1")
  75. cursor.fetchone()
  76. return True
  77. except Exception as e:
  78. logger.warning(f"连接健康检查失败: {e}")
  79. return False
  80. def ensure_connection(self) -> None:
  81. """确保数据库连接可用,如果断开则重连"""
  82. if not self.check_connection_health():
  83. logger.info("检测到连接断开,正在重新连接...")
  84. self.disconnect_db()
  85. self.connect_db()
  86. def get_pending_data(self) -> List[Dict[str, Any]]:
  87. """获取待解析的数据"""
  88. # 确保连接可用
  89. self.ensure_connection()
  90. try:
  91. with self.connection.cursor(DictCursor) as cursor:
  92. sql = f"""
  93. SELECT id, student_no, academic_year_str, semester_str, monthly_scores
  94. FROM {self.temp_import_table}
  95. WHERE import_status = 'pending'
  96. ORDER BY id
  97. LIMIT {self.batch_size}
  98. """
  99. cursor.execute(sql)
  100. return cursor.fetchall()
  101. except Exception as e:
  102. logger.error(f"获取待解析数据失败: {e}")
  103. return []
  104. def parse_academic_year(self, year_str: str) -> Tuple[Optional[int], Optional[str]]:
  105. """解析学年格式:2015-2016 → 2015"""
  106. if not year_str or not isinstance(year_str, str):
  107. return None, f"学年为空或格式错误: {year_str}"
  108. # 匹配XXXX-XXXX格式
  109. match = re.match(r'^(\d{4})-(\d{4})$', year_str.strip())
  110. if not match:
  111. return None, f"学年格式不正确,应为XXXX-XXXX格式: {year_str}"
  112. start_year = int(match.group(1))
  113. end_year = int(match.group(2))
  114. # 验证年份合理性
  115. if end_year != start_year + 1:
  116. return None, f"学年跨度不正确: {year_str}"
  117. if start_year < 2000 or start_year > 2030:
  118. return None, f"学年范围不合理: {year_str}"
  119. return start_year, None
  120. def parse_semester(self, semester_str: str) -> Tuple[Optional[int], Optional[str]]:
  121. """解析学期格式:第一学期→1,第二学期→2"""
  122. if not semester_str or not isinstance(semester_str, str):
  123. return None, f"学期为空或格式错误: {semester_str}"
  124. semester = semester_str.strip()
  125. if semester in SEMESTER_MAPPING:
  126. return SEMESTER_MAPPING[semester], None
  127. else:
  128. return None, f"学期格式不正确,应为'第一学期'或'第二学期': {semester}"
  129. def parse_monthly_scores(self, score_str: str) -> Tuple[List[Tuple[int, float]], Optional[str]]:
  130. """解析月度分数:一月:90,九月:85 → [(1, 90.0), (9, 85.0), ...]"""
  131. if not score_str or not isinstance(score_str, str):
  132. return [], f"平时分数为空或格式错误: {score_str}"
  133. score_str = score_str.strip()
  134. if not score_str:
  135. return [], "平时分数为空"
  136. parsed_scores = []
  137. error_msgs = []
  138. # 分割多个月份的分数
  139. score_parts = score_str.split(',')
  140. for part in score_parts:
  141. part = part.strip()
  142. if not part:
  143. continue
  144. # 匹配"月份:分数"格式
  145. match = re.match(r'^(.+?):(.+)$', part)
  146. if not match:
  147. error_msgs.append(f"分数格式错误: {part}")
  148. continue
  149. month_name = match.group(1).strip()
  150. score_value = match.group(2).strip()
  151. # 验证月份名称
  152. if month_name not in MONTH_MAPPING:
  153. error_msgs.append(f"月份名称不正确: {month_name}")
  154. continue
  155. month_num = MONTH_MAPPING[month_name]
  156. # 验证分数
  157. try:
  158. score = float(score_value)
  159. # 取消分数范围限制,允许任何数值分数
  160. except ValueError:
  161. error_msgs.append(f"分数格式不是数字: {score_value}")
  162. continue
  163. parsed_scores.append((month_num, score))
  164. if error_msgs:
  165. return parsed_scores, "; ".join(error_msgs)
  166. elif not parsed_scores:
  167. return [], "未解析到任何有效分数"
  168. else:
  169. return parsed_scores, None
  170. def insert_parsed_data(self, parsed_records: List[Dict[str, Any]]) -> tuple[int, int]:
  171. """插入解析后的数据"""
  172. if not parsed_records:
  173. return 0, 0
  174. # 确保连接可用
  175. self.ensure_connection()
  176. try:
  177. with self.connection.cursor() as cursor:
  178. sql = f"""
  179. INSERT INTO {self.temp_parsed_table} (
  180. temp_id, student_no, academic_year_str, semester_str,
  181. academic_year, semester, month, score, processing_status
  182. ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'parsed')
  183. """
  184. values = []
  185. for record in parsed_records:
  186. values.append((
  187. record['temp_id'],
  188. record['student_no'],
  189. record['academic_year_str'],
  190. record['semester_str'],
  191. record['academic_year'],
  192. record['semester'],
  193. record['month'],
  194. record['score']
  195. ))
  196. cursor.executemany(sql, values)
  197. self.connection.commit()
  198. return len(parsed_records), 0
  199. except Exception as e:
  200. logger.error(f"插入解析数据失败: {e}")
  201. self.connection.rollback()
  202. return 0, len(parsed_records)
  203. def update_import_status(self, temp_ids: List[int], status: str, error_msg: str = None) -> None:
  204. """更新导入表的状态"""
  205. # 确保连接可用
  206. self.ensure_connection()
  207. try:
  208. with self.connection.cursor() as cursor:
  209. if error_msg:
  210. sql = f"""
  211. UPDATE {self.temp_import_table}
  212. SET import_status = %s, error_msg = %s, update_time = NOW()
  213. WHERE id IN ({','.join(['%s'] * len(temp_ids))})
  214. """
  215. cursor.execute(sql, [status, error_msg] + temp_ids)
  216. else:
  217. sql = f"""
  218. UPDATE {self.temp_import_table}
  219. SET import_status = %s, update_time = NOW()
  220. WHERE id IN ({','.join(['%s'] * len(temp_ids))})
  221. """
  222. cursor.execute(sql, [status] + temp_ids)
  223. self.connection.commit()
  224. except Exception as e:
  225. logger.error(f"更新导入状态失败: {e}")
  226. self.connection.rollback()
  227. def process_batch(self, batch_data: List[Dict[str, Any]]) -> Dict[str, Any]:
  228. """处理一批数据"""
  229. parsed_records = []
  230. success_ids = []
  231. error_ids = []
  232. for row in batch_data:
  233. temp_id = row['id']
  234. student_no = row['student_no']
  235. academic_year_str = row['academic_year_str']
  236. semester_str = row['semester_str']
  237. monthly_scores = row['monthly_scores']
  238. # 解析学年
  239. academic_year, year_error = self.parse_academic_year(academic_year_str)
  240. if year_error:
  241. logger.warning(f"学号{student_no}: {year_error}")
  242. error_ids.append(temp_id)
  243. continue
  244. # 解析学期
  245. semester, semester_error = self.parse_semester(semester_str)
  246. if semester_error:
  247. logger.warning(f"学号{student_no}: {semester_error}")
  248. error_ids.append(temp_id)
  249. continue
  250. # 解析月度分数
  251. monthly_data, score_error = self.parse_monthly_scores(monthly_scores)
  252. if score_error:
  253. logger.warning(f"学号{student_no}: {score_error}")
  254. # 如果解析出分数,创建记录
  255. if monthly_data:
  256. for month, score in monthly_data:
  257. parsed_records.append({
  258. 'temp_id': temp_id,
  259. 'student_no': student_no,
  260. 'academic_year_str': academic_year_str,
  261. 'semester_str': semester_str,
  262. 'academic_year': academic_year,
  263. 'semester': semester,
  264. 'month': month,
  265. 'score': score
  266. })
  267. success_ids.append(temp_id)
  268. else:
  269. # 没有解析出有效分数,但学年学期正确,也算成功(只是没有月度数据)
  270. if not score_error:
  271. success_ids.append(temp_id)
  272. else:
  273. error_ids.append(temp_id)
  274. # 插入解析后的数据
  275. inserted, failed = self.insert_parsed_data(parsed_records)
  276. # 更新状态
  277. if success_ids:
  278. self.update_import_status(success_ids, 'parsed')
  279. if error_ids:
  280. self.update_import_status(error_ids, 'error', '数据解析失败')
  281. return {
  282. 'processed': len(batch_data),
  283. 'success': len(success_ids),
  284. 'error': len(error_ids),
  285. 'parsed_records': inserted,
  286. 'inserted': inserted,
  287. 'insert_failed': failed
  288. }
  289. def run(self) -> bool:
  290. """执行解析流程"""
  291. start_time = time.time()
  292. total_processed = 0
  293. total_success = 0
  294. total_error = 0
  295. total_parsed_records = 0
  296. try:
  297. self.connect_db()
  298. logger.info("开始数据解析处理")
  299. batch_count = 0
  300. while True:
  301. batch_count += 1
  302. # 每处理10批数据检查一次连接健康状态
  303. if batch_count % 10 == 0:
  304. logger.info("执行定期连接健康检查...")
  305. self.ensure_connection()
  306. # 获取待处理数据
  307. batch_data = self.get_pending_data()
  308. if not batch_data:
  309. logger.info("没有更多待处理数据")
  310. break
  311. logger.info(f"处理批次: {len(batch_data)}条记录")
  312. # 处理这一批数据
  313. result = self.process_batch(batch_data)
  314. total_processed += result['processed']
  315. total_success += result['success']
  316. total_error += result['error']
  317. total_parsed_records += result['parsed_records']
  318. logger.info(f"批次结果: 处理{result['processed']}, 成功{result['success']}, "
  319. f"失败{result['error']}, 生成{result['parsed_records']}条月度记录")
  320. # 如果这一批都是错误,说明可能有问题,停止处理
  321. if result['success'] == 0 and result['processed'] > 0:
  322. logger.warning("当前批次全部处理失败,可能存在数据质量问题,请检查日志")
  323. break
  324. # 输出统计信息
  325. logger.info("="*50)
  326. logger.info("数据解析统计结果:")
  327. logger.info(f" 处理总记录: {total_processed}")
  328. logger.info(f" 解析成功: {total_success}")
  329. logger.info(f" 解析失败: {total_error}")
  330. logger.info(f" 生成月度记录: {total_parsed_records}")
  331. logger.info(f"解析总耗时: {elapsed_time:.1f}秒")
  332. logger.info("="*50)
  333. return total_error == 0
  334. except Exception as e:
  335. logger.error(f"解析过程出现错误: {e}")
  336. return False
  337. finally:
  338. self.disconnect_db()
  339. elapsed_time = time.time() - start_time
  340. logger.info(f"解析总耗时: {elapsed_time:.1f}秒")
  341. def main():
  342. """主函数"""
  343. parser = argparse.ArgumentParser(description='学生操行分历史数据 - 数据解析处理')
  344. logger.info("="*60)
  345. logger.info("学生操行分历史数据迁移 - 脚本2:数据解析处理")
  346. logger.info("="*60)
  347. # 创建logs目录
  348. import os
  349. os.makedirs("logs", exist_ok=True)
  350. # 执行解析
  351. parser = DataParser()
  352. success = parser.run()
  353. if success:
  354. logger.info("✅ 数据解析处理完成")
  355. sys.exit(0)
  356. else:
  357. logger.error("❌ 数据解析处理失败")
  358. sys.exit(1)
  359. if __name__ == "__main__":
  360. main()