This commit is contained in:
zlwu committed 2026-09-02 09:58:57 +08:00
1 parent 8f99ac7482
commit f231386592
44 files changed
+800734 -2509

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -26,16 +26,20 @@ import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.excel.core.DropDownOptions;
import org.dromara.common.excel.core.ExcelResult;
import org.dromara.common.excel.utils.ExcelUtil;
import org.dromara.xg.domain.bo.XgDormCampusBo;
import org.dromara.xg.domain.vo.XgClassMsgImportVo;
import org.dromara.xg.domain.vo.XgClassMsgVo;
import org.dromara.xg.domain.vo.XgSysDeptVo;
import org.dromara.xg.domain.vo.XgDormCampusVo;
import org.dromara.xg.domain.bo.XgClassMsgBo;
import org.dromara.xg.domain.bo.XgClassStatusBo;
import org.dromara.xg.domain.bo.XgClassStatusBatchBo;
import org.dromara.xg.domain.bo.XgClassCampusBatchBo;
import org.dromara.xg.excel.XgClassMsgImportTemplateWriter;
import org.dromara.xg.listener.XgClassMsgImportListener;
import org.dromara.xg.service.IXgClassMsgService;
import org.dromara.xg.service.IXgSysDeptService;
import org.dromara.xg.service.IXgDormCampusService;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.satoken.utils.LoginHelper;
@@ -54,6 +58,7 @@ public class XgClassMsgController extends BaseController {
private final IXgClassMsgService xgClassMsgService;
private final IXgSysDeptService xgSysDeptService;
private final IXgDormCampusService xgDormCampusService;
/**
* 查询班级信息-新版列表
@@ -136,6 +141,17 @@ public class XgClassMsgController extends BaseController {
return toAjax(xgClassMsgService.updateClassStatusBatch(bo.getIds(), bo.getClassStatus()));
}
/**
* 批量设置班级所在校区
*/
// @SaCheckPermission("classMsg:classMsg:edit")
@Log(title = "班级信息-新版", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PutMapping("/campus/batch")
public R<Void> updateCampusBatch(@Validated @RequestBody XgClassCampusBatchBo bo) {
return toAjax(xgClassMsgService.updateCampusBatch(bo.getIds(), bo.getCampusId()));
}
/**
* 删除班级信息-新版
*
@@ -237,9 +253,22 @@ public class XgClassMsgController extends BaseController {
// 学制常见可选值
DropDownOptions schoolSystemOptions = new DropDownOptions(6, Arrays.asList("2", "3", "4", "5", "6"));
// 所在校区下拉(列 index=10)
List<XgDormCampusVo> campusList = xgDormCampusService.queryList(new XgDormCampusBo());
List<String> campusNames = new ArrayList<>();
if (campusList != null) {
for (XgDormCampusVo campus : campusList) {
if (StringUtils.isNotBlank(campus.getCampusName())) {
campusNames.add(campus.getCampusName());
}
}
}
DropDownOptions campusOptions = new DropDownOptions(10, campusNames);
List<DropDownOptions> options = new ArrayList<>();
options.add(collegeToMajor);
options.add(schoolSystemOptions);
options.add(campusOptions);
return options;
}
@@ -55,6 +55,16 @@ public class XgClassMsg extends TenantEntity {
*/
private String majorName;
/**
* 所在校区ID,关联xg_dorm_campus.id
*/
private Long campusId;
/**
* 所在校区名称
*/
private String campusName;
/**
* 班级代码
*/
@@ -0,0 +1,22 @@
package org.dromara.xg.domain.bo;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 班级所在校区批量更新业务对象
*/
@Data
public class XgClassCampusBatchBo {
/** 主键ID集合(雪花ID) */
@NotEmpty(message = "主键ID集合不能为空")
private List<Long> ids;
/** 校区ID,关联 xg_dorm_campus.id */
@NotNull(message = "校区不能为空")
private Long campusId;
}
@@ -48,6 +48,16 @@ public class XgClassMsgBo extends BaseEntity {
*/
private String majorName;
/**
* 所在校区ID,关联xg_dorm_campus.id(选填)
*/
private Long campusId;
/**
* 所在校区名称
*/
private String campusName;
/**
* 班级代码
*/
@@ -99,10 +99,18 @@ public class XgClassMsgImportVo implements Serializable {
@ExcelProperty(value = "班级教室位置", index = 9)
private String classLocation;
/**
* 所在校区(下拉:校区名称,选填)
*/
@ExcelProperty(value = "所在校区", index = 10)
@ColumnWidth(20)
@ExcelNotation(value = "选填,从下拉选择校区名称;留空则不写入校区")
private String campus;
/**
* 班级状态(字典下拉:xg_class_status)
*/
@ExcelProperty(value = "班级状态", index = 10, converter = ExcelDictConvert.class)
@ExcelProperty(value = "班级状态", index = 11, converter = ExcelDictConvert.class)
@ExcelDictFormat(dictType = "xg_class_status")
@NotBlank(message = "班级状态不能为空")
private String classStatus;
@@ -110,19 +118,19 @@ public class XgClassMsgImportVo implements Serializable {
/**
* 班主任(选填,填写姓名;仅唯一匹配时写入,不影响班级保存)
*/
@ExcelProperty(value = "班主任", index = 11)
@ExcelProperty(value = "班主任", index = 12)
@ExcelNotation(value = "选填,填写姓名。匹配到唯一人员才写入;未匹配或多人同名则不写入,不影响班级导入")
private String classTeacherName;
/**
* 班级描述
*/
@ExcelProperty(value = "班级描述", index = 12)
@ExcelProperty(value = "班级描述", index = 13)
private String classDescription;
/**
* 备注信息
*/
@ExcelProperty(value = "备注信息", index = 13)
@ExcelProperty(value = "备注信息", index = 14)
private String remark;
}
@@ -59,6 +59,17 @@ public class XgClassMsgVo implements Serializable {
@ExcelProperty(value = "专业名称")
private String majorName;
/**
* 所在校区ID
*/
private Long campusId;
/**
* 所在校区名称
*/
@ExcelProperty(value = "所在校区")
private String campusName;
/**
* 班级代码
*/
@@ -19,6 +19,7 @@ import org.dromara.common.excel.core.ExcelListener;
import org.dromara.common.excel.core.ExcelResult;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.xg.domain.XgClassMsg;
import org.dromara.xg.domain.XgDormCampus;
import org.dromara.xg.domain.XgSysDept;
import org.dromara.xg.domain.XgSysUser;
import org.dromara.xg.domain.bo.XgClassMsgBo;
@@ -26,6 +27,7 @@ import org.dromara.xg.domain.bo.XgClassTeacherCounselorRelBo;
import org.dromara.xg.domain.vo.XgClassMsgImportVo;
import org.dromara.xg.domain.vo.XgSysDeptVo;
import org.dromara.xg.mapper.XgClassMsgMapper;
import org.dromara.xg.mapper.XgDormCampusMapper;
import org.dromara.xg.mapper.XgSysDeptMapper;
import org.dromara.xg.mapper.XgSysUserMapper;
import org.dromara.xg.service.IXgClassMsgService;
@@ -50,6 +52,7 @@ public class XgClassMsgImportListener extends AnalysisEventListener<XgClassMsgIm
private final XgSysDeptMapper deptMapper;
private final XgClassMsgMapper classMsgMapper;
private final XgSysUserMapper sysUserMapper;
private final XgDormCampusMapper dormCampusMapper;
private final Long operUserId;
private int successNum = 0;
@@ -64,6 +67,7 @@ public class XgClassMsgImportListener extends AnalysisEventListener<XgClassMsgIm
this.deptMapper = SpringUtils.getBean(XgSysDeptMapper.class);
this.classMsgMapper = SpringUtils.getBean(XgClassMsgMapper.class);
this.sysUserMapper = SpringUtils.getBean(XgSysUserMapper.class);
this.dormCampusMapper = SpringUtils.getBean(XgDormCampusMapper.class);
this.operUserId = LoginHelper.getUserId();
}
@@ -127,6 +131,8 @@ public class XgClassMsgImportListener extends AnalysisEventListener<XgClassMsgIm
bo.setRemark(importVo.getRemark());
bo.setCreateBy(operUserId);
resolveAndFillCampus(bo, importVo.getCampus());
classMsgService.insertByBo(bo);
String teacherTip = bindClassTeacherIfUnique(bo.getId(), importVo.getClassTeacherName());
@@ -201,6 +207,7 @@ public class XgClassMsgImportListener extends AnalysisEventListener<XgClassMsgIm
importVo.getGrade(),
importVo.getClassStatus(),
importVo.getClassLocation(),
importVo.getCampus(),
importVo.getClassDescription(),
importVo.getRemark()
) && importVo.getEnrollmentYear() == null
@@ -208,6 +215,28 @@ public class XgClassMsgImportListener extends AnalysisEventListener<XgClassMsgIm
&& importVo.getIsGraduation() == null;
}
/**
* 按校区名称解析并写入 BO;空则跳过。
*/
private void resolveAndFillCampus(XgClassMsgBo bo, String campusNameRaw) {
String campusName = StringUtils.trim(campusNameRaw);
if (StringUtils.isBlank(campusName)) {
return;
}
LambdaQueryWrapper<XgDormCampus> wrapper = Wrappers.lambdaQuery();
wrapper.eq(XgDormCampus::getCampusName, campusName);
List<XgDormCampus> list = dormCampusMapper.selectList(wrapper);
if (list == null || list.isEmpty()) {
throw new ServiceException("校区不存在:" + campusName);
}
if (list.size() > 1) {
throw new ServiceException("校区名称不唯一:" + campusName);
}
XgDormCampus campus = list.get(0);
bo.setCampusId(campus.getId());
bo.setCampusName(campus.getCampusName());
}
private Long resolveCollegeId(String optionValue) {
Long id = parseOptionId(optionValue);
if (id != null) {
@@ -83,4 +83,13 @@ public interface IXgClassMsgService {
* @return 是否更新成功
*/
Boolean updateClassStatusBatch(Collection<Long> ids, String classStatus);
/**
* 批量更新班级所在校区
*
* @param ids 班级ID集合
* @param campusId 校区ID
* @return 是否更新成功
*/
Boolean updateCampusBatch(Collection<Long> ids, Long campusId);
}
@@ -2,8 +2,8 @@ package org.dromara.xg.service.impl;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.system.domain.SysDept;
import org.dromara.common.mybatis.core.page.PageQuery;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -16,9 +16,11 @@ import org.springframework.transaction.annotation.Transactional;
import org.dromara.xg.domain.bo.XgClassMsgBo;
import org.dromara.xg.domain.vo.XgClassMsgVo;
import org.dromara.xg.domain.XgClassMsg;
import org.dromara.xg.domain.XgDormCampus;
import org.dromara.xg.domain.XgStudentMsg;
import org.dromara.xg.domain.XgSysDept;
import org.dromara.xg.mapper.XgClassMsgMapper;
import org.dromara.xg.mapper.XgDormCampusMapper;
import org.dromara.xg.mapper.XgStudentMsgMapper;
import org.dromara.xg.mapper.XgSysDeptMapper;
import org.dromara.xg.service.IXgClassMsgService;
@@ -41,6 +43,7 @@ public class XgClassMsgServiceImpl implements IXgClassMsgService {
private final XgClassMsgMapper baseMapper;
private final XgSysDeptMapper deptMapper;
private final XgStudentMsgMapper studentMsgMapper;
private final XgDormCampusMapper dormCampusMapper;
/**
* 查询班级信息-新版
@@ -94,9 +97,25 @@ public class XgClassMsgServiceImpl implements IXgClassMsgService {
lqw.eq(StringUtils.isNotBlank(bo.getGrade()), XgClassMsg::getGrade, bo.getGrade());
lqw.eq(StringUtils.isNotBlank(bo.getClassStatus()), XgClassMsg::getClassStatus, bo.getClassStatus());
lqw.eq(bo.getClassTeacherId() != null, XgClassMsg::getClassTeacherId, bo.getClassTeacherId());
lqw.eq(bo.getCampusId() != null, XgClassMsg::getCampusId, bo.getCampusId());
return lqw;
}
/**
* 按 campusId 回填 campusName;为空则清空名称
*/
private void fillCampusFields(XgClassMsgBo bo) {
if (bo.getCampusId() == null) {
bo.setCampusName(null);
return;
}
XgDormCampus campus = dormCampusMapper.selectById(bo.getCampusId());
if (campus == null) {
throw new ServiceException("校区不存在或已删除");
}
bo.setCampusName(campus.getCampusName());
}
/**
* 新增班级信息-新版
*
@@ -106,6 +125,7 @@ public class XgClassMsgServiceImpl implements IXgClassMsgService {
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean insertByBo(XgClassMsgBo bo) {
fillCampusFields(bo);
XgClassMsg add = MapstructUtils.convert(bo, XgClassMsg.class);
validEntityBeforeSave(add);
@@ -160,6 +180,7 @@ public class XgClassMsgServiceImpl implements IXgClassMsgService {
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateByBo(XgClassMsgBo bo) {
fillCampusFields(bo);
XgClassMsg update = MapstructUtils.convert(bo, XgClassMsg.class);
validEntityBeforeSave(update);
@@ -168,6 +189,15 @@ public class XgClassMsgServiceImpl implements IXgClassMsgService {
updateWrapper.eq(XgClassMsg::getId, update.getId());
boolean result = baseMapper.update(update, updateWrapper) > 0;
// 显式同步校区字段,支持清空(绕过 update 忽略 null 策略)
if (result) {
LambdaUpdateWrapper<XgClassMsg> campusUw = Wrappers.lambdaUpdate();
campusUw.eq(XgClassMsg::getId, update.getId())
.set(XgClassMsg::getCampusId, bo.getCampusId())
.set(XgClassMsg::getCampusName, bo.getCampusName());
baseMapper.update(null, campusUw);
}
// 更新成功后,同步更新部门表信息
if (result) {
// 查询部门信息
@@ -307,4 +337,26 @@ public class XgClassMsgServiceImpl implements IXgClassMsgService {
uw.in(XgClassMsg::getId, ids).set(XgClassMsg::getClassStatus, classStatus);
return baseMapper.update(null, uw) > 0;
}
/**
* 批量更新班级所在校区
*/
@Override
public Boolean updateCampusBatch(Collection<Long> ids, Long campusId) {
if (ids == null || ids.isEmpty()) {
return false;
}
if (campusId == null) {
throw new ServiceException("校区不能为空");
}
XgDormCampus campus = dormCampusMapper.selectById(campusId);
if (campus == null) {
throw new ServiceException("校区不存在或已删除");
}
LambdaUpdateWrapper<XgClassMsg> uw = Wrappers.lambdaUpdate();
uw.in(XgClassMsg::getId, ids)
.set(XgClassMsg::getCampusId, campus.getId())
.set(XgClassMsg::getCampusName, campus.getCampusName());
return baseMapper.update(null, uw) > 0;
}
}
@@ -10,6 +10,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="collegeName" column="college_name"/>
<result property="majorId" column="major_id"/>
<result property="majorName" column="major_name"/>
<result property="campusId" column="campus_id"/>
<result property="campusName" column="campus_name"/>
<result property="classCode" column="class_code"/>
<result property="className" column="class_name"/>
<result property="classType" column="class_type"/>
@@ -65,6 +67,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectXgClassMsgVo">
select c.id, c.college_id, dc.dept_name as college_name, c.major_id, dm.dept_name as major_name,
c.campus_id, c.campus_name,
c.class_code, c.class_name, c.class_type, c.grade, c.enrollment_year,c.school_system, c.graduation_year,
c.training_type, c.degree_type, c.counselor_id, c.counselor_name, c.class_teacher_id,
c.class_teacher_name, c.class_advisor_id, c.class_advisor_name, c.class_monitor_id,
@@ -0,0 +1,58 @@
# 班级信息「所在校区」Implementation Plan
> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为班级信息增加选填「所在校区」,支持列表筛选、单条维护、批量设置、导入模板下拉与导入解析。
**Architecture:** `xg_class_msg` 增加 `campus_id` + `campus_name`;增改/批量由服务端按 `xg_dorm_campus` 回填名称;导入按校区名称解析;前端复用 `listDormCampus`。
**Tech Stack:** Spring Boot 3 + MyBatis-Plus、Vue 3 + Element Plus、FastExcel 下拉
**Spec:** `docs/superpowers/specs/2026-09-01-class-msg-campus-design.md`
---
## File map
| File | Responsibility |
|------|----------------|
| `docs/数据库脚本/2026-09-01-xg_class_msg-add-campus.sql` | 加列+索引 |
| `XgClassMsg.java` / Bo / Vo / ImportVo | 字段与 Excel 列 |
| `XgClassCampusBatchBo.java` | 批量请求体 |
| `XgClassMsgController` / Service / Impl | 筛选、回填、batch、模板下拉 |
| `XgClassMsgImportListener` | 导入解析校区 |
| `XgClassMsgMapper.xml` | resultMap + select 补字段 |
| `web/.../classMsg/types.ts` + `index.ts` | 类型与 batch API |
| `web/.../classMsg/index.vue` | 搜索/列/表单/批量弹窗 |
---
### Task 1: 数据库
- [ ] 编写并(在可连库时)执行增量 SQL
### Task 2: 后端领域模型 + Mapper
- [ ] Entity/Bo/Vo 加 `campusId`/`campusName`;Vo 导出 Excel 列「所在校区」
- [ ] ImportVo 插入「所在校区」列并顺延后续 index
- [ ] Mapper.xml 补字段
### Task 3: 服务与接口
- [ ] `buildQueryWrapper` 支持 `campusId`
- [ ] insert/update 前校验并回填 `campusName`(空则清空)
- [ ] `updateCampusBatch` + Controller `PUT /campus/batch`
- [ ] importTemplate 增加校区下拉
### Task 4: 导入监听
- [ ] 按校区名称解析;空跳过;0/多匹配失败
### Task 5: 前端
- [ ] types + `updateClassCampusBatch`
- [ ] 搜索、表格列、表单、批量弹窗
### Task 6: 文档
- [ ] 更新 spec 状态为已实现;按需补业务/API 说明
@@ -0,0 +1,199 @@
# 班级信息增加「所在校区」
> 日期:2026-09-01
> 状态:已实现(待库表执行 SQL 后验收)
> 方案:A — 班级表直接挂 `campus_id` + `campus_name`
> 范围:Web 班级信息模块 + 后端 `classMsg` + 导入模板/导入;不改移动端
---
## 1. 背景与目标
### 1.1 问题
班级信息(`xg_class_msg`)目前无校区属性。业务需要标识班级所属校区,数据来源于【校区信息】(`xg_dorm_campus`),并支持历史数据逐步补齐。
### 1.2 目标
1. 班级增加「所在校区」(选填)
2. 列表可按校区筛选;表格展示校区名称
3. 新增/修改可选择校区
4. 列表多选后「批量设置校区」
5. 导入模板与导入支持校区(下拉 + 按名称解析)
### 1.3 已确认产品决策
| 项 | 决策 |
|----|------|
| 必填性 | 选填,允许为空 |
| 存储方案 | A:`campus_id` + `campus_name` 冗余(对齐学院/专业) |
| 批量设置 | 列表多选 → 按钮 → 弹窗选一个校区统一写入 |
| 导入识别 | 模板下拉填校区名称;导入按名称解析为 ID |
| 列表筛选 | 搜索区增加校区下拉 |
| 历史补齐 | 主要靠批量设置;导入仅服务新建班级 |
---
## 2. 数据模型
表 `xg_class_msg` 新增:
| 字段 | 类型 | 可空 | 说明 |
|------|------|------|------|
| `campus_id` | `bigint(20)` | YES | 关联 `xg_dorm_campus.id` |
| `campus_name` | `varchar(100)` | YES | 冗余校区名称 |
- 索引:`KEY idx_campus_id (campus_id)`
- **不建数据库外键**(与学院/专业一致)
- 校区改名/删除:本期不做级联同步;班级保留已写入值;筛选仍按 `campus_id`
同步改动的类型:
- 后端:`XgClassMsg`、`XgClassMsgBo`、`XgClassMsgVo`、`XgClassMsgImportVo`
- 前端:`ClassMsgVO` / `ClassMsgForm` / `ClassMsgQuery`(`web/src/api/xg/xsjcxx/classMsg/...`)
增量 SQL 放入 `docs/数据库脚本/`(文件名建议 `2026-09-01-xg_class_msg-add-campus.sql`),实现时同步更新 `docs/设计文档/database/` 中班级表说明(若有)。
---
## 3. 接口设计
### 3.1 扩展现有接口
| 方法 | 路径 | 变化 |
|------|------|------|
| GET | `/classMsg/classMsg/list` | Query 支持 `campusId`;行数据含 `campusId`/`campusName` |
| GET | `/classMsg/classMsg/{id}` | 详情含校区字段 |
| POST | `/classMsg/classMsg` | Body 可选 `campusId`;服务端校验存在后回填 `campusName` |
| PUT | `/classMsg/classMsg` | 同上;允许将校区改为空(清空) |
| POST | `/classMsg/classMsg/export` | 导出列增加「所在校区」(`campusName`) |
| POST | `/classMsg/classMsg/importTemplate` | 模板增加「所在校区」下拉 |
| POST | `/classMsg/classMsg/importData` | 解析并写入校区 |
单条增改校区规则:
- `campusId` 为空:`campus_id`、`campus_name` 置空
- `campusId` 有值:必须在 `xg_dorm_campus` 且未删除;用该记录的 `campus_name` 覆盖冗余字段(不信任前端传的 name)
### 3.2 新增批量接口
```
PUT /classMsg/classMsg/campus/batch
```
请求体:
```json
{
"ids": [1, 2, 3],
"campusId": 1946815553291304962
}
```
| 字段 | 校验 |
|------|------|
| `ids` | 非空 |
| `campusId` | 非空;必须存在于 `xg_dorm_campus`(未删除) |
行为:将选中班级的 `campus_id`、`campus_name` 统一更新为该校区。
权限:`classMsg:classMsg:edit`(与现有批量状态一致,当前控制器上部分权限注解可能被注释,实现时保持与同模块其他写接口同一风格)。
日志:`@Log` 业务类型 UPDATE,标题「班级信息-新版」。
BO 建议:`XgClassCampusBatchBo`(对齐 `XgClassStatusBatchBo`)。
校区选项:前端继续使用 `GET /xqxx/dormCampus/list`,不新增选项 API。
---
## 4. 前端设计
页面:`web/src/views/xg/xsjcxx/classmsg/classMsg/index.vue`
| 区域 | 改动 |
|------|------|
| 搜索区 | 「所在校区」`el-select`,选项来自 `listDormCampus`,`queryParams.campusId` |
| 工具栏 | 「批量设置校区」按钮;未勾选行时禁用(与毕业/暂停同用 `:disabled="multiple"`);`v-hasPermi="['classMsg:classMsg:edit']"` |
| 表格 | 列「所在校区」→ `campusName` |
| 新增/修改弹窗 | 「所在校区」下拉,选填;变更时同步 `form.campusId` / `form.campusName` |
| 批量弹窗 | 仅校区下拉(必选)+ 确认;调用 `updateClassCampusBatch` |
API 封装:在 `classMsg/index.ts` 增加 `updateClassCampusBatch`,指向 `/classMsg/classMsg/campus/batch`。
交互:成功后提示并 `getList()`,对齐批量毕业/暂停。
---
## 5. 导入 / 模板规则
### 5.1 模板
- `XgClassMsgImportVo` 增加字段「所在校区」(选填,无 `@NotBlank`)
- 列顺序:插在「班级教室位置」之后;其后「班级状态 / 班主任 / 班级描述 / 备注」index 依次 +1
- `buildImportDropDownOptions`:加载全部有效校区名称,注册为该列下拉
- 示例行可不填校区;若填写须为真实可解析的校区名
### 5.2 导入解析(`XgClassMsgImportListener`)
| 情况 | 行为 |
|------|------|
| 单元格为空 | 不设置校区,班级仍可导入成功 |
| 名称唯一匹配到未删除校区 | 写入 `campusId` + `campusName` |
| 名称不存在 / 已删除 | **该行失败**,提示「校区不存在:xxx」 |
| 重名校区(极端) | 该行失败,提示「校区名称不唯一:xxx」 |
说明:现有导入为**仅新增**;班级名重复则失败。历史班级补校区依赖批量设置,**本期不做导入更新已有班级**。
---
## 6. 主要改动文件(预估)
**数据库**
- 增量 SQL:`xg_class_msg` 加列 + 索引
**后端**
- `XgClassMsg.java` / `XgClassMsgBo` / `XgClassMsgVo` / `XgClassMsgImportVo`
- `XgClassCampusBatchBo`(新建)
- `XgClassMsgController`:list/export/importTemplate/importData + `/campus/batch`
- `IXgClassMsgService` / `XgClassMsgServiceImpl`:查询条件、回填名称、批量更新
- `XgClassMsgImportListener`:校区解析
- `XgClassMsgMapper.xml`:若自定义列清单需补字段
**前端**
- `api/.../classMsg/types.ts`、`classMsg/index.ts`
- `views/.../classMsg/index.vue`
**文档**
- 本 spec;实现后按项目规范同步 API/业务模块文档(若有班级信息专档)
---
## 7. 不在本期
- 批量清空校区
- 校区改名/删除后同步班级冗余名
- 导入更新已有班级
- 移动端班级相关页面
- 数据库物理外键
---
## 8. 验收要点
1. 新增班级可不选校区;可选校区后列表显示名称
2. 搜索按校区过滤正确(含「空校区」班级不被误筛入有校区条件)
3. 多选批量设置后,选中行 `campusId`/`campusName` 一致更新
4. 下载模板「所在校区」为下拉;导入空校区成功;错误校区名行失败且有明确提示
5. 导出含所在校区列
6. 修改班级时可更换或清空校区
---
## 9. 测试建议
- 单测/手工:批量接口非法 `campusId`、空 `ids`
- 手工:导入模板下拉可选到西溪/临安等现有校区
- 回归:原有批量毕业/暂停/解散、学院专业导入联动不受影响
+33 -6
View File
@@ -2,11 +2,32 @@
**模块名称**:classMsg / 班级信息
**基础路径**:`/classMsg/classMsg`
**更新时间**:2026-07-22
**更新时间**:2026-09-01
## 模块功能
班级基础信息的增删改查、状态批量变更,以及 Excel 批量导入(模板含字典下拉、学院-专业联动下拉)。
班级基础信息的增删改查、状态批量变更、所在校区维护(含批量设置),以及 Excel 批量导入(模板含字典下拉、学院-专业联动下拉、校区下拉)。
## 所在校区(2026-09-01)
- 数据字段:`campusId` / `campusName`(选填,关联【校区信息】`xg_dorm_campus`)
- 列表查询支持 `campusId` 筛选
- 新增/修改可选择或清空校区;服务端按校区 ID 回填名称
- 历史班级补齐:列表多选 →「批量设置校区」
### 批量设置校区
- **URL**:`PUT /classMsg/classMsg/campus/batch`
- **Body**:
```json
{
"ids": [1, 2, 3],
"campusId": 1946815553291304962
}
```
- **说明**:`ids`、`campusId` 均必填;校区须存在且未删除;统一写入 `campus_id` + `campus_name`
## 导入相关接口
@@ -28,6 +49,7 @@
| 毕业年份 | 公式自动计算 | Excel 公式:`入学年份+学制`;导入时同样按此规则计算 |
| 是否已毕业 | 字典下拉 | `is_graduation`(未毕业/已毕业) |
| 班级教室位置 | 文本 | 可选 |
| 所在校区 | 下拉 | 选填;选项来自【校区信息】校区名称;按名称解析写入 |
| 班级状态 | 字典下拉 | `xg_class_status`(正常/暂停/毕业/解散) |
| 班主任 | 文本(姓名) | 选填;按教职工姓名精确匹配。唯一命中才写入;未匹配或多人同名则跳过,**不影响班级保存** |
| 班级描述 | 文本 | 可选 |
@@ -35,8 +57,10 @@
- **注意**:
- 模板首行示例数据以「示例」开头,导入时会自动跳过
- 学院、专业、字典项请务必从下拉框选择,避免手填错误
- 学院、专业、校区、字典项请务必从下拉框选择,避免手填错误
- 所在校区留空则不写入;名称不存在或不唯一则该行失败
- 班主任匹配失败不会导致该行班级导入失败,可事后在列表中单独设置
- 导入为仅新增;历史班级补校区请用「批量设置校区」
### 2. 导入班级数据
@@ -50,8 +74,9 @@
2. 字典中文标签自动转换为字典值
3. 班级名称作为 `classCode` / `className`,重复则该行失败
4. 毕业年份按 `入学年份 + 学制` 自动计算
5. 调用既有新增逻辑,同步创建对应 `sys_dept` 班级节点
6. 班主任按姓名匹配有效教职工:0 人或多人 → 不写班主任;1 人 → 写入并同步关联/角色
5. 可选解析所在校区名称并写入 `campusId`/`campusName`
6. 调用既有新增逻辑,同步创建对应 `sys_dept` 班级节点
7. 班主任按姓名匹配有效教职工:0 人或多人 → 不写班主任;1 人 → 写入并同步关联/角色
## 其他已有接口(摘要)
@@ -64,10 +89,12 @@
| 删除 | DELETE | `/classMsg/classMsg/{ids}` |
| 更新状态 | PUT | `/classMsg/classMsg/status` |
| 批量更新状态 | PUT | `/classMsg/classMsg/status/batch` |
| 批量设置校区 | PUT | `/classMsg/classMsg/campus/batch` |
| 导出 | POST | `/classMsg/classMsg/export` |
| 我作为班主任的班级 | GET | `/classMsg/classMsg/myClassListAsTeacher` |
## 前端入口
- 页面:`web/src/views/xg/xsjcxx/classmsg/classMsg/index.vue`
- 操作:工具栏「导入」→ 下载模板 / 上传 Excel
- 操作:工具栏「导入」→ 下载模板 / 上传 Excel;「批量设置校区」
- 数据库脚本:`docs/数据库脚本/2026-09-01-xg_class_msg-add-campus.sql`
@@ -0,0 +1,7 @@
-- 班级信息增加所在校区字段
-- 2026-09-01
ALTER TABLE `xg_class_msg`
ADD COLUMN `campus_id` bigint(20) DEFAULT NULL COMMENT '所在校区ID,关联xg_dorm_campus.id' AFTER `major_name`,
ADD COLUMN `campus_name` varchar(100) DEFAULT NULL COMMENT '所在校区名称' AFTER `campus_id`,
ADD KEY `idx_campus_id` (`campus_id`);
@@ -0,0 +1,228 @@
-- ============================================================
-- 班级信息:按班级名称批量设置所在校区
-- 日期:2026-09-01
-- 依赖:先执行 2026-09-01-xg_class_msg-add-campus.sql
-- 说明:
-- 1. 按 class_name 精确匹配(含「无人机 261」等带空格名称)
-- 2. 校区 ID 从 xg_dorm_campus 按名称解析,不写死雪花 ID
-- 3. 执行前可用下方「预览」语句核对命中条数与未命中班级
-- ============================================================
-- ---------- 预览:西溪校区将命中多少条 ----------
-- SELECT id, class_name, campus_id, campus_name
-- FROM xg_class_msg
-- WHERE deleted = 0
-- AND class_name IN (
-- '美术设计241','美术设计242','美术设计251','电气261','电气263','电气264',
-- '机器人261','无人机 261','数字制造261','智能制造261','汽修261','机电261','机电263',
-- '中药261','中药263','药剂261','生物制药261','健康管理261','园林技术261','园林技术263',
-- '环境设计261','农业技术261','西餐运营261','西餐运营263','西餐运营264','中烹261','中烹263',
-- '中西点261','中西点263','数字媒体261','数字媒体263','人工智能261','电商261','电商263','电商264','美术设计261',
-- '电气211','无人机221','无人机222','电气241','无人机241','电气221','电气231','无人机231',
-- '大数据231','大数据241','机器人241','电气251','机器人251','无人机 251','大数据251',
-- '药剂211','药剂222','药剂223','医药物流221','药剂242','药剂243','药品营销241',
-- '药剂221','药剂231','药剂232','医药物流231','药剂241','药剂251','药剂252','药剂253','药品营销251','健康管理251',
-- '西餐运营211','烹饪221','西餐运营222','西餐运营241','中烹241','中西点241',
-- '西餐运营221','西餐运营231','西餐运营232','中烹231','中西点231','中西点232',
-- '西餐运营242','中烹242','西餐运营251','西餐运营252','中烹251','中烹252','中西点251','中西点252',
-- '电子商务242','数字媒体241','电商231','电商232','电子商务241','人工智能241',
-- '数字媒体251','人工智能251','网络安全251','电商251','电商252'
-- );
-- ---------- 预览:临安校区将命中多少条 ----------
-- SELECT id, class_name, campus_id, campus_name
-- FROM xg_class_msg
-- WHERE deleted = 0
-- AND class_name IN (
-- '数字制造211','汽修221','数控221','数控241','数字制造221','数控231','数字制造231',
-- '汽修231','汽修232','数字制造241','汽修241','数字制造251','智能制造251','汽修251','产品检测251',
-- '中药211','中药221','中药231','中药232','中药241','中药251','中药252',
-- '园林211','园林工程221','园林设计221','园林技术241','园林设计241','园林技术221',
-- '园林工程231','园林设计231','园林技术231','园林工程241','园林技术251','园林技术252',
-- '园林工程251','环境设计251'
-- );
-- ---------- 预览:名单中库里不存在的班级名(两边合并后左连接检查)----------
-- 可将下方更新用的名单拆出核对;有结果说明名称不一致(空格/别名等)
START TRANSACTION;
-- 1) 西溪校区
UPDATE xg_class_msg c
INNER JOIN xg_dorm_campus camp
ON camp.campus_name = '西溪校区'
AND camp.deleted = '0'
SET
c.campus_id = camp.id,
c.campus_name = camp.campus_name,
c.update_time = NOW()
WHERE c.deleted = 0
AND c.class_name IN (
-- 261 / 美术设计等
'美术设计241',
'美术设计242',
'美术设计251',
'电气261',
'电气263',
'电气264',
'机器人261',
'无人机 261',
'数字制造261',
'智能制造261',
'汽修261',
'机电261',
'机电263',
'中药261',
'中药263',
'药剂261',
'生物制药261',
'健康管理261',
'园林技术261',
'园林技术263',
'环境设计261',
'农业技术261',
'西餐运营261',
'西餐运营263',
'西餐运营264',
'中烹261',
'中烹263',
'中西点261',
'中西点263',
'数字媒体261',
'数字媒体263',
'人工智能261',
'电商261',
'电商263',
'电商264',
'美术设计261',
-- 电气 / 无人机 / 大数据 / 机器人
'电气211',
'无人机221',
'无人机222',
'电气241',
'无人机241',
'电气221',
'电气231',
'无人机231',
'大数据231',
'大数据241',
'机器人241',
'电气251',
'机器人251',
'无人机 251',
'大数据251',
-- 药剂 / 医药物流 / 健康管理
'药剂211',
'药剂222',
'药剂223',
'医药物流221',
'药剂242',
'药剂243',
'药品营销241',
'药剂221',
'药剂231',
'药剂232',
'医药物流231',
'药剂241',
'药剂251',
'药剂252',
'药剂253',
'药品营销251',
'健康管理251',
-- 西餐 / 中烹 / 中西点
'西餐运营211',
'烹饪221',
'西餐运营222',
'西餐运营241',
'中烹241',
'中西点241',
'西餐运营221',
'西餐运营231',
'西餐运营232',
'中烹231',
'中西点231',
'中西点232',
'西餐运营242',
'中烹242',
'西餐运营251',
'西餐运营252',
'中烹251',
'中烹252',
'中西点251',
'中西点252',
-- 电商 / 数字媒体 / 人工智能
'电子商务242',
'数字媒体241',
'电商231',
'电商232',
'电子商务241',
'人工智能241',
'数字媒体251',
'人工智能251',
'网络安全251',
'电商251',
'电商252'
);
-- 2) 临安校区
UPDATE xg_class_msg c
INNER JOIN xg_dorm_campus camp
ON camp.campus_name = '临安校区'
AND camp.deleted = '0'
SET
c.campus_id = camp.id,
c.campus_name = camp.campus_name,
c.update_time = NOW()
WHERE c.deleted = 0
AND c.class_name IN (
-- 数字制造 / 汽修 / 数控
'数字制造211',
'汽修221',
'数控221',
'数控241',
'数字制造221',
'数控231',
'数字制造231',
'汽修231',
'汽修232',
'数字制造241',
'汽修241',
'数字制造251',
'智能制造251',
'汽修251',
'产品检测251',
-- 中药
'中药211',
'中药221',
'中药231',
'中药232',
'中药241',
'中药251',
'中药252',
-- 园林 / 环境设计
'园林211',
'园林工程221',
'园林设计221',
'园林技术241',
'园林设计241',
'园林技术221',
'园林工程231',
'园林设计231',
'园林技术231',
'园林工程241',
'园林技术251',
'园林技术252',
'园林工程251',
'环境设计251'
);
-- 核对更新结果(按校区汇总)
SELECT campus_name, COUNT(*) AS cnt
FROM xg_class_msg
WHERE deleted = 0
AND campus_name IN ('西溪校区', '临安校区')
GROUP BY campus_name;
-- 确认无误后提交;有问题则 ROLLBACK
COMMIT;
-- ROLLBACK;
@@ -0,0 +1,81 @@
-- ============================================================
-- 补齐未命中的 8 个西溪校区班级(名称变体)
-- 原名单未命中:
-- 无人机 261 / 无人机 251(多半库中无空格)
-- 电商261/263/264/251/252(多半是「电子商务xxx」)
-- 西餐运营211(需先确认实际名称)
-- ============================================================
-- ---------- 1) 先查实际名称(务必先跑)----------
SELECT id, class_name, campus_id, campus_name
FROM xg_class_msg
WHERE deleted = 0
AND (
class_name IN (
'无人机261', '无人机 261',
'无人机251', '无人机 251',
'电商261', '电子商务261',
'电商263', '电子商务263',
'电商264', '电子商务264',
'电商251', '电子商务251',
'电商252', '电子商务252',
'西餐运营211'
)
OR class_name LIKE '无人机%261%'
OR class_name LIKE '无人机%251%'
OR class_name LIKE '%电商%261%'
OR class_name LIKE '%电商%263%'
OR class_name LIKE '%电商%264%'
OR class_name LIKE '%电商%251%'
OR class_name LIKE '%电商%252%'
OR class_name LIKE '西餐%211%'
)
ORDER BY class_name;
START TRANSACTION;
-- ---------- 2) 按常见变体补设为西溪校区 ----------
UPDATE xg_class_msg c
INNER JOIN xg_dorm_campus camp
ON camp.campus_name = '西溪校区'
AND camp.deleted = '0'
SET
c.campus_id = camp.id,
c.campus_name = camp.campus_name,
c.update_time = NOW()
WHERE c.deleted = 0
AND (
-- 去空格
c.class_name IN ('无人机261', '无人机251')
-- 电商 → 电子商务
OR c.class_name IN (
'电子商务261', '电子商务263', '电子商务264',
'电子商务251', '电子商务252'
)
-- 若库中本来就是「电商xxx」但上次因其他原因未更新,再兜底一次
OR c.class_name IN (
'电商261', '电商263', '电商264', '电商251', '电商252',
'无人机 261', '无人机 251', '西餐运营211'
)
);
-- 核对这批是否已挂上西溪
SELECT id, class_name, campus_name
FROM xg_class_msg
WHERE deleted = 0
AND class_name IN (
'无人机261', '无人机 261', '无人机251', '无人机 251',
'电商261', '电子商务261', '电商263', '电子商务263',
'电商264', '电子商务264', '电商251', '电子商务251',
'电商252', '电子商务252', '西餐运营211'
);
-- 西溪应接近 99(若 8 个变体都找到);临安仍为 36
SELECT campus_name, COUNT(*) AS cnt
FROM xg_class_msg
WHERE deleted = 0
AND campus_name IN ('西溪校区', '临安校区')
GROUP BY campus_name;
COMMIT;
-- ROLLBACK;
@@ -0,0 +1,194 @@
-- =============================================================================
-- 教务同步视图生产验证脚本
-- 用途:部署 vw_jw_student_export / vw_jw_student_change_export 后执行
--
-- 用法:
-- mysql -h <host> -u root -p hzdyjs_xg < docs/数据库脚本/2026-09-02-verify-jw-export-views.sql
--
-- 说明:
-- * 脚本仅 SELECT,不修改数据
-- * 若某段报错,说明对应视图或依赖表/字段异常
-- * 「培养层次疑似中文」> 0 表示仍返回中文名称,需核对视图是否已更新
-- =============================================================================
SET NAMES utf8mb4;
SELECT '========== 1. 视图是否存在 ==========' AS section;
SELECT
TABLE_NAME AS view_name,
TABLE_TYPE AS object_type
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('vw_jw_student_export', 'vw_jw_student_change_export')
ORDER BY TABLE_NAME;
SELECT '========== 2. 依赖:班级校区字段是否存在 ==========' AS section;
SELECT
COLUMN_NAME,
COLUMN_TYPE,
COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'xg_class_msg'
AND COLUMN_NAME IN ('campus_id', 'campus_name')
ORDER BY ORDINAL_POSITION;
SELECT '========== 3. 基础数据量对比 ==========' AS section;
SELECT 'xg_student_msg(未删除)' AS metric, COUNT(*) AS cnt
FROM xg_student_msg WHERE deleted = 0
UNION ALL
SELECT 'vw_jw_student_export', COUNT(*) FROM vw_jw_student_export
UNION ALL
SELECT 'vw_jw_student_change_export(全部)', COUNT(*) FROM vw_jw_student_change_export
UNION ALL
SELECT CONCAT('vw_jw_student_change_export(', YEAR(CURDATE()), '年)'),
COUNT(*)
FROM vw_jw_student_change_export
WHERE YEAR(`异动生效时间`) = YEAR(CURDATE());
SELECT '========== 4. vw_jw_student_export 关键字段填充率 ==========' AS section;
SELECT
COUNT(*) AS total,
SUM(`学号` IS NOT NULL AND TRIM(`学号`) <> '') AS has_student_no,
SUM(`班级信息` IS NOT NULL AND TRIM(`班级信息`) <> '') AS has_class,
SUM(`所在校区` IS NOT NULL AND TRIM(`所在校区`) <> '') AS has_campus,
SUM(`系部信息` IS NOT NULL AND TRIM(`系部信息`) <> '') AS has_college,
SUM(`专业信息` IS NOT NULL AND TRIM(`专业信息`) <> '') AS has_major,
SUM(`培养层次` IS NOT NULL AND TRIM(`培养层次`) <> '') AS has_pycc,
SUM(`培养层次` REGEXP '^[0-5]$') AS pycc_dict_value_ok,
SUM(
`培养层次` IS NOT NULL
AND TRIM(`培养层次`) <> ''
AND `培养层次` NOT REGEXP '^[0-5]$'
) AS pycc_maybe_chinese_name
FROM vw_jw_student_export;
SELECT '========== 5. vw_jw_student_export 培养层次分布(应为 0~5 字典值)==========' AS section;
SELECT
COALESCE(`培养层次`, '(null)') AS pycc_value,
COUNT(*) AS cnt
FROM vw_jw_student_export
GROUP BY `培养层次`
ORDER BY cnt DESC
LIMIT 20;
SELECT '========== 6. vw_jw_student_export 所在校区分布(Top 10)==========' AS section;
SELECT
COALESCE(`所在校区`, '(null)') AS campus,
COUNT(*) AS cnt
FROM vw_jw_student_export
GROUP BY `所在校区`
ORDER BY cnt DESC
LIMIT 10;
SELECT '========== 7. vw_jw_student_export 样例(3 条,含组织信息)==========' AS section;
SELECT
`学号`, `姓名`, `班级信息`, `所在校区`, `系部信息`, `专业信息`, `培养层次`, `学制`, `学生当前状态`
FROM vw_jw_student_export
ORDER BY `学号`
LIMIT 3;
SELECT '========== 8. vw_jw_student_change_export 关键字段填充率 ==========' AS section;
SELECT
COUNT(*) AS total,
SUM(`异动类型` IS NOT NULL AND TRIM(`异动类型`) <> '') AS has_change_type,
SUM(`异动前所在院系` IS NOT NULL AND TRIM(`异动前所在院系`) <> '') AS has_before_college,
SUM(`异动前所在专业` IS NOT NULL AND TRIM(`异动前所在专业`) <> '') AS has_before_major,
SUM(`异动前所在班级` IS NOT NULL AND TRIM(`异动前所在班级`) <> '') AS has_before_class,
SUM(`异动前所在校区` IS NOT NULL AND TRIM(`异动前所在校区`) <> '') AS has_before_campus,
SUM(`异动后所在院系` IS NOT NULL AND TRIM(`异动后所在院系`) <> '') AS has_after_college,
SUM(`异动后所在专业` IS NOT NULL AND TRIM(`异动后所在专业`) <> '') AS has_after_major,
SUM(`异动后所在班级` IS NOT NULL AND TRIM(`异动后所在班级`) <> '') AS has_after_class,
SUM(`异动后所在校区` IS NOT NULL AND TRIM(`异动后所在校区`) <> '') AS has_after_campus
FROM vw_jw_student_change_export;
SELECT '========== 9. vw_jw_student_change_export 异动类型分布 ==========' AS section;
SELECT
`异动类型`,
COUNT(*) AS cnt
FROM vw_jw_student_change_export
GROUP BY `异动类型`
ORDER BY cnt DESC;
SELECT '========== 10. vw_jw_student_change_export 当年记录数(按年)==========' AS section;
SELECT
YEAR(`异动生效时间`) AS effect_year,
COUNT(*) AS cnt
FROM vw_jw_student_change_export
GROUP BY YEAR(`异动生效时间`)
ORDER BY effect_year DESC
LIMIT 10;
SELECT '========== 11. vw_jw_student_change_export 样例(3 条)==========' AS section;
SELECT
`提交时间`, `异动生效时间`, `异动类型`, `申请人学号`, `姓名`,
`异动前所在校区`, `异动前所在院系`, `异动前所在专业`, `异动前所在班级`,
`异动后所在校区`, `异动后所在院系`, `异动后所在专业`, `异动后所在班级`
FROM vw_jw_student_change_export
ORDER BY `异动生效时间` DESC
LIMIT 3;
SELECT '========== 12. jw_cx 账号视图权限(可选)==========' AS section;
SELECT
GRANTEE,
TABLE_NAME,
PRIVILEGE_TYPE
FROM information_schema.TABLE_PRIVILEGES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('vw_jw_student_export', 'vw_jw_student_change_export')
AND GRANTEE LIKE '%jw_cx%'
ORDER BY TABLE_NAME, PRIVILEGE_TYPE;
SELECT '========== 13. 快速结论(人工查看)==========' AS section;
SELECT
CASE
WHEN (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('vw_jw_student_export', 'vw_jw_student_change_export')
) = 2 THEN 'PASS'
ELSE 'FAIL'
END AS views_exist,
CASE
WHEN (
SELECT COUNT(*) FROM vw_jw_student_export LIMIT 1
) >= 0 THEN 'PASS'
ELSE 'FAIL'
END AS student_export_queryable,
CASE
WHEN (
SELECT COUNT(*) FROM vw_jw_student_change_export LIMIT 1
) >= 0 THEN 'PASS'
ELSE 'FAIL'
END AS change_export_queryable,
CASE
WHEN (
SELECT COUNT(*) FROM vw_jw_student_export
WHERE `培养层次` IS NOT NULL
AND TRIM(`培养层次`) <> ''
AND `培养层次` NOT REGEXP '^[0-5]$'
) = 0 THEN 'PASS'
ELSE 'WARN'
END AS pycc_is_dict_value,
CASE
WHEN (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'xg_class_msg'
AND COLUMN_NAME = 'campus_id'
) = 1 THEN 'PASS'
ELSE 'FAIL'
END AS class_campus_column_exists;
@@ -0,0 +1,6 @@
-- 学生异动:院系/专业改从 sys_dept 获取,校区改从班级 campus_id 获取
-- 与学生基本信息接口 vw_jw_student_export 口径对齐
-- 执行后无需重启 API,视图查询即时生效
--
-- 在项目根目录执行:
-- mysql -h <host> -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_change_export.sql
@@ -0,0 +1,6 @@
-- 学生所在校区改为从班级信息获取(xg_class_msg.campus_id / campus_name)
-- 不再通过宿舍分配(xg_dorm_bed_student)推断校区
-- 执行后无需重启 API,视图查询即时生效
--
-- 在项目根目录执行:
-- mysql -h <host> -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_export.sql
@@ -0,0 +1,6 @@
-- 系部/专业名称改为优先通过 college_id、major_id 关联 sys_dept.dept_name 获取
-- 班级名称仍通过 class_id 关联 xg_class_msg.class_name
-- 执行后无需重启 API,视图查询即时生效
--
-- 在项目根目录执行:
-- mysql -h <host> -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_export.sql
@@ -0,0 +1,5 @@
-- 培养层次改为返回字典值(class_type),不再输出中文名称
-- 执行后无需重启 API,视图查询即时生效
--
-- 在项目根目录执行:
-- mysql -h <host> -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_export.sql
@@ -0,0 +1,224 @@
-- =============================================================================
-- 刷新 xg_student_msg 冗余字段:班级名称、专业名称
--
-- 背景:老师修改班级/专业后,xg_class_msg、sys_dept 已更新,
-- 但 xg_student_msg 上冗余的 class_name、major_name 仍为旧值。
--
-- 刷新口径(与 vw_jw_student_export 一致):
-- * class_name ← 关联班级 xg_class_msg.class_name(按 class_id)
-- * major_name ← 优先学生 major_id → sys_dept.dept_name
-- 其次班级 major_id → sys_dept.dept_name
-- 再回退 xg_class_msg.major_name
--
-- 用法:
-- mysql -h <host> -u root -p hzdyjs_xg < docs/数据库脚本/2026-09-02-xg_student_msg-refresh-class-major-name.sql
--
-- 建议:先执行「一、预览」确认影响范围,再执行「二、更新」
-- =============================================================================
SET NAMES utf8mb4;
-- -----------------------------------------------------------------------------
-- 一、预览:即将刷新的记录数
-- -----------------------------------------------------------------------------
SELECT '========== 1. 班级名称不一致(将刷新)==========' AS section;
SELECT COUNT(*) AS need_refresh_class_name
FROM xg_student_msg m
INNER JOIN xg_class_msg cm
ON cm.deleted = 0
AND cm.tenant_id = m.tenant_id
AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
WHERE m.deleted = 0
AND NULLIF(TRIM(m.class_id), '') IS NOT NULL
AND (
m.class_name IS NULL
OR TRIM(m.class_name) = ''
OR m.class_name <> cm.class_name
);
SELECT '========== 2. 专业名称不一致(将刷新)==========' AS section;
SELECT COUNT(*) AS need_refresh_major_name
FROM xg_student_msg m
LEFT JOIN xg_class_msg cm
ON cm.deleted = 0
AND cm.tenant_id = m.tenant_id
AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
LEFT JOIN sys_dept dm_m
ON dm_m.dept_id = CAST(NULLIF(TRIM(m.major_id), '') AS UNSIGNED)
AND dm_m.del_flag = '0'
AND dm_m.tenant_id = m.tenant_id
LEFT JOIN sys_dept dm_cm
ON dm_cm.dept_id = cm.major_id
AND dm_cm.del_flag = '0'
AND dm_cm.tenant_id = m.tenant_id
WHERE m.deleted = 0
AND COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name) IS NOT NULL
AND TRIM(COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name)) <> ''
AND (
m.major_name IS NULL
OR TRIM(m.major_name) = ''
OR m.major_name <> COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name)
);
SELECT '========== 3. 不一致样例(班级名称,最多 20 条)==========' AS section;
SELECT
m.id,
m.student_no AS 学号,
m.name AS 姓名,
m.class_id,
m.class_name AS 当前班级名称,
cm.class_name AS 新班级名称
FROM xg_student_msg m
INNER JOIN xg_class_msg cm
ON cm.deleted = 0
AND cm.tenant_id = m.tenant_id
AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
WHERE m.deleted = 0
AND NULLIF(TRIM(m.class_id), '') IS NOT NULL
AND (
m.class_name IS NULL
OR TRIM(m.class_name) = ''
OR m.class_name <> cm.class_name
)
ORDER BY m.student_no
LIMIT 20;
SELECT '========== 4. 不一致样例(专业名称,最多 20 条)==========' AS section;
SELECT
m.id,
m.student_no AS 学号,
m.name AS 姓名,
m.major_id,
m.major_name AS 当前专业名称,
COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name) AS 新专业名称
FROM xg_student_msg m
LEFT JOIN xg_class_msg cm
ON cm.deleted = 0
AND cm.tenant_id = m.tenant_id
AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
LEFT JOIN sys_dept dm_m
ON dm_m.dept_id = CAST(NULLIF(TRIM(m.major_id), '') AS UNSIGNED)
AND dm_m.del_flag = '0'
AND dm_m.tenant_id = m.tenant_id
LEFT JOIN sys_dept dm_cm
ON dm_cm.dept_id = cm.major_id
AND dm_cm.del_flag = '0'
AND dm_cm.tenant_id = m.tenant_id
WHERE m.deleted = 0
AND COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name) IS NOT NULL
AND TRIM(COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name)) <> ''
AND (
m.major_name IS NULL
OR TRIM(m.major_name) = ''
OR m.major_name <> COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name)
)
ORDER BY m.student_no
LIMIT 20;
-- -----------------------------------------------------------------------------
-- 二、执行更新(确认预览无误后再执行)
-- -----------------------------------------------------------------------------
START TRANSACTION;
-- 2.1 刷新班级名称
UPDATE xg_student_msg m
INNER JOIN xg_class_msg cm
ON cm.deleted = 0
AND cm.tenant_id = m.tenant_id
AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
SET
m.class_name = cm.class_name,
m.update_time = NOW()
WHERE m.deleted = 0
AND NULLIF(TRIM(m.class_id), '') IS NOT NULL
AND (
m.class_name IS NULL
OR TRIM(m.class_name) = ''
OR m.class_name <> cm.class_name
);
SELECT ROW_COUNT() AS updated_class_name_rows;
-- 2.2 刷新专业名称
UPDATE xg_student_msg m
LEFT JOIN xg_class_msg cm
ON cm.deleted = 0
AND cm.tenant_id = m.tenant_id
AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
LEFT JOIN sys_dept dm_m
ON dm_m.dept_id = CAST(NULLIF(TRIM(m.major_id), '') AS UNSIGNED)
AND dm_m.del_flag = '0'
AND dm_m.tenant_id = m.tenant_id
LEFT JOIN sys_dept dm_cm
ON dm_cm.dept_id = cm.major_id
AND dm_cm.del_flag = '0'
AND dm_cm.tenant_id = m.tenant_id
SET
m.major_name = COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name),
m.update_time = NOW()
WHERE m.deleted = 0
AND COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name) IS NOT NULL
AND TRIM(COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name)) <> ''
AND (
m.major_name IS NULL
OR TRIM(m.major_name) = ''
OR m.major_name <> COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name)
);
SELECT ROW_COUNT() AS updated_major_name_rows;
-- 确认无误后提交;若要撤销请执行 ROLLBACK;
COMMIT;
-- -----------------------------------------------------------------------------
-- 三、更新后复核
-- -----------------------------------------------------------------------------
SELECT '========== 5. 更新后仍不一致(应为 0 或接近 0)==========' AS section;
SELECT
(
SELECT COUNT(*)
FROM xg_student_msg m
INNER JOIN xg_class_msg cm
ON cm.deleted = 0
AND cm.tenant_id = m.tenant_id
AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
WHERE m.deleted = 0
AND NULLIF(TRIM(m.class_id), '') IS NOT NULL
AND (
m.class_name IS NULL
OR TRIM(m.class_name) = ''
OR m.class_name <> cm.class_name
)
) AS remain_class_name_mismatch,
(
SELECT COUNT(*)
FROM xg_student_msg m
LEFT JOIN xg_class_msg cm
ON cm.deleted = 0
AND cm.tenant_id = m.tenant_id
AND cm.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
LEFT JOIN sys_dept dm_m
ON dm_m.dept_id = CAST(NULLIF(TRIM(m.major_id), '') AS UNSIGNED)
AND dm_m.del_flag = '0'
AND dm_m.tenant_id = m.tenant_id
LEFT JOIN sys_dept dm_cm
ON dm_cm.dept_id = cm.major_id
AND dm_cm.del_flag = '0'
AND dm_cm.tenant_id = m.tenant_id
WHERE m.deleted = 0
AND COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name) IS NOT NULL
AND TRIM(COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name)) <> ''
AND (
m.major_name IS NULL
OR TRIM(m.major_name) = ''
OR m.major_name <> COALESCE(dm_m.dept_name, dm_cm.dept_name, cm.major_name)
)
) AS remain_major_name_mismatch;
@@ -3,7 +3,9 @@
-- 说明见:学生学籍异动同步教务系统改造方案.md
--
-- 约定:
-- * 异动前/后「校区」列为 NULL(本系统无校区字段)
-- * 异动前/后「校区」:班级 campus_id → xg_dorm_campus.campus_name,再回退班级 campus_name
-- * 异动前/后「院系/专业」:优先 college_id、major_id → sys_dept.dept_name,再回退冗余名称
-- * 异动前/后「班级」:优先 xg_class_msg.class_name,再回退冗余名称
-- * 「异动生效学期」列保留,值为 NULL(教务可接受空值)
-- * 「异动生效时间」:学籍异动 = effective_date;换班级 = effective_time(API 按年筛选)
-- * 「异动类型」:输出教务系统中文名称(见 §异动类型映射)
@@ -65,16 +67,16 @@ SELECT
(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 `异动前所在校区`,
(COALESCE(mh.college_name, m.college_name, cm_before.college_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在院系`,
(COALESCE(mh.major_name, m.major_name, cm_before.major_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在专业`,
(COALESCE(mh.class_name, m.class_name, cm_before.class_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在班级`,
(COALESCE(campus_before.campus_name, cm_before.campus_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在校区`,
(COALESCE(dc_mh.dept_name, dc_cm_before.dept_name, mh.college_name, cm_before.college_name, m.college_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在院系`,
(COALESCE(dm_mh.dept_name, dm_cm_before.dept_name, mh.major_name, cm_before.major_name, m.major_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在专业`,
(COALESCE(cm_before.class_name, mh.class_name, m.class_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在班级`,
COALESCE(cm_before.school_system, mh.school_system, m.school_system) AS `异动前学制`,
(CAST(NULL AS CHAR(64) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动后所在校区`,
(COALESCE(campus_after.campus_name, cm_after.campus_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在校区`,
(COALESCE(m.grade, cm_after.grade, mh.grade) COLLATE utf8mb4_unicode_ci) AS `异动后所在年级`,
(COALESCE(m.class_name, cm_after.class_name, mh.class_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在班级`,
(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.class_name, m.class_name, mh.class_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在班级`,
(COALESCE(dc_m.dept_name, dc_cm_after.dept_name, m.college_name, cm_after.college_name, mh.college_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在院系`,
(COALESCE(dm_m.dept_name, dm_cm_after.dept_name, 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 COLLATE utf8mb4_unicode_ci), '' COLLATE utf8mb4_unicode_ci),
@@ -124,6 +126,44 @@ 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 COLLATE utf8mb4_unicode_ci), '' COLLATE utf8mb4_unicode_ci) AS UNSIGNED)
LEFT JOIN sys_dept dc_mh
ON dc_mh.dept_id = CAST(NULLIF(TRIM(mh.college_id), '') AS UNSIGNED)
AND (dc_mh.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_mh.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_mh
ON dm_mh.dept_id = CAST(NULLIF(TRIM(mh.major_id), '') AS UNSIGNED)
AND (dm_mh.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_mh.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dc_m
ON dc_m.dept_id = CAST(NULLIF(TRIM(m.college_id), '') AS UNSIGNED)
AND (dc_m.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_m.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_m
ON dm_m.dept_id = CAST(NULLIF(TRIM(m.major_id), '') AS UNSIGNED)
AND (dm_m.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_m.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dc_cm_before
ON dc_cm_before.dept_id = cm_before.college_id
AND (dc_cm_before.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_cm_before.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_cm_before
ON dm_cm_before.dept_id = cm_before.major_id
AND (dm_cm_before.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_cm_before.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dc_cm_after
ON dc_cm_after.dept_id = cm_after.college_id
AND (dc_cm_after.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_cm_after.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_cm_after
ON dm_cm_after.dept_id = cm_after.major_id
AND (dm_cm_after.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_cm_after.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN xg_dorm_campus campus_before
ON campus_before.id = cm_before.campus_id
AND (campus_before.deleted COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
LEFT JOIN xg_dorm_campus campus_after
ON campus_after.id = cm_after.campus_id
AND (campus_after.deleted COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
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)
@@ -170,16 +210,16 @@ SELECT
(c.student_no COLLATE utf8mb4_unicode_ci) AS `申请人学号`,
(c.student_name COLLATE utf8mb4_unicode_ci) AS `姓名`,
(oc.grade COLLATE utf8mb4_unicode_ci) AS `异动前所在年级`,
(CAST(NULL AS CHAR(64) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动前所在校区`,
(c.original_college_name COLLATE utf8mb4_unicode_ci) AS `异动前所在院系`,
(c.original_major_name COLLATE utf8mb4_unicode_ci) AS `异动前所在专业`,
(c.original_class_name COLLATE utf8mb4_unicode_ci) AS `异动前所在班级`,
(COALESCE(campus_oc.campus_name, oc.campus_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在校区`,
(COALESCE(dc_oc.dept_name, dc_c_orig.dept_name, oc.college_name, c.original_college_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在院系`,
(COALESCE(dm_oc.dept_name, dm_c_orig.dept_name, oc.major_name, c.original_major_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在专业`,
(COALESCE(oc.class_name, c.original_class_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在班级`,
oc.school_system AS `异动前学制`,
(CAST(NULL AS CHAR(64) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动后所在校区`,
(COALESCE(campus_nc.campus_name, nc.campus_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在校区`,
(nc.grade COLLATE utf8mb4_unicode_ci) AS `异动后所在年级`,
(c.new_class_name COLLATE utf8mb4_unicode_ci) AS `异动后所在班级`,
(c.new_college_name COLLATE utf8mb4_unicode_ci) AS `异动后所在院系`,
(c.new_major_name COLLATE utf8mb4_unicode_ci) AS `异动后所在专业`,
(COALESCE(nc.class_name, c.new_class_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在班级`,
(COALESCE(dc_nc.dept_name, dc_c_new.dept_name, nc.college_name, c.new_college_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在院系`,
(COALESCE(dm_nc.dept_name, dm_c_new.dept_name, nc.major_name, c.new_major_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在专业`,
nc.school_system AS `异动后学制`,
(COALESCE(
NULLIF(TRIM(su2.user_no COLLATE utf8mb4_unicode_ci), '' COLLATE utf8mb4_unicode_ci),
@@ -212,6 +252,44 @@ LEFT JOIN xg_class_msg nc
ON nc.id = c.new_class_id
AND nc.deleted = 0
AND (nc.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dc_oc
ON dc_oc.dept_id = oc.college_id
AND (dc_oc.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_oc.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_oc
ON dm_oc.dept_id = oc.major_id
AND (dm_oc.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_oc.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dc_nc
ON dc_nc.dept_id = nc.college_id
AND (dc_nc.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_nc.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_nc
ON dm_nc.dept_id = nc.major_id
AND (dm_nc.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_nc.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dc_c_orig
ON dc_c_orig.dept_id = c.original_college_id
AND (dc_c_orig.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_c_orig.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_c_orig
ON dm_c_orig.dept_id = c.original_major_id
AND (dm_c_orig.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_c_orig.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dc_c_new
ON dc_c_new.dept_id = c.new_college_id
AND (dc_c_new.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_c_new.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_c_new
ON dm_c_new.dept_id = c.new_major_id
AND (dm_c_new.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_c_new.tenant_id COLLATE utf8mb4_unicode_ci) = (c.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN xg_dorm_campus campus_oc
ON campus_oc.id = oc.campus_id
AND (campus_oc.deleted COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
LEFT JOIN xg_dorm_campus campus_nc
ON campus_nc.id = nc.campus_id
AND (campus_nc.deleted COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_user su2
ON su2.user_id = nc.class_teacher_id
AND (su2.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
@@ -4,7 +4,7 @@
--
-- 口径摘要:
-- * 身份证件类型:有 id_card 则「居民身份证」,否则 NULL
-- * 所在校区:有有效宿舍分配则取 xg_dorm_campus.campus_name,否则 NULL
-- * 所在校区:学生 class_id → xg_class_msg.campus_id → xg_dorm_campus.campus_name,再回退班级冗余 campus_name
-- * 班主任工号:class_teacher_id → sys_user.user_no(空则 user_name)
-- * 学生是否在校:默认「在校」;退学(4)、毕业(5) 为「不在校」
-- * 异动类型:固定 NULL
@@ -13,6 +13,8 @@
-- * 性别:0→男,1→女
-- * 成员1/2:父亲/母亲及关系、电话
-- * 学生当前状态:输出教务中文名称(见对接说明 §状态映射)
-- * 培养层次:班级 class_type 字典值(zsjy_education_level),非中文名称
-- * 系部/专业:优先 college_id、major_id 关联 sys_dept.dept_name;班级名称取 xg_class_msg.class_name
-- =============================================================================
DROP VIEW IF EXISTS `vw_jw_student_export`;
@@ -28,12 +30,12 @@ SELECT
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(cm.class_name, m.class_name) COLLATE utf8mb4_unicode_ci) AS `班级信息`,
(COALESCE(campus_cm.campus_name, cm.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(dc_m.dept_name, dc_cm.dept_name, m.college_name, cm.college_name) COLLATE utf8mb4_unicode_ci) AS `系部信息`,
(COALESCE(dm_m.dept_name, dm_cm.dept_name, 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
@@ -56,15 +58,7 @@ SELECT
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 `培养层次`,
(cm.class_type COLLATE utf8mb4_unicode_ci) AS `培养层次`,
COALESCE(cm.school_system, m.school_system) AS `学制`,
(
CASE
@@ -159,31 +153,29 @@ 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_dept dc_m
ON dc_m.dept_id = CAST(NULLIF(TRIM(m.college_id), '') AS UNSIGNED)
AND (dc_m.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_m.tenant_id COLLATE utf8mb4_unicode_ci) = (m.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_m
ON dm_m.dept_id = CAST(NULLIF(TRIM(m.major_id), '') AS UNSIGNED)
AND (dm_m.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_m.tenant_id COLLATE utf8mb4_unicode_ci) = (m.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dc_cm
ON dc_cm.dept_id = cm.college_id
AND (dc_cm.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dc_cm.tenant_id COLLATE utf8mb4_unicode_ci) = (m.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN sys_dept dm_cm
ON dm_cm.dept_id = cm.major_id
AND (dm_cm.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
AND (dm_cm.tenant_id COLLATE utf8mb4_unicode_ci) = (m.tenant_id COLLATE utf8mb4_unicode_ci)
LEFT JOIN xg_dorm_campus campus_cm
ON campus_cm.id = cm.campus_id
AND (campus_cm.deleted COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
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,
@@ -20,12 +20,12 @@
| 5 | 申请人学号 | 直接输出 | `student_no` |
| 6 | 姓名 | 输出异动当时姓名:换班级表有 `student_name`;学籍异动可 `JOIN xg_student_msg` 取当前 `name`(若需「当时」姓名需后续增强,见 §6) | 业务表 + `xg_student_msg` |
| 7 | 异动前所在年级 | 有则输出 | 换班级:`original_`* 无年级时 JOIN 原班级 `xg_class_msg.grade`;学籍异动:见 §6 |
| 8 | 异动前所在校区 | **暂不填写,恒为空** | `NULL` |
| 9 | 异动前所在院系 | 有则输出 | 换班级 `original_college_name`;学籍异动见 §6 |
| 10 | 异动前所在专业 | 有则输出 | 换班级 `original_major_name`;学籍异动见 §6 |
| 11 | 异动前所在班级 | 有则输出 | 换班级 `original_class_name`;学籍异动见 §6 |
| 8 | 异动前所在校区 | 异动前班级 `campus_id` → `xg_dorm_campus.campus_name`,再回退 `xg_class_msg.campus_name` | `xg_class_msg` + `xg_dorm_campus` |
| 9 | 异动前所在院系 | 优先 `college_id` → `sys_dept.dept_name`,再回退冗余名称;换班级用 `original_college_id` / `original_college_name` | 换班级 `xg_student_class_change`;学籍异动见 §6 |
| 10 | 异动前所在专业 | 优先 `major_id` → `sys_dept.dept_name`,再回退冗余名称 | 同上 |
| 11 | 异动前所在班级 | 优先 `xg_class_msg.class_name`,再回退冗余名称 | 同上 |
| 12 | 异动前学制 | 有则输出 | JOIN `xg_class_msg`(原 `class_id`)取 `school_system` |
| 13~18 | 异动后所在校区 / 年级 / 班级 / 院系 / 专业 / 学制 | 校区:**空**;其余:换班级用 `new_`* + 新班 `xg_class_msg`;纯学籍异动时教务若仍要求「后」与「前」一致,可用 **COALESCE(后, 前)** 或统一填「前」快照(需与教务确认) | `xg_student_class_change` + `xg_class_msg` / 约定 |
| 13~18 | 异动后所在校区 / 年级 / 班级 / 院系 / 专业 / 学制 | 校区:异动后班级 `campus_id` → `xg_dorm_campus`;院系/专业:`sys_dept`;班级:`xg_class_msg`;换班级用 `new_*` + 新班;纯学籍异动「后」取当前学生/班级 | `xg_student_class_change` + `xg_class_msg` / `sys_dept` |
| 19 | 异动后班主任工号 | 有则输出 | `class_teacher_id` → `sys_user.user_no`(需业务确认教师工号存于 `user_no`) |
| 20 | 异动后班主任姓名 | 有则输出 | `xg_class_msg.class_teacher_name` 或 `xg_student_msg.class_teacher_name` |
| 21 | 新名字 | 选填;无独立「改名流水」时建议 **NULL**;若仅用 `former_name`/`name` 推断改名,易产生误判,不推荐作为正式口径 | 暂空或后续扩展表 |
+798408 -8
View File
File diff suppressed because it is too large. Load diff
@@ -55,6 +55,15 @@ export function updateClassStatusBatch(data: { ids: Array<string | number>; clas
});
}
// 批量设置班级所在校区
export function updateClassCampusBatch(data: { ids: Array<string | number>; campusId: string | number }) {
return request({
url: '/classMsg/classMsg/campus/batch',
method: 'put',
data
});
}
// 删除班级信息-新版
export function delClassMsg(id: string | number) {
return request({
@@ -14,6 +14,16 @@ export interface ClassMsgVO {
*/
majorId: string | number;
/**
* 所在校区ID
*/
campusId?: string | number;
/**
* 所在校区名称
*/
campusName?: string;
/**
* 班级名称
*/
@@ -76,6 +86,16 @@ export interface ClassMsgForm extends BaseEntity {
*/
majorName?: string;
/**
* 所在校区ID
*/
campusId?: string | number;
/**
* 所在校区名称
*/
campusName?: string;
/**
* 班级代码
*/
@@ -303,6 +323,11 @@ export interface ClassMsgQuery extends PageQuery {
*/
majorId?: string | number;
/**
* 所在校区ID
*/
campusId?: string | number;
/**
* 班级名称
*/
+6
View File
@@ -38,6 +38,8 @@ export interface ClassMsgQuery {
collegeId?: number | string;
/** 专业代码 */
majorId?: number | string;
/** 所在校区ID */
campusId?: number | string;
/** 班级名称 */
className?: string;
/** 班级类型 */
@@ -66,6 +68,10 @@ export interface ClassMsgVO {
majorId?: string | number;
/** 专业名称 */
majorName?: string;
/** 所在校区ID */
campusId?: string | number;
/** 所在校区名称 */
campusName?: string;
/** 班级代码 */
classCode?: string;
/** 班级名称 */
@@ -30,6 +30,15 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="所在校区" prop="campusId">
<el-select v-model="queryParams.campusId" placeholder="请选择校区" clearable filterable>
<el-option v-for="item in campusOptions" :key="item.id" :label="item.campusName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" class="search-btn-container">
<el-form-item>
@@ -91,6 +100,17 @@
>解散</el-button
>
</el-col>
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="OfficeBuilding"
:disabled="multiple"
@click="handleBatchSetCampus"
v-hasPermi="['classMsg:classMsg:edit']"
>批量设置校区</el-button
>
</el-col>
<!-- <el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['classMsg:classMsg:export']">导出</el-button>
</el-col> -->
@@ -105,6 +125,7 @@
<el-table-column label="专业代码" align="center" prop="majorId" v-if="false" />
<el-table-column label="学院名称" align="center" prop="collegeName" />
<el-table-column label="专业名称" align="center" prop="majorName" />
<el-table-column label="所在校区" align="center" prop="campusName" min-width="110" />
<el-table-column label="班级名称" align="center" prop="className" />
<el-table-column label="学历层次" align="center" prop="classType">
<template #default="scope">
@@ -185,6 +206,11 @@
<el-form-item label="专业名称" prop="majorName" v-if="false">
<el-input v-model="form.majorName" placeholder="请输入专业名称" readonly />
</el-form-item>
<el-form-item label="所在校区" prop="campusId">
<el-select v-model="form.campusId" placeholder="请选择校区(选填)" clearable filterable style="width: 100%" @change="handleFormCampusChange">
<el-option v-for="item in campusOptions" :key="item.id" :label="item.campusName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="班级代码" prop="classCode" v-if="false">
<el-input v-model="form.classCode" placeholder="请输入班级代码" />
</el-form-item>
@@ -476,6 +502,26 @@
</div>
</template>
</el-dialog>
<!-- 批量设置所在校区 -->
<el-dialog title="批量设置校区" v-model="campusBatchDialog.visible" width="420px" append-to-body>
<el-form label-width="90px">
<el-form-item label="所在校区" required>
<el-select v-model="campusBatchDialog.campusId" placeholder="请选择校区" filterable style="width: 100%">
<el-option v-for="item in campusOptions" :key="item.id" :label="item.campusName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="已选班级">
<span>{{ ids.length }} 个</span>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button :loading="buttonLoading" type="primary" @click="submitBatchSetCampus">确 定</el-button>
<el-button @click="campusBatchDialog.visible = false">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
@@ -487,10 +533,13 @@ import {
addClassMsg,
updateClassMsg,
updateClassStatus,
updateClassStatusBatch
updateClassStatusBatch,
updateClassCampusBatch
} from '@/api/xg/xsjcxx/classmsg/classMsg';
import { listColleges, collegeTree, listMajorsByCollegeId } from '@/api/xg/xsjcxx/classmsg/college';
import { ClassMsgVO, ClassMsgQuery, ClassMsgForm, SysDeptVo } from '@/api/xg/xsjcxx/classmsg/types';
import { listDormCampus } from '@/api/xg/xgss/xqxx/dormCampus';
import type { DormCampusVO } from '@/api/xg/xgss/xqxx/dormCampus/types';
import { ComponentInternalInstance, ref, reactive, toRefs, onMounted, getCurrentInstance, watch } from 'vue';
import type { FormInstance as ElFormInstance, UploadFile, UploadInstance } from 'element-plus';
import { ElMessageBox } from 'element-plus';
@@ -571,6 +620,7 @@ const collegeOptions = ref<SysDeptVo[]>([]); // 学院选项
const majorOptions = ref<SysDeptVo[]>([]); // 检索时的专业选项
const formMajorOptions = ref<SysDeptVo[]>([]); // 表单中的专业选项
const collegeTreeData = ref<SysDeptVo[]>([]); // 学院专业树形数据
const campusOptions = ref<DormCampusVO[]>([]); // 校区选项
const queryFormRef = ref<ElFormInstance>();
const classMsgFormRef = ref<ElFormInstance>();
@@ -590,12 +640,19 @@ const dialog = reactive<DialogOption>({
title: ''
});
const campusBatchDialog = reactive<{ visible: boolean; campusId?: string | number }>({
visible: false,
campusId: undefined
});
const initFormData: ClassMsgForm = {
id: undefined,
collegeId: undefined,
collegeName: undefined,
majorId: undefined,
majorName: undefined,
campusId: undefined,
campusName: undefined,
classCode: undefined,
className: undefined,
classType: '4',
@@ -648,6 +705,7 @@ const data = reactive<PageData<ClassMsgForm, ClassMsgQuery>>({
pageSize: 10,
collegeId: undefined,
majorId: undefined,
campusId: undefined,
className: undefined,
classType: undefined,
grade: undefined,
@@ -686,6 +744,16 @@ const getCollegeList = async () => {
collegeOptions.value = res.data;
};
/** 查询校区列表 */
const getCampusList = async () => {
try {
const res = await listDormCampus({ pageNum: 1, pageSize: 1000 });
campusOptions.value = (res as any).rows || res.data || [];
} catch (e) {
campusOptions.value = [];
}
};
/** 查询学院专业树形结构 */
const getCollegeTree = async () => {
const res = await collegeTree();
@@ -752,6 +820,17 @@ const handleFormMajorChange = (value: number) => {
}
};
/** 表单校区变更:同步名称 */
const handleFormCampusChange = (campusId?: string | number | null) => {
if (campusId === undefined || campusId === null || campusId === '') {
form.value.campusId = undefined;
form.value.campusName = undefined;
return;
}
const campus = campusOptions.value.find((item) => String(item.id) === String(campusId));
form.value.campusName = campus?.campusName;
};
// 监听班级名称变化,自动同步到班级代码
watch(
() => form.value.className,
@@ -977,6 +1056,33 @@ const handleBatchDissolve = async () => {
await handleBatchChangeStatus('4', '解散');
};
/** 打开批量设置校区弹窗 */
const handleBatchSetCampus = () => {
if (!ids.value || ids.value.length === 0) {
proxy?.$modal.msgWarning('请先勾选需要操作的班级');
return;
}
campusBatchDialog.campusId = undefined;
campusBatchDialog.visible = true;
};
/** 提交批量设置校区 */
const submitBatchSetCampus = async () => {
if (campusBatchDialog.campusId === undefined || campusBatchDialog.campusId === null || campusBatchDialog.campusId === '') {
proxy?.$modal.msgWarning('请选择校区');
return;
}
buttonLoading.value = true;
try {
await updateClassCampusBatch({ ids: ids.value, campusId: campusBatchDialog.campusId });
proxy?.$modal.msgSuccess('批量设置校区成功');
campusBatchDialog.visible = false;
await getList();
} finally {
buttonLoading.value = false;
}
};
/** 导出按钮操作 */
const handleExport = () => {
proxy?.download(
@@ -1281,6 +1387,7 @@ onMounted(() => {
getList();
getCollegeList();
getCollegeTree();
getCampusList();
});
</script>
<style scoped>
+50 -14
View File
@@ -12,9 +12,11 @@
├── data.conf.prod # 生产环境配置(Oracle 目标库)
├── create_tables.sh # 创建中间表
├── sync_user_to_sso.sh # 主同步脚本
├── verify_sync.sh # 同步结果校验
├── verify_sync.sh # 同步结果校验(支持 MySQL / Oracle)
├── db_helper.py # MySQL 辅助
├── oracle_gen_merge.py # 从 MySQL 生成 Oracle MERGE SQL(生产)
├── sync_sql/sync_all_mysql.sql # MySQL 同步 SQL
├── sync_oracle/merge_all.sql # Oracle MERGE 模板(生产)
├── sync_oracle/merge_all.sql # Oracle 说明(实际由 gen 脚本生成)
├── 字段映射说明.md
└── logs/ # 运行日志(自动创建)
```
@@ -35,13 +37,34 @@ chmod +x *.sh
./verify_sync.sh
```
## 定时运行
## 定时运行(推荐:守护进程 + 时间窗,无需 crontab)
生产配置 `SYNC_WINDOW_ENABLED=true` 时:脚本每小时醒来一次,**仅在 0 点~1 点之间**真正执行同步,其余时段跳过。手动 `--once` / `--full --once` 不受时间窗限制。
```bash
# 守护进程模式,默认每 3600 秒(1小时)同步一次
./sync_user_to_sso.sh
# 后台常驻(服务器重启后需重新拉起,或配合 systemd)
source /etc/profile.d/oracle.sh
cd /path/to/用户信息同步统一身份认证
nohup ./sync_user_to_sso.sh >> logs/daemon.log 2>&1 &
# 修改同步频率:编辑 data.conf 中 CYCLE_INTERVAL(秒)
# 查看
ps -ef | grep sync_user_to_sso | grep -v grep
tail -f logs/daemon.log
```
相关配置(`data.conf` / `data.conf.prod`):
| 配置项 | 说明 |
|--------|------|
| `CYCLE_INTERVAL` | 醒来间隔秒数,默认 3600 |
| `SYNC_WINDOW_ENABLED` | `true` 开启时间窗 |
| `SYNC_WINDOW_START_HOUR` | 开始小时(含),默认 0 |
| `SYNC_WINDOW_END_HOUR` | 结束小时(不含),默认 1,即 00:00~00:59 |
也可改用 crontab(可选):
```bash
10 0 * * * source /etc/profile.d/oracle.sh; /path/to/sync_user_to_sso.sh --once >> /path/to/logs/cron.log 2>&1
```
## 生产部署
@@ -49,13 +72,24 @@ chmod +x *.sh
1. 复制 `data.conf.prod` 为 `data.conf` 或指定 `--config=data.conf.prod`
2. 填写源库(校园系统 MySQL)和目标库(认证中间库 Oracle)连接信息
3. 目标库使用第三方提供的 `3.在认证中间库建立表.sql` 在 Oracle 中建表
4. 确保服务器已安装 `sqlplus`(Oracle 客户端)
5. 使用 crontab 或 systemd 定时执行:
```bash
# crontab 示例:每小时执行一次
0 * * * * /path/to/用户信息同步统一身份认证/sync_user_to_sso.sh --once >> /path/to/logs/cron.log 2>&1
```
4. 确保服务器已安装 Instant Client(`basic` + `sqlplus`),并配置:
```bash
export ORACLE_HOME=/usr/lib/oracle/23/client64
export PATH=$ORACLE_HOME/bin:$PATH
export LD_LIBRARY_PATH=$ORACLE_HOME/lib:$LD_LIBRARY_PATH
```
5. 依赖:`python3` + `pymysql`(`pip3 install pymysql`)
6. 全量同步一次并校验:
```bash
source /etc/profile.d/oracle.sh
./sync_user_to_sso.sh --full --once
./verify_sync.sh
```
7. 后台常驻(时间窗内每天自动同步一次):
```bash
source /etc/profile.d/oracle.sh
nohup ./sync_user_to_sso.sh >> logs/daemon.log 2>&1 &
```
## 配置说明
@@ -65,4 +99,6 @@ chmod +x *.sh
| TARGET_DB_* | 目标库(认证中间库,读写) |
| TARGET_DB_TYPE | `mysql`(测试)或 `oracle`(生产) |
| SYNC_MODE | `full` 全量 / `incremental` 增量 |
| CYCLE_INTERVAL | 循环间隔秒数,默认 3600 |
| CYCLE_INTERVAL | 守护进程醒来间隔秒数,默认 3600 |
| SYNC_WINDOW_ENABLED | 是否仅在指定小时段同步(守护模式) |
| SYNC_WINDOW_START_HOUR / END_HOUR | 时间窗,默认 0~1(每天 0 点档) |
@@ -32,6 +32,11 @@ BATCH_SIZE=500
MAX_RETRY=3
RETRY_DELAY=5
# 守护进程时间窗(研发默认关闭;生产见 data.conf.prod)
SYNC_WINDOW_ENABLED=false
SYNC_WINDOW_START_HOUR=0
SYNC_WINDOW_END_HOUR=1
# ---------- 日志与锁 ----------
LOG_DIR="${SCRIPT_DIR}/logs"
LOCK_FILE="${SCRIPT_DIR}/sync_user_to_sso.lock"
@@ -10,7 +10,7 @@ SOURCE_DB_HOST="127.0.0.1"
SOURCE_DB_PORT="3306"
SOURCE_DB_NAME="hzdyjs_xg"
SOURCE_DB_USER="root"
SOURCE_DB_PASS="请填写生产源库密码"
SOURCE_DB_PASS="Hzdyjs@Root@123456"
# ---------- 目标库(认证中间库 Oracle,读写) ----------
# 第三方厂家提供:172.16.0.82:1521/zfdb,用户 CHG_RZ
@@ -30,6 +30,12 @@ BATCH_SIZE=500
MAX_RETRY=3
RETRY_DELAY=5
# 守护进程时间窗(仅 ./sync_user_to_sso.sh 常驻模式生效;--once 不受限)
# true=开启;每天 [START, END) 点之间才真正同步,例如 0~1 表示 00:00~00:59
SYNC_WINDOW_ENABLED=true
SYNC_WINDOW_START_HOUR=0
SYNC_WINDOW_END_HOUR=1
# ---------- 日志与锁 ----------
LOG_DIR="${SCRIPT_DIR}/logs"
LOCK_FILE="${SCRIPT_DIR}/sync_user_to_sso.lock"
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""根据 hzdyjs_xg.sql 模型生成「学生可提取第三方信息」Excel 清单(单表版)"""
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
OUTPUT = "学生信息第三方提取清单.xlsx"
HEADER_FILL = PatternFill("solid", fgColor="4472C4")
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
THIN = Side(style="thin")
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
WRAP = Alignment(wrap_text=True, vertical="center")
CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
# (数据表, 字段名, 中文名称, 数据类型, 说明)
FIELDS = [
# ── xg_student_msg 学生信息主表 ──
("xg_student_msg", "student_no", "学号", "varchar(32)", "学号,唯一标识"),
("xg_student_msg", "name", "姓名", "varchar(50)", ""),
("xg_student_msg", "name_english", "英文姓名", "varchar(100)", ""),
("xg_student_msg", "former_name", "曾用名", "varchar(50)", ""),
("xg_student_msg", "gender", "性别", "char(1)", "0-男,1-女"),
("xg_student_msg", "birth_date", "出生日期", "date", ""),
("xg_student_msg", "nationality", "民族", "varchar(20)", ""),
("xg_student_msg", "native_place", "籍贯", "varchar(20)", ""),
("xg_student_msg", "political_status", "政治面貌", "varchar(20)", ""),
("xg_student_msg", "id_card", "身份证号", "varchar(18)", ""),
("xg_student_msg", "photo_url", "照片URL", "varchar(500)", ""),
("xg_student_msg", "phone", "手机号码", "varchar(20)", ""),
("xg_student_msg", "email", "电子邮箱", "varchar(100)", ""),
("xg_student_msg", "qq", "QQ号码", "varchar(20)", ""),
("xg_student_msg", "wechat", "微信号", "varchar(50)", ""),
("xg_student_msg", "postal_code", "邮政编码", "varchar(10)", ""),
("xg_student_msg", "home_address", "家庭地址", "varchar(1000)", ""),
("xg_student_msg", "current_address", "现住址", "varchar(1000)", ""),
("xg_student_msg", "hukou_location", "户籍所在地", "varchar(1000)", ""),
("xg_student_msg", "student_status", "学籍状态", "char(1)", "1-在籍,2-休学,3-复学,4-退学,5-毕业,6-不在籍"),
("xg_student_msg", "enrollment_year", "入学年份", "int(4)", ""),
("xg_student_msg", "enrollment_date", "入学日期", "date", ""),
("xg_student_msg", "admission_category", "录取类别", "varchar(20)", "统招、自主招生、专项计划等"),
("xg_student_msg", "school_system", "学制(年)", "int(2)", ""),
("xg_student_msg", "academic_level", "学历层次", "varchar(20)", ""),
("xg_student_msg", "training_type", "培养方式", "varchar(20)", "全日制、在职、函授、夜大等"),
("xg_student_msg", "major_id", "专业ID", "varchar(100)", ""),
("xg_student_msg", "major_name", "专业名称", "varchar(100)", ""),
("xg_student_msg", "class_id", "班级ID", "varchar(100)", ""),
("xg_student_msg", "class_name", "班级名称", "varchar(100)", ""),
("xg_student_msg", "grade", "年级", "varchar(10)", ""),
("xg_student_msg", "college_id", "学院ID", "varchar(100)", ""),
("xg_student_msg", "college_name", "学院名称", "varchar(100)", ""),
("xg_student_msg", "counselor_name", "辅导员姓名", "varchar(50)", ""),
("xg_student_msg", "class_teacher_name", "班主任姓名", "varchar(50)", ""),
("xg_student_msg", "is_boarder", "是否住校", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "education_level", "文化程度", "varchar(20)", ""),
("xg_student_msg", "household_register_type", "户口类型", "varchar(20)", "农业、非农业"),
("xg_student_msg", "religion", "宗教信仰", "varchar(200)", ""),
("xg_student_msg", "source_region", "生源地区", "varchar(100)", "省市县"),
("xg_student_msg", "graduate_school", "毕业院校", "varchar(100)", "升学前毕业院校"),
("xg_student_msg", "marital_status", "婚姻状况", "varchar(10)", "1未婚、2已婚、3离异、4丧偶"),
("xg_student_msg", "emergency_contact_name", "紧急联系人姓名", "varchar(50)", ""),
("xg_student_msg", "emergency_contact_phone", "紧急联系人电话", "varchar(20)", ""),
("xg_student_msg", "emergency_contact_relation", "紧急联系人关系", "varchar(100)", ""),
("xg_student_msg", "is_poor_student", "是否贫困生", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "is_minority", "是否少数民族", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "is_disabled", "是否残疾", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "is_orphan", "是否孤儿", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "is_left_behind_child", "是否留守儿童", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "is_single_child", "是否独生子女", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "is_low_income", "是否低保", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "has_loan", "是否有助学贷款", "tinyint(1)", "0-否,1-是"),
("xg_student_msg", "loan_amount", "助学贷款金额", "decimal(10,2)", ""),
("xg_student_msg", "father_name", "父亲姓名", "varchar(50)", ""),
("xg_student_msg", "father_phone", "父亲电话", "varchar(20)", ""),
("xg_student_msg", "father_work_unit", "父亲工作单位", "varchar(1000)", ""),
("xg_student_msg", "mother_name", "母亲姓名", "varchar(50)", ""),
("xg_student_msg", "mother_phone", "母亲电话", "varchar(20)", ""),
("xg_student_msg", "mother_work_unit", "母亲工作单位", "varchar(1000)", ""),
("xg_student_msg", "guardian_name", "监护人姓名", "varchar(50)", ""),
("xg_student_msg", "guardian_phone", "监护人电话", "varchar(20)", ""),
("xg_student_msg", "guardian_relation", "监护人关系", "varchar(20)", ""),
("xg_student_msg", "family_income", "家庭年收入", "decimal(12,2)", ""),
("xg_student_msg", "per_capita_income", "人均收入", "decimal(12,2)", ""),
("xg_student_msg", "income_source", "收入来源", "varchar(100)", ""),
("xg_student_msg", "family_economic_status", "家庭经济状况", "varchar(20)", "富裕、一般、困难、特困"),
("xg_student_msg", "family_members", "家庭成员数量", "tinyint(2)", ""),
("xg_student_msg", "height", "身高(cm)", "decimal(10,2)", ""),
("xg_student_msg", "weight", "体重(kg)", "decimal(10,2)", ""),
("xg_student_msg", "blood_type", "血型", "varchar(10)", ""),
("xg_student_msg", "health_status", "健康状况", "varchar(1000)", ""),
("xg_student_msg", "special_condition", "特殊体质或疾病", "varchar(1000)", ""),
("xg_student_msg", "medical_history", "病史", "varchar(1000)", ""),
("xg_student_msg", "allergies", "过敏史", "varchar(1000)", ""),
("xg_student_msg", "disability_type", "残疾类型", "varchar(50)", "1视力、2听力、3智力、9其他"),
("xg_student_msg", "disability_level", "残疾等级", "varchar(20)", ""),
("xg_student_msg", "bank_card_no", "银行卡号", "varchar(30)", ""),
("xg_student_msg", "bank_name", "开户银行", "varchar(100)", ""),
("xg_student_msg", "bank_branch", "开户支行", "varchar(100)", ""),
("xg_student_msg", "card_holder_name", "持卡人姓名", "varchar(50)", ""),
("xg_student_msg", "specialties", "特长爱好", "text", ""),
("xg_student_msg", "awards", "获奖情况", "text", ""),
("xg_student_msg", "violations", "违纪记录", "text", ""),
("xg_student_msg", "social_activities", "社会活动", "text", ""),
("xg_student_msg", "dormitory_building", "宿舍楼栋", "varchar(100)", ""),
("xg_student_msg", "dormitory_room", "宿舍房间号", "varchar(100)", ""),
# ── xg_class_msg 班级信息表 ──
("xg_class_msg", "class_code", "班级代码", "varchar(32)", ""),
("xg_class_msg", "class_name", "班级名称", "varchar(100)", ""),
("xg_class_msg", "college_name", "学院名称", "varchar(100)", ""),
("xg_class_msg", "major_name", "专业名称", "varchar(100)", ""),
("xg_class_msg", "grade", "年级", "varchar(10)", ""),
("xg_class_msg", "enrollment_year", "入学年份", "int(4)", ""),
("xg_class_msg", "graduation_year", "毕业年份", "int(4)", ""),
("xg_class_msg", "school_system", "学制", "int(11)", ""),
("xg_class_msg", "training_type", "培养方式", "varchar(20)", ""),
("xg_class_msg", "degree_type", "学位类型", "varchar(20)", ""),
("xg_class_msg", "counselor_name", "辅导员姓名", "varchar(50)", ""),
("xg_class_msg", "class_teacher_name", "班主任姓名", "varchar(50)", ""),
("xg_class_msg", "class_status", "班级状态", "char(1)", "1-正常,2-暂停,3-毕业,4-解散"),
# ── xg_dorm_bed_student 宿舍分配表 ──
("xg_dorm_bed_student", "student_id", "学生ID", "bigint(20)", "关联 xg_student_msg.id"),
("xg_dorm_bed_student", "bed_id", "床位ID", "bigint(20)", ""),
("xg_dorm_bed_student", "room_id", "房间ID", "bigint(20)", ""),
("xg_dorm_bed_student", "building_id", "宿舍楼ID", "bigint(20)", ""),
("xg_dorm_bed_student", "campus_id", "校区ID", "bigint(20)", ""),
("xg_dorm_bed_student", "status", "状态", "char(1)", "0-正常,1-已退宿"),
("xg_dorm_bed_student", "assign_time", "分配时间", "datetime", ""),
# ── xg_student_leave 请假记录表 ──
("xg_student_leave", "student_no", "学号", "varchar(20)", ""),
("xg_student_leave", "student_name", "学生姓名", "varchar(50)", ""),
("xg_student_leave", "class_name", "班级名称", "varchar(100)", ""),
("xg_student_leave", "college_name", "学院名称", "varchar(100)", ""),
("xg_student_leave", "leave_type_name", "请假类型", "varchar(50)", ""),
("xg_student_leave", "start_time", "开始时间", "datetime", ""),
("xg_student_leave", "end_time", "结束时间", "datetime", ""),
("xg_student_leave", "leave_duration", "请假时长(天)", "decimal(5,2)", ""),
("xg_student_leave", "is_leave_school", "是否离校", "char(1)", "0-否,1-是"),
("xg_student_leave", "leave_reason", "请假原因", "text", ""),
("xg_student_leave", "approval_status", "审批状态", "char(100)", ""),
# ── xg_student_status_change 学籍异动表 ──
("xg_student_status_change", "student_no", "学号", "varchar(100)", ""),
("xg_student_status_change", "change_type", "异动类型", "varchar(20)", "TUI_XUE退学、XIU_XUE休学、FU_XUE复学、BI_YE毕业"),
("xg_student_status_change", "apply_date", "申请日期", "datetime", ""),
("xg_student_status_change", "effective_date", "生效日期", "datetime", ""),
("xg_student_status_change", "reason", "异动原因", "varchar(1000)", ""),
# ── xg_student_class_change 换班记录表 ──
("xg_student_class_change", "student_no", "学号", "varchar(32)", ""),
("xg_student_class_change", "student_name", "学生姓名", "varchar(50)", ""),
("xg_student_class_change", "original_class_name", "原班级名称", "varchar(100)", ""),
("xg_student_class_change", "new_class_name", "新班级名称", "varchar(100)", ""),
("xg_student_class_change", "change_time", "换班时间", "datetime", ""),
("xg_student_class_change", "change_reason", "换班原因", "varchar(500)", ""),
# ── jcbt_work_study_application 勤工助学申请表 ──
("jcbt_work_study_application", "student_number", "学号", "varchar(20)", ""),
("jcbt_work_study_application", "student_name", "学生姓名", "varchar(50)", ""),
("jcbt_work_study_application", "college_name", "学院名称", "varchar(100)", ""),
("jcbt_work_study_application", "major", "专业", "varchar(100)", ""),
("jcbt_work_study_application", "grade", "年级", "varchar(10)", ""),
("jcbt_work_study_application", "class_name", "班级", "varchar(50)", ""),
("jcbt_work_study_application", "phone", "联系电话", "varchar(20)", ""),
("jcbt_work_study_application", "id_card", "身份证号", "varchar(18)", ""),
("jcbt_work_study_application", "position_name", "岗位名称", "varchar(100)", ""),
("jcbt_work_study_application", "status", "申请状态", "varchar(20)", ""),
]
def main():
wb = Workbook()
ws = wb.active
ws.title = "学生可提取信息"
headers = ["序号", "数据表", "字段名", "中文名称", "数据类型", "说明"]
ws.append(headers)
for cell in ws[1]:
cell.fill = HEADER_FILL
cell.font = HEADER_FONT
cell.alignment = CENTER
cell.border = BORDER
for i, row in enumerate(FIELDS, 1):
ws.append([i, *row])
for cell in ws[ws.max_row]:
cell.alignment = WRAP
cell.border = BORDER
for col in ws.columns:
letter = get_column_letter(col[0].column)
width = max(len(str(c.value or "")) for c in col)
ws.column_dimensions[letter].width = min(max(width + 2, 8), 40)
ws.column_dimensions["A"].width = 6
ws.column_dimensions["B"].width = 28
ws.column_dimensions["F"].width = 40
ws.freeze_panes = "A2"
wb.save(OUTPUT)
print(f"已生成: {OUTPUT}(共 {len(FIELDS)} 条)")
if __name__ == "__main__":
main()
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""从 MySQL 源库导出数据,生成 Oracle MERGE SQL,供 sqlplus 执行。"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable, List, Optional, Sequence, Tuple
try:
import pymysql
except ImportError:
print("ERROR: 需要安装 pymysql: pip install pymysql", file=sys.stderr)
sys.exit(1)
def connect_mysql(host: str, port: int, user: str, password: str, database: str):
return pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
def q(val: Any, max_len: Optional[int] = None) -> str:
"""Python 值转 Oracle SQL 字面量。"""
if val is None:
return "NULL"
if isinstance(val, datetime):
s = val.strftime("%Y%m%d%H%M%S")
else:
s = str(val).strip()
if s == "":
return "NULL"
if max_len is not None:
s = s[:max_len]
s = s.replace("'", "''")
return f"'{s}'"
def right10(val: Any) -> Optional[str]:
if val is None:
return None
s = str(val).strip()
if not s or s == "0":
return None
return s[-10:]
def fmt_dt(val: Any, fmt: str = "%Y%m%d%H%M%S") -> Optional[str]:
if val is None:
return None
if isinstance(val, datetime):
return val.strftime(fmt)
s = str(val).strip()
return s or None
def sex_code(val: Any) -> str:
s = "" if val is None else str(val)
if s == "0":
return "1"
if s == "1":
return "2"
return "0"
def write_header(out) -> None:
out.write("-- generated by oracle_gen_merge.py\n")
out.write("WHENEVER SQLERROR EXIT SQL.SQLCODE\n")
out.write("SET ECHO OFF\n")
out.write("SET FEEDBACK OFF\n")
out.write("SET DEFINE OFF\n")
out.write("SET SERVEROUTPUT ON SIZE UNLIMITED\n")
out.write("\n")
def commit_batch(out, count: int, label: str) -> None:
out.write("COMMIT;\n")
out.write(f"PROMPT {label} committed batch, total so far: {count}\n")
out.write("\n")
def gen_dept(cur, out, incr_sql: str, batch_size: int) -> int:
sql = f"""
SELECT
d.dept_id,
d.dept_name,
d.parent_id,
d.status,
d.del_flag,
IFNULL(d.update_time, d.create_time) AS ts
FROM sys_dept d
WHERE 1=1
{incr_sql}
"""
cur.execute(sql)
n = 0
for row in cur:
dwh = right10(row["dept_id"])
if not dwh:
continue
lsdwh = right10(row["parent_id"])
dwyxbs = "1" if row["status"] == "0" and row["del_flag"] == "0" else "0"
scbz = "y" if row["del_flag"] == "1" else "n"
zhgxsj = fmt_dt(row["ts"])
out.write(
f"""MERGE INTO GXXX_YXSDWJBSJZL t
USING (SELECT {q(dwh, 10)} AS dwh FROM dual) s
ON (t.dwh = s.dwh)
WHEN MATCHED THEN UPDATE SET
t.dwmc = {q(row['dept_name'], 60)},
t.lsdwh = {q(lsdwh, 10)},
t.dwyxbs = {q(dwyxbs, 1)},
t.zhgxsj = {q(zhgxsj, 20)},
t.scbz = {q(scbz, 1)}
WHEN NOT MATCHED THEN INSERT (dwh, dwmc, lsdwh, dwyxbs, zhgxsj, scbz)
VALUES ({q(dwh, 10)}, {q(row['dept_name'], 60)}, {q(lsdwh, 10)}, {q(dwyxbs, 1)}, {q(zhgxsj, 20)}, {q(scbz, 1)});
"""
)
n += 1
if n % batch_size == 0:
commit_batch(out, n, "DEPT")
if n % batch_size != 0:
commit_batch(out, n, "DEPT")
out.write(f"PROMPT DEPT done: {n}\n\n")
return n
def gen_teacher(cur, out, incr_sql: str, batch_size: int) -> Tuple[int, int]:
sql = f"""
SELECT
u.user_name,
u.nick_name,
u.dept_id,
u.sex,
u.status,
u.del_flag,
u.phonenumber,
u.email,
IFNULL(u.update_time, u.create_time) AS ts
FROM sys_user u
WHERE u.account_type = '1'
{incr_sql}
"""
cur.execute(sql)
n_base = 0
n_tx = 0
for row in cur:
gh = (row["user_name"] or "").strip()
if not gh:
continue
gh = gh[:20]
dwh = right10(row["dept_id"]) or "0"
dwh = dwh[:10]
xm = (row["nick_name"] or row["user_name"] or "")[:36]
xbm = sex_code(row["sex"])
dqztm = "11" if row["status"] == "0" else "99"
scbz = "y" if row["del_flag"] == "1" else "n"
zhgxsj = fmt_dt(row["ts"])
yddh = row["phonenumber"] or None
dzxx = row["email"] or None
out.write(
f"""MERGE INTO GXJG_JZGJCSJZLB t
USING (SELECT {q(gh, 20)} AS gh FROM dual) s
ON (t.gh = s.gh)
WHEN MATCHED THEN UPDATE SET
t.dwh = {q(dwh, 10)},
t.xm = {q(xm, 36)},
t.xbm = {q(xbm, 1)},
t.dqztm = {q(dqztm, 2)},
t.zhgxsj = {q(zhgxsj, 20)},
t.scbz = {q(scbz, 1)}
WHEN NOT MATCHED THEN INSERT (gh, dwh, xm, xbm, dqztm, zhgxsj, scbz)
VALUES ({q(gh, 20)}, {q(dwh, 10)}, {q(xm, 36)}, {q(xbm, 1)}, {q(dqztm, 2)}, {q(zhgxsj, 20)}, {q(scbz, 1)});
"""
)
n_base += 1
tid = gh[:48]
out.write(
f"""MERGE INTO GXJG_GRTXFSZL t
USING (SELECT {q(tid, 48)} AS id FROM dual) s
ON (t.id = s.id)
WHEN MATCHED THEN UPDATE SET
t.gh = {q(gh, 20)},
t.yddh = {q(yddh, 30)},
t.dzxx = {q(dzxx, 40)},
t.zhgxsj = {q(zhgxsj, 20)},
t.scbz = {q(scbz, 1)}
WHEN NOT MATCHED THEN INSERT (id, gh, yddh, dzxx, zhgxsj, scbz)
VALUES ({q(tid, 48)}, {q(gh, 20)}, {q(yddh, 30)}, {q(dzxx, 40)}, {q(zhgxsj, 20)}, {q(scbz, 1)});
"""
)
n_tx += 1
if n_base % batch_size == 0:
commit_batch(out, n_base, "TEACHER")
if n_base % batch_size != 0:
commit_batch(out, n_base, "TEACHER")
out.write(f"PROMPT TEACHER done: base={n_base} tx={n_tx}\n\n")
return n_base, n_tx
def gen_student(cur, out, incr_sql: str, batch_size: int) -> Tuple[int, int]:
sql = f"""
SELECT
s.student_no,
s.name,
s.name_english,
s.former_name,
s.gender,
s.birth_date,
s.native_place,
s.id_card,
s.political_status,
s.blood_type,
s.is_single_child,
s.college_id,
s.class_id,
s.student_status,
s.deleted,
s.postal_code,
s.current_address,
s.home_address,
s.phone,
s.email,
s.qq,
s.wechat,
c.class_code,
IFNULL(s.update_time, s.create_time) AS ts
FROM xg_student_msg s
LEFT JOIN xg_class_msg c ON c.id = s.class_id AND c.deleted = 0
WHERE 1=1
{incr_sql}
"""
cur.execute(sql)
n_base = 0
n_tx = 0
for row in cur:
xh = (row["student_no"] or "").strip()
if not xh:
continue
xh = xh[:20]
dwh = str(row["college_id"] or "0")[:20]
if not dwh:
dwh = "0"
szbh = (row["class_code"] or row["class_id"] or "")
szbh = str(szbh)[:20] if szbh else None
status = "" if row["student_status"] is None else str(row["student_status"])
sfzx = "1" if status in ("1", "2", "3") else "0"
sfyby = "1" if status == "5" else "0"
scbz = "y" if row["deleted"] == 1 else "n"
zhgxsj = fmt_dt(row["ts"])
csrq = fmt_dt(row["birth_date"], "%Y%m%d")
sfdszn = "1" if row["is_single_child"] == 1 else "0"
zzmmm = (row["political_status"] or None)
if zzmmm is not None:
zzmmm = str(zzmmm)[:2]
txdz = row["current_address"] or row["home_address"]
jstxh = row["qq"] or row["wechat"]
out.write(
f"""MERGE INTO GXXS_XSJBSJZL t
USING (SELECT {q(xh, 20)} AS xh FROM dual) s
ON (t.xh = s.xh)
WHEN MATCHED THEN UPDATE SET
t.xm = {q(row['name'], 60)},
t.ywxm = {q(row['name_english'], 60)},
t.cym = {q(row['former_name'], 60)},
t.xbm = {q(sex_code(row['gender']), 1)},
t.csrq = {q(csrq, 10)},
t.jg = {q(row['native_place'], 60)},
t.sfzjlxm = '1',
t.sfzjh = {q(row['id_card'], 20)},
t.zzmmm = {q(zzmmm, 2)},
t.xxm = {q(row['blood_type'], 30)},
t.sfdszn = {q(sfdszn, 1)},
t.zhgxsj = {q(zhgxsj, 20)},
t.scbz = {q(scbz, 1)},
t.dwh = {q(dwh, 20)},
t.szbh = {q(szbh, 20)},
t.sfzx = {q(sfzx, 10)},
t.sfyby = {q(sfyby, 6)}
WHEN NOT MATCHED THEN INSERT (
xh, xm, ywxm, cym, xbm, csrq, jg, sfzjlxm, sfzjh, zzmmm, xxm,
sfdszn, zhgxsj, scbz, dwh, szbh, sfzx, sfyby
) VALUES (
{q(xh, 20)}, {q(row['name'], 60)}, {q(row['name_english'], 60)}, {q(row['former_name'], 60)},
{q(sex_code(row['gender']), 1)}, {q(csrq, 10)}, {q(row['native_place'], 60)}, '1',
{q(row['id_card'], 20)}, {q(zzmmm, 2)}, {q(row['blood_type'], 30)}, {q(sfdszn, 1)},
{q(zhgxsj, 20)}, {q(scbz, 1)}, {q(dwh, 20)}, {q(szbh, 20)}, {q(sfzx, 10)}, {q(sfyby, 6)}
);
"""
)
n_base += 1
out.write(
f"""MERGE INTO GXXS_GRTXZL t
USING (SELECT {q(xh, 20)} AS xh FROM dual) s
ON (t.xh = s.xh)
WHEN MATCHED THEN UPDATE SET
t.yzbm = {q(row['postal_code'], 6)},
t.txdz = {q(txdz, 180)},
t.yddh = {q(row['phone'], 30)},
t.dzxx = {q(row['email'], 40)},
t.jstxh = {q(jstxh, 40)},
t.zhgxsj = {q(zhgxsj, 20)},
t.scbz = {q(scbz, 1)}
WHEN NOT MATCHED THEN INSERT (xh, yzbm, txdz, yddh, dzxx, jstxh, zhgxsj, scbz)
VALUES (
{q(xh, 20)}, {q(row['postal_code'], 6)}, {q(txdz, 180)}, {q(row['phone'], 30)},
{q(row['email'], 40)}, {q(jstxh, 40)}, {q(zhgxsj, 20)}, {q(scbz, 1)}
);
"""
)
n_tx += 1
if n_base % batch_size == 0:
commit_batch(out, n_base, "STUDENT")
if n_base % batch_size != 0:
commit_batch(out, n_base, "STUDENT")
out.write(f"PROMPT STUDENT done: base={n_base} tx={n_tx}\n\n")
return n_base, n_tx
def build_incr(alias: str, last_sync: str, full: bool) -> str:
if full or not last_sync or last_sync.startswith("1970"):
return ""
return (
f"AND ({alias}.update_time > '{last_sync}' "
f"OR {alias}.create_time > '{last_sync}')"
)
def main() -> int:
parser = argparse.ArgumentParser(description="生成 Oracle MERGE SQL")
parser.add_argument("--host", required=True)
parser.add_argument("--port", default="3306")
parser.add_argument("--user", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--output", required=True, help="输出 SQL 文件路径")
parser.add_argument("--last-sync", default="1970-01-01 00:00:00")
parser.add_argument("--full", action="store_true")
parser.add_argument("--batch-size", type=int, default=200)
args = parser.parse_args()
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
conn = connect_mysql(
args.host, int(args.port), args.user, args.password, args.database
)
try:
with conn.cursor() as cur, out_path.open("w", encoding="utf-8") as out:
write_header(out)
dept_n = gen_dept(
cur, out, build_incr("d", args.last_sync, args.full), args.batch_size
)
tea_n, tea_tx = gen_teacher(
cur, out, build_incr("u", args.last_sync, args.full), args.batch_size
)
stu_n, stu_tx = gen_student(
cur, out, build_incr("s", args.last_sync, args.full), args.batch_size
)
out.write("COMMIT;\n")
out.write(
f"PROMPT SYNC SUMMARY dept={dept_n} teacher={tea_n}/{tea_tx} "
f"student={stu_n}/{stu_tx}\n"
)
out.write("EXIT;\n")
print(
f"OK dept={dept_n} teacher={tea_n} teacher_tx={tea_tx} "
f"student={stu_n} student_tx={stu_tx} file={out_path}"
)
return 0
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main())
@@ -1,17 +1,19 @@
-- ==========================================
-- Oracle 目标库 MERGE 同步脚本(生产环境)
-- 由 sync_user_to_sso.sh 通过 sqlplus 调用
-- 注意:需根据实际导出文件格式调整
-- Oracle 目标库 MERGE 同步说明
-- ==========================================
-- 生产同步不再直接使用本占位文件。
-- sync_user_to_sso.sh 会调用 oracle_gen_merge.py:
-- 1) 从 MySQL 源库读取部门/教职工/学生
-- 2) 生成带 MERGE 语句的临时 SQL
-- 3) 通过 sqlplus 执行写入认证中间库
--
-- 手动调试示例:
-- python3 oracle_gen_merge.py \
-- --host 127.0.0.1 --port 3306 --user root --password xxx \
-- --database hzdyjs_xg --output /tmp/merge.sql --full
-- sqlplus user/pass@host:1521/service @/tmp/merge.sql
-- ==========================================
-- 示例:部门 MERGE(生产部署时需配合 sqlldr 或外部表导入)
-- 当前为占位模板,待 Oracle 环境连通后完善
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET VERIFY OFF
PROMPT Oracle MERGE sync placeholder - 请根据生产环境完善
PROMPT Use oracle_gen_merge.py via sync_user_to_sso.sh
EXIT;
@@ -173,42 +173,47 @@ sync_mysql_target() {
sync_oracle_target() {
local last_sync
last_sync=$(get_last_sync_time)
log "INFO" "开始 Oracle 目标库同步,上次同步: $last_sync"
local mode_label="增量"
if [[ "$FORCE_FULL" == true ]] || [[ "$SYNC_MODE" == "full" ]]; then
mode_label="全量"
fi
log "INFO" "开始 Oracle 目标库同步,模式: ${mode_label},上次同步: $last_sync"
if ! command -v sqlplus >/dev/null 2>&1; then
error_exit "未找到 sqlplus 命令,无法连接 Oracle 目标库"
error_exit "未找到 sqlplus 命令,无法连接 Oracle 目标库(请安装 Instant Client 并配置 PATH)"
fi
local export_dir="/tmp/sso_sync_export_$$"
mkdir -p "$export_dir"
# 导出部门
source_mysql "
SELECT
RIGHT(CAST(d.dept_id AS CHAR), 10),
REPLACE(IFNULL(d.dept_name,''),'\t',' '),
CASE WHEN d.parent_id IS NULL OR d.parent_id=0 THEN '' ELSE RIGHT(CAST(d.parent_id AS CHAR),10) END,
CASE WHEN d.status='0' AND d.del_flag='0' THEN '1' ELSE '0' END,
DATE_FORMAT(IFNULL(d.update_time,d.create_time),'%Y%m%d%H%i%s'),
CASE WHEN d.del_flag='1' THEN 'y' ELSE 'n' END
FROM sys_dept d
" > "${export_dir}/dept.tsv" || true
local oracle_script="${SCRIPT_DIR}/sync_oracle/merge_all.sql"
if [[ ! -f "$oracle_script" ]]; then
error_exit "Oracle MERGE 脚本未找到: $oracle_script"
local merge_sql="/tmp/sso_oracle_merge_$$.sql"
local gen_cmd=(
python3 "${SCRIPT_DIR}/oracle_gen_merge.py"
--host "$SOURCE_DB_HOST" --port "$SOURCE_DB_PORT"
--user "$SOURCE_DB_USER" --password "$SOURCE_DB_PASS"
--database "$SOURCE_DB_NAME"
--output "$merge_sql"
--last-sync "$last_sync"
--batch-size "${BATCH_SIZE:-200}"
)
if [[ "$FORCE_FULL" == true ]] || [[ "$SYNC_MODE" == "full" ]]; then
gen_cmd+=(--full)
fi
local gen_out
if ! gen_out=$("${gen_cmd[@]}" 2>&1); then
log "ERROR" "生成 Oracle MERGE SQL 失败: $gen_out"
rm -f "$merge_sql"
return 1
fi
log "INFO" "已生成 MERGE SQL: $gen_out"
local conn="${TARGET_DB_USER}/${TARGET_DB_PASS}@${TARGET_ORACLE_CONNECT:-${TARGET_DB_HOST}:${TARGET_DB_PORT}/${TARGET_DB_NAME}}"
if sqlplus -S "$conn" @"$oracle_script" "$export_dir" >> "$LOG_FILE" 2>&1; then
if sqlplus -S "$conn" @"$merge_sql" >> "$LOG_FILE" 2>&1; then
log "INFO" "Oracle 同步完成"
save_sync_time
rm -rf "$export_dir"
rm -f "$merge_sql"
return 0
else
log "ERROR" "Oracle 同步失败,详见日志"
log "ERROR" "Oracle 同步失败,详见日志: $LOG_FILE (SQL 文件保留: $merge_sql)"
write_errlog "ALL" "Oracle数据同步" "sqlplus执行失败"
rm -rf "$export_dir"
return 1
fi
}
@@ -261,12 +266,33 @@ verify_counts() {
log "INFO" "源表 xg_student_msg(未删除): ${src_student} 条"
}
# 守护进程时间窗:仅在 [SYNC_WINDOW_START_HOUR, SYNC_WINDOW_END_HOUR) 内执行
# 例如 START=0 END=1 表示每天 00:00~00:59;--once / --full 不受此限制
in_sync_window() {
local enabled="${SYNC_WINDOW_ENABLED:-false}"
if [[ "$enabled" != "true" ]]; then
return 0
fi
local start_h="${SYNC_WINDOW_START_HOUR:-0}"
local end_h="${SYNC_WINDOW_END_HOUR:-1}"
local now_h
now_h=$(date '+%-H' 2>/dev/null || date '+%H' | sed 's/^0//')
[[ -z "$now_h" ]] && now_h=0
if (( now_h >= start_h && now_h < end_h )); then
return 0
fi
return 1
}
main() {
acquire_lock
log "INFO" "统一身份认证用户数据同步启动"
log "INFO" "源库: ${SOURCE_DB_TYPE} ${SOURCE_DB_USER}@${SOURCE_DB_HOST}:${SOURCE_DB_PORT}/${SOURCE_DB_NAME}"
log "INFO" "目标库: ${TARGET_DB_TYPE} ${TARGET_DB_USER}@${TARGET_DB_HOST}:${TARGET_DB_PORT}/${TARGET_DB_NAME}"
log "INFO" "同步模式: ${SYNC_MODE},循环间隔: ${CYCLE_INTERVAL}秒"
if [[ "${SYNC_WINDOW_ENABLED:-false}" == "true" ]]; then
log "INFO" "守护进程时间窗: ${SYNC_WINDOW_START_HOUR:-0}:00 ~ ${SYNC_WINDOW_END_HOUR:-1}:00(仅该时段执行;--once 不受限)"
fi
if [[ "$RUN_ONCE" == true ]]; then
run_sync_cycle && verify_counts
@@ -274,7 +300,11 @@ main() {
fi
while true; do
run_sync_cycle && verify_counts || log "WARN" "本轮同步失败,将在 ${CYCLE_INTERVAL} 秒后重试"
if in_sync_window; then
run_sync_cycle && verify_counts || log "WARN" "本轮同步失败,将在 ${CYCLE_INTERVAL} 秒后重试"
else
log "INFO" "当前不在同步时间窗内(${SYNC_WINDOW_START_HOUR:-0}:00~${SYNC_WINDOW_END_HOUR:-1}:00),跳过本轮"
fi
log "INFO" "等待 ${CYCLE_INTERVAL} 秒后开始下一轮..."
sleep "$CYCLE_INTERVAL"
done
@@ -18,6 +18,27 @@ mysql_q() {
scalar "$6" 2>/dev/null
}
oracle_counts() {
if ! command -v sqlplus >/dev/null 2>&1; then
echo "ERROR: 未找到 sqlplus,无法校验 Oracle 目标库" >&2
return 1
fi
local conn="${TARGET_DB_USER}/${TARGET_DB_PASS}@${TARGET_ORACLE_CONNECT:-${TARGET_DB_HOST}:${TARGET_DB_PORT}/${TARGET_DB_NAME}}"
sqlplus -S "$conn" <<'EOF'
SET PAGESIZE 100
SET LINESIZE 200
SET HEADING OFF
SET FEEDBACK OFF
SET VERIFY OFF
SELECT 'GXXX_YXSDWJBSJZL=' || COUNT(*) FROM GXXX_YXSDWJBSJZL;
SELECT 'GXJG_JZGJCSJZLB=' || COUNT(*) FROM GXJG_JZGJCSJZLB;
SELECT 'GXJG_GRTXFSZL=' || COUNT(*) FROM GXJG_GRTXFSZL;
SELECT 'GXXS_XSJBSJZL=' || COUNT(*) FROM GXXS_XSJBSJZL;
SELECT 'GXXS_GRTXZL=' || COUNT(*) FROM GXXS_GRTXZL;
EXIT;
EOF
}
echo "========== 统一身份认证同步校验 =========="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
@@ -48,7 +69,6 @@ if [[ "$TARGET_DB_TYPE" == "mysql" ]]; then
echo "[目标库] ZFTAL_XTGL_ERRLOG: ${TGT_ERR}"
echo ""
# 抽样对比
echo "---------- 抽样数据对比 ----------"
python3 "${SCRIPT_DIR}/db_helper.py" \
--host "$TARGET_DB_HOST" --port "$TARGET_DB_PORT" \
@@ -60,7 +80,6 @@ if [[ "$TARGET_DB_TYPE" == "mysql" ]]; then
query "SELECT xh, xm, dwh, szbh, scbz FROM GXXS_XSJBSJZL LIMIT 3" 2>/dev/null
echo ""
# 简单一致性检查
PASS=true
[[ "$TGT_DEPT" -ge 1 ]] || { echo "WARN: 部门同步数量为0"; PASS=false; }
[[ "$TGT_TEACHER" -ge 1 ]] || { echo "WARN: 教职工同步数量为0"; PASS=false; }
@@ -75,7 +94,37 @@ if [[ "$TARGET_DB_TYPE" == "mysql" ]]; then
echo "校验结果: 未通过"
exit 1
fi
elif [[ "$TARGET_DB_TYPE" == "oracle" ]]; then
echo "[目标库 Oracle] 记录数:"
ORACLE_OUT=$(oracle_counts) || { echo "校验结果: 未通过(sqlplus 失败)"; exit 1; }
echo "$ORACLE_OUT"
TGT_DEPT=$(echo "$ORACLE_OUT" | sed -n 's/.*GXXX_YXSDWJBSJZL=\([0-9][0-9]*\).*/\1/p' | head -1)
TGT_TEACHER=$(echo "$ORACLE_OUT" | sed -n 's/.*GXJG_JZGJCSJZLB=\([0-9][0-9]*\).*/\1/p' | head -1)
TGT_TEACHER_TX=$(echo "$ORACLE_OUT" | sed -n 's/.*GXJG_GRTXFSZL=\([0-9][0-9]*\).*/\1/p' | head -1)
TGT_STUDENT=$(echo "$ORACLE_OUT" | sed -n 's/.*GXXS_XSJBSJZL=\([0-9][0-9]*\).*/\1/p' | head -1)
TGT_STUDENT_TX=$(echo "$ORACLE_OUT" | sed -n 's/.*GXXS_GRTXZL=\([0-9][0-9]*\).*/\1/p' | head -1)
echo ""
echo "---------- 与源库对照 ----------"
echo "部门: 源=${SRC_DEPT} 目标=${TGT_DEPT:-0}"
echo "教职工: 源=${SRC_TEACHER} 目标=${TGT_TEACHER:-0} / 通讯=${TGT_TEACHER_TX:-0}"
echo "学生: 源=${SRC_STUDENT} 目标=${TGT_STUDENT:-0} / 通讯=${TGT_STUDENT_TX:-0}"
echo ""
PASS=true
[[ "${TGT_DEPT:-0}" -ge 1 ]] || { echo "WARN: 部门同步数量为0"; PASS=false; }
[[ "${TGT_TEACHER:-0}" -ge 1 ]] || { echo "WARN: 教职工同步数量为0"; PASS=false; }
[[ "${TGT_STUDENT:-0}" -ge 1 ]] || { echo "WARN: 学生同步数量为0"; PASS=false; }
if [[ "$PASS" == true ]]; then
echo "校验结果: 通过(Oracle 目标库已有数据)"
exit 0
else
echo "校验结果: 未通过"
exit 1
fi
else
echo "目标库为 Oracle,请使用 sqlplus 手动校验目标表记录数"
exit 0
echo "不支持的目标库类型: $TARGET_DB_TYPE"
exit 1
fi
@@ -102,9 +102,9 @@
1. **单位号 dwh 长度限制**:第三方表 `dwh` 仅 10 位,而 `sys_dept.dept_id` 为雪花ID(19位),当前取后10位;若对方有编码规范需另行映射。
2. **增量同步**:基于源表 `update_time` / `create_time`,状态记录在 `.sync_state`。
3. **全量同步**:设置 `SYNC_MODE=full` 或执行 `./sync_user_to_sso.sh --full --once`。
4. **生产 Oracle**:修改 `data.conf.prod` 中 `TARGET_DB_TYPE=oracle` 及连接信息,使用 `sqlplus` 执行 MERGE(模板见 `sync_oracle/`)。
4. **生产 Oracle**:`TARGET_DB_TYPE=oracle` 时,由 `oracle_gen_merge.py` 从 MySQL 生成 MERGE SQL,再经 `sqlplus` 写入中间库;`verify_sync.sh` 可直接校验 Oracle 表记录数。
---
**版本**: v1.0.0
**版本**: v1.1.0
**更新时间**: 2026-08-24
@@ -15,7 +15,7 @@
| 教务字段 | 口径 |
|----------|------|
| 身份证件类型 | 有 `id_card` →「居民身份证」,否则 `null` |
| 所在校区 | 有有效宿舍分配(`xg_dorm_bed_student.status=0`)→ `xg_dorm_campus.campus_name`,否则 `null` |
| 所在校区 | 学生 `class_id` → `xg_class_msg.campus_id` → `xg_dorm_campus.campus_name`,再回退班级冗余 `campus_name`;无班级或未设置时为 `null` |
| 班主任工号 | `class_teacher_id` → `sys_user.user_no`,空则 `user_name` |
| 学生是否在校 | 默认「在校」;学籍状态为 **退学(4)、毕业(5)** →「不在校」 |
| 异动类型 | 固定 `null` |
@@ -26,6 +26,21 @@
| 性别 | `0`→「男」,`1`→「女」 |
| 成员1 | 父亲姓名/关系「父亲」/ `father_phone` |
| 成员2 | 母亲姓名/关系「母亲」/ `mother_phone` |
| 培养层次 | 班级 `xg_class_msg.class_type` 字典值(`zsjy_education_level`),**非中文名称** |
| 系部信息 | 优先 `college_id` → `sys_dept.dept_name`(学生表,其次班级表),再回退冗余 `college_name` |
| 专业信息 | 优先 `major_id` → `sys_dept.dept_name`(学生表,其次班级表),再回退冗余 `major_name` |
| 班级信息 | `class_id` → `xg_class_msg.class_name`(优先班级表,再回退学生表冗余字段) |
## 培养层次字典值(`zsjy_education_level`)
| 字典值 | 中文名称 |
|--------|----------|
| 0 | 三年制中级技工 |
| 1 | 三年制技师 |
| 2 | 四年制高级工 |
| 3 | 五年制高级技工 |
| 4 | 六年制技师 |
| 5 | 中高职一体 |
## 学生当前状态映射(本系统 → 教务中文名)
@@ -106,8 +106,8 @@ gunicorn -w 2 -b 0.0.0.0:5050 app:app
| 异动生效学期 | 本系统无数据,为 `null` |
| 异动类型 | **教务中文名称**(退学、休学、复学、转班级) |
| 申请人学号 / 姓名 | |
| 异动前/后 年级、院系、专业、班级、学制 | 学籍异动「前」优先取处理前快照 |
| 异动前/后 所在校区 | 本系统无数据,为 `null` |
| 异动前/后 年级、院系、专业、班级、学制 | 院系/专业优先 `sys_dept`;班级优先 `xg_class_msg`;学籍异动「前」优先历史快照 |
| 异动前/后 所在校区 | 班级 `campus_id` → `xg_dorm_campus.campus_name`,再回退班级 `campus_name` |
| 异动后班主任工号 / 姓名 | |
| 新名字 | 本系统无改名流水,为 `null` |
| 审核状态 | 固定「已通过」 |
@@ -144,18 +144,18 @@ while True:
| 联系电话 | 否 | |
| 身份证件类型 | 否 | 有身份证号时为「居民身份证」,否则 `null` |
| 身份证件号 | 否 | |
| 班级信息 | 是 | 班级名称 |
| 所在校区 | 是 | 有宿舍分配时取校区名,否则 `null` |
| 班级信息 | 是 | `class_id` → `xg_class_msg.class_name`(优先班级表,再回退学生表冗余字段) |
| 所在校区 | 是 | 学生 `class_id` → 班级 `campus_id` → `xg_dorm_campus.campus_name`,再回退班级 `campus_name`;无班级或未设置时为 `null` |
| 所在年级 | 是 | |
| 入学年份 | 是 | 整数,如 `2024` |
| 系部信息 | 是 | 院系名称 |
| 专业信息 | 是 | 专业名称 |
| 系部信息 | 是 | `college_id` → `sys_dept.dept_name`(优先学生表 ID,其次班级表 ID,再回退冗余 `college_name`) |
| 专业信息 | 是 | `major_id` → `sys_dept.dept_name`(优先学生表 ID,其次班级表 ID,再回退冗余 `major_name`) |
| 班主任工号 | 是 | |
| 班主任姓名 | 是 | |
| 学生当前状态 | 是 | 教务中文名:在读、休学、复学、退学、毕业、结业、停学、其他 |
| 学生是否在校 | 是 | 「在校」或「不在校」;退学、毕业为不在校 |
| 异动类型 | 否 | 固定 `null` |
| 培养层次 | 否 | 如三年制技师、六年制技师等 |
| 培养层次 | 否 | 班级 `class_type` 字典值(`zsjy_education_level`):`0` 三年制中级技工、`1` 三年制技师、`2` 四年制高级工、`3` 五年制高级技工、`4` 六年制技师、`5` 中高职一体;无班级或未设置时为 `null` |
| 学制 | 是 | 年数,整数 |
| 毕业时间 | 是 | **年份**整数;已毕业取异动生效年,否则按入学年+学制推算 |
| 毕业学年学期 | 是 | 如 `2025-2026-2`;无法推算时为 `null` |
@@ -198,7 +198,7 @@ while True:
"学生当前状态": "在读",
"学生是否在校": "在校",
"异动类型": null,
"培养层次": "三年制技师",
"培养层次": "1",
"学制": 3,
"毕业时间": 2027,
"毕业学年学期": "2026-2027-2",
@@ -260,10 +260,10 @@ X-Jw-Cx-Key: JWCX001
| 异动类型 | 教务中文名:**退学、休学、复学、转班级** |
| 申请人学号 | |
| 姓名 | |
| 异动前所在年级 / 院系 / 专业 / 班级 / 学制 | |
| 异动前所在校区 | 固定 `null` |
| 异动后所在年级 / 班级 / 院系 / 专业 / 学制 | |
| 异动后所在校区 | 固定 `null` |
| 异动前所在年级 / 院系 / 专业 / 班级 / 学制 | 院系/专业优先 `sys_dept`;班级优先 `xg_class_msg`;再回退快照冗余名称 |
| 异动前所在校区 | 异动前班级 `campus_id` → `xg_dorm_campus.campus_name`,再回退班级 `campus_name` |
| 异动后所在年级 / 班级 / 院系 / 专业 / 学制 | 同上(异动后取当前学生/新班级) |
| 异动后所在校区 | 异动后班级 `campus_id` → `xg_dorm_campus.campus_name`,再回退班级 `campus_name` |
| 异动后班主任工号 / 姓名 | |
| 新名字 | 固定 `null` |
| 审核状态 | 固定「已通过」 |
@@ -293,12 +293,12 @@ X-Jw-Cx-Key: JWCX001
"申请人学号": "2024010101",
"姓名": "张三",
"异动前所在年级": "2024",
"异动前所在校区": null,
"异动前所在校区": "西溪校区",
"异动前所在院系": "电气工程系",
"异动前所在专业": "电气工程",
"异动前所在班级": "24技师1班",
"异动前学制": 3,
"异动后所在校区": null,
"异动后所在校区": "西溪校区",
"异动后所在年级": "2024",
"异动后所在班级": "24技师1班",
"异动后所在院系": "电气工程系",
@@ -27,7 +27,10 @@
- 对外字段名:**异动类型**
- 字段值:上表「教务系统异动类型名称」列的中文名称(非本库内部编码)
- **异动生效学期**、**异动前/后所在校区**:本系统无数据,固定返回 `null`
- **异动生效学期**、**新名字**:本系统无数据,固定返回 `null`
- **异动前/后所在校区**:班级 `campus_id` → `xg_dorm_campus.campus_name`,再回退班级 `campus_name`
- **异动前/后院系/专业**:优先 `college_id`、`major_id` → `sys_dept.dept_name`,再回退冗余名称
- **异动前/后班级**:优先 `xg_class_msg.class_name`,再回退冗余名称
- **新名字**:本系统无改名流水,固定返回 `null`
- **审核状态**:固定返回「已通过」