Files
hzdyjs/跟教务系统同步学生异动信息/api_service/app.py
T
2026-07-03 11:25:45 +08:00

406 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)