zlwu пре 4 месеци
родитељ
комит
dd6212400c

+ 105 - 7
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)

+ 1 - 0
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) |
 
 

+ 68 - 12
跟教务系统同步学生异动信息/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?year=2026"
+
+# 学生基本信息:分页(第 1 页,默认每页 500 条)
 curl -s -H "X-Jw-Cx-Key: JWCX001" \
-  "http://127.0.0.1:5050/api/v1/student-changes"
+  "http://127.0.0.1:5050/api/v1/students?page=1"
 
-# 指定 2026 年
+# 指定每页 1000 条、第 2 页
 curl -s -H "X-Jw-Cx-Key: JWCX001" \
-  "http://127.0.0.1:5050/api/v1/student-changes?year=2026"
+  "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;必要时加网络白名单。

+ 185 - 35
跟教务系统同步学生异动信息/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()

+ 1 - 0
跟教务系统同步学生异动信息/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 =

+ 3 - 1
跟教务系统同步学生异动信息/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 =

+ 1 - 0
跟教务系统同步学生异动信息/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 =