diff --git a/backend/README.md b/backend/README.md
index 5d6dc9c0..199ac325 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -68,7 +68,7 @@ Mall4J 高质量Java商城系统 - https://www.mall4j.com/cn/?statId=11
| 数据库主键 | 采用 雪花ID 基于时间戳的 有序增长 唯一ID 再也不用为分库分表 数据合并主键冲突重复而发愁 | 采用 数据库自增ID 支持数据量有限 不支持多数据源主键唯一 |
| WebSocket协议 | 基于 Spring 封装的 WebSocket 协议 扩展了Token鉴权与分布式会话同步 不再只是基于单机的废物 | 无 |
| SSE推送 | 采用 Spring SSE 实现 扩展了Token鉴权与分布式会话同步 | 无 |
-| 序列化 | 采用 Jackson Spring官方内置序列化 靠谱!!! | 采用 fastjson bugjson 远近闻名 |
+| 序列化 | 采用 Jackson Spring官方内置序列化 靠谱!!!(JustAuth 传递依赖已统一升级为 Fastjson2 1.x 兼容包 2.0.61) | 采用 fastjson bugjson 远近闻名 |
| 分布式幂等 | 参考美团GTIS防重系统简化实现(细节可看文档) | 手动编写注解基于aop实现 |
| 分布式锁 | 采用 Lock4j 底层基于 Redisson | 无 |
| 分布式任务调度 | 采用 SnailJob 天生支持分布式 统一的管理中心 支持多种数据库 支持分片重试DAG任务流等 | 采用 Quartz 基于数据库锁性能差 集群需要做很多配置与改造 |
diff --git a/backend/pom.xml b/backend/pom.xml
index 261234a4..417a8c33 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -43,8 +43,8 @@
2.28.22
3.3.4
-
- 1.2.83
+
+ 2.0.61
8.7.2-20250101
diff --git a/docs/技术问题解决记录/Fastjson1.2.83漏洞整改-升级Fastjson2兼容包.md b/docs/技术问题解决记录/Fastjson1.2.83漏洞整改-升级Fastjson2兼容包.md
new file mode 100644
index 00000000..5da03810
--- /dev/null
+++ b/docs/技术问题解决记录/Fastjson1.2.83漏洞整改-升级Fastjson2兼容包.md
@@ -0,0 +1,52 @@
+# Fastjson 1.2.83 漏洞整改(升级 Fastjson2 兼容包)
+
+## 背景
+
+客户通报 Fastjson `<= 1.2.83` 存在远程代码执行风险,要求排查整改。建议升级至 Fastjson2。
+
+## 排查结论
+
+| 项 | 说明 |
+|---|---|
+| 业务序列化 | 使用 Jackson(`ruoyi-common-json`),业务代码未直接调用 Fastjson |
+| 引入路径 | `JustAuth 1.16.7`(`ruoyi-common-social`)传递依赖 `com.alibaba:fastjson:1.2.83` |
+| 影响功能 | 第三方社交登录/绑定、微信小程序登录(JustAuth) |
+
+## 整改方案
+
+采用 **Fastjson2 的 1.x 兼容包**(坐标仍为 `com.alibaba:fastjson`,版本切到 `2.0.x`),无需修改 JustAuth 调用代码。
+
+修改位置:`backend/pom.xml`
+
+```xml
+
+2.0.61
+```
+
+`dependencyManagement` 中已统一锁定该版本,JustAuth 传递依赖会解析为:
+
+```
+JustAuth
+└─ com.alibaba:fastjson:2.0.61
+ └─ com.alibaba.fastjson2:fastjson2-extension:2.0.61
+ └─ com.alibaba.fastjson2:fastjson2:2.0.61
+```
+
+## 验证
+
+```bash
+cd backend
+mvn -pl ruoyi-admin -am dependency:tree -Dincludes=com.alibaba:fastjson,com.alibaba.fastjson2:fastjson2
+```
+
+确认结果中为 `com.alibaba:fastjson:jar:2.0.61`,且无 `1.2.83`。
+
+## 回归建议
+
+1. 账号密码登录(应不受影响)
+2. 实际启用的第三方登录/绑定(如有)
+3. 微信小程序登录(如有启用)
+
+## 变更日期
+
+2026-07-22
diff --git a/docs/设计文档/database/vw_jw_student_export.sql b/docs/设计文档/database/vw_jw_student_export.sql
new file mode 100644
index 00000000..52ecd432
--- /dev/null
+++ b/docs/设计文档/database/vw_jw_student_export.sql
@@ -0,0 +1,203 @@
+-- =============================================================================
+-- 学生基本信息 → 教务对接宽表视图
+-- 说明见:跟教务系统同步学生基础信息/学生基本信息对接说明.md
+--
+-- 口径摘要:
+-- * 身份证件类型:有 id_card 则「居民身份证」,否则 NULL
+-- * 所在校区:有有效宿舍分配则取 xg_dorm_campus.campus_name,否则 NULL
+-- * 班主任工号:class_teacher_id → sys_user.user_no(空则 user_name)
+-- * 学生是否在校:默认「在校」;退学(4)、毕业(5) 为「不在校」
+-- * 异动类型:固定 NULL
+-- * 毕业时间/毕业学年学期:在籍按 入学年份+学制 推算;已毕业(5)取 BI_YE 异动 effective_date
+-- * 学习经历/何年何月/在何地学习/在校照片:固定 NULL
+-- * 性别:0→男,1→女
+-- * 成员1/2:父亲/母亲及关系、电话
+-- * 学生当前状态:输出教务中文名称(见对接说明 §状态映射)
+-- =============================================================================
+
+DROP VIEW IF EXISTS `vw_jw_student_export`;
+
+CREATE VIEW `vw_jw_student_export` AS
+SELECT
+ (m.`name` COLLATE utf8mb4_unicode_ci) AS `姓名`,
+ (m.student_no COLLATE utf8mb4_unicode_ci) AS `学号`,
+ (m.phone COLLATE utf8mb4_unicode_ci) AS `联系电话`,
+ (CASE
+ WHEN NULLIF(TRIM(m.id_card), '') IS NOT NULL
+ THEN ('居民身份证' COLLATE utf8mb4_unicode_ci)
+ ELSE (CAST(NULL AS CHAR(32) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci)
+ END) AS `身份证件类型`,
+ (m.id_card COLLATE utf8mb4_unicode_ci) AS `身份证件号`,
+ (COALESCE(m.class_name, cm.class_name) COLLATE utf8mb4_unicode_ci) AS `班级信息`,
+ (dorm.campus_name COLLATE utf8mb4_unicode_ci) AS `所在校区`,
+ (COALESCE(m.grade, cm.grade) COLLATE utf8mb4_unicode_ci) AS `所在年级`,
+ m.enrollment_year AS `入学年份`,
+ (COALESCE(m.college_name, cm.college_name) COLLATE utf8mb4_unicode_ci) AS `系部信息`,
+ (COALESCE(m.major_name, cm.major_name) COLLATE utf8mb4_unicode_ci) AS `专业信息`,
+ (COALESCE(NULLIF(TRIM(su.user_no), ''), su.user_name) COLLATE utf8mb4_unicode_ci) AS `班主任工号`,
+ (COALESCE(m.class_teacher_name, cm.class_teacher_name) COLLATE utf8mb4_unicode_ci) AS `班主任姓名`,
+ (CASE m.student_status
+ WHEN '1' THEN '在读'
+ WHEN '2' THEN '休学'
+ WHEN '3' THEN '复学'
+ WHEN '4' THEN '退学'
+ WHEN '5' THEN '毕业'
+ WHEN '6' THEN '其他'
+ WHEN '7' THEN '结业'
+ WHEN '8' THEN '停学'
+ WHEN '9' THEN '其他'
+ ELSE COALESCE(m.student_status, '')
+ END COLLATE utf8mb4_unicode_ci) AS `学生当前状态`,
+ (CASE
+ WHEN (m.student_status COLLATE utf8mb4_unicode_ci) IN (
+ ('4' COLLATE utf8mb4_unicode_ci),
+ ('5' COLLATE utf8mb4_unicode_ci)
+ ) THEN ('不在校' COLLATE utf8mb4_unicode_ci)
+ ELSE ('在校' COLLATE utf8mb4_unicode_ci)
+ END) AS `学生是否在校`,
+ (CAST(NULL AS CHAR(32) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动类型`,
+ (CASE cm.class_type
+ WHEN '0' THEN '三年制中级技工'
+ WHEN '1' THEN '三年制技师'
+ WHEN '2' THEN '四年制高级工'
+ WHEN '3' THEN '五年制高级技工'
+ WHEN '4' THEN '六年制技师'
+ WHEN '5' THEN '中高职一体'
+ ELSE cm.class_type
+ END COLLATE utf8mb4_unicode_ci) AS `培养层次`,
+ COALESCE(cm.school_system, m.school_system) AS `学制`,
+ (
+ CASE
+ WHEN (m.student_status COLLATE utf8mb4_unicode_ci) = ('5' COLLATE utf8mb4_unicode_ci)
+ AND grad_sc.effective_date IS NOT NULL
+ THEN YEAR(grad_sc.effective_date)
+ WHEN m.enrollment_year IS NOT NULL
+ AND COALESCE(cm.school_system, m.school_system) IS NOT NULL
+ THEN m.enrollment_year + COALESCE(cm.school_system, m.school_system)
+ WHEN cm.graduation_year IS NOT NULL
+ THEN cm.graduation_year
+ ELSE NULL
+ END
+ ) AS `毕业时间`,
+ (
+ CASE
+ WHEN (
+ CASE
+ WHEN (m.student_status COLLATE utf8mb4_unicode_ci) = ('5' COLLATE utf8mb4_unicode_ci)
+ AND grad_sc.effective_date IS NOT NULL
+ THEN YEAR(grad_sc.effective_date)
+ WHEN m.enrollment_year IS NOT NULL
+ AND COALESCE(cm.school_system, m.school_system) IS NOT NULL
+ THEN m.enrollment_year + COALESCE(cm.school_system, m.school_system)
+ WHEN cm.graduation_year IS NOT NULL
+ THEN cm.graduation_year
+ ELSE NULL
+ END
+ ) IS NOT NULL
+ THEN CONCAT(
+ (
+ CASE
+ WHEN (m.student_status COLLATE utf8mb4_unicode_ci) = ('5' COLLATE utf8mb4_unicode_ci)
+ AND grad_sc.effective_date IS NOT NULL
+ THEN YEAR(grad_sc.effective_date)
+ WHEN m.enrollment_year IS NOT NULL
+ AND COALESCE(cm.school_system, m.school_system) IS NOT NULL
+ THEN m.enrollment_year + COALESCE(cm.school_system, m.school_system)
+ WHEN cm.graduation_year IS NOT NULL
+ THEN cm.graduation_year
+ ELSE NULL
+ END
+ ) - 1,
+ '-',
+ (
+ CASE
+ WHEN (m.student_status COLLATE utf8mb4_unicode_ci) = ('5' COLLATE utf8mb4_unicode_ci)
+ AND grad_sc.effective_date IS NOT NULL
+ THEN YEAR(grad_sc.effective_date)
+ WHEN m.enrollment_year IS NOT NULL
+ AND COALESCE(cm.school_system, m.school_system) IS NOT NULL
+ THEN m.enrollment_year + COALESCE(cm.school_system, m.school_system)
+ WHEN cm.graduation_year IS NOT NULL
+ THEN cm.graduation_year
+ ELSE NULL
+ END
+ ),
+ '-2'
+ )
+ ELSE NULL
+ END
+ ) AS `毕业学年学期`,
+ (CAST(NULL AS CHAR(500) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `学习经历`,
+ (CAST(NULL AS CHAR(128) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `何年何月-何年何月`,
+ (CAST(NULL AS CHAR(256) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `在何地学习`,
+ (CASE m.gender
+ WHEN '0' THEN '男'
+ WHEN '1' THEN '女'
+ ELSE NULL
+ END COLLATE utf8mb4_unicode_ci) AS `性别`,
+ (m.native_place COLLATE utf8mb4_unicode_ci) AS `籍贯地`,
+ (CAST(NULL AS CHAR(500) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `在校照片`,
+ DATE(m.birth_date) AS `出生日期`,
+ (COALESCE(NULLIF(TRIM(m.home_address), ''), m.current_address) COLLATE utf8mb4_unicode_ci) AS `家庭现住址`,
+ (m.father_name COLLATE utf8mb4_unicode_ci) AS `成员1姓名`,
+ (m.mother_name COLLATE utf8mb4_unicode_ci) AS `成员2姓名`,
+ (CASE
+ WHEN NULLIF(TRIM(m.father_name), '') IS NOT NULL
+ THEN ('父亲' COLLATE utf8mb4_unicode_ci)
+ ELSE (CAST(NULL AS CHAR(16) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci)
+ END) AS `成员1关系`,
+ (CASE
+ WHEN NULLIF(TRIM(m.mother_name), '') IS NOT NULL
+ THEN ('母亲' COLLATE utf8mb4_unicode_ci)
+ ELSE (CAST(NULL AS CHAR(16) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci)
+ END) AS `成员2关系`,
+ (m.father_phone COLLATE utf8mb4_unicode_ci) AS `成员1联系电话`,
+ (m.mother_phone COLLATE utf8mb4_unicode_ci) AS `成员2联系电话`,
+ m.id AS `来源记录id`
+FROM xg_student_msg m
+LEFT JOIN xg_class_msg cm
+ ON cm.deleted = 0
+ AND (cm.tenant_id COLLATE utf8mb4_unicode_ci) = (m.tenant_id COLLATE utf8mb4_unicode_ci)
+ AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
+LEFT JOIN sys_user su
+ ON su.user_id = COALESCE(m.class_teacher_id, cm.class_teacher_id)
+ AND (su.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
+ AND (su.tenant_id COLLATE utf8mb4_unicode_ci) = (m.tenant_id COLLATE utf8mb4_unicode_ci)
+LEFT JOIN (
+ SELECT
+ dbs.student_id,
+ MAX(dc.campus_name) AS campus_name
+ FROM xg_dorm_bed_student dbs
+ INNER JOIN (
+ SELECT student_id, MAX(assign_time) AS max_assign
+ FROM xg_dorm_bed_student
+ WHERE deleted = '0'
+ AND (status COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
+ GROUP BY student_id
+ ) latest
+ ON latest.student_id = dbs.student_id
+ AND dbs.assign_time = latest.max_assign
+ INNER JOIN xg_dorm_campus dc
+ ON dc.id = dbs.campus_id
+ AND (dc.deleted COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
+ WHERE dbs.deleted = '0'
+ AND (dbs.status COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
+ GROUP BY dbs.student_id
+) dorm ON dorm.student_id = m.id
+LEFT JOIN (
+ SELECT
+ sc.student_id,
+ MAX(sc.effective_date) AS effective_date
+ FROM xg_student_status_change sc
+ WHERE sc.deleted = 0
+ AND sc.process_status = 1
+ AND (sc.change_type COLLATE utf8mb4_unicode_ci) = ('BI_YE' COLLATE utf8mb4_unicode_ci)
+ GROUP BY sc.student_id
+) grad_sc ON grad_sc.student_id = m.id
+WHERE m.deleted = 0;
+
+-- -----------------------------------------------------------------------------
+-- 授权:用户 jw_cx 仅查询本视图(需使用具备 GRANT 权限的账号执行,如 root)
+-- -----------------------------------------------------------------------------
+GRANT SELECT ON `hzdyjs_xg`.`vw_jw_student_export` TO 'jw_cx'@'%';
+FLUSH PRIVILEGES;
diff --git a/跟教务系统同步学生基础信息/学生基本信息对接说明.md b/跟教务系统同步学生基础信息/学生基本信息对接说明.md
new file mode 100644
index 00000000..2940056f
--- /dev/null
+++ b/跟教务系统同步学生基础信息/学生基本信息对接说明.md
@@ -0,0 +1,78 @@
+# 学生基本信息 — 教务对接说明
+
+## 接口
+
+| 项目 | 说明 |
+|------|------|
+| 路径 | `GET /api/v1/students` |
+| 鉴权 | Header `X-Jw-Cx-Key` |
+| 数据范围 | 全部 `xg_student_msg.deleted = 0` 的学生,**分页返回** |
+| 视图 DDL | `docs/设计文档/database/vw_jw_student_export.sql` |
+| 服务代码 | `跟教务系统同步学生异动信息/api_service/app.py`(与异动接口同进程) |
+
+## 字段口径(已确认)
+
+| 教务字段 | 口径 |
+|----------|------|
+| 身份证件类型 | 有 `id_card` →「居民身份证」,否则 `null` |
+| 所在校区 | 有有效宿舍分配(`xg_dorm_bed_student.status=0`)→ `xg_dorm_campus.campus_name`,否则 `null` |
+| 班主任工号 | `class_teacher_id` → `sys_user.user_no`,空则 `user_name` |
+| 学生是否在校 | 默认「在校」;学籍状态为 **退学(4)、毕业(5)** →「不在校」 |
+| 异动类型 | 固定 `null` |
+| 毕业时间 | 年份整数。已毕业(5):取 `BI_YE` 已处理异动的 `effective_date` 年份;否则 `入学年份 + 学制`(或班级 `graduation_year`) |
+| 毕业学年学期 | `{毕业年-1}-{毕业年}-2`,例如 `2025-2026-2`;无法推算则为 `null` |
+| 学习经历 / 何年何月-何年何月 / 在何地学习 | 固定 `null` |
+| 在校照片 | 固定 `null` |
+| 性别 | `0`→「男」,`1`→「女」 |
+| 成员1 | 父亲姓名/关系「父亲」/ `father_phone` |
+| 成员2 | 母亲姓名/关系「母亲」/ `mother_phone` |
+
+## 学生当前状态映射(本系统 → 教务中文名)
+
+| 本系统 `student_status` | 本系统标签 | 接口输出(教务中文名) |
+|-------------------------|------------|------------------------|
+| 1 | 在读 | 在读 |
+| 2 | 休学 | 休学 |
+| 3 | 复学 | 复学 |
+| 4 | 退学 | 退学 |
+| 5 | 毕业 | 毕业 |
+| 6 | 不在籍 | 其他 |
+| 7 | 结业 | 结业 |
+| 8 | 停学 | 停学 |
+| 9 | 分流 | 其他 |
+
+教务附件2 字典值(供联调参考,接口当前输出**中文名称**):
+
+| 教务状态名称 | 字典值 |
+|--------------|--------|
+| 在读 | XSDQZT016 |
+| 休学 | XSDQZT015 |
+| 退学 | XSDQZT014 |
+| 停学 | XSDQZT013 |
+| 复学 | XSDQZT012 |
+| 毕业 | XSDQZT010 |
+| 结业 | XSDQZT009 |
+| 其他 | XSDQZT007 |
+
+## 部署
+
+```bash
+# 1. 建视图
+mysql -h -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_export.sql
+
+# 2. 重启 API(与异动接口相同进程)
+export JW_CX_CONFIG=/path/to/config.prod.ini
+python app.py
+
+# 3. 调用示例
+curl -s -H "X-Jw-Cx-Key: JWCX001" \
+ "http://127.0.0.1:5050/api/v1/students?page=1&page_size=500"
+```
+
+响应含 `total`(总条数)、`page`、`page_size`、`total_pages`,教务可按页循环直至 `page >= total_pages`。
+
+## 与异动接口关系
+
+- 异动接口:`/api/v1/student-changes?year=2026`(按异动生效年份)
+- 学生基本信息:`/api/v1/students?page=1&page_size=500`(分页,含 total / total_pages)
+- 毕业时间在学生接口中仍可使用;**毕业记录不再通过异动接口同步**(异动接口已排除 `BI_YE`)
diff --git a/跟教务系统同步学生基础信息/学生当前状态(附件2).xlsx b/跟教务系统同步学生基础信息/学生当前状态(附件2).xlsx
new file mode 100644
index 00000000..461d1b23
Binary files /dev/null and b/跟教务系统同步学生基础信息/学生当前状态(附件2).xlsx differ
diff --git a/跟教务系统同步学生基础信息/第一技师学生基本信息.docx b/跟教务系统同步学生基础信息/第一技师学生基本信息.docx
new file mode 100644
index 00000000..8956958e
Binary files /dev/null and b/跟教务系统同步学生基础信息/第一技师学生基本信息.docx differ
diff --git a/跟教务系统同步学生异动信息/api_service/test_jw_cx_api.py b/跟教务系统同步学生异动信息/api_service/test_jw_cx_api.py
new file mode 100755
index 00000000..39e7fb74
--- /dev/null
+++ b/跟教务系统同步学生异动信息/api_service/test_jw_cx_api.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+教务同步只读 API 联调脚本(Python 3.7+,仅标准库)
+
+调用两个业务接口并打印成功结果:
+ - GET /api/v1/students 学生基本信息(第 1 页)
+ - GET /api/v1/student-changes 学生异动(按年)
+
+用法:
+ python test_jw_cx_api.py --prod
+ python test_jw_cx_api.py --base-url http://web.hzdyjsxy.cn:18081/jw-cx-api --year 2026
+
+环境变量:JW_CX_BASE_URL、JW_CX_API_KEY
+"""
+from __future__ import print_function
+
+import argparse
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+
+HEADER_API_KEY = "X-Jw-Cx-Key"
+DEFAULT_BASE_URL = "http://127.0.0.1:5050"
+PROD_BASE_URL = "http://web.hzdyjsxy.cn:18081"
+DEFAULT_API_KEY = "JWCX001"
+
+
+def _get_json(url, api_key, timeout=120):
+ req = urllib.request.Request(url, method="GET")
+ req.add_header(HEADER_API_KEY, api_key)
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ body = resp.read().decode("utf-8")
+ return resp.status, json.loads(body)
+
+
+def _join_url(base, path):
+ return base.rstrip("/") + "/" + path.lstrip("/")
+
+
+def fetch_students(base_url, api_key, page=1, page_size=10):
+ qs = urllib.parse.urlencode({"page": page, "page_size": page_size})
+ url = _join_url(base_url, "/api/v1/students?" + qs)
+ status, data = _get_json(url, api_key)
+ if status != 200 or data.get("code") != 200:
+ raise RuntimeError("学生基本信息接口失败: status=%s body=%s" % (status, data))
+ return data
+
+
+def fetch_student_changes(base_url, api_key, year):
+ qs = urllib.parse.urlencode({"year": year})
+ url = _join_url(base_url, "/api/v1/student-changes?" + qs)
+ status, data = _get_json(url, api_key)
+ if status != 200 or data.get("code") != 200:
+ raise RuntimeError("学生异动接口失败: status=%s body=%s" % (status, data))
+ return data
+
+
+def main():
+ parser = argparse.ArgumentParser(description="教务同步 API:获取学生信息与学生异动")
+ parser.add_argument("--base-url", default=None, help="API 根地址")
+ parser.add_argument("--prod", action="store_true", help="使用 %s" % PROD_BASE_URL)
+ parser.add_argument(
+ "--api-key",
+ default=os.environ.get("JW_CX_API_KEY", DEFAULT_API_KEY),
+ help="请求头 %s" % HEADER_API_KEY,
+ )
+ parser.add_argument(
+ "--year",
+ type=int,
+ default=None,
+ help="异动筛选年份,默认当前自然年",
+ )
+ parser.add_argument(
+ "--page-size",
+ type=int,
+ default=10,
+ help="学生基本信息每页条数,默认 10",
+ )
+ args = parser.parse_args()
+
+ if args.base_url:
+ base_url = args.base_url
+ elif args.prod:
+ base_url = PROD_BASE_URL
+ else:
+ base_url = os.environ.get("JW_CX_BASE_URL", DEFAULT_BASE_URL)
+
+ year = args.year if args.year is not None else time.localtime().tm_year
+
+ print("BASE_URL: %s" % base_url)
+ print("")
+
+ try:
+ students = fetch_students(base_url, args.api_key, page=1, page_size=args.page_size)
+ print("=== 学生基本信息 GET /api/v1/students ===")
+ print(
+ "total=%s page=%s/%s 本页=%s条"
+ % (
+ students.get("total"),
+ students.get("page"),
+ students.get("total_pages"),
+ len(students.get("data") or []),
+ )
+ )
+ if students.get("data"):
+ print("首条:", json.dumps(students["data"][0], ensure_ascii=False, indent=2))
+ print("")
+
+ changes = fetch_student_changes(base_url, args.api_key, year)
+ print("=== 学生异动 GET /api/v1/student-changes?year=%s ===" % year)
+ print("total=%s filter_year=%s" % (changes.get("total"), changes.get("filter_year")))
+ if changes.get("data"):
+ print("首条:", json.dumps(changes["data"][0], ensure_ascii=False, indent=2))
+ elif changes.get("total") == 0:
+ print("(当年无异动记录)")
+
+ except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError, ValueError) as e:
+ print("调用失败: %s" % e, file=sys.stderr)
+ sys.exit(1)
+
+ print("")
+ print("调用成功")
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/跟教务系统同步学生异动信息/api_service/学生信息接口和学生异动接口调用说明.md b/跟教务系统同步学生异动信息/api_service/学生信息接口和学生异动接口调用说明.md
new file mode 100644
index 00000000..15fd3b2f
--- /dev/null
+++ b/跟教务系统同步学生异动信息/api_service/学生信息接口和学生异动接口调用说明.md
@@ -0,0 +1,402 @@
+# 教务系统对接 — 学工数据只读 API 调用说明
+
+本文档面向**教务系统**开发/联调人员,说明如何调用学工系统提供的学生基本信息、学生学籍异动两类只读接口。
+
+---
+
+## 1. 概述
+
+| 项目 | 说明 |
+|------|------|
+| 协议 | HTTP GET |
+| 数据格式 | JSON,`Content-Type: application/json` |
+| 字符编码 | UTF-8 |
+| 鉴权方式 | 请求头 `X-Jw-Cx-Key`(由学工方分配) |
+| 接口数量 | 2 个业务接口 + 1 个健康检查 |
+
+| 接口 | 路径 | 用途 |
+|------|------|------|
+| 学生基本信息 | `GET /api/v1/students` | 分页拉取全部在库学生快照 |
+| 学生学籍异动 | `GET /api/v1/student-changes` | 按自然年拉取已办结异动 |
+| 健康检查 | `GET /health` | 探测服务是否存活(无需密钥) |
+
+---
+
+## 2. 访问地址
+
+| 项目 | 值 |
+|------|-----|
+| Base URL | `http://web.hzdyjsxy.cn:18081/jw-cx-api` |
+
+完整示例:
+
+```
+http://web.hzdyjsxy.cn:18081/jw-cx-api/api/v1/students?page=1
+http://web.hzdyjsxy.cn:18081/jw-cx-api/api/v1/student-changes?year=2026
+http://web.hzdyjsxy.cn:18081/jw-cx-api/health
+```
+
+> 若域名、端口或路径前缀有调整,以学工方最新通知为准。
+
+---
+
+## 3. 鉴权
+
+除 `/health` 外,所有业务接口必须在 **HTTP Header** 中携带:
+
+```http
+X-Jw-Cx-Key: <学工方分配的密钥>
+```
+
+| 情况 | HTTP 状态 | 响应示例 |
+|------|-----------|----------|
+| 未携带或密钥错误 | 401 | `{"code":401,"msg":"无效的密钥或未提供请求头 X-Jw-Cx-Key"}` |
+| 参数错误 | 400 | `{"code":400,"msg":"..."}` |
+| 成功 | 200 | `{"code":200,"msg":"success",...}` |
+| 服务端异常 | 500 | `{"code":500,"msg":"数据库查询失败",...}` |
+
+---
+
+## 4. 统一响应结构
+
+### 4.1 成功
+
+```json
+{
+ "code": 200,
+ "msg": "success",
+ "data": [ /* 记录数组 */ ],
+ "total": 100
+}
+```
+
+各接口还会在顶层返回分页或筛选元数据(见下文)。
+
+### 4.2 字段值约定
+
+- 日期:`YYYY-MM-DD`(如 `"2026-04-21"`)
+- 日期时间:`YYYY-MM-DD HH:mm:ss`
+- 无数据:JSON `null`(不是空字符串 `""`)
+- 异动类型、学生当前状态等枚举:**中文名称**(非内部代码)
+
+---
+
+## 5. 接口一:学生基本信息
+
+### 5.1 请求
+
+```
+GET /api/v1/students
+```
+
+**Query 参数**
+
+| 参数 | 必填 | 说明 | 默认值 |
+|------|------|------|--------|
+| `page` | 否 | 页码,从 1 开始 | `1` |
+| `page_size` | 否 | 每页条数,范围 1~5000 | `500` |
+
+**示例**
+
+```http
+GET /api/v1/students?page=1&page_size=500
+X-Jw-Cx-Key: JWCX001
+```
+
+### 5.2 响应元数据
+
+| 字段 | 说明 |
+|------|------|
+| `total` | 学生总条数 |
+| `page` | 当前页码 |
+| `page_size` | 本页大小 |
+| `total_pages` | 总页数 |
+
+### 5.3 拉取全量建议流程
+
+学生数量可能达到数万条,**必须分页循环**:
+
+```
+1. 请求 page=1,读取 total、total_pages
+2. 处理 data 数组
+3. page 递增,直到 page >= total_pages
+```
+
+伪代码:
+
+```python
+page = 1
+page_size = 500
+while True:
+ resp = GET(f"/api/v1/students?page={page}&page_size={page_size}")
+ # 入库或处理 resp["data"]
+ if page >= resp["total_pages"]:
+ break
+ page += 1
+```
+
+### 5.4 `data` 每条记录字段
+
+| 字段名 | 必填(教务) | 说明 |
+|--------|--------------|------|
+| 姓名 | 是 | |
+| 学号 | 是 | |
+| 联系电话 | 否 | |
+| 身份证件类型 | 否 | 有身份证号时为「居民身份证」,否则 `null` |
+| 身份证件号 | 否 | |
+| 班级信息 | 是 | 班级名称 |
+| 所在校区 | 是 | 有宿舍分配时取校区名,否则 `null` |
+| 所在年级 | 是 | |
+| 入学年份 | 是 | 整数,如 `2024` |
+| 系部信息 | 是 | 院系名称 |
+| 专业信息 | 是 | 专业名称 |
+| 班主任工号 | 是 | |
+| 班主任姓名 | 是 | |
+| 学生当前状态 | 是 | 教务中文名:在读、休学、复学、退学、毕业、结业、停学、其他 |
+| 学生是否在校 | 是 | 「在校」或「不在校」;退学、毕业为不在校 |
+| 异动类型 | 否 | 固定 `null` |
+| 培养层次 | 否 | 如三年制技师、六年制技师等 |
+| 学制 | 是 | 年数,整数 |
+| 毕业时间 | 是 | **年份**整数;已毕业取异动生效年,否则按入学年+学制推算 |
+| 毕业学年学期 | 是 | 如 `2025-2026-2`;无法推算时为 `null` |
+| 学习经历 | 否 | 固定 `null` |
+| 何年何月-何年何月 | 否 | 固定 `null` |
+| 在何地学习 | 否 | 固定 `null` |
+| 性别 | 是 | 「男」或「女」 |
+| 籍贯地 | 否 | |
+| 在校照片 | 否 | 固定 `null` |
+| 出生日期 | 否 | `YYYY-MM-DD` |
+| 家庭现住址 | 否 | |
+| 成员1姓名 | 否 | 父亲姓名 |
+| 成员2姓名 | 否 | 母亲姓名 |
+| 成员1关系 | 否 | 有父亲姓名时为「父亲」 |
+| 成员2关系 | 否 | 有母亲姓名时为「母亲」 |
+| 成员1联系电话 | 否 | 父亲电话 |
+| 成员2联系电话 | 否 | 母亲电话 |
+
+### 5.5 响应示例(节选)
+
+```json
+{
+ "code": 200,
+ "msg": "success",
+ "data": [
+ {
+ "姓名": "张三",
+ "学号": "2024010101",
+ "联系电话": "13800138000",
+ "身份证件类型": "居民身份证",
+ "身份证件号": "3301************",
+ "班级信息": "24技师1班",
+ "所在校区": "西溪校区",
+ "所在年级": "2024",
+ "入学年份": 2024,
+ "系部信息": "电气工程系",
+ "专业信息": "电气工程",
+ "班主任工号": "T001",
+ "班主任姓名": "李老师",
+ "学生当前状态": "在读",
+ "学生是否在校": "在校",
+ "异动类型": null,
+ "培养层次": "三年制技师",
+ "学制": 3,
+ "毕业时间": 2027,
+ "毕业学年学期": "2026-2027-2",
+ "性别": "男"
+ }
+ ],
+ "total": 12500,
+ "page": 1,
+ "page_size": 500,
+ "total_pages": 25
+}
+```
+
+---
+
+## 6. 接口二:学生学籍异动
+
+### 6.1 请求
+
+```
+GET /api/v1/student-changes
+```
+
+**Query 参数**
+
+| 参数 | 必填 | 说明 | 默认值 |
+|------|------|------|--------|
+| `year` | 否 | 按「异动生效时间」所在**自然年**筛选,范围 2000~2100 | 当前自然年 |
+
+**示例**
+
+```http
+GET /api/v1/student-changes?year=2026
+X-Jw-Cx-Key: JWCX001
+```
+
+### 6.2 数据范围说明
+
+- 仅返回 **已办结** 记录(学籍异动 `process_status=1`,换班级 `status=1`)
+- 按 **`异动生效时间` 的年份** 过滤(非提交时间)
+- **不分页**,一次返回该年全部记录(数据量通常远小于学生全量)
+- **不包含毕业(BI_YE)** 记录
+- **去重**:同一学号、同一登记日(`提交时间`)、同一异动类型仅保留一条;视图与 API 双层去重
+
+### 6.3 响应元数据
+
+| 字段 | 说明 |
+|------|------|
+| `total` | 本次返回条数(等于 `data` 长度) |
+| `filter_year` | 实际筛选年份 |
+| `filter_by_column` | 固定为 `异动生效时间` |
+
+### 6.4 `data` 每条记录字段
+
+| 字段名 | 说明 |
+|--------|------|
+| 提交时间 | `YYYY-MM-DD` |
+| 异动生效学期 | 固定 `null`(本系统无学年学期) |
+| 异动类型 | 教务中文名:**退学、休学、复学、转班级** |
+| 申请人学号 | |
+| 姓名 | |
+| 异动前所在年级 / 院系 / 专业 / 班级 / 学制 | |
+| 异动前所在校区 | 固定 `null` |
+| 异动后所在年级 / 班级 / 院系 / 专业 / 学制 | |
+| 异动后所在校区 | 固定 `null` |
+| 异动后班主任工号 / 姓名 | |
+| 新名字 | 固定 `null` |
+| 审核状态 | 固定「已通过」 |
+
+### 6.5 异动类型说明
+
+| 接口返回值 | 含义 |
+|------------|------|
+| 退学 | 学籍异动-退学 |
+| 休学 | 学籍异动-休学 |
+| 复学 | 学籍异动-复学 |
+| 转班级 | 换班级记录 |
+
+> 本系统**不同步**毕业异动;毕业相关信息请从「学生基本信息」接口获取。
+
+### 6.6 响应示例
+
+```json
+{
+ "code": 200,
+ "msg": "success",
+ "data": [
+ {
+ "提交时间": "2026-03-15",
+ "异动生效学期": null,
+ "异动类型": "休学",
+ "申请人学号": "2024010101",
+ "姓名": "张三",
+ "异动前所在年级": "2024",
+ "异动前所在校区": null,
+ "异动前所在院系": "电气工程系",
+ "异动前所在专业": "电气工程",
+ "异动前所在班级": "24技师1班",
+ "异动前学制": 3,
+ "异动后所在校区": null,
+ "异动后所在年级": "2024",
+ "异动后所在班级": "24技师1班",
+ "异动后所在院系": "电气工程系",
+ "异动后所在专业": "电气工程",
+ "异动后学制": 3,
+ "异动后班主任工号": "T001",
+ "异动后班主任姓名": "李老师",
+ "新名字": null,
+ "审核状态": "已通过"
+ }
+ ],
+ "total": 12,
+ "filter_year": 2026,
+ "filter_by_column": "异动生效时间"
+}
+```
+
+---
+
+## 7. 调用示例
+
+### 7.1 cURL
+
+```bash
+# 健康检查
+curl -s "http://web.hzdyjsxy.cn:18081/jw-cx-api/health"
+
+# 学生基本信息(第 1 页)
+curl -s -H "X-Jw-Cx-Key: JWCX001" \
+ "http://web.hzdyjsxy.cn:18081/jw-cx-api/api/v1/students?page=1&page_size=500"
+
+# 学生异动(2026 年)
+curl -s -H "X-Jw-Cx-Key: JWCX001" \
+ "http://web.hzdyjsxy.cn:18081/jw-cx-api/api/v1/student-changes?year=2026"
+```
+
+### 7.2 Postman
+
+1. Method 选 **GET**
+2. URL 填入完整地址(含 Query)
+3. **Headers** 增加一行:
+ - Key: `X-Jw-Cx-Key`
+ - Value: 学工分配的密钥
+4. 点击 Send
+
+### 7.3 Python(标准库)
+
+项目内提供联调脚本:
+
+```bash
+cd 跟教务系统同步学生异动信息/api_service
+python test_jw_cx_api.py --base-url http://web.hzdyjsxy.cn:18081/jw-cx-api --year 2026
+```
+
+---
+
+## 8. 联调检查清单
+
+| 步骤 | 操作 |
+|------|------|
+| 1 | 访问 `/health` 返回 `{"status":"ok"}` |
+| 2 | 不带密钥访问业务接口,应返回 401 |
+| 3 | 带正确密钥访问 `/api/v1/students?page=1`,`code=200` 且含 `total`、`total_pages` |
+| 4 | 循环分页直至拉取全部学生 |
+| 5 | 带密钥访问 `/api/v1/student-changes?year=2026`,核对异动类型中文名 |
+| 6 | 确认 `null` 字段(校区、异动生效学期等)教务侧可接受 |
+
+---
+
+## 9. 常见问题
+
+**Q:为什么 80 端口访问 404?**
+A:公网 API 走 **18081** 端口及 `/jw-cx-api` 路径,请使用本文 2.1 节地址。
+
+**Q:学生接口为什么要分页?**
+A:全量学生可能数万条,单次响应过大易超时;请按 `total_pages` 逐页拉取。
+
+**Q:异动接口为何没有毕业?**
+A:按对接约定,毕业不同步至异动接口;毕业状态与毕业时间见学生基本信息接口。
+
+**Q:「复学并换班级」会有几条异动?**
+A:可能同时存在「复学」「转班级」两条记录,请按学号+提交时间处理。
+
+**Q:密钥如何更换?**
+A:由学工方在服务端配置 `[api] api_key` 后通知教务更新 Header。
+
+---
+
+## 10. 相关文档
+
+| 文档 | 说明 |
+|------|------|
+| [README.md](./README.md) | 服务部署与运维(学工侧) |
+| [../异动类型映射说明.md](../异动类型映射说明.md) | 异动类型详细映射 |
+| [../../跟教务系统同步学生基础信息/学生基本信息对接说明.md](../../跟教务系统同步学生基础信息/学生基本信息对接说明.md) | 学生字段口径与状态映射 |
+| [test_jw_cx_api.py](./test_jw_cx_api.py) | 联调测试脚本 |
+
+---
+
+**文档版本**:2026-07-02
+**维护**:学工系统开发组(接口变更时请同步更新本文档)