app.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. # -*- coding: utf-8 -*-
  2. """
  3. 教务同步:学生异动视图只读 API(Python 3.7+)
  4. 配置:默认读取与本文件同目录下的 config.ini(INI 格式),路径可用环境变量 JW_CX_CONFIG 覆盖。
  5. 校验:请求头 X-Jw-Cx-Key 须与配置文件 [api] api_key 一致。
  6. """
  7. import hmac
  8. import os
  9. import sys
  10. from datetime import date, datetime
  11. from decimal import Decimal
  12. import configparser
  13. import pymysql
  14. from flask import Flask, jsonify, request
  15. from pymysql.cursors import DictCursor
  16. # 与调用方约定的 Header 名(勿随意改动,已写入 README)
  17. HEADER_API_KEY = "X-Jw-Cx-Key"
  18. VIEW_NAME = "vw_jw_student_change_export"
  19. # 视图列名(与 vw_jw_student_change_export 一致;筛选近 N 天时使用)
  20. COL_SUBMIT_DATE = "提交时间"
  21. _BASE_DIR = os.path.dirname(os.path.abspath(__file__))
  22. _DEFAULT_CONFIG_PATH = os.path.join(_BASE_DIR, "config.ini")
  23. app = Flask(__name__)
  24. _config_parser = None
  25. def _config_path():
  26. return os.environ.get("JW_CX_CONFIG", _DEFAULT_CONFIG_PATH)
  27. def _load_config():
  28. """加载并缓存 INI 配置;缺失文件时给出明确提示。"""
  29. global _config_parser
  30. if _config_parser is not None:
  31. return _config_parser
  32. path = _config_path()
  33. if not os.path.isfile(path):
  34. sys.stderr.write(
  35. "错误:未找到配置文件 %s\n"
  36. "请复制 config.ini.example 为 config.ini 并填写数据库等信息,"
  37. "或设置环境变量 JW_CX_CONFIG 指向您的配置文件路径。\n" % (path,)
  38. )
  39. sys.exit(1)
  40. parser = configparser.ConfigParser()
  41. # Python 3.2+:read 支持 encoding
  42. read_ok = parser.read(path, encoding="utf-8")
  43. if not read_ok:
  44. sys.stderr.write("错误:无法读取配置文件 %s\n" % (path,))
  45. sys.exit(1)
  46. _config_parser = parser
  47. return _config_parser
  48. def _get(section, option, fallback=""):
  49. parser = _load_config()
  50. if parser.has_option(section, option):
  51. return parser.get(section, option).strip()
  52. return fallback
  53. def _get_int(section, option, fallback):
  54. raw = _get(section, option, str(fallback))
  55. try:
  56. return int(raw)
  57. except ValueError:
  58. return int(fallback)
  59. def configured_api_key():
  60. return _get("api", "api_key", "JWCX001")
  61. def db_config():
  62. return {
  63. "host": _get("database", "host", "127.0.0.1"),
  64. "port": _get_int("database", "port", 3306),
  65. "user": _get("database", "user", "jw_cx"),
  66. "password": _get("database", "password", ""),
  67. "database": _get("database", "database", "hzdyjs_xg"),
  68. }
  69. def server_bind():
  70. return {
  71. "host": _get("api", "bind_host", "0.0.0.0"),
  72. "port": _get_int("api", "bind_port", 5050),
  73. }
  74. def _api_defaults_from_config():
  75. """从 [api] 读取 page_size、recent_days 默认值(INI 见 config*.ini)。"""
  76. page_size = _get_int("api", "page_size", 500)
  77. recent_days = _get_int("api", "recent_days", 30)
  78. if page_size < 1:
  79. page_size = 1
  80. if page_size > 5000:
  81. page_size = 5000
  82. if recent_days < 1:
  83. recent_days = 1
  84. if recent_days > 3660:
  85. recent_days = 3660
  86. return page_size, recent_days
  87. def _clamp_int(value, lo, hi):
  88. v = int(value)
  89. if v < lo:
  90. return lo
  91. if v > hi:
  92. return hi
  93. return v
  94. def _resolve_query_paging():
  95. """
  96. 合并 URL 参数与配置文件:
  97. - page_size:Query `page_size`,未传则用 [api] page_size
  98. - recent_days:Query `recent_days` 或 `days`(别名),未传则用 [api] recent_days
  99. 范围:page_size 1~5000,recent_days 1~3660
  100. """
  101. cfg_ps, cfg_rd = _api_defaults_from_config()
  102. raw_ps = request.args.get("page_size")
  103. if raw_ps is not None and str(raw_ps).strip() != "":
  104. try:
  105. page_size = _clamp_int(raw_ps, 1, 5000)
  106. except (ValueError, TypeError):
  107. return None, None, "page_size 必须为整数"
  108. else:
  109. page_size = cfg_ps
  110. raw_rd = request.args.get("recent_days")
  111. if raw_rd is None or str(raw_rd).strip() == "":
  112. raw_rd = request.args.get("days")
  113. if raw_rd is not None and str(raw_rd).strip() != "":
  114. try:
  115. recent_days = _clamp_int(raw_rd, 1, 3660)
  116. except (ValueError, TypeError):
  117. return None, None, "recent_days / days 必须为整数"
  118. else:
  119. recent_days = cfg_rd
  120. return page_size, recent_days, None
  121. def _check_api_key():
  122. sent = request.headers.get(HEADER_API_KEY, "")
  123. expected = configured_api_key()
  124. if not expected:
  125. return False
  126. try:
  127. return hmac.compare_digest(sent.encode("utf-8"), expected.encode("utf-8"))
  128. except Exception:
  129. return False
  130. def _serialize_value(value):
  131. if value is None:
  132. return None
  133. if isinstance(value, datetime):
  134. return value.strftime("%Y-%m-%d %H:%M:%S")
  135. if isinstance(value, date):
  136. return value.strftime("%Y-%m-%d")
  137. if isinstance(value, Decimal):
  138. return str(value)
  139. return value
  140. def _row_to_dict(row):
  141. return {k: _serialize_value(v) for k, v in row.items()}
  142. def _db_connect():
  143. cfg = db_config()
  144. return pymysql.connect(
  145. host=cfg["host"],
  146. port=cfg["port"],
  147. user=cfg["user"],
  148. password=cfg["password"],
  149. database=cfg["database"],
  150. charset="utf8mb4",
  151. cursorclass=DictCursor,
  152. autocommit=True,
  153. )
  154. @app.route("/health", methods=["GET"])
  155. def health():
  156. """存活探测,不做密钥校验,便于负载均衡 / 运维。"""
  157. return jsonify({"status": "ok"})
  158. @app.route("/api/v1/student-changes", methods=["GET"])
  159. def list_student_changes():
  160. if not _check_api_key():
  161. return jsonify({"code": 401, "msg": "无效的密钥或未提供请求头 " + HEADER_API_KEY}), 401
  162. try:
  163. page = int(request.args.get("page", "1"))
  164. except ValueError:
  165. return jsonify({"code": 400, "msg": "page 必须为整数"}), 400
  166. if page < 1:
  167. page = 1
  168. page_size, recent_days, param_err = _resolve_query_paging()
  169. if param_err:
  170. return jsonify({"code": 400, "msg": param_err}), 400
  171. offset = (page - 1) * page_size
  172. # 近 recent_days 天:按视图中的「提交时间」筛选(登记/提交日期)
  173. where_recent = (
  174. "WHERE `{col}` >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)".format(
  175. col=COL_SUBMIT_DATE, days=recent_days
  176. )
  177. )
  178. sql_count = "SELECT COUNT(*) AS cnt FROM `{view}` {where_clause}".format(
  179. view=VIEW_NAME, where_clause=where_recent
  180. )
  181. sql_list = (
  182. "SELECT * FROM `{view}` {where_clause} "
  183. "ORDER BY `来源记录id` ASC LIMIT %s OFFSET %s"
  184. ).format(view=VIEW_NAME, where_clause=where_recent)
  185. conn = None
  186. try:
  187. conn = _db_connect()
  188. with conn.cursor() as cur:
  189. cur.execute(sql_count)
  190. total = int(cur.fetchone()["cnt"])
  191. cur.execute(sql_list, (page_size, offset))
  192. rows = cur.fetchall()
  193. except pymysql.Error as e:
  194. return jsonify({"code": 500, "msg": "数据库查询失败", "detail": str(e)}), 500
  195. finally:
  196. if conn is not None:
  197. conn.close()
  198. data = [_row_to_dict(r) for r in rows]
  199. return jsonify(
  200. {
  201. "code": 200,
  202. "msg": "success",
  203. "data": data,
  204. "page": page,
  205. "page_size": page_size,
  206. "total": total,
  207. "filter_recent_days": recent_days,
  208. "filter_by_column": COL_SUBMIT_DATE,
  209. }
  210. )
  211. if __name__ == "__main__":
  212. _load_config()
  213. bind = server_bind()
  214. app.run(host=bind["host"], port=bind["port"], threaded=True)