02_parse_score_data_py36_compatible.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 学生操行分历史数据迁移 - 脚本2:数据解析处理(Python 3.6兼容版)
  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 3.6
  18. - 移除类型注解
  19. - 使用.format()替代f-string
  20. - 简化字符串格式化表达式
  21. 执行方式:
  22. python 02_parse_score_data_py36_compatible.py
  23. 注意事项:
  24. - 依赖脚本1已执行完成
  25. - 支持断点续传(已解析的数据会跳过)
  26. - 自动处理数据库连接问题
  27. """
  28. # 完全兼容Python 3.4+的版本,不使用任何高级类型注解
  29. import argparse
  30. import logging
  31. import sys
  32. import time
  33. import pymysql
  34. from db_config import DB_CONFIG
  35. # 配置日志
  36. logging.basicConfig(
  37. level=logging.INFO,
  38. format='%(asctime)s - %(levelname)s - %(message)s',
  39. handlers=[
  40. logging.FileHandler('02_parse_score_data.log', encoding='utf-8'),
  41. logging.StreamHandler(sys.stdout)
  42. ]
  43. )
  44. logger = logging.getLogger(__name__)
  45. # 常量配置
  46. CONSTANTS = {
  47. "BATCH_SIZE": 1000, # 每批处理1000条记录
  48. "MAX_RETRIES": 3, # 最大重试次数
  49. "RETRY_DELAY": 5, # 重试间隔(秒)
  50. }
  51. # 数据库表配置
  52. TABLE_CONFIG = {
  53. "temp_import": "temp_conduct_score_import", # 临时导入表
  54. "temp_parsed": "temp_conduct_score_parsed", # 解析结果表
  55. }
  56. class ConductScoreParser:
  57. """学生操行分数据解析器(Python 3.6兼容版)"""
  58. def __init__(self, db_config):
  59. self.db_config = db_config
  60. self.temp_import_table = TABLE_CONFIG["temp_import"]
  61. self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
  62. self.batch_size = CONSTANTS["BATCH_SIZE"]
  63. self.max_retries = self.db_config.get('max_retries', 3)
  64. self.connection = None
  65. self.last_health_check = time.time()
  66. self.health_check_interval = 300 # 5分钟检查一次连接健康状态
  67. def connect_db(self):
  68. """连接数据库(带重试机制)"""
  69. retry_delay = 5 # 每次重试间隔5秒
  70. for attempt in range(self.max_retries + 1):
  71. try:
  72. self.connection = pymysql.connect(**self.db_config)
  73. logger.info("数据库连接成功")
  74. self.last_health_check = time.time()
  75. return
  76. except Exception as e:
  77. if attempt < self.max_retries:
  78. # 简化f-string表达式,避免Python 3.6兼容性问题
  79. attempt_info = "数据库连接失败 (尝试 {}/{})".format(attempt + 1, self.max_retries + 1)
  80. logger.warning("{}: {}".format(attempt_info, e))
  81. logger.info("等待 {} 秒后重试...".format(retry_delay))
  82. time.sleep(retry_delay)
  83. retry_delay = min(retry_delay * 2, 60) # 指数退避,最多60秒
  84. else:
  85. logger.error("数据库连接失败,已达到最大重试次数: {}".format(e))
  86. raise
  87. def disconnect_db(self):
  88. """断开数据库连接"""
  89. if self.connection:
  90. try:
  91. self.connection.close()
  92. logger.info("数据库连接已断开")
  93. except Exception as e:
  94. logger.warning("断开连接时出现警告: {}".format(e))
  95. finally:
  96. self.connection = None
  97. def check_connection_health(self):
  98. """检查数据库连接健康状态"""
  99. if not self.connection:
  100. return False
  101. try:
  102. # 执行简单查询检查连接
  103. with self.connection.cursor() as cursor:
  104. cursor.execute("SELECT 1")
  105. cursor.fetchone()
  106. self.last_health_check = time.time()
  107. return True
  108. except Exception as e:
  109. logger.warning("连接健康检查失败: {}".format(e))
  110. return False
  111. def ensure_connection(self):
  112. """确保数据库连接有效,必要时重连"""
  113. current_time = time.time()
  114. # 检查是否需要定期健康检查
  115. if current_time - self.last_health_check > self.health_check_interval:
  116. if not self.check_connection_health():
  117. logger.info("连接健康检查失败,尝试重连...")
  118. self.disconnect_db()
  119. self.connect_db()
  120. elif not self.connection:
  121. # 连接不存在,直接连接
  122. self.connect_db()
  123. def get_pending_data(self):
  124. """获取待解析的数据"""
  125. max_retries = 3
  126. retry_delay = 2
  127. for attempt in range(max_retries):
  128. try:
  129. self.ensure_connection()
  130. with self.connection.cursor() as cursor:
  131. # 获取未解析或解析失败的数据
  132. sql = """
  133. SELECT temp_id, student_no, academic_year, semester, conduct_score
  134. FROM {}
  135. WHERE import_status IN ('pending', 'error')
  136. ORDER BY temp_id
  137. LIMIT {}
  138. """.format(self.temp_import_table, self.batch_size)
  139. cursor.execute(sql)
  140. columns = [desc[0] for desc in cursor.description]
  141. rows = cursor.fetchall()
  142. # 转换为字典列表
  143. data = []
  144. for row in rows:
  145. data.append(dict(zip(columns, row)))
  146. logger.info("获取到 {} 条待解析记录".format(len(data)))
  147. return data
  148. except Exception as e:
  149. if attempt < max_retries - 1:
  150. attempt_info = "获取待解析数据失败 (尝试 {}/{})".format(attempt + 1, max_retries)
  151. logger.warning("{}: {}".format(attempt_info, e))
  152. time.sleep(retry_delay)
  153. retry_delay *= 2
  154. else:
  155. logger.error("获取待解析数据失败,已达到最大重试次数: {}".format(e))
  156. return []
  157. return []
  158. def parse_academic_year(self, year_str):
  159. """解析学年字符串,返回(年份, 错误信息)"""
  160. if not year_str or not isinstance(year_str, str):
  161. return None, "学年为空或格式错误: {}".format(year_str)
  162. year_str = year_str.strip()
  163. # 尝试匹配XXXX-XXXX格式
  164. if '-' in year_str:
  165. parts = year_str.split('-')
  166. if len(parts) == 2:
  167. try:
  168. start_year = int(parts[0].strip())
  169. end_year = int(parts[1].strip())
  170. # 检查年份跨度是否合理(应该相差1年)
  171. if end_year - start_year == 1:
  172. # 检查年份范围是否合理
  173. if 2000 <= start_year <= 2030:
  174. return start_year, None
  175. else:
  176. return None, "学年范围不合理: {}".format(year_str)
  177. else:
  178. return None, "学年跨度不正确: {}".format(year_str)
  179. except ValueError:
  180. return None, "学年格式不正确,应为XXXX-XXXX格式: {}".format(year_str)
  181. else:
  182. return None, "学年格式不正确,应为XXXX-XXXX格式: {}".format(year_str)
  183. else:
  184. # 尝试直接转换为整数
  185. try:
  186. year = int(year_str)
  187. if 2000 <= year <= 2030:
  188. return year, None
  189. else:
  190. return None, "学年范围不合理: {}".format(year_str)
  191. except ValueError:
  192. return None, "学年格式不正确: {}".format(year_str)
  193. def parse_semester(self, semester_str):
  194. """解析学期字符串,返回(学期编号, 错误信息)"""
  195. if not semester_str or not isinstance(semester_str, str):
  196. return None, "学期为空或格式错误: {}".format(semester_str)
  197. semester = semester_str.strip()
  198. # 映射学期名称到编号
  199. semester_mapping = {
  200. "第一学期": 1,
  201. "第二学期": 2,
  202. "上学期": 1,
  203. "下学期": 2,
  204. "春季学期": 1,
  205. "秋季学期": 2
  206. }
  207. if semester in semester_mapping:
  208. return semester_mapping[semester], None
  209. else:
  210. return None, "学期格式不正确,应为'第一学期'或'第二学期': {}".format(semester)
  211. def parse_monthly_scores(self, score_str):
  212. """解析月度分数字符串,返回(分数列表, 错误信息)"""
  213. if not score_str or not isinstance(score_str, str):
  214. return [], "平时分数为空或格式错误: {}".format(score_str)
  215. score_str = score_str.strip()
  216. monthly_scores = []
  217. error_msgs = []
  218. # 按逗号分割多个月份的分数
  219. parts = [part.strip() for part in score_str.split(',') if part.strip()]
  220. # 月度映射
  221. month_mapping = {
  222. "一月": 1, "二月": 2, "三月": 3, "四月": 4, "五月": 5, "六月": 6,
  223. "七月": 7, "八月": 8, "九月": 9, "十月": 10, "十一月": 11, "十二月": 12,
  224. "1月": 1, "2月": 2, "3月": 3, "4月": 4, "5月": 5, "6月": 6,
  225. "7月": 7, "8月": 8, "9月": 9, "10月": 10, "11月": 11, "12月": 12
  226. }
  227. for part in parts:
  228. if ':' not in part:
  229. error_msgs.append("分数格式错误: {}".format(part))
  230. continue
  231. month_part, score_part = part.split(':', 1)
  232. month_name = month_part.strip()
  233. score_value = score_part.strip()
  234. # 解析月份
  235. if month_name not in month_mapping:
  236. error_msgs.append("月份名称不正确: {}".format(month_name))
  237. continue
  238. month_num = month_mapping[month_name]
  239. # 解析分数
  240. try:
  241. score = float(score_value)
  242. if 0 <= score <= 100:
  243. monthly_scores.append((month_num, score))
  244. else:
  245. error_msgs.append("分数范围不正确(0-100): {}".format(score))
  246. except ValueError:
  247. error_msgs.append("分数格式不是数字: {}".format(score_value))
  248. if error_msgs:
  249. return monthly_scores, "; ".join(error_msgs)
  250. else:
  251. return monthly_scores, None
  252. def insert_parsed_data(self, parsed_records):
  253. """插入解析后的数据"""
  254. if not parsed_records:
  255. return 0, 0
  256. max_retries = 3
  257. retry_delay = 2
  258. total_inserted = 0
  259. total_failed = 0
  260. for attempt in range(max_retries):
  261. try:
  262. self.ensure_connection()
  263. with self.connection.cursor() as cursor:
  264. # 批量插入解析结果
  265. sql = """
  266. INSERT INTO {} (
  267. temp_id, student_no, academic_year, semester,
  268. score_month, conduct_score, parse_status, error_message
  269. ) VALUES {}
  270. """.format(
  271. self.temp_parsed_table,
  272. ", ".join(["(%s, %s, %s, %s, %s, %s, %s, %s)"] * len(parsed_records))
  273. )
  274. # 准备参数
  275. params = []
  276. for record in parsed_records:
  277. params.extend([
  278. record['temp_id'],
  279. record['student_no'],
  280. record['academic_year'],
  281. record['semester'],
  282. record['score_month'],
  283. record['conduct_score'],
  284. record['parse_status'],
  285. record['error_message']
  286. ])
  287. cursor.execute(sql, params)
  288. self.connection.commit()
  289. total_inserted = len(parsed_records)
  290. logger.info("成功插入 {} 条解析记录".format(total_inserted))
  291. return total_inserted, 0
  292. except Exception as e:
  293. total_failed = len(parsed_records)
  294. if attempt < max_retries - 1:
  295. attempt_info = "插入解析数据失败 (尝试 {}/{})".format(attempt + 1, max_retries)
  296. logger.warning("{}: {}".format(attempt_info, e))
  297. time.sleep(retry_delay)
  298. retry_delay *= 2
  299. else:
  300. logger.error("插入解析数据失败,已达到最大重试次数: {}".format(e))
  301. return 0, total_failed
  302. return total_inserted, total_failed
  303. def update_import_status(self, temp_ids, status, error_msg=None):
  304. """更新导入状态"""
  305. if not temp_ids:
  306. return
  307. max_retries = 3
  308. retry_delay = 2
  309. for attempt in range(max_retries):
  310. try:
  311. self.ensure_connection()
  312. with self.connection.cursor() as cursor:
  313. # 批量更新状态
  314. placeholders = ", ".join(["%s"] * len(temp_ids))
  315. if error_msg:
  316. sql = """
  317. UPDATE {} SET import_status = %s, error_message = %s, updated_at = NOW()
  318. WHERE temp_id IN ({})
  319. """.format(self.temp_import_table, placeholders)
  320. cursor.execute(sql, [status, error_msg] + temp_ids)
  321. else:
  322. sql = """
  323. UPDATE {} SET import_status = %s, error_message = NULL, updated_at = NOW()
  324. WHERE temp_id IN ({})
  325. """.format(self.temp_import_table, placeholders)
  326. cursor.execute(sql, [status] + temp_ids)
  327. self.connection.commit()
  328. affected_rows = cursor.rowcount
  329. logger.info("更新 {} 条记录状态为: {}".format(affected_rows, status))
  330. return
  331. except Exception as e:
  332. if attempt < max_retries - 1:
  333. attempt_info = "更新导入状态失败 (尝试 {}/{})".format(attempt + 1, max_retries)
  334. logger.warning("{}: {}".format(attempt_info, e))
  335. time.sleep(retry_delay)
  336. retry_delay *= 2
  337. else:
  338. logger.error("更新导入状态失败,已达到最大重试次数: {}".format(e))
  339. def process_batch(self, batch_data):
  340. """处理一批数据"""
  341. processed = 0
  342. success = 0
  343. error = 0
  344. parsed_records = []
  345. for record in batch_data:
  346. processed += 1
  347. temp_id = record['temp_id']
  348. student_no = record['student_no']
  349. try:
  350. # 解析学年
  351. academic_year, year_error = self.parse_academic_year(record['academic_year'])
  352. if year_error:
  353. logger.warning("学号{}: {}".format(student_no, year_error))
  354. error += 1
  355. continue
  356. # 解析学期
  357. semester, semester_error = self.parse_semester(record['semester'])
  358. if semester_error:
  359. logger.warning("学号{}: {}".format(student_no, semester_error))
  360. error += 1
  361. continue
  362. # 解析月度分数
  363. monthly_scores, score_error = self.parse_monthly_scores(record['conduct_score'])
  364. if score_error:
  365. logger.warning("学号{}: {}".format(student_no, score_error))
  366. error += 1
  367. continue
  368. # 生成解析记录
  369. for month, score in monthly_scores:
  370. parsed_records.append({
  371. 'temp_id': temp_id,
  372. 'student_no': student_no,
  373. 'academic_year': academic_year,
  374. 'semester': semester,
  375. 'score_month': month,
  376. 'conduct_score': score,
  377. 'parse_status': 'success',
  378. 'error_message': None
  379. })
  380. success += 1
  381. except Exception as e:
  382. logger.error("处理记录时出现异常 (学号: {}): {}".format(student_no, e))
  383. error += 1
  384. return {
  385. 'processed': processed,
  386. 'success': success,
  387. 'error': error,
  388. 'parsed_records': parsed_records
  389. }
  390. def run(self):
  391. """主运行函数"""
  392. start_time = time.time()
  393. logger.info("开始学生操行分数据解析...")
  394. logger.info("批量大小: {}, 重试次数: {}".format(self.batch_size, self.max_retries))
  395. try:
  396. # 连接数据库
  397. self.connect_db()
  398. total_processed = 0
  399. total_success = 0
  400. total_error = 0
  401. total_parsed_records = 0
  402. batch_count = 0
  403. while True:
  404. batch_count += 1
  405. # 获取待处理数据
  406. batch_data = self.get_pending_data()
  407. if not batch_data:
  408. logger.info("没有更多待处理数据,解析完成")
  409. break
  410. # 执行定期连接健康检查
  411. if batch_count % 10 == 0: # 每10批检查一次
  412. logger.info("已处理 {} 批数据,执行定期连接健康检查...".format(batch_count))
  413. self.ensure_connection()
  414. # 处理当前批次
  415. logger.info("处理批次 {}: {}条记录".format(batch_count, len(batch_data)))
  416. result = self.process_batch(batch_data)
  417. # 插入解析结果
  418. if result['parsed_records']:
  419. inserted, failed = self.insert_parsed_data(result['parsed_records'])
  420. if failed > 0:
  421. logger.error("批次 {} 插入失败 {} 条记录".format(batch_count, failed))
  422. # 更新导入状态
  423. temp_ids = [record['temp_id'] for record in batch_data]
  424. if result['success'] > 0:
  425. success_ids = temp_ids[:result['success']]
  426. self.update_import_status(success_ids, 'completed')
  427. if result['error'] > 0:
  428. error_ids = temp_ids[result['success']:result['success'] + result['error']]
  429. self.update_import_status(error_ids, 'error', '解析失败')
  430. # 统计信息
  431. logger.info("批次 {} 结果: 处理{}, 成功{}, 失败{}, 生成{}条月度记录".format(
  432. batch_count, result['processed'], result['success'],
  433. result['error'], len(result['parsed_records'])
  434. ))
  435. # 累计统计
  436. total_processed += result['processed']
  437. total_success += result['success']
  438. total_error += result['error']
  439. total_parsed_records += len(result['parsed_records'])
  440. # 输出最终统计
  441. elapsed_time = time.time() - start_time
  442. logger.info("数据解析完成!")
  443. logger.info(" 处理总记录: {}".format(total_processed))
  444. logger.info(" 解析成功: {}".format(total_success))
  445. logger.info(" 解析失败: {}".format(total_error))
  446. logger.info(" 生成月度记录: {}".format(total_parsed_records))
  447. logger.info("解析总耗时: {:.1f}秒".format(elapsed_time))
  448. return True
  449. except KeyboardInterrupt:
  450. logger.info("用户中断解析过程")
  451. return False
  452. except Exception as e:
  453. elapsed_time = time.time() - start_time
  454. logger.error("解析过程出现错误: {}".format(e))
  455. return False
  456. finally:
  457. self.disconnect_db()
  458. elapsed_time = time.time() - start_time
  459. logger.info("解析总耗时: {:.1f}秒".format(elapsed_time))
  460. def main():
  461. """主函数"""
  462. parser = argparse.ArgumentParser(description='学生操行分数据解析工具')
  463. parser.add_argument('--config', help='数据库配置文件路径')
  464. parser.add_argument('--batch-size', type=int, default=1000, help='批处理大小')
  465. parser.add_argument('--max-retries', type=int, default=3, help='最大重试次数')
  466. args = parser.parse_args()
  467. # 更新配置
  468. if args.batch_size:
  469. CONSTANTS["BATCH_SIZE"] = args.batch_size
  470. # 创建解析器并运行
  471. parser = ConductScoreParser(DB_CONFIG)
  472. success = parser.run()
  473. sys.exit(0 if success else 1)
  474. if __name__ == "__main__":
  475. main()