# -*- 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 名(勿随意改动,已写入 README) HEADER_API_KEY = "X-Jw-Cx-Key" VIEW_NAME = "vw_jw_student_change_export" # 视图列名:API 按「异动生效时间」所在自然年筛选 COL_EFFECTIVE_TIME = "异动生效时间" # 对外返回字段(与教务对接清单一致;不含视图内部列) EXPORT_COLUMNS = ( "提交时间", "异动生效学期", "异动类型", "申请人学号", "姓名", "异动前所在年级", "异动前所在校区", "异动前所在院系", "异动前所在专业", "异动前所在班级", "异动前学制", "异动后所在校区", "异动后所在年级", "异动后所在班级", "异动后所在院系", "异动后所在专业", "异动后学制", "异动后班主任工号", "异动后班主任姓名", "新名字", "审核状态", ) _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(): """加载并缓存 INI 配置;缺失文件时给出明确提示。""" 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() # Python 3.2+:read 支持 encoding 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_filter_year(): """配置文件 default_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(): """ 合并 URL 参数与配置文件: - year:Query `year`,未传则用 [api] default_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): return {col: _serialize_value(row.get(col)) for col in EXPORT_COLUMNS} 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, ) @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 select_cols = ", ".join("`%s`" % col for col in EXPORT_COLUMNS) where_year = ( "WHERE `{col}` IS NOT NULL AND YEAR(`{col}`) = %s".format( col=COL_EFFECTIVE_TIME ) ) sql_list = ( "SELECT {cols} FROM `{view}` {where_clause} " "ORDER BY `来源记录id` ASC" ).format(cols=select_cols, view=VIEW_NAME, where_clause=where_year) conn = None try: conn = _db_connect() with conn.cursor() as cur: cur.execute(sql_list, (filter_year,)) rows = cur.fetchall() except pymysql.Error as e: return jsonify({"code": 500, "msg": "数据库查询失败", "detail": str(e)}), 500 finally: if conn is not None: conn.close() data = [_row_to_export_dict(r) for r in rows] return jsonify( { "code": 200, "msg": "success", "data": data, "total": len(data), "filter_year": filter_year, "filter_by_column": COL_EFFECTIVE_TIME, } ) if __name__ == "__main__": _load_config() bind = server_bind() app.run(host=bind["host"], port=bind["port"], threaded=True)