02_parse_score_data_py36.py 18 KB

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