406 lines
11 KiB
Python
406 lines
11 KiB
Python
# -*- 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)
|