02_parse_score_data_py34_compatible.py 21 KB

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