提交
This commit is contained in:
1 parent
10f90d3824
commit
dd6212400c
7 files changed
+365
-56
No files matched your search
@@ -8,6 +8,7 @@
|
||||
-- * 「异动生效时间」:学籍异动 = effective_date;换班级 = effective_time(API 按年筛选)
|
||||
-- * 「异动类型」:输出教务系统中文名称(见 §异动类型映射)
|
||||
-- * 仅导出已处理:学籍 process_status = 1;换班级 status = '1'
|
||||
-- * 去重:同一展示学号 + 登记日 + 异动类型 仅保留 id 最小的一条(学号取 COALESCE(sc,m))
|
||||
-- * 学籍异动「异动前」:优先 xg_student_msg_his(处理脚本落库快照),否则回退 xg_student_msg
|
||||
-- * 学籍异动「异动后」:取自当前 xg_student_msg(纯学籍异动组织维度通常不变)
|
||||
-- * 末尾「数据来源」「来源记录id」供联调,API 层不对外返回
|
||||
@@ -25,6 +26,32 @@
|
||||
DROP VIEW IF EXISTS `vw_jw_student_change_export`;
|
||||
|
||||
CREATE VIEW `vw_jw_student_change_export` AS
|
||||
SELECT
|
||||
ANY_VALUE(`提交时间`) AS `提交时间`,
|
||||
ANY_VALUE(`异动生效学期`) AS `异动生效学期`,
|
||||
ANY_VALUE(`异动生效时间`) AS `异动生效时间`,
|
||||
ANY_VALUE(`异动类型`) AS `异动类型`,
|
||||
ANY_VALUE(`申请人学号`) AS `申请人学号`,
|
||||
ANY_VALUE(`姓名`) AS `姓名`,
|
||||
ANY_VALUE(`异动前所在年级`) AS `异动前所在年级`,
|
||||
ANY_VALUE(`异动前所在校区`) AS `异动前所在校区`,
|
||||
ANY_VALUE(`异动前所在院系`) AS `异动前所在院系`,
|
||||
ANY_VALUE(`异动前所在专业`) AS `异动前所在专业`,
|
||||
ANY_VALUE(`异动前所在班级`) AS `异动前所在班级`,
|
||||
ANY_VALUE(`异动前学制`) AS `异动前学制`,
|
||||
ANY_VALUE(`异动后所在校区`) AS `异动后所在校区`,
|
||||
ANY_VALUE(`异动后所在年级`) AS `异动后所在年级`,
|
||||
ANY_VALUE(`异动后所在班级`) AS `异动后所在班级`,
|
||||
ANY_VALUE(`异动后所在院系`) AS `异动后所在院系`,
|
||||
ANY_VALUE(`异动后所在专业`) AS `异动后所在专业`,
|
||||
ANY_VALUE(`异动后学制`) AS `异动后学制`,
|
||||
ANY_VALUE(`异动后班主任工号`) AS `异动后班主任工号`,
|
||||
ANY_VALUE(`异动后班主任姓名`) AS `异动后班主任姓名`,
|
||||
ANY_VALUE(`新名字`) AS `新名字`,
|
||||
ANY_VALUE(`审核状态`) AS `审核状态`,
|
||||
ANY_VALUE(`数据来源`) AS `数据来源`,
|
||||
`来源记录id`
|
||||
FROM (
|
||||
SELECT
|
||||
DATE(COALESCE(sc.apply_date, sc.create_time)) AS `提交时间`,
|
||||
(CAST(NULL AS CHAR(32) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动生效学期`,
|
||||
@@ -33,9 +60,9 @@ SELECT
|
||||
WHEN 'TUI_XUE' THEN '退学'
|
||||
WHEN 'XIU_XUE' THEN '休学'
|
||||
WHEN 'FU_XUE' THEN '复学'
|
||||
ELSE COALESCE(sc.change_type, '')
|
||||
ELSE COALESCE(sc.change_type COLLATE utf8mb4_unicode_ci, '' COLLATE utf8mb4_unicode_ci)
|
||||
END COLLATE utf8mb4_unicode_ci) AS `异动类型`,
|
||||
(COALESCE(sc.student_no, m.student_no) COLLATE utf8mb4_unicode_ci) AS `申请人学号`,
|
||||
(COALESCE(sc.student_no COLLATE utf8mb4_unicode_ci, m.student_no COLLATE utf8mb4_unicode_ci) COLLATE utf8mb4_unicode_ci) AS `申请人学号`,
|
||||
(COALESCE(mh.`name`, m.`name`) COLLATE utf8mb4_unicode_ci) AS `姓名`,
|
||||
(COALESCE(mh.grade, m.grade, cm_before.grade) COLLATE utf8mb4_unicode_ci) AS `异动前所在年级`,
|
||||
(CAST(NULL AS CHAR(64) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动前所在校区`,
|
||||
@@ -49,13 +76,38 @@ SELECT
|
||||
(COALESCE(m.college_name, cm_after.college_name, mh.college_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在院系`,
|
||||
(COALESCE(m.major_name, cm_after.major_name, mh.major_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在专业`,
|
||||
COALESCE(cm_after.school_system, m.school_system, cm_before.school_system, mh.school_system) AS `异动后学制`,
|
||||
(COALESCE(NULLIF(TRIM(su.user_no), ''), su.user_name) COLLATE utf8mb4_unicode_ci) AS `异动后班主任工号`,
|
||||
(COALESCE(
|
||||
NULLIF(TRIM(su.user_no COLLATE utf8mb4_unicode_ci), '' COLLATE utf8mb4_unicode_ci),
|
||||
su.user_name COLLATE utf8mb4_unicode_ci
|
||||
) COLLATE utf8mb4_unicode_ci) AS `异动后班主任工号`,
|
||||
(COALESCE(m.class_teacher_name, cm_after.class_teacher_name, mh.class_teacher_name) COLLATE utf8mb4_unicode_ci) AS `异动后班主任姓名`,
|
||||
(CAST(NULL AS CHAR(50) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `新名字`,
|
||||
('已通过' COLLATE utf8mb4_unicode_ci) AS `审核状态`,
|
||||
('学籍异动' COLLATE utf8mb4_unicode_ci) AS `数据来源`,
|
||||
sc.id AS `来源记录id`
|
||||
FROM xg_student_status_change sc
|
||||
INNER JOIN (
|
||||
SELECT MIN(sc2.id) AS id
|
||||
FROM xg_student_status_change sc2
|
||||
LEFT JOIN xg_student_msg m2
|
||||
ON m2.id = sc2.student_id
|
||||
AND m2.deleted = 0
|
||||
AND (m2.tenant_id COLLATE utf8mb4_unicode_ci) = (sc2.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
WHERE sc2.deleted = 0
|
||||
AND sc2.process_status = 1
|
||||
AND sc2.effective_date IS NOT NULL
|
||||
AND (sc2.change_type COLLATE utf8mb4_unicode_ci) <> ('BI_YE' COLLATE utf8mb4_unicode_ci)
|
||||
GROUP BY
|
||||
COALESCE(
|
||||
NULLIF(
|
||||
TRIM(COALESCE(sc2.student_no COLLATE utf8mb4_unicode_ci, m2.student_no COLLATE utf8mb4_unicode_ci)),
|
||||
'' COLLATE utf8mb4_unicode_ci
|
||||
),
|
||||
(CONCAT('__sid__', sc2.student_id) COLLATE utf8mb4_unicode_ci)
|
||||
),
|
||||
(sc2.change_type COLLATE utf8mb4_unicode_ci),
|
||||
DATE(COALESCE(sc2.apply_date, sc2.create_time))
|
||||
) sc_dedup ON sc.id = sc_dedup.id
|
||||
LEFT JOIN xg_student_msg m
|
||||
ON m.id = sc.student_id
|
||||
AND m.deleted = 0
|
||||
@@ -67,11 +119,11 @@ LEFT JOIN xg_student_msg_his mh
|
||||
LEFT JOIN xg_class_msg cm_before
|
||||
ON cm_before.deleted = 0
|
||||
AND (cm_before.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
AND cm_before.id = CAST(NULLIF(TRIM(COALESCE(mh.class_id, m.class_id)), '') AS UNSIGNED)
|
||||
AND cm_before.id = CAST(NULLIF(TRIM(COALESCE(mh.class_id, m.class_id) COLLATE utf8mb4_unicode_ci), '' COLLATE utf8mb4_unicode_ci) AS UNSIGNED)
|
||||
LEFT JOIN xg_class_msg cm_after
|
||||
ON cm_after.deleted = 0
|
||||
AND (cm_after.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
AND cm_after.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
|
||||
AND cm_after.id = CAST(NULLIF(TRIM(m.class_id COLLATE utf8mb4_unicode_ci), '' COLLATE utf8mb4_unicode_ci) AS UNSIGNED)
|
||||
LEFT JOIN sys_user su
|
||||
ON su.user_id = COALESCE(m.class_teacher_id, cm_after.class_teacher_id)
|
||||
AND (su.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
|
||||
@@ -79,9 +131,37 @@ LEFT JOIN sys_user su
|
||||
WHERE sc.deleted = 0
|
||||
AND sc.process_status = 1
|
||||
AND (sc.change_type COLLATE utf8mb4_unicode_ci) <> ('BI_YE' COLLATE utf8mb4_unicode_ci)
|
||||
) sc_export
|
||||
GROUP BY `来源记录id`
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
ANY_VALUE(`提交时间`) AS `提交时间`,
|
||||
ANY_VALUE(`异动生效学期`) AS `异动生效学期`,
|
||||
ANY_VALUE(`异动生效时间`) AS `异动生效时间`,
|
||||
ANY_VALUE(`异动类型`) AS `异动类型`,
|
||||
ANY_VALUE(`申请人学号`) AS `申请人学号`,
|
||||
ANY_VALUE(`姓名`) AS `姓名`,
|
||||
ANY_VALUE(`异动前所在年级`) AS `异动前所在年级`,
|
||||
ANY_VALUE(`异动前所在校区`) AS `异动前所在校区`,
|
||||
ANY_VALUE(`异动前所在院系`) AS `异动前所在院系`,
|
||||
ANY_VALUE(`异动前所在专业`) AS `异动前所在专业`,
|
||||
ANY_VALUE(`异动前所在班级`) AS `异动前所在班级`,
|
||||
ANY_VALUE(`异动前学制`) AS `异动前学制`,
|
||||
ANY_VALUE(`异动后所在校区`) AS `异动后所在校区`,
|
||||
ANY_VALUE(`异动后所在年级`) AS `异动后所在年级`,
|
||||
ANY_VALUE(`异动后所在班级`) AS `异动后所在班级`,
|
||||
ANY_VALUE(`异动后所在院系`) AS `异动后所在院系`,
|
||||
ANY_VALUE(`异动后所在专业`) AS `异动后所在专业`,
|
||||
ANY_VALUE(`异动后学制`) AS `异动后学制`,
|
||||
ANY_VALUE(`异动后班主任工号`) AS `异动后班主任工号`,
|
||||
ANY_VALUE(`异动后班主任姓名`) AS `异动后班主任姓名`,
|
||||
ANY_VALUE(`新名字`) AS `新名字`,
|
||||
ANY_VALUE(`审核状态`) AS `审核状态`,
|
||||
ANY_VALUE(`数据来源`) AS `数据来源`,
|
||||
`来源记录id`
|
||||
FROM (
|
||||
SELECT
|
||||
DATE(COALESCE(c.change_time, c.create_time)) AS `提交时间`,
|
||||
(CAST(NULL AS CHAR(32) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动生效学期`,
|
||||
@@ -101,13 +181,29 @@ SELECT
|
||||
(c.new_college_name COLLATE utf8mb4_unicode_ci) AS `异动后所在院系`,
|
||||
(c.new_major_name COLLATE utf8mb4_unicode_ci) AS `异动后所在专业`,
|
||||
nc.school_system AS `异动后学制`,
|
||||
(COALESCE(NULLIF(TRIM(su2.user_no), ''), su2.user_name) COLLATE utf8mb4_unicode_ci) AS `异动后班主任工号`,
|
||||
(COALESCE(
|
||||
NULLIF(TRIM(su2.user_no COLLATE utf8mb4_unicode_ci), '' COLLATE utf8mb4_unicode_ci),
|
||||
su2.user_name COLLATE utf8mb4_unicode_ci
|
||||
) COLLATE utf8mb4_unicode_ci) AS `异动后班主任工号`,
|
||||
(nc.class_teacher_name COLLATE utf8mb4_unicode_ci) AS `异动后班主任姓名`,
|
||||
(CAST(NULL AS CHAR(50) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `新名字`,
|
||||
('已通过' COLLATE utf8mb4_unicode_ci) AS `审核状态`,
|
||||
('换班级' COLLATE utf8mb4_unicode_ci) AS `数据来源`,
|
||||
c.id AS `来源记录id`
|
||||
FROM xg_student_class_change c
|
||||
INNER JOIN (
|
||||
SELECT MIN(id) AS id
|
||||
FROM xg_student_class_change
|
||||
WHERE deleted = 0
|
||||
AND effective_time IS NOT NULL
|
||||
AND (`status` COLLATE utf8mb4_unicode_ci) = ('1' COLLATE utf8mb4_unicode_ci)
|
||||
GROUP BY
|
||||
COALESCE(
|
||||
NULLIF(TRIM(student_no COLLATE utf8mb4_unicode_ci), '' COLLATE utf8mb4_unicode_ci),
|
||||
(CONCAT('__sid__', student_id) COLLATE utf8mb4_unicode_ci)
|
||||
),
|
||||
DATE(COALESCE(change_time, create_time))
|
||||
) c_dedup ON c.id = c_dedup.id
|
||||
LEFT JOIN xg_class_msg oc
|
||||
ON oc.id = c.original_class_id
|
||||
AND oc.deleted = 0
|
||||
@@ -121,7 +217,9 @@ LEFT JOIN sys_user su2
|
||||
AND (su2.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
|
||||
AND (su2.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
WHERE c.deleted = 0
|
||||
AND (c.`status` COLLATE utf8mb4_unicode_ci) = ('1' COLLATE utf8mb4_unicode_ci);
|
||||
AND (c.`status` COLLATE utf8mb4_unicode_ci) = ('1' COLLATE utf8mb4_unicode_ci)
|
||||
) c_export
|
||||
GROUP BY `来源记录id`;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 授权:用户 jw_cx 仅查询本视图(需使用具备 GRANT 权限的账号执行,如 root)
|
||||
|
||||
@@ -138,5 +138,6 @@
|
||||
| 2026-05-07 | 补充:`vw_jw_student_change_export.sql` 可执行 DDL |
|
||||
| 2026-06-22 | API 改为按「异动生效时间」自然年筛选;视图输出教务中文异动类型;学籍异动「前」优先读 `xg_student_msg_his` |
|
||||
| 2026-06-22 | 毕业(`BI_YE`)记录不同步给教务,视图 WHERE 排除 |
|
||||
| 2026-07-03 | 视图去重:同一学号 + 登记日 + 异动类型仅保留 `MIN(id)` 一条(按学号分组,非 student_id) |
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# 学生异动视图只读 API
|
||||
# 教务同步只读 API(学生异动 + 学生基本信息)
|
||||
|
||||
从 MySQL 视图 `vw_jw_student_change_export` 拉取数据,供教务或其它系统调用。
|
||||
供教务或其它系统调用,**对外接口说明见 [`接口调用说明.md`](./接口调用说明.md)**。
|
||||
|
||||
- **学生异动**:视图 `vw_jw_student_change_export` → `GET /api/v1/student-changes`
|
||||
- **学生基本信息**:视图 `vw_jw_student_export` → `GET /api/v1/students`
|
||||
|
||||
- **Python**:开发与生产建议 **3.7.9**(与现网一致)。
|
||||
- **依赖**:见 `requirements.txt`(Flask 2.0.x + PyMySQL)。
|
||||
- **异动类型映射**:见上级目录 [`异动类型映射说明.md`](../异动类型映射说明.md)
|
||||
- **异动类型映射**:见 [`异动类型映射说明.md`](../异动类型映射说明.md)
|
||||
- **学生基本信息口径**:见 [`学生基本信息对接说明.md`](../../跟教务系统同步学生基础信息/学生基本信息对接说明.md)
|
||||
|
||||
## 配置文件与环境对应关系
|
||||
|
||||
@@ -59,11 +63,12 @@ python app.py
|
||||
|----|------|
|
||||
| `api_key` | 与请求头 `X-Jw-Cx-Key` 一致 |
|
||||
| `bind_host` / `bind_port` | HTTP 监听(仅直接运行 `python app.py` 时生效) |
|
||||
| `default_year` | **默认筛选年份**(按视图列「异动生效时间」);未配置则使用当前自然年;请求可带 `year` 覆盖 |
|
||||
| `default_year` | 异动接口默认筛选年份(`year` 未传时);未配置则当前自然年 |
|
||||
| `page_size` | 学生基本信息接口默认每页条数;请求可带 `page_size` 覆盖,范围 1~5000 |
|
||||
|
||||
## 密钥校验
|
||||
|
||||
所有 **`/api/v1/student-changes`** 请求必须在 HTTP Header 中携带:
|
||||
所有 **`/api/v1/*`** 业务接口必须在 HTTP Header 中携带:
|
||||
|
||||
```http
|
||||
X-Jw-Cx-Key: <与配置文件中 [api] api_key 相同,默认 JWCX001>
|
||||
@@ -117,28 +122,79 @@ gunicorn -w 2 -b 0.0.0.0:5050 app:app
|
||||
|
||||
**未授权**(HTTP 401):未带或错误的 `X-Jw-Cx-Key`。
|
||||
|
||||
### GET `/api/v1/students`
|
||||
|
||||
**数据范围**:全部未逻辑删除的学生(`xg_student_msg.deleted = 0`),**分页返回**。
|
||||
|
||||
| Query | 说明 | 未传时的默认值 |
|
||||
|--------|------|------------------|
|
||||
| `page` | 页码,从 1 开始 | `1` |
|
||||
| `page_size` | 每页条数,有效范围 1~5000 | `[api] page_size`(默认 500) |
|
||||
|
||||
成功响应元数据:
|
||||
|
||||
| JSON 字段 | 说明 |
|
||||
|-----------|------|
|
||||
| `total` | 符合条件的记录总数 |
|
||||
| `page` | 当前页码 |
|
||||
| `page_size` | 本页大小 |
|
||||
| `total_pages` | 总页数(`ceil(total / page_size)`) |
|
||||
|
||||
字段口径见 [`学生基本信息对接说明.md`](../../跟教务系统同步学生基础信息/学生基本信息对接说明.md)。
|
||||
|
||||
**拉取全量示例**(教务侧循环):
|
||||
|
||||
```python
|
||||
page = 1
|
||||
while True:
|
||||
resp = get(f"/api/v1/students?page={page}&page_size=500")
|
||||
# 处理 resp["data"]
|
||||
if page >= resp["total_pages"]:
|
||||
break
|
||||
page += 1
|
||||
```
|
||||
|
||||
## 调用示例
|
||||
|
||||
```bash
|
||||
# 拉取当前自然年(如 2026)全部异动
|
||||
curl -s -H "X-Jw-Cx-Key: JWCX001" \
|
||||
"http://127.0.0.1:5050/api/v1/student-changes"
|
||||
|
||||
# 指定 2026 年
|
||||
# 异动:当前自然年全部
|
||||
curl -s -H "X-Jw-Cx-Key: JWCX001" \
|
||||
"http://127.0.0.1:5050/api/v1/student-changes?year=2026"
|
||||
|
||||
# 学生基本信息:分页(第 1 页,默认每页 500 条)
|
||||
curl -s -H "X-Jw-Cx-Key: JWCX001" \
|
||||
"http://127.0.0.1:5050/api/v1/students?page=1"
|
||||
|
||||
# 指定每页 1000 条、第 2 页
|
||||
curl -s -H "X-Jw-Cx-Key: JWCX001" \
|
||||
"http://127.0.0.1:5050/api/v1/students?page=2&page_size=1000"
|
||||
```
|
||||
|
||||
## 部署前:重建视图
|
||||
|
||||
代码更新后,请在目标库执行:
|
||||
|
||||
```bash
|
||||
mysql -h <host> -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_change_export.sql
|
||||
mysql -h <host> -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_export.sql
|
||||
```
|
||||
|
||||
## 联调测试脚本
|
||||
|
||||
仅调用两个业务接口(学生基本信息、学生异动)并打印结果:
|
||||
|
||||
```bash
|
||||
cd 跟教务系统同步学生异动信息/api_service
|
||||
|
||||
# 生产
|
||||
python test_jw_cx_api.py --prod
|
||||
|
||||
# 若 Nginx 带路径前缀
|
||||
python test_jw_cx_api.py --base-url http://web.hzdyjsxy.cn:18081/jw-cx-api --year 2026
|
||||
```
|
||||
|
||||
脚本仅依赖 Python 标准库;详见 `test_jw_cx_api.py --help`。
|
||||
|
||||
## 安全说明
|
||||
|
||||
1. `config.dev.ini` / `config.prod.ini` 含数据库凭据,请按公司规范控制仓库可见性与部署权限。
|
||||
2. 建议数据库侧使用**仅对视图** `vw_jw_student_change_export` 有 `SELECT` 的专用账号,与后端 `root` 分离。
|
||||
2. 建议数据库侧使用**仅对视图** `vw_jw_student_change_export`、`vw_jw_student_export` 有 `SELECT` 的专用账号,与后端 `root` 分离。
|
||||
3. 生产将 **`api_key`** 换为强随机串,并走 HTTPS;必要时加网络白名单。
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
教务同步:学生异动视图只读 API(Python 3.7+)
|
||||
教务同步:学生异动 + 学生基本信息 只读 API(Python 3.7+)
|
||||
|
||||
配置:默认读取与本文件同目录下的 config.ini(INI 格式),路径可用环境变量 JW_CX_CONFIG 覆盖。
|
||||
校验:请求头 X-Jw-Cx-Key 须与配置文件 [api] api_key 一致。
|
||||
@@ -17,16 +17,14 @@ 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"
|
||||
VIEW_STUDENT_CHANGES = "vw_jw_student_change_export"
|
||||
VIEW_STUDENT_BASE = "vw_jw_student_export"
|
||||
|
||||
# 视图列名:API 按「异动生效时间」所在自然年筛选
|
||||
COL_EFFECTIVE_TIME = "异动生效时间"
|
||||
|
||||
# 对外返回字段(与教务对接清单一致;不含视图内部列)
|
||||
EXPORT_COLUMNS = (
|
||||
CHANGE_EXPORT_COLUMNS = (
|
||||
"提交时间",
|
||||
"异动生效学期",
|
||||
"异动类型",
|
||||
@@ -50,6 +48,43 @@ EXPORT_COLUMNS = (
|
||||
"审核状态",
|
||||
)
|
||||
|
||||
STUDENT_EXPORT_COLUMNS = (
|
||||
"姓名",
|
||||
"学号",
|
||||
"联系电话",
|
||||
"身份证件类型",
|
||||
"身份证件号",
|
||||
"班级信息",
|
||||
"所在校区",
|
||||
"所在年级",
|
||||
"入学年份",
|
||||
"系部信息",
|
||||
"专业信息",
|
||||
"班主任工号",
|
||||
"班主任姓名",
|
||||
"学生当前状态",
|
||||
"学生是否在校",
|
||||
"异动类型",
|
||||
"培养层次",
|
||||
"学制",
|
||||
"毕业时间",
|
||||
"毕业学年学期",
|
||||
"学习经历",
|
||||
"何年何月-何年何月",
|
||||
"在何地学习",
|
||||
"性别",
|
||||
"籍贯地",
|
||||
"在校照片",
|
||||
"出生日期",
|
||||
"家庭现住址",
|
||||
"成员1姓名",
|
||||
"成员2姓名",
|
||||
"成员1关系",
|
||||
"成员2关系",
|
||||
"成员1联系电话",
|
||||
"成员2联系电话",
|
||||
)
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_DEFAULT_CONFIG_PATH = os.path.join(_BASE_DIR, "config.ini")
|
||||
|
||||
@@ -63,7 +98,6 @@ def _config_path():
|
||||
|
||||
|
||||
def _load_config():
|
||||
"""加载并缓存 INI 配置;缺失文件时给出明确提示。"""
|
||||
global _config_parser
|
||||
if _config_parser is not None:
|
||||
return _config_parser
|
||||
@@ -78,7 +112,6 @@ def _load_config():
|
||||
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,))
|
||||
@@ -124,8 +157,45 @@ def server_bind():
|
||||
}
|
||||
|
||||
|
||||
def _default_page_size():
|
||||
page_size = _get_int("api", "page_size", 500)
|
||||
if page_size < 1:
|
||||
page_size = 1
|
||||
if page_size > 5000:
|
||||
page_size = 5000
|
||||
return page_size
|
||||
|
||||
|
||||
def _clamp_int(value, lo, hi):
|
||||
v = int(value)
|
||||
if v < lo:
|
||||
return lo
|
||||
if v > hi:
|
||||
return hi
|
||||
return v
|
||||
|
||||
|
||||
def _resolve_paging():
|
||||
"""解析 page / page_size;未传 page_size 则用配置 [api] page_size。"""
|
||||
try:
|
||||
page = int(request.args.get("page", "1"))
|
||||
except (ValueError, TypeError):
|
||||
return None, None, "page 必须为整数"
|
||||
if page < 1:
|
||||
page = 1
|
||||
|
||||
page_size = _default_page_size()
|
||||
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 必须为整数"
|
||||
|
||||
return page, page_size, None
|
||||
|
||||
|
||||
def _default_filter_year():
|
||||
"""配置文件 default_year;未配置或无效时使用当前自然年。"""
|
||||
raw = _get("api", "default_year", "")
|
||||
if raw:
|
||||
try:
|
||||
@@ -138,10 +208,6 @@ def _default_filter_year():
|
||||
|
||||
|
||||
def _resolve_filter_year():
|
||||
"""
|
||||
合并 URL 参数与配置文件:
|
||||
- year:Query `year`,未传则用 [api] default_year 或当前自然年
|
||||
"""
|
||||
default_year = _default_filter_year()
|
||||
raw_year = request.args.get("year")
|
||||
if raw_year is not None and str(raw_year).strip() != "":
|
||||
@@ -180,8 +246,26 @@ def _serialize_value(value):
|
||||
return value
|
||||
|
||||
|
||||
def _row_to_export_dict(row):
|
||||
return {col: _serialize_value(row.get(col)) for col in EXPORT_COLUMNS}
|
||||
def _row_to_export_dict(row, columns):
|
||||
return {col: _serialize_value(row.get(col)) for col in columns}
|
||||
|
||||
|
||||
def _dedupe_student_changes(rows, columns):
|
||||
"""同一学号 + 异动类型 + 提交时间 仅保留首条(兜底,防视图 JOIN 放大或学号在关联表)。"""
|
||||
seen = set()
|
||||
out = []
|
||||
for row in rows:
|
||||
item = _row_to_export_dict(row, columns)
|
||||
key = (
|
||||
item.get("申请人学号"),
|
||||
item.get("异动类型"),
|
||||
item.get("提交时间"),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _db_connect():
|
||||
@@ -198,9 +282,53 @@ def _db_connect():
|
||||
)
|
||||
|
||||
|
||||
def _fetch_view_page(view_name, columns, page, page_size, where_clause="", params=()):
|
||||
"""分页查询视图,返回 (rows, total)。"""
|
||||
select_cols = ", ".join("`%s`" % col for col in columns)
|
||||
sql_count = "SELECT COUNT(*) AS cnt FROM `{view}` {where}".format(
|
||||
view=view_name, where=where_clause
|
||||
)
|
||||
offset = (page - 1) * page_size
|
||||
sql_list = (
|
||||
"SELECT {cols} FROM `{view}` {where} "
|
||||
"ORDER BY `来源记录id` ASC LIMIT %s OFFSET %s"
|
||||
).format(cols=select_cols, view=view_name, where=where_clause)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = _db_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql_count, params)
|
||||
total = int(cur.fetchone()["cnt"])
|
||||
cur.execute(sql_list, params + (page_size, offset))
|
||||
rows = cur.fetchall()
|
||||
return rows, total
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _fetch_view_rows(view_name, columns, where_clause="", params=(), order_by="`来源记录id` ASC"):
|
||||
select_cols = ", ".join("`%s`" % col for col in columns)
|
||||
sql = "SELECT {cols} FROM `{view}` {where} ORDER BY {order_by}".format(
|
||||
cols=select_cols,
|
||||
view=view_name,
|
||||
where=where_clause,
|
||||
order_by=order_by,
|
||||
)
|
||||
conn = None
|
||||
try:
|
||||
conn = _db_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""存活探测,不做密钥校验,便于负载均衡 / 运维。"""
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@@ -213,30 +341,20 @@ def list_student_changes():
|
||||
if param_err:
|
||||
return jsonify({"code": 400, "msg": param_err}), 400
|
||||
|
||||
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
|
||||
)
|
||||
where_year = "WHERE `{col}` IS NOT NULL AND YEAR(`{col}`) = %s".format(
|
||||
col=COL_EFFECTIVE_TIME
|
||||
)
|
||||
sql_list = (
|
||||
"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_list, (filter_year,))
|
||||
rows = cur.fetchall()
|
||||
rows = _fetch_view_rows(
|
||||
VIEW_STUDENT_CHANGES,
|
||||
CHANGE_EXPORT_COLUMNS,
|
||||
where_clause=where_year,
|
||||
params=(filter_year,),
|
||||
)
|
||||
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_export_dict(r) for r in rows]
|
||||
data = _dedupe_student_changes(rows, CHANGE_EXPORT_COLUMNS)
|
||||
return jsonify(
|
||||
{
|
||||
"code": 200,
|
||||
@@ -249,6 +367,38 @@ def list_student_changes():
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/v1/students", methods=["GET"])
|
||||
def list_students():
|
||||
"""分页返回未删除学生基本信息。"""
|
||||
if not _check_api_key():
|
||||
return jsonify({"code": 401, "msg": "无效的密钥或未提供请求头 " + HEADER_API_KEY}), 401
|
||||
|
||||
page, page_size, param_err = _resolve_paging()
|
||||
if param_err:
|
||||
return jsonify({"code": 400, "msg": param_err}), 400
|
||||
|
||||
try:
|
||||
rows, total = _fetch_view_page(
|
||||
VIEW_STUDENT_BASE, STUDENT_EXPORT_COLUMNS, page, page_size
|
||||
)
|
||||
except pymysql.Error as e:
|
||||
return jsonify({"code": 500, "msg": "数据库查询失败", "detail": str(e)}), 500
|
||||
|
||||
data = [_row_to_export_dict(r, STUDENT_EXPORT_COLUMNS) for r in rows]
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
return jsonify(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_load_config()
|
||||
bind = server_bind()
|
||||
|
||||
@@ -18,4 +18,5 @@ database = hzdyjs_xg
|
||||
api_key = JWCX001
|
||||
bind_host = 0.0.0.0
|
||||
bind_port = 5050
|
||||
page_size = 500
|
||||
# default_year =
|
||||
@@ -17,5 +17,7 @@ database = hzdyjs_xg
|
||||
api_key = JWCX001
|
||||
bind_host = 0.0.0.0
|
||||
bind_port = 5050
|
||||
# 列表接口:默认筛选年份(异动生效时间);请求可带 year 覆盖;留空则使用当前自然年
|
||||
# 学生基本信息接口:默认每页条数;请求可带 page_size 覆盖,范围 1~5000
|
||||
page_size = 500
|
||||
# 异动接口:默认筛选年份(异动生效时间);请求可带 year 覆盖;留空则使用当前自然年
|
||||
# default_year =
|
||||
@@ -19,4 +19,5 @@ database = hzdyjs_xg
|
||||
api_key = JWCX001
|
||||
bind_host = 0.0.0.0
|
||||
bind_port = 5050
|
||||
page_size = 500
|
||||
# default_year =
|
||||
Reference in new issue
Block a user