outlets = body.getOutlets() == null ? Collections.emptyList() : body.getOutlets();
+ if (!outlets.contains(outlet)) {
+ // 出口契约不一致(流程定义改了出口名而端点没跟上):抛错让 flow-center 挂起,不默认放行
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "判断结果未命中出口集合:outlet=" + outlet + " outlets=" + outlets
+ + "(totalHours=" + hours + ", bizId=" + body.getBizId() + ")");
+ }
+ log.info("[dept-judge] bizId={}, instanceId={}, totalHours={}h, outlet={}",
+ body.getBizId(), body.getInstanceId(), hours, outlet);
+ return Collections.singletonMap("outlet", outlet);
+ }
+
+ /** formData 里的请假时长(发起时 bizSnapshot 同源透传;缺字段/非法返回 null 回落查表) */
+ private static Double variablesTotalHours(JudgeBody body) {
+ Object v = body.getVariables() == null ? null : body.getVariables().get("totalHours");
+ if (v instanceof Number) {
+ return ((Number) v).doubleValue();
+ }
+ if (v instanceof String) {
+ try {
+ return Double.valueOf(((String) v).trim());
+ } catch (NumberFormatException e) {
+ // 回落查表
+ }
+ }
+ return null;
+ }
+
+ /** bizId 形态 {leaveId}-{attempt},取首段(同 LeaveFlowCallbackHandler) */
+ private static Integer parseLeaveId(JudgeBody body) {
+ if (!StringUtils.hasText(body.getBizId())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "bizId 缺失,无法定位请假单");
+ }
+ try {
+ return Integer.valueOf(body.getBizId().trim().split("-")[0]);
+ } catch (NumberFormatException e) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "bizId 非法:" + body.getBizId());
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/BaseEntity.java b/src/main/java/com/yunhai/workleave/entity/BaseEntity.java
new file mode 100644
index 0000000..356ceb2
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/BaseEntity.java
@@ -0,0 +1,42 @@
+package com.yunhai.workleave.entity;
+
+import com.yunhai.workleave.common.listener.BaseAuditListener;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.*;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link BaseAuditListener} 自动填充)。
+ * 创建人/更新人只存平台用户ID(不保存账号,见需求「实体不保存教师和学生账号」)。
+ */
+@MappedSuperclass
+@Getter
+@Setter
+@EntityListeners(BaseAuditListener.class)
+public abstract class BaseEntity implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Integer id;
+
+ /** 创建人用户ID(平台用户中心ID) */
+ @Column(name = "create_by", updatable = false)
+ private Integer createBy;
+
+ /** 创建时间 */
+ @Column(name = "create_time", updatable = false)
+ private LocalDateTime createTime;
+
+ /** 更新人用户ID */
+ @Column(name = "update_by")
+ private Integer updateBy;
+
+ /** 更新时间 */
+ @Column(name = "update_time")
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/ContactGroup.java b/src/main/java/com/yunhai/workleave/entity/ContactGroup.java
new file mode 100644
index 0000000..64aaaae
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/ContactGroup.java
@@ -0,0 +1,28 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 自定义联系组(应用设置-自定义组)。组名按学校隔离。
+ */
+@NoArgsConstructor
+@Getter
+@Setter
+@Entity
+@Table(name = "contact_group")
+public class ContactGroup extends BaseEntity {
+
+ /** 学校ID(多租户隔离) */
+ @Column(name = "sch_id")
+ private Integer schId;
+
+ /** 组名 */
+ @Column(name = "name", length = 100, nullable = false)
+ private String name;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/ContactGroupUser.java b/src/main/java/com/yunhai/workleave/entity/ContactGroupUser.java
new file mode 100644
index 0000000..769cba1
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/ContactGroupUser.java
@@ -0,0 +1,29 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 联系组-成员关联。只存成员(教师)平台用户ID,不存账号。
+ */
+@NoArgsConstructor
+@Getter
+@Setter
+@Entity
+@Table(name = "contact_group_user",
+ uniqueConstraints = @UniqueConstraint(name = "uk_group_user", columnNames = {"group_id", "user_id"}))
+public class ContactGroupUser extends BaseEntity {
+
+ @Column(name = "group_id", nullable = false)
+ private Integer groupId;
+
+ /** 成员(教师)平台用户ID */
+ @Column(name = "user_id", nullable = false)
+ private Integer userId;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveApply.java b/src/main/java/com/yunhai/workleave/entity/LeaveApply.java
new file mode 100644
index 0000000..781bb6f
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveApply.java
@@ -0,0 +1,117 @@
+package com.yunhai.workleave.entity;
+
+import com.yunhai.workleave.enums.LeaveStatusEnum;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+import java.time.LocalDateTime;
+
+/**
+ * 请假申请主表。
+ * 表名不用 leave:MySQL 保留字。人员只存平台用户ID(不存账号),
+ * 姓名/头像/类型名为冗余展示字段。审核流转由平台流程审核器承担,
+ * 本表只保留单据状态与流程实例映射(biz_id / instance_id / instance_version)。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_apply")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveApply extends BaseEntity {
+
+ /** 学校ID(多租户隔离) */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 申请人用户ID(平台用户中心数字ID,不存账号) */
+ @Column(name = "user_id", nullable = false)
+ private Integer userId;
+
+ /** 申请人姓名(冗余展示) */
+ @Column(name = "user_name", length = 64)
+ private String userName;
+
+ /** 申请人头像 URL(冗余展示,可空) */
+ @Column(name = "user_photo", length = 255)
+ private String userPhoto;
+
+ /** 请假类型(leaveType 字典 dictKey) */
+ @Column(name = "leave_type", length = 50, nullable = false)
+ private String leaveType;
+
+ /** 请假类型名称(冗余展示) */
+ @Column(name = "leave_type_name", length = 100)
+ private String leaveTypeName;
+
+ /** 开始时间 yyyy-MM-dd HH:mm:ss */
+ @Column(name = "start_time", nullable = false, length = 19)
+ private String startTime;
+
+ /** 结束时间 yyyy-MM-dd HH:mm:ss */
+ @Column(name = "end_time", nullable = false, length = 19)
+ private String endTime;
+
+ /** 请假时长(小时,服务端按作息复核后落库) */
+ @Column(name = "total_hours")
+ private Double totalHours;
+
+ /** 请假事由 */
+ @Column(name = "reason", length = 500)
+ private String reason;
+
+ /** 附件ID CSV(up_file.id) */
+ @Column(name = "file_ids", length = 500)
+ private String fileIds;
+
+ /** 抄送人用户ID CSV(发起时选择;流程 NOTIFY 节点另发消息) */
+ @Column(name = "cc_user_ids", length = 500)
+ private String ccUserIds;
+
+ /** 课务安排:0 无课务安排(默认)/ 1 自行安排 / 2 教务安排(对应历史课务安排三选一) */
+ @Column(name = "course_arrange")
+ private Integer courseArrange = 0;
+
+ /** 单据状态:见 {@link LeaveStatusEnum} */
+ @Column(name = "status", nullable = false)
+ private Integer status;
+
+ /** 流程业务ID({leaveId}-{attempt},流程中心按 bizType+bizId 幂等) */
+ @Column(name = "biz_id", length = 64)
+ private String bizId;
+
+ /** 流程实例ID(流程中心 instanceId) */
+ @Column(name = "instance_id")
+ private Long instanceId;
+
+ /** 已处理的最大流程版本号(乱序门) */
+ @Column(name = "instance_version")
+ private Integer instanceVersion;
+
+ /** 流程定义标识(flowGuid) */
+ @Column(name = "flow_guid", length = 64)
+ private String flowGuid;
+
+ /** 第几次提交(拒绝后重新提交自增,驱动 bizId 变化) */
+ @Column(name = "flow_attempt")
+ private Integer flowAttempt;
+
+ /** 终态快照 JSON(回调事件 data:审批意见/节点等) */
+ @Column(name = "snapshot", columnDefinition = "TEXT")
+ private String snapshot;
+
+ /** 申请时间(列表排序/统计口径) */
+ @Column(name = "apply_time", nullable = false)
+ private LocalDateTime applyTime;
+
+ /** 销假时间 */
+ @Column(name = "cancel_time")
+ private LocalDateTime cancelTime;
+
+ /** 销假事由/备注 */
+ @Column(name = "cancel_reason", length = 500)
+ private String cancelReason;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveAuditTrace.java b/src/main/java/com/yunhai/workleave/entity/LeaveAuditTrace.java
new file mode 100644
index 0000000..89390ef
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveAuditTrace.java
@@ -0,0 +1,52 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+import java.time.LocalDateTime;
+
+/**
+ * 审核痕迹表(本地留痕)。
+ * 流程中心的审核历史在中心侧;本表在审核动作提交成功后落一条,
+ * 支撑「我已审核」列表与详情时间线补全,避免每次翻页都查流程中心。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_audit_trace")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveAuditTrace extends BaseEntity {
+
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ @Column(name = "leave_id", nullable = false)
+ private Integer leaveId;
+
+ /** 流程任务ID */
+ @Column(name = "task_id")
+ private Long taskId;
+
+ /** 动作:approve / reject */
+ @Column(name = "action", length = 20)
+ private String action;
+
+ /** 审批意见 */
+ @Column(name = "opinion", length = 500)
+ private String opinion;
+
+ /** 操作人 */
+ @Column(name = "operator_id")
+ private Integer operatorId;
+ @Column(name = "operator_name", length = 64)
+ private String operatorName;
+
+ /** 审核时间 */
+ @Column(name = "audit_time", nullable = false)
+ private LocalDateTime auditTime;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveCc.java b/src/main/java/com/yunhai/workleave/entity/LeaveCc.java
new file mode 100644
index 0000000..4f7d64d
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveCc.java
@@ -0,0 +1,42 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 抄送留痕表。
+ * 平台流程中心 NOTIFY 节点负责发抄送消息;本表在回调 flow.cc.sent 时落痕,
+ * 供「抄送列表」页渲染(历史 Common_SendUserinfo/SendUserID 的替代)。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_cc")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveCc extends BaseEntity {
+
+ /** 学校ID */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 请假单ID */
+ @Column(name = "leave_id", nullable = false)
+ private Integer leaveId;
+
+ /** 被抄送人用户ID */
+ @Column(name = "cc_user_id")
+ private Integer ccUserId;
+
+ /** 被抄送人姓名(冗余展示) */
+ @Column(name = "cc_user_name", length = 64)
+ private String ccUserName;
+
+ /** 流程实例ID(幂等键组成部分) */
+ @Column(name = "instance_id")
+ private Long instanceId;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveFlowBind.java b/src/main/java/com/yunhai/workleave/entity/LeaveFlowBind.java
new file mode 100644
index 0000000..44da04e
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveFlowBind.java
@@ -0,0 +1,64 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 审批流程绑定(一校多行)。
+ * 对应《子应用流程与消息对接手册 v1.0》§1.3/§3.2:本应用 bizType 恒为 {@code leave},
+ * 多套审批流用 flowGuid 区分(如 leave-annual-v1 / leave-cancel-v1),
+ * 这里记录「哪个场景走哪条 flowGuid」。
+ *
+ * 两级作用域:
+ *
+ * - {@link #SCOPE_DEFAULT} 学校默认流程(一校仅一条,兜底所有场景)
+ * - {@link #SCOPE_TYPE} 按请假类型指定流程(scopeKey = 请假类型 dictKey),优先级高于默认
+ *
+ * 未绑定任何记录时,回落到配置 {@code leave.flow.guid}。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_flow_bind")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveFlowBind extends BaseEntity {
+
+ /** 绑定范围:学校默认流程 */
+ public static final Integer SCOPE_DEFAULT = 0;
+
+ /** 绑定范围:按请假类型指定流程 */
+ public static final Integer SCOPE_TYPE = 1;
+
+ /** 学校ID */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 绑定范围:0=学校默认 / 1=按请假类型 */
+ @Column(name = "bind_scope", nullable = false)
+ private Integer bindScope = SCOPE_DEFAULT;
+
+ /** 范围键:scope=1 时为请假类型 dictKey;scope=0 时为 "-"(便于唯一性判断) */
+ @Column(name = "scope_key", length = 100)
+ private String scopeKey = "-";
+
+ /** 流程定义标识(流程中心「流程编排」注册所得的 flowGuid) */
+ @Column(name = "flow_guid", nullable = false, length = 100)
+ private String flowGuid;
+
+ /** 流程名称(冗余展示;流程中心改名后重新绑定即刷新) */
+ @Column(name = "flow_name", length = 200)
+ private String flowName;
+
+ /** 流程版本(冗余展示) */
+ @Column(name = "flow_version")
+ private Integer flowVersion;
+
+ /** 节点摘要(冗余展示,如「发起 → 部门审核 → 校长审批」) */
+ @Column(name = "node_summary", length = 500)
+ private String nodeSummary;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveFlowEnable.java b/src/main/java/com/yunhai/workleave/entity/LeaveFlowEnable.java
new file mode 100644
index 0000000..f3a43f4
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveFlowEnable.java
@@ -0,0 +1,48 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 流程启用绑定(一校多行):标记哪条流程「启用并绑定到本应用」。
+ *
+ * 背景:开放网关 oapi 的流程列表不按应用隔离(无 code 字段),
+ * 通知流程等非本应用的流程会混进候选列表。启用绑定开关用于:
+ *
+ * - 开启(enable):登记该流程为本应用可用,并回调登记到流程中心;之后才允许设为默认 / 按类型绑定
+ * - 关闭(disable):该流程仅展示、不可被绑定;若已有默认/按类型绑定,一并解除
+ *
+ * 唯一性由 (sch_id, flow_guid) 逻辑保证,不建物理唯一索引(与 leave_flow_bind 同理)。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_flow_enable")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveFlowEnable extends BaseEntity {
+
+ /** 学校ID */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 流程定义标识(流程中心「流程编排」注册所得的 flowGuid) */
+ @Column(name = "flow_guid", nullable = false, length = 100)
+ private String flowGuid;
+
+ /** 流程名称(冗余展示) */
+ @Column(name = "flow_name", length = 200)
+ private String flowName;
+
+ /** 流程版本(冗余展示) */
+ @Column(name = "flow_version")
+ private Integer flowVersion;
+
+ /** 节点摘要(冗余展示,如「发起 → 部门审核 → 校长审批」) */
+ @Column(name = "node_summary", length = 500)
+ private String nodeSummary;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveSection.java b/src/main/java/com/yunhai/workleave/entity/LeaveSection.java
new file mode 100644
index 0000000..20484c6
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveSection.java
@@ -0,0 +1,41 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 节次表(设置页可维护;P7 指派代课/调课的节次数据源)。
+ * 对应历史 Setting_CourseSection,rank 决定排序。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_section")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveSection extends BaseEntity {
+
+ /** 学校ID */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 节次名称(如 "第一节") */
+ @Column(name = "name", length = 100)
+ private String name;
+
+ /** 排序号 */
+ @Column(name = "rank_no")
+ private Integer rankNo = 0;
+
+ /** 开始时间 "HH:mm" */
+ @Column(name = "begin_time", length = 10)
+ private String beginTime;
+
+ /** 结束时间 "HH:mm" */
+ @Column(name = "end_time", length = 10)
+ private String endTime;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveSetting.java b/src/main/java/com/yunhai/workleave/entity/LeaveSetting.java
new file mode 100644
index 0000000..08b3e38
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveSetting.java
@@ -0,0 +1,54 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 学校请假设置(一校一行)。
+ * 对应历史 QJ_LeaveMustItem,按新方案裁剪:
+ * 「按小时/按天」拆成固化为小时制,「半天上下午」字段取消,「课务安排」延后到 P7。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_setting")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveSetting extends BaseEntity {
+
+ /** 学校ID */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 请假事由是否必填:0 选填 / 1 必填(默认 1) */
+ @Column(name = "reason_must")
+ private Integer reasonMust = 1;
+
+ /** 是否显示请假说明:0 否 / 1 是(默认 1) */
+ @Column(name = "show_explain")
+ private Integer showExplain = 1;
+
+ /** 是否启用课务安排(P7 预留,默认 0) */
+ @Column(name = "enable_course")
+ private Integer enableCourse = 0;
+
+ /** 是否允许换课(P7 预留,默认 0) */
+ @Column(name = "allow_exchange")
+ private Integer allowExchange = 0;
+
+ /** 时长管控的请假类型 CSV(dictKey;留空=全部类型不限时长) */
+ @Column(name = "time_managed_types", length = 500)
+ private String timeManagedTypes;
+
+ /** 是否启用时长管控:0 否 / 1 是(默认 0,与历史「类型绑定即管控」语义等价但集中开关) */
+ @Column(name = "time_limit_enabled")
+ private Integer timeLimitEnabled = 0;
+
+ /** 请假计算口径:1 按小时(默认,历史 IsCalculateWay=true)/ 0 按天(半天/半天以上输入模式) */
+ @Column(name = "calc_way")
+ private Integer calcWay = 1;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveTimeSet.java b/src/main/java/com/yunhai/workleave/entity/LeaveTimeSet.java
new file mode 100644
index 0000000..6a72183
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveTimeSet.java
@@ -0,0 +1,50 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 学校请假作息时间(一校一行)。
+ * 对应历史 QJ_SetSchTime。默认 上午 08:00-12:00 / 下午 13:00-17:00,
+ * 最短请假间隔 10 分钟(intervalMinutes 目前仅存设置,不参与时长计算)。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_time_set")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveTimeSet extends BaseEntity {
+
+ /** 学校ID */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 上午开始 "HH:mm" */
+ @Column(name = "forenoon1", length = 10)
+ private String forenoon1 = "08:00";
+
+ /** 上午结束 "HH:mm" */
+ @Column(name = "forenoon2", length = 10)
+ private String forenoon2 = "12:00";
+
+ /** 下午开始 "HH:mm" */
+ @Column(name = "afternoon1", length = 10)
+ private String afternoon1 = "13:00";
+
+ /** 下午结束 "HH:mm" */
+ @Column(name = "afternoon2", length = 10)
+ private String afternoon2 = "17:00";
+
+ /** 最短请假间隔(分钟,默认 10) */
+ @Column(name = "interval_minutes")
+ private Integer intervalMinutes = 10;
+
+ /** 最少请假时间(分钟,0 = 不限制;对应历史学校时间设置「最少请假时间」) */
+ @Column(name = "min_minutes")
+ private Integer minMinutes = 0;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveUserTime.java b/src/main/java/com/yunhai/workleave/entity/LeaveUserTime.java
new file mode 100644
index 0000000..cc81afc
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveUserTime.java
@@ -0,0 +1,41 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 教师可用时长池(一校一人一行)。
+ * 对应历史 QJ_SchUserTime。小时制;(schId,userId) 唯一。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_user_time")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveUserTime extends BaseEntity {
+
+ /** 学校ID */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 教师用户ID(平台用户中心ID,不存账号) */
+ @Column(name = "user_id", nullable = false)
+ private Integer userId;
+
+ /** 教师姓名(冗余展示) */
+ @Column(name = "user_name", length = 64)
+ private String userName;
+
+ /** 剩余可用小时数(默认 0,绝不出现 null 相加) */
+ @Column(name = "usable_hours")
+ private Double usableHours = 0D;
+
+ /** 备注 */
+ @Column(name = "remark", length = 500)
+ private String remark;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/LeaveUserTimeRecord.java b/src/main/java/com/yunhai/workleave/entity/LeaveUserTimeRecord.java
new file mode 100644
index 0000000..9cbd501
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/LeaveUserTimeRecord.java
@@ -0,0 +1,68 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+import java.time.LocalDateTime;
+
+/**
+ * 教师时长变更流水。
+ * 对应历史 QJ_SchUserTimeRecord。editHours 正=新加、负=扣除;管理员手动调整
+ * 的流水 leaveType 记为空串,由 describe 区分("新加"/"销假返还"/"扣除"等原样留存)。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "leave_user_time_record")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class LeaveUserTimeRecord extends BaseEntity {
+
+ /** 学校ID */
+ @Column(name = "sch_id", nullable = false)
+ private Integer schId;
+
+ /** 教师用户ID */
+ @Column(name = "user_id", nullable = false)
+ private Integer userId;
+
+ /** 教师姓名(冗余展示) */
+ @Column(name = "user_name", length = 64)
+ private String userName;
+
+ /** 关联请假单ID(管理员手动调整时为空) */
+ @Column(name = "leave_id")
+ private Integer leaveId;
+
+ /** 请假类型 dictKey(手动调整为空串) */
+ @Column(name = "leave_type", length = 50)
+ private String leaveType;
+
+ /** 本次增减小时(正=加,负=减) */
+ @Column(name = "edit_hours")
+ private Double editHours;
+
+ /** 变更前小时 */
+ @Column(name = "old_hours")
+ private Double oldHours;
+
+ /** 变更后小时 */
+ @Column(name = "new_hours")
+ private Double newHours;
+
+ /** 备注("扣除"/"销假返还"/管理员备注/"新加") */
+ @Column(name = "describe_text", length = 500)
+ private String describeText;
+
+ /** 操作人用户ID(审核人/管理员) */
+ @Column(name = "operator_id")
+ private Integer operatorId;
+
+ /** 发生时间(统计过滤口径,独立于审计 createTime) */
+ @Column(name = "occur_time", nullable = false)
+ private LocalDateTime occurTime;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/SysDict.java b/src/main/java/com/yunhai/workleave/entity/SysDict.java
new file mode 100644
index 0000000..b37f3ea
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/SysDict.java
@@ -0,0 +1,45 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 系统字典(主办部门 dept / 活动分类 category)。
+ * schId 做学校隔离;schId=0 视为全局种子数据(学校无自有字典时兜底可用)。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "sys_dict")
+@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
+public class SysDict extends BaseEntity {
+
+ /** 学校ID;0=全局种子 */
+ @Column(name = "sch_id")
+ private Integer schId = 0;
+
+ /** 字典类型:dept=主办部门 / category=活动分类 */
+ @Column(name = "dict_type", length = 50)
+ private String dictType;
+
+ /** 字典 key(前端 CodeID,如 D001/C001) */
+ @Column(name = "dict_key", length = 100)
+ private String dictKey;
+
+ /** 字典 value(显示名称,前端 Name) */
+ @Column(name = "dict_value", length = 200)
+ private String dictValue;
+
+ /** 排序号 */
+ @Column(name = "sort_order")
+ private Integer sortOrder = 0;
+
+ /** 是否启用:0 禁用 / 1 启用 */
+ @Column(name = "enable_flag")
+ private Integer enableFlag = 1;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/SysOperationLog.java b/src/main/java/com/yunhai/workleave/entity/SysOperationLog.java
new file mode 100644
index 0000000..6c6895d
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/SysOperationLog.java
@@ -0,0 +1,84 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.CreationTimestamp;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 系统审计日志实体(移植自 UserclassService SysOperationLog)。
+ * 全量记录 controller 请求:用户、URI、参数、响应(GZIP)、耗时、异常。
+ * 索引由 resources/sql/sys_operation_log.sql 创建,本实体不含 @Table(indexes),
+ * 避免 ddl-auto=update 启动时重复建索引告警。
+ */
+@NoArgsConstructor
+@Data
+@Entity
+@Table(name = "sys_operation_log")
+public class SysOperationLog implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
+ @Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
+ private Integer logLevel;
+
+ @Column(name = "operation_type", nullable = false, length = 16)
+ private String operationType;
+
+ @Column(name = "user_id")
+ private Integer userId;
+
+ @Column(name = "user_name", length = 64)
+ private String userName;
+
+ @Column(name = "sch_id")
+ private Integer schId;
+
+ @Column(name = "request_ip", nullable = false, length = 64)
+ private String requestIp;
+
+ @Column(name = "request_uri", nullable = false, length = 512)
+ private String requestUri;
+
+ @Column(name = "request_method", nullable = false, length = 16)
+ private String requestMethod;
+
+ @Column(name = "class_method", nullable = false, length = 512)
+ private String classMethod;
+
+ @Column(name = "request_params", columnDefinition = "TEXT")
+ private String requestParams;
+
+ /** GZIP 压缩后的响应 JSON(不参与 JSON 序列化) */
+ @Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
+ private byte[] responseData;
+
+ @Column(name = "status_code", nullable = false)
+ private Integer statusCode;
+
+ /** 耗时 ms */
+ @Column(name = "cost_time", nullable = false)
+ private Long costTime;
+
+ @Column(name = "user_agent", length = 512)
+ private String userAgent;
+
+ @Column(name = "error_msg", columnDefinition = "TEXT")
+ private String errorMsg;
+
+ @CreationTimestamp
+ @Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)")
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/yunhai/workleave/entity/UpFile.java b/src/main/java/com/yunhai/workleave/entity/UpFile.java
new file mode 100644
index 0000000..4a53689
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/entity/UpFile.java
@@ -0,0 +1,44 @@
+package com.yunhai.workleave.entity;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+
+/**
+ * 附件元信息实体(按前端 UpFileVo 契约重建)。
+ */
+@NoArgsConstructor
+@Getter
+@Setter
+@Entity
+@Table(name = "up_file")
+public class UpFile extends BaseEntity {
+
+ /** 主题类型(如 "leave") */
+ @Column(name = "topic_type", length = 50)
+ private String topicType;
+
+ /** 主题ID(活动ID;未绑定时为空) */
+ @Column(name = "topic_id")
+ private Integer topicId;
+
+ /** 原始文件名 */
+ @Column(name = "file_true_name", length = 255)
+ private String fileTrueName;
+
+ /** 存储相对路径 */
+ @Column(name = "file_name", length = 255)
+ private String fileName;
+
+ /** 文件大小(字节) */
+ @Column(name = "file_size")
+ private Integer fileSize;
+
+ /** 扩展名(小写) */
+ @Column(name = "file_type", length = 50)
+ private String fileType;
+}
diff --git a/src/main/java/com/yunhai/workleave/enums/LeaveStatusEnum.java b/src/main/java/com/yunhai/workleave/enums/LeaveStatusEnum.java
new file mode 100644
index 0000000..df42d63
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/enums/LeaveStatusEnum.java
@@ -0,0 +1,71 @@
+package com.yunhai.workleave.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 请假单据状态。
+ * 历史状态机的 0~6 收敛为 5 个:撤销待审/通过(撤销被拒)/修改待审
+ * 三个依赖自建流程引擎的状态取消,审核流转交由平台流程审核器承担。
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public enum LeaveStatusEnum {
+
+ /** 审批中(已发起流程,待审核) */
+ APPROVING(0, "审批中"),
+
+ /** 已通过(flow.completed;管控类型扣减时长) */
+ PASSED(1, "已通过"),
+
+ /** 已拒绝(flow.rejected) */
+ REJECTED(2, "已拒绝"),
+
+ /** 已销假(已通过后教师发起销假;返还时长) */
+ CANCELED(3, "已销假"),
+
+ /** 已撤回(flow.withdrawn,提交人撤回) */
+ WITHDRAWN(4, "已撤回");
+
+ private Integer id;
+ private String name;
+
+ public static String nameById(Integer id) {
+ if (id == null) {
+ return "";
+ }
+ for (LeaveStatusEnum e : values()) {
+ if (e.id.equals(id)) {
+ return e.name;
+ }
+ }
+ return "";
+ }
+
+ /** 终态判定:通过/拒绝/销假为终态;撤回与审批中非终态 */
+ public static boolean isFinal(Integer id) {
+ return PASSED.id.equals(id) || REJECTED.id.equals(id) || CANCELED.id.equals(id);
+ }
+
+ /** 是否可编辑:审批中/已拒绝/已撤回 */
+ public static boolean editable(Integer id) {
+ return APPROVING.id.equals(id) || REJECTED.id.equals(id) || WITHDRAWN.id.equals(id);
+ }
+
+ /** 是否可删除:非已通过(已通过只能销假,不能删) */
+ public static boolean deletable(Integer id) {
+ return !PASSED.id.equals(id);
+ }
+
+ /** 该状态是否计入时间冲突(与历史 leave_status in (0,1,5) 对齐) */
+ public static boolean conflictStatus(Integer id) {
+ return APPROVING.id.equals(id) || PASSED.id.equals(id);
+ }
+
+ /** 该状态是否占用时长(在途占用:审批中) */
+ public static boolean occupyingStatus(Integer id) {
+ return APPROVING.id.equals(id);
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/enums/UserType.java b/src/main/java/com/yunhai/workleave/enums/UserType.java
new file mode 100644
index 0000000..826071c
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/enums/UserType.java
@@ -0,0 +1,20 @@
+package com.yunhai.workleave.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 用户类型(与基础平台 BoaUser.type 对齐)。
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public enum UserType {
+ SUPER_ADMINISTRATOR(1, "超级管理员"),
+ SCHOOL_ADMIN(2, "学校管理员"),
+ REGULAR_USER(3, "普通用户");
+
+ private Integer id;
+ private String name;
+}
diff --git a/src/main/java/com/yunhai/workleave/flow/ILeaveFlowService.java b/src/main/java/com/yunhai/workleave/flow/ILeaveFlowService.java
new file mode 100644
index 0000000..ffdbb37
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/flow/ILeaveFlowService.java
@@ -0,0 +1,67 @@
+package com.yunhai.workleave.flow;
+
+import com.yunhai.workleave.entity.LeaveApply;
+import com.yunhai.workleave.model.leave.FlowProgressVo;
+import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
+import com.yunhai.platform.openapi.flow.model.FlowProgress;
+import com.yunhai.platform.openapi.flow.model.FlowSummary;
+import com.yunhai.platform.openapi.flow.model.FlowProgressStep;
+import com.yunhai.platform.openapi.flow.model.FlowProgressTask;
+import com.yunhai.platform.openapi.flow.model.FlowTodoTask;
+import com.yunhai.platform.openapi.flow.model.FlowTodoTaskPage;
+
+import java.util.List;
+
+/**
+ * 流程审核器门面(本模块对 YschFlowOpenApi 的唯一出口)。
+ * 把静态 SDK 调用收敛到一处,便于单测替换与异常语义统一:
+ * 跨仓调用失败一律包成 BizException 抛出,由平台全局异常处理返回统一错误。
+ */
+public interface ILeaveFlowService {
+
+ /** 回调登记(首次发起前确保已注册;结果落 starter 的登记镜像表) */
+ void ensureRegistered(Integer schId, String flowGuid);
+
+ /** 编排注册所得的本模块流程标识(配置 leave.flow.guid,未配时为默认值) */
+ String defaultFlowGuid();
+
+ /**
+ * 流程中心的流程列表(流程编排里已注册的流程)。
+ *
+ * @return 流程摘要列表;流程中心不可达时抛 BizException(由调用方决定是否降级)
+ */
+ List listFlows(Integer schId);
+
+ /** 发起流程:返回实例ID;duplicated=true 表示同 bizId 幂等命中 */
+ FlowApplyResult apply(LeaveApply leave, String bizId, String bizTitle);
+
+ /** 撤回(仅提交人本人 + 尚无任务被办理) */
+ void withdraw(Integer schId, Long instanceId, Integer submitterId);
+
+ /** 我的待办(bizType 固定 leave) */
+ FlowTodoTaskPage todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size, String bizType);
+
+ /** 同意任务 */
+ void approveTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion);
+
+ /** 拒绝任务(意见必填,服务层已校验) */
+ void rejectTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion);
+
+ /** 办件进度(时间线渲染);异常/不可达返回 null(详情页降级展示) */
+ FlowProgress progress(LeaveApply leave);
+
+ /** 流程图短时 URL(不可达返回 null) */
+ String diagramUrl(LeaveApply leave);
+
+ /** 流程中心进度 → 前端视图 */
+ FlowProgressVo toProgressVo(FlowProgress progress);
+
+ /** 待办条目 → 本地字段映射辅助(供 service 组装 TodoVo) */
+ List contentOf(FlowTodoTaskPage page);
+
+ /** 流程节点/任务遍历辅助(进度视图构建用) */
+ List stepsOf(FlowProgress progress);
+
+ /** 任务遍历辅助 */
+ List tasksOf(FlowProgressStep step);
+}
diff --git a/src/main/java/com/yunhai/workleave/flow/LeaveFlowCallbackHandler.java b/src/main/java/com/yunhai/workleave/flow/LeaveFlowCallbackHandler.java
new file mode 100644
index 0000000..f4e1557
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/flow/LeaveFlowCallbackHandler.java
@@ -0,0 +1,297 @@
+package com.yunhai.workleave.flow;
+
+import com.yunhai.workleave.entity.LeaveApply;
+import com.yunhai.workleave.entity.LeaveCc;
+import com.yunhai.workleave.enums.LeaveStatusEnum;
+import com.yunhai.workleave.repository.LeaveApplyRepository;
+import com.yunhai.workleave.repository.LeaveCcRepository;
+import com.yunhai.workleave.service.ILeaveUserTimeService;
+import com.alibaba.fastjson.JSON;
+import com.yunhai.platform.flowcallback.FlowCallbackHandler;
+import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.Map;
+
+/**
+ * 请假流程回调处理器(手册 §2.4 范本落地)。
+ * 事件已过全部门禁(验签、bizType 过滤、eventId 幂等、乱序门),本类只写状态推进:
+ *
+ * - flow.submitted / task.created / task.handled → 审批中(终态不回退);
+ * - flow.completed → 已通过 + 扣减时长 + 落终态快照;
+ * - flow.rejected → 已拒绝 + 落终态快照(不扣不返还);
+ * - flow.withdrawn → 已撤回(仅审批中可回退);
+ * - flow.cc.sent → 抄送留痕(幂等)。
+ *
+ * 事务语义:本方法与事件行同一事务(starter 内置),抛出异常整体回滚 + 流程中心重推;
+ * 确定性脏消息(bizId 解析失败等)记录 warn 后正常 return,绝不靠抛异常过滤。
+ */
+@Component
+public class LeaveFlowCallbackHandler implements FlowCallbackHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(LeaveFlowCallbackHandler.class);
+
+ public static final String EVENT_SUBMITTED = "flow.submitted";
+ public static final String EVENT_TASK_CREATED = "flow.task.created";
+ public static final String EVENT_TASK_HANDLED = "flow.task.handled";
+ public static final String EVENT_COMPLETED = "flow.completed";
+ public static final String EVENT_REJECTED = "flow.rejected";
+ public static final String EVENT_WITHDRAWN = "flow.withdrawn";
+ public static final String EVENT_CC_SENT = "flow.cc.sent";
+
+ @Autowired
+ private LeaveApplyRepository leaveApplyRepository;
+
+ @Autowired
+ private LeaveCcRepository leaveCcRepository;
+
+ @Autowired
+ private ILeaveUserTimeService userTimeService;
+
+ @Override
+ @Transactional
+ public void onEvent(FlowCallbackEvent event) {
+ if (event == null || !StringUtils.hasText(event.getEvent())) {
+ log.warn("[flow-callback] 空事件,忽略");
+ return;
+ }
+ String eventCode = event.getEvent();
+ log.info("[flow-callback] 收到事件: event={}, bizId={}, instanceId={}, version={}, operator={}",
+ eventCode, event.getBizId(), event.getInstanceId(), event.getInstanceVersion(),
+ event.getOperatorName());
+
+ // 抄送事件不推进单据状态,按 leaveId 落留痕即可
+ if (EVENT_CC_SENT.equals(eventCode)) {
+ handleCcSent(event);
+ return;
+ }
+
+ LeaveApply leave = resolveLeave(event);
+ if (leave == null) {
+ // 确定性脏消息:本应用单据不存在,记录后正常返回(不触发重推)
+ log.warn("[flow-callback] 未找到对应单据,忽略: bizId={}", event.getBizId());
+ return;
+ }
+
+ if (!belongsToCurrentAttempt(leave, event)) {
+ log.info("[flow-callback] 非当前流程实例事件,忽略: leaveId={}, currentBizId={}, eventBizId={}, "
+ + "currentInstanceId={}, eventInstanceId={}",
+ leave.getId(), leave.getBizId(), event.getBizId(), leave.getInstanceId(), event.getInstanceId());
+ return;
+ }
+
+ // 乱序门(starter 已按 instanceVersion 过滤大部分;此处对单据已记录版本再兜一层)
+ if (event.getInstanceVersion() != null && leave.getInstanceVersion() != null
+ && event.getInstanceVersion() < leave.getInstanceVersion()) {
+ log.info("[flow-callback] 事件版本落后,忽略: leaveId={}, eventV={}, recordV={}",
+ leave.getId(), event.getInstanceVersion(), leave.getInstanceVersion());
+ return;
+ }
+ if (event.getInstanceVersion() != null) {
+ leave.setInstanceVersion(event.getInstanceVersion());
+ }
+
+ switch (eventCode) {
+ case EVENT_SUBMITTED:
+ case EVENT_TASK_CREATED:
+ case EVENT_TASK_HANDLED:
+ markApproving(leave);
+ break;
+ case EVENT_COMPLETED:
+ markPassed(leave, event);
+ break;
+ case EVENT_REJECTED:
+ markRejected(leave, event);
+ break;
+ case EVENT_WITHDRAWN:
+ markWithdrawn(leave);
+ break;
+ default:
+ log.warn("[flow-callback] 未知事件,忽略: {}", eventCode);
+ return;
+ }
+ leaveApplyRepository.save(leave);
+ }
+
+ // ============================ 状态推进 ============================
+
+ /** 审批中:终态不回退(并发推送可能乱序到达) */
+ private void markApproving(LeaveApply leave) {
+ if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
+ log.info("[flow-callback] 当前流程已离开审批中状态({}),拒绝非终态事件回退: leaveId={}",
+ LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
+ return;
+ }
+ leave.setStatus(LeaveStatusEnum.APPROVING.getId());
+ }
+
+ /** 已通过:扣减时长 + 终态快照 */
+ private void markPassed(LeaveApply leave, FlowCallbackEvent event) {
+ if (LeaveStatusEnum.PASSED.getId().equals(leave.getStatus())) {
+ log.info("[flow-callback] 通过事件重复到达,忽略副作用: leaveId={}", leave.getId());
+ return;
+ }
+ if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
+ log.warn("[flow-callback] 单据已处其他终态({}),忽略冲突通过事件: leaveId={}",
+ LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
+ return;
+ }
+ Integer before = leave.getStatus();
+ leave.setStatus(LeaveStatusEnum.PASSED.getId());
+ leave.setSnapshot(snapshotOf(event));
+ // 幂等:已扣减过则 service 内部跳过
+ userTimeService.deduct(leave.getSchId(), leave.getUserId(), leave.getUserName(),
+ leave.getId(), leave.getLeaveType(),
+ leave.getTotalHours() == null ? 0D : leave.getTotalHours(),
+ event.getOperatorUserId());
+ log.info("[flow-callback] 单据已通过: leaveId={}, before={}, operator={}, opinion={}",
+ leave.getId(), LeaveStatusEnum.nameById(before), event.getOperatorName(), opinionOf(event));
+ }
+
+ /** 已拒绝:不扣不返还(从未扣过),落终态快照 */
+ private void markRejected(LeaveApply leave, FlowCallbackEvent event) {
+ if (LeaveStatusEnum.REJECTED.getId().equals(leave.getStatus())) {
+ log.info("[flow-callback] 拒绝事件重复到达,忽略: leaveId={}", leave.getId());
+ return;
+ }
+ if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
+ log.warn("[flow-callback] 单据已处其他终态({}),忽略冲突拒绝事件: leaveId={}",
+ LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
+ return;
+ }
+ leave.setStatus(LeaveStatusEnum.REJECTED.getId());
+ leave.setSnapshot(snapshotOf(event));
+ log.info("[flow-callback] 单据已拒绝: leaveId={}, operator={}, opinion={}",
+ leave.getId(), event.getOperatorName(), opinionOf(event));
+ }
+
+ /** 已撤回:仅审批中可回退 */
+ private void markWithdrawn(LeaveApply leave) {
+ if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) {
+ log.info("[flow-callback] 单据非审批中({}),忽略撤回事件: leaveId={}",
+ LeaveStatusEnum.nameById(leave.getStatus()), leave.getId());
+ return;
+ }
+ leave.setStatus(LeaveStatusEnum.WITHDRAWN.getId());
+ }
+
+ /** 抄送留痕(幂等:同实例同人不重复落) */
+ private void handleCcSent(FlowCallbackEvent event) {
+ Long leaveId = parseLeaveId(event.getBizId());
+ if (leaveId == null) {
+ log.warn("[flow-callback] 抄送事件 bizId 非法,忽略: {}", event.getBizId());
+ return;
+ }
+ LeaveApply leave = leaveApplyRepository.findById(leaveId.intValue()).orElse(null);
+ if (leave == null) {
+ log.warn("[flow-callback] 抄送事件未找到单据,忽略: bizId={}", event.getBizId());
+ return;
+ }
+ if (!belongsToCurrentAttempt(leave, event)) {
+ log.info("[flow-callback] 非当前流程实例抄送事件,忽略: leaveId={}, eventBizId={}",
+ leave.getId(), event.getBizId());
+ return;
+ }
+ Integer ccUserId = event.getOperatorUserId();
+ if (ccUserId == null) {
+ // 抄送事件可能以 data.ccUserIds 列表下发;解析首个
+ Object dataValue = event.getData() == null ? null : event.getData().get("ccUserIds");
+ if (dataValue != null) {
+ String raw = String.valueOf(dataValue);
+ for (String part : raw.split(",")) {
+ recordCc(leave, parseInteger(part), null, event.getInstanceId());
+ }
+ }
+ return;
+ }
+ recordCc(leave, ccUserId, event.getOperatorName(), event.getInstanceId());
+ }
+
+ private void recordCc(LeaveApply leave, Integer ccUserId, String ccUserName, Long instanceId) {
+ if (ccUserId == null) {
+ return;
+ }
+ if (leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(leave.getId(), ccUserId, instanceId)) {
+ return;
+ }
+ LeaveCc cc = new LeaveCc();
+ cc.setSchId(leave.getSchId());
+ cc.setLeaveId(leave.getId());
+ cc.setCcUserId(ccUserId);
+ cc.setCcUserName(ccUserName);
+ cc.setInstanceId(instanceId);
+ leaveCcRepository.save(cc);
+ }
+
+ // ============================ 工具 ============================
+
+ private LeaveApply resolveLeave(FlowCallbackEvent event) {
+ // 优先按 instanceId 定位(bizId 因重提交变化不影响)
+ if (event.getInstanceId() != null) {
+ LeaveApply byInstance = leaveApplyRepository.findByInstanceId(event.getInstanceId()).orElse(null);
+ if (byInstance != null) {
+ return byInstance;
+ }
+ }
+ Long leaveId = parseLeaveId(event.getBizId());
+ return leaveId == null ? null : leaveApplyRepository.findById(leaveId.intValue()).orElse(null);
+ }
+
+ /**
+ * 同一请假单可在拒绝/撤回后重新提交。按主键兜底定位时必须再核对当前 attempt,
+ * 否则旧实例迟到事件会污染新实例状态。
+ */
+ private boolean belongsToCurrentAttempt(LeaveApply leave, FlowCallbackEvent event) {
+ if (StringUtils.hasText(leave.getBizId()) && StringUtils.hasText(event.getBizId())
+ && !leave.getBizId().equals(event.getBizId())) {
+ return false;
+ }
+ return leave.getInstanceId() == null || event.getInstanceId() == null
+ || leave.getInstanceId().equals(event.getInstanceId());
+ }
+
+ /** bizId 形态 {leaveId}-{attempt},取首段 */
+ private Long parseLeaveId(String bizId) {
+ if (!StringUtils.hasText(bizId)) {
+ return null;
+ }
+ String first = bizId.trim().split("-")[0];
+ try {
+ return Long.parseLong(first);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private Integer parseInteger(String value) {
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /** 终态快照:event.data 序列化(含 opinion/节点等),详情页免二次查询 */
+ private String snapshotOf(FlowCallbackEvent event) {
+ Map data = event.getData();
+ if (data == null || data.isEmpty()) {
+ return null;
+ }
+ try {
+ return JSON.toJSONString(data);
+ } catch (Exception e) {
+ log.warn("[flow-callback] 快照序列化失败: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private String opinionOf(FlowCallbackEvent event) {
+ Object value = event.getData() == null ? null : event.getData().get("opinion");
+ return value == null ? null : String.valueOf(value);
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/flow/LeaveFlowPages.java b/src/main/java/com/yunhai/workleave/flow/LeaveFlowPages.java
new file mode 100644
index 0000000..0b9f7db
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/flow/LeaveFlowPages.java
@@ -0,0 +1,24 @@
+package com.yunhai.workleave.flow;
+
+import com.yunhai.platform.openapi.core.JumpTarget;
+
+/**
+ * 流程跳转页面名常量(手册 §3.1:页面名 + query 参数,多端最大公约数)。
+ * PC 端路由 {@code /detail?leaveId=x} 与移动端页面名 {@code detail} 保持一致。
+ */
+public final class LeaveFlowPages {
+
+ private LeaveFlowPages() {
+ }
+
+ /** 请假详情页(消息/待办跳转目标) */
+ public static final String DETAIL = "detail";
+
+ /** 详情页参数名 */
+ public static final String PARAM_LEAVE_ID = "leaveId";
+
+ /** 构造详情页跳转目标 */
+ public static JumpTarget detail(Integer leaveId) {
+ return JumpTarget.page(DETAIL).param(PARAM_LEAVE_ID, leaveId);
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/flow/LeaveFlowService.java b/src/main/java/com/yunhai/workleave/flow/LeaveFlowService.java
new file mode 100644
index 0000000..29ee1a5
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/flow/LeaveFlowService.java
@@ -0,0 +1,305 @@
+package com.yunhai.workleave.flow;
+
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
+import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
+import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
+import com.yunhai.platform.openapi.flow.model.FlowDiagramUrl;
+import com.yunhai.platform.openapi.flow.model.FlowProgress;
+import com.yunhai.platform.openapi.flow.model.FlowProgressStep;
+import com.yunhai.platform.openapi.flow.model.FlowProgressTask;
+import com.yunhai.platform.openapi.flow.model.FlowSummary;
+import com.yunhai.platform.openapi.flow.model.FlowTodoTask;
+import com.yunhai.platform.openapi.flow.model.FlowTodoTaskPage;
+import com.yunhai.workleave.entity.LeaveApply;
+import com.yunhai.workleave.model.leave.FlowProgressVo;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 流程审核器门面实现(对齐《子应用流程与消息对接手册 v1.0》)。
+ * 地址类配置零项:SDK 按 {@code yh.open.base-host} + 服务名自动拼装
+ * (三级解析:call.targets → open.services → 枚举兜底)。
+ */
+@Service
+public class LeaveFlowService implements ILeaveFlowService {
+
+ private static final Logger log = LoggerFactory.getLogger(LeaveFlowService.class);
+
+ /** 本应用全局唯一业务类型(手册 §3.2 红线:= app_code,不可带点分档) */
+ public static final String BIZ_TYPE = "leave";
+
+ /** 流程流转中单据标题截断长度(快照/标题只放摘要) */
+ private static final int TITLE_MAX = 60;
+
+ @Value("${leave.flow.guid:leave-apply-v1}")
+ private String defaultFlowGuid;
+
+ /** 回调登记注册器(starter 提供,可能未装配) */
+ @Autowired
+ private ObjectProvider callbackRegistrarProvider;
+
+ // ============================ 登记 ============================
+
+ @Override
+ public String defaultFlowGuid() {
+ return defaultFlowGuid;
+ }
+
+ // ============================ 流程列表 ============================
+
+ @Override
+ public List listFlows(Integer schId) {
+ try {
+ List flows = YschFlowOpenApi.listFlows(schId == null ? 0L : schId, FlowSummary.class);
+ return flows == null ? Collections.emptyList() : flows;
+ } catch (Exception e) {
+ log.error("[flow] 流程列表查询失败: schId={}", schId, e);
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取流程列表失败:" + e.getMessage());
+ }
+ }
+
+ @Override
+ public void ensureRegistered(Integer schId, String flowGuid) {
+ FlowCallbackRegistrar registrar = callbackRegistrarProvider.getIfAvailable();
+ if (registrar == null) {
+ log.warn("[flow] 回调注册器未装配(yh.flow-callback.enabled=false?),跳过登记: schId={}, flowGuid={}",
+ schId, flowGuid);
+ return;
+ }
+ try {
+ registrar.ensureRegistered(schId, flowGuid);
+ } catch (Exception e) {
+ // 登记失败不阻断提交:流程中心可能已存在旧登记,或本地网络暂不可达
+ log.warn("[flow] 回调登记失败: schId={}, flowGuid={}, err={}", schId, flowGuid, e.getMessage());
+ }
+ }
+
+ // ============================ 发起 ============================
+
+ @Override
+ public FlowApplyResult apply(LeaveApply leave, String bizId, String bizTitle) {
+ FlowApplyBody body = buildApplyBody(leave, bizId, bizTitle);
+ try {
+ FlowApplyResult result = YschFlowOpenApi.apply(leave.getSchId(), body, FlowApplyResult.class);
+ if (result == null || result.getInstanceId() == null) {
+ throw new IllegalStateException("流程中心未返回流程实例ID");
+ }
+ log.info("[flow] 发起流程成功: leaveId={}, bizId={}, instanceId={}, duplicated={}",
+ leave.getId(), bizId, result.getInstanceId(), result.isDuplicated());
+ return result;
+ } catch (Exception e) {
+ log.error("[flow] 发起流程失败: leaveId={}, bizId={}", leave.getId(), bizId, e);
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "发起审批流程失败:" + e.getMessage());
+ }
+ }
+
+ /**
+ * 构造流程发起契约。保持包级可见,供契约单测直接断言,避免测试触发真实 OpenAPI。
+ */
+ FlowApplyBody buildApplyBody(LeaveApply leave, String bizId, String bizTitle) {
+ FlowApplyBody body = new FlowApplyBody();
+ body.setFlowGuid(StringUtils.hasText(leave.getFlowGuid()) ? leave.getFlowGuid() : defaultFlowGuid);
+ body.setBizType(BIZ_TYPE);
+ body.setBizId(bizId);
+ body.setBizTitle(truncate(bizTitle, TITLE_MAX));
+ body.setBizJump(LeaveFlowPages.detail(leave.getId()));
+ body.setBizSnapshot(buildSnapshot(leave));
+ body.setSubmitterId(leave.getUserId());
+ body.setSubmitterName(leave.getUserName());
+ body.setFormData(buildFormData(leave));
+ return body;
+ }
+
+ // ============================ 撤回 ============================
+
+ @Override
+ public void withdraw(Integer schId, Long instanceId, Integer submitterId) {
+ if (instanceId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "该单据尚未发起审批流程,无法撤回");
+ }
+ try {
+ YschFlowOpenApi.withdraw(schId, instanceId, submitterId == null ? 0 : submitterId);
+ log.info("[flow] 撤回成功: instanceId={}, submitterId={}", instanceId, submitterId);
+ } catch (Exception e) {
+ log.error("[flow] 撤回失败: instanceId={}", instanceId, e);
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "撤回失败:" + e.getMessage());
+ }
+ }
+
+ // ============================ 待办 / 审核 ============================
+
+ @Override
+ public FlowTodoTaskPage todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size, String bizType) {
+ try {
+ return YschFlowOpenApi.todoTasks(schId == null ? 0L : schId, assigneeId == null ? 0 : assigneeId,
+ bizType, page == null ? 1 : page, size == null ? 10 : size);
+ } catch (Exception e) {
+ log.error("[flow] 待办查询失败: schId={}, assigneeId={}", schId, assigneeId, e);
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取待办列表失败:" + e.getMessage());
+ }
+ }
+
+ @Override
+ public void approveTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion) {
+ try {
+ YschFlowOpenApi.approveTask(schId == null ? 0L : schId, taskId,
+ operatorId == null ? 0 : operatorId, operatorName, opinion);
+ log.info("[flow] 审核同意: taskId={}, operatorId={}", taskId, operatorId);
+ } catch (Exception e) {
+ log.error("[flow] 审核同意失败: taskId={}", taskId, e);
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核提交失败:" + e.getMessage());
+ }
+ }
+
+ @Override
+ public void rejectTask(Integer schId, Long taskId, Integer operatorId, String operatorName, String opinion) {
+ try {
+ YschFlowOpenApi.rejectTask(schId == null ? 0L : schId, taskId,
+ operatorId == null ? 0 : operatorId, operatorName, opinion);
+ log.info("[flow] 审核拒绝: taskId={}, operatorId={}", taskId, operatorId);
+ } catch (Exception e) {
+ log.error("[flow] 审核拒绝失败: taskId={}", taskId, e);
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核提交失败:" + e.getMessage());
+ }
+ }
+
+ // ============================ 进度 / 流程图 ============================
+
+ @Override
+ public FlowProgress progress(LeaveApply leave) {
+ if (leave == null) {
+ return null;
+ }
+ try {
+ return YschFlowOpenApi.progress(leave.getInstanceId(), BIZ_TYPE, leave.getBizId(),
+ leave.getSchId() == null ? 0L : leave.getSchId(), FlowProgress.class);
+ } catch (Exception e) {
+ log.warn("[flow] 进度查询失败(降级空进度): leaveId={}, err={}", leave.getId(), e.getMessage());
+ return null;
+ }
+ }
+
+ @Override
+ public String diagramUrl(LeaveApply leave) {
+ if (leave == null || leave.getInstanceId() == null) {
+ return null;
+ }
+ try {
+ FlowDiagramUrl url = YschFlowOpenApi.diagramUrl(leave.getInstanceId(), leave.getFlowGuid(),
+ "track", leave.getSchId() == null ? 0L : leave.getSchId(), FlowDiagramUrl.class);
+ return url == null ? null : url.getImgUrl();
+ } catch (Exception e) {
+ log.warn("[flow] 流程图查询失败: leaveId={}, err={}", leave.getId(), e.getMessage());
+ return null;
+ }
+ }
+
+ @Override
+ public FlowProgressVo toProgressVo(FlowProgress progress) {
+ if (progress == null) {
+ return null;
+ }
+ FlowProgressVo vo = new FlowProgressVo();
+ vo.setInstanceId(progress.getInstanceId());
+ vo.setFlowName(progress.getBizTitle());
+ vo.setStatus(progress.getStatus() == null ? null : String.valueOf(progress.getStatus()));
+ vo.setCurrentNodeName(progress.getCurrentNodes());
+ vo.setSubmitterName(progress.getSubmitterName());
+ vo.setStartTime(progress.getSubmitTime());
+ vo.setEndTime(progress.getFinishTime());
+
+ List steps = new ArrayList<>();
+ for (FlowProgressStep step : stepsOf(progress)) {
+ List tasks = tasksOf(step);
+ if (tasks == null || tasks.isEmpty()) {
+ // 无处理人的节点(尚未到达)也展示为待处理
+ FlowProgressVo.StepVo s = new FlowProgressVo.StepVo();
+ s.setNodeName(step.getNodeName());
+ s.setFinished(!step.isActive() && false);
+ s.setAction(step.getLabel());
+ steps.add(s);
+ continue;
+ }
+ for (FlowProgressTask task : tasks) {
+ FlowProgressVo.StepVo s = new FlowProgressVo.StepVo();
+ s.setNodeName(step.getNodeName());
+ s.setAssigneeName(task.getUserName());
+ s.setAction(StringUtils.hasText(task.getStatusLabel()) ? task.getStatusLabel() : step.getLabel());
+ s.setOpinion(task.getOpinion());
+ s.setTime(StringUtils.hasText(task.getHandleTime()) ? task.getHandleTime() : task.getArriveTime());
+ s.setFinished(StringUtils.hasText(task.getHandleTime()));
+ steps.add(s);
+ }
+ }
+ vo.setSteps(steps);
+ return vo;
+ }
+
+ @Override
+ public List contentOf(FlowTodoTaskPage page) {
+ if (page == null || page.getContent() == null) {
+ return Collections.emptyList();
+ }
+ return page.getContent();
+ }
+
+ @Override
+ public List stepsOf(FlowProgress progress) {
+ if (progress == null || progress.getSteps() == null) {
+ return Collections.emptyList();
+ }
+ return progress.getSteps();
+ }
+
+ @Override
+ public List tasksOf(FlowProgressStep step) {
+ if (step == null || step.getAssignees() == null) {
+ return Collections.emptyList();
+ }
+ return step.getAssignees();
+ }
+
+ // ============================ 内部方法 ============================
+
+ /** 只读摘要(手册坑 5:勿放大字段/富文本) */
+ private Map buildSnapshot(LeaveApply leave) {
+ Map snapshot = new LinkedHashMap<>();
+ snapshot.put("leaveTypeName", leave.getLeaveTypeName());
+ snapshot.put("startTime", leave.getStartTime());
+ snapshot.put("endTime", leave.getEndTime());
+ snapshot.put("totalHours", leave.getTotalHours());
+ snapshot.put("reason", truncate(leave.getReason(), 100));
+ return snapshot;
+ }
+
+ /** 驱动条件路由/会签的表单数据 */
+ private Map buildFormData(LeaveApply leave) {
+ Map form = new LinkedHashMap<>();
+ form.put("leaveType", leave.getLeaveType());
+ form.put("leaveTypeName", leave.getLeaveTypeName());
+ form.put("totalHours", leave.getTotalHours());
+ form.put("days", leave.getTotalHours() == null ? 0 : Math.round(leave.getTotalHours() / 8D * 100D) / 100D);
+ return form;
+ }
+
+ private String truncate(String value, int max) {
+ if (value == null) {
+ return null;
+ }
+ return value.length() <= max ? value : value.substring(0, max);
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/BasePageQuery.java b/src/main/java/com/yunhai/workleave/model/BasePageQuery.java
new file mode 100644
index 0000000..7d576a7
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/BasePageQuery.java
@@ -0,0 +1,14 @@
+package com.yunhai.workleave.model;
+
+import lombok.Data;
+
+/**
+ * 统一分页查询入参。pageIndex 从 1 起。
+ */
+@Data
+public class BasePageQuery {
+ private int pageIndex = 1;
+ private int pageSize = 10;
+ /** 通用模糊搜索关键词 */
+ private String keyword;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/BasePageResult.java b/src/main/java/com/yunhai/workleave/model/BasePageResult.java
new file mode 100644
index 0000000..11a6eeb
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/BasePageResult.java
@@ -0,0 +1,33 @@
+package com.yunhai.workleave.model;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 统一分页响应外壳。
+ */
+@Data
+public class BasePageResult {
+ /** 总记录数 */
+ private int total;
+ /** 当前页数据 */
+ private List content;
+ /** 当前页码(从 1 起) */
+ private int pageIndex;
+ /** 每页条数 */
+ private int pageSize;
+ /** 总页数 */
+ private int totalPages;
+ /** 是否还有下一页 */
+ private boolean hasNext;
+
+ public BasePageResult(List content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) {
+ this.content = content;
+ this.total = total;
+ this.pageIndex = pageIndex;
+ this.pageSize = pageSize;
+ this.totalPages = totalPages;
+ this.hasNext = hasNext;
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/SchTeaList.java b/src/main/java/com/yunhai/workleave/model/SchTeaList.java
new file mode 100644
index 0000000..eda6140
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/SchTeaList.java
@@ -0,0 +1,106 @@
+package com.yunhai.workleave.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。
+ * 对应 C# BestSchool.Entity.Model.SchTeaList。
+ */
+public class SchTeaList {
+
+ /** 学校 GUID(SchSerID) */
+ @JsonProperty("SchSerID")
+ private String schSerID;
+
+ @JsonProperty("TeaTel")
+ private String teaTel;
+
+ /** 教师姓名 */
+ @JsonProperty("TeaName")
+ private String teaName;
+
+ /** 教师用户ID */
+ @JsonProperty("UserTeaSerID")
+ private Integer userTeaSerID;
+
+ @JsonProperty("TeaIDCard")
+ private String teaIDCard;
+
+ /** 教师工号 */
+ @JsonProperty("TeaNo")
+ private String teaNo;
+
+ /** 教师所属组ID列表(兼容旧字段 GroupIds) */
+ @JsonProperty("GroupIds")
+ private List groupIds;
+
+ /** 教师所属组ID(接口现行字段 GroupID,单个) */
+ @JsonProperty("GroupID")
+ private Integer groupId;
+
+ public Integer getGroupId() {
+ return groupId;
+ }
+
+ public void setGroupId(Integer groupId) {
+ this.groupId = groupId;
+ }
+
+ public String getSchSerID() {
+ return schSerID;
+ }
+
+ public void setSchSerID(String schSerID) {
+ this.schSerID = schSerID;
+ }
+
+ public String getTeaTel() {
+ return teaTel;
+ }
+
+ public void setTeaTel(String teaTel) {
+ this.teaTel = teaTel;
+ }
+
+ public String getTeaName() {
+ return teaName;
+ }
+
+ public void setTeaName(String teaName) {
+ this.teaName = teaName;
+ }
+
+ public Integer getUserTeaSerID() {
+ return userTeaSerID;
+ }
+
+ public void setUserTeaSerID(Integer userTeaSerID) {
+ this.userTeaSerID = userTeaSerID;
+ }
+
+ public String getTeaIDCard() {
+ return teaIDCard;
+ }
+
+ public void setTeaIDCard(String teaIDCard) {
+ this.teaIDCard = teaIDCard;
+ }
+
+ public String getTeaNo() {
+ return teaNo;
+ }
+
+ public void setTeaNo(String teaNo) {
+ this.teaNo = teaNo;
+ }
+
+ public List getGroupIds() {
+ return groupIds;
+ }
+
+ public void setGroupIds(List groupIds) {
+ this.groupIds = groupIds;
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/UserSysRole.java b/src/main/java/com/yunhai/workleave/model/UserSysRole.java
new file mode 100644
index 0000000..0ecb41f
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/UserSysRole.java
@@ -0,0 +1,29 @@
+package com.yunhai.workleave.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 用户系统角色上下文(来自请求头 currentrole,URL 编码 JSON:{childId, roleId, schId, userId})。
+ * 请假模块不使用 childId,仅透传平台统一的 currentrole 上下文。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class UserSysRole {
+
+ /** 子账号/孩子ID,0=主账号 */
+ private Integer childId;
+
+ /** 角色ID */
+ private Integer roleId;
+
+ /** 学校ID(数据隔离关键字段) */
+ private Integer schId;
+
+ /** 用户ID(部分平台版本携带) */
+ private Integer userId;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/base/SetRoleRequest.java b/src/main/java/com/yunhai/workleave/model/base/SetRoleRequest.java
new file mode 100644
index 0000000..8881d5b
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/base/SetRoleRequest.java
@@ -0,0 +1,12 @@
+package com.yunhai.workleave.model.base;
+
+import lombok.Data;
+
+/**
+ * 角色切换入参(对齐前端 baseApi.setRole)。
+ */
+@Data
+public class SetRoleRequest {
+ /** "teacher" 或 "parent" */
+ private String as;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/base/SetRoleResult.java b/src/main/java/com/yunhai/workleave/model/base/SetRoleResult.java
new file mode 100644
index 0000000..34ae1d0
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/base/SetRoleResult.java
@@ -0,0 +1,15 @@
+package com.yunhai.workleave.model.base;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 角色切换结果。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class SetRoleResult {
+ private String role;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/bsch/ClassTreeNodeVo.java b/src/main/java/com/yunhai/workleave/model/bsch/ClassTreeNodeVo.java
new file mode 100644
index 0000000..d01ed64
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/bsch/ClassTreeNodeVo.java
@@ -0,0 +1,29 @@
+package com.yunhai.workleave.model.bsch;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 年级→班级树节点(对齐前端 ClassTreeNode;叶子=班级,附学生数)。
+ */
+@Data
+public class ClassTreeNodeVo {
+ private Integer id;
+ private String name;
+ /** 前端固定字段名 isParent */
+ @JsonProperty("isParent")
+ private boolean parent;
+ /** 叶子(班级)附学生数 */
+ private Integer studentCount;
+ private List children;
+
+ public void setParent(boolean parent) {
+ this.parent = parent;
+ }
+
+ public boolean isParent() {
+ return parent;
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/file/UpFileResultVo.java b/src/main/java/com/yunhai/workleave/model/file/UpFileResultVo.java
new file mode 100644
index 0000000..e1a7695
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/file/UpFileResultVo.java
@@ -0,0 +1,16 @@
+package com.yunhai.workleave.model.file;
+
+import lombok.Data;
+
+/**
+ * 文件上传结果(对齐前端 UpFileResultVo,含旧字段 fileid/host/fileurl)。
+ */
+@Data
+public class UpFileResultVo {
+ private Integer fileid;
+ private String name;
+ private String fileurl;
+ private String host;
+ private String type;
+ private Integer size;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/file/UpFileVo.java b/src/main/java/com/yunhai/workleave/model/file/UpFileVo.java
new file mode 100644
index 0000000..ca92edd
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/file/UpFileVo.java
@@ -0,0 +1,25 @@
+package com.yunhai.workleave.model.file;
+
+import com.yunhai.workleave.entity.UpFile;
+import lombok.Data;
+import org.springframework.beans.BeanUtils;
+
+/**
+ * 附件视图对象(对齐前端 UpFileVo)。
+ */
+@Data
+public class UpFileVo {
+ private Integer id;
+ private String fileTrueName;
+ private String fileName;
+ private Integer fileSize;
+ private String fileType;
+ private Integer topicId;
+ private String topicType;
+
+ public static UpFileVo from(UpFile entity) {
+ UpFileVo vo = new UpFileVo();
+ BeanUtils.copyProperties(entity, vo);
+ return vo;
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/FlowBindRequest.java b/src/main/java/com/yunhai/workleave/model/leave/FlowBindRequest.java
new file mode 100644
index 0000000..72b7f54
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/FlowBindRequest.java
@@ -0,0 +1,37 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * 流程绑定入参。
+ */
+@Data
+public class FlowBindRequest {
+
+ /** 绑定范围:0=学校默认流程 / 1=按请假类型指定 */
+ @NotNull(message = "绑定范围不能为空")
+ private Integer scope;
+
+ /** 范围键:scope=1 时与 scopeKeys 二选一(兼容旧单选入参) */
+ private String scopeKey;
+
+ /** 范围键列表:scope=1 时至少一项(请假类型 dictKey),一次把多个类型绑到同一流程 */
+ private List scopeKeys;
+
+ /** 流程定义标识(flowGuid) */
+ @NotBlank(message = "流程标识不能为空")
+ private String flowGuid;
+
+ /** 流程名称(可空;为空时由流程中心列表回填) */
+ private String flowName;
+
+ /** 流程版本(可空;由流程中心列表回填) */
+ private Integer flowVersion;
+
+ /** 节点摘要(可空;由流程中心列表回填) */
+ private String nodeSummary;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/FlowBindVo.java b/src/main/java/com/yunhai/workleave/model/leave/FlowBindVo.java
new file mode 100644
index 0000000..604a1ca
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/FlowBindVo.java
@@ -0,0 +1,40 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+/**
+ * 已绑定流程一行(绑定关系表)。
+ */
+@Data
+public class FlowBindVo {
+
+ /** 绑定记录ID */
+ private Integer id;
+
+ /** 绑定范围:0=学校默认 / 1=按请假类型 */
+ private Integer scope;
+
+ /** 范围键(scope=1 为请假类型 dictKey) */
+ private String scopeKey;
+
+ /** 范围显示名:「全校默认」或请假类型名称 */
+ private String scopeName;
+
+ /** 流程定义标识 */
+ private String flowGuid;
+
+ /** 流程名称 */
+ private String flowName;
+
+ /** 流程版本 */
+ private Integer flowVersion;
+
+ /** 节点摘要 */
+ private String nodeSummary;
+
+ /**
+ * 该 flowGuid 是否仍存在于流程中心。
+ * 流程中心不可达时为 null(未知),前端不做告警。
+ */
+ private Boolean existsInCenter;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/FlowEnableRequest.java b/src/main/java/com/yunhai/workleave/model/leave/FlowEnableRequest.java
new file mode 100644
index 0000000..b0b8d26
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/FlowEnableRequest.java
@@ -0,0 +1,28 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 流程启用入参(/flow/Enable)。
+ * 启用只关心流程本身(标记本应用可用 + 回调登记),与绑定范围无关;
+ * 历史 version 曾复用 FlowBindRequest 导致 scope 被误校验必填,前端只能传 0 绕过。
+ * 兼容旧调用方:请求体多传的 scope/scopeKey 会被 Jackson 忽略,不报错。
+ */
+@Data
+public class FlowEnableRequest {
+
+ /** 流程定义标识(flowGuid) */
+ @NotBlank(message = "流程标识不能为空")
+ private String flowGuid;
+
+ /** 流程名称(可空;为空时由流程中心列表回填) */
+ private String flowName;
+
+ /** 流程版本(可空;由流程中心列表回填) */
+ private Integer flowVersion;
+
+ /** 节点摘要(可空;由流程中心列表回填) */
+ private String nodeSummary;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/FlowItemVo.java b/src/main/java/com/yunhai/workleave/model/leave/FlowItemVo.java
new file mode 100644
index 0000000..e9d7285
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/FlowItemVo.java
@@ -0,0 +1,38 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 流程中心流程项(流程列表一行)+ 本校绑定状态。
+ *
数据源:{@code YschFlowOpenApi.listFlows}(FlowSummary)。
+ */
+@Data
+public class FlowItemVo {
+
+ /** 流程定义标识(flowGuid) */
+ private String flowGuid;
+
+ /** 流程名称 */
+ private String name;
+
+ /** 流程版本 */
+ private Integer version;
+
+ /** 节点摘要(如「发起 → 部门审核 → 校长审批」) */
+ private String nodeSummary;
+
+ /** 是否为本校默认流程 */
+ private boolean defaultBind = false;
+
+ /** 是否已启用并绑定到本应用(未启用仅展示,不可被绑定) */
+ private boolean enabled = false;
+
+ /** 本流程被绑定到的请假类型 key 列表 */
+ private List typeKeys = new ArrayList<>();
+
+ /** 本流程被绑定到的请假类型名称列表(与 typeKeys 同序) */
+ private List typeNames = new ArrayList<>();
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/FlowManageVo.java b/src/main/java/com/yunhai/workleave/model/leave/FlowManageVo.java
new file mode 100644
index 0000000..3bdbb3b
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/FlowManageVo.java
@@ -0,0 +1,37 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 流程管理页聚合视图:流程列表 + 已绑定关系 + 请假类型。
+ */
+@Data
+public class FlowManageVo {
+
+ /** 本应用 bizType(手册 §3.2:恒等于 app_code,含 leave 一条) */
+ private String bizType = "leave";
+
+ /** 未绑定任何流程时的兜底 flowGuid(配置 leave.flow.guid) */
+ private String configFlowGuid;
+
+ /**
+ * 流程中心是否可达。false 时流程列表为空、只展示已绑定关系,
+ * 前端据此提示「流程中心暂不可达,仅可查看/解除已有绑定」。
+ */
+ private boolean centerReachable = true;
+
+ /** 流程中心不可达/查询失败的原因摘要(可达时为 null) */
+ private String centerError;
+
+ /** 流程中心流程列表 */
+ private List flows = new ArrayList<>();
+
+ /** 本校已绑定关系(默认流程排在最前) */
+ private List binds = new ArrayList<>();
+
+ /** 请假类型(供「按类型绑定」选择):{key, name} */
+ private List leaveTypes = new ArrayList<>();
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/FlowProgressVo.java b/src/main/java/com/yunhai/workleave/model/leave/FlowProgressVo.java
new file mode 100644
index 0000000..dab60fa
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/FlowProgressVo.java
@@ -0,0 +1,55 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 流程进度视图(包装流程中心 FlowProgress,端上只读渲染时间线)。
+ */
+@Data
+public class FlowProgressVo {
+
+ /** 流程实例ID */
+ private Long instanceId;
+
+ /** 流程标识 */
+ private String flowGuid;
+
+ /** 流程名称 */
+ private String flowName;
+
+ /** 实例状态(流程中心口径) */
+ private String status;
+
+ /** 当前节点名 */
+ private String currentNodeName;
+
+ /** 发起人姓名 */
+ private String submitterName;
+
+ /** 发起时间 */
+ private String startTime;
+
+ /** 结束时间 */
+ private String endTime;
+
+ /** 节点/任务列表(按时间正序) */
+ private List steps;
+
+ @Data
+ public static class StepVo {
+ /** 节点名 */
+ private String nodeName;
+ /** 处理人姓名(可空:待办中) */
+ private String assigneeName;
+ /** 处理结果/动作(同意/拒绝/待处理等) */
+ private String action;
+ /** 审批意见 */
+ private String opinion;
+ /** 时间 */
+ private String time;
+ /** 是否已完成 */
+ private Boolean finished;
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveAuditRequest.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveAuditRequest.java
new file mode 100644
index 0000000..660f525
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveAuditRequest.java
@@ -0,0 +1,27 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+/**
+ * 审核动作入参(同意/拒绝)。
+ * 只调用流程中心 approveTask/rejectTask,本地状态由回调推进(不在本接口直改)。
+ */
+@Data
+public class LeaveAuditRequest {
+
+ @NotNull(message = "任务ID不得为空")
+ private Long taskId;
+
+ /** 本地单据ID(审核列表已带出,用于落本地留痕) */
+ private Integer leaveId;
+
+ /** approve=同意 / reject=拒绝 */
+ @NotBlank(message = "审核动作不得为空")
+ private String action;
+
+ /** 审批意见(拒绝时必填,服务端校验) */
+ private String opinion;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveCancelRequest.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveCancelRequest.java
new file mode 100644
index 0000000..ef616dc
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveCancelRequest.java
@@ -0,0 +1,18 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * 销假入参(已通过后发起)。
+ */
+@Data
+public class LeaveCancelRequest {
+
+ @NotNull(message = "单据ID不得为空")
+ private Integer id;
+
+ /** 销假备注 */
+ private String reason;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveDetailVo.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveDetailVo.java
new file mode 100644
index 0000000..dde1ea5
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveDetailVo.java
@@ -0,0 +1,82 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 请假详情视图(详情页/移动端消息跳转落地页)。
+ */
+@Data
+public class LeaveDetailVo {
+
+ private Integer id;
+
+ private Integer userId;
+ private String userName;
+ private String userPhoto;
+
+ private String leaveType;
+ private String leaveTypeName;
+
+ private String startTime;
+ private String endTime;
+ private Double totalHours;
+ private String reason;
+
+ /** 课务安排:0 无 / 1 自行 / 2 教务 */
+ private Integer courseArrange;
+ private String courseArrangeName;
+
+ private Integer status;
+ private String statusName;
+
+ /** 申请时间(打印请假单展示用) */
+ private String applyTime;
+
+ /** 附件列表 */
+ private List files;
+
+ /** 抄送人(发起时选择 + 流程抄送留痕合并去重) */
+ private List ccUsers;
+
+ /** 流程进度(时间线;流程中心不可达时为 null) */
+ private FlowProgressVo progress;
+
+ /** 流程图短时 URL(流程中心不可达时为 null) */
+ private String diagramUrl;
+
+ /** 终态快照 JSON(审批意见等) */
+ private String snapshot;
+
+ /** 单据使用的审批流程 flowGuid(编辑重提时前端回显用) */
+ private String flowGuid;
+
+ /** 销假信息 */
+ private String cancelTime;
+ private String cancelReason;
+
+ /** 当前登录用户能否审核该单(审核列表用) */
+ private Boolean canAudit;
+
+ /** 当前登录用户是否可编辑/销假/撤回/删除 */
+ private Boolean canEdit;
+ private Boolean canCancel;
+ private Boolean canWithdraw;
+ private Boolean canDelete;
+
+ @Data
+ public static class FileItem {
+ private Integer fileId;
+ private String fileName;
+ private String fileUrl;
+ private Integer fileSize;
+ private String fileType;
+ }
+
+ @Data
+ public static class CcItem {
+ private Integer userId;
+ private String userName;
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveQuery.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveQuery.java
new file mode 100644
index 0000000..c1f247f
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveQuery.java
@@ -0,0 +1,29 @@
+package com.yunhai.workleave.model.leave;
+
+import com.yunhai.workleave.model.BasePageQuery;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 请假列表查询条件。
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class LeaveQuery extends BasePageQuery {
+
+ /** 视角:user=我的 / all=全部(管理端)/ cc=抄送我的 */
+ private String scope;
+
+ /** 状态筛选(null/负数=全部) */
+ private Integer status;
+
+ /** 时间区间(yyyy-MM-dd,按请假开始/结束日期重叠过滤) */
+ private String startTime;
+ private String endTime;
+
+ /** 指定申请人(管理端按人筛选,user_id) */
+ private Integer userId;
+
+ /** 指定请假类型 dictKey */
+ private String leaveType;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveRequest.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveRequest.java
new file mode 100644
index 0000000..0d948a8
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveRequest.java
@@ -0,0 +1,47 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 请假申请提交/编辑入参。
+ * 时间只提交字符串(yyyy-MM-dd HH:mm),服务端按学校作息复核时长。
+ */
+@Data
+public class LeaveRequest {
+
+ /** 编辑/重新提交时的单据ID(新建为空) */
+ private Integer id;
+
+ @NotBlank(message = "请假类型不得为空")
+ private String leaveType;
+
+ @NotBlank(message = "开始时间不得为空")
+ private String startTime;
+
+ @NotBlank(message = "结束时间不得为空")
+ private String endTime;
+
+ /** 请假事由(是否必填由学校设置决定,服务端二次校验) */
+ private String reason;
+
+ /** 附件ID CSV */
+ private String fileIds;
+
+ /** 抄送人用户ID CSV */
+ private String ccUserIds;
+
+ /** 课务安排:0 无课务安排 / 1 自行安排 / 2 教务安排(学校启用课务安排时必传) */
+ private Integer courseArrange;
+
+ /** 前端试算时长(小时);服务端复核后以复核值落库 */
+ private Double totalHours;
+
+ /**
+ * 发起时指定的审批流程 flowGuid(申请页下拉选择,可空)。
+ * 为空时服务端按绑定解析:按请假类型绑定 → 学校默认绑定 → 配置 leave.flow.guid。
+ */
+ private String flowGuid;
+
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveSettingVo.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveSettingVo.java
new file mode 100644
index 0000000..40e548b
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveSettingVo.java
@@ -0,0 +1,31 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+/**
+ * 学校请假设置视图/入参。
+ */
+@Data
+public class LeaveSettingVo {
+
+ /** 请假事由是否必填 0/1 */
+ private Integer reasonMust;
+
+ /** 是否显示请假说明 0/1 */
+ private Integer showExplain;
+
+ /** 是否启用课务安排 0/1(P7 预留) */
+ private Integer enableCourse;
+
+ /** 是否允许换课 0/1(P7 预留) */
+ private Integer allowExchange;
+
+ /** 时长管控的请假类型 CSV(dictKey) */
+ private String timeManagedTypes;
+
+ /** 是否启用时长管控 0/1 */
+ private Integer timeLimitEnabled;
+
+ /** 请假计算口径:1 按小时 / 0 按天(半天/半天以上输入模式) */
+ private Integer calcWay;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveStatsVo.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveStatsVo.java
new file mode 100644
index 0000000..c8672b2
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveStatsVo.java
@@ -0,0 +1,48 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 请假统计视图。
+ */
+@Data
+public class LeaveStatsVo {
+
+ /** 区间内请假单数(口径:状态 ∈ 已通过) */
+ private Integer totalCount;
+
+ /** 区间内请假总时长(小时) */
+ private Double totalHours;
+
+ /** 折合天数(总时长/8,保留 1 位) */
+ private Double totalDays;
+
+ /** 出现最多的类型名 */
+ private String topTypeName;
+
+ /** 按类型分布 */
+ private List typeStats;
+
+ /** 按月份趋势 */
+ private List monthStats;
+
+ @Data
+ public static class TypeStat {
+ private String leaveType;
+ private String leaveTypeName;
+ /** 单数 */
+ private Integer count;
+ /** 时长(小时) */
+ private Double hours;
+ }
+
+ @Data
+ public static class MonthStat {
+ /** yyyy-MM */
+ private String month;
+ private Integer count;
+ private Double hours;
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveTypeOptionVo.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveTypeOptionVo.java
new file mode 100644
index 0000000..0dd3479
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveTypeOptionVo.java
@@ -0,0 +1,20 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 请假类型选项(流程绑定下拉用)。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class LeaveTypeOptionVo {
+
+ /** 类型 key(sys_dict.dict_key,请假单 leave_type 存此值) */
+ private String key;
+
+ /** 类型名称 */
+ private String name;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/LeaveVo.java b/src/main/java/com/yunhai/workleave/model/leave/LeaveVo.java
new file mode 100644
index 0000000..b78776b
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/LeaveVo.java
@@ -0,0 +1,55 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 请假列表行视图(列表/抄送共用)。
+ */
+@Data
+public class LeaveVo {
+
+ private Integer id;
+
+ private Integer userId;
+ private String userName;
+ private String userPhoto;
+
+ private String leaveType;
+ private String leaveTypeName;
+
+ /** yyyy-MM-dd HH:mm:ss */
+ private String startTime;
+ private String endTime;
+
+ /** 时长(小时) */
+ private Double totalHours;
+
+ private String reason;
+
+ /** 课务安排:0 无 / 1 自行 / 2 教务 */
+ private Integer courseArrange;
+ private String courseArrangeName;
+
+ /** 附件ID CSV */
+ private String fileIds;
+
+ /** 状态码 + 名称 */
+ private Integer status;
+ private String statusName;
+
+ private LocalDateTime applyTime;
+
+ /** 流程实例ID(有值时可拉进度/流程图) */
+ private Long instanceId;
+
+ /** 当前登录用户是否可编辑/销假/撤回/删除(前端据此置灰) */
+ private Boolean canEdit;
+ private Boolean canCancel;
+ private Boolean canWithdraw;
+ private Boolean canDelete;
+
+ /** 抄送列表场景:我的抄送时间 */
+ private LocalDateTime ccTime;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/SectionVo.java b/src/main/java/com/yunhai/workleave/model/leave/SectionVo.java
new file mode 100644
index 0000000..75291dd
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/SectionVo.java
@@ -0,0 +1,24 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+/**
+ * 节次视图/入参。
+ */
+@Data
+public class SectionVo {
+
+ private Integer id;
+
+ /** 节次名称 */
+ private String name;
+
+ /** 排序号 */
+ private Integer rankNo;
+
+ /** 开始时间 HH:mm */
+ private String beginTime;
+
+ /** 结束时间 HH:mm */
+ private String endTime;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/TimeSetVo.java b/src/main/java/com/yunhai/workleave/model/leave/TimeSetVo.java
new file mode 100644
index 0000000..dc48749
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/TimeSetVo.java
@@ -0,0 +1,33 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 学校作息时间入参/视图(Get/Set 共用)。
+ */
+@Data
+public class TimeSetVo {
+
+ /** 上午开始 HH:mm */
+ @NotBlank
+ private String forenoon1;
+
+ /** 上午结束 HH:mm */
+ @NotBlank
+ private String forenoon2;
+
+ /** 下午开始 HH:mm */
+ @NotBlank
+ private String afternoon1;
+
+ /** 下午结束 HH:mm */
+ @NotBlank
+ private String afternoon2;
+
+ /** 最短请假间隔(分钟) */
+ private Integer intervalMinutes;
+ /** 最少请假时间(分钟,0 = 不限制) */
+ private Integer minMinutes;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/TodoVo.java b/src/main/java/com/yunhai/workleave/model/leave/TodoVo.java
new file mode 100644
index 0000000..1b31897
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/TodoVo.java
@@ -0,0 +1,47 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+/**
+ * 待办列表行(流程中心 todoTasks + 本地单据补全)。
+ */
+@Data
+public class TodoVo {
+
+ /** 流程任务ID(审核提交用) */
+ private Long taskId;
+
+ /** 流程实例ID */
+ private Long instanceId;
+
+ /** 流程版本(并发提示用) */
+ private Integer instanceVersion;
+
+ /** 本地单据ID(bizId 解析所得) */
+ private Integer leaveId;
+
+ /** 申请人 */
+ private Integer userId;
+ private String userName;
+
+ private String leaveType;
+ private String leaveTypeName;
+
+ private String startTime;
+ private String endTime;
+ private Double totalHours;
+ private String reason;
+
+ /** 单据状态名(审批中/已通过…) */
+ private Integer status;
+ private String statusName;
+
+ /** 流程节点名("部门负责人审批"等) */
+ private String nodeName;
+
+ /** 任务到达时间 */
+ private String arriveTime;
+
+ /** 标题(流程中心 bizTitle,本地单据缺失时兜底展示) */
+ private String bizTitle;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/UserTimeAdjustItem.java b/src/main/java/com/yunhai/workleave/model/leave/UserTimeAdjustItem.java
new file mode 100644
index 0000000..2a09baa
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/UserTimeAdjustItem.java
@@ -0,0 +1,22 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * 时长调整行(批量调整数组元素)。
+ */
+@Data
+public class UserTimeAdjustItem {
+
+ @NotNull(message = "教师ID不得为空")
+ private Integer userId;
+
+ /** 增减小时(正=新加,负=扣减) */
+ @NotNull(message = "调整时长不得为空")
+ private Double editHours;
+
+ /** 备注 */
+ private String remark;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/UserTimeAdjustRequest.java b/src/main/java/com/yunhai/workleave/model/leave/UserTimeAdjustRequest.java
new file mode 100644
index 0000000..f863247
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/UserTimeAdjustRequest.java
@@ -0,0 +1,19 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotEmpty;
+
+import java.util.List;
+
+/**
+ * 时长批量调整入参。
+ */
+@Data
+public class UserTimeAdjustRequest {
+
+ @NotEmpty(message = "调整列表不得为空")
+ @Valid
+ private List items;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/UserTimeImportResult.java b/src/main/java/com/yunhai/workleave/model/leave/UserTimeImportResult.java
new file mode 100644
index 0000000..08a290e
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/UserTimeImportResult.java
@@ -0,0 +1,70 @@
+package com.yunhai.workleave.model.leave;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 教师时长 Excel 导入结果(对齐旧版 ImportQJSchUserTime 语义:任一行出错则整体不导入)。
+ */
+public class UserTimeImportResult {
+
+ private boolean success;
+
+ private String message;
+
+ /** 错误明细(行号从 1 计,对应数据行) */
+ private List errors = new ArrayList<>();
+
+ public static class RowError {
+ private int row;
+ private String msg;
+
+ public RowError() {
+ }
+
+ public RowError(int row, String msg) {
+ this.row = row;
+ this.msg = msg;
+ }
+
+ public int getRow() {
+ return row;
+ }
+
+ public void setRow(int row) {
+ this.row = row;
+ }
+
+ public String getMsg() {
+ return msg;
+ }
+
+ public void setMsg(String msg) {
+ this.msg = msg;
+ }
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public List getErrors() {
+ return errors;
+ }
+
+ public void setErrors(List errors) {
+ this.errors = errors;
+ }
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/UserTimeRecordVo.java b/src/main/java/com/yunhai/workleave/model/leave/UserTimeRecordVo.java
new file mode 100644
index 0000000..b0e5553
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/UserTimeRecordVo.java
@@ -0,0 +1,42 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 时长流水行视图。
+ */
+@Data
+public class UserTimeRecordVo {
+
+ private Integer id;
+
+ private Integer userId;
+ private String userName;
+
+ /** 关联请假单ID(可空) */
+ private Integer leaveId;
+
+ private String leaveType;
+ private String leaveTypeName;
+
+ /** 本次增减(小时,正负) */
+ private Double editHours;
+
+ private Double oldHours;
+ private Double newHours;
+
+ /** 备注(扣除/销假返还/新加…) */
+ private String describeText;
+
+ /** 操作人 */
+ private Integer operatorId;
+ private String operatorName;
+
+ private LocalDateTime occurTime;
+
+ /** 展示用:请假单起止(关联单据可查时填充) */
+ private String startTime;
+ private String endTime;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/UserTimeSumVo.java b/src/main/java/com/yunhai/workleave/model/leave/UserTimeSumVo.java
new file mode 100644
index 0000000..dc00143
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/UserTimeSumVo.java
@@ -0,0 +1,16 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+/**
+ * 时长合计视图。
+ */
+@Data
+public class UserTimeSumVo {
+
+ /** 区间内新加总时长(Σ 增减,小时) */
+ private Double addHours;
+
+ /** 当前剩余总时长(Σ usable,小时) */
+ private Double remainHours;
+}
diff --git a/src/main/java/com/yunhai/workleave/model/leave/UserTimeVo.java b/src/main/java/com/yunhai/workleave/model/leave/UserTimeVo.java
new file mode 100644
index 0000000..1d3eae2
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/model/leave/UserTimeVo.java
@@ -0,0 +1,24 @@
+package com.yunhai.workleave.model.leave;
+
+import lombok.Data;
+
+/**
+ * 教师时长列表行。
+ */
+@Data
+public class UserTimeVo {
+
+ /** 时长池记录ID(无记录时为 null,前端按 0 展示) */
+ private Integer id;
+
+ private Integer userId;
+ private String userName;
+ /** 部门/组名(列表展示,可空) */
+ private String deptName;
+
+ /** 剩余可用小时 */
+ private Double usableHours;
+
+ /** 备注 */
+ private String remark;
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/ContactGroupRepository.java b/src/main/java/com/yunhai/workleave/repository/ContactGroupRepository.java
new file mode 100644
index 0000000..cf465c5
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/ContactGroupRepository.java
@@ -0,0 +1,16 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.ContactGroup;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+/**
+ * 联系组仓储。
+ */
+public interface ContactGroupRepository extends JpaRepository {
+
+ List findBySchIdOrderByIdDesc(Integer schId);
+
+ List findByIdIn(List ids);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/ContactGroupUserRepository.java b/src/main/java/com/yunhai/workleave/repository/ContactGroupUserRepository.java
new file mode 100644
index 0000000..29e3921
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/ContactGroupUserRepository.java
@@ -0,0 +1,22 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.ContactGroupUser;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 联系组-成员关联仓储。
+ */
+public interface ContactGroupUserRepository extends JpaRepository {
+
+ List findByGroupId(Integer groupId);
+
+ List findByGroupIdIn(List groupIds);
+
+ @Modifying
+ @Transactional
+ void deleteByGroupId(Integer groupId);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveApplyRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveApplyRepository.java
new file mode 100644
index 0000000..0bb738b
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveApplyRepository.java
@@ -0,0 +1,28 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveApply;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 请假申请仓储。
+ */
+public interface LeaveApplyRepository extends JpaRepository, JpaSpecificationExecutor {
+
+ /** 按主键与学校取单(防跨校越权) */
+ Optional findByIdAndSchId(Integer id, Integer schId);
+
+ /** 流程实例定位(回调按 bizType+bizId 或 instanceId 找回单据) */
+ Optional findByBizId(String bizId);
+
+ Optional findByInstanceId(Long instanceId);
+
+ /** 同人同状态集合(时间冲突校验/在途占用统计用) */
+ List findBySchIdAndUserIdAndStatusIn(Integer schId, Integer userId, List statuses);
+
+ /** 某类型被引用过(字典删除前置校验) */
+ boolean existsBySchIdAndLeaveType(Integer schId, String leaveType);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveAuditTraceRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveAuditTraceRepository.java
new file mode 100644
index 0000000..9111862
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveAuditTraceRepository.java
@@ -0,0 +1,21 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveAuditTrace;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+
+import java.util.List;
+
+/**
+ * 审核痕迹仓储。
+ */
+public interface LeaveAuditTraceRepository extends JpaRepository, JpaSpecificationExecutor {
+
+ Page findBySchIdAndOperatorIdOrderByAuditTimeDesc(Integer schId, Integer operatorId, Pageable pageable);
+
+ List findBySchIdAndLeaveIdOrderByAuditTimeAsc(Integer schId, Integer leaveId);
+
+ boolean existsByLeaveIdAndTaskIdAndAction(Integer leaveId, Long taskId, String action);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveCcRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveCcRepository.java
new file mode 100644
index 0000000..903f692
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveCcRepository.java
@@ -0,0 +1,19 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveCc;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+/**
+ * 抄送留痕仓储。
+ */
+public interface LeaveCcRepository extends JpaRepository {
+
+ List findBySchIdAndLeaveId(Integer schId, Integer leaveId);
+
+ List findBySchIdAndCcUserIdOrderByIdDesc(Integer schId, Integer ccUserId);
+
+ /** 回调幂等:同实例同人不重复落痕 */
+ boolean existsByLeaveIdAndCcUserIdAndInstanceId(Integer leaveId, Integer ccUserId, Long instanceId);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveFlowBindRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveFlowBindRepository.java
new file mode 100644
index 0000000..575e346
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveFlowBindRepository.java
@@ -0,0 +1,27 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveFlowBind;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 审批流程绑定仓储。
+ * 唯一性由 (sch_id, bind_scope, scope_key) 逻辑保证,服务层按此三元组 upsert,
+ * 不建物理唯一索引——避免不同环境下 ddl-auto=update 追加约束失败。
+ */
+public interface LeaveFlowBindRepository extends JpaRepository {
+
+ /** 学校全部绑定(含默认与各类型) */
+ List findBySchId(Integer schId);
+
+ /** 学校 + 范围 + 范围键(默认流程的 scopeKey 固定为 "-") */
+ Optional findBySchIdAndBindScopeAndScopeKey(Integer schId, Integer bindScope, String scopeKey);
+
+ /** 学校下某范围的全部绑定(用于列出所有按类型绑定的记录) */
+ List findBySchIdAndBindScope(Integer schId, Integer bindScope);
+
+ /** 学校下引用某条流程的全部绑定(关闭「启用绑定」时一并解除) */
+ List findBySchIdAndFlowGuid(Integer schId, String flowGuid);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveFlowEnableRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveFlowEnableRepository.java
new file mode 100644
index 0000000..a410941
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveFlowEnableRepository.java
@@ -0,0 +1,21 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveFlowEnable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 流程启用绑定仓储。
+ * 唯一性由 (sch_id, flow_guid) 逻辑保证,服务层按此二元组 upsert,
+ * 不建物理唯一索引——避免不同环境下 ddl-auto=update 追加约束失败。
+ */
+public interface LeaveFlowEnableRepository extends JpaRepository {
+
+ /** 学校全部启用记录 */
+ List findBySchId(Integer schId);
+
+ /** 学校 + 流程标识(启用开关按此定位,upsert) */
+ Optional findBySchIdAndFlowGuid(Integer schId, String flowGuid);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveSectionRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveSectionRepository.java
new file mode 100644
index 0000000..946b434
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveSectionRepository.java
@@ -0,0 +1,16 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveSection;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+/**
+ * 节次仓储。
+ */
+public interface LeaveSectionRepository extends JpaRepository {
+
+ List findBySchIdOrderByRankNoAsc(Integer schId);
+
+ long countBySchId(Integer schId);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveSettingRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveSettingRepository.java
new file mode 100644
index 0000000..a84a2c1
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveSettingRepository.java
@@ -0,0 +1,14 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveSetting;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+/**
+ * 学校请假设置仓储。
+ */
+public interface LeaveSettingRepository extends JpaRepository {
+
+ Optional findBySchId(Integer schId);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveTimeSetRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveTimeSetRepository.java
new file mode 100644
index 0000000..cb88896
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveTimeSetRepository.java
@@ -0,0 +1,14 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveTimeSet;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+/**
+ * 学校作息时间仓储。
+ */
+public interface LeaveTimeSetRepository extends JpaRepository {
+
+ Optional findBySchId(Integer schId);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveUserTimeRecordRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveUserTimeRecordRepository.java
new file mode 100644
index 0000000..14b96a7
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveUserTimeRecordRepository.java
@@ -0,0 +1,25 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveUserTimeRecord;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+
+/**
+ * 教师时长流水仓储。
+ */
+public interface LeaveUserTimeRecordRepository extends JpaRepository, JpaSpecificationExecutor {
+
+ /** 区间内加减合计(正负净额,统计页"新加总时长"口径) */
+ @Query("select coalesce(sum(r.editHours), 0) from LeaveUserTimeRecord r "
+ + "where r.schId = :schId and r.occurTime >= :start and r.occurTime < :end")
+ Double sumEditHours(@Param("schId") Integer schId,
+ @Param("start") LocalDateTime start,
+ @Param("end") LocalDateTime end);
+
+ /** 按教师统计在途/已发生的请假是否已扣减(销假幂等辅助) */
+ boolean existsByLeaveIdAndDescribeText(Integer leaveId, String describeText);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/LeaveUserTimeRepository.java b/src/main/java/com/yunhai/workleave/repository/LeaveUserTimeRepository.java
new file mode 100644
index 0000000..c31dfb9
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/LeaveUserTimeRepository.java
@@ -0,0 +1,18 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.LeaveUserTime;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 教师时长池仓储。
+ */
+public interface LeaveUserTimeRepository extends JpaRepository, JpaSpecificationExecutor {
+
+ Optional findBySchIdAndUserId(Integer schId, Integer userId);
+
+ List findBySchIdAndUserIdIn(Integer schId, List userIds);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/SysDictRepository.java b/src/main/java/com/yunhai/workleave/repository/SysDictRepository.java
new file mode 100644
index 0000000..896d0f4
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/SysDictRepository.java
@@ -0,0 +1,30 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.SysDict;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 系统字典仓储。
+ */
+public interface SysDictRepository extends JpaRepository {
+
+ /** 某类型的全量字典(管理列表),sortOrder 升序 */
+ List findByDictTypeOrderBySortOrderAsc(String dictType);
+
+ /** 某学校自有的全量字典(管理列表),sortOrder 升序 */
+ List findByDictTypeAndSchIdOrderBySortOrderAsc(String dictType, Integer schId);
+
+ /** 学校自有字典启用项 */
+ List findByDictTypeAndSchIdAndEnableFlagOrderBySortOrderAsc(String dictType, Integer schId, Integer enableFlag);
+
+ /** 学校是否已有自有字典(首次进入初始化的判断依据) */
+ boolean existsByDictTypeAndSchId(String dictType, Integer schId);
+
+ Optional findByDictTypeAndDictKey(String dictType, String dictKey);
+
+ /** 限定 schId 的 key 精确查找(本校优先,再回退全局种子) */
+ Optional findFirstByDictTypeAndDictKeyAndSchId(String dictType, String dictKey, Integer schId);
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/workleave/repository/SysOperationLogRepository.java
new file mode 100644
index 0000000..3611c05
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/SysOperationLogRepository.java
@@ -0,0 +1,12 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.SysOperationLog;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+
+/**
+ * 系统审计日志仓储。
+ */
+public interface SysOperationLogRepository
+ extends JpaRepository, JpaSpecificationExecutor {
+}
diff --git a/src/main/java/com/yunhai/workleave/repository/UpFileRepository.java b/src/main/java/com/yunhai/workleave/repository/UpFileRepository.java
new file mode 100644
index 0000000..394e703
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/repository/UpFileRepository.java
@@ -0,0 +1,16 @@
+package com.yunhai.workleave.repository;
+
+import com.yunhai.workleave.entity.UpFile;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+/**
+ * 附件元信息仓储。
+ */
+public interface UpFileRepository extends JpaRepository {
+
+ List findByTopicTypeAndTopicId(String topicType, Integer topicId);
+
+ List findByIdIn(List ids);
+}
diff --git a/src/main/java/com/yunhai/workleave/service/IClassService.java b/src/main/java/com/yunhai/workleave/service/IClassService.java
new file mode 100644
index 0000000..f886ab8
--- /dev/null
+++ b/src/main/java/com/yunhai/workleave/service/IClassService.java
@@ -0,0 +1,58 @@
+package com.yunhai.workleave.service;
+
+import com.yunhai.workleave.model.SchTeaList;
+import com.yunhai.workleave.model.bsch.ClassTreeNodeVo;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 班级管理(bschclass)开放接口客户端。
+ * 基址取配置 {@code yh.open.bschclass}。
+ *
+ * - 教师:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}
+ * - 学生树:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?schId={schId}&toUser=true}
+ * (学校→学段→年级→班级→学生)
+ *
+ */
+public interface IClassService {
+
+ /**
+ * 获取指定学校全部教师(含所属组 GroupIds)。
+ */
+ List getSchTeaList(int schId);
+
+ /**
+ * 获取学校学生树原始节点(带 5 分钟内存缓存)。
+ * 节点字段:id/name/pid/type(1组 2学生)/isParent/children。
+ * 外部接口不可达时返回 null(调用方降级处理,不阻断业务)。
+ */
+ List