zlwu 1 settimana fa
parent
commit
6034b215e5
19 ha cambiato i file con 1396 aggiunte e 10 eliminazioni
  1. 6 0
      .cursor/cursorrules-workflow.md
  2. 140 2
      backend/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskServiceImpl.java
  3. 22 2
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgHolidayStayServiceImpl.java
  4. 36 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgStudentCancelLeaveServiceImpl.java
  5. 38 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgStudentLeaveServiceImpl.java
  6. 27 0
      docs/开发文档/API/假期留宿管理API.md
  7. 2 0
      docs/开发文档/API/学生销假管理API.md
  8. 11 1
      docs/开发文档/warm-flow工作流框架开发指南.md
  9. 126 0
      docs/数据库脚本/2026-10-16-xg_student_msg-set-boarder-2026.sql
  10. 342 0
      docs/设计文档/database/fix_朱茏_学生管理员设置分配范围权限.sql
  11. 132 0
      docs/设计文档/database/fix_朱茏_学籍异动新增权限.sql
  12. 246 0
      docs/设计文档/database/fix_朱茏_换班级新增权限.sql
  13. 1 1
      docs/设计文档/database/hzdyjs_xg0820.sql
  14. 23 0
      docs/设计文档/database/假期留宿_student_phone允许为空.sql
  15. 13 1
      mobile/src/pages/xg/xsjcxx/xsqj/cancelLeaveEdit/index.vue
  16. 12 3
      mobile/src/pages/xgss/jqzs/apply.vue
  17. 213 0
      org/dromara/warm/flow/core/listener/ListenerVariable.java
  18. 3 0
      web/src/views/xg/xgss/holidayStay/holidayStay/index-sg.vue
  19. 3 0
      web/src/views/xg/xgss/holidayStay/holidayStay/index.vue

+ 6 - 0
.cursor/cursorrules-workflow.md

@@ -632,6 +632,12 @@ const handleStartWorkFlow = async (businessData: any) => {
 // - 退回 (backProcess)
 ```
 
+**🔴 退回残留任务清理(强制性,2026-10-15)**:
+
+- `backProcess` 在 `skip(REJECT)` 后必须清理同实例下非目标节点残留 `flow_task`/`flow_user`(`cleanResidualTasksAfterBack`)
+- 草稿/撤销/退回重新提交时,`startWorkFlow` 需自愈多任务脏数据并按实例状态解析正确 taskId(`healResidualTasksOnRestart`)
+- 背景:退回后若残留原审批节点 + draft 申请人任务,移动端再提交会报「启动工作流失败」
+
 ### 3. 流程撤销
 
 ```typescript

+ 140 - 2
backend/ruoyi-modules/ruoyi-workflow/src/main/java/org/dromara/workflow/service/impl/FlwTaskServiceImpl.java

@@ -112,12 +112,18 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
                 .eq(FlowInstance::getBusinessId, businessId));
         if (ObjectUtil.isNotNull(flowInstance)) {
             BusinessStatusEnum.checkStartStatus(flowInstance.getFlowStatus());
+            // 草稿/撤销/退回重新提交前,清理历史残留待办,避免取到错误 taskId
+            healResidualTasksOnRestart(flowInstance);
             List<Task> taskList = taskService.list(new FlowTask().setInstanceId(flowInstance.getId()));
+            if (CollUtil.isEmpty(taskList)) {
+                throw new ServiceException("当前流程无待办任务,无法重新提交,请联系管理员");
+            }
+            Task currentTask = resolveRestartTask(taskList, flowInstance);
             taskService.mergeVariable(flowInstance, variables);
             insService.updateById(flowInstance);
             StartProcessReturnDTO dto = new StartProcessReturnDTO();
-            dto.setProcessInstanceId(taskList.get(0).getInstanceId());
-            dto.setTaskId(taskList.get(0).getId());
+            dto.setProcessInstanceId(currentTask.getInstanceId());
+            dto.setTaskId(currentTask.getId());
             return dto;
         }
         FlowParams flowParams = FlowParams.build()
@@ -421,6 +427,9 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
                     .hisTaskExt(bo.getFileId());
             taskService.skip(task.getId(), flowParams);
 
+            // 退回后清理残留待办(原节点未删干净、历史 draft 申请人任务等)
+            cleanResidualTasksAfterBack(task.getInstanceId(), bo.getNodeCode());
+
             // 保存手写签名到历史任务表(驳回场景)
             if (StringUtils.isNotBlank(bo.getSignature())) {
                 flwHisTaskExtMapper.updateSignatureByTaskId(taskId, bo.getSignature());
@@ -433,6 +442,135 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
         }
     }
 
+    /**
+     * 退回后清理残留待办:仅保留目标节点上最新有效任务,删除其余 flow_task 及关联 flow_user。
+     * <p>
+     * warm-flow skip(REJECT) 后偶发残留原审批节点任务、以及启动时留下的 draft 申请人任务,
+     * 导致学生重新提交时 startWorkFlow 取到错误 taskId,移动端提示「启动工作流失败」。
+     *
+     * @param instanceId     流程实例ID
+     * @param targetNodeCode 退回目标节点编码
+     */
+    private void cleanResidualTasksAfterBack(Long instanceId, String targetNodeCode) {
+        List<FlowTask> tasks = selectByInstId(instanceId);
+        if (CollUtil.isEmpty(tasks) || tasks.size() == 1) {
+            return;
+        }
+        if (StringUtils.isBlank(targetNodeCode)) {
+            log.warn("退回清理跳过:目标节点编码为空, instanceId={}", instanceId);
+            return;
+        }
+
+        Comparator<FlowTask> newestFirst = Comparator
+            .comparing(FlowTask::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder()))
+            .thenComparing(FlowTask::getId, Comparator.nullsLast(Comparator.reverseOrder()));
+
+        List<FlowTask> targetTasks = tasks.stream()
+            .filter(t -> StringUtils.equals(targetNodeCode, t.getNodeCode()))
+            .sorted(newestFirst)
+            .collect(Collectors.toList());
+        if (CollUtil.isEmpty(targetTasks)) {
+            log.warn("退回清理未找到目标节点任务, instanceId={}, targetNodeCode={}, remain={}",
+                instanceId, targetNodeCode, StreamUtils.toList(tasks, FlowTask::getId));
+            return;
+        }
+
+        // 优先保留目标节点上状态为 back 的最新任务,否则取目标节点最新一条
+        FlowTask keepTask = targetTasks.stream()
+            .filter(t -> TaskStatusEnum.BACK.getStatus().equals(t.getFlowStatus()))
+            .findFirst()
+            .orElse(targetTasks.get(0));
+
+        List<Long> deleteIds = tasks.stream()
+            .map(FlowTask::getId)
+            .filter(id -> !Objects.equals(id, keepTask.getId()))
+            .collect(Collectors.toList());
+        if (CollUtil.isEmpty(deleteIds)) {
+            return;
+        }
+
+        log.info("退回后清理残留任务 instanceId={}, keepTaskId={}, keepNode={}, deleteTaskIds={}",
+            instanceId, keepTask.getId(), keepTask.getNodeName(), deleteIds);
+        deleteTasksWithUsers(deleteIds);
+    }
+
+    /**
+     * 草稿/撤销/退回重新提交前,清理同实例下多余残留待办,只保留与实例状态匹配的有效任务。
+     */
+    private void healResidualTasksOnRestart(FlowInstance flowInstance) {
+        if (!BusinessStatusEnum.isDraftOrCancelOrBack(flowInstance.getFlowStatus())) {
+            return;
+        }
+        List<FlowTask> tasks = selectByInstId(flowInstance.getId());
+        if (CollUtil.isEmpty(tasks) || tasks.size() <= 1) {
+            return;
+        }
+
+        String status = flowInstance.getFlowStatus();
+        String applyNodeCode = flwCommonService.applyNodeCode(flowInstance.getDefinitionId());
+        Comparator<FlowTask> newestFirst = Comparator
+            .comparing(FlowTask::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder()))
+            .thenComparing(FlowTask::getId, Comparator.nullsLast(Comparator.reverseOrder()));
+
+        FlowTask keepTask = tasks.stream()
+            .filter(t -> status.equals(t.getFlowStatus()))
+            .filter(t -> StringUtils.isBlank(applyNodeCode) || applyNodeCode.equals(t.getNodeCode()))
+            .sorted(newestFirst)
+            .findFirst()
+            .orElseGet(() -> tasks.stream()
+                .filter(t -> status.equals(t.getFlowStatus()))
+                .sorted(newestFirst)
+                .findFirst()
+                .orElse(null));
+        if (keepTask == null) {
+            log.warn("重新提交自愈未找到匹配状态任务, instanceId={}, status={}, remain={}",
+                flowInstance.getId(), status, StreamUtils.toList(tasks, FlowTask::getId));
+            return;
+        }
+
+        List<Long> deleteIds = tasks.stream()
+            .map(FlowTask::getId)
+            .filter(id -> !Objects.equals(id, keepTask.getId()))
+            .collect(Collectors.toList());
+        if (CollUtil.isEmpty(deleteIds)) {
+            return;
+        }
+
+        log.info("重新提交前清理残留任务 instanceId={}, keepTaskId={}, keepNode={}, deleteTaskIds={}",
+            flowInstance.getId(), keepTask.getId(), keepTask.getNodeName(), deleteIds);
+        deleteTasksWithUsers(deleteIds);
+    }
+
+    /**
+     * 从待办列表中解析重新提交应使用的任务(多任务时优先匹配实例状态)。
+     */
+    private Task resolveRestartTask(List<Task> taskList, FlowInstance flowInstance) {
+        if (taskList.size() == 1) {
+            return taskList.get(0);
+        }
+        String status = flowInstance.getFlowStatus();
+        List<Task> matched = taskList.stream()
+            .filter(t -> status.equals(t.getFlowStatus()))
+            .collect(Collectors.toList());
+        if (CollUtil.isNotEmpty(matched)) {
+            return matched.get(0);
+        }
+        log.warn("重新提交存在多个待办且无法按状态匹配, instanceId={}, status={}, taskIds={}",
+            flowInstance.getId(), status, StreamUtils.toList(taskList, Task::getId));
+        return taskList.get(0);
+    }
+
+    /**
+     * 删除待办任务及其审批人关联。
+     */
+    private void deleteTasksWithUsers(List<Long> taskIds) {
+        if (CollUtil.isEmpty(taskIds)) {
+            return;
+        }
+        FlowEngine.userService().deleteByTaskIds(taskIds);
+        flowTaskMapper.deleteByIds(taskIds);
+    }
+
     /**
      * 获取可驳回的前置节点
      *

+ 22 - 2
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgHolidayStayServiceImpl.java

@@ -245,7 +245,16 @@ public class XgHolidayStayServiceImpl implements IXgHolidayStayService {
      * 保存前的数据校验
      */
     private void validEntityBeforeSave(XgHolidayStay entity) {
-        // TODO 做一些数据校验,如唯一约束
+        // 库表 student_phone / emergency_* 为 NOT NULL;null 会被 MyBatis 跳过导致 SQLException
+        if (StringUtils.isBlank(entity.getStudentPhone())) {
+            throw new ServiceException("学生联系电话不能为空");
+        }
+        if (StringUtils.isBlank(entity.getEmergencyContact())) {
+            throw new ServiceException("紧急联系人不能为空");
+        }
+        if (StringUtils.isBlank(entity.getEmergencyPhone())) {
+            throw new ServiceException("紧急联系电话不能为空");
+        }
     }
 
     /**
@@ -787,6 +796,16 @@ public class XgHolidayStayServiceImpl implements IXgHolidayStayService {
         if (bo.getBedId() == null || bo.getRoomId() == null) {
             throw new ServiceException("未查询到当前在住宿舍/床位信息,请先完成床位分配后再申请留宿");
         }
+        // student_phone / emergency_* 为库表 NOT NULL,空值会被 MyBatis 跳过导致 SQL 报错
+        if (StringUtils.isBlank(bo.getStudentPhone())) {
+            throw new ServiceException("请填写联系电话");
+        }
+        if (StringUtils.isBlank(bo.getEmergencyContact())) {
+            throw new ServiceException("请填写紧急联系人");
+        }
+        if (StringUtils.isBlank(bo.getEmergencyPhone())) {
+            throw new ServiceException("请填写紧急联系电话");
+        }
         bo.setApplyStatus("1");
         bo.setApplyTime(new Date());
         bo.setAuditorId(null);
@@ -903,7 +922,8 @@ public class XgHolidayStayServiceImpl implements IXgHolidayStayService {
             }
         }
         bo.setCollegeName(student.getCollegeName());
-        if (StringUtils.isBlank(bo.getStudentPhone())) {
+        // 仅在档案有电话时回填,避免把表单空串覆盖成 null 导致 INSERT 省略该字段
+        if (StringUtils.isBlank(bo.getStudentPhone()) && StringUtils.isNotBlank(student.getPhone())) {
             bo.setStudentPhone(student.getPhone());
         }
     }

+ 36 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgStudentCancelLeaveServiceImpl.java

@@ -1,5 +1,6 @@
 package org.dromara.xg.service.impl;
 
+import org.dromara.common.core.enums.BusinessStatusEnum;
 import org.dromara.common.core.utils.MapstructUtils;
 import org.dromara.common.core.utils.StringUtils;
 import org.dromara.common.mybatis.core.page.TableDataInfo;
@@ -144,6 +145,7 @@ public class XgStudentCancelLeaveServiceImpl implements IXgStudentCancelLeaveSer
     @Override
     @Transactional(rollbackFor = Exception.class)
     public XgStudentCancelLeaveVo insertByBo(XgStudentCancelLeaveBo bo) {
+        bo.setCancelStatus(resolveCancelStatusForSave(null, bo.getCancelStatus()));
         XgStudentCancelLeave add = MapstructUtils.convert(bo, XgStudentCancelLeave.class);
         validEntityBeforeSave(add);
         boolean flag = baseMapper.insert(add) > 0;
@@ -178,6 +180,12 @@ public class XgStudentCancelLeaveServiceImpl implements IXgStudentCancelLeaveSer
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Boolean updateByBo(XgStudentCancelLeaveBo bo) {
+        if (bo.getId() != null) {
+            XgStudentCancelLeave existing = baseMapper.selectById(bo.getId());
+            if (existing != null) {
+                bo.setCancelStatus(resolveCancelStatusForSave(existing.getCancelStatus(), bo.getCancelStatus()));
+            }
+        }
         XgStudentCancelLeave update = MapstructUtils.convert(bo, XgStudentCancelLeave.class);
         validEntityBeforeSave(update);
         boolean flag = baseMapper.updateById(update) > 0;
@@ -200,6 +208,34 @@ public class XgStudentCancelLeaveServiceImpl implements IXgStudentCancelLeaveSer
         return flag;
     }
 
+    /**
+     * 销假保存时的状态。
+     * 请假单上的 0/1/2/3 是「未销假、已销假、逾期、无需销假」,不能当作流程状态写入。
+     * 流程已进入待审核、退回、完成等状态后,再次保存销假内容不得把状态盖回去。
+     */
+    private String resolveCancelStatusForSave(String existingStatus, String incomingStatus) {
+        if (isWorkflowOwnedStatus(existingStatus)) {
+            return existingStatus;
+        }
+        if (StringUtils.isBlank(incomingStatus)
+            || "0".equals(incomingStatus)
+            || "1".equals(incomingStatus)
+            || "2".equals(incomingStatus)
+            || "3".equals(incomingStatus)) {
+            return BusinessStatusEnum.DRAFT.getStatus();
+        }
+        return incomingStatus;
+    }
+
+    private boolean isWorkflowOwnedStatus(String status) {
+        return BusinessStatusEnum.WAITING.getStatus().equals(status)
+            || BusinessStatusEnum.FINISH.getStatus().equals(status)
+            || BusinessStatusEnum.INVALID.getStatus().equals(status)
+            || BusinessStatusEnum.TERMINATION.getStatus().equals(status)
+            || BusinessStatusEnum.BACK.getStatus().equals(status)
+            || BusinessStatusEnum.CANCEL.getStatus().equals(status);
+    }
+
     /**
      * 保存前的数据校验
      */

+ 38 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgStudentLeaveServiceImpl.java

@@ -561,9 +561,47 @@ public class XgStudentLeaveServiceImpl implements IXgStudentLeaveService {
      *
      * @param processTaskEvent 参数
      */
+    @Transactional(rollbackFor = Exception.class)
     @EventListener(condition = "#processTaskEvent.flowCode.startsWith('hzdyjs-xj')")
     public void processTaskHandler_xj(ProcessTaskEvent processTaskEvent) {
         log.info("当前任务创建了{}", processTaskEvent.toString());
+        if (StringUtils.isBlank(processTaskEvent.getBusinessId())) {
+            return;
+        }
+        if (!BusinessStatusEnum.WAITING.getStatus().equals(processTaskEvent.getStatus())) {
+            return;
+        }
+        XgStudentCancelLeave cancelLeave = xgStudentCancelLeaveMapper
+            .selectById(Long.valueOf(processTaskEvent.getBusinessId()));
+        if (ObjectUtil.isNull(cancelLeave) || !shouldAlignCancelStatusToWaiting(cancelLeave.getCancelStatus())) {
+            return;
+        }
+        String previousStatus = cancelLeave.getCancelStatus();
+        cancelLeave.setCancelStatus(BusinessStatusEnum.WAITING.getStatus());
+        xgStudentCancelLeaveMapper.updateById(cancelLeave);
+        if (cancelLeave.getLeaveId() != null) {
+            XgStudentLeave leave = baseMapper.selectById(cancelLeave.getLeaveId());
+            if (ObjectUtil.isNotNull(leave)) {
+                leave.setCancelStatus(BusinessStatusEnum.WAITING.getStatus());
+                baseMapper.updateById(leave);
+            }
+        }
+        log.info("销假业务状态已对齐为 waiting, businessId={}, taskId={}, nodeName={}, previousStatus={}",
+            processTaskEvent.getBusinessId(), processTaskEvent.getTaskId(), processTaskEvent.getNodeName(),
+            previousStatus);
+    }
+
+    /**
+     * 销假流程状态尚未写成 waiting 时需要对齐。
+     * 0/1/2/3 是请假单上的旧销假字典,draft 是提交标记丢失后停留的草稿。
+     */
+    private boolean shouldAlignCancelStatusToWaiting(String cancelStatus) {
+        return StringUtils.isBlank(cancelStatus)
+            || "0".equals(cancelStatus)
+            || "1".equals(cancelStatus)
+            || "2".equals(cancelStatus)
+            || "3".equals(cancelStatus)
+            || BusinessStatusEnum.DRAFT.getStatus().equals(cancelStatus);
     }
 
     /**

+ 27 - 0
docs/开发文档/API/假期留宿管理API.md

@@ -0,0 +1,27 @@
+# 假期留宿管理 API
+
+## 学生申请留宿
+
+- **接口**: `POST /holidayStay/holidayStay/apply`
+- **权限**: `holidayStay:holidayStay:apply`
+
+### 必填业务字段(库表 NOT NULL)
+
+| 字段 | 说明 |
+|------|------|
+| holidayType | 假期类型 |
+| applyStartDate / applyEndDate | 留宿起止时间 |
+| stayReason | 留宿原因 |
+| studentPhone | 学生联系电话(档案无电话时须前端手动填写) |
+| emergencyContact | 紧急联系人 |
+| emergencyPhone | 紧急联系电话 |
+
+> **说明(2026-10-15)**:
+> - 库表已支持 `student_phone` 可空(见 `docs/设计文档/database/假期留宿_student_phone允许为空.sql`),仅执行 SQL 即可先解除线上报错、无需发版。
+> - 代码侧仍建议保留校验/友好提示;档案 `phone` 为空时不会把表单空串覆盖为 `null`。
+
+### 自动填充
+
+- 学生信息快照:学号/姓名/班级/专业/学院(按当前登录学生)
+- 宿舍快照:当前在住床位(无床位则拒绝申请)
+- 申请状态:`1`(待审批)

+ 2 - 0
docs/开发文档/API/学生销假管理API.md

@@ -307,6 +307,8 @@
 
 **事务保证**:使用 `@Transactional` 注解确保两个表的更新操作要么全部成功,要么全部回滚。
 
+**状态保护**(2026-10-19):`cancelStatus` 在销假流程中使用 `draft`、`waiting`、`finish` 等流程状态。请假单上的 `0/1/2/3`(未销假、已销假、逾期未销假、无需销假)传入本接口时按 `draft` 保存,不会写入销假单。销假单已是 `waiting`、`back`、`cancel`、`finish`、`invalid`、`termination` 时,再次保存销假内容不会覆盖该状态。审批任务创建且流程为待审核时,若业务状态仍是旧字典值或 `draft`,会自动对齐为 `waiting`。
+
 ### 5. 删除销假
 
 - **接口路径**:`DELETE /studentCancelLeave/studentCancelLeave/{ids}`

+ 11 - 1
docs/开发文档/warm-flow工作流框架开发指南.md

@@ -1045,12 +1045,22 @@ public class TestLeaveController {
 1. 流程定义未发布
 2. 流程编码不存在
 3. 业务数据状态不正确
+4. **退回后残留多个 `flow_task`**(原审批节点未清、历史 draft 申请人任务),重新提交时取到错误 taskId,移动端统一提示「启动工作流失败」
 
 **解决**:
 
 1. 检查流程定义是否已发布
 2. 确认 `flowCode` 参数正确
-3. 确保业务数据状态为 `draft`
+3. 确保业务数据状态为 `draft` / `back` / `cancel`(可重新提交)
+4. **退回清理(已实现)**:`FlwTaskServiceImpl.backProcess` 在 `skip(REJECT)` 后调用 `cleanResidualTasksAfterBack`,只保留退回目标节点最新有效任务;重新提交时 `healResidualTasksOnRestart` 自愈历史脏数据
+
+**排查 SQL 示例**(同一 `instance_id` 不应长期存在多条无关待办):
+
+```sql
+SELECT id, node_name, flow_status, create_time
+FROM flow_task
+WHERE instance_id = ? AND IFNULL(del_flag, '0') = '0';
+```
 
 ### 9.5 日期格式问题
 

+ 126 - 0
docs/数据库脚本/2026-10-16-xg_student_msg-set-boarder-2026.sql

@@ -0,0 +1,126 @@
+-- =============================================================================
+-- 将入学年份为 2026 或年级为 2026 的学生「是否住校」统一改为「是」
+--
+-- 目标表字段:
+--   xg_student_msg.is_boarder  0-否,1-是
+--   xg_student_msg.enrollment_year / grade
+--   同时兼容班级表 xg_class_msg.enrollment_year / grade(学生字段为空时以班级为准)
+--
+-- 用法:
+--   mysql -h <host> -u root -p hzdyjs_xg < docs/数据库脚本/2026-10-16-xg_student_msg-set-boarder-2026.sql
+--
+-- 建议:先执行「一、预览」确认影响范围,再执行「二、更新」
+-- =============================================================================
+
+SET NAMES utf8mb4;
+
+-- -----------------------------------------------------------------------------
+-- 一、预览:命中范围与当前住校分布
+-- -----------------------------------------------------------------------------
+
+SELECT '========== 1. 命中学生数量(含已是住校)==========' AS section;
+
+SELECT COUNT(*) AS match_total
+FROM xg_student_msg s
+LEFT JOIN xg_class_msg c
+  ON c.deleted = 0
+ AND NULLIF(TRIM(s.class_id), '') IS NOT NULL
+ AND c.id = CAST(NULLIF(TRIM(s.class_id), '') AS UNSIGNED)
+WHERE s.deleted = 0
+  AND (
+       s.enrollment_year = 2026
+    OR s.grade = '2026'
+    OR c.enrollment_year = 2026
+    OR c.grade = '2026'
+  );
+
+SELECT '========== 2. 命中且尚未住校(将被更新)==========' AS section;
+
+SELECT COUNT(*) AS need_update_count
+FROM xg_student_msg s
+LEFT JOIN xg_class_msg c
+  ON c.deleted = 0
+ AND NULLIF(TRIM(s.class_id), '') IS NOT NULL
+ AND c.id = CAST(NULLIF(TRIM(s.class_id), '') AS UNSIGNED)
+WHERE s.deleted = 0
+  AND IFNULL(s.is_boarder, 0) <> 1
+  AND (
+       s.enrollment_year = 2026
+    OR s.grade = '2026'
+    OR c.enrollment_year = 2026
+    OR c.grade = '2026'
+  );
+
+SELECT '========== 3. 命中明细(抽样核对)==========' AS section;
+
+SELECT
+    s.id,
+    s.student_no,
+    s.name,
+    s.enrollment_year AS student_enrollment_year,
+    s.grade           AS student_grade,
+    c.enrollment_year AS class_enrollment_year,
+    c.grade           AS class_grade,
+    s.is_boarder,
+    s.class_name,
+    s.college_name,
+    s.student_status
+FROM xg_student_msg s
+LEFT JOIN xg_class_msg c
+  ON c.deleted = 0
+ AND NULLIF(TRIM(s.class_id), '') IS NOT NULL
+ AND c.id = CAST(NULLIF(TRIM(s.class_id), '') AS UNSIGNED)
+WHERE s.deleted = 0
+  AND (
+       s.enrollment_year = 2026
+    OR s.grade = '2026'
+    OR c.enrollment_year = 2026
+    OR c.grade = '2026'
+  )
+ORDER BY s.enrollment_year, s.student_no
+LIMIT 200;
+
+-- -----------------------------------------------------------------------------
+-- 二、更新:是否住校 -> 是(1)
+-- -----------------------------------------------------------------------------
+
+START TRANSACTION;
+
+UPDATE xg_student_msg s
+LEFT JOIN xg_class_msg c
+  ON c.deleted = 0
+ AND NULLIF(TRIM(s.class_id), '') IS NOT NULL
+ AND c.id = CAST(NULLIF(TRIM(s.class_id), '') AS UNSIGNED)
+SET
+    s.is_boarder = 1,
+    s.update_time = NOW()
+WHERE s.deleted = 0
+  AND IFNULL(s.is_boarder, 0) <> 1
+  AND (
+       s.enrollment_year = 2026
+    OR s.grade = '2026'
+    OR c.enrollment_year = 2026
+    OR c.grade = '2026'
+  );
+
+-- 核对更新结果(事务内)
+SELECT ROW_COUNT() AS updated_rows;
+
+SELECT COUNT(*) AS still_not_boarder
+FROM xg_student_msg s
+LEFT JOIN xg_class_msg c
+  ON c.deleted = 0
+ AND NULLIF(TRIM(s.class_id), '') IS NOT NULL
+ AND c.id = CAST(NULLIF(TRIM(s.class_id), '') AS UNSIGNED)
+WHERE s.deleted = 0
+  AND IFNULL(s.is_boarder, 0) <> 1
+  AND (
+       s.enrollment_year = 2026
+    OR s.grade = '2026'
+    OR c.enrollment_year = 2026
+    OR c.grade = '2026'
+  );
+
+COMMIT;
+
+-- 可选:仅预览不更新时,把上面 TRANSACTION 整段注释掉,或改为 ROLLBACK

+ 342 - 0
docs/设计文档/database/fix_朱茏_学生管理员设置分配范围权限.sql

@@ -0,0 +1,342 @@
+-- ============================================================
+-- 临时权限修复:学生管理员设置「分配管理范围」
+-- 背景:
+--   页面:学生管理员设置 / 自定义管理员 / 为【xxx】分配管理范围
+--   提交接口:POST /xgStudentManagerSource/studentManagerSource/grantDeptAuth
+--   后端权限:xgStudentManagerSource:studentManagerSource:grantDeptAuth
+--   前端无按钮权限控制,点确定后接口 403:
+--     黄条「无访问权限,可能原因:1. 用户权限不足…」
+--     红字「分配失败,请重试」
+--   该权限码是后加的自定义接口,菜单里往往没有对应按钮,角色也就授不了
+-- 适用:
+--   朱茏老师(user_id=223,角色含 3003-学生信息管理)
+--   以及已经能打开本模块(已有管理员列表/授权查询权限)的同类角色
+-- 执行后:
+--   相关用户需重新登录,使权限缓存生效
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- A) 先查(只读,可单独执行)
+-- ------------------------------------------------------------
+
+-- A1. 朱茏当前角色,以及本模块相关权限(无行=该权限没有)
+SELECT
+  u.user_id,
+  u.user_name,
+  u.nick_name,
+  r.role_id,
+  r.role_name,
+  m.menu_id,
+  m.menu_name,
+  m.perms
+FROM sys_user u
+JOIN sys_user_role ur ON ur.user_id = u.user_id
+JOIN sys_role r ON r.role_id = ur.role_id
+JOIN sys_role_menu rm ON rm.role_id = r.role_id
+JOIN sys_menu m ON m.menu_id = rm.menu_id
+WHERE u.user_id = 223
+  AND (
+    m.perms LIKE 'xgStudentManager%'
+    OR m.menu_name LIKE '%学生管理员%'
+  )
+ORDER BY m.perms, r.role_id;
+
+-- A2. 关键权限是否已具备(1=有,0=没有)
+SELECT
+  p.perms,
+  EXISTS (
+    SELECT 1
+    FROM sys_user_role ur
+    JOIN sys_role_menu rm ON rm.role_id = ur.role_id
+    JOIN sys_menu m ON m.menu_id = rm.menu_id
+    WHERE ur.user_id = 223 AND m.perms = p.perms
+  ) AS has_perm
+FROM (
+  SELECT 'xgStudentManager:studentManager:list' AS perms
+  UNION ALL SELECT 'xgStudentManagerSource:studentManagerSource:list'
+  UNION ALL SELECT 'xgStudentManagerSource:studentManagerSource:query'
+  UNION ALL SELECT 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+) p;
+
+-- A3. 库里是否已有这些菜单权限码(grantDeptAuth 无行=需要下面的修复段补菜单)
+SELECT menu_id, menu_name, parent_id, menu_type, perms, status, visible
+FROM sys_menu
+WHERE perms IN (
+  'xgStudentManager:studentManager:list',
+  'xgStudentManagerSource:studentManagerSource:list',
+  'xgStudentManagerSource:studentManagerSource:query',
+  'xgStudentManagerSource:studentManagerSource:edit',
+  'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+)
+   OR menu_name LIKE '%学生管理员%'
+ORDER BY parent_id, order_num, menu_id;
+
+-- ============================================================
+-- 以下为修复段:确认缺 grantDeptAuth 后再执行;先备份再改
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- 0) 修改前备份(带时间戳后缀,避免覆盖)
+-- ------------------------------------------------------------
+SET @bak_suffix = DATE_FORMAT(NOW(), '%Y%m%d_%H%i%s');
+
+SET @sql_bak_menu = CONCAT(
+  'CREATE TABLE IF NOT EXISTS sys_menu_bak_', @bak_suffix,
+  ' AS SELECT * FROM sys_menu'
+);
+PREPARE stmt_bak_menu FROM @sql_bak_menu;
+EXECUTE stmt_bak_menu;
+DEALLOCATE PREPARE stmt_bak_menu;
+
+SET @sql_bak_role_menu = CONCAT(
+  'CREATE TABLE IF NOT EXISTS sys_role_menu_bak_', @bak_suffix,
+  ' AS SELECT * FROM sys_role_menu'
+);
+PREPARE stmt_bak_role_menu FROM @sql_bak_role_menu;
+EXECUTE stmt_bak_role_menu;
+DEALLOCATE PREPARE stmt_bak_role_menu;
+
+SELECT
+  CONCAT('sys_menu_bak_', @bak_suffix) AS menu_backup_table,
+  CONCAT('sys_role_menu_bak_', @bak_suffix) AS role_menu_backup_table;
+
+-- ------------------------------------------------------------
+-- 1) 解析父菜单(一条 SELECT,避免 Navicat 把 SET+子查询拆段)
+--    优先级:授权关系列表菜单 > 其父级 > 同模块按钮父级 > 学生管理员设置 > 管理员列表父级
+-- ------------------------------------------------------------
+SET @parent_id = NULL;
+
+SELECT picked.menu_id INTO @parent_id
+FROM (
+  SELECT menu_id, 1 AS ord
+  FROM sys_menu
+  WHERE perms = 'xgStudentManagerSource:studentManagerSource:list'
+    AND menu_type IN ('C', 'M')
+  UNION ALL
+  SELECT parent_id, 2
+  FROM sys_menu
+  WHERE perms = 'xgStudentManagerSource:studentManagerSource:list'
+  UNION ALL
+  SELECT parent_id, 3
+  FROM sys_menu
+  WHERE perms IN (
+    'xgStudentManagerSource:studentManagerSource:query',
+    'xgStudentManagerSource:studentManagerSource:edit',
+    'xgStudentManagerSource:studentManagerSource:add'
+  )
+  UNION ALL
+  SELECT menu_id, 4
+  FROM sys_menu
+  WHERE menu_name LIKE '%学生管理员设置%'
+    AND menu_type IN ('C', 'M')
+  UNION ALL
+  SELECT parent_id, 5
+  FROM sys_menu
+  WHERE perms = 'xgStudentManager:studentManager:list'
+) picked
+WHERE picked.menu_id IS NOT NULL
+ORDER BY picked.ord, picked.menu_id
+LIMIT 1;
+
+SELECT @parent_id AS resolved_parent_id;
+
+-- ------------------------------------------------------------
+-- 2) 新增「分配管理范围」按钮权限(若不存在,且已解析到父菜单)
+-- ------------------------------------------------------------
+INSERT INTO sys_menu (
+  menu_id, menu_name, parent_id, order_num, path, component, query_param,
+  is_frame, is_cache, menu_type, visible, status, perms, icon,
+  create_dept, create_by, create_time, update_by, update_time, remark
+)
+SELECT
+  2005461500000000003,
+  '分配管理范围',
+  @parent_id,
+  6,
+  '',
+  NULL,
+  NULL,
+  1,
+  0,
+  'F',
+  '0',
+  '0',
+  'xgStudentManagerSource:studentManagerSource:grantDeptAuth',
+  '',
+  100,
+  1,
+  NOW(),
+  1,
+  NOW(),
+  '补齐自定义管理员分配管理范围接口权限,解决提交403'
+FROM DUAL
+WHERE @parent_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_menu
+    WHERE perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+  );
+
+-- 父菜单没解析到时,这条会提示,且上面的 INSERT 不会写入
+SELECT
+  CASE
+    WHEN @parent_id IS NULL THEN '未找到父菜单,未插入按钮。请先看 A3 的菜单结果,手工指定 parent_id 后再执行第 2 步'
+    WHEN EXISTS (
+      SELECT 1 FROM sys_menu
+      WHERE perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+    ) THEN 'grantDeptAuth 菜单已存在(含本次新建)'
+    ELSE '未插入,请检查 sys_menu'
+  END AS menu_insert_result;
+
+-- ------------------------------------------------------------
+-- 3) 给「学生信息管理」角色(3003)授权(朱茏已拥有该角色)
+-- ------------------------------------------------------------
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 3003, m.menu_id
+FROM sys_menu m
+WHERE m.perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_menu rm
+    WHERE rm.role_id = 3003 AND rm.menu_id = m.menu_id
+  );
+
+-- ------------------------------------------------------------
+-- 4) 兼容:已能使用本模块的角色,一并补上分配范围权限
+--    判定:已有管理员列表、授权关系查询/列表/修改,或菜单名含「学生管理员设置」
+-- ------------------------------------------------------------
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT DISTINCT rm.role_id, grant_menu.menu_id
+FROM sys_role_menu rm
+JOIN sys_menu owned ON owned.menu_id = rm.menu_id
+JOIN sys_menu grant_menu
+  ON grant_menu.perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+WHERE (
+    owned.perms IN (
+      'xgStudentManager:studentManager:list',
+      'xgStudentManagerSource:studentManagerSource:list',
+      'xgStudentManagerSource:studentManagerSource:query',
+      'xgStudentManagerSource:studentManagerSource:edit',
+      'xgStudentManagerSource:studentManagerSource:add'
+    )
+    OR owned.menu_name LIKE '%学生管理员设置%'
+  )
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_menu x
+    WHERE x.role_id = rm.role_id AND x.menu_id = grant_menu.menu_id
+  );
+
+-- ------------------------------------------------------------
+-- 5) 校验:朱茏是否已具备分配范围权限
+-- ------------------------------------------------------------
+SELECT
+  u.user_id,
+  u.user_name,
+  u.nick_name,
+  r.role_id,
+  r.role_name,
+  m.menu_id,
+  m.menu_name,
+  m.perms
+FROM sys_user u
+JOIN sys_user_role ur ON ur.user_id = u.user_id
+JOIN sys_role r ON r.role_id = ur.role_id
+JOIN sys_role_menu rm ON rm.role_id = r.role_id
+JOIN sys_menu m ON m.menu_id = rm.menu_id
+WHERE u.user_id = 223
+  AND m.perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth';
+
+-- 有行=已具备。无行时看她的角色是否在第 3/4 步的授权范围内
+SELECT
+  'xgStudentManagerSource:studentManagerSource:grantDeptAuth' AS perms,
+  EXISTS (
+    SELECT 1
+    FROM sys_user_role ur
+    JOIN sys_role_menu rm ON rm.role_id = ur.role_id
+    JOIN sys_menu m ON m.menu_id = rm.menu_id
+    WHERE ur.user_id = 223
+      AND m.perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+  ) AS has_perm;
+
+-- ------------------------------------------------------------
+-- 6) 复查是否执行成功(只读,可单独反复执行)
+--    期望:menu_ready=1,role_3003_ready=1,zhulong_ready=1
+-- ------------------------------------------------------------
+SELECT
+  EXISTS (
+    SELECT 1 FROM sys_menu
+    WHERE perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+      AND menu_type = 'F'
+      AND status = '0'
+      AND parent_id IS NOT NULL
+  ) AS menu_ready,
+  EXISTS (
+    SELECT 1
+    FROM sys_role_menu rm
+    JOIN sys_menu m ON m.menu_id = rm.menu_id
+    WHERE rm.role_id = 3003
+      AND m.perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+  ) AS role_3003_ready,
+  EXISTS (
+    SELECT 1
+    FROM sys_user u
+    JOIN sys_user_role ur ON ur.user_id = u.user_id
+    JOIN sys_role_menu rm ON rm.role_id = ur.role_id
+    JOIN sys_menu m ON m.menu_id = rm.menu_id
+    WHERE u.user_id = 223
+      AND u.nick_name = '朱茏'
+      AND m.perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+      AND m.status = '0'
+  ) AS zhulong_ready;
+
+-- 明细:按钮挂在哪个菜单下,哪些角色拿到了
+SELECT
+  m.menu_id,
+  m.menu_name,
+  m.parent_id,
+  p.menu_name AS parent_menu_name,
+  m.perms,
+  m.menu_type,
+  m.status,
+  r.role_id,
+  r.role_name
+FROM sys_menu m
+LEFT JOIN sys_menu p ON p.menu_id = m.parent_id
+LEFT JOIN sys_role_menu rm ON rm.menu_id = m.menu_id
+LEFT JOIN sys_role r ON r.role_id = rm.role_id
+WHERE m.perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+ORDER BY r.role_id;
+
+-- 明细:朱茏通过哪个角色拥有该权限(有行即成功)
+SELECT
+  u.user_id,
+  u.user_name,
+  u.nick_name,
+  r.role_id,
+  r.role_name,
+  m.menu_id,
+  m.menu_name,
+  m.perms,
+  m.status AS menu_status
+FROM sys_user u
+JOIN sys_user_role ur ON ur.user_id = u.user_id
+JOIN sys_role r ON r.role_id = ur.role_id
+JOIN sys_role_menu rm ON rm.role_id = r.role_id
+JOIN sys_menu m ON m.menu_id = rm.menu_id
+WHERE u.user_id = 223
+  AND m.perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth';
+
+-- ------------------------------------------------------------
+-- 回滚示例(按需执行,把后缀换成上面查出的备份表名)
+-- ------------------------------------------------------------
+-- DELETE FROM sys_role_menu
+-- WHERE menu_id IN (
+--   SELECT menu_id FROM sys_menu
+--   WHERE perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+-- );
+-- DELETE FROM sys_menu
+-- WHERE perms = 'xgStudentManagerSource:studentManagerSource:grantDeptAuth'
+--   AND menu_id = 2005461500000000003;
+-- -- 或整表回滚:
+-- -- TRUNCATE TABLE sys_role_menu;
+-- -- INSERT INTO sys_role_menu SELECT * FROM sys_role_menu_bak_YYYYMMDD_HHMMSS;
+-- -- TRUNCATE TABLE sys_menu;
+-- -- INSERT INTO sys_menu SELECT * FROM sys_menu_bak_YYYYMMDD_HHMMSS;

+ 132 - 0
docs/设计文档/database/fix_朱茏_学籍异动新增权限.sql

@@ -0,0 +1,132 @@
+-- ============================================================
+-- 临时权限修复:学籍异动「新增」权限(退学/休学/复学/毕业提交接口)
+-- 背景:
+--   前端退学按钮权限:studentMsg:status:tuixue
+--   后端提交接口校验:studentMsg:studentStatusChange:add
+--   系统菜单中原先缺少 add 权限码,导致有按钮但提交报 403
+-- 适用:
+--   朱茏老师(user_id=223,角色含 3003-学生信息管理)及同类角色
+-- 执行后:
+--   相关用户需重新登录,使权限缓存生效
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- 0) 修改前备份相关表(带时间戳后缀,避免覆盖)
+-- ------------------------------------------------------------
+SET @bak_suffix = DATE_FORMAT(NOW(), '%Y%m%d_%H%i%s');
+
+SET @sql_bak_menu = CONCAT(
+  'CREATE TABLE IF NOT EXISTS sys_menu_bak_', @bak_suffix,
+  ' AS SELECT * FROM sys_menu'
+);
+PREPARE stmt_bak_menu FROM @sql_bak_menu;
+EXECUTE stmt_bak_menu;
+DEALLOCATE PREPARE stmt_bak_menu;
+
+SET @sql_bak_role_menu = CONCAT(
+  'CREATE TABLE IF NOT EXISTS sys_role_menu_bak_', @bak_suffix,
+  ' AS SELECT * FROM sys_role_menu'
+);
+PREPARE stmt_bak_role_menu FROM @sql_bak_role_menu;
+EXECUTE stmt_bak_role_menu;
+DEALLOCATE PREPARE stmt_bak_role_menu;
+
+-- 查看本次备份表名
+SELECT
+  CONCAT('sys_menu_bak_', @bak_suffix) AS menu_backup_table,
+  CONCAT('sys_role_menu_bak_', @bak_suffix) AS role_menu_backup_table;
+
+-- ------------------------------------------------------------
+-- 1) 新增「学籍异动-新增」按钮权限(挂在「学籍异动信息」菜单下)
+-- ------------------------------------------------------------
+INSERT INTO sys_menu (
+  menu_id, menu_name, parent_id, order_num, path, component, query_param,
+  is_frame, is_cache, menu_type, visible, status, perms, icon,
+  create_dept, create_by, create_time, update_by, update_time, remark
+)
+SELECT
+  2005461500000000001,
+  '新增',
+  2004197349643042818,  -- 学籍异动信息
+  0,
+  '',
+  NULL,
+  NULL,
+  1,
+  0,
+  'F',
+  '0',
+  '0',
+  'studentMsg:studentStatusChange:add',
+  '',
+  100,
+  1,
+  NOW(),
+  1,
+  NOW(),
+  '补齐学籍异动提交接口权限,解决退学/休学等提交403'
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_menu WHERE perms = 'studentMsg:studentStatusChange:add'
+);
+
+-- 若库中已用其他 menu_id 创建了同名 perms,下面授权统一按 perms 查找 menu_id
+
+-- ------------------------------------------------------------
+-- 2) 给「学生信息管理(保留)」角色(3003)授权(朱茏已拥有该角色)
+-- ------------------------------------------------------------
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 3003, m.menu_id
+FROM sys_menu m
+WHERE m.perms = 'studentMsg:studentStatusChange:add'
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_menu rm
+    WHERE rm.role_id = 3003 AND rm.menu_id = m.menu_id
+  );
+
+-- ------------------------------------------------------------
+-- 3) 兼容:所有已拥有「退学」按钮权限的角色,一并补上 add
+-- ------------------------------------------------------------
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT DISTINCT rm.role_id, add_menu.menu_id
+FROM sys_role_menu rm
+JOIN sys_menu tuixue ON tuixue.menu_id = rm.menu_id AND tuixue.perms = 'studentMsg:status:tuixue'
+JOIN sys_menu add_menu ON add_menu.perms = 'studentMsg:studentStatusChange:add'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_role_menu x
+  WHERE x.role_id = rm.role_id AND x.menu_id = add_menu.menu_id
+);
+
+-- ------------------------------------------------------------
+-- 4) 校验:朱茏是否已具备 add 权限
+-- ------------------------------------------------------------
+SELECT
+  u.user_id,
+  u.user_name,
+  u.nick_name,
+  r.role_id,
+  r.role_name,
+  m.perms,
+  m.menu_name
+FROM sys_user u
+JOIN sys_user_role ur ON ur.user_id = u.user_id
+JOIN sys_role r ON r.role_id = ur.role_id
+JOIN sys_role_menu rm ON rm.role_id = r.role_id
+JOIN sys_menu m ON m.menu_id = rm.menu_id
+WHERE u.user_id = 223
+  AND m.perms IN (
+    'studentMsg:status:tuixue',
+    'studentMsg:studentStatusChange:add',
+    'studentMsg:studentStatusChange:list'
+  )
+ORDER BY m.perms, r.role_id;
+
+-- ------------------------------------------------------------
+-- 回滚示例(按需执行,把后缀换成上面查出的备份表名)
+-- ------------------------------------------------------------
+-- TRUNCATE TABLE sys_role_menu;
+-- INSERT INTO sys_role_menu SELECT * FROM sys_role_menu_bak_YYYYMMDD_HHMMSS;
+-- DELETE FROM sys_menu WHERE perms = 'studentMsg:studentStatusChange:add';
+-- -- 或整表回滚菜单:
+-- -- TRUNCATE TABLE sys_menu;
+-- -- INSERT INTO sys_menu SELECT * FROM sys_menu_bak_YYYYMMDD_HHMMSS;

+ 246 - 0
docs/设计文档/database/fix_朱茏_换班级新增权限.sql

@@ -0,0 +1,246 @@
+-- ============================================================
+-- 临时权限修复:换班级「新增」权限(学生信息页提交换班级接口)
+-- 背景:
+--   前端换班级按钮权限:studentMsg:class:change
+--   后端提交接口校验:studentMsg:studentClassChange:add
+--   有按钮但提交报 403 Forbidden(无访问权限)
+-- 适用:
+--   朱茏老师(user_id=223,角色含 3003-学生信息管理)及同类角色
+-- 执行后:
+--   相关用户需重新登录,使权限缓存生效
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- A) 先查:朱茏是否已具备 add / 前端按钮权限(只读,可单独执行)
+-- ------------------------------------------------------------
+SELECT
+  u.user_id,
+  u.user_name,
+  u.nick_name,
+  r.role_id,
+  r.role_name,
+  m.menu_id,
+  m.menu_name,
+  m.perms
+FROM sys_user u
+JOIN sys_user_role ur ON ur.user_id = u.user_id
+JOIN sys_role r ON r.role_id = ur.role_id
+JOIN sys_role_menu rm ON rm.role_id = r.role_id
+JOIN sys_menu m ON m.menu_id = rm.menu_id
+WHERE u.user_id = 223
+  AND m.perms IN (
+    'studentMsg:class:change',
+    'studentMsg:studentClassChange:add',
+    'studentMsg:studentClassChange:list',
+    'studentMsg:studentClassChange:query',
+    'studentMsg:studentClassChange:edit',
+    'studentMsg:studentClassChange:remove',
+    'studentMsg:studentClassChange:export'
+  )
+ORDER BY m.perms, r.role_id;
+
+-- 汇总:是否具备关键权限(有行=有,无行=没有)
+SELECT
+  'studentMsg:class:change' AS perms,
+  EXISTS (
+    SELECT 1
+    FROM sys_user_role ur
+    JOIN sys_role_menu rm ON rm.role_id = ur.role_id
+    JOIN sys_menu m ON m.menu_id = rm.menu_id
+    WHERE ur.user_id = 223 AND m.perms = 'studentMsg:class:change'
+  ) AS has_perm
+UNION ALL
+SELECT
+  'studentMsg:studentClassChange:add',
+  EXISTS (
+    SELECT 1
+    FROM sys_user_role ur
+    JOIN sys_role_menu rm ON rm.role_id = ur.role_id
+    JOIN sys_menu m ON m.menu_id = rm.menu_id
+    WHERE ur.user_id = 223 AND m.perms = 'studentMsg:studentClassChange:add'
+  );
+
+-- 库中是否已存在 add 菜单权限码
+SELECT menu_id, menu_name, parent_id, perms, status, visible
+FROM sys_menu
+WHERE perms IN (
+  'studentMsg:class:change',
+  'studentMsg:studentClassChange:add',
+  'studentMsg:studentClassChange:list'
+)
+ORDER BY perms;
+
+-- ============================================================
+-- 以下为修复段:确认缺权限后再执行;先备份再改
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- 0) 修改前备份相关表(带时间戳后缀,避免覆盖)
+-- ------------------------------------------------------------
+SET @bak_suffix = DATE_FORMAT(NOW(), '%Y%m%d_%H%i%s');
+
+SET @sql_bak_menu = CONCAT(
+  'CREATE TABLE IF NOT EXISTS sys_menu_bak_', @bak_suffix,
+  ' AS SELECT * FROM sys_menu'
+);
+PREPARE stmt_bak_menu FROM @sql_bak_menu;
+EXECUTE stmt_bak_menu;
+DEALLOCATE PREPARE stmt_bak_menu;
+
+SET @sql_bak_role_menu = CONCAT(
+  'CREATE TABLE IF NOT EXISTS sys_role_menu_bak_', @bak_suffix,
+  ' AS SELECT * FROM sys_role_menu'
+);
+PREPARE stmt_bak_role_menu FROM @sql_bak_role_menu;
+EXECUTE stmt_bak_role_menu;
+DEALLOCATE PREPARE stmt_bak_role_menu;
+
+SELECT
+  CONCAT('sys_menu_bak_', @bak_suffix) AS menu_backup_table,
+  CONCAT('sys_role_menu_bak_', @bak_suffix) AS role_menu_backup_table;
+
+-- ------------------------------------------------------------
+-- 1) 解析父菜单:优先挂在「换班级」列表菜单下,否则挂在「换班级」按钮同级父菜单
+-- ------------------------------------------------------------
+SET @parent_id = (
+  SELECT parent_id
+  FROM sys_menu
+  WHERE perms = 'studentMsg:studentClassChange:list'
+    AND menu_type IN ('C', 'M')
+  ORDER BY menu_id
+  LIMIT 1
+);
+
+-- list 本身若是目录/菜单,直接用其 menu_id 作父级
+SET @parent_id = IFNULL(
+  (
+    SELECT menu_id
+    FROM sys_menu
+    WHERE perms = 'studentMsg:studentClassChange:list'
+      AND menu_type IN ('C', 'M')
+    ORDER BY menu_id
+    LIMIT 1
+  ),
+  @parent_id
+);
+
+-- 仍找不到时,退化为 class:change 按钮的父菜单
+SET @parent_id = IFNULL(
+  @parent_id,
+  (
+    SELECT parent_id
+    FROM sys_menu
+    WHERE perms = 'studentMsg:class:change'
+    ORDER BY menu_id
+    LIMIT 1
+  )
+);
+
+-- 最终兜底:学籍异动信息菜单(与学籍异动修复脚本同父级,仅当上面都找不到时)
+SET @parent_id = IFNULL(@parent_id, 2004197349643042818);
+
+SELECT @parent_id AS resolved_parent_id;
+
+-- ------------------------------------------------------------
+-- 2) 新增「换班级-新增」按钮权限(若不存在)
+-- ------------------------------------------------------------
+INSERT INTO sys_menu (
+  menu_id, menu_name, parent_id, order_num, path, component, query_param,
+  is_frame, is_cache, menu_type, visible, status, perms, icon,
+  create_dept, create_by, create_time, update_by, update_time, remark
+)
+SELECT
+  2005461500000000002,
+  '新增',
+  @parent_id,
+  1,
+  '',
+  NULL,
+  NULL,
+  1,
+  0,
+  'F',
+  '0',
+  '0',
+  'studentMsg:studentClassChange:add',
+  '',
+  100,
+  1,
+  NOW(),
+  1,
+  NOW(),
+  '补齐换班级提交接口权限,解决学生信息页换班级提交403'
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_menu WHERE perms = 'studentMsg:studentClassChange:add'
+);
+
+-- ------------------------------------------------------------
+-- 3) 给「学生信息管理(保留)」角色(3003)授权
+-- ------------------------------------------------------------
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT 3003, m.menu_id
+FROM sys_menu m
+WHERE m.perms = 'studentMsg:studentClassChange:add'
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_menu rm
+    WHERE rm.role_id = 3003 AND rm.menu_id = m.menu_id
+  );
+
+-- ------------------------------------------------------------
+-- 4) 兼容:所有已有「换班级」按钮权限的角色,一并补上 add
+-- ------------------------------------------------------------
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT DISTINCT rm.role_id, add_menu.menu_id
+FROM sys_role_menu rm
+JOIN sys_menu btn ON btn.menu_id = rm.menu_id AND btn.perms = 'studentMsg:class:change'
+JOIN sys_menu add_menu ON add_menu.perms = 'studentMsg:studentClassChange:add'
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_role_menu x
+  WHERE x.role_id = rm.role_id AND x.menu_id = add_menu.menu_id
+);
+
+-- ------------------------------------------------------------
+-- 5) 校验:朱茏是否已具备 add 权限
+-- ------------------------------------------------------------
+SELECT
+  u.user_id,
+  u.user_name,
+  u.nick_name,
+  r.role_id,
+  r.role_name,
+  m.perms,
+  m.menu_name,
+  m.menu_id
+FROM sys_user u
+JOIN sys_user_role ur ON ur.user_id = u.user_id
+JOIN sys_role r ON r.role_id = ur.role_id
+JOIN sys_role_menu rm ON rm.role_id = r.role_id
+JOIN sys_menu m ON m.menu_id = rm.menu_id
+WHERE u.user_id = 223
+  AND m.perms IN (
+    'studentMsg:class:change',
+    'studentMsg:studentClassChange:add',
+    'studentMsg:studentClassChange:list'
+  )
+ORDER BY m.perms, r.role_id;
+
+SELECT
+  'studentMsg:studentClassChange:add' AS perms,
+  EXISTS (
+    SELECT 1
+    FROM sys_user_role ur
+    JOIN sys_role_menu rm ON rm.role_id = ur.role_id
+    JOIN sys_menu m ON m.menu_id = rm.menu_id
+    WHERE ur.user_id = 223 AND m.perms = 'studentMsg:studentClassChange:add'
+  ) AS has_perm;
+
+-- ------------------------------------------------------------
+-- 回滚示例(按需执行,把后缀换成上面查出的备份表名)
+-- ------------------------------------------------------------
+-- TRUNCATE TABLE sys_role_menu;
+-- INSERT INTO sys_role_menu SELECT * FROM sys_role_menu_bak_YYYYMMDD_HHMMSS;
+-- DELETE FROM sys_menu WHERE perms = 'studentMsg:studentClassChange:add';
+-- -- 或整表回滚菜单:
+-- -- TRUNCATE TABLE sys_menu;
+-- -- INSERT INTO sys_menu SELECT * FROM sys_menu_bak_YYYYMMDD_HHMMSS;

+ 1 - 1
docs/设计文档/database/hzdyjs_xg0820.sql

@@ -4622,7 +4622,7 @@ CREATE TABLE `xg_holiday_stay` (
   `apply_end_date` datetime NOT NULL COMMENT '申请留宿结束日期',
   `stay_reason` text NOT NULL COMMENT '留宿原因(必填)',
   `stay_reason_type` varchar(20) DEFAULT NULL COMMENT '留宿原因类型(学习、实习、家庭困难、其他)',
-  `student_phone` varchar(20) NOT NULL COMMENT '学生联系电话',
+  `student_phone` varchar(20) DEFAULT NULL COMMENT '学生联系电话',
   `emergency_contact` varchar(50) NOT NULL COMMENT '紧急联系人姓名',
   `emergency_phone` varchar(20) NOT NULL COMMENT '紧急联系人电话',
   `parent_consent` char(1) NOT NULL DEFAULT '1' COMMENT '家长同意(0否 1是)',

+ 23 - 0
docs/设计文档/database/假期留宿_student_phone允许为空.sql

@@ -0,0 +1,23 @@
+-- ============================================================
+-- 假期留宿:student_phone 允许为空
+-- 背景:学生档案无手机号时,申请 INSERT 省略 student_phone,
+--       原 NOT NULL 无默认值会报 Field 'student_phone' doesn't have a default value
+-- 目的:仅改库即可恢复申请,无需立即发版
+-- 日期:2026-10-15
+-- ============================================================
+
+-- 主表:允许联系电话为空
+ALTER TABLE `xg_holiday_stay`
+  MODIFY COLUMN `student_phone` varchar(20) DEFAULT NULL COMMENT '学生联系电话';
+
+-- 如存在历史表,一并放宽(没有可跳过)
+-- ALTER TABLE `xg_holiday_stay_his`
+--   MODIFY COLUMN `student_phone` varchar(20) DEFAULT NULL COMMENT '学生联系电话';
+
+-- ------------------------------------------------------------
+-- 可选预防:紧急联系人/电话同样是 NOT NULL,表单空串一般能写入;
+-- 若希望彻底避免同类 SQL 报错,可一并执行:
+-- ALTER TABLE `xg_holiday_stay`
+--   MODIFY COLUMN `emergency_contact` varchar(50) DEFAULT NULL COMMENT '紧急联系人姓名',
+--   MODIFY COLUMN `emergency_phone` varchar(20) DEFAULT NULL COMMENT '紧急联系人电话';
+-- ------------------------------------------------------------

+ 13 - 1
mobile/src/pages/xg/xsjcxx/xsqj/cancelLeaveEdit/index.vue

@@ -1420,6 +1420,18 @@ export default {
             } catch (_) {}
         },
 
+        // 请假单上的 0/1/2/3 不是流程状态,不能随销假保存回写
+        resolveSubmitCancelStatus(action) {
+            if (action === 'draft') {
+                return 'draft';
+            }
+            const current = this.cancelForm.status;
+            if (!current || ['0', '1', '2', '3'].includes(String(current))) {
+                return 'draft';
+            }
+            return current;
+        },
+
         // ============== 审批与提交 ==============
         async handleApprovalVerifyOpen() {
             this.$refs.submitVerifyRef.openDialog(this.routeParams.taskId || '');
@@ -1548,7 +1560,7 @@ export default {
                 cancelLocation: this.cancelForm.cancelLocation,
                 cancelRemark: this.cancelForm.cancelRemark,
                 cancelPhotoOssIds: this.cancelForm.cancelPhotoOssIds,
-                cancelStatus: status === 'draft' ? 'draft' : this.cancelForm.status || 'draft',
+                cancelStatus: this.resolveSubmitCancelStatus(status),
             };
 
             try {

+ 12 - 3
mobile/src/pages/xgss/jqzs/apply.vue

@@ -79,13 +79,13 @@
                         count
                     ></u-textarea>
                 </u-form-item>
-                <u-form-item label="联系电话" prop="studentPhone">
+                <u-form-item label="联系电话" prop="studentPhone" required>
                     <u-input v-model="form.studentPhone" placeholder="可自动带出,可修改" />
                 </u-form-item>
-                <u-form-item label="紧急联系人" prop="emergencyContact">
+                <u-form-item label="紧急联系人" prop="emergencyContact" required>
                     <u-input v-model="form.emergencyContact" placeholder="请输入紧急联系人" />
                 </u-form-item>
-                <u-form-item label="紧急电话" prop="emergencyPhone">
+                <u-form-item label="紧急电话" prop="emergencyPhone" required>
                     <u-input v-model="form.emergencyPhone" placeholder="请输入紧急联系电话" />
                 </u-form-item>
                 <u-form-item label="家长同意" prop="parentConsent">
@@ -308,6 +308,15 @@ export default {
             if (!this.form.stayReason || !this.form.stayReason.trim()) {
                 return uni.showToast({ title: '请填写留宿原因', icon: 'none' });
             }
+            if (!this.form.studentPhone || !String(this.form.studentPhone).trim()) {
+                return uni.showToast({ title: '请填写联系电话', icon: 'none' });
+            }
+            if (!this.form.emergencyContact || !String(this.form.emergencyContact).trim()) {
+                return uni.showToast({ title: '请填写紧急联系人', icon: 'none' });
+            }
+            if (!this.form.emergencyPhone || !String(this.form.emergencyPhone).trim()) {
+                return uni.showToast({ title: '请填写紧急联系电话', icon: 'none' });
+            }
             this.submitting = true;
             try {
                 await applyHolidayStay({ ...this.form });

+ 213 - 0
org/dromara/warm/flow/core/listener/ListenerVariable.java

@@ -0,0 +1,213 @@
+/*
+ *    Copyright 2024-2025, Warm-Flow (290631660@qq.com).
+ *
+ *    Licensed under the Apache License, Version 2.0 (the "License");
+ *    you may not use this file except in compliance with the License.
+ *    You may obtain a copy of the License at
+ *
+ *       https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *    Unless required by applicable law or agreed to in writing, software
+ *    distributed under the License is distributed on an "AS IS" BASIS,
+ *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *    See the License for the specific language governing permissions and
+ *    limitations under the License.
+ */
+package org.dromara.warm.flow.core.listener;
+
+import org.dromara.warm.flow.core.dto.FlowParams;
+import org.dromara.warm.flow.core.entity.Definition;
+import org.dromara.warm.flow.core.entity.Instance;
+import org.dromara.warm.flow.core.entity.Node;
+import org.dromara.warm.flow.core.entity.Task;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 监听器变量
+ *
+ * @author warm
+ * @see <a href="https://warm-flow.dromara.org/master/advanced/listener.html">文档地址</a>
+ */
+public class ListenerVariable {
+
+    /**
+     * 流程定义
+     */
+    private Definition definition;
+
+    /**
+     * 流程实例
+     */
+    private Instance instance;
+
+    /**
+     * 监听器对应的节点
+     */
+    private Node node;
+
+    /**
+     * 当前任务
+     */
+    private Task task;
+
+    /**
+     * 下一次执行的节点集合
+     */
+    private List<Node> nextNodes;
+
+    /**
+     * 新创建任务集合
+     */
+    private List<Task> nextTasks;
+
+    /**
+     * 流程变量
+     */
+    private Map<String, Object> variable;
+
+    /**
+     * 工作流内置参数
+     */
+    private FlowParams flowParams;
+
+
+    public ListenerVariable() {
+    }
+
+    public ListenerVariable(Definition definition, Instance instance, Map<String, Object> variable) {
+        this.definition = definition;
+        this.instance = instance;
+        this.variable = variable;
+    }
+
+    public ListenerVariable(Definition definition, Instance instance, Node node, Map<String, Object> variable) {
+        this.definition = definition;
+        this.instance = instance;
+        this.node = node;
+        this.variable = variable;
+    }
+
+    public ListenerVariable(Definition definition, Instance instance, Map<String, Object> variable, Task task) {
+        this.definition = definition;
+        this.instance = instance;
+        this.variable = variable;
+        this.task = task;
+    }
+
+    public ListenerVariable(Definition definition, Instance instance, Node node, Map<String, Object> variable, Task task) {
+        this.definition = definition;
+        this.instance = instance;
+        this.node = node;
+        this.variable = variable;
+        this.task = task;
+    }
+
+    public ListenerVariable(Definition definition, Instance instance, Node node, Map<String, Object> variable, Task task, List<Node> nextNodes) {
+        this.definition = definition;
+        this.instance = instance;
+        this.node = node;
+        this.variable = variable;
+        this.task = task;
+        this.nextNodes = nextNodes;
+    }
+
+    public ListenerVariable(Definition definition, Instance instance, Node node , Map<String, Object> variable, Task task
+            , List<Node> nextNodes, List<Task> nextTasks) {
+        this.definition = definition;
+        this.instance = instance;
+        this.node = node;
+        this.variable = variable;
+        this.task = task;
+        this.nextNodes = nextNodes;
+        this.nextTasks = nextTasks;
+    }
+
+    public Definition getDefinition() {
+        return definition;
+    }
+
+    public ListenerVariable setDefinition(Definition definition) {
+        this.definition = definition;
+        return this;
+    }
+
+    public Instance getInstance() {
+        return instance;
+    }
+
+    public ListenerVariable setInstance(Instance instance) {
+        this.instance = instance;
+        return this;
+    }
+
+    public Node getNode() {
+        return node;
+    }
+
+    public ListenerVariable setNode(Node node) {
+        this.node = node;
+        return this;
+    }
+
+    public Task getTask() {
+        return task;
+    }
+
+    public ListenerVariable setTask(Task task) {
+        this.task = task;
+        return this;
+    }
+
+    public List<Node> getNextNodes() {
+        return nextNodes;
+    }
+
+    public ListenerVariable setNextNodes(List<Node> nextNodes) {
+        this.nextNodes = nextNodes;
+        return this;
+    }
+
+    public List<Task> getNextTasks() {
+        return nextTasks;
+    }
+
+    public ListenerVariable setNextTasks(List<Task> nextTasks) {
+        this.nextTasks = nextTasks;
+        return this;
+    }
+
+    public Map<String, Object> getVariable() {
+        return variable;
+    }
+
+    public ListenerVariable setVariable(Map<String, Object> variable) {
+        this.variable = variable;
+        return this;
+    }
+
+    public FlowParams getFlowParams() {
+        return flowParams;
+    }
+
+    public ListenerVariable setFlowParams(FlowParams flowParams) {
+        this.flowParams = flowParams;
+        return this;
+    }
+
+
+    @Override
+    public String toString() {
+        return "ListenerVariable{" +
+                "definition=" + definition +
+                ", instance=" + instance +
+                ", node=" + node +
+                ", task=" + task +
+                ", nextNodes=" + nextNodes +
+                ", nextTasks=" + nextTasks +
+                ", variable=" + variable +
+                ", flowParams=" + flowParams +
+                '}';
+    }
+}

+ 3 - 0
web/src/views/xg/xgss/holidayStay/holidayStay/index-sg.vue

@@ -939,6 +939,9 @@ const data = reactive<PageData<HolidayStayForm, HolidayStayQuery>>({
     studentId: [{ required: true, message: '学生ID', trigger: 'blur' }],
     studentName: [{ required: true, message: '学生姓名不能为空', trigger: 'blur' }],
     studentNo: [{ required: true, message: '学号不能为空', trigger: 'blur' }],
+    studentPhone: [{ required: true, message: '学生联系电话不能为空', trigger: 'blur' }],
+    emergencyContact: [{ required: true, message: '紧急联系人不能为空', trigger: 'blur' }],
+    emergencyPhone: [{ required: true, message: '紧急联系电话不能为空', trigger: 'blur' }],
     applyStartDate: [{ required: true, message: '留宿开始日期不能为空', trigger: 'blur' }],
     applyEndDate: [{ required: true, message: '留宿结束日期不能为空', trigger: 'blur' }]
   }

+ 3 - 0
web/src/views/xg/xgss/holidayStay/holidayStay/index.vue

@@ -770,6 +770,9 @@ const data = reactive<PageData<HolidayStayForm, HolidayStayQuery>>({
     studentId: [{ required: true, message: '学生ID', trigger: 'blur' }],
     studentName: [{ required: true, message: '学生姓名不能为空', trigger: 'blur' }],
     studentNo: [{ required: true, message: '学号不能为空', trigger: 'blur' }],
+    studentPhone: [{ required: true, message: '学生联系电话不能为空', trigger: 'blur' }],
+    emergencyContact: [{ required: true, message: '紧急联系人不能为空', trigger: 'blur' }],
+    emergencyPhone: [{ required: true, message: '紧急联系电话不能为空', trigger: 'blur' }],
     applyStartDate: [{ required: true, message: '留宿开始日期不能为空', trigger: 'blur' }],
     applyEndDate: [{ required: true, message: '留宿结束日期不能为空', trigger: 'blur' }]
   }