diff --git a/docs/设计文档/database/vw_jw_student_change_export.sql b/docs/设计文档/database/vw_jw_student_change_export.sql index 62d07fb9..d7268944 100644 --- a/docs/设计文档/database/vw_jw_student_change_export.sql +++ b/docs/设计文档/database/vw_jw_student_change_export.sql @@ -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) diff --git a/docs/设计文档/database/学生学籍异动同步教务系统改造方案.md b/docs/设计文档/database/学生学籍异动同步教务系统改造方案.md index 2679f2b7..07d9a896 100644 --- a/docs/设计文档/database/学生学籍异动同步教务系统改造方案.md +++ b/docs/设计文档/database/学生学籍异动同步教务系统改造方案.md @@ -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) | diff --git a/跟教务系统同步学生异动信息/api_service/README.md b/跟教务系统同步学生异动信息/api_service/README.md index 6eb7f6c0..2dfcb215 100644 --- a/跟教务系统同步学生异动信息/api_service/README.md +++ b/跟教务系统同步学生异动信息/api_service/README.md @@ -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 -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_change_export.sql +mysql -h -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;必要时加网络白名单。 diff --git a/跟教务系统同步学生异动信息/api_service/app.py b/跟教务系统同步学生异动信息/api_service/app.py index ce0fb93d..89066244 100644 --- a/跟教务系统同步学生异动信息/api_service/app.py +++ b/跟教务系统同步学生异动信息/api_service/app.py @@ -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() diff --git a/跟教务系统同步学生异动信息/api_service/config.dev.ini b/跟教务系统同步学生异动信息/api_service/config.dev.ini index a319b2e8..e4acbf62 100644 --- a/跟教务系统同步学生异动信息/api_service/config.dev.ini +++ b/跟教务系统同步学生异动信息/api_service/config.dev.ini @@ -18,4 +18,5 @@ database = hzdyjs_xg api_key = JWCX001 bind_host = 0.0.0.0 bind_port = 5050 +page_size = 500 # default_year = diff --git a/跟教务系统同步学生异动信息/api_service/config.ini.example b/跟教务系统同步学生异动信息/api_service/config.ini.example index b5a6d67c..a46c74f7 100644 --- a/跟教务系统同步学生异动信息/api_service/config.ini.example +++ b/跟教务系统同步学生异动信息/api_service/config.ini.example @@ -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 = diff --git a/跟教务系统同步学生异动信息/api_service/config.prod.ini b/跟教务系统同步学生异动信息/api_service/config.prod.ini index 649982a9..a2915222 100644 --- a/跟教务系统同步学生异动信息/api_service/config.prod.ini +++ b/跟教务系统同步学生异动信息/api_service/config.prod.ini @@ -19,4 +19,5 @@ database = hzdyjs_xg api_key = JWCX001 bind_host = 0.0.0.0 bind_port = 5050 +page_size = 500 # default_year =