This commit is contained in:
zlwu committed 2026-05-07 16:23:46 +08:00
1 parent 33d75d6d1b
commit 52d892337e
6 files changed
+508

No files matched your search

@@ -0,0 +1,270 @@
# -*- 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"
# 视图列名(与 vw_jw_student_change_export 一致;筛选近 N 天时使用)
COL_SUBMIT_DATE = "提交时间"
_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 _api_defaults_from_config():
"""从 [api] 读取 page_size、recent_days 默认值(INI 见 config*.ini)。"""
page_size = _get_int("api", "page_size", 500)
recent_days = _get_int("api", "recent_days", 30)
if page_size < 1:
page_size = 1
if page_size > 5000:
page_size = 5000
if recent_days < 1:
recent_days = 1
if recent_days > 3660:
recent_days = 3660
return page_size, recent_days
def _clamp_int(value, lo, hi):
v = int(value)
if v < lo:
return lo
if v > hi:
return hi
return v
def _resolve_query_paging():
"""
合并 URL 参数与配置文件:
- page_size:Query `page_size`,未传则用 [api] page_size
- recent_days:Query `recent_days` 或 `days`(别名),未传则用 [api] recent_days
范围:page_size 1~5000,recent_days 1~3660
"""
cfg_ps, cfg_rd = _api_defaults_from_config()
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 必须为整数"
else:
page_size = cfg_ps
raw_rd = request.args.get("recent_days")
if raw_rd is None or str(raw_rd).strip() == "":
raw_rd = request.args.get("days")
if raw_rd is not None and str(raw_rd).strip() != "":
try:
recent_days = _clamp_int(raw_rd, 1, 3660)
except (ValueError, TypeError):
return None, None, "recent_days / days 必须为整数"
else:
recent_days = cfg_rd
return page_size, recent_days, 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_dict(row):
return {k: _serialize_value(v) for k, v in row.items()}
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
try:
page = int(request.args.get("page", "1"))
except ValueError:
return jsonify({"code": 400, "msg": "page 必须为整数"}), 400
if page < 1:
page = 1
page_size, recent_days, param_err = _resolve_query_paging()
if param_err:
return jsonify({"code": 400, "msg": param_err}), 400
offset = (page - 1) * page_size
# 近 recent_days 天:按视图中的「提交时间」筛选(登记/提交日期)
where_recent = (
"WHERE `{col}` >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)".format(
col=COL_SUBMIT_DATE, days=recent_days
)
)
sql_count = "SELECT COUNT(*) AS cnt FROM `{view}` {where_clause}".format(
view=VIEW_NAME, where_clause=where_recent
)
sql_list = (
"SELECT * FROM `{view}` {where_clause} "
"ORDER BY `来源记录id` ASC LIMIT %s OFFSET %s"
).format(view=VIEW_NAME, where_clause=where_recent)
conn = None
try:
conn = _db_connect()
with conn.cursor() as cur:
cur.execute(sql_count)
total = int(cur.fetchone()["cnt"])
cur.execute(sql_list, (page_size, offset))
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_dict(r) for r in rows]
return jsonify(
{
"code": 200,
"msg": "success",
"data": data,
"page": page,
"page_size": page_size,
"total": total,
"filter_recent_days": recent_days,
"filter_by_column": COL_SUBMIT_DATE,
}
)
if __name__ == "__main__":
_load_config()
bind = server_bind()
app.run(host=bind["host"], port=bind["port"], threaded=True)