提交
This commit is contained in:
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
|
||||
Reference in new issue
Block a user