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,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;必要时加网络白名单。
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
# 生产环境 Python 3.7.9 兼容
Flask>=2.0.2,<2.3
PyMySQL>=1.0.2,<2.0