# -*- coding: utf-8 -*- """ 教务同步:学生异动 + 学生基本信息 只读 API(Python 3.7+) 配置:默认读取与本文件同目录下的 config.ini(INI 格式),路径可用环境变量 JW_CX_CONFIG 覆盖。 校验:请求头 X-Jw-Cx-Key 须与配置文件 [api] api_key 一致。 """ import hmac import os import sys from datetime import date, datetime from decimal import Decimal import configparser import pymysql from flask import Flask, jsonify, request from pymysql.cursors import DictCursor HEADER_API_KEY = "X-Jw-Cx-Key" VIEW_STUDENT_CHANGES = "vw_jw_student_change_export" VIEW_STUDENT_BASE = "vw_jw_student_export" COL_EFFECTIVE_TIME = "异动生效时间" CHANGE_EXPORT_COLUMNS = ( "提交时间", "异动生效学期", "异动类型", "申请人学号", "姓名", "异动前所在年级", "异动前所在校区", "异动前所在院系", "异动前所在专业", "异动前所在班级", "异动前学制", "异动后所在校区", "异动后所在年级", "异动后所在班级", "异动后所在院系", "异动后所在专业", "异动后学制", "异动后班主任工号", "异动后班主任姓名", "新名字", "审核状态", ) STUDENT_EXPORT_COLUMNS = ( "姓名", "学号", "联系电话", "身份证件类型", "身份证件号", "班级信息", "所在校区", "所在年级", "入学年份", "系部信息", "专业信息", "班主任工号", "班主任姓名", "学生当前状态", "学生是否在校", "异动类型", "培养层次", "学制", "毕业时间", "毕业学年学期", "学习经历", "何年何月-何年何月", "在何地学习", "性别", "籍贯地", "在校照片", "出生日期", "家庭现住址", "成员1姓名", "成员2姓名", "成员1关系", "成员2关系", "成员1联系电话", "成员2联系电话", ) _BASE_DIR = os.path.dirname(os.path.abspath(__file__)) _DEFAULT_CONFIG_PATH = os.path.join(_BASE_DIR, "config.ini") app = Flask(__name__) _config_parser = None def _config_path(): return os.environ.get("JW_CX_CONFIG", _DEFAULT_CONFIG_PATH) def _load_config(): global _config_parser if _config_parser is not None: return _config_parser path = _config_path() if not os.path.isfile(path): sys.stderr.write( "错误:未找到配置文件 %s\n" "请复制 config.ini.example 为 config.ini 并填写数据库等信息," "或设置环境变量 JW_CX_CONFIG 指向您的配置文件路径。\n" % (path,) ) sys.exit(1) parser = configparser.ConfigParser() read_ok = parser.read(path, encoding="utf-8") if not read_ok: sys.stderr.write("错误:无法读取配置文件 %s\n" % (path,)) sys.exit(1) _config_parser = parser return _config_parser def _get(section, option, fallback=""): parser = _load_config() if parser.has_option(section, option): return parser.get(section, option).strip() return fallback def _get_int(section, option, fallback): raw = _get(section, option, str(fallback)) try: return int(raw) except ValueError: return int(fallback) def configured_api_key(): return _get("api", "api_key", "JWCX001") def db_config(): return { "host": _get("database", "host", "127.0.0.1"), "port": _get_int("database", "port", 3306), "user": _get("database", "user", "jw_cx"), "password": _get("database", "password", ""), "database": _get("database", "database", "hzdyjs_xg"), } def server_bind(): return { "host": _get("api", "bind_host", "0.0.0.0"), "port": _get_int("api", "bind_port", 5050), } def _default_page_size(): page_size = _get_int("api", "page_size", 500) if page_size < 1: page_size = 1 if page_size > 5000: page_size = 5000 return page_size def _clamp_int(value, lo, hi): v = int(value) if v < lo: return lo if v > hi: return hi return v def _resolve_paging(): """解析 page / page_size;未传 page_size 则用配置 [api] page_size。""" try: page = int(request.args.get("page", "1")) except (ValueError, TypeError): return None, None, "page 必须为整数" if page < 1: page = 1 page_size = _default_page_size() raw_ps = request.args.get("page_size") if raw_ps is not None and str(raw_ps).strip() != "": try: page_size = _clamp_int(raw_ps, 1, 5000) except (ValueError, TypeError): return None, None, "page_size 必须为整数" return page, page_size, None def _default_filter_year(): raw = _get("api", "default_year", "") if raw: try: year = int(raw) if 2000 <= year <= 2100: return year except ValueError: pass return date.today().year def _resolve_filter_year(): default_year = _default_filter_year() raw_year = request.args.get("year") if raw_year is not None and str(raw_year).strip() != "": try: year = int(raw_year) except (ValueError, TypeError): return None, "year 必须为整数" if year < 2000 or year > 2100: return None, "year 有效范围为 2000~2100" else: year = default_year return year, None def _check_api_key(): sent = request.headers.get(HEADER_API_KEY, "") expected = configured_api_key() if not expected: return False try: return hmac.compare_digest(sent.encode("utf-8"), expected.encode("utf-8")) except Exception: return False def _serialize_value(value): if value is None: return None if isinstance(value, datetime): return value.strftime("%Y-%m-%d %H:%M:%S") if isinstance(value, date): return value.strftime("%Y-%m-%d") if isinstance(value, Decimal): return str(value) return value def _row_to_export_dict(row, columns): return {col: _serialize_value(row.get(col)) for col in columns} def _dedupe_student_changes(rows, columns): """同一学号 + 异动类型 + 提交时间 仅保留首条(兜底,防视图 JOIN 放大或学号在关联表)。""" seen = set() out = [] for row in rows: item = _row_to_export_dict(row, columns) key = ( item.get("申请人学号"), item.get("异动类型"), item.get("提交时间"), ) if key in seen: continue seen.add(key) out.append(item) return out def _db_connect(): cfg = db_config() return pymysql.connect( host=cfg["host"], port=cfg["port"], user=cfg["user"], password=cfg["password"], database=cfg["database"], charset="utf8mb4", cursorclass=DictCursor, autocommit=True, ) def _fetch_view_page(view_name, columns, page, page_size, where_clause="", params=()): """分页查询视图,返回 (rows, total)。""" select_cols = ", ".join("`%s`" % col for col in columns) sql_count = "SELECT COUNT(*) AS cnt FROM `{view}` {where}".format( view=view_name, where=where_clause ) offset = (page - 1) * page_size sql_list = ( "SELECT {cols} FROM `{view}` {where} " "ORDER BY `来源记录id` ASC LIMIT %s OFFSET %s" ).format(cols=select_cols, view=view_name, where=where_clause) conn = None try: conn = _db_connect() with conn.cursor() as cur: cur.execute(sql_count, params) total = int(cur.fetchone()["cnt"]) cur.execute(sql_list, params + (page_size, offset)) rows = cur.fetchall() return rows, total finally: if conn is not None: conn.close() def _fetch_view_rows(view_name, columns, where_clause="", params=(), order_by="`来源记录id` ASC"): select_cols = ", ".join("`%s`" % col for col in columns) sql = "SELECT {cols} FROM `{view}` {where} ORDER BY {order_by}".format( cols=select_cols, view=view_name, where=where_clause, order_by=order_by, ) conn = None try: conn = _db_connect() with conn.cursor() as cur: cur.execute(sql, params) return cur.fetchall() finally: if conn is not None: conn.close() @app.route("/health", methods=["GET"]) def health(): return jsonify({"status": "ok"}) @app.route("/api/v1/student-changes", methods=["GET"]) def list_student_changes(): if not _check_api_key(): return jsonify({"code": 401, "msg": "无效的密钥或未提供请求头 " + HEADER_API_KEY}), 401 filter_year, param_err = _resolve_filter_year() if param_err: return jsonify({"code": 400, "msg": param_err}), 400 where_year = "WHERE `{col}` IS NOT NULL AND YEAR(`{col}`) = %s".format( col=COL_EFFECTIVE_TIME ) try: rows = _fetch_view_rows( VIEW_STUDENT_CHANGES, CHANGE_EXPORT_COLUMNS, where_clause=where_year, params=(filter_year,), ) except pymysql.Error as e: return jsonify({"code": 500, "msg": "数据库查询失败", "detail": str(e)}), 500 data = _dedupe_student_changes(rows, CHANGE_EXPORT_COLUMNS) return jsonify( { "code": 200, "msg": "success", "data": data, "total": len(data), "filter_year": filter_year, "filter_by_column": COL_EFFECTIVE_TIME, } ) @app.route("/api/v1/students", methods=["GET"]) def list_students(): """分页返回未删除学生基本信息。""" if not _check_api_key(): return jsonify({"code": 401, "msg": "无效的密钥或未提供请求头 " + HEADER_API_KEY}), 401 page, page_size, param_err = _resolve_paging() if param_err: return jsonify({"code": 400, "msg": param_err}), 400 try: rows, total = _fetch_view_page( VIEW_STUDENT_BASE, STUDENT_EXPORT_COLUMNS, page, page_size ) except pymysql.Error as e: return jsonify({"code": 500, "msg": "数据库查询失败", "detail": str(e)}), 500 data = [_row_to_export_dict(r, STUDENT_EXPORT_COLUMNS) for r in rows] total_pages = (total + page_size - 1) // page_size if total > 0 else 0 return jsonify( { "code": 200, "msg": "success", "data": data, "total": total, "page": page, "page_size": page_size, "total_pages": total_pages, } ) if __name__ == "__main__": _load_config() bind = server_bind() app.run(host=bind["host"], port=bind["port"], threaded=True)