From 52d892337e94fd92610b02adb755ed8084c30ef2 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 7 May 2026 16:23:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_service/README.md | 167 +++++++++++ 跟教务系统同步学生异动信息/api_service/app.py | 270 ++++++++++++++++++ .../api_service/config.dev.ini | 22 ++ .../api_service/config.ini.example | 23 ++ .../api_service/config.prod.ini | 23 ++ .../api_service/requirements.txt | 3 + 6 files changed, 508 insertions(+) create mode 100644 跟教务系统同步学生异动信息/api_service/README.md create mode 100644 跟教务系统同步学生异动信息/api_service/app.py create mode 100644 跟教务系统同步学生异动信息/api_service/config.dev.ini create mode 100644 跟教务系统同步学生异动信息/api_service/config.ini.example create mode 100644 跟教务系统同步学生异动信息/api_service/config.prod.ini create mode 100644 跟教务系统同步学生异动信息/api_service/requirements.txt diff --git a/跟教务系统同步学生异动信息/api_service/README.md b/跟教务系统同步学生异动信息/api_service/README.md new file mode 100644 index 00000000..b0043643 --- /dev/null +++ b/跟教务系统同步学生异动信息/api_service/README.md @@ -0,0 +1,167 @@ +# 学生异动视图只读 API + +从 MySQL 视图 `vw_jw_student_change_export` 拉取数据,供教务或其它系统调用。 + +- **Python**:开发与生产建议 **3.7.9**(与现网一致)。 +- **依赖**:见 `requirements.txt`(Flask 2.0.x + PyMySQL)。 + +## 配置文件与环境对应关系 + +| 本目录文件 | 后端配置来源 | Spring 环境 | +|------------|----------------|-------------| +| **`config.dev.ini`** | `backend/ruoyi-admin/src/main/resources/application-dev.yml`(`spring.datasource.dynamic.datasource.master`) | dev(测试/开发) | +| **`config.prod.ini`** | `backend/ruoyi-admin/src/main/resources/application-prod.yml`(同上) | prod(生产) | + +主机、端口、库名、用户名、密码已与上述 YAML 中 **master** 数据源保持一致;后端配置变更时请同步修改对应 INI。 + +## 安装 + +### 开发机(推荐虚拟环境) + +```bash +cd 跟教务系统同步学生异动信息/api_service +python3.7 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +``` + +### Linux 生产环境:`pip3:未找到命令` / `No module named pymysql` + +很多最小化系统只有 `python3`,没有独立的 **`pip3`** 命令。请用 **当前解释器自带的 pip 模块** 安装依赖,例如: + +```bash +cd /path/to/api_service + +# 1)优先尝试(与 python3 一一对应) +python3 -m pip install -r requirements.txt + +# 若 `python3 -m pip` 报 No module named pip,先装系统级 pip(需 root): +# CentOS 7 / 部分 RHEL: +# yum install -y python3-pip +# CentOS 8+ / RHEL 8+ / Fedora: +# dnf install -y python3-pip +# 装完再执行: +python3 -m pip install -r requirements.txt + +# 若无 yum/dnf 或仓库无 python3-pip,可用官方引导脚本(任选其一 Python 版本需为 3.7+): +# curl -sS https://bootstrap.pypa.io/pip/3.7/get-pip.py -o get-pip.py +# python3 get-pip.py +# python3 -m pip install -r requirements.txt +# (3.7 专用 get-pip 见:https://bootstrap.pypa.io/pip/3.7/get-pip.py ) +``` + +确认: + +```bash +python3 -c "import pymysql, flask; print('ok')" +``` + +启动时请用 **`python3 app.py`**(或与安装依赖时相同的 `python3`),避免混用 `python` 指向旧版 2.x。 + +## 选用配置并启动 + +**测试/开发**(与 dev 库一致): + +```bash +cp config.dev.ini config.ini +python app.py +``` + +或直接: + +```bash +export JW_CX_CONFIG="$(pwd)/config.dev.ini" +python app.py +``` + +**生产**: + +```bash +export JW_CX_CONFIG=/opt/jw-cx/config.prod.ini +python app.py +``` + +### 配置文件说明 + +| 环境变量 | 说明 | +|----------|------| +| `JW_CX_CONFIG` | 配置文件路径;不设则使用当前工作目录下的 `config.ini`(相对路径相对于**进程启动时的工作目录**) | + +模板见 **`config.ini.example`**。**`config.ini`** 已在 `.gitignore` 中忽略,避免把本地覆盖提交上去。 + +### INI 节说明 + +**`[database]`**:`host`、`port`、`user`、`password`、`database` +**`[api]`**: + +| 键 | 说明 | +|----|------| +| `api_key` | 与请求头 `X-Jw-Cx-Key` 一致 | +| `bind_host` / `bind_port` | HTTP 监听(仅直接运行 `python app.py` 时生效) | +| `page_size` | **默认每页条数**;请求可带 `page_size` Query 覆盖,程序内限制 1~5000 | +| `recent_days` | **默认近 N 天**;请求可带 `recent_days` / `days` 覆盖,程序内限制 1~3660 | + +## 密钥校验 + +所有 **`/api/v1/student-changes`** 请求必须在 HTTP Header 中携带: + +```http +X-Jw-Cx-Key: <与配置文件中 [api] api_key 相同,默认 JWCX001> +``` + +**`/health`** 不做密钥校验。 + +## 生产进程(gunicorn) + +```bash +pip install 'gunicorn>=20.1.0,<21' +export JW_CX_CONFIG=/path/to/config.prod.ini +gunicorn -w 2 -b 0.0.0.0:5050 app:app +``` + +监听地址/端口以 gunicorn 的 `-b` 为准;`bind_port` 仅对直接运行 `python app.py` 有效。 + +## 接口说明 + +### GET `/health` + +返回 `{"status":"ok"}`。 + +### GET `/api/v1/student-changes` + +**数据范围**:仅 **`提交时间` 在最近 N 个自然日内**的记录;N 由 **`recent_days`**(或 `days`)指定,**未传则使用配置** `[api] recent_days`(默认 30)。 +**分页**:每页条数 **`page_size`** 可传 Query 覆盖,**未传则使用** `[api] page_size`(默认 500)。 + +| Query | 说明 | 未传时的默认值 | +|--------|------|------------------| +| `page` | 页码,从 1 开始 | `1` | +| `page_size` | 每页条数,有效范围 1~5000 | `[api] page_size` | +| `recent_days` | 查询近 N 天(与视图列「提交时间」比较),有效范围 1~3660 | `[api] recent_days` | +| `days` | 与 `recent_days` 同义,二选一即可 | (同上) | + +成功响应中除 `data`、`page`、`page_size`、`total` 外,还包括: + +| JSON 字段 | 说明 | +|-----------|------| +| `filter_recent_days` | **本次请求实际生效**的近 N 天(含 URL 覆盖后的值) | +| `filter_by_column` | 固定为 `提交时间`(筛选字段名) | + +**未授权**(HTTP 401):未带或错误的 `X-Jw-Cx-Key`。 + +## 调用示例 + +```bash +# 使用配置中的 page_size、recent_days +curl -s -H "X-Jw-Cx-Key: JWCX001" \ + "http://127.0.0.1:5050/api/v1/student-changes?page=1" + +# 指定每页 50 条、近 7 天 +curl -s -H "X-Jw-Cx-Key: JWCX001" \ + "http://127.0.0.1:5050/api/v1/student-changes?page=1&page_size=50&recent_days=7" +``` + +## 安全说明 + +1. `config.dev.ini` / `config.prod.ini` 含数据库凭据,与后端 YAML 同源;请按公司规范控制仓库可见性与部署权限。 +2. 建议数据库侧使用**仅对视图** `vw_jw_student_change_export` 有 `SELECT` 的专用账号,与后端 `root` 分离。 +3. 生产将 **`api_key`** 换为强随机串,并走 HTTPS;必要时加网络白名单。 diff --git a/跟教务系统同步学生异动信息/api_service/app.py b/跟教务系统同步学生异动信息/api_service/app.py new file mode 100644 index 00000000..77f7e1e5 --- /dev/null +++ b/跟教务系统同步学生异动信息/api_service/app.py @@ -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) diff --git a/跟教务系统同步学生异动信息/api_service/config.dev.ini b/跟教务系统同步学生异动信息/api_service/config.dev.ini new file mode 100644 index 00000000..8531b49b --- /dev/null +++ b/跟教务系统同步学生异动信息/api_service/config.dev.ini @@ -0,0 +1,22 @@ +# 测试 / 开发环境(与后端 spring.profiles.active=dev 对应) +# 数据源来源:backend/ruoyi-admin/src/main/resources/application-dev.yml +# spring.datasource.dynamic.datasource.master.url / username / password +# JDBC: jdbc:mysql://47.99.164.66:3306/hzdyjs_xg?... +# +# 使用方式:复制为 config.ini,或 JW_CX_CONFIG=.../config.dev.ini python app.py +# +# 说明:与后端一致使用 root;若仅开放视图权限,可改为仅 SELECT 视图的用户(如 jw_cx)。 + +[database] +host = 47.99.164.66 +port = 3306 +user = root +password = hn02le.34lkdLKD +database = hzdyjs_xg + +[api] +api_key = JWCX001 +bind_host = 0.0.0.0 +bind_port = 5050 +page_size = 500 +recent_days = 30 diff --git a/跟教务系统同步学生异动信息/api_service/config.ini.example b/跟教务系统同步学生异动信息/api_service/config.ini.example new file mode 100644 index 00000000..196f317e --- /dev/null +++ b/跟教务系统同步学生异动信息/api_service/config.ini.example @@ -0,0 +1,23 @@ +# 模板:请复制其一并重命名为 config.ini(或在启动时通过 JW_CX_CONFIG 指定) +# +# 已与本项目后端数据源对齐的现成文件(可直接复制使用): +# config.dev.ini ← 对应 application-dev.yml(测试/开发) +# config.prod.ini ← 对应 application-prod.yml(生产) +# +# cp config.dev.ini config.ini + +[database] +host = 127.0.0.1 +port = 3306 +user = root +password = +database = hzdyjs_xg + +[api] +api_key = JWCX001 +bind_host = 0.0.0.0 +bind_port = 5050 +# 列表接口:默认每页条数;请求可带 page_size 覆盖,范围 1~5000 +page_size = 500 +# 列表接口:默认近 N 天;请求可带 recent_days 或 days 覆盖,范围 1~3660 +recent_days = 30 diff --git a/跟教务系统同步学生异动信息/api_service/config.prod.ini b/跟教务系统同步学生异动信息/api_service/config.prod.ini new file mode 100644 index 00000000..4f483433 --- /dev/null +++ b/跟教务系统同步学生异动信息/api_service/config.prod.ini @@ -0,0 +1,23 @@ +# 生产环境(与后端 spring.profiles.active=prod 对应) +# 数据源来源:backend/ruoyi-admin/src/main/resources/application-prod.yml +# spring.datasource.dynamic.datasource.master.url / username / password +# JDBC: jdbc:mysql://172.16.0.67:3306/hzdyjs_xg?... +# +# 使用方式:部署时复制为 config.ini,或 +# export JW_CX_CONFIG=/path/to/config.prod.ini +# +# 说明:与后端主库一致;建议生产单独使用仅 vw_jw_student_change_export 只读账号。 + +[database] +host = 172.16.0.67 +port = 3306 +user = root +password = Hzdyjs@Root@123456 +database = hzdyjs_xg + +[api] +api_key = JWCX001 +bind_host = 0.0.0.0 +bind_port = 5050 +page_size = 500 +recent_days = 30 diff --git a/跟教务系统同步学生异动信息/api_service/requirements.txt b/跟教务系统同步学生异动信息/api_service/requirements.txt new file mode 100644 index 00000000..05f70d6d --- /dev/null +++ b/跟教务系统同步学生异动信息/api_service/requirements.txt @@ -0,0 +1,3 @@ +# 生产环境 Python 3.7.9 兼容 +Flask>=2.0.2,<2.3 +PyMySQL>=1.0.2,<2.0