提交
This commit is contained in:
1 parent
a88d87047d
commit
c0de1b9bef
19 files changed
+17083
-202
No files matched your search
@@ -4,6 +4,7 @@
|
||||
|
||||
- **Python**:开发与生产建议 **3.7.9**(与现网一致)。
|
||||
- **依赖**:见 `requirements.txt`(Flask 2.0.x + PyMySQL)。
|
||||
- **异动类型映射**:见上级目录 [`异动类型映射说明.md`](../异动类型映射说明.md)
|
||||
|
||||
## 配置文件与环境对应关系
|
||||
|
||||
@@ -25,55 +26,23 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Linux 生产环境:`pip3:未找到命令` / `No module named pymysql`
|
||||
|
||||
很多最小化系统只有 `python3`,没有独立的 **`pip3`** 命令。请用 **当前解释器自带的 pip 模块** 安装依赖,例如:
|
||||
### Linux 生产环境
|
||||
|
||||
```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
|
||||
@@ -81,14 +50,6 @@ 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`
|
||||
@@ -98,8 +59,7 @@ python app.py
|
||||
|----|------|
|
||||
| `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 |
|
||||
| `default_year` | **默认筛选年份**(按视图列「异动生效时间」);未配置则使用当前自然年;请求可带 `year` 覆盖 |
|
||||
|
||||
## 密钥校验
|
||||
|
||||
@@ -119,8 +79,6 @@ 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`
|
||||
@@ -129,39 +87,58 @@ gunicorn -w 2 -b 0.0.0.0:5050 app:app
|
||||
|
||||
### GET `/api/v1/student-changes`
|
||||
|
||||
**数据范围**:仅 **`提交时间` 在最近 N 个自然日内**的记录;N 由 **`recent_days`**(或 `days`)指定,**未传则使用配置** `[api] recent_days`(默认 30)。
|
||||
**分页**:每页条数 **`page_size`** 可传 Query 覆盖,**未传则使用** `[api] page_size`(默认 500)。
|
||||
**数据范围**:**`异动生效时间` 落在指定自然年**内的已办结记录,**一次返回全部**(不分页);年份由 **`year`** 指定,**未传则使用当前自然年**(或配置 `default_year`)。
|
||||
|
||||
| Query | 说明 | 未传时的默认值 |
|
||||
|--------|------|------------------|
|
||||
| `page` | 页码,从 1 开始 | `1` |
|
||||
| `page_size` | 每页条数,有效范围 1~5000 | `[api] page_size` |
|
||||
| `recent_days` | 查询近 N 天(与视图列「提交时间」比较),有效范围 1~3660 | `[api] recent_days` |
|
||||
| `days` | 与 `recent_days` 同义,二选一即可 | (同上) |
|
||||
| `year` | 按「异动生效时间」所在自然年筛选,有效范围 2000~2100 | 当前自然年 |
|
||||
|
||||
成功响应中除 `data`、`page`、`page_size`、`total` 外,还包括:
|
||||
**响应 `data` 每条记录字段**(与教务对接清单一致):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 提交时间 | `YYYY-MM-DD` |
|
||||
| 异动生效学期 | 本系统无数据,为 `null` |
|
||||
| 异动类型 | **教务中文名称**(退学、休学、复学、转班级) |
|
||||
| 申请人学号 / 姓名 | |
|
||||
| 异动前/后 年级、院系、专业、班级、学制 | 学籍异动「前」优先取处理前快照 |
|
||||
| 异动前/后 所在校区 | 本系统无数据,为 `null` |
|
||||
| 异动后班主任工号 / 姓名 | |
|
||||
| 新名字 | 本系统无改名流水,为 `null` |
|
||||
| 审核状态 | 固定「已通过」 |
|
||||
|
||||
成功响应元数据:
|
||||
|
||||
| JSON 字段 | 说明 |
|
||||
|-----------|------|
|
||||
| `filter_recent_days` | **本次请求实际生效**的近 N 天(含 URL 覆盖后的值) |
|
||||
| `filter_by_column` | 固定为 `提交时间`(筛选字段名) |
|
||||
| `total` | 本次返回的记录总数(等于 `data` 数组长度) |
|
||||
| `filter_year` | 本次请求实际生效的筛选年份 |
|
||||
| `filter_by_column` | 固定为 `异动生效时间` |
|
||||
|
||||
**未授权**(HTTP 401):未带或错误的 `X-Jw-Cx-Key`。
|
||||
|
||||
## 调用示例
|
||||
|
||||
```bash
|
||||
# 使用配置中的 page_size、recent_days
|
||||
# 拉取当前自然年(如 2026)全部异动
|
||||
curl -s -H "X-Jw-Cx-Key: JWCX001" \
|
||||
"http://127.0.0.1:5050/api/v1/student-changes?page=1"
|
||||
"http://127.0.0.1:5050/api/v1/student-changes"
|
||||
|
||||
# 指定每页 50 条、近 7 天
|
||||
# 指定 2026 年
|
||||
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"
|
||||
"http://127.0.0.1:5050/api/v1/student-changes?year=2026"
|
||||
```
|
||||
|
||||
## 部署前:重建视图
|
||||
|
||||
代码更新后,请在目标库执行:
|
||||
|
||||
```bash
|
||||
mysql -h <host> -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_change_export.sql
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
1. `config.dev.ini` / `config.prod.ini` 含数据库凭据,与后端 YAML 同源;请按公司规范控制仓库可见性与部署权限。
|
||||
1. `config.dev.ini` / `config.prod.ini` 含数据库凭据,请按公司规范控制仓库可见性与部署权限。
|
||||
2. 建议数据库侧使用**仅对视图** `vw_jw_student_change_export` 有 `SELECT` 的专用账号,与后端 `root` 分离。
|
||||
3. 生产将 **`api_key`** 换为强随机串,并走 HTTPS;必要时加网络白名单。
|
||||
@@ -22,8 +22,33 @@ HEADER_API_KEY = "X-Jw-Cx-Key"
|
||||
|
||||
VIEW_NAME = "vw_jw_student_change_export"
|
||||
|
||||
# 视图列名(与 vw_jw_student_change_export 一致;筛选近 N 天时使用)
|
||||
COL_SUBMIT_DATE = "提交时间"
|
||||
# 视图列名:API 按「异动生效时间」所在自然年筛选
|
||||
COL_EFFECTIVE_TIME = "异动生效时间"
|
||||
|
||||
# 对外返回字段(与教务对接清单一致;不含视图内部列)
|
||||
EXPORT_COLUMNS = (
|
||||
"提交时间",
|
||||
"异动生效学期",
|
||||
"异动类型",
|
||||
"申请人学号",
|
||||
"姓名",
|
||||
"异动前所在年级",
|
||||
"异动前所在校区",
|
||||
"异动前所在院系",
|
||||
"异动前所在专业",
|
||||
"异动前所在班级",
|
||||
"异动前学制",
|
||||
"异动后所在校区",
|
||||
"异动后所在年级",
|
||||
"异动后所在班级",
|
||||
"异动后所在院系",
|
||||
"异动后所在专业",
|
||||
"异动后学制",
|
||||
"异动后班主任工号",
|
||||
"异动后班主任姓名",
|
||||
"新名字",
|
||||
"审核状态",
|
||||
)
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_DEFAULT_CONFIG_PATH = os.path.join(_BASE_DIR, "config.ini")
|
||||
@@ -99,60 +124,37 @@ def server_bind():
|
||||
}
|
||||
|
||||
|
||||
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 _default_filter_year():
|
||||
"""配置文件 default_year;未配置或无效时使用当前自然年。"""
|
||||
raw = _get("api", "default_year", "")
|
||||
if raw:
|
||||
try:
|
||||
year = int(raw)
|
||||
if 2000 <= year <= 2100:
|
||||
return year
|
||||
except ValueError:
|
||||
pass
|
||||
return date.today().year
|
||||
|
||||
|
||||
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():
|
||||
def _resolve_filter_year():
|
||||
"""
|
||||
合并 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
|
||||
- year:Query `year`,未传则用 [api] default_year 或当前自然年
|
||||
"""
|
||||
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() != "":
|
||||
default_year = _default_filter_year()
|
||||
raw_year = request.args.get("year")
|
||||
if raw_year is not None and str(raw_year).strip() != "":
|
||||
try:
|
||||
page_size = _clamp_int(raw_ps, 1, 5000)
|
||||
year = int(raw_year)
|
||||
except (ValueError, TypeError):
|
||||
return None, None, "page_size 必须为整数"
|
||||
return None, "year 必须为整数"
|
||||
if year < 2000 or year > 2100:
|
||||
return None, "year 有效范围为 2000~2100"
|
||||
else:
|
||||
page_size = cfg_ps
|
||||
year = default_year
|
||||
|
||||
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
|
||||
return year, None
|
||||
|
||||
|
||||
def _check_api_key():
|
||||
@@ -178,8 +180,8 @@ def _serialize_value(value):
|
||||
return value
|
||||
|
||||
|
||||
def _row_to_dict(row):
|
||||
return {k: _serialize_value(v) for k, v in row.items()}
|
||||
def _row_to_export_dict(row):
|
||||
return {col: _serialize_value(row.get(col)) for col in EXPORT_COLUMNS}
|
||||
|
||||
|
||||
def _db_connect():
|
||||
@@ -207,41 +209,26 @@ 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()
|
||||
filter_year, param_err = _resolve_filter_year()
|
||||
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
|
||||
select_cols = ", ".join("`%s`" % col for col in EXPORT_COLUMNS)
|
||||
where_year = (
|
||||
"WHERE `{col}` IS NOT NULL AND YEAR(`{col}`) = %s".format(
|
||||
col=COL_EFFECTIVE_TIME
|
||||
)
|
||||
)
|
||||
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)
|
||||
"SELECT {cols} FROM `{view}` {where_clause} "
|
||||
"ORDER BY `来源记录id` ASC"
|
||||
).format(cols=select_cols, view=VIEW_NAME, where_clause=where_year)
|
||||
|
||||
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))
|
||||
cur.execute(sql_list, (filter_year,))
|
||||
rows = cur.fetchall()
|
||||
except pymysql.Error as e:
|
||||
return jsonify({"code": 500, "msg": "数据库查询失败", "detail": str(e)}), 500
|
||||
@@ -249,17 +236,15 @@ def list_student_changes():
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
data = [_row_to_dict(r) for r in rows]
|
||||
data = [_row_to_export_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,
|
||||
"total": len(data),
|
||||
"filter_year": filter_year,
|
||||
"filter_by_column": COL_EFFECTIVE_TIME,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -18,5 +18,4 @@ database = hzdyjs_xg
|
||||
api_key = JWCX001
|
||||
bind_host = 0.0.0.0
|
||||
bind_port = 5050
|
||||
page_size = 500
|
||||
recent_days = 30
|
||||
# default_year =
|
||||
@@ -17,7 +17,5 @@ database = hzdyjs_xg
|
||||
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
|
||||
# 列表接口:默认筛选年份(异动生效时间);请求可带 year 覆盖;留空则使用当前自然年
|
||||
# default_year =
|
||||
@@ -19,5 +19,4 @@ database = hzdyjs_xg
|
||||
api_key = JWCX001
|
||||
bind_host = 0.0.0.0
|
||||
bind_port = 5050
|
||||
page_size = 500
|
||||
recent_days = 30
|
||||
# default_year =
|
||||
@@ -0,0 +1,38 @@
|
||||
# 学工系统 → 教务系统 异动类型映射
|
||||
|
||||
依据教务《异动类型管理(附件1)》与本系统实际业务数据整理。
|
||||
|
||||
## 本系统产生的异动类型
|
||||
|
||||
| 本库来源 | 本库内部编码 / 标识 | 本库业务含义 | 教务系统异动类型名称 | 说明 |
|
||||
|----------|---------------------|--------------|----------------------|------|
|
||||
| `xg_student_status_change.change_type` | `TUI_XUE` | 退学 | **退学** | 名称一致 |
|
||||
| `xg_student_status_change.change_type` | `XIU_XUE` | 休学 | **休学** | 名称一致 |
|
||||
| `xg_student_status_change.change_type` | `FU_XUE` | 复学 | **复学** | 名称一致 |
|
||||
| `xg_student_class_change`(已处理) | — | 换班级 | **转班级** | 非 `change_type` 字段,视图层固定输出 |
|
||||
|
||||
## 本系统有、但不同步给教务的类型
|
||||
|
||||
| 本库来源 | 内部编码 | 本库业务含义 | 说明 |
|
||||
|----------|----------|--------------|------|
|
||||
| `xg_student_status_change.change_type` | `BI_YE` | 毕业 | **不传给教务**(按对接约定排除) |
|
||||
|
||||
## 教务码表有、本系统暂不产生的类型
|
||||
|
||||
以下类型在教务附件中存在,**本学工系统当前无对应业务流水**,接口不会返回:
|
||||
|
||||
实习、转录失败+实习、肄业、提前毕业、死亡、开除学籍、转系、转专业、转学(转出)、保留学籍、取消学籍、恢复学籍、取消入学资格、恢复入学资格、保留入学资格、停学、跳级、降级、留级、改名
|
||||
|
||||
## 接口约定
|
||||
|
||||
- 对外字段名:**异动类型**
|
||||
- 字段值:上表「教务系统异动类型名称」列的中文名称(非本库内部编码)
|
||||
- **异动生效学期**、**异动前/后所在校区**:本系统无数据,固定返回 `null`
|
||||
- **新名字**:本系统无改名流水,固定返回 `null`
|
||||
- **审核状态**:固定返回「已通过」
|
||||
|
||||
## 视图与 API
|
||||
|
||||
- 视图 DDL:`docs/设计文档/database/vw_jw_student_change_export.sql`
|
||||
- 只读 API:`跟教务系统同步学生异动信息/api_service/app.py`
|
||||
- 数据范围:按 **`异动生效时间` 所在自然年** 筛选(Query `year`,默认当前年);**一次返回该年全部记录,不分页**
|
||||
Reference in new issue
Block a user