diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a46b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/clip.rgba b/clip.rgba new file mode 100644 index 0000000..569a338 Binary files /dev/null and b/clip.rgba differ diff --git a/clip.rgba.json b/clip.rgba.json new file mode 100644 index 0000000..95d399c --- /dev/null +++ b/clip.rgba.json @@ -0,0 +1 @@ +{"w":3056,"h":1298,"ch":3} \ No newline at end of file diff --git a/docs/leave-form-beautified.html b/docs/leave-form-beautified.html new file mode 100644 index 0000000..0f042ad --- /dev/null +++ b/docs/leave-form-beautified.html @@ -0,0 +1,543 @@ + + + + + +教师请假 · 我要请假(美化版) + + + + + + + + +
+
+ +
+
李
+ 李晶 +
+
+ +
+
+
+

请假申请

+
提交后将进入审批流程,审批结果会通过消息通知您
+
+
+ +
+ + 时间说明:上午(08:00–12:00)– 下午(13:00–17:00),固定 3 小时工作制,按半天为单位计费时长 +
+ +
+ + +
+
+ +

请假信息

+ 带 * 为必填项 +
+
+ +
+ +
+
+ + + + + +
+
不同类型可能需要附加不同的证明材料
+
+
+ +
+ +
+
+
+ + +
+
+
+
+ + +
+ 至 +
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+ + 0.5 天(按选择自动计算) +
+
+
+ +
+ +
+
+ + + +
+
+
+ +
+ +
+ +
0/50
+
+
+ +
+
+ + +
+
+ +

证明材料

+ 选填 +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ 王王主任✕ + 张张老师✕ + +
+
审批通过后,请假单将自动抄送给以上人员
+
+
+ +
+
+
+
+ + +
+ + + +
+
+ + + + diff --git a/docs/search-wrap-design-spec.html b/docs/search-wrap-design-spec.html new file mode 100644 index 0000000..6044a89 --- /dev/null +++ b/docs/search-wrap-design-spec.html @@ -0,0 +1,189 @@ + + + + + +查询条件区(.search-wrap)视觉规范 + + + +
+ +

查询条件区 .search-wrap 视觉规范

+

请假模块 6 个列表页统一筛选条 · 全局面板 · 纯 CSS 实现(layout.css)

+

适用范围:我的请假 / 全部请假 / 抄送列表 / 审批列表 / 时长日志 / 统计分析

+ +

一、容器(面板)

+

原先是一条通栏灰带(#f7f7f7、无圆角、52px),与白色页面板对比生硬、边界模糊。改为白底卡片:靠细边框分层,靠极淡投影浮起。高度 56px 与 padding 10px 是为了让 32px 控件上下各留 10px 余量,同时消化 1px 边框与聚焦光圈,不顶破容器。

+ +
+
+
+
+
开始时间▦
+ 至 +
结束时间▦
+
+
请假类型
+
+ +
搜索申请人 / 事由⌕
+
+

默认态:起止两个日期框等宽等距、同一基线,中间以弱化的「至」连接;右侧「重置 + 搜索」固定靠右

+
+ + + + + + + + + + + +
属性取值说明
background#fff与页面底板同色,靠边框与投影分层
border1px solid #e3eaf6比表格分隔线 #ecedf3 略深偏冷,保证纯白底上仍看得见
border-radius10px面板用 10,内部控件用 8,形成层次差
height56px(含 box-sizing:border-box)10 + 32 + 10 + 边框,与改造前 52px 竖向占位接近,不影响表格高度预算
box-shadow0 2px 8px rgba(23,35,61,.05)极淡,只做「浮起」暗示,不做视觉噪音
layoutdisplay:flex; align-items:center弃用 .fl/.fr 浮动:条件项增减(抄送页无日期、统计页无搜索框)都不会挤在一起
+ +

二、控件

+

输入框、下拉、起止日期统一 32px 高 / 8px 圆角,边框比面板边框略深一档,让控件在面板内可辨。同一对起止日期之间只允许「宽度、高度、边框、圆角、字号、内边距」逐项相同。

+ +
+

交互状态

+
+
+
默认态 · 占位文字 #99a2b5
+

border #e6ebf4

+
+
+
悬停态 · 边框加深提示可交互
+

border #c8d4ee

+
+
+
聚焦态 · 品牌色描边
+

border #577afb + 0 0 0 3px rgba(87,122,251,.12)

+
+
+
已输入内容⌕✕
+

悬停整框时放大镜转品牌蓝;清空键位于其左侧,两个图标不叠在同一槽位

+
+
+
+ + + + + + + + + + +
控件宽 / 高关键样式
起止日期对 .search-wrap_dates140 × 32(各)type="date" transfer,两个独立输入框;容器为 display:flex; gap:8px,保证等宽、等高、同一基线;中间 .search-wrap_tilde(「至」)仅作语义连接
类型下拉 .search-wrap_sel150 × 32选中值与占位文字左右内边距 10px,行高 30px 视觉居中
关键词 .search-wrap_kw240 × 32search 输入框;右内边距 40px 给放大镜让位
重置 .search-wrap_reset自动 × 32幽灵按钮:白底 + 灰字,hover 才染品牌色,避免与查询按钮抢注意力
查询(统计页)自动 × 32沿用 type="primary" 品牌蓝实心,全页仅此一处主按钮
+ +

三、色板(与「我要请假」页同源)

+

不引入第二套品牌色。所有取值复用 ApplyLeave 已有 token,保证跨页一致。

+ + + + + + + + + + + + +
用途色值Token
品牌主色#577afb$primary
浅主色底#eef3ff$primary-bg
主文字#1c2333$text-main
次文字#5b6478$text-sub
提示 / 占位#99a2b5$text-hint
控件边框#e6ebf4$border
面板边框#e3eaf6—
+ +

四、无障碍与可用性

+ + +

五、实现要点(易错处)

+ + +
+ + diff --git a/docs/sidebar-beautify-preview.html b/docs/sidebar-beautify-preview.html new file mode 100644 index 0000000..4b88fd5 --- /dev/null +++ b/docs/sidebar-beautify-preview.html @@ -0,0 +1,154 @@ + + + + +左侧导航栏视觉升级 · 效果预览 + + + + +
+ +
+
+ +
+

主内容区(216px 偏移)

+

+ 216px 侧栏44px 行高8px 圆角 + 12px 侧边留白胶囊选中态左侧指示条 + 图标随状态变色子级圆点层级 +

+

+ 鼠标悬停任一菜单项可查看 hover 态(浅灰底 #f5f7fb + 图标转品牌蓝)。 +

+
+ + + \ No newline at end of file diff --git a/docs/图片上传样式预览.html b/docs/图片上传样式预览.html new file mode 100644 index 0000000..7a80b25 --- /dev/null +++ b/docs/图片上传样式预览.html @@ -0,0 +1,180 @@ + + + + +图片上传样式预览 + + + +
+

图片上传样式预览

+

对应组件:src/components/AttachmentUpload.vue(imageOnly 模式)— 应用页 src/views/leave/ApplyLeave.vue

+ + +
+ 默认态 +
图片
+
+
+
+
+ + + +
+
+
点击或拖拽图片到此处上传
+
支持 .jpg / .bmp / .png 格式,单张不超过 20MB,可多选
+
+
+
+
+
+ + +
+ 悬停态 +
图片
+
+
+
+
+ + + +
+
+
点击或拖拽图片到此处上传
+
支持 .jpg / .bmp / .png 格式,单张不超过 20MB,可多选
+
+
+
+
+
+ + +
+ 拖拽经过态(dragOver) +
图片
+
+
+
+
+ + + +
+
+
点击或拖拽图片到此处上传
+
松开鼠标即可上传
+
+
+
+
+
+ + +
+ 已上传态(缩略图 + 点击放大) +
图片
+
+
+
+
+ + + +
+
+
点击或拖拽图片到此处上传
+
支持 .jpg / .bmp / .png 格式,单张不超过 20MB,可多选
+
+
+
+
+
+
+
+
+
查看大图
+
+
病历单.jpg✕
+
+
+
+
+
查看大图
+
+
证明材料_01.png✕
+
+
+
+
+ + \ No newline at end of file diff --git a/docs/教师时长页面设计预览.html b/docs/教师时长页面设计预览.html new file mode 100644 index 0000000..657c6a8 --- /dev/null +++ b/docs/教师时长页面设计预览.html @@ -0,0 +1,349 @@ + + + + + +教师时长 · 页面设计预览 + + + +
+
请假管理 / 请假设置
+ +
+
+
作息时间
+
请假类型
+
教师时长
+
节次
+
其他设置
+
+ +
+ +
+
+
+
■
+
+
+ 需扣减时长的请假类型 + 已启用 3 项 +
+
勾选后,该类型请假审批通过将自动扣减教师时长;取消勾选即停止扣减。变更即时保存。
+
+
+ +
+
+ ✓事假 + ✓病假 + ✓调休 + 婚假 + 产假 + 丧假 +
+
+ + +
+
+
☆
+
48人
当前范围教师
+
+
+
⏱
+
612.5小时
剩余时长合计
+
+
+
◫
+
12.76小时
人均可用时长
+
+
+
⚠
+
3人
时长已用尽
+
+
+ + +
+
+
◫教师分组点击筛选
+
+
▼全部教师48
+
▶语文组12
+
▶数学组10
+
▶英语组9
+
▶综合组17
+
+
+ +
+
+
+ + 共 48 位教师 +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
序号教师剩余时长本次新加(小时)调整后情况备注操作
1
王王雅静语文组 · 教研组长
24小时
≈ 3 天
4
+
28+4
撤销
2
李李国栋数学组
16小时
≈ 2 天
可填负数
+
——
3
张张明远英语组
6小时
≈ 0.75 天
-6
+
0-6
撤销
4
陈陈思彤综合组
0小时
需及时补充
可填负数
+
——
5
周周建华语文组
40小时
≈ 5 天
可填负数
+
——
+ + +
+
+
+
+ +
+

本次设计要点

+ +
+
+ + \ No newline at end of file diff --git a/docs/教师请假模块-改造计划-v1.md b/docs/教师请假模块-改造计划-v1.md new file mode 100644 index 0000000..e524b53 --- /dev/null +++ b/docs/教师请假模块-改造计划-v1.md @@ -0,0 +1,542 @@ +# 教师请假(WorkLeave)模块改造计划 v1 + +> 目标:把当前「宿舍通知(DormNotice)」模板工程整体改造为**教师请假**子应用。 +> 业务闭环:**教师提交请假申请 → 平台流程审核器(流程中心)流转审批 → 回调推进单据状态 → 扣减教师时长**。 +> 本文件是执行依据:按 P0~P7 逐阶段落地,每阶段附验收标准。 + +- 当前后端:`E:\YHXT\java\WorkLeave`(现为 com.yunhai.dormnotice,Spring Boot 2.7 + JPA + 基础平台 yh-spring-boot-starter) +- 当前前端:`E:\YHXT\view\WorkLeave`(现为 dorm-notice,Vue3 + Vite + view-ui-plus + alova) +- 历史前端(Vue2,**只参考页面/菜单节点逻辑**):`H:\工作区\MYFile\备份\请假前端` +- 历史后端(C#/EF,参考实体字段与业务规则):`H:\工作区\MYFile\备份\请假申请2.2` +- 流程对接手册:`E:\xwechat_files\wxid_psk2hommdhtq22_310d\msg\file\2026-09\子应用流程与消息对接手册-20260929.md` +- 平台参考实现:`E:\YHXT\java\UserclassService`(UserContext / SysOperationLogAspect)、`E:\YHXT\java\HomeLeave`(同平台请假姊妹工程,风格参照) + +--- + +## 1. 参考资产盘点(已勘察结论) + +### 1.1 历史左侧菜单节点(请假前端 router,modId 100~190) + +| modId | 页面 | 历史路径 | 新方案 | +|---|---|---|---| +| 100 | 我要请假(申请表单) | `/leave/` | 保留 → `/apply` | +| 110 | 我的请假 | `/leave/myleave` | 保留 → `/myleave` | +| 111 | 请假编辑(子页) | `/leave/edit` | 合并进 `/apply?serid=`(隐藏路由) | +| 120 | 审核列表(待我审核) | `/leave/shenhe` | 保留 → `/audit`(改用流程中心待办) | +| 130 | 抄送列表 | `/leave/leave_send` | 保留 → `/cc` | +| 140 | 全部请假(管理端) | `/leave/totallist` | 保留 → `/all` | +| 150 | 指派代课 | `/leave/assignment` | **P7 可选**(依赖排课/课务数据,v1 暂缓) | +| 160 | 流程管理(固定流程配置) | `/leave/liucheng` | **删除**:新平台由「流程中心-流程编排」承担(管理员配置) | +| 170 | 请假设置(6 个 tab) | `/leave/leave_timeSetting` | 保留 → `/setting`(作息/类型/教师时长/节次/其他) | +| 180 | 时长日志 | `/leave/leave_amendant` | 保留 → `/timelog` | +| 190 | 统计分析 | `/leave/tongji` | 保留 → `/stats`(本地实现,旧版跳外部站点不沿用) | + +### 1.2 历史状态机(C# 口径,新方案做了流程化收敛) + +历史:`0 待审核 / 1 通过 / 2 未通过 / 3 撤销(通过) / 4 撤销待审 / 5 通过(撤销被拒) / 6 修改待审`, +并配套自建流程引擎(`Common_flowinfo/flowstep/freeflowinfo/flowtranctInfo/Groupuser`)实现固定流程与自由流程。 + +新方案:**审核流转全部交给平台流程审核器**,本地只保留单据状态;4/5/6 三个依赖自建引擎的状态取消: + +| 新状态 | 枚举 | 进入事件 | +|---|---|---| +| 0 | APPROVING 审批中 | 提交(flow.submitted / flow.task.created / flow.task.handled) | +| 1 | PASSED 已通过 | flow.completed(**扣减时长**) | +| 2 | REJECTED 已拒绝 | flow.rejected | +| 3 | CANCELED 已销假 | 已通过后教师发起销假(本模块动作,**返还时长**) | +| 4 | WITHDRAWN 已撤回 | flow.withdrawn(提交人撤回,需无任务被办理) | + +### 1.3 历史核心业务规则(重写要点,详见 §6) + +- 请假类型:字典(历史 `Common_Dictionary typeName='请假类别'`),学校开关 + 排序 → 新方案 `sys_dict(dict_type='leaveType')`。 +- 时长(调休余额)池:类型进入「时长管控清单」时校验余额;在途占用 = 审批中单据时长之和;通过扣减、销假返还;加减流水留痕。 +- 时间/时长计算:按学校作息(上午 F1-F2、下午 A1-A2)折算小时,同日去午休、跨天按整天 8h;小时制(`IsCalculateWay=true`)为唯一口径,按天模式取消。 +- 冲突校验:同一申请人、状态 ∈ {0,1} 的时间区间不得重叠(历史含 5,新方案无该状态)。 +- 编辑:仅「审批中」「已拒绝」可编辑并重新提交(重新发起流程,bizId 追加尝试序号);已通过只能销假,不能改。 +- 详情快照:终态时把回调事件 data(含审批意见)序列化落库,详情页免二次查询。 +- 抄送:流程中心 NOTIFY 节点自动发消息;本模块落 `leave_cc` 留痕表供「抄送列表」渲染。 +- 删除:物理删除(历史口径),仅限审批中/已拒绝/已撤回,且仅本人。 + +### 1.4 历史缺陷清单(重写时修正,不复制) + +1. 半天换算 `JsAPm` 判成 `KsAPm`(C# 源码 bug)→ 新方案按天模式整体取消,不涉及。 +2. 拒绝时返还时长传错类型 → 新方案拒绝不返还(未扣过),无此分支。 +3. 附件绑定不落库 → 新方案仓储显式 save。 +4. 时长池新建记录 `null + x = null` → 新方案 `default 0`。 +5. 时间段校验用字符串拼 SQL → 新方案参数化 JPA + LocalDateTime。 +6. 列表时间过滤口径不一(重叠 vs 包含)→ 统一为**重叠**语义。 + +--- + +## 2. 关键决策 + +| # | 决策 | 理由 | +|---|---|---| +| D1 | 后端包名 `com.yunhai.workleave`,artifactId/finalName=`workleave`,应用名`WorkLeave` | 需求 1;与旧模板彻底切割 | +| D2 | 删除宿舍业务(Activity/ActivityRead/ActivitySignup 及全部 Model/Repository/Service/Controller),**保留** `sys_operation_log`(含切面)、`sys_dict`、`up_file`、contacts(自定义组)、base/dict/file/log 平台代理控制器 | 需求 2(删实体保留日志表);保留件是请假的通用底座 | +| D3 | 业务人员字段**只存平台数字 ID**(userId),姓名/头像/类型名做冗余展示字段;`BaseEntity.create_by/update_by` 改存**操作人 userId**,不存账号 | 需求 3(不保存师生账号) | +| D4 | 审核流转对接**平台流程审核器**(yh-flow-callback-starter + YschFlowOpenApi),bizType=`leave` 全局唯一 | 需求 7 对接手册;替代历史自建流程引擎 | +| D5 | 申请发起 `bizId = {leaveId}-{attempt}`(attempt 为第几次提交,从 1 起);回调按 `-` 前缀解析 leaveId | 流程中心按 bizType+bizId 幂等,拒绝后重新提交必须换 bizId | +| D6 | 消息跳转 `JumpTarget.page("detail").param("leaveId", id)`,前端提供 `/detail?leaveId=` 落地页 | 手册 §3.1 红线(query 形态、页面名两端一致) | +| D7 | 时长计算做成工具类前后端同规则(前端实时算、后端复核),小时制保留 2 位小数 | 需求 3 业务逻辑;历史算法在前端算、后端只校验,重写为双端一致 | +| D8 | 前端保留现有技术栈与壳层(Vue3+Vite+view-ui-plus+alova),仅替换业务域与菜单契约 | 与当前前端工程一致,降低风险;"只参考页面逻辑"不搬 Vue2 代码 | +| D9 | 节点 160 流程管理删除;审批意见/流程图/进度时间线用流程中心能力(progress/diagramUrl) | 平台已提供,避免重复建设 | + +--- + +## 3. 前置依赖(需平台管理员配合,先登记后联调) + +| # | 事项 | 说明 | 状态 | +|---|---|---|---| +| A1 | projman 建应用 | 部署名称 `leave`(= app_code,逐字一致;公共组件勾 Redis) | ☐ 待确认 | +| A2 | UserBase 注册应用 | app_code=`leave`、app_key/app_secret、pc_url=`http://<域名>/leave/front/index.html#/` | ☐ 待确认 | +| A3 | 流程中心编排 | 注册流程记 flowGuid(建议 `leave-apply-v1`;如需销假审批再加 `leave-cancel-v1`) | ☐ 待确认 | +| A4 | 白名单登记 | flow-center `trusted-apps` 加 `appKey:secret`;UserMsg/UserBase 密钥链 | ☐ 待确认 | +| A5 | 菜单树配置 | 基础平台按 §4.3 的 modId/path 配置 9 个节点(对齐 GetUserMod) | ☐ 待确认 | +| A6 | Nacos 配置 | `workleave.yaml`(app-key/secret/flow-guid/open.base-host),本地用 application.yml 兜底 | ☐ 待确认 | + +> 本地开发不需要 A4;`open-host` 指向 `http://open.yunhaixingtong.com`(hosts 加 `192.168.10.160 open.yunhaixingtong.com`)可直连真实服务(手册 §5.0)。 + +--- + +## 4. 目标架构 + +### 4.1 后端目录结构(com.yunhai.workleave) + +``` +src/main/java/com/yunhai/workleave/ +├── WorkLeaveApplication.java +├── aspect/ ControllerUserContextAspect(Order1)· SysOperationLogAspect(Order3) +├── common/ listener/BaseAuditListener · validation/ValidationGroups +├── config/ Config(API_PATH) · JacksonConfig · SwaggerConfig +├── entity/ BaseEntity · SysDict · UpFile · SysOperationLog +│ LeaveApply · LeaveSetting · LeaveTimeSet · LeaveUserTime · LeaveUserTimeRecord +│ LeaveSection · LeaveCc · (P7) LeaveCourse · LeaveCourseExchange · LeaveApplyOld +├── enums/ LeaveStatusEnum · LeaveActionEnum · UserType +├── flow/ LeaveFlowService(apply/withdraw/todo/approve/reject/progress/diagram) +│ LeaveFlowCallbackHandler(事件状态机 + 终态守卫 + 快照 + 扣减/返还 + 抄送留痕) +├── model/ BasePageQuery/BasePageResult · bsch(SchTeaList/ClassTreeNodeVo) · file(UpFileVo/Result) +│ └── leave/ LeaveVo · LeaveDetailVo · LeaveQuery · LeaveRequest · LeaveCancelRequest +│ LeaveAuditRequest · LeaveProgressVo · LeaveStatsVo · TimeSetVo/TimeSetRequest +│ LeaveSettingVo · UserTimeVo · UserTimeRecordVo · UserTimeAdjustRequest · SectionVo ... +├── repository/ LeaveApplyRepository · LeaveSettingRepository · LeaveTimeSetRepository +│ LeaveUserTimeRepository · LeaveUserTimeRecordRepository · LeaveSectionRepository +│ LeaveCcRepository · SysDictRepository · UpFileRepository · SysOperationLogRepository +├── service/ I/DictService · I/FileService · I/ContactsService · I/UserCenterService · I/ClassService +│ I/LeaveApplyService · I/LeaveSettingService · I/LeaveUserTimeService +│ I/LeaveStatsService · I/SysOperationLogService +│ └── impl/ 同名实现 + LeaveTimeCalculator(时长算法) +├── controller/api/ BaseApiController · DictController · FileController · LogController · ContactsController +│ LeaveController(申请域)· LeaveAuditController(待办/审核/进度) +│ LeaveSettingController(设置域)· LeaveTimeController(时长域)· LeaveStatsController +└── utils/ UserContext · PageUtils · GzipUtil · IpUtils · LeaveTimeText +``` + +### 4.2 数据表(`sql/workleave_schema.sql`;ddl-auto=update 兜底建表,脚本供人工核对) + +| 表 | 说明 | 关键字段 | +|---|---|---| +| `leave_apply` | 请假申请主表 | id, sch_id, user_id, user_name, user_photo, leave_type(字典key), leave_type_name, start_time, end_time, total_hours, reason, file_ids, cc_user_ids, status, biz_id, instance_id, instance_version, flow_guid, flow_attempt, snapshot(终态JSON), apply_time + BaseEntity 审计列 | +| `leave_setting` | 学校设置(一校一行) | sch_id, reason_must, show_explain, enable_course, allow_exchange, time_managed_types(CSV 字典key), time_limit_enabled | +| `leave_time_set` | 作息时间(一校一行) | sch_id, forenoon1, forenoon2, afternoon1, afternoon2, interval_minutes(默认 08:00/12:00/13:00/17:00/10) | +| `leave_user_time` | 教师时长池 | sch_id, user_id, user_name, usable_hours, remark;唯一键 (sch_id,user_id) | +| `leave_user_time_record` | 时长流水 | sch_id, user_id, user_name, leave_id, leave_type, edit_hours(±), old_hours, new_hours, describe, operator_id, create_time | +| `leave_section` | 节次表(P7 课务用,设置页可维护) | sch_id, name, rank, begin_time, end_time | +| `leave_cc` | 抄送留痕 | sch_id, leave_id, cc_user_id, cc_user_name, instance_id, create_time | +| `sys_dict` | 字典(保留):新增 `leaveType` | (sch_id, dict_type='leaveType', dict_key, dict_value, sort_order, enable_flag) | +| `up_file` / `sys_operation_log` | 附件 / 审计日志(保留) | 现状不动 | +| `flow_callback_event` / `flow_callback_reg` | 回调 starter 自动建表 | 不手工维护 | + +### 4.3 前端路由与 modId 契约(对齐平台菜单树) + +| modId | path | 页面 | 说明 | +|---|---|---|---| +| 100 | `/apply` | 我要请假 | 新建/编辑(`?serid=`),含时长试算、附件、抄送人 | +| 110 | `/myleave` | 我的请假 | 本人列表:查看/编辑/销假/撤回/删除 | +| 111 | `/detail` | 请假详情 | `?leaveId=`,**流程消息跳转落地页**;meta.parentId=110(菜单不单列) | +| 120 | `/audit` | 审核列表 | tab:待我审核(流程待办)/ 我已审核;同意/拒绝/流程图 | +| 130 | `/cc` | 抄送列表 | leave_cc 渲染 | +| 140 | `/all` | 全部请假 | 管理端全量 + 导出 CSV | +| 170 | `/setting` | 请假设置 | tab:作息时间/请假类型/教师时长/节次/其他设置 | +| 180 | `/timelog` | 时长日志 | 时长流水 + 合计(新加/剩余),支持加减时长 | +| 190 | `/stats` | 统计分析 | 数量/时长按类型与月份、明细 | +| (150) | `/assignment` | 指派代课 | **P7 可选** | +| — | `/` → `/apply` 重定向 | — | | + +> 平台菜单若分配了不同 modId:优先按 `path` 解析(menuRoute.ts 已具备),仅需把上表 modId 改为实际值即可。 + +### 4.4 前端文件结构(E:\YHXT\view\WorkLeave) + +``` +src/ +├── api/module/leave.ts # 全部接口封装(沿用 get/postForm/postJSON 约定) +├── types/leave.ts # 类型契约(枚举、VO/DTO、请求入参) +├── router/routes.ts # 上表路由 + meta.modId/parentId +├── utils/leaveTime.ts # 时长计算(与后端同规则) +├── views/leave/ +│ ├── ApplyLeave.vue # 我要请假(新建/编辑) +│ ├── MyLeave.vue # 我的请假 +│ ├── LeaveDetail.vue # 详情(信息+进度时间线+附件+抄送人) +│ ├── LeaveAudit.vue # 审核列表 +│ ├── LeaveCc.vue # 抄送列表 +│ ├── AllLeave.vue # 全部请假 +│ ├── LeaveSetting.vue # 请假设置(多 tab) +│ ├── TimeLog.vue # 时长日志 +│ └── LeaveStats.vue # 统计分析 +└── (保留)components/、utils/、assets/ 等基础设施;删除 views/dorm/* +``` + +--- + +## 5. 接口契约(前后端对齐) + +统一前缀:`/{platform}/api`(前端 `serviceHost` + `/api/...`;平台值 api/manage/mobiapi)。响应由平台 `ApiResponseAdvice` 包装 `{code,msg,data}`,alova 解包 `data`。 + +### 5.1 申请域 `leave/` + +| 方法 | 接口 | 入参 | 出参 | +|---|---|---|---| +| GET | `/leave/List` | scope(user=我的/all=全部), status, keyword, startTime, endTime, pageIndex, pageSize | `{content:[LeaveVo], total, ...}` | +| GET | `/leave/Detail` | leaveId | LeaveDetailVo(含进度、抄送、附件、快照、当前用户能否审核) | +| POST | `/leave/Create` | LeaveRequest(JSON) | leaveId(内部:落库→发起流程→回写 bizId/instanceId) | +| POST | `/leave/Edit` | LeaveRequest + id(JSON) | void(仅 0/2/4 状态;重新发起流程 attempt+1) | +| POST | `/leave/Delete` | ids(JSON 数组) | void | +| POST | `/leave/Withdraw` | id | void(调流程中心 withdraw) | +| POST | `/leave/Cancel` | id + reason(JSON) | void(销假:状态 1 → 3,返还时长,落流水) | +| GET | `/leave/Progress` | leaveId | `{steps:[...], tasks:[...]}`(流程中心 progress 包装) | +| GET | `/leave/DiagramUrl` | leaveId | `{url}`(流程图 10 分钟 token) | +| GET | `/leave/MyTime` | leaveType | `{usable, inTransit, remaining}`(表单页余额提示) | + +### 5.2 审核域 `leave/` + +| 方法 | 接口 | 入参 | 出参 | +|---|---|---|---| +| GET | `/leave/TodoList` | pageIndex, pageSize, keyword | `{content:[TodoVo], total}`(todoTasks(bizType=leave) + 本地单号补全) | +| GET | `/leave/AuditedList` | pageIndex, pageSize, keyword | 已办:本地按 instance 与审核记录反查 | +| POST | `/leave/Audit` | taskId, action(approve/reject), opinion(JSON) | void(approveTask/rejectTask;状态由回调推进) | + +### 5.3 抄送 / 统计 / 设置 / 时长 + +| 方法 | 接口 | 说明 | +|---|---|---| +| GET | `/leave/CcList` | 抄送给我的列表(leave_cc 关联 leave_apply) | +| GET | `/leave/Statis` | dateStart/dateEnd → 总数量、总时长、按类型分布、按月趋势、明细 Top | +| GET | `/leave/setting/GetTimeSet` / `SetTimeSet` | 学校作息 | +| GET | `/leave/setting/GetOption` / `SetOption` | 其他设置(含时长管控类型) | +| GET | `/leave/setting/SectionList` | 节次列表(P7 预览,设置页可维护) | +| POST | `/leave/setting/SectionSave` / `SectionDelete` / `SectionSort` | 节次维护 | +| GET | `/leave/time/UserTimeList` | 教师时长列表(按组/关键字;可用 `base/GetSchoolAll` 组树过滤) | +| POST | `/leave/time/Adjust` | 加减时长(单人或批量,JSON;写流水) | +| GET | `/leave/time/Records` | 时长流水(rqType 0今日/1本周/2本月/3累计 + 关键字 + 分页) | +| GET | `/leave/time/Sum` | 合计:新加总时长、剩余总时长 | +| GET/POST | `/api/dict/*`(复用) | type=leaveType 的字典增删改查排序(请假类型 tab) | + +### 5.4 平台代理接口(保留,改包名) + +`base/GetUserInfo`、`base/GetUserMod`、`base/GetSchoolAll`(选人树)、`base/SetRole`、`base/FileUpload`、`base/Logout`、 +`dict/*`、`file/*`、`contacts/*`(自定义组)、`log/*`(审计日志查询)。 + +--- + +## 6. 业务规则明细 + +### 6.1 时长计算(前后端同规则,工具类 `LeaveTimeCalculator` / `leaveTime.ts`) + +输入:起止时间(yyyy-MM-dd HH:mm)、作息(F1/F2/A1/A2)。输出:小时(四舍五入 2 位)。 + +- 同日:start `clamp` 到工作时段(早于 F1→F1;F1~F2 之间保持;F2~A1 之间→A1;晚于 A2→A2;A1~A2 保持); + end 同理(F2~A1 之间→F2);`minutes = end - start`;若 `start<=F2 && end>=A1` 再减午休 `(A1-F2)`。 +- 跨天:首日 = `A2 - start`(start 落在 F1~F2 时再减午休);末日 = `end - F1`(end 落在 A1~A2 时再减午休); + 中间整天按 `8h/天`(= `A2-A1 + F2-F1`);合计取小时。 +- 校验:结束必须晚于开始;单条时长 > 0;跨天视为按天折算(不再区分上下午字段)。 + +### 6.2 申请与校验(Create/Edit) + +1. 类型必须为启用的 leaveType 字典项;事由在 `reason_must=1` 时必填且 ≤50 字。 +2. 时间冲突:同人、`status in (0,1)`、`(start_time < 已有.end_time) && (已有.start_time < end_time)` → 报"与已有请假时间冲突"。 +3. 时长管控:类型 ∈ `time_managed_types` 时,`在途(0状态且管控类型) + 本次 <= usable`(无记录按 0),否则报"可申请 X 小时,时间不足"。 +4. `total_hours` 后端按 §6.1 复核(与前端不一致以服务端为准落库)。 +5. 提交成功 → 组装 `FlowApplyBody`:bizType=`leave`、bizId=`{id}-{attempt}`、bizTitle=`{姓名}的{类型}申请`、bizJump=page("detail").param("leaveId",id)、bizSnapshot=摘要(类型/起止/时长/事由)、submitterId/Name、formData(类型、时长、天数)。 +6. 发起成功回写 `biz_id/instance_id/flow_guid/status=0`;`duplicated=true` 时视为幂等成功(不新建实例)。 + +### 6.3 回调状态机(LeaveFlowCallbackHandler) + +| 事件 | 处理 | +|---|---| +| `flow.submitted` / `flow.task.created` / `flow.task.handled` | status→0(审批中);**终态不回退**:已 1/2/3/4 则忽略 | +| `flow.completed` | status→1;扣时长(管控类型才执行)+ 流水"扣除";落终态快照 | +| `flow.rejected` | status→2;不扣不返还;落终态快照 | +| `flow.withdrawn` | status→4(仅当时为 0 时) | +| `flow.cc.sent` | 写 `leave_cc` 留痕(幂等:同 leaveId+ccUserId+instanceId 不重复落) | + +- 乱序守卫:事件带 `instanceVersion`,小于单据已记录版本的事件忽略(终态快照之外)。 +- 事务:回调方法内异常 → 整体回滚 + 流程中心重推;确定性脏消息(bizId 解析失败等)记录 warn 后正常 return,不抛异常。 +- 注册:首次发起前 `callbackRegistrar.ensureRegistered(schId, flowGuid)`(结果落 flow_callback_reg 镜像表)。 + +### 6.4 销假(Cancel)与撤回(Withdraw) + +- 销假:仅 `status=1`;置 3;管控类型返还时长 + 流水"销假返还";记 `cancel_time/reason`(快照字段)。已通过 50 天时效沿用(可配置为 50 天常量)。 +- 撤回:仅 `status=0`;调 `YschFlowOpenApi.withdraw(schId, instanceId, submitterId)`;等回调置 4(不本地直改,避免与流程中心不一致)。 + +### 6.5 时长池管理 + +- 列表:`leave_user_time` LEFT JOIN 教师名单(`base/GetSchoolAll` 同源,按组过滤)。 +- 调整:`edit_hours` 可正可负,`now = old + edit`(不低于 0),写流水(describe=备注或"新加"),operator 取当前用户。 +- 流水统计:`Sum` 返回 `新加总=Σ(±)`、`剩余总=Σ usable`(按过滤区间口径)。 + +--- + +## 7. 流程审核器对接要点(照手册执行) + +1. **pom**:`yh-flow-callback-spring-boot-starter` + `spring-boot-starter-data-redis`(验签密钥链必需,optional 不传递)。 +2. **配置**: + ```yaml + yh: + app-auth: { enabled: true, app-key: "1000xx", app-secret: "******" } + flow-callback: + enabled: true + biz-types: [leave] + open-host: ${yh.open.base-host} # 未配 callback-url 时用于拼回调地址 + management.health.redis.enabled: false + leave.flow.guid: leave-apply-v1 # 编排所得 + ``` + 回调地址默认 `/oapi/flow/callback`(starter 自证 context-path 生成);`yh.sso.url-pattern=/api/*` 不含 oapi,无需放行改造。 +3. **发起**:`YschFlowOpenApi.apply(schId, body, FlowApplyResult.class)`。 +4. **回调**:实现 `FlowCallbackHandler#onEvent`(§6.3);`schId` 从事件取,不信任本地上下文。 +5. **待办/审核**:`todoTasks(schId, assigneeId, "leave", page, size)`、`approveTask(schId, taskId, operatorId, operatorName, opinion[, edgeName])`、`rejectTask(...)`。 +6. **进度/流程图**:`progress(null, "leave", bizId, schId, FlowProgress.class)`、`diagramUrl(instanceId, flowGuid, "track", schId, FlowDiagramUrl.class)`。 +7. **自检**(手册 §5.1):本地单测(mock 回调事件 + apply 出参断言)→ 160 环境全链路 9 项验收。 + +--- + +## 8. 分阶段任务与验收 + +### P0 骨架重整 +- [x] 后端:包 `com.yunhai.dormnotice` → `com.yunhai.workleave`(目录迁移 + 全量 import 替换);`DormNoticeApplication` → `WorkLeaveApplication`;pom `artifactId/name/finalName` → `workleave`;`bootstrap.yml spring.application.name` → `workleave`;日志/库名对齐 +- [x] 后端:删除宿舍业务(ActivityController、Activity 系实体/模型/仓储/服务);保留 log/dict/file/base/contacts 底座 +- [x] 前端:`package.json` name、`vite.config` 标题、`index.html`、README、App.vue appName → 教师请假 +- **验收**:后端 `mvn -q compile` 通过;前端构建通过;全局搜索无 `dormnotice` 残留 + +### P1 实体与数据层 +- [x] `BaseEntity` 审计人改存 userId(`create_by/update_by`) +- [x] 新实体 + Repository(LeaveApply/LeaveSetting/LeaveTimeSet/LeaveUserTime/LeaveUserTimeRecord/LeaveSection/LeaveCc/LeaveAuditTrace) +- [x] `sql/workleave_schema.sql`(建表 + leaveType 字典种子:事假/病假/调休/公假/婚假/产假) +- **验收**:编译通过;启动自动建表成功;`leaveType` 字典种子可查 + +### P2 设置域 + 时长域 +- [x] `LeaveSettingController/Service`:作息时间、其他设置、节次 +- [x] `LeaveTimeController/Service`:时长列表/调整/流水/合计;`LeaveTimeCalculator` +- **验收**:单测覆盖时长算法(同日/跨天/边界 clamp/午休)—— 16 例全绿 + +### P3 申请域 + 流程对接 +- [x] `LeaveController/Service`:Create/Edit/Delete/Withdraw/Cancel/List/Detail/MyTime/Statis/TodoList/AuditedList/Audit +- [x] `flow/LeaveFlowService` + `flow/LeaveFlowCallbackHandler`(§6.3) +- [x] pom/application.yml 流程依赖与配置(§7) +- **验收**:编译通过;回调单测(mock FlowCallbackEvent 覆盖 5 类事件 + 终态不回退 + 乱序门 + 抄送幂等)—— 12 例全绿 + +### P4 前端壳层与路由 +- [x] `api/module/leave.ts`、`types/leave.ts`、`router/routes.ts`(§4.3)、`menuRoute.ts` 别名更新 +- [x] 删除 `views/dorm/*`、`api/module/dorm.ts`、`types/dorm.ts` 及依赖其的组件 +- **验收**:`npm run build` 通过;菜单解析器对新节点 path 命中 + +### P5 前端页面 +- [x] ApplyLeave:表单 + 时长实时试算 + 余额提示 + 附件 + 抄送人 + 提交/重新提交 +- [x] MyLeave / LeaveDetail / LeaveAudit / LeaveCc / AllLeave +- [x] LeaveSetting(5 个 tab)/ TimeLog / LeaveStats +- **验收**:构建通过(联调需本地后端 + DB 环境,见 P6 说明) + +### P6 联调与验收 +- [x] 后端 `mvn -o compile` / 单测全绿;前端 `npm run build` +- [ ] 160 环境按手册 §5.1 九项清单验收(含消息卡片跳转 `/detail?leaveId=`)—— **需管理员提供 A1~A6 前置项** +- **验收**:清单全过;`proj34.sys_message` 最新行 `jump_path=/detail?leaveId=xxx`、`jump_app=leave` + +### P6.5 本地冒烟测试(2026-10-09 新增) + +> 目标:在不依赖平台管理员前置项(A1~A6)的前提下,对已交付代码做**本地全链路冒烟**, +> 验证后端服务、数据库、核心业务接口、前端构建与页面渲染的可运行性。 +> 160 全链路(真实 SSO 登录 + 流程审批闭环)仍归 P6,等管理员补齐前置项。 + +**冒烟辅助件(开发专用,生产零影响)**: +- 新增 `config/DevMockUserFilter`:仅当 `smoke.auth.enabled=true` 时注册,向 `BoaUserContext` + 注入可配置的模拟用户(`smoke.auth.user-id/user-name/sch-id/user-type`),默认关闭。 +- 本地启动时用 `--yh.sso.url-pattern=/__smoke_off__/*` 关闭 CAS 拦截,使 `/api/*` 可直连 + (模拟用户由上述过滤器提供,业务代码无需感知)。 + +**冒烟清单(S1~S6)**: + +| # | 项 | 内容 | 通过标准 | 结果 | +|---|---|---|---|---| +| S1 | 构建 | `mvn -o test`(含 28 例单测) | BUILD SUCCESS,0 失败 | ✅ 28 例全绿(修复后复跑仍全绿) | +| S2 | 启动 | smoke 模式 `java -jar` 起 8105 | 日志无 ERROR,`/actuator/health` 200 | ✅ | +| S3 | 数据 | DB `work_leave` 13 张表 + leaveType 种子 | 表齐全,LT001~LT006 可查 | ✅ 13 表 + 6 种子 | +| S4 | 设置域 API | GetTimeSet/SetTimeSet、GetOption/SetOption、dict 全链路 | 读默认值→写入→回读一致 | ✅(修复 2 个 bug 后通过,见 §10) | +| S5 | 申请/时长域 API | List 空态、time/Adjust+Sum+Records、MyTime、Statis、Create(流程中心链路) | 只读接口正常;Adjust 后 Sum/Records 联动;Create 失败时**事务回滚无孤儿单**(flowGuid 占位期预期),若流程中心可用则全链路通过 | ✅ Adjust +8h→Sum/Records/MyTime 联动一致;Create 报"流程失败 404"且回滚(leave_apply=0);操作日志记录失败尝试 | +| S6 | 前端 | `npm run build` + dev server 浏览器访问核心页面 | 构建通过;页面壳层渲染、控制台无阻断性报错 | ✅ 9 页面全渲染;申请表单可交互(类型下拉 6 项/时长试算 4h/事由 50 字计数/提交按钮联动);提交失败 toast 正确透传后端错误;"我的请假"空态表格/统计卡正常;设置页 5 tab 正常 | + +**执行结论随做随记入 §10。** + +### P7 可选扩展(v1 后) +- [ ] 指派代课/调课(课务安排表 + 教务安排人 + 通知);节次/班级/科目数据源接入 +- [ ] 修改待审流程(快照表 `leave_apply_old` + 二次流程) +- [ ] 发起销假审批(第二 flowGuid `leave-cancel-v1`) +- [ ] 教师时长 Excel 导入导出;统计图表增强 + +### P8 对齐旧版生产行为(2026-10-09,参考 oa.bestsch.com/leave 实地勘察) + +> 背景:登录旧版生产系统(lwj 账号)实地核对,发现原计划 v1 裁剪掉的两个行为在生产实际在用, +> 且旧版"其他设置"里有**请假计算(按小时/按天)**开关(该学校当前为按天)。本阶段把这两块补齐。 +> 旧版算法结论:后端永远存**小时**(leave_totaltime),"按天"只是输入/显示模式(显示=小时/8), +> 与新架构小时制天然兼容,改造聚焦在输入侧与配置面。 + +**后端**: +- `leave_setting.calc_way`(1 按小时[默认] / 0 按天,历史 IsCalculateWay 同义) +- `leave_time_set.min_minutes`(最少请假时间,分钟,0 不限制;历史"学校时间设置"项) +- `leave_apply.course_arrange`(0 无课务安排 / 1 自行安排 / 2 教务安排;不传默认 0) +- Create/Edit 校验:时长分钟 < min_minutes → 拒绝"请假时长不能少于 X 分钟"(边界=值本身放行) +- VO:TimeSetVo+minMinutes、LeaveSettingVo+calcWay、LeaveRequest/LeaveVo/LeaveDetailVo+courseArrange(+Name) + +**前端**: +- ApplyLeave:calcWay=0 时表单切换为「半天/半天以上 + 日期 + 上午/下午」输入(按作息组合起止 + datetime 后复用既有小时试算),时长主显**天**(=小时/8,同旧版);calcWay=1 保持 datetime 输入。 + 新增「时间说明」行(showExplain=1 时):`上午(F1-F2)-下午(A1-A2),固定X小时工作制`。 + 新增「课务安排」三选一(enableCourse=1 时,默认无课务安排)。 +- LeaveSetting:作息 tab + 「最少请假时间(分钟)」;其他设置 tab 置顶 + 「请假计算:按小时/按天」 + radio;课务安排/允许换课去掉 P7 标注(课务字段已落地输入侧,代课执行仍属 P7)。 +- LeaveDetail:显示课务安排。 + +**验证(2026-10-09)**:28 例单测全绿;smoke API 实测 calcWay/minMinutes 读写回显一致、 +20 分钟申请被拒(30 下限)、4h 半天口径(08:00-12:00)正确、courseArrange 落库字段就绪(流程 404 +回滚期无孤儿单);浏览器(真实账号李晶 + 8108 IDEA 实例 + devtools 热载):时间说明行、按小时表单、 +左侧 9 菜单节点渲染正常。 + +**端口变更**:application.yml `server.port` 8105 → **8108**(IDEA debug 实例),vite 代理同步改 8108。 +用户另起 dev server 于 **8079**(与 8078 同源,均为 vite --mode dev)。 + +**学校 3(李晶,对齐旧版生产)已切按天**:`calc_way=0`、`enable_course=1`(2026-10-09 经 DB 设置)。 +8079 实测:半天=0.5 天(合 4 小时)、半天以上 10-12 上午~10-14 下午=3 天(合 24 小时),与旧版算法一致。 + +**按小时模式对齐旧版(2026-10-09 二次勘察旧系统按小时形态后)**: +起止时间改为旧版同款四控件一行(开始日期+开始时间 至 结束日期+结束时间),TimePicker 分钟按 +`最短请假间隔` 步进(`:steps=[1,intervalMinutes,1]`,对应旧版分钟过滤);时长标签「时长(小时)」 +纯小时显示(去掉天副注)。8079 实测 10-12 08:00~12:00 = 4 小时。验证后学校 3 已切回按天。 + +**附件 → 图片上传(2026-10-09,对齐旧版)**:`AttachmentUpload` 组件加 `imageOnly` 模式 +(仅 jpg/jpeg/bmp/png、单张 ≤20MB、accept 过滤、错误文案"只能上传 jpg/bmp/png 格式的图片!"); +申请页 FormItem「附件」→「图片」,按钮「上传图片」+ 提示 +「(上传图片格式:*.jpg、*.bmp、*.png 图片大小不要超过20MB)」逐字对齐旧版; +详情页「附件」→「图片」,图片文件渲染 60×60 缩略图(点击预览直链打开)。 +后端 FileUpload 保持通用(格式限制在前端,P6 联调时可评估是否服务端加固)。 + +**申请页样式对齐旧版(2026-10-09 四轮,末轮按旧版真实 CSS 值精调)**:① label 统一加全角「:」 +(CSS `::after`,不逐个改);② `label-width` 100px、标签色 #272829(旧版实测值); +③ **表单铺满内容区**(取消窄卡片约束,旧版控件实测宽 ~946px);④ 控件宽度按旧版实测: +类型下拉/事由文本框**满宽**、日期 190px(旧版 193px)、时间 130px、时长 180px(旧版 180px); +⑤ 行距 20px(旧版实测);⑥ 时长由蓝色文字改为**禁用 Input**(占位「自动获取时长」); +⑦ 按天模式「起止时间」合并为一行:半天/半天以上 + 开始日期 + 上午/下午(半天以上再接 +「至」+ 结束日期 + 上午/下午);按小时模式同为四控件一行;⑧ 事由占位「请输入请假事由……」(旧版原文); +⑨ 图片上传按钮改旧版同款方形图标按钮;⑩ 抄送人未选时显示灰色提示 +「请选择抄送人,审批通过后通知抄送人」;⑪ 时间说明文字色 #515a6e。 +8079 浏览器复核:满宽下拉/事由、100px 冒号标签、180px 时长框、一行起止时间、方形图片按钮均与旧版一致。 + +### P9 流程一致性与安全加固(2026-10-09 现状审计新增) + +> 审计范围:以《子应用流程与消息对接手册-20260929》§2.3、§2.4、§5.1 为准,复核 +> `LeaveFlowService`、`LeaveFlowCallbackHandler`、申请身份边界及现有单测。P0~P8 已交付功能不重做。 + +**审计发现**: + +1. 流程发起返回 `null` 或缺少 `instanceId` 时,当前代码仍可能提交为“审批中”,形成无真实流程实例的孤儿单。 +2. 拒绝/撤回后重新提交会复用同一请假主键;旧实例迟到事件若按主键兜底定位,可能误推进新 attempt。 +3. `LeaveRequest.userId` 来自客户端请求体,普通教师可构造该字段替其他用户发起申请;申请人必须只信任登录态。 +4. 手册 §5.1 要求 apply 请求契约断言;现有 12 例流程测试只覆盖回调状态机,没有覆盖 + `bizType/bizId/bizJump/bizSnapshot/formData/submitter`。 + +**实施任务**: + +- [x] P9.1 发起结果强校验:`FlowApplyResult` 及 `instanceId` 必须非空,否则抛业务异常并由事务回滚。 +- [x] P9.2 回调实例隔离:事件 `bizId/instanceId` 必须与当前 attempt 一致;旧实例迟到事件正常忽略,不触发重推。 +- [x] P9.3 回调终态幂等:重复终态事件不重复产生副作用,冲突终态事件不得覆盖已落终态。 +- [x] P9.4 申请身份收口:Create 始终使用登录态 `currentUserId/userName`,删除前后端请求契约中的 `userId`。 +- [x] P9.5 补齐流程发起请求构造测试、空实例回滚边界测试、旧 attempt/终态冲突回调测试。 +- [x] P9.6 验证:后端 `mvn test` 34 例全绿;前端 `npm run typecheck:leave` 通过;`npm run build` 通过。 + 全工程 `vue-tsc --noEmit` 已执行,另发现 74 个模板历史错误(知识树/年级班级/Vant 等未接入请假路由的遗留件), + 与本次请假业务文件无关,登记为 R7,不混入本阶段改造。 + +**验收标准**: + +- 流程发起无实例 ID 时接口失败且事务不留下申请记录;正常结果完整回写 `bizId/instanceId`。 +- `100-1` 的迟到回调不能修改当前 `bizId=100-2` 的单据;重复 `completed` 只扣减一次。 +- 请求体即使伪造 `userId` 也不进入后端 DTO,申请人只取登录态。 +- 单测显式断言跳转为 `/detail?leaveId=xxx`、`bizType=leave`,以及快照/表单/提交人字段。 + +--- + +## 9. 风险与未决项 + +| # | 风险/未决 | 处置 | +|---|---|---| +| R1 | app_key/app_secret、flowGuid、菜单 modId 实际值未定(依赖管理员) | 全部走配置项;本地先以占位值 + mock 单测开发;P6 前与管理员确认 | +| R2 | 回调在本地收不到(flow-center 不能推回本机) | 回调逻辑以单测 mock 覆盖;真链路 160 验收 | +| R3 | Redis 不可达导致启动/健康检查异常 | `management.health.redis.enabled=false`;starter 只在回调验签用 Redis,本地联调可用 docker 起一个 | +| R4 | 历史「按天/半天」模式与「课务安排」不影响 v1 核心闭环 | 已在决策中裁剪,P7 按需恢复 | +| R5 | 教师名单来源(bschclass `GetSchteaList`)不可达时选人/时长列表降级 | 沿用现有 ClassService 缓存 + 空列表降级策略 | +| R6 | 时长算法双端实现漂移 | 同一份规则单测用例(P2 后端 + P4 前端各一份对照) | +| R7 | 前端模板遗留 74 个全量类型错误(知识树/年级班级/移动端 Vant 等未使用模块) | 新增 `tsconfig.leave.json` + `npm run typecheck:leave` 作为请假模块严格门禁;遗留模板另立清理任务,不影响当前构建 | + +--- + +## 10. 执行记录(随实施更新) + +| 日期 | 阶段 | 说明 | +|---|---|---| +| 2026-10-08 | 计划 | 本文档创建,勘察完成 | +| 2026-10-08 | P0 完成 | 后端包名 `com.yunhai.workleave`、宿舍业务全删、工程标识更名 workleave;前端标识更名教师请假 | +| 2026-10-08 | P1 完成 | 8 个业务实体 + 仓储 + 建库脚本(含 leaveType 字典种子);审计人改存 userId | +| 2026-10-08 | P2 完成 | 设置域(作息/其他/节次)+ 时长域(列表/调整/流水/合计)+ LeaveTimeCalculator;16 例单测通过 | +| 2026-10-08 | P3 完成 | 申请域(申请/编辑/删除/撤回/销假/待办/审核/统计)+ 流程门面 + 回调状态机;12 例单测通过(共 28 例) | +| 2026-10-08 | P4/P5 完成 | 前端 types/api/routes/壳层重建;9 个业务页面落地;宿舍遗留全删 | +| 2026-10-08 | P6 完成 | 后端 `mvn package` 出包 `target/workleave.jar`(105MB fat jar);28 例单测 + 前端 `npm run build` + `vue-tsc` 新增文件全绿;**启动验证**:`java -jar` 起 8105,Hibernate 自动建 13 张表(leave_apply/leave_setting/leave_time_set/leave_user_time/leave_user_time_record/leave_section/leave_cc/leave_audit_trace + 底座表),`/actuator/health` 返回 200,`leaveType` 字典种子可查(LT001~LT006) | +| 2026-10-08 | 待办 | 160 环境全链路验收依赖 §3 前置项 A1~A6(app_key/flowGuid/菜单 modId 由管理员分配);`/api/*` 请求按平台 CAS 约定 302 跳 SSO 登录(浏览器带票据后正常,与改造前行为一致) | +| 2026-10-09 | P6.5 冒烟 | S1~S6 全部执行完毕(结果见 §8 P6.5 表)。**新增冒烟辅助件** `config/DevMockUserFilter`(`smoke.auth.enabled=true` + `--yh.sso.url-pattern=/__smoke_off__/*` 启动即可本地免 CAS 冒烟)。**修复 3 个 bug**:① `DictService.normalizeType` 大小写比对错误(`"leaveType".equals("leavetype")` 恒 false)导致字典写接口全挂 → 改为忽略大小写比对;② `LeaveSettingService.saveOption` 显式 null 无法清空 `timeManagedTypes` → 改为无条件写入(null 视为清空);③ **`LeaveRequest.userId` 多余的 `@NotNull` 与前端契约冲突**(前端从不传 userId、后端本就从 UserContext 取)导致前端 Create/Edit 100% 被 Bean Validation 拦截报"不能为null" → 删除该注解。**前端修复**:ApplyLeave/LeaveSetting 共 6 处裸 `required` FormItem 补 `:model`/`prop`/`rules`,消除常驻 "undefined is required" 错误文案。修复后 28 例单测复跑全绿、前端重建通过、浏览器复验通过(提交失败 toast 正确透传"发起审批流程失败:OpenAPI 404",DB 无孤儿单)。流程中心 404 为 flowGuid 占位预期(待 A3) | +| 2026-10-09 | P8 完成 | 对照旧版生产系统(oa.bestsch.com/leave,lwj 登录勘察)补齐:按天模式(calc_way + 半天/半天以上表单 + 天显示)、最少请假时间(min_minutes + 校验)、课务安排三选一(course_arrange)、申请页时间说明行;详见 §8 P8。真实账号(李晶)浏览器全链路验证通过;application.yml 端口改 8108(IDEA 实例),vite 代理同步 | +| 2026-10-09 | P8 微调 | ① 移除申请页顶部「我要请假/编辑请假申请」标题(页面更贴近旧版留白);② **时长开放编辑**:申请页时长输入框由禁用改为可编辑(默认自动试算值、后缀带单位,起止时间变化后恢复自动值),提交携带手工值;后端 `resolveHours`:请求 totalHours>0 时以手工值为准(原逻辑一律服务端重算覆盖),最少请假时间/余额校验按最终落库值执行;后端编译 + 前端构建通过(后端需重启生效) | +| 2026-10-09 | P8 教师时长设置对齐旧版 | 按旧版 oa.bestsch.com/leave/leave_timeSetting「教师时长设置」tab 实地截图 + 历史源码(请假前端 TeaTimeImport.vue、C# leave_System.cs ImportQJSchUserTime/ExportQJSchUserTime)重做:① 管控类型勾选迁入教师时长 tab(「请选择需要使用时长的请假类型」勾选即存,timeLimitEnabled 随勾选联动;其他设置中原两项移除);② 左侧教师组树过滤(base/GetSchoolAll 组节点);③ 表格逐行「新加时长(小时)InputNumber + 情况备注」,底部组内合计剩余时长 + 统一提交(复用 Adjust 批量接口),替代原逐人弹窗;④ 新增 Excel 导入:GET leave/time/ImportTemplate(easyexcel 出模板:部门/教师名称/新加时长(小时)/备注,教师按组预填)+ POST leave/time/Import(表头校验、按部门+姓名定位教师、任一行错整体不入账并返回行级错误明细,语义同旧版 C#);前端 LeaveSetting.vue 重构该 tab + 导入弹窗;mvn test 28 例全绿 + 前端 build 通过(后端需重启) | +| 2026-10-09 | P9 完成 | 流程发起空实例强制失败回滚;回调增加当前 attempt/instance 隔离、重复终态幂等与冲突终态保护;Create 申请人只取登录态并移除前后端 `LeaveRequest.userId`;新增 6 例流程契约/边界测试(总计 34 例全绿),`npm run typecheck:leave` 与生产构建通过。全工程类型检查另检出 74 个未使用模板历史错误,见 R7。 | + +### 交付物清单 + +**后端**(`E:\YHXT\java\WorkLeave`) +- `docs/教师请假模块-改造计划-v1.md`(本文件) +- `src/main/resources/sql/workleave_schema.sql`(建表 + 字典种子) +- `src/main/java/com/yunhai/workleave/`(8 实体 + 8 仓储 + 4 控制器 + 5 服务 + 2 门面/回调) +- `src/test/java/com/yunhai/workleave/`(28 例单测) +- `target/workleave.jar`(可执行 fat jar) + +**前端**(`E:\YHXT\view\WorkLeave`) +- `src/types/leave.ts`、`src/api/module/leave.ts`、`src/router/routes.ts` +- `src/utils/leaveTime.ts`(与后端同规则的时长试算) +- `src/views/leave/*.vue`(9 个页面) +- `dist/`(构建产物,vite build 直出) + +### 启动与联调说明 + +```bash +# 后端(本地库 work_leave,root/123123,见 application.yml) +cd E:\YHXT\java\WorkLeave +mvn -o -DskipTests package +java -jar target/workleave.jar # 首启自动建表;8108 端口 + +# 前端 +cd E:\YHXT\view\WorkLeave +npm install && npm run dev # 8078,/api 代理到 8108 +npm run typecheck:leave # 请假模块严格类型检查 +``` + +> 本地未起 Redis / 未配 flow-callback 时 `yh.flow-callback.enabled=false`, +> 申请提交会因流程中心不可达而整体回滚(设计如此:不留"不回流"的孤儿单)。 +> 需要本地跑通提交链路时,按手册 §5.0 配 `yh.open.base-host` 指向 open 子域名并联通 160。 + +```bash +# 本地冒烟模式(免 CAS + 模拟用户 1001/冒烟测试教师/schId=1,见 P6.5) +java -jar target/workleave.jar --smoke.auth.enabled=true --yh.sso.url-pattern=/__smoke_off__/* +``` diff --git a/docs/流程管理页面-开发说明.md b/docs/流程管理页面-开发说明.md new file mode 100644 index 0000000..b19932a --- /dev/null +++ b/docs/流程管理页面-开发说明.md @@ -0,0 +1,140 @@ +# 流程管理页面 · 开发说明 + +> 依据《子应用流程与消息对接手册 v1.0》(2026-09-29)实现。 +> 日期:2026-10-09 + +## 一、做了什么 + +在「请假设置」(路由 `/setting`,菜单 modId=170)中新增第 6 个 Tab **流程管理**,提供: + +1. **流程列表** —— 实时拉取流程中心「流程编排」里已注册的流程(名称 / flowGuid / 版本 / 节点摘要)。 +2. **流程绑定** —— 选中流程后绑定到本校,支持两级作用域;绑定即触发回调登记。 + +之所以做成设置页的 Tab 而非新路由:左侧菜单来自平台 `GetUserMod`,新增 modId 需平台侧配菜单才会出现;挂在已有的「请假设置」下可立即访问。后续若要在菜单单列,只需新增 `/flow` 路由(meta.modId 取新值)+ 平台配一条菜单,页面逻辑可直接复用。 + +## 二、手册要点落地对照 + +| 手册条款 | 实现 | +|---|---| +| §1.3 流程中心编排注册流程,记下 flowGuid;多套流程 = 多个 flowGuid | 流程列表来自 `YschFlowOpenApi.listFlows`;绑定表按 scope+scopeKey 存多条 | +| §3.2 bizType 恒等于 app_code,不可点分档 | 页面顶部明示 `bizType = leave`;发起时 `FlowApplyBody.bizType` 仍取 `LeaveFlowService.BIZ_TYPE` | +| §2.4 首次发起前完成回调登记 | `bind()` 保存绑定后立刻调用 `flowService.ensureRegistered(schId, flowGuid)` | +| §2.4 登记失败不要阻断业务 | 登记异常只 warn,绑定照常落库 | +| §6.4 别用异常过滤脏消息 | 流程中心不可达时不抛错,降级为 `centerReachable=false`,页面只展示已绑定关系 | + +## 三、后端改动 + +### 新增文件 + +| 文件 | 作用 | +|---|---| +| `entity/LeaveFlowBind.java` | 流程绑定实体,表 `leave_flow_bind` | +| `repository/LeaveFlowBindRepository.java` | 按 (sch_id, bind_scope, scope_key) 查询 | +| `model/leave/FlowItemVo.java` | 流程列表项 + 绑定标记(是否默认 / 绑定到哪些类型) | +| `model/leave/FlowBindVo.java` | 已绑定关系行 | +| `model/leave/FlowManageVo.java` | 页面聚合视图(列表 + 绑定 + 请假类型 + 可达性) | +| `model/leave/LeaveTypeOptionVo.java` | 请假类型选项 | +| `model/leave/FlowBindRequest.java` | 绑定入参 | +| `service/ILeaveFlowBindService.java`
`service/impl/LeaveFlowBindService.java` | 列表 / 绑定 / 解绑 / 发起时解析 flowGuid | +| `controller/api/LeaveFlowController.java` | `/api/leave/flow/List`、`/Bind`、`/Unbind` | + +### 修改文件 + +- `flow/ILeaveFlowService.java` + `flow/LeaveFlowService.java`:新增 `listFlows(schId)`,封装 `YschFlowOpenApi.listFlows(schId, FlowSummary.class)`。 +- `service/impl/LeaveApplyService.java`:`startFlow()` 的 flowGuid 由「配置默认值」改为「绑定解析」: + + ``` + 单据已定(重提沿用) → 按请假类型绑定 → 学校默认绑定 → 配置 leave.flow.guid + ``` + +- `resources/sql/workleave_schema.sql`:补充 `leave_flow_bind` 建表脚本(线上以 `ddl-auto=update` 自动建表,该脚本用于人工建库/核对)。 + +### 数据表 + +```sql +leave_flow_bind( + sch_id, bind_scope(0=全校默认 / 1=按请假类型), scope_key, + flow_guid, flow_name, flow_version, node_summary, ... +) +``` + +`scope=0` 时 `scope_key` 固定为 `-`。未建物理唯一索引(避免不同环境 `ddl-auto=update` 追加约束失败),唯一性由服务层 upsert 保证。 + +## 四、前端改动 + +- `types/leave.ts`:`FlowBindScope` / `FlowItemVo` / `FlowBindVo` / `LeaveTypeOptionVo` / `FlowManageVo` / `FlowBindRequest`。 +- `api/module/leave.ts`:新增 `leaveFlowApi`(`List` / `Bind` / `Unbind`),导出 `leaveFlowApi`。 +- `views/leave/LeaveSetting.vue`:新增「流程管理」Tab,含 + - 全校默认流程卡片(流程名 / 版本 / flowGuid / 节点摘要 + 更换 / 解除) + - 流程列表表格(设为默认、按类型绑定;名称列以徽标展示绑定的请假类型名称,超过 2 个折叠为 +N、悬停看全量) + - 绑定弹窗(范围单选 + 请假类型多选下拉 + 流程下拉 + 节点摘要预览;按类型绑定时预选该流程已绑类型,提交为替换语义——未勾选的原绑定类型自动解除) + - 流程中心不可达时的告警条 + - 样式沿用页面既有 CSS 变量(`--set-*`),并补了 `prefers-color-scheme: dark` 深色覆写 + +> 2026-10-10 调整:移除独立的「按请假类型指定」表格卡片(类型绑定统一从流程列表「按类型绑定」入口操作); +> 绑定弹窗请假类型由单选改为多选(后端 `FlowBindRequest` 新增 `scopeKeys` 列表,一次把多个类型绑到同一流程); +> 流程列表名称列直接展示绑定的类型名称(原先只显示「N 个类型」计数)。 +> +> 2026-10-10 二次调整:列表去掉「流程标识(flowGuid)」列,绑定类型从名称列徽标拆为独立 +> 「绑定的请假类型」列(超 2 个折叠 +N、悬停看全量,未绑定显示灰字);页面不再裸露 flowGuid—— +> 默认流程卡片、绑定弹窗预览、默认流程空态(回落值改为显示流程名,中心查不到才显示配置值)同步收敛, +> 顶部提示删去 flowGuid 说明语句。 + +## 五、验收情况 + +- 后端 `mvn -o compile`(JDK8)通过。 +- 前端 `vue-tsc --noEmit -p tsconfig.leave.json` 通过;`vite build --mode base` 通过(154 modules transformed)。 +- **未做真链路联调**:本地不出站(手册 §5.0),流程中心不可达时页面走降级分支。需在 160 环境用真实 flowGuid 验证「列表拉取 → 绑定 → 发起 → 回调」。 + +## 六、遗留事项 + +1. 本地 `yh.flow-callback.enabled=false`,回调登记会跳过并 warn——属预期,上环境后由公共配置覆盖打开。 +2. 前端仓库根目录残留一个构建校验产物目录 `E:\YHXT\view\WorkLeave\dist-verify`(沙箱回收站不可用未能删除),可手动删除。 + +## 七、条件路由判断开放接口 `/oapi/flow/dept-judge`(2026-10-10) + +流程中心 CONDITION 节点(`routerKey=work-leave.dept-judge`,路由注册表 path=`/oapi/flow/dept-judge`, +serviceKey=work-leave)激活时**同步**回调本端点,按请假时长选出口:**小于3天 / 大于等于3天**。 + +已发布流程 `d2237a5b…`(v4)/ `c3e9c6ee…`(v7)「请假申请(小于3天或大于等于3天)」的两条出边 +name 即出口契约:`小于3天`、`大于等于3天`。 + +**契约**(对齐 noticeservice `OapiFlowRouteController` 参考实现,收在 `/oapi/*` 下由 AppAuthFilter +验 flow-center 签名): + +- 入参:POST JSON `{schId, instanceId, instanceVersion, bizType, bizId, nodeId, nodeName, routerKey, outlets, variables}` + - `bizId` 形态 `{leaveId}-{attempt}`,取首段定位 `leave_apply`; + - `outlets`:流程定义的出口名集合;`variables`:发起时透传的 formData(含 `totalHours`/`days`)。 +- 出参:`{"outlet": "小于3天" | "大于等于3天"}`。 +- 时长口径:**3天 = 24小时**(一天按作息 8 小时折算,同 `LeaveFlowService#buildFormData` 的 + `days=totalHours/8`);恰好 24h 归「大于等于3天」。 +- 免查库:`variables.totalHours` 存在则直接用(与发起口径同源),缺失回落按 bizId 查表。 +- 契约纪律:计算出的 outlet 不在 `outlets` 集合内 → 抛业务错让 flow-center 挂起,**绝不默认放行**; + 单据不存在 / 无时长同样挂起。 + +**鉴权**:`yh.app-auth.server.enforce-patterns: [/oapi/*]`(本次新增,对齐 noticeservice 生产姿势)—— +`/oapi/**` 无签名一律 401,有签名走中央密钥链校验;`application-local.yml` 已用永不匹配 pattern +`[/__local_no_match__]` 关闭(不能直接置空:base 的 `enforce-patterns[0]` 索引键仍会使条件命中, +而空 pattern 列表会让 FilterRegistration 启动报错),本地无签名联调不受影响。 + +**代码**:`controller/oapi/FlowJudgeController.java`(端点)+ `FlowJudgeControllerTest`(9 项单测: +出口两分支 / 恰好3天边界 / variables 免查库与回落查表 / bizId 带重提后缀解析 / 出口不命中与缺时长挂起)。 + +**响应包装**:端点裸返回 `{"outlet":...}`,由 yh-api-std `ApiResponseAdvice` 统一包成 +`{code:0, msg, data:{outlet}}` —— notice 参考实现(生产在用)同受此包装,flow-center 读 `data.outlet`,行为一致。 + +**病假绑定与真链路测试(2026-10-10)**: + +- 病假 = LT002(全局字典,schId=0),已在「流程管理」把 LT001/LT002/LT003/LT004/LT005 五类绑到 + c3e9c6ee(请假申请(小于3天或大于等于3天) v7,启用中)。v7 结构:CONDITION →「小于3天」审批人 + 10007 /「大于等于3天」审批人 10008(lijing),两分支汇合后抄送 10008 → END。 +- 本地直连契约测试(模拟 flow-center 请求体)全过:16h→`小于3天`;24h 整→`大于等于3天`; + variables 缺 totalHours→回落查表;出口不命中→业务错挂起(`code=2` + 明确 message)。 +- ⚠️ **平台侧前置缺口:流程中心的判断调用走开放网关 `{gateway}{basePath}{path}` + (=`open.yunhaixingtong.com/work-leave/oapi/flow/dept-judge`),网关当前无 `/work-leave` 路由 + (404;对照 `/notice` 路由存在、401=已到达 notice 服务)⇒ 网关出不了 160,调用永远到不了本机, + CONDITION 实例按设计**挂起在 u1c**。需平台部署 work-leave / 注册网关路由后真链路才能闭环。 +- 挂起实例可正常**撤回**(flow.withdrawn 正常回调,单据回已撤回)——是卡单的恢复路径。 +- 遗留:病假测试单 id=18(10-19 一天)在绑定生效前提交,走了兜底流程 d2237a5b 并卡在 u1c, + 可在「我的请假」撤回后重提(重提将按绑定走 c3e9c6ee)。 + diff --git a/gui-test-screenshots/sb_collapsed.png b/gui-test-screenshots/sb_collapsed.png new file mode 100644 index 0000000..b3f5e2f Binary files /dev/null and b/gui-test-screenshots/sb_collapsed.png differ diff --git a/gui-test-screenshots/sb_final_v_collapsed.png b/gui-test-screenshots/sb_final_v_collapsed.png new file mode 100644 index 0000000..a9ec2e8 Binary files /dev/null and b/gui-test-screenshots/sb_final_v_collapsed.png differ diff --git a/gui-test-screenshots/sb_final_v_narrow.png b/gui-test-screenshots/sb_final_v_narrow.png new file mode 100644 index 0000000..43c86d9 Binary files /dev/null and b/gui-test-screenshots/sb_final_v_narrow.png differ diff --git a/gui-test-screenshots/sb_final_v_wide.png b/gui-test-screenshots/sb_final_v_wide.png new file mode 100644 index 0000000..b310e9e Binary files /dev/null and b/gui-test-screenshots/sb_final_v_wide.png differ diff --git a/gui-test-screenshots/sb_narrow.png b/gui-test-screenshots/sb_narrow.png new file mode 100644 index 0000000..5a4222e Binary files /dev/null and b/gui-test-screenshots/sb_narrow.png differ diff --git a/gui-test-screenshots/sb_probe.png b/gui-test-screenshots/sb_probe.png new file mode 100644 index 0000000..267f5f2 Binary files /dev/null and b/gui-test-screenshots/sb_probe.png differ diff --git a/gui-test-screenshots/sb_probe2.png b/gui-test-screenshots/sb_probe2.png new file mode 100644 index 0000000..49fa87c Binary files /dev/null and b/gui-test-screenshots/sb_probe2.png differ diff --git a/gui-test-screenshots/sb_probe3.png b/gui-test-screenshots/sb_probe3.png new file mode 100644 index 0000000..31b6514 Binary files /dev/null and b/gui-test-screenshots/sb_probe3.png differ diff --git a/gui-test-screenshots/sb_probe4.png b/gui-test-screenshots/sb_probe4.png new file mode 100644 index 0000000..51e6a02 Binary files /dev/null and b/gui-test-screenshots/sb_probe4.png differ diff --git a/gui-test-screenshots/sb_wide.png b/gui-test-screenshots/sb_wide.png new file mode 100644 index 0000000..d09daa2 Binary files /dev/null and b/gui-test-screenshots/sb_wide.png differ diff --git a/gui-test-screenshots/sb_wide2.png b/gui-test-screenshots/sb_wide2.png new file mode 100644 index 0000000..36b2716 Binary files /dev/null and b/gui-test-screenshots/sb_wide2.png differ diff --git a/gui-test-screenshots/screenshot-1791530757281.png b/gui-test-screenshots/screenshot-1791530757281.png new file mode 100644 index 0000000..0d0066a Binary files /dev/null and b/gui-test-screenshots/screenshot-1791530757281.png differ diff --git a/gui-test-screenshots/screenshot-1791531557411.png b/gui-test-screenshots/screenshot-1791531557411.png new file mode 100644 index 0000000..9007070 Binary files /dev/null and b/gui-test-screenshots/screenshot-1791531557411.png differ diff --git a/gui-test-screenshots/screenshot-1791531649795.png b/gui-test-screenshots/screenshot-1791531649795.png new file mode 100644 index 0000000..2da72cb Binary files /dev/null and b/gui-test-screenshots/screenshot-1791531649795.png differ diff --git a/gui-test-screenshots/sidebar-beautify.png b/gui-test-screenshots/sidebar-beautify.png new file mode 100644 index 0000000..9f2f913 Binary files /dev/null and b/gui-test-screenshots/sidebar-beautify.png differ diff --git a/gui-test-screenshots/t0_sso_login_filled.png b/gui-test-screenshots/t0_sso_login_filled.png new file mode 100644 index 0000000..d3188b7 Binary files /dev/null and b/gui-test-screenshots/t0_sso_login_filled.png differ diff --git a/gui-test-screenshots/t1_after_submit.png b/gui-test-screenshots/t1_after_submit.png new file mode 100644 index 0000000..bc56659 Binary files /dev/null and b/gui-test-screenshots/t1_after_submit.png differ diff --git a/gui-test-screenshots/t1_after_type_select.png b/gui-test-screenshots/t1_after_type_select.png new file mode 100644 index 0000000..01bbe26 Binary files /dev/null and b/gui-test-screenshots/t1_after_type_select.png differ diff --git a/gui-test-screenshots/t1_apply_as_lijing.png b/gui-test-screenshots/t1_apply_as_lijing.png new file mode 100644 index 0000000..3462181 Binary files /dev/null and b/gui-test-screenshots/t1_apply_as_lijing.png differ diff --git a/gui-test-screenshots/t1_apply_initial.png b/gui-test-screenshots/t1_apply_initial.png new file mode 100644 index 0000000..df242bd Binary files /dev/null and b/gui-test-screenshots/t1_apply_initial.png differ diff --git a/gui-test-screenshots/t1_filled_before_submit.png b/gui-test-screenshots/t1_filled_before_submit.png new file mode 100644 index 0000000..08854cf Binary files /dev/null and b/gui-test-screenshots/t1_filled_before_submit.png differ diff --git a/gui-test-screenshots/t1_halfday_plus_mode.png b/gui-test-screenshots/t1_halfday_plus_mode.png new file mode 100644 index 0000000..adae796 Binary files /dev/null and b/gui-test-screenshots/t1_halfday_plus_mode.png differ diff --git a/gui-test-screenshots/t1_submit_blocked.png b/gui-test-screenshots/t1_submit_blocked.png new file mode 100644 index 0000000..ae15efc Binary files /dev/null and b/gui-test-screenshots/t1_submit_blocked.png differ diff --git a/gui-test-screenshots/t1_type_dropdown.png b/gui-test-screenshots/t1_type_dropdown.png new file mode 100644 index 0000000..e024961 Binary files /dev/null and b/gui-test-screenshots/t1_type_dropdown.png differ diff --git a/gui-test-screenshots/t2_myleave.png b/gui-test-screenshots/t2_myleave.png new file mode 100644 index 0000000..64de6cb Binary files /dev/null and b/gui-test-screenshots/t2_myleave.png differ diff --git a/gui-test-screenshots/t5_stats.png b/gui-test-screenshots/t5_stats.png new file mode 100644 index 0000000..93d2f22 Binary files /dev/null and b/gui-test-screenshots/t5_stats.png differ diff --git a/gui-test-screenshots/ut2.png b/gui-test-screenshots/ut2.png new file mode 100644 index 0000000..5a4b876 Binary files /dev/null and b/gui-test-screenshots/ut2.png differ diff --git a/gui-test-screenshots/ut3.png b/gui-test-screenshots/ut3.png new file mode 100644 index 0000000..5a4b876 Binary files /dev/null and b/gui-test-screenshots/ut3.png differ diff --git a/gui-test-screenshots/ut4.png b/gui-test-screenshots/ut4.png new file mode 100644 index 0000000..5a4b876 Binary files /dev/null and b/gui-test-screenshots/ut4.png differ diff --git a/gui-test-screenshots/ut5.png b/gui-test-screenshots/ut5.png new file mode 100644 index 0000000..7a7520e Binary files /dev/null and b/gui-test-screenshots/ut5.png differ diff --git a/gui-test-screenshots/ut6.png b/gui-test-screenshots/ut6.png new file mode 100644 index 0000000..7a7520e Binary files /dev/null and b/gui-test-screenshots/ut6.png differ diff --git a/gui-test-screenshots/ut7.png b/gui-test-screenshots/ut7.png new file mode 100644 index 0000000..7a7520e Binary files /dev/null and b/gui-test-screenshots/ut7.png differ diff --git a/gui-test-screenshots/ut_edit.png b/gui-test-screenshots/ut_edit.png new file mode 100644 index 0000000..f44009d Binary files /dev/null and b/gui-test-screenshots/ut_edit.png differ diff --git a/gui-test-screenshots/ut_final.png b/gui-test-screenshots/ut_final.png new file mode 100644 index 0000000..b8c23f7 Binary files /dev/null and b/gui-test-screenshots/ut_final.png differ diff --git a/gui-test-screenshots/ut_fixed.png b/gui-test-screenshots/ut_fixed.png new file mode 100644 index 0000000..5a4b876 Binary files /dev/null and b/gui-test-screenshots/ut_fixed.png differ diff --git a/gui-test-screenshots/ut_ok.png b/gui-test-screenshots/ut_ok.png new file mode 100644 index 0000000..288c778 Binary files /dev/null and b/gui-test-screenshots/ut_ok.png differ diff --git a/gui-test-screenshots/v_collapsed.png b/gui-test-screenshots/v_collapsed.png new file mode 100644 index 0000000..a9ec2e8 Binary files /dev/null and b/gui-test-screenshots/v_collapsed.png differ diff --git a/gui-test-screenshots/v_narrow.png b/gui-test-screenshots/v_narrow.png new file mode 100644 index 0000000..43c86d9 Binary files /dev/null and b/gui-test-screenshots/v_narrow.png differ diff --git a/gui-test-screenshots/v_wide.png b/gui-test-screenshots/v_wide.png new file mode 100644 index 0000000..b310e9e Binary files /dev/null and b/gui-test-screenshots/v_wide.png differ diff --git a/png-analyze.mjs b/png-analyze.mjs new file mode 100644 index 0000000..516c7d5 --- /dev/null +++ b/png-analyze.mjs @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import zlib from "node:zlib"; + +const file = process.argv[2]; +const buf = fs.readFileSync(file); + +// ---- 解析 PNG ---- +let pos = 8; +let W = 0, H = 0, bitDepth = 0, colorType = 0, idat = []; +while (pos < buf.length) { + const len = buf.readUInt32BE(pos); + const type = buf.toString("ascii", pos + 4, pos + 8); + const data = buf.subarray(pos + 8, pos + 8 + len); + if (type === "IHDR") { + W = data.readUInt32BE(0); H = data.readUInt32BE(4); + bitDepth = data[8]; colorType = data[9]; + } else if (type === "IDAT") idat.push(data); + else if (type === "IEND") break; + pos += 12 + len; +} +console.log(`size=${W}x${H} bitDepth=${bitDepth} colorType=${colorType}`); + +const raw = zlib.inflateSync(Buffer.concat(idat)); +const ch = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : 0; +if (!ch || bitDepth !== 8) { console.log("unsupported format"); process.exit(0); } +const stride = W * ch; +const px = Buffer.alloc(H * stride); + +// ---- 反 filter ---- +let p = 0; +for (let y = 0; y < H; y++) { + const ft = raw[p++]; + const line = raw.subarray(p, p + stride); p += stride; + const cur = px.subarray(y * stride, (y + 1) * stride); + const prev = y > 0 ? px.subarray((y - 1) * stride, y * stride) : null; + for (let x = 0; x < stride; x++) { + const a = x >= ch ? cur[x - ch] : 0; + const b = prev ? prev[x] : 0; + const c = prev && x >= ch ? prev[x - ch] : 0; + let v = line[x]; + if (ft === 1) v += a; + else if (ft === 2) v += b; + else if (ft === 3) v += (a + b) >> 1; + else if (ft === 4) { + const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c); + v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c); + } + cur[x] = v & 255; + } +} +const at = (x, y) => { const o = (y * W + x) * ch; return [px[o], px[o + 1], px[o + 2]]; }; +const near = (c, t, tol) => Math.abs(c[0]-t[0])<=tol && Math.abs(c[1]-t[1])<=tol && Math.abs(c[2]-t[2])<=tol; + +// ---- 直方图:统计出现最多的颜色 ---- +const hist = new Map(); +for (let y = 0; y < H; y += 2) for (let x = 0; x < W; x += 2) { + const c = at(x, y); const k = `${c[0]},${c[1]},${c[2]}`; + hist.set(k, (hist.get(k) || 0) + 1); +} +const top = [...hist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12); +console.log("主色 Top12(含占比):"); +for (const [k, v] of top) console.log(` #${k.split(",").map(n=>Number(n).toString(16).padStart(2,"0")).join("")} ${(v/((W/2)*(H/2))*100).toFixed(1)}% (${k})`); +fs.writeFileSync(process.argv[3] || "png-px.bin", Buffer.from(px)); diff --git a/png-bar.mjs b/png-bar.mjs new file mode 100644 index 0000000..3cfa6c1 --- /dev/null +++ b/png-bar.mjs @@ -0,0 +1,51 @@ +import fs from "node:fs"; + +const meta = JSON.parse(fs.readFileSync("clip.rgba.json", "utf8")); +const px = fs.readFileSync("clip.rgba"); +const { w, ch, h } = meta; +const at = (x, y) => { const o = (y * w + x) * ch; return [px[o], px[o + 1], px[o + 2]]; }; +const D = (a, b) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]); +const hex = (c) => c.map((v) => v.toString(16).padStart(2, "0")).join(""); + +const Y0 = Number(process.argv[2] || 372); +const Y1 = Number(process.argv[3] || 500); + +const isInk = (x, y) => { + const c = at(x, y); + if (D(c, [255, 255, 255]) <= 20) return false; + if (D(c, [227, 234, 246]) <= 14) return false; + if (D(c, [230, 235, 244]) <= 14) return false; + if (D(c, [236, 237, 243]) <= 10) return false; + return true; +}; + +const seen = new Uint8Array(w * h); +const comps = []; +for (let y = Y0; y < Y1; y++) { + for (let x = 0; x < w; x++) { + if (seen[y * w + x] || !isInk(x, y)) continue; + let minX = x, maxX = x, minY = y, maxY = y, n = 0, sr = 0, sg = 0, sb = 0; + const st = [x, y]; + seen[y * w + x] = 1; + while (st.length) { + const cy = st.pop(), cx = st.pop(); + n++; + const c = at(cx, cy); + sr += c[0]; sg += c[1]; sb += c[2]; + if (cx < minX) minX = cx; if (cx > maxX) maxX = cx; + if (cy < minY) minY = cy; if (cy > maxY) maxY = cy; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || nx >= w || ny < Y0 || ny >= Y1) continue; + if (!seen[ny * w + nx] && isInk(nx, ny)) { seen[ny * w + nx] = 1; st.push(nx, ny); } + } + } + if (n > 25) comps.push({ x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1, n, c: [Math.round(sr / n), Math.round(sg / n), Math.round(sb / n)] }); + } +} + +comps.sort((a, b) => a.x - b.x); +console.log(`区域 y${Y0}-${Y1} 内「墨迹」连通域 ${comps.length} 个(按 x 排序,CSS 坐标):`); +for (const b of comps) { + console.log(` x${Math.round(b.x / 2)} y${Math.round(b.y / 2)} ${Math.round(b.w / 2)}x${Math.round(b.h / 2)} px${b.n} avg#${hex(b.c)}`); +} diff --git a/png-box.mjs b/png-box.mjs new file mode 100644 index 0000000..b8d165c --- /dev/null +++ b/png-box.mjs @@ -0,0 +1,48 @@ +import fs from "node:fs"; +const meta = JSON.parse(fs.readFileSync("clip.rgba.json", "utf8")); +const px = fs.readFileSync("clip.rgba"); +const { w, ch } = meta, h = meta.h; +const at = (x, y) => { const o = (y * w + x) * ch; return [px[o], px[o+1], px[o+2]]; }; +const D = (a,b) => Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])+Math.abs(a[2]-b[2]); + +// 非白二值图(看整体结构) +const CW = 130, CHh = 56; +let map = ""; +for (let gy = 0; gy < CHh; gy++) { + let line = ""; + for (let gx = 0; gx < CW; gx++) { + const x0 = Math.floor(gx*w/CW), x1 = Math.max(x0+1, Math.floor((gx+1)*w/CW)); + const y0 = Math.floor(gy*h/CHh), y1 = Math.max(y0+1, Math.floor((gy+1)*h/CHh)); + let n = 0, tot = 0; + for (let y = y0; y < y1; y += 2) for (let x = x0; x < x1; x += 2) { tot++; if (D(at(x,y), [255,255,255]) > 24) n++; } + const r = n / Math.max(1, tot); + line += r > 0.5 ? "#" : r > 0.18 ? "+" : r > 0.04 ? "." : " "; + } + map += String(gy).padStart(2) + "|" + line + "\n"; +} +console.log("=== 非白结构图(# 密集 / + 中等 / . 稀疏)==="); +console.log(map); + +// 连通域:找边框色(#e3eaf6 面板边框 / #e6ebf4 控件边框 / #ecedf3 分隔线)的矩形 +const TARGETS = { panelBorder: [227,234,246], ctrlBorder: [230,235,244], divider: [236,237,243] }; +for (const [name, t] of Object.entries(TARGETS)) { + const seen = new Uint8Array(w*h); const boxes = []; + const match = (x,y) => D(at(x,y), t) <= 12; + for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) { + if (seen[y*w+x] || !match(x,y)) continue; + let minX=x,maxX=x,minY=y,maxY=y,n=0; const st=[x,y]; seen[y*w+x]=1; + while (st.length) { const cy=st.pop(), cx=st.pop(); n++; + if(cxmaxX)maxX=cx; if(cymaxY)maxY=cy; + for (const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { + const nx=cx+dx, ny=cy+dy; + if (nx<0||ny<0||nx>=w||ny>=h) continue; + if (!seen[ny*w+nx] && match(nx,ny)) { seen[ny*w+nx]=1; st.push(nx,ny); } + } + } + if (n > 60) boxes.push({x:minX,y:minY,w:maxX-minX+1,h:maxY-minY+1,n}); + } + boxes.sort((a,b)=>a.y-b.y || a.x-b.x); + console.log(`\n=== ${name} #${t.join(",")} 连通域 ${boxes.length} 个 ===`); + for (const b of boxes.slice(0, 26)) + console.log(` x${b.x} y${b.y} ${b.w}x${b.h} px${b.n} (CSS≈ x${Math.round(b.x/2)} y${Math.round(b.y/2)} ${Math.round(b.w/2)}x${Math.round(b.h/2)})`); +} diff --git a/png-map.mjs b/png-map.mjs new file mode 100644 index 0000000..48e71d7 --- /dev/null +++ b/png-map.mjs @@ -0,0 +1,34 @@ +import fs from "node:fs"; +const W = Number(process.argv[3] || 110), H = Number(process.argv[4] || 46); +const meta = JSON.parse(fs.readFileSync(process.argv[2] + ".json", "utf8")); +const px = fs.readFileSync(process.argv[2]); +const { w, h, ch } = meta; +const at = (x, y) => { const o = (y * w + x) * ch; return [px[o], px[o+1], px[o+2]]; }; +const D = (a,b) => Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])+Math.abs(a[2]-b[2]); +const CLASSES = [ + [" ", [255,255,255]], ["b", [235,247,255]], ["B", [87,122,251]], + ["-", [227,234,246]], ["=", [236,237,243]], ["#", [81,90,110]], + ["R", [250,81,81]], ["g", [69,185,114]], ["o", [232,165,76]], + ["f", [246,248,249]], [".", [239,244,255]], +]; +const cls = (c) => { let best = ".", bd = 1e9; + for (const [ch2, t] of CLASSES) { const d = D(c, t); if (d < bd) { bd = d; best = ch2; } } + return bd < 40 ? best : "?"; }; +let out = ""; +for (let gy = 0; gy < H; gy++) { + let line = ""; + for (let gx = 0; gx < W; gx++) { + const x0 = Math.floor(gx * w / W), x1 = Math.max(x0+1, Math.floor((gx+1) * w / W)); + const y0 = Math.floor(gy * h / H), y1 = Math.max(y0+1, Math.floor((gy+1) * h / H)); + const cnt = new Map(); + for (let y = y0; y < y1; y += 2) for (let x = x0; x < x1; x += 2) { + const k = cls(at(x, y)); cnt.set(k, (cnt.get(k)||0)+1); + } + let best = " ", bc = -1; + for (const [k, v] of cnt) if (v > bc) { bc = v; best = k; } + line += best; + } + out += String(gy).padStart(2) + "|" + line + "\n"; +} +console.log(`图 ${w}x${h} → ${W}x${H} 字符`); +console.log(out); diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7671db1 --- /dev/null +++ b/pom.xml @@ -0,0 +1,216 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + workleave + 0.0.1 + WorkLeave + 请假申请子应用 + + 1.8 + + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + 2021.0.5.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 3.1.8 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + + com.alibaba + fastjson + 1.2.80 + + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + + com.alibaba + easyexcel + 3.3.2 + + + + + com.yunhai.platform + yh-flow-callback-spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + + workleave + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.workleave.WorkLeaveApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/notice-build/workleave + + + + + diff --git a/src/main/java/com/yunhai/workleave/WorkLeaveApplication.java b/src/main/java/com/yunhai/workleave/WorkLeaveApplication.java new file mode 100644 index 0000000..f711c19 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/WorkLeaveApplication.java @@ -0,0 +1,20 @@ +package com.yunhai.workleave; + +import com.alibaba.fastjson.parser.ParserConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@SpringBootApplication(scanBasePackages = "com.yunhai.workleave") +@EnableTransactionManagement +@EnableAsync +public class WorkLeaveApplication { + + public static void main(String[] args) { + // 开启 fastjson 安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(WorkLeaveApplication.class, args); + } + +} diff --git a/src/main/java/com/yunhai/workleave/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/workleave/aspect/ControllerUserContextAspect.java new file mode 100644 index 0000000..b1c5d4e --- /dev/null +++ b/src/main/java/com/yunhai/workleave/aspect/ControllerUserContextAspect.java @@ -0,0 +1,154 @@ +package com.yunhai.workleave.aspect; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.workleave.enums.UserType; +import com.yunhai.workleave.model.UserSysRole; +import com.yunhai.workleave.utils.IpUtils; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.auth.BoaUserContext; +import com.yunhai.platform.utils.model.BoaUser; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +/** + * 当前用户上下文填充切面(移植自 UserclassService ControllerUserContextAspect)。 + *

@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。 + * 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute; + * 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。

+ */ +@Aspect +@Component +@Order(1) +public class ControllerUserContextAspect { + + private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key */ + private static final List BOA_USER_ATTR_KEYS = Arrays.asList( + "boaUser", "boauser", "currentUser", "user", "authUser", "loginUser" + ); + + @Around("execution(* com.yunhai.workleave.controller..*.*(..))") + public Object setUserContext(ProceedingJoinPoint joinPoint) throws Throwable { + Object[] args = joinPoint.getArgs(); + BoaUser boaUser = null; + UserSysRole userSysRole = null; + + for (Object arg : args) { + if (arg instanceof BoaUser) { + boaUser = (BoaUser) arg; + } else if (arg instanceof UserSysRole) { + userSysRole = (UserSysRole) arg; + } + } + + if (boaUser == null) { + try { + boaUser = BoaUserContext.get(); + } catch (Throwable ignore) { + boaUser = null; + } + } + HttpServletRequest request = getCurrentHttpRequest(); + if (request != null) { + if (boaUser == null) { + boaUser = resolveBoaUserFromRequest(request); + } + if (userSysRole == null) { + userSysRole = resolveUserSysRoleFromRequest(request); + } + } + + populateUserContext(boaUser, userSysRole); + + try { + return joinPoint.proceed(); + } finally { + UserContext.clear(); + log.debug("UserContext cleared for {}", joinPoint.getSignature().toShortString()); + } + } + + /** 从 currentrole(URL 编码,Header 或 Query 参数)解析 UserSysRole */ + private UserSysRole resolveUserSysRoleFromRequest(HttpServletRequest request) { + String raw = request.getHeader("currentrole"); + boolean fromQuery = false; + if (raw == null) { + raw = request.getParameter("currentrole"); + fromQuery = true; + } + if (raw == null) { + return null; + } + try { + String decoded = fromQuery ? raw : URLDecoder.decode(raw, StandardCharsets.UTF_8.name()); + return MAPPER.readValue(decoded, UserSysRole.class); + } catch (Exception e) { + log.warn("解析 currentrole 失败: {}", e.getMessage()); + return null; + } + } + + /** 从 request attribute(外部框架注入的 BoaUser)回退解析 */ + private BoaUser resolveBoaUserFromRequest(HttpServletRequest request) { + for (String key : BOA_USER_ATTR_KEYS) { + Object attr = request.getAttribute(key); + if (attr instanceof BoaUser) { + return (BoaUser) attr; + } + } + return null; + } + + private HttpServletRequest getCurrentHttpRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } + + private void populateUserContext(BoaUser boaUser, UserSysRole userSysRole) { + if (boaUser != null) { + UserContext.setUserId(boaUser.getId()); + UserContext.setUserName(boaUser.getName() != null ? boaUser.getName() : "unknown"); + Integer schId = boaUser.getSchool() == null ? null : boaUser.getSchool().getId(); + if (schId == null && userSysRole != null) { + schId = userSysRole.getSchId(); + } + UserContext.setSchId(schId); + } else { + UserContext.setUserName("anonymous"); + if (userSysRole != null && userSysRole.getSchId() != null) { + UserContext.setSchId(userSysRole.getSchId()); + } + log.warn("No BoaUser found, using anonymous"); + } + + if (userSysRole != null) { + UserContext.setRoleId(userSysRole.getRoleId()); + UserContext.setChildId(userSysRole.getChildId()); + } + + UserContext.setType(boaUser != null ? boaUser.getType() : null); + + // 超级管理员(TYPE=1)不绑定具体学校,schId 统一置 0 + if (UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType())) { + UserContext.setSchId(0); + } + + UserContext.setIp(IpUtils.getClientIp()); + } +} diff --git a/src/main/java/com/yunhai/workleave/aspect/SysOperationLogAspect.java b/src/main/java/com/yunhai/workleave/aspect/SysOperationLogAspect.java new file mode 100644 index 0000000..694437e --- /dev/null +++ b/src/main/java/com/yunhai/workleave/aspect/SysOperationLogAspect.java @@ -0,0 +1,223 @@ +package com.yunhai.workleave.aspect; + +import com.alibaba.fastjson.JSON; +import com.yunhai.workleave.entity.SysOperationLog; +import com.yunhai.workleave.service.ISysOperationLogService; +import com.yunhai.workleave.utils.GzipUtil; +import com.yunhai.workleave.utils.IpUtils; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.apistd.ApiResponse; +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.exception.IErrorCode; +import com.yunhai.platform.utils.http.ApiResult; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统审计日志切面(移植自 UserclassService SysOperationLogAspect)。 + *

全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归); + * 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext; + * 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。

+ */ +@Aspect +@Component +@Order(3) +public class SysOperationLogAspect { + + private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class); + + /** 排除的控制器(日志查询自身,防递归刷屏) */ + private static final List EXCLUDE_CONTROLLERS = java.util.Arrays.asList( + "com.yunhai.workleave.controller.api.LogController" + ); + private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240; + private static final int MAX_ERROR_MSG = 8000; + private static final int MAX_PARAMS = 2000; + + @Autowired + private ISysOperationLogService sysOperationLogService; + + @Around("execution(* com.yunhai.workleave.controller..*.*(..))") + public Object around(ProceedingJoinPoint point) throws Throwable { + String declType = point.getSignature().getDeclaringTypeName(); + if (EXCLUDE_CONTROLLERS.contains(declType)) { + return point.proceed(); + } + + HttpServletRequest request = getRequest(); + String httpMethod = request != null ? request.getMethod() : ""; + + long start = System.currentTimeMillis(); + SysOperationLog entity = buildEntity(point, request, httpMethod); + + Object result; + try { + result = point.proceed(); + entity.setStatusCode(resolveSuccessCode(result)); + fillResponse(entity, result, DEFAULT_MAX_RESPONSE_SIZE); + return result; + } catch (Throwable e) { + entity.setStatusCode(resolveErrorCode(e)); + entity.setErrorMsg(buildErrorMsg(e)); + throw e; + } finally { + entity.setCostTime(System.currentTimeMillis() - start); + sysOperationLogService.saveAsync(entity); + } + } + + private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) { + SysOperationLog e = new SysOperationLog(); + e.setCreateTime(LocalDateTime.now()); + + // 用户信息(ControllerUserContextAspect Order 1 已填充) + e.setUserId(UserContext.getUserId()); + e.setUserName(StringUtils.hasText(UserContext.getUserName()) ? UserContext.getUserName() : "anonymous"); + e.setSchId(UserContext.getSchId()); + e.setRequestIp(StringUtils.hasText(UserContext.getIp()) ? UserContext.getIp() : IpUtils.getClientIp()); + + String uri = request != null ? request.getRequestURI() : ""; + String methodName = point.getSignature().getName(); + e.setRequestUri(uri); + e.setRequestMethod(httpMethod); + e.setClassMethod(point.getTarget().getClass().getName() + "." + methodName); + e.setLogLevel(resolveLevel(httpMethod)); + e.setOperationType(resolveOperationType(httpMethod, methodName)); + if (request != null) { + e.setUserAgent(request.getHeader("User-Agent")); + } + e.setRequestParams(truncate(buildRequestParams(point, request), MAX_PARAMS)); + return e; + } + + private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) { + try { + if (request != null && "GET".equalsIgnoreCase(request.getMethod())) { + String qs = request.getQueryString(); + if (StringUtils.hasText(qs)) { + return qs; + } + } + Object[] args = point.getArgs(); + if (args == null || args.length == 0) { + return ""; + } + List filtered = new ArrayList<>(); + for (Object arg : args) { + if (arg == null) { + continue; + } + if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse + || arg instanceof HttpSession || arg instanceof MultipartFile + || arg instanceof MultipartFile[]) { + continue; + } + // BoaUser 等平台对象跳过序列化(无 toString 意义且可能循环引用) + if (arg.getClass().getName().startsWith("com.yunhai.platform.")) { + continue; + } + filtered.add(arg); + } + return filtered.isEmpty() ? "" : JSON.toJSONString(filtered); + } catch (Exception ex) { + return ""; + } + } + + private void fillResponse(SysOperationLog e, Object result, int max) { + if (result == null) { + return; + } + try { + String resp = JSON.toJSONString(result); + if (resp.length() > max) { + resp = resp.substring(0, max) + "...[截断]"; + } + e.setResponseData(GzipUtil.compress(resp)); + } catch (Exception ex) { + log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri()); + } + } + + /** 成功路径:Controller 返回 ApiResponse/ApiResult 时取其 code,其余视为 SUCCESS(0) */ + private int resolveSuccessCode(Object result) { + if (result instanceof ApiResponse) { + return ((ApiResponse) result).getCode(); + } + if (result instanceof ApiResult) { + return ((ApiResult) result).getCode(); + } + return ErrorCode.SUCCESS.getCode(); + } + + /** 异常路径:从业务异常提取错误码,兜底 INTERNAL_SERVER_ERROR */ + private int resolveErrorCode(Throwable e) { + if (e instanceof DException) { + IErrorCode ec = ((DException) e).getCode(); + if (ec != null) { + return ec.getCode(); + } + } + return ErrorCode.INTERNAL_SERVER_ERROR.getCode(); + } + + private HttpServletRequest getRequest() { + ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return a != null ? a.getRequest() : null; + } + + private String buildErrorMsg(Throwable e) { + String msg = e.getMessage(); + String head = (msg == null || msg.trim().isEmpty()) ? e.getClass().getName() : msg; + return truncate(head + System.lineSeparator() + ExceptionUtils.getStackTrace(e), MAX_ERROR_MSG); + } + + private String truncate(String s, int max) { + return s != null && s.length() > max ? s.substring(0, max) : s; + } + + /** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */ + private int resolveLevel(String httpMethod) { + if ("DELETE".equalsIgnoreCase(httpMethod)) { + return 1; + } + if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)) { + return 2; + } + return 3; + } + + private String resolveOperationType(String httpMethod, String methodName) { + String lower = methodName == null ? "" : methodName.toLowerCase(); + if ("DELETE".equalsIgnoreCase(httpMethod) || lower.startsWith("delete") || lower.startsWith("del")) { + return "DELETE"; + } + if (lower.startsWith("create") || lower.startsWith("add")) { + return "CREATE"; + } + if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod) + || lower.startsWith("edit") || lower.startsWith("update") || lower.startsWith("set")) { + return "UPDATE"; + } + return "QUERY"; + } +} diff --git a/src/main/java/com/yunhai/workleave/common/listener/BaseAuditListener.java b/src/main/java/com/yunhai/workleave/common/listener/BaseAuditListener.java new file mode 100644 index 0000000..d6b7277 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/common/listener/BaseAuditListener.java @@ -0,0 +1,36 @@ +package com.yunhai.workleave.common.listener; + +import com.yunhai.workleave.entity.BaseEntity; +import com.yunhai.workleave.utils.UserContext; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人与时间戳。 + *

操作人取 {@link UserContext#getUserId()}——只存平台用户ID,不保存账号; + * 无用户上下文的线程(如流程回调、定时任务)为 null,不影响落库。

+ */ +@Component +public class BaseAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + LocalDateTime now = LocalDateTime.now(); + entity.setCreateTime(now); + entity.setUpdateTime(now); + Integer userId = UserContext.getUserId(); + if (entity.getCreateBy() == null) { + entity.setCreateBy(userId); + } + entity.setUpdateBy(userId); + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateTime(LocalDateTime.now()); + entity.setUpdateBy(UserContext.getUserId()); + } +} diff --git a/src/main/java/com/yunhai/workleave/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/workleave/common/validation/ValidationGroups.java new file mode 100644 index 0000000..c67d4fc --- /dev/null +++ b/src/main/java/com/yunhai/workleave/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.workleave.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/workleave/config/Config.java b/src/main/java/com/yunhai/workleave/config/Config.java new file mode 100644 index 0000000..36479ad --- /dev/null +++ b/src/main/java/com/yunhai/workleave/config/Config.java @@ -0,0 +1,11 @@ +package com.yunhai.workleave.config; + +/** + * 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。 + */ +public enum Config { + Inst; + + public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔 + public static final String OPEN_API_PATH = "/oapi"; +} diff --git a/src/main/java/com/yunhai/workleave/config/DevMockUserFilter.java b/src/main/java/com/yunhai/workleave/config/DevMockUserFilter.java new file mode 100644 index 0000000..eb687ef --- /dev/null +++ b/src/main/java/com/yunhai/workleave/config/DevMockUserFilter.java @@ -0,0 +1,83 @@ +package com.yunhai.workleave.config; + +import com.yunhai.platform.auth.BoaUserContext; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +/** + * 本地冒烟/联调辅助:向 BoaUserContext 注入模拟登录用户。 + *

仅当 smoke.auth.enabled=true 时注册(默认关闭,线上不受影响)。 + * 需配合关闭 CAS 拦截启动:--yh.sso.url-pattern=/__smoke_off__/*

+ */ +@Configuration +@ConditionalOnProperty(prefix = "smoke.auth", name = "enabled", havingValue = "true") +public class DevMockUserFilter { + + @Bean + public FilterRegistrationBean mockUserFilter(DevMockUserProperties props) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter((request, response, chain) -> doFilter(props, request, response, chain)); + registration.addUrlPatterns("/api/*", "/manage/*", "/mobiapi/*"); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); + registration.setName("devMockUserFilter"); + return registration; + } + + private void doFilter(DevMockUserProperties props, ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + BoaUser user = new BoaUser(); + user.setId(props.getUserId()); + user.setName(props.getUserName()); + user.setType(props.getUserType()); + BoaGroup school = new BoaGroup(); + school.setId(props.getSchId()); + school.setName("冒烟测试学校"); + user.setSchool(school); + BoaUserContext.set(user); + try { + chain.doFilter(request, response); + } finally { + BoaUserContext.clear(); + } + if (request instanceof HttpServletRequest) { + org.slf4j.LoggerFactory.getLogger(DevMockUserFilter.class) + .debug("mock user {} applied for {}", props.getUserId(), ((HttpServletRequest) request).getRequestURI()); + } + } + + @org.springframework.boot.context.properties.ConfigurationProperties(prefix = "smoke.auth") + @Bean + public DevMockUserProperties devMockUserProperties() { + return new DevMockUserProperties(); + } + + /** 模拟用户参数(默认值见 application.yml smoke 段) */ + public static class DevMockUserProperties { + private Integer userId = 1001; + private String userName = "冒烟测试教师"; + private Integer schId = 1; + private Integer userType = 3; + + public Integer getUserId() { return userId; } + public void setUserId(Integer userId) { this.userId = userId; } + public String getUserName() { return userName; } + public void setUserName(String userName) { this.userName = userName; } + public Integer getSchId() { return schId; } + public void setSchId(Integer schId) { this.schId = schId; } + public Integer getUserType() { return userType; } + public void setUserType(Integer userType) { this.userType = userType; } + } +} diff --git a/src/main/java/com/yunhai/workleave/config/JacksonConfig.java b/src/main/java/com/yunhai/workleave/config/JacksonConfig.java new file mode 100644 index 0000000..82cc38b --- /dev/null +++ b/src/main/java/com/yunhai/workleave/config/JacksonConfig.java @@ -0,0 +1,35 @@ +package com.yunhai.workleave.config; + +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import java.time.format.DateTimeFormatter; + +/** + * Jackson 时间序列化全局约定(对齐旧 .NET 版 "yyyy-MM-dd HH:mm:ss"): + * 接口出入参中的 LocalDateTime/LocalDate 统一使用该格式,避免 ISO 的 "T" 分隔符 + * (如 2026-09-01T16:38:40)直接暴露给前端表格。 + */ +@Configuration +public class JacksonConfig { + + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { + return builder -> builder + .serializers( + new LocalDateTimeSerializer(DATE_TIME), + new LocalDateSerializer(DATE)) + .deserializers( + new LocalDateTimeDeserializer(DATE_TIME), + new LocalDateDeserializer(DATE)); + } +} diff --git a/src/main/java/com/yunhai/workleave/config/LocalCallbackBypassConfig.java b/src/main/java/com/yunhai/workleave/config/LocalCallbackBypassConfig.java new file mode 100644 index 0000000..71b0c99 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/config/LocalCallbackBypassConfig.java @@ -0,0 +1,83 @@ +package com.yunhai.workleave.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 本地联调辅助:流程中心入站 oapi 调用(appKey=100019 签名)被 AppAuthFilter 401 拦截的绕行。 + *

背景:应用间签名的密钥链在 160 infra Redis(容器网内),开发机不可达,本地无法校验 + * 流程中心的 X-App-Key/X-Timestamp/X-Signature,覆盖两个入站端点:

+ *
    + *
  • /oapi/flow/callback——回调接收端(starter),剥头后另有 yh-flow-callback 的 + * X-Flw 验签(登记下发的 secret)兜底;
  • + *
  • /oapi/flow/dept-judge——条件路由判断端点(CONDITION 节点同步回调),纯读无副作用, + * 无第二层验签,仅本地开放(base 配置 enforce-patterns=[/oapi/*],线上签名强校验不受影响)。
  • + *
+ *

仅当 yh.app-auth.local-callback-bypass=true 时注册(默认关闭,线上不配即不生效)。

+ */ +@Configuration +@ConditionalOnProperty(prefix = "yh.app-auth", name = "local-callback-bypass", havingValue = "true") +public class LocalCallbackBypassConfig { + + private static final Set STRIPPED = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("x-app-key", "x-timestamp", "x-signature"))); + + /** AppAuthFilter 的注册序是 HIGHEST_PRECEDENCE+10,本过滤器须更靠前才能先剥头 */ + @Bean + public FilterRegistrationBean localCallbackHeaderStripFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter((request, response, chain) -> { + if (request instanceof HttpServletRequest) { + chain.doFilter(new HeaderStrippingRequest((HttpServletRequest) request), response); + } else { + chain.doFilter(request, response); + } + }); + registration.addUrlPatterns("/oapi/flow/callback", "/oapi/flow/dept-judge"); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 5); + registration.setName("localCallbackHeaderStripFilter"); + return registration; + } + + /** 剥掉应用间签名三头后,AppAuthFilter 视为无签名请求直接放行 */ + private static class HeaderStrippingRequest extends HttpServletRequestWrapper { + HeaderStrippingRequest(HttpServletRequest request) { + super(request); + } + private boolean stripped(String name) { + return name != null && STRIPPED.contains(name.toLowerCase()); + } + @Override + public String getHeader(String name) { + return stripped(name) ? null : super.getHeader(name); + } + @Override + public Enumeration getHeaders(String name) { + return stripped(name) ? Collections.emptyEnumeration() : super.getHeaders(name); + } + @Override + public Enumeration getHeaderNames() { + List names = Collections.list(super.getHeaderNames()); + names.removeIf(this::stripped); + return Collections.enumeration(names); + } + } +} diff --git a/src/main/java/com/yunhai/workleave/config/SwaggerConfig.java b/src/main/java/com/yunhai/workleave/config/SwaggerConfig.java new file mode 100644 index 0000000..52e1192 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package com.yunhai.workleave.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 接口文档(springdoc-openapi,替代旧 Springfox)。 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI noticeOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("请假申请接口文档") + .description("请假申请子应用:申请、审核(流程中心)、抄送、时长、统计(springdoc-openapi)") + .version("1.0")); + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/BaseApiController.java b/src/main/java/com/yunhai/workleave/controller/api/BaseApiController.java new file mode 100644 index 0000000..1c617ac --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/BaseApiController.java @@ -0,0 +1,177 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.model.UserSysRole; +import com.yunhai.workleave.model.bsch.ClassTreeNodeVo; +import com.yunhai.workleave.model.base.SetRoleRequest; +import com.yunhai.workleave.model.base.SetRoleResult; +import com.yunhai.workleave.model.file.UpFileResultVo; +import com.yunhai.workleave.service.IClassService; +import com.yunhai.workleave.service.IFileService; +import com.yunhai.workleave.service.IUserCenterService; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.utils.model.BoaUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 平台代理域接口(base/)。 + *

承载:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / FileUpload / Logout。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); + + /** 家长角色ID(UserRole.PATRIARCH_USER),用于 GetUserInfo 的 role 判定 */ + private static final Integer ROLE_PARENT = 4; + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IFileService fileService; + + @Autowired + private IClassService classService; + + // ============================ FileUpload ============================ + + /** + * 附件上传(单文件)。 + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("FileUpload") + public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "leave"); + } + + // ============================ GetUserInfo ============================ + + /** + * 获取当前登录用户信息。 + * 返回 {id, name, role: teacher|parent, profilePicture, school: {id, name}, currentRole} + */ + @GetMapping("GetUserInfo") + public Map getUserInfo(BoaUser authUserWithDept) { + Map result = new LinkedHashMap<>(); + result.put("id", authUserWithDept.getId()); + result.put("name", authUserWithDept.getName()); + UserSysRole role = currentRole(authUserWithDept); + boolean parent = isParent(role); + result.put("role", parent ? "parent" : "teacher"); + result.put("profilePicture", ""); + Map school = new LinkedHashMap<>(); + if (authUserWithDept.getSchool() != null) { + school.put("id", authUserWithDept.getSchool().getId()); + school.put("name", authUserWithDept.getSchool().getName()); + } + result.put("school", school); + result.put("currentRole", role); + return result; + } + + // ============================ GetUserMod ============================ + + /** + * 获取当前用户已授权的模块列表(左侧菜单树)。 + */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = schIdOf(authUserWithDept); + return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId); + } + + // ============================ GetSchoolAll ============================ + + /** + * 获取当前用户学校组及教师(树形结构,选人数据源)。 + */ + @GetMapping("GetSchoolAll") + public List> getSchoolAll(BoaUser authUserWithDept) { + return userCenterService.getSchoolAllTree(schIdOf(authUserWithDept)); + } + + // ============================ GetClassTree ============================ + + /** + * 年级→班级树(叶子=班级,附学生数)。数据来自班级服务学生树(5 分钟缓存)。 + */ + @GetMapping("GetClassTree") + public List getClassTree(BoaUser authUserWithDept) { + return classService.getClassTree(schIdOf(authUserWithDept)); + } + + // ============================ SetRole ============================ + + /** + * 角色切换(teacher / parent)。会话身份由平台 CAS 管理,此处回显确认。 + */ + @PostMapping("SetRole") + public SetRoleResult setRole(@RequestBody SetRoleRequest request) { + String role = request != null && "parent".equalsIgnoreCase(request.getAs()) ? "parent" : "teacher"; + return new SetRoleResult(role); + } + + // ============================ Logout ============================ + + /** + * 退出登录。认证由平台网关处理,此处仅返回成功。 + */ + @PostMapping("Logout") + public void logout() { + LoginUser user = UserContextHolder.get(); + log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName()); + } + + // ============================ 内部方法 ============================ + + private Integer schIdOf(BoaUser user) { + if (user.getSchool() != null) { + return user.getSchool().getId(); + } + return UserContext.getSchId(); + } + + private UserSysRole currentRole(BoaUser user) { + UserSysRole role = new UserSysRole(); + if (user.getSchool() != null) { + role.setSchId(user.getSchool().getId()); + } + role.setUserId(user.getId()); + if (user.getRoleIds() != null && !user.getRoleIds().isEmpty()) { + role.setRoleId(user.getRoleIds().get(0)); + } + // childId 由 ControllerUserContextAspect 填充进 UserContext + Integer childId = UserContext.getChildId(); + role.setChildId(childId == null ? 0 : childId); + return role; + } + + /** 家长判定:角色=家长(4) 或带有效 childId */ + private boolean isParent(UserSysRole role) { + if (role == null) { + return false; + } + if (ROLE_PARENT.equals(role.getRoleId())) { + return true; + } + return role.getChildId() != null && role.getChildId() > 0; + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/ContactsController.java b/src/main/java/com/yunhai/workleave/controller/api/ContactsController.java new file mode 100644 index 0000000..3f85ad5 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/ContactsController.java @@ -0,0 +1,71 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.service.IContactsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 自定义联系组接口(contacts/)。 + *

路径与方法名对齐前端 settingApi;POST form 参数只从 query 接收; + * 成员只存教师平台用户ID,不存账号。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/contacts/") +public class ContactsController { + + @Autowired + private IContactsService contactsService; + + /** 联系组分页:{list:[{serid, GroupName, count}], total} */ + @GetMapping("GetGroup") + public Map getGroup(@RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "count", defaultValue = "10") int count) { + return contactsService.getGroups(page, count); + } + + /** 新增组,返回组ID */ + @PostMapping("AddGroup") + public Integer addGroup(@RequestParam("name") String name) { + return contactsService.addGroup(name); + } + + /** 编辑组名 */ + @PostMapping("EditGroup") + public void editGroup(@RequestParam("id") Integer id, @RequestParam("name") String name) { + contactsService.editGroup(id, name); + } + + /** 批量删除组(ids 逗号串) */ + @PostMapping("DelGroup") + public void delGroup(@RequestParam("ids") String ids) { + contactsService.deleteGroups(ids); + } + + /** 组成员分页(page=0 取全部):{list:[{id, name}], total} */ + @GetMapping("GetContactsTea") + public Map getContactsTea(@RequestParam("gid") Integer gid, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "count", defaultValue = "10") int count) { + return contactsService.getContactsTea(gid, page, count); + } + + /** 设置组成员(全量覆盖,ids 逗号串) */ + @PostMapping("SetContactsTea") + public void setContactsTea(@RequestParam("gid") Integer gid, @RequestParam("ids") String ids) { + contactsService.setContactsTea(gid, ids); + } + + /** 学校组织树(选人数据源) */ + @GetMapping("GetGroupTree") + public List> getGroupTree(@RequestParam(value = "type", required = false) Integer type) { + return contactsService.getGroupTree(); + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/DictController.java b/src/main/java/com/yunhai/workleave/controller/api/DictController.java new file mode 100644 index 0000000..f52ec03 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/DictController.java @@ -0,0 +1,89 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.service.IDictService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 系统字典接口(请假类型 leaveType)。 + *

List 兼容 type=1/2 数字与 leaveType 字符串两种入参(历史前端沿用数字); + * POST form 参数只从 query 接收(与前端 postForm 契约一致)。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/dict/") +public class DictController { + + @Autowired + private IDictService dictService; + + /** 请假类型下拉(启用项),每项 {k, v} */ + @GetMapping("List") + public List> list(@RequestParam(value = "type", defaultValue = "leaveType") String type) { + return dictService.getSysDic(parseType(type)); + } + + /** 字典管理列表(全量含禁用),每项 {SerID, CodeID, Name, Des, Enable} */ + @GetMapping("ManageList") + public List> manageList(@RequestParam(value = "type", defaultValue = "leaveType") String type) { + return dictService.getDictMangeList(type); + } + + /** 新增字典项(服务端按 (schId, type, name) 查重,防文字重复) */ + @PostMapping("Add") + public void add(@RequestParam("name") String name, + @RequestParam(value = "type", defaultValue = "leaveType") String type) { + dictService.addDict(name, type); + } + + /** 更新字典项(名称/启停用) */ + @PostMapping("Update") + public void update(@RequestParam("SerID") Integer serID, + @RequestParam("Name") String name, + @RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable, + @RequestParam(value = "type", defaultValue = "leaveType") String type) { + dictService.updateDict(serID, name, enable, type); + } + + /** 删除字典项(service 侧校验被请假单引用时拒绝) */ + @PostMapping("Delete") + public void delete(@RequestParam("serid") Integer serID, + @RequestParam(value = "type", defaultValue = "leaveType") String type) { + dictService.deleteDict(serID, type); + } + + /** 排序(ids = 前端顺序的 SerID 逗号串) */ + @PostMapping("Sort") + public void sort(@RequestParam("ids") String ids, + @RequestParam(value = "type", defaultValue = "leaveType") String type) { + dictService.sortDict(ids, type); + } + + /** 短信模块开关(前端 getSchModuleIsSms) */ + @GetMapping("Sync") + public Boolean sync() { + return dictService.getSchModuleIsSms(); + } + + /** 数字入参一律归一为 leaveType(兼容历史 1/2) */ + private Integer parseType(String type) { + if (type == null) { + return null; + } + switch (type) { + case "1": + case "2": + case "leaveType": + return 1; + default: + return -1; + } + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/FileController.java b/src/main/java/com/yunhai/workleave/controller/api/FileController.java new file mode 100644 index 0000000..9d1f733 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/FileController.java @@ -0,0 +1,74 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.model.file.UpFileResultVo; +import com.yunhai.workleave.model.file.UpFileVo; +import com.yunhai.workleave.service.IFileService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 附件域接口(file/)。topicType 统一挂 topicType=leave。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/file/") +public class FileController { + + private static final String TOPIC_TYPE = "leave"; + + @Autowired + private IFileService fileService; + + /** 附件上传(单文件),返回 { fileid, host, fileurl, name, type, size } */ + @PostMapping("Upload") + public UpFileResultVo upload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, TOPIC_TYPE); + } + + /** 图片上传(多文件) */ + @PostMapping("UploadImage") + public List uploadImage(@RequestParam("file") MultipartFile[] files) { + return fileService.uploadImages(files, TOPIC_TYPE); + } + + /** 文件下载(浏览器直接 location.href 跳转) */ + @GetMapping("Down") + public void down(@RequestParam("id") Integer id, HttpServletResponse response) { + fileService.download(id, response); + } + + /** 文件在线预览(inline 输出) */ + @GetMapping("Preview") + public void preview(@RequestParam("id") Integer id, + @RequestParam(value = "type", required = false) Integer type, + HttpServletResponse response) { + fileService.preview(id, response); + } + + /** 删除文件 */ + @PostMapping("Delete") + public void delete(@RequestParam("fileId") Integer fileId) { + fileService.delete(fileId); + } + + /** 查询某请假单的附件列表 */ + @GetMapping("List") + public List list(@RequestParam("topicId") Integer topicId) { + return fileService.listByTopic(TOPIC_TYPE, topicId); + } + + /** 批量绑定附件到活动 */ + @PostMapping("Bind") + public void bind(@RequestParam("fileIds") List fileIds, + @RequestParam("topicId") Integer topicId) { + fileService.bindToTopic(fileIds, topicId); + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/LeaveController.java b/src/main/java/com/yunhai/workleave/controller/api/LeaveController.java new file mode 100644 index 0000000..f9c7b26 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/LeaveController.java @@ -0,0 +1,154 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.model.BasePageResult; +import com.yunhai.workleave.model.leave.LeaveAuditRequest; +import com.yunhai.workleave.model.leave.LeaveCancelRequest; +import com.yunhai.workleave.model.leave.LeaveDetailVo; +import com.yunhai.workleave.model.leave.LeaveQuery; +import com.yunhai.workleave.model.leave.LeaveRequest; +import com.yunhai.workleave.model.leave.LeaveStatsVo; +import com.yunhai.workleave.model.leave.LeaveVo; +import com.yunhai.workleave.model.leave.TodoVo; +import com.yunhai.workleave.model.leave.UserTimeSumVo; +import com.yunhai.workleave.service.ILeaveApplyService; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +/** + * 请假申请域接口(leave/):申请 / 列表 / 详情 / 撤回 / 销假 / 待办 / 审核 / 统计。 + *

审核流转全部由平台流程审核器承担;本控制器不直接改单据状态, + * 状态由 {@link com.yunhai.workleave.flow.LeaveFlowCallbackHandler} 按回调推进。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/leave/") +public class LeaveController { + + @Autowired + private ILeaveApplyService leaveApplyService; + + // ============================ 列表 / 详情 ============================ + + /** 请假列表:scope=user 我的 / all 全部 / cc 抄送我的 */ + @GetMapping("List") + public BasePageResult list(LeaveQuery query) { + return leaveApplyService.list(schId(), userId(), query); + } + + /** 请假详情(消息跳转落地页/详情弹窗) */ + @GetMapping("Detail") + public LeaveDetailVo detail(@RequestParam("leaveId") Integer leaveId) { + return leaveApplyService.detail(schId(), userId(), leaveId); + } + + // ============================ 申请 / 编辑 / 删除 ============================ + + /** 新建申请(JSON):返回单据ID */ + @PostMapping("Create") + public Integer create(@RequestBody @Valid LeaveRequest request) { + return leaveApplyService.create(schId(), userId(), userName(), request); + } + + /** 编辑/重新提交(JSON,仅审批中/已拒绝/已撤回) */ + @PostMapping("Edit") + public void edit(@RequestBody @Valid LeaveRequest request) { + leaveApplyService.edit(schId(), userId(), userName(), request); + } + + /** 删除(JSON 数组:仅本人,已通过不能删) */ + @PostMapping("Delete") + public void delete(@RequestBody List ids) { + leaveApplyService.delete(schId(), userId(), ids); + } + + // ============================ 撤回 / 销假 ============================ + + /** 撤回(审批中) */ + @PostMapping("Withdraw") + public void withdraw(@RequestParam("id") Integer id) { + leaveApplyService.withdraw(schId(), userId(), id); + } + + /** 销假(已通过 → 已销假,返还时长) */ + @PostMapping("Cancel") + public void cancel(@RequestBody @Valid LeaveCancelRequest request) { + leaveApplyService.cancel(schId(), userId(), request); + } + + // ============================ 待办 / 审核 ============================ + + /** 待我审核列表 */ + @GetMapping("TodoList") + public BasePageResult todoList(@RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "startTime", required = false) String startTime, + @RequestParam(value = "endTime", required = false) String endTime, + @RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "pageSize", defaultValue = "10") int pageSize) { + return leaveApplyService.todoList(schId(), userId(), keyword, startTime, endTime, pageIndex, pageSize); + } + + /** 我已审核列表 */ + @GetMapping("AuditedList") + public BasePageResult auditedList(@RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "startTime", required = false) String startTime, + @RequestParam(value = "endTime", required = false) String endTime, + @RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "pageSize", defaultValue = "10") int pageSize) { + return leaveApplyService.auditedList(schId(), userId(), keyword, startTime, endTime, pageIndex, pageSize); + } + + /** 审核(同意/拒绝,JSON);状态由回调推进 */ + @PostMapping("Audit") + public void audit(@RequestBody @Valid LeaveAuditRequest request) { + leaveApplyService.audit(schId(), userId(), userName(), request); + } + + // ============================ 余额 / 统计 ============================ + + /** 表单页余额提示(可用 - 在途) */ + @GetMapping("MyTime") + public UserTimeSumVo myTime() { + return leaveApplyService.myTime(schId(), userId()); + } + + /** 统计分析(区间按申请时间,口径:已通过) */ + @GetMapping("Statis") + public LeaveStatsVo statis(@RequestParam(value = "startTime", required = false) String startTime, + @RequestParam(value = "endTime", required = false) String endTime) { + return leaveApplyService.statistics(schId(), startTime, endTime); + } + + // ============================ 内部方法 ============================ + + private Integer schId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + return schId; + } + + private Integer userId() { + Integer userId = UserContext.getUserId(); + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取当前用户信息失败"); + } + return userId; + } + + private String userName() { + String userName = UserContext.getUserName(); + return userName == null ? "" : userName; + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/LeaveFlowController.java b/src/main/java/com/yunhai/workleave/controller/api/LeaveFlowController.java new file mode 100644 index 0000000..b01987f --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/LeaveFlowController.java @@ -0,0 +1,76 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.model.leave.FlowBindRequest; +import com.yunhai.workleave.model.leave.FlowEnableRequest; +import com.yunhai.workleave.model.leave.FlowManageVo; +import com.yunhai.workleave.service.ILeaveFlowBindService; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +/** + * 流程管理域接口(leave/flow/):流程列表 / 绑定 / 解绑。 + *

对齐《子应用流程与消息对接手册 v1.0》: + * 流程在流程中心「流程编排」注册,本应用只记录选用哪条 flowGuid(bizType 恒为 leave)。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/leave/flow/") +public class LeaveFlowController { + + @Autowired + private ILeaveFlowBindService flowBindService; + + /** 流程管理页数据:流程中心列表 + 本校绑定关系 + 请假类型 */ + @GetMapping("List") + public FlowManageVo list() { + return flowBindService.manage(schId()); + } + + /** 绑定流程(按 scope + scopeKey upsert,并触发回调登记) */ + @PostMapping("Bind") + public void bind(@RequestBody @Valid FlowBindRequest request) { + flowBindService.bind(schId(), request); + } + + /** + * 启用绑定:把流程标记为本应用可用(并回调登记到流程中心)。 + *

启用后才允许设为默认 / 按请假类型绑定。与绑定范围无关,不校验 scope。

+ */ + @PostMapping("Enable") + public void enable(@RequestBody @Valid FlowEnableRequest request) { + flowBindService.enable(schId(), request); + } + + /** + * 关闭启用:移除启用标记;若该流程已被设为默认 / 按类型绑定,一并解除。 + */ + @PostMapping("Disable") + public void disable(@RequestParam("flowGuid") String flowGuid) { + flowBindService.disable(schId(), flowGuid); + } + + /** 解除绑定:scope=0 学校默认(scopeKey 可空)/ scope=1 按请假类型(scopeKey 必填) */ + @PostMapping("Unbind") + public void unbind(@RequestParam("scope") Integer scope, + @RequestParam(value = "scopeKey", required = false) String scopeKey) { + flowBindService.unbind(schId(), scope, scopeKey); + } + + private Integer schId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + return schId; + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/LeaveSettingController.java b/src/main/java/com/yunhai/workleave/controller/api/LeaveSettingController.java new file mode 100644 index 0000000..a00d475 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/LeaveSettingController.java @@ -0,0 +1,92 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.model.leave.LeaveSettingVo; +import com.yunhai.workleave.model.leave.SectionVo; +import com.yunhai.workleave.model.leave.TimeSetVo; +import com.yunhai.workleave.service.ILeaveSettingService; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 请假设置域接口(setting/):作息时间 / 其他设置 / 节次。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/leave/setting/") +public class LeaveSettingController { + + @Autowired + private ILeaveSettingService settingService; + + // ============================ 作息时间 ============================ + + /** 学校作息时间 */ + @GetMapping("GetTimeSet") + public TimeSetVo getTimeSet() { + return settingService.getTimeSet(schId()); + } + + /** 保存作息时间(JSON) */ + @PostMapping("SetTimeSet") + public void setTimeSet(@RequestBody TimeSetVo vo) { + settingService.saveTimeSet(schId(), vo); + } + + // ============================ 其他设置 ============================ + + /** 其他设置 */ + @GetMapping("GetOption") + public LeaveSettingVo getOption() { + return settingService.getOption(schId()); + } + + /** 保存其他设置(JSON) */ + @PostMapping("SetOption") + public void setOption(@RequestBody LeaveSettingVo vo) { + settingService.saveOption(schId(), vo); + } + + // ============================ 节次 ============================ + + /** 节次列表 */ + @GetMapping("SectionList") + public List sectionList() { + return settingService.sectionList(schId()); + } + + /** 新增/编辑节次(JSON) */ + @PostMapping("SectionSave") + public void sectionSave(@RequestBody SectionVo vo) { + settingService.sectionSave(schId(), vo); + } + + /** 删除节次 */ + @PostMapping("SectionDelete") + public void sectionDelete(@RequestParam("id") Integer id) { + settingService.sectionDelete(schId(), id); + } + + /** 节次排序(ids 逗号串,按前端展示顺序) */ + @PostMapping("SectionSort") + public void sectionSort(@RequestParam("ids") String ids) { + settingService.sectionSort(schId(), ids); + } + + private Integer schId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + return schId; + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/LeaveTimeController.java b/src/main/java/com/yunhai/workleave/controller/api/LeaveTimeController.java new file mode 100644 index 0000000..cb07d03 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/LeaveTimeController.java @@ -0,0 +1,100 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.model.BasePageResult; +import com.yunhai.workleave.model.leave.UserTimeAdjustRequest; +import com.yunhai.workleave.model.leave.UserTimeImportResult; +import com.yunhai.workleave.model.leave.UserTimeRecordVo; +import com.yunhai.workleave.model.leave.UserTimeSumVo; +import com.yunhai.workleave.model.leave.UserTimeVo; +import com.yunhai.workleave.service.ILeaveUserTimeService; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.net.URLEncoder; +import java.util.List; + +/** + * 教师时长域接口(leave/time/):列表 / 调整 / 流水 / 合计。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/leave/time/") +public class LeaveTimeController { + + @Autowired + private ILeaveUserTimeService userTimeService; + + /** 教师时长列表 */ + @GetMapping("UserTimeList") + public List userTimeList(@RequestParam(value = "keyword", required = false) String keyword) { + return userTimeService.list(schId(), keyword); + } + + /** 批量加减时长(JSON) */ + @PostMapping("Adjust") + public void adjust(@RequestBody @Valid UserTimeAdjustRequest request) { + userTimeService.adjust(schId(), UserContext.getUserId(), request.getItems()); + } + + /** 下载「教师时长导入模板」(浏览器 location.href 直接跳转) */ + @GetMapping("ImportTemplate") + public void importTemplate(HttpServletResponse response) { + byte[] data = userTimeService.importTemplate(schId()); + try { + response.setContentType("application/octet-stream"); + String name = URLEncoder.encode("教师时长导入模板.xlsx", "UTF-8").replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + name + "\""); + response.getOutputStream().write(data); + } catch (Exception e) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "模板下载失败"); + } + } + + /** 教师时长 Excel 导入(任一行出错整体不入账,返回错误明细) */ + @PostMapping("Import") + public UserTimeImportResult importExcel(@RequestParam("file") MultipartFile file) throws java.io.IOException { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择要上传的文件"); + } + return userTimeService.importExcel(schId(), UserContext.getUserId(), file.getInputStream()); + } + + /** 时长流水(分页) */ + @GetMapping("Records") + public BasePageResult records( + @RequestParam(value = "rqType", defaultValue = "3") Integer rqType, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "startTime", required = false) String startTime, + @RequestParam(value = "endTime", required = false) String endTime, + @RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "pageSize", defaultValue = "10") int pageSize) { + return userTimeService.records(schId(), rqType, keyword, startTime, endTime, pageIndex, pageSize); + } + + /** 合计:区间新加总时长 + 当前剩余总时长 */ + @GetMapping("Sum") + public UserTimeSumVo sum(@RequestParam(value = "rqType", defaultValue = "3") Integer rqType, + @RequestParam(value = "startTime", required = false) String startTime, + @RequestParam(value = "endTime", required = false) String endTime) { + return userTimeService.sum(schId(), rqType, startTime, endTime); + } + + private Integer schId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + return schId; + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/api/LogController.java b/src/main/java/com/yunhai/workleave/controller/api/LogController.java new file mode 100644 index 0000000..724222b --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/api/LogController.java @@ -0,0 +1,32 @@ +package com.yunhai.workleave.controller.api; + +import com.yunhai.workleave.config.Config; +import com.yunhai.workleave.entity.SysOperationLog; +import com.yunhai.workleave.model.BasePageQuery; +import com.yunhai.workleave.model.BasePageResult; +import com.yunhai.workleave.service.ISysOperationLogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 审计日志查询接口(log/)。 + *

本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/log/") +public class LogController { + + @Autowired + private ISysOperationLogService logService; + + /** 分页查询审计日志(responseData 不返回) */ + @GetMapping("Page") + public BasePageResult page(BasePageQuery query, + @RequestParam(value = "operationType", required = false) String operationType, + @RequestParam(value = "statusCode", required = false) Integer statusCode) { + return logService.page(query, operationType, statusCode); + } +} diff --git a/src/main/java/com/yunhai/workleave/controller/oapi/FlowJudgeController.java b/src/main/java/com/yunhai/workleave/controller/oapi/FlowJudgeController.java new file mode 100644 index 0000000..7f82a80 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/controller/oapi/FlowJudgeController.java @@ -0,0 +1,120 @@ +package com.yunhai.workleave.controller.oapi; + +import com.yunhai.workleave.entity.LeaveApply; +import com.yunhai.workleave.repository.LeaveApplyRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.Data; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * 条件路由判断端点:flow-center CONDITION 节点(routerKey=work-leave.dept-judge)激活时同步调用—— + * 按请假时长分流,小于3天走「小于3天」线,大于等于3天走「大于等于3天」线。 + *

流程中心路由注册表:path=/oapi/flow/dept-judge(serviceKey=work-leave); + * 已发布流程 d2237a5b…/c3e9c6ee… 的出边名即本端点的出口契约。

+ *
    + *
  • 验签:收在 /oapi/* 下由 AppAuthFilter 强制验 flow-center 的应用签名,零新概念;
  • + *
  • 纯读幂等:凭 bizId({leaveId}-{attempt})查 leave_apply 比对时长(variables 里有 formData 的 + * totalHours 则免查库,与发起时透传口径一致);
  • + *
  • 时长口径:3天 = 24小时(一天按作息 8 小时折算,同 LeaveFlowService#buildFormData 的 + * days=totalHours/8,恰好等于3天归「大于等于3天」);
  • + *
  • 契约纪律:返回 outlet 必须命中 outlets——流程定义与端点的出口名是同一份契约,不命中抛业务错 + * (flow-center 侧按判断失败挂起,绝不默认放行);
  • + *
  • 轻实现:同步短超时,最多一次主键查询。
  • + *
+ */ +@RestController +@RequestMapping("/oapi/flow") +public class FlowJudgeController { + + private static final Logger log = LoggerFactory.getLogger(FlowJudgeController.class); + + /** 出口名(须与流程定义出边 name 逐字一致) */ + public static final String OUTLET_LT_3_DAYS = "小于3天"; + public static final String OUTLET_GE_3_DAYS = "大于等于3天"; + + /** 3天折算小时数(8小时/天 × 3) */ + static final double THREE_DAYS_HOURS = 24D; + + @Autowired + private LeaveApplyRepository leaveApplyRepository; + + /** 判断请求体(flow-center CONDITION 节点下发;字段忽略未知向前兼容) */ + @Data + public static class JudgeBody { + private Long schId; + private Long instanceId; + private Integer instanceVersion; + private String bizType; + private String bizId; + private String nodeId; + private String nodeName; + private String routerKey; + private List outlets; + private Map variables; + } + + @PostMapping("/dept-judge") + public Map deptJudge(@RequestBody JudgeBody body) { + Double hours = variablesTotalHours(body); + if (hours == null) { + LeaveApply leave = leaveApplyRepository.findById(parseLeaveId(body)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "请假单不存在:bizId=" + body.getBizId())); + hours = leave.getTotalHours(); + } + if (hours == null) { + // 无时长无法判断:挂起而不是默认放行 + throw new BizException(ErrorCode.PARAM_ERROR, "请假单缺少时长,无法判断:bizId=" + body.getBizId()); + } + String outlet = hours < THREE_DAYS_HOURS ? OUTLET_LT_3_DAYS : OUTLET_GE_3_DAYS; + List 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> getStuTree(int schId); + + /** + * 年级→班级树(叶子=班级,附 studentCount),由学生树派生。 + * 外部接口不可达时返回空列表。 + */ + List getClassTree(int schId); + + /** 班级学生数;班级不存在或数据不可得返回 0 */ + int studentCountOf(int schId, Integer classId); + + /** 班级名称;未知返回 null */ + String classNameOf(int schId, Integer classId); + + /** 年级名(按班级反查其父年级节点名);未知返回 null */ + String gradeNameOf(int schId, Integer classId); + + /** + * 学生定位:按学生ID在学生树中找到所在班级。 + * 返回 {studentId, studentName, classId, className};找不到或不可得返回 null。 + */ + Map resolveStudent(int schId, Integer studentId); + + /** + * 班级→学生名单(roster):{studentId, studentName, classId, className} 列表。 + * classIds 为空集合时返回空列表。 + */ + List> studentsOfClasses(int schId, List classIds); +} diff --git a/src/main/java/com/yunhai/workleave/service/IContactsService.java b/src/main/java/com/yunhai/workleave/service/IContactsService.java new file mode 100644 index 0000000..284e869 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/IContactsService.java @@ -0,0 +1,32 @@ +package com.yunhai.workleave.service; + +import java.util.List; +import java.util.Map; + +/** + * 自定义联系组业务接口(应用设置-自定义组)。 + * 成员只存教师平台用户ID(不存账号),姓名经班级服务教师接口解析。 + */ +public interface IContactsService { + + /** 联系组分页:{list:[{serid, GroupName, count}], total} */ + Map getGroups(int pageIndex, int pageSize); + + /** 新增组,返回组ID */ + Integer addGroup(String name); + + /** 编辑组名 */ + void editGroup(Integer id, String name); + + /** 批量删除组 */ + void deleteGroups(String ids); + + /** 组成员分页(page=0 取全部):{list:[{id, name}], total} */ + Map getContactsTea(Integer gid, int page, int count); + + /** 设置组成员(全量覆盖,ids 逗号串) */ + void setContactsTea(Integer gid, String ids); + + /** 学校组织树(选人数据源,透传用户中心树) */ + List> getGroupTree(); +} diff --git a/src/main/java/com/yunhai/workleave/service/IDictService.java b/src/main/java/com/yunhai/workleave/service/IDictService.java new file mode 100644 index 0000000..f286e44 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/IDictService.java @@ -0,0 +1,43 @@ +package com.yunhai.workleave.service; + +import java.util.List; +import java.util.Map; + +/** + * 系统字典业务接口(请假类型 leaveType 域)。 + */ +public interface IDictService { + + /** + * 字典下拉(启用项)。前端 type=leaveType(兼容旧数字 1/2 入参)。 + * + * @return 每项 {k: dictKey, v: 名称} + */ + List> getSysDic(Integer type); + + /** + * 字典管理列表(全量含禁用)。 + * + * @param type 固定 "leaveType" + * @return 每项 {SerID, CodeID, Name, Des, Enable} + */ + List> getDictMangeList(String type); + + /** 新增字典项(按 (schId, type, name) 查重,重复报错) */ + void addDict(String name, String type); + + /** 更新字典项(名称/启停用,重名校验排除自身) */ + void updateDict(Integer serId, String name, Integer enable, String type); + + /** 删除字典项(删除前由调用方校验是否被请假单引用) */ + void deleteDict(Integer serId, String type); + + /** 排序(按前端顺序的 SerID 逗号串) */ + void sortDict(String ids, String type); + + /** 短信模块开关(Dict Sync 接口用) */ + Boolean getSchModuleIsSms(); + + /** 按 dictKey 解析请假类型名称;未命中返回 null */ + String resolveTypeName(String typeKey); +} diff --git a/src/main/java/com/yunhai/workleave/service/IFileService.java b/src/main/java/com/yunhai/workleave/service/IFileService.java new file mode 100644 index 0000000..426c119 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/IFileService.java @@ -0,0 +1,94 @@ +package com.yunhai.workleave.service; + +import com.yunhai.workleave.entity.UpFile; +import com.yunhai.workleave.model.file.UpFileVo; +import com.yunhai.workleave.model.file.UpFileResultVo; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 文件上传/下载/删除业务接口。 + */ +public interface IFileService { + /** + * 上传单个文件。 + * + * @param file 上传的文件 + * @param appType 应用类型(固定 "leave") + * @param topicId 关联主题ID(可为空,通知保存后再绑定) + * @return 文件视图对象 + */ + UpFileVo upload(MultipartFile file, String appType, Integer topicId); + + /** + * 将已上传但未绑定主题的附件绑定到指定请假单。 + * + * @param fileIds 文件记录ID列表 + * @param topicId 通知ID + */ + void bindToTopic(List fileIds, Integer topicId); + + /** + * 查询某主题下的附件列表。 + * + * @param appType 应用类型 + * @param topicId 主题ID + * @return 附件列表 + */ + List listByTopic(String appType, Integer topicId); + + /** + * 下载文件。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void download(Integer fileId, HttpServletResponse response); + + /** + * 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type, + * 供前端 open-file-viewer 取 Blob / video 播放)。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void preview(Integer fileId, HttpServletResponse response); + + /** + * 删除文件(同时删除物理文件和数据库记录)。 + * + * @param fileId 文件记录ID + */ + void delete(Integer fileId); + + /** + * 打包下载多个附件为 ZIP。 + * + * @param files 附件记录列表(调用方按 fileIds 查好) + * @param zipName 压缩包显示名(自动追加 .zip) + * @param response HTTP 响应 + */ + void downloadZip(List files, String zipName, HttpServletResponse response); + + /** + * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 + * + * @param topicType 主题类型(固定 "leave") + * @param topicId 主题ID + * @param zipName 压缩包显示名 + * @param response HTTP 响应 + */ + void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response); + + /** + * 批量上传图片(循环上传,返回每个文件的视图对象列表)。 + */ + List uploadImages(MultipartFile[] files, String appType); + + /** + * 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。 + */ + UpFileResultVo uploadToResult(MultipartFile file, String appType); +} diff --git a/src/main/java/com/yunhai/workleave/service/ILeaveApplyService.java b/src/main/java/com/yunhai/workleave/service/ILeaveApplyService.java new file mode 100644 index 0000000..a106f22 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/ILeaveApplyService.java @@ -0,0 +1,58 @@ +package com.yunhai.workleave.service; + +import com.yunhai.workleave.model.BasePageResult; +import com.yunhai.workleave.model.leave.LeaveAuditRequest; +import com.yunhai.workleave.model.leave.LeaveCancelRequest; +import com.yunhai.workleave.model.leave.LeaveDetailVo; +import com.yunhai.workleave.model.leave.LeaveQuery; +import com.yunhai.workleave.model.leave.LeaveRequest; +import com.yunhai.workleave.model.leave.LeaveStatsVo; +import com.yunhai.workleave.model.leave.LeaveVo; +import com.yunhai.workleave.model.leave.TodoVo; +import com.yunhai.workleave.model.leave.UserTimeSumVo; + +import java.util.List; + +/** + * 请假申请域服务(申请 / 列表 / 详情 / 销假 / 撤回 / 审核 / 统计)。 + */ +public interface ILeaveApplyService { + + /** 列表:scope=user 我的 / all 全部 / cc 抄送我的 */ + BasePageResult list(Integer schId, Integer currentUserId, LeaveQuery query); + + /** 详情 */ + LeaveDetailVo detail(Integer schId, Integer currentUserId, Integer leaveId); + + /** 新建(落库 + 发起流程);返回单据ID */ + Integer create(Integer schId, Integer currentUserId, String userName, LeaveRequest request); + + /** 编辑/重新提交(仅审批中/已拒绝/已撤回;重新发起流程 attempt+1) */ + void edit(Integer schId, Integer currentUserId, String userName, LeaveRequest request); + + /** 删除(仅本人;已通过只能销假不能删) */ + void delete(Integer schId, Integer currentUserId, List ids); + + /** 撤回(调流程中心,状态由回调推进) */ + void withdraw(Integer schId, Integer currentUserId, Integer leaveId); + + /** 销假(已通过 → 已销假,返还时长) */ + void cancel(Integer schId, Integer currentUserId, LeaveCancelRequest request); + + /** 待我审核列表(流程中心待办 + 本地单据补全;startTime/endTime 按请假区间重叠过滤) */ + BasePageResult todoList(Integer schId, Integer currentUserId, String keyword, + String startTime, String endTime, int pageIndex, int pageSize); + + /** 我已审核列表(本地口径:我操作过该实例的终态/流转单据;startTime/endTime 按请假区间重叠过滤) */ + BasePageResult auditedList(Integer schId, Integer currentUserId, String keyword, + String startTime, String endTime, int pageIndex, int pageSize); + + /** 审核动作(同意/拒绝,提交流程中心,本地状态待回调) */ + void audit(Integer schId, Integer currentUserId, String userName, LeaveAuditRequest request); + + /** 表单页余额提示 */ + UserTimeSumVo myTime(Integer schId, Integer currentUserId); + + /** 统计(区间按申请时间,口径:已通过) */ + LeaveStatsVo statistics(Integer schId, String startTime, String endTime); +} diff --git a/src/main/java/com/yunhai/workleave/service/ILeaveFlowBindService.java b/src/main/java/com/yunhai/workleave/service/ILeaveFlowBindService.java new file mode 100644 index 0000000..5c14287 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/ILeaveFlowBindService.java @@ -0,0 +1,47 @@ +package com.yunhai.workleave.service; + +import com.yunhai.workleave.model.leave.FlowBindRequest; +import com.yunhai.workleave.model.leave.FlowEnableRequest; +import com.yunhai.workleave.model.leave.FlowManageVo; + +/** + * 审批流程绑定服务(流程管理页)。 + *

对齐《子应用流程与消息对接手册 v1.0》:bizType 恒为 {@code leave}, + * 多套审批流以 flowGuid 区分,本服务决定「本校 / 某请假类型走哪条 flowGuid」。

+ */ +public interface ILeaveFlowBindService { + + /** 流程管理页聚合数据:流程列表 + 已绑定关系 + 请假类型 */ + FlowManageVo manage(Integer schId); + + /** + * 绑定流程(按 scope + scopeKey upsert)。 + *

绑定成功后立刻向流程中心登记回调(手册 §2.4:首次发起前完成登记)。 + * 登记失败不回滚绑定(登记为幂等动作,可由下次发起重试)。

+ */ + void bind(Integer schId, FlowBindRequest request); + + /** + * 启用绑定:把流程标记为本应用可用(upsert),并回调登记到流程中心。 + *

启用后该流程才允许被设为默认 / 按请假类型绑定。

+ */ + void enable(Integer schId, FlowEnableRequest request); + + /** + * 关闭启用:移除启用标记,并解除该流程在本校的全部绑定(默认 + 按类型)。 + *

幂等:未启用过的流程重复关闭不报错。

+ */ + void disable(Integer schId, String flowGuid); + + /** 解除绑定(scope=1 时 scopeKey 必填) */ + void unbind(Integer schId, Integer scope, String scopeKey); + + /** + * 解析单据实际使用的 flowGuid:按请假类型绑定 → 学校默认绑定 → 配置 leave.flow.guid。 + * + * @param schId 学校ID + * @param leaveType 请假类型 dictKey(可空) + * @return 流程定义标识,永不为 null + */ + String resolveFlowGuid(Integer schId, String leaveType); +} diff --git a/src/main/java/com/yunhai/workleave/service/ILeaveSettingService.java b/src/main/java/com/yunhai/workleave/service/ILeaveSettingService.java new file mode 100644 index 0000000..e44f8d0 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/ILeaveSettingService.java @@ -0,0 +1,37 @@ +package com.yunhai.workleave.service; + +import com.yunhai.workleave.model.leave.LeaveSettingVo; +import com.yunhai.workleave.model.leave.SectionVo; +import com.yunhai.workleave.model.leave.TimeSetVo; + +import java.util.List; + +/** + * 请假设置域服务(作息时间 / 其他设置 / 节次)。 + */ +public interface ILeaveSettingService { + + /** 学校作息时间(不存在时创建默认:08:00-12:00 / 13:00-17:00 / 间隔 10 分钟) */ + TimeSetVo getTimeSet(Integer schId); + + /** 保存作息时间 */ + void saveTimeSet(Integer schId, TimeSetVo vo); + + /** 其他设置(不存在时创建默认) */ + LeaveSettingVo getOption(Integer schId); + + /** 保存其他设置 */ + void saveOption(Integer schId, LeaveSettingVo vo); + + /** 节次列表(不存在时按默认 8 节种子初始化) */ + List sectionList(Integer schId); + + /** 新增/编辑节次 */ + void sectionSave(Integer schId, SectionVo vo); + + /** 删除节次 */ + void sectionDelete(Integer schId, Integer id); + + /** 节次排序(ids 按前端展示顺序) */ + void sectionSort(Integer schId, String ids); +} diff --git a/src/main/java/com/yunhai/workleave/service/ILeaveUserTimeService.java b/src/main/java/com/yunhai/workleave/service/ILeaveUserTimeService.java new file mode 100644 index 0000000..058bbdf --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/ILeaveUserTimeService.java @@ -0,0 +1,75 @@ +package com.yunhai.workleave.service; + +import com.yunhai.workleave.model.BasePageResult; +import com.yunhai.workleave.model.leave.UserTimeAdjustItem; +import com.yunhai.workleave.model.leave.UserTimeImportResult; +import com.yunhai.workleave.model.leave.UserTimeRecordVo; +import com.yunhai.workleave.model.leave.UserTimeSumVo; +import com.yunhai.workleave.model.leave.UserTimeVo; + +import java.util.List; +import java.util.Set; + +/** + * 教师时长(调休余额)域服务。 + *

承接历史 QJ_SchUserTime / QJ_SchUserTimeRecord 语义: + * 通过扣减、销假返还、管理员加减、流水留痕、合计统计。

+ */ +public interface ILeaveUserTimeService { + + /** 教师时长列表(教师名单来自班级服务;接口不可达时降级为时长池已有记录) */ + List list(Integer schId, String keyword); + + /** 批量调整时长(正加负减,逐行写流水) */ + void adjust(Integer schId, Integer operatorId, List items); + + /** + * 生成「教师时长导入模板」xlsx 字节流。 + * 列:部门 / 教师名称 / 新加时长(小时) / 备注;每名教师一行(部门预填、时长与备注留空)。 + */ + byte[] importTemplate(Integer schId); + + /** + * 导入教师时长(对齐旧版 ImportQJSchUserTime): + * 表头校验 → 逐行按「部门+教师名称」(部门为空时全校匹配)定位教师 → + * 任一行错误则整体不导入并返回错误明细;全部通过后按 adjust 语义批量入账。 + */ + UserTimeImportResult importExcel(Integer schId, Integer operatorId, java.io.InputStream in); + + /** + * 时长流水(分页)。 + * + * @param rqType 0 今日 / 1 本周 / 2 本月 / 3 累计 + */ + BasePageResult records(Integer schId, Integer rqType, String keyword, + String startTime, String endTime, int pageIndex, int pageSize); + + /** 合计:区间新加总时长 + 当前剩余总时长 */ + UserTimeSumVo sum(Integer schId, Integer rqType, String startTime, String endTime); + + /** 某教师当前可用时长(无记录返回 0) */ + double usableOf(Integer schId, Integer userId); + + /** 某教师已通过的时长总和(小时) */ + double passedHoursOf(Integer schId, Integer userId); + + /** 某教师审批中的在途占用时长(小时) */ + double inTransitHoursOf(Integer schId, Integer userId); + + /** 时长管控类型集合(dictKey);未启用管控返回空集 */ + Set managedTypes(Integer schId); + + /** 是否管控该类型 */ + boolean isManaged(Integer schId, String leaveType); + + /** 余额校验:在途 + 本次 <= 可用,否则抛业务异常并附可申请时长 */ + void checkEnough(Integer schId, Integer userId, String leaveType, double hours); + + /** 扣减(审批通过):写流水"扣除";非管控类型静默跳过 */ + void deduct(Integer schId, Integer userId, String userName, Integer leaveId, String leaveType, + double hours, Integer operatorId); + + /** 返还(销假):写流水"销假返还";非管控类型静默跳过 */ + void refund(Integer schId, Integer userId, String userName, Integer leaveId, String leaveType, + double hours, Integer operatorId, String describe); +} diff --git a/src/main/java/com/yunhai/workleave/service/ISysOperationLogService.java b/src/main/java/com/yunhai/workleave/service/ISysOperationLogService.java new file mode 100644 index 0000000..5b1d719 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/ISysOperationLogService.java @@ -0,0 +1,17 @@ +package com.yunhai.workleave.service; + +import com.yunhai.workleave.entity.SysOperationLog; +import com.yunhai.workleave.model.BasePageQuery; +import com.yunhai.workleave.model.BasePageResult; + +/** + * 系统审计日志业务接口:异步落库 + 分页查询。 + */ +public interface ISysOperationLogService { + + /** 异步保存(失败仅记应用日志,不影响业务请求) */ + void saveAsync(SysOperationLog entity); + + /** 分页查询(responseData 不返回) */ + BasePageResult page(BasePageQuery query, String operationType, Integer statusCode); +} diff --git a/src/main/java/com/yunhai/workleave/service/IUserCenterService.java b/src/main/java/com/yunhai/workleave/service/IUserCenterService.java new file mode 100644 index 0000000..a29fabf --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/IUserCenterService.java @@ -0,0 +1,57 @@ +package com.yunhai.workleave.service; + +import java.util.List; +import java.util.Map; + +/** + * 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。 + *

封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用, + * 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层, + * Controller 只保留"取参 → 调 Service → 返回"的薄壳。

+ */ +public interface IUserCenterService { + + /** + * 获取当前用户学校组及用户(树形结构)。 + *

组装规则(从原 BaseApiController.getSchoolAll 下沉):

+ *
    + *
  1. userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;
  2. + *
  3. bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下, + * 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;
  4. + *
  5. 扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。
  6. + *
+ * + * @param schId 学校ID(用户当前角色所属学校) + * @return 用户树形结构(组节点 type=1 / 教师节点 type=2) + */ + List> getSchoolAllTree(Integer schId); + + /** + * 学校组树中所有组的 id → 名称映射(剔除学生组;同 C# GetGroupDepttree 的组名用途)。 + * 供教师时长导入按「部门」列匹配教师所属组。 + */ + Map groupNames(Integer schId); + + /** + * 获取指定组内用户(教师 + 管理员)。 + *

对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义 + * (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。 + * 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。

+ * + * @param gid 组ID + * @param role 角色(默认 2=教师;1=管理员等) + * @return 组内用户列表 + */ + List> getGroupUser(Integer gid, Integer role); + + /** + * 获取当前用户可访问的模块树(左侧菜单)。 + *

从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构 + * ({@code modId/page/children}),对应前端 GetUserMod 接口。

+ * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List> getUserModuleTree(Integer userId, Integer schId); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/workleave/service/impl/ClassService.java b/src/main/java/com/yunhai/workleave/service/impl/ClassService.java new file mode 100644 index 0000000..3c8e313 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/ClassService.java @@ -0,0 +1,346 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.model.SchTeaList; +import com.yunhai.workleave.model.bsch.ClassTreeNodeVo; +import com.yunhai.workleave.service.IClassService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 班级管理(bschclass)开放接口实现。 + * 独立 RestTemplate 直连;学生树做 5 分钟内存缓存(TTL)。 + * 学生树层级:学校(type=1)→学段(1)→年级(1)→班级(1)→学生(2)。 + * 派生查询(班级树/学生数/学生定位/名单)基于缓存树一次遍历建索引后计算。 + */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 缓存有效期 ms */ + private static final long CACHE_TTL_MS = 5 * 60 * 1000L; + + /** 班级管理服务基址 */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + /** 学生树缓存:schId -> {time, tree} */ + private final ConcurrentHashMap stuTreeCache = new ConcurrentHashMap<>(); + + private static class CacheEntry { + final long time; + final List> tree; + + CacheEntry(long time, List> tree) { + this.time = time; + this.tree = tree; + } + } + + /** 学生树一次性索引:年级/班级/学生平铺 */ + private static class StuIndex { + /** classId -> 年级名 */ + final Map gradeNames = new LinkedHashMap<>(); + /** classId -> 年级ID */ + final Map gradeIds = new LinkedHashMap<>(); + /** classId -> 班级名 */ + final Map classNames = new LinkedHashMap<>(); + /** classId -> 班级节点(挂学生) */ + final Map> classNodes = new LinkedHashMap<>(); + /** studentId -> {studentId, studentName, classId, className} */ + final Map> students = new LinkedHashMap<>(); + } + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + // ============================ 教师 ============================ + + @Override + public List getSchTeaList(int schId) { + requireHost(); + String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId; + try { + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + List list = resp.getBody(); + log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[getSchTeaList] 请求班级管理接口失败, schId={}, url={}", schId, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败"); + } + } + + // ============================ 学生树 ============================ + + @Override + public List> getStuTree(int schId) { + long now = System.currentTimeMillis(); + CacheEntry cached = stuTreeCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.tree; + } + synchronized (this) { + cached = stuTreeCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.tree; + } + List> tree = fetchStuTree(schId); + if (tree == null) { + // 拉取失败时回退旧值(若有),避免外部抖动击穿业务 + return cached != null ? cached.tree : null; + } + stuTreeCache.put(schId, new CacheEntry(now, tree)); + return tree; + } + } + + @SuppressWarnings("unchecked") + private List> fetchStuTree(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchStuTree] 班级管理接口未配置(yh.open.bschclass)"); + return null; + } + String url = bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId + "&toUser=true"; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + log.info("[fetchStuTree] schId={}, 根节点 {} 个", schId, body == null ? 0 : body.size()); + return body; + } catch (RestClientException e) { + log.error("[fetchStuTree] 请求学生树失败, schId={}, url={}", schId, url, e); + return null; + } + } + + // ============================ 派生查询 ============================ + + @Override + public List getClassTree(int schId) { + List> tree = getStuTree(schId); + List result = new ArrayList<>(); + if (tree == null) { + return result; + } + StuIndex index = buildIndex(tree); + Map> gradeClasses = new LinkedHashMap<>(); + Map gradeNodeNames = new LinkedHashMap<>(); + for (Map.Entry e : index.classNames.entrySet()) { + Integer classId = e.getKey(); + String gradeName = index.gradeNames.getOrDefault(classId, ""); + Integer gradeId = index.gradeIds.getOrDefault(classId, 0); + gradeClasses.computeIfAbsent(gradeId, k -> new ArrayList<>()) + .add(toClassNode(classId, index)); + gradeNodeNames.putIfAbsent(gradeId, gradeName); + } + for (Map.Entry> e : gradeClasses.entrySet()) { + ClassTreeNodeVo gradeNode = new ClassTreeNodeVo(); + gradeNode.setId(e.getKey()); + gradeNode.setName(gradeNodeNames.get(e.getKey())); + gradeNode.setParent(true); + gradeNode.setChildren(e.getValue()); + result.add(gradeNode); + } + return result; + } + + @Override + public int studentCountOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + if (index == null || classId == null) { + return 0; + } + Map node = index.classNodes.get(classId); + return node == null ? 0 : countStudents(node); + } + + @Override + public String classNameOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + return index == null || classId == null ? null : index.classNames.get(classId); + } + + @Override + public String gradeNameOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + return index == null || classId == null ? null : index.gradeNames.get(classId); + } + + @Override + public Map resolveStudent(int schId, Integer studentId) { + StuIndex index = indexOrNull(schId); + return index == null || studentId == null ? null : index.students.get(studentId); + } + + @Override + public List> studentsOfClasses(int schId, List classIds) { + StuIndex index = indexOrNull(schId); + List> result = new ArrayList<>(); + if (index == null || classIds == null || classIds.isEmpty()) { + return result; + } + for (Integer classId : classIds) { + Map node = index.classNodes.get(classId); + if (node != null) { + collectStudents(node, classId, index.classNames.get(classId), result); + } + } + return result; + } + + // ============================ 内部辅助 ============================ + + private StuIndex indexOrNull(int schId) { + List> tree = getStuTree(schId); + return tree == null ? null : buildIndex(tree); + } + + /** 一次遍历学生树,平铺出年级名/班级名/班级节点/学生定位索引 */ + @SuppressWarnings("unchecked") + private StuIndex buildIndex(List> tree) { + StuIndex index = new StuIndex(); + for (Map school : tree) { + Object systems = school.get("children"); + if (!(systems instanceof List)) { + continue; + } + for (Object sysObj : (List) systems) { + Map system = (Map) sysObj; + Object grades = system.get("children"); + if (!(grades instanceof List)) { + continue; + } + for (Object gradeObj : (List) grades) { + Map grade = (Map) gradeObj; + String gradeName = asStr(grade.get("name")); + Object classes = grade.get("children"); + if (!(classes instanceof List)) { + continue; + } + for (Object classObj : (List) classes) { + Map clazz = (Map) classObj; + Integer classId = asInt(clazz.get("id")); + if (classId == null) { + continue; + } + index.gradeNames.put(classId, gradeName); + index.gradeIds.put(classId, asInt(grade.get("id"))); + index.classNames.put(classId, asStr(clazz.get("name"))); + index.classNodes.put(classId, clazz); + for (Object stuObj : (List) getChildren(clazz)) { + Map stu = (Map) stuObj; + Integer stuId = asInt(stu.get("id")); + if (stuId == null) { + continue; + } + Map info = new LinkedHashMap<>(); + info.put("studentId", stuId); + info.put("studentName", asStr(stu.get("name"))); + info.put("classId", classId); + info.put("className", asStr(clazz.get("name"))); + index.students.put(stuId, info); + } + } + } + } + } + return index; + } + + @SuppressWarnings("unchecked") + private List getChildren(Map node) { + Object children = node.get("children"); + return children instanceof List ? (List) children : Collections.emptyList(); + } + + private int countStudents(Map classNode) { + int count = 0; + for (Object stuObj : getChildren(classNode)) { + Map stu = (Map) stuObj; + if (Integer.valueOf(2).equals(asInt(stu.get("type")))) { + count++; + } + } + return count; + } + + private void collectStudents(Map classNode, Integer classId, String className, + List> result) { + for (Object stuObj : getChildren(classNode)) { + Map stu = (Map) stuObj; + if (!Integer.valueOf(2).equals(asInt(stu.get("type")))) { + continue; + } + Map info = new LinkedHashMap<>(); + info.put("studentId", asInt(stu.get("id"))); + info.put("studentName", asStr(stu.get("name"))); + info.put("classId", classId); + info.put("className", className); + result.add(info); + } + } + + private ClassTreeNodeVo toClassNode(Integer classId, StuIndex index) { + ClassTreeNodeVo node = new ClassTreeNodeVo(); + node.setId(classId); + node.setName(index.classNames.get(classId)); + node.setParent(false); + Map classNode = index.classNodes.get(classId); + node.setStudentCount(classNode == null ? 0 : countStudents(classNode)); + return node; + } + + private void requireHost() { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + } + + private Integer asInt(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + try { + return Integer.valueOf(String.valueOf(v)); + } catch (NumberFormatException e) { + return null; + } + } + + private String asStr(Object v) { + return v == null ? null : String.valueOf(v); + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/ContactsService.java b/src/main/java/com/yunhai/workleave/service/impl/ContactsService.java new file mode 100644 index 0000000..fb4b570 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/ContactsService.java @@ -0,0 +1,234 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.entity.ContactGroup; +import com.yunhai.workleave.entity.ContactGroupUser; +import com.yunhai.workleave.model.SchTeaList; +import com.yunhai.workleave.repository.ContactGroupRepository; +import com.yunhai.workleave.repository.ContactGroupUserRepository; +import com.yunhai.workleave.service.IClassService; +import com.yunhai.workleave.service.IContactsService; +import com.yunhai.workleave.service.IUserCenterService; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 自定义联系组业务实现。 + * 成员姓名解析走班级服务教师名单(UserTeaSerID -> TeaName),不落库、不存账号。 + */ +@Service +public class ContactsService implements IContactsService { + + @Autowired + private ContactGroupRepository groupRepository; + + @Autowired + private ContactGroupUserRepository groupUserRepository; + + @Autowired + private IClassService classService; + + @Autowired + private IUserCenterService userCenterService; + + // ============================ 组 CRUD ============================ + + @Override + public Map getGroups(int pageIndex, int pageSize) { + List all = groupRepository.findBySchIdOrderByIdDesc(requireSchId()); + Map counts = groupUserRepository.findByGroupIdIn( + all.stream().map(ContactGroup::getId).collect(Collectors.toList())) + .stream() + .collect(Collectors.groupingBy(ContactGroupUser::getGroupId, Collectors.counting())); + + int total = all.size(); + int from = Math.max((Math.max(pageIndex, 1) - 1) * Math.max(pageSize, 1), 0); + int to = Math.min(from + Math.max(pageSize, 1), total); + List> list = new ArrayList<>(); + for (ContactGroup g : all.subList(from, to)) { + Map item = new LinkedHashMap<>(); + item.put("serid", g.getId()); + item.put("GroupName", g.getName()); + item.put("count", counts.getOrDefault(g.getId(), 0L).intValue()); + list.add(item); + } + Map result = new LinkedHashMap<>(); + result.put("list", list); + result.put("total", total); + return result; + } + + @Override + @Transactional + public Integer addGroup(String name) { + String clean = cleanName(name); + if (clean == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组名不得为空"); + } + ContactGroup group = new ContactGroup(); + group.setSchId(requireSchId()); + group.setName(clean); + groupRepository.save(group); + return group.getId(); + } + + @Override + @Transactional + public void editGroup(Integer id, String name) { + String clean = cleanName(name); + if (clean == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组名不得为空"); + } + ContactGroup group = findOwned(id); + group.setName(clean); + groupRepository.save(group); + } + + @Override + @Transactional + public void deleteGroups(String ids) { + for (Integer id : parseIntList(ids)) { + findOwned(id); + groupUserRepository.deleteByGroupId(id); + groupRepository.deleteById(id); + } + } + + // ============================ 组成员 ============================ + + @Override + public Map getContactsTea(Integer gid, int page, int count) { + ContactGroup group = findOwned(gid); + List members = groupUserRepository.findByGroupId(group.getId()); + + // 姓名(及候选集里已不存在的成员)经教师名单解析 + Map nameMap = teacherNameMap(); + List> all = new ArrayList<>(); + for (ContactGroupUser m : members) { + String teaName = nameMap.get(m.getUserId()); + if (teaName == null) { + continue; // 已不在教师名单(离职等)则不展示 + } + Map item = new LinkedHashMap<>(); + item.put("id", m.getUserId()); + item.put("name", teaName); + all.add(item); + } + int total = all.size(); + List> pageList; + if (page <= 0 || count <= 0) { + pageList = all; // page=0 取全部 + } else { + int from = Math.min((page - 1) * count, total); + int to = Math.min(from + count, total); + pageList = new ArrayList<>(all.subList(from, to)); + } + Map result = new LinkedHashMap<>(); + result.put("list", pageList); + result.put("total", total); + return result; + } + + @Override + @Transactional + public void setContactsTea(Integer gid, String ids) { + ContactGroup group = findOwned(gid); + List userIds = parseIntList(ids); + Set target = new HashSet<>(userIds); + + Set existing = groupUserRepository.findByGroupId(group.getId()).stream() + .map(ContactGroupUser::getUserId) + .collect(Collectors.toSet()); + // 删除不再勾选的 + for (ContactGroupUser m : groupUserRepository.findByGroupId(group.getId())) { + if (!target.contains(m.getUserId())) { + groupUserRepository.delete(m); + } + } + // 新增勾选的 + for (Integer userId : userIds) { + if (!existing.contains(userId)) { + ContactGroupUser m = new ContactGroupUser(); + m.setGroupId(group.getId()); + m.setUserId(userId); + groupUserRepository.save(m); + } + } + } + + @Override + public List> getGroupTree() { + return userCenterService.getSchoolAllTree(requireSchId()); + } + + // ============================ 内部方法 ============================ + + /** 教师 UserTeaSerID -> 姓名(失败降级为空映射,成员只显ID) */ + private Map teacherNameMap() { + Map map = new HashMap<>(); + try { + for (SchTeaList tea : classService.getSchTeaList(requireSchId())) { + if (tea.getUserTeaSerID() != null) { + map.putIfAbsent(tea.getUserTeaSerID(), + tea.getTeaName() == null ? "" : tea.getTeaName()); + } + } + } catch (Exception e) { + // 班级服务不可用时不阻断组管理 + } + return map; + } + + private ContactGroup findOwned(Integer id) { + ContactGroup group = groupRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "联系组不存在")); + Integer schId = UserContext.getSchId(); + if (schId != null && schId != 0 && group.getSchId() != null && !schId.equals(group.getSchId())) { + throw new BizException(ErrorCode.FORBIDDEN, "无权操作该联系组"); + } + return group; + } + + private List parseIntList(String ids) { + if (!StringUtils.hasText(ids)) { + return new ArrayList<>(); + } + return Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(s -> { + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "非法ID:" + s); + } + }) + .collect(Collectors.toList()); + } + + private String cleanName(String raw) { + return raw == null ? null : raw.trim().isEmpty() ? null : raw.trim(); + } + + private Integer requireSchId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未获取到学校信息,请重新登录"); + } + return schId; + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/DictService.java b/src/main/java/com/yunhai/workleave/service/impl/DictService.java new file mode 100644 index 0000000..904a7c3 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/DictService.java @@ -0,0 +1,354 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.entity.SysDict; +import com.yunhai.workleave.repository.SysDictRepository; +import com.yunhai.workleave.service.IDictService; +import com.yunhai.workleave.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 系统字典服务实现(请假类型 leaveType 域)。 + *

请假类型取代了旧模板的「主办部门/活动分类」:类型即字典项, + * 学校可增删改、启停用、拖动排序。学校首次进入(无自有字典)时按默认 + * 7 类(事假/病假/调休/公假/婚假/产假/丧假)初始化自有副本;schId=0 + * 为全局种子,仅作未初始化学校的兜底展示,一旦有自有项即完全替代。

+ *

防"字典文字重复"的最后防线:Add/Update 均按 (schId, dictType, name) 查重。 + * 同时兼容前端误把同一参数 query+body 双发导致的 "name=x,x" 合并值(取第一段)。

+ */ +@Service +public class DictService implements IDictService { + + private static final Logger log = LoggerFactory.getLogger(DictService.class); + + /** 请假类型字典域 */ + public static final String TYPE_LEAVE_TYPE = "leaveType"; + + /** 学校首次进入时初始化的默认请假类型(按展示顺序,dictKey 依次 LT001…LT007) */ + private static final String[] DEFAULT_LEAVE_TYPES = { + "事假", "病假", "调休", "公假", "婚假", "产假", "丧假" + }; + + @Autowired + private SysDictRepository sysDictRepository; + + @Autowired + private com.yunhai.workleave.repository.LeaveApplyRepository leaveApplyRepository; + + @Value("${sms.enabled:false}") + private Boolean smsEnabled; + + // ============================ 下拉 ============================ + + @Override + @Transactional + public List> getSysDic(Integer type) { + String dictType = normalizeType(type == null ? null : String.valueOf(type)); + ensureSeeded(dictType); + return listEnabled(dictType).stream() + .map(this::toOption) + .collect(Collectors.toList()); + } + + // ============================ 管理列表 ============================ + + @Override + @Transactional + public List> getDictMangeList(String type) { + String dictType = normalizeType(type); + ensureSeeded(dictType); + return listAll(dictType).stream() + .sorted(Comparator.comparingInt(d -> sortOf(d))) + .map(this::toManageItem) + .collect(Collectors.toList()); + } + + // ============================ Add / Update / Delete / Sort ============================ + + @Override + @Transactional + public void addDict(String name, String type) { + String dictType = normalizeType(type); + ensureSeeded(dictType); + String clean = cleanName(name); + if (clean == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "名称不得为空!"); + } + // 重名兜底:防前端双击连点 / query+body 双发造成文字重复 + if (existsName(dictType, clean, null)) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); + } + List existing = listAll(dictType); + int nextSort = existing.stream().mapToInt(DictService::sortOf).max().orElse(0) + 1; + + SysDict dict = new SysDict(); + dict.setSchId(currentSchId()); + dict.setDictType(dictType); + dict.setDictValue(clean); + dict.setSortOrder(nextSort); + dict.setDictKey(nextDictKey(dictType, existing)); + dict.setEnableFlag(1); + sysDictRepository.save(dict); + } + + @Override + @Transactional + public void updateDict(Integer serId, String name, Integer enable, String type) { + String dictType = normalizeType(type); + if (serId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); + } + String clean = cleanName(name); + if (clean != null && existsName(dictType, clean, serId)) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); + } + SysDict dict = findOwned(dictType, serId); + if (clean != null) { + dict.setDictValue(clean); + } + if (enable != null) { + dict.setEnableFlag(enable); + } + sysDictRepository.save(dict); + } + + @Override + @Transactional + public void deleteDict(Integer serId, String type) { + String dictType = normalizeType(type); + SysDict dict = findOwned(dictType, serId); + // 已被请假单引用则禁止删除(字典文字与其他功能解耦,这里只做业务引用校验) + if (leaveApplyRepository.existsBySchIdAndLeaveType(currentSchId(), dict.getDictKey())) { + throw new BizException(ErrorCode.FORBIDDEN, "已有请假记录使用该类型,无法删除!"); + } + sysDictRepository.delete(dict); + } + + @Override + @Transactional + public void sortDict(String ids, String type) { + String dictType = normalizeType(type); + ensureSeeded(dictType); + if (!StringUtils.hasText(ids)) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + List list = listAll(dictType); + if (list.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "字典类型错误,未找到该字典类型"); + } + int[] idArr = Arrays.stream(cleanName(ids).split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .mapToInt(Integer::parseInt) + .toArray(); + int maxSort = list.stream().mapToInt(DictService::sortOf).max().orElse(idArr.length); + // 逆序遍历,从最大序号递减(与原 C# 逻辑一致) + final int[] ord = {maxSort}; + for (int i = idArr.length - 1; i >= 0; i--) { + int id = idArr[i]; + sysDictRepository.findById(id).ifPresent(dict -> { + dict.setSortOrder(ord[0]); + sysDictRepository.save(dict); + ord[0]--; + }); + } + } + + // ============================ Sync / 辅助 ============================ + + @Override + public Boolean getSchModuleIsSms() { + // 短信通道未接入,回读配置开关 + return Boolean.TRUE.equals(smsEnabled); + } + + @Override + public String resolveTypeName(String typeKey) { + if (!StringUtils.hasText(typeKey)) { + return null; + } + String key = typeKey.trim(); + // 本校自有行优先(学校可改名),无自有行再回退全局种子 + Integer schId = UserContext.getSchId(); + if (schId != null) { + Optional own = + sysDictRepository.findFirstByDictTypeAndDictKeyAndSchId(TYPE_LEAVE_TYPE, key, schId); + if (own.isPresent()) { + return own.get().getDictValue(); + } + } + return sysDictRepository.findFirstByDictTypeAndDictKeyAndSchId(TYPE_LEAVE_TYPE, key, 0) + .map(SysDict::getDictValue) + .orElse(null); + } + + // ============================ 内部方法 ============================ + + /** + * 学校首次进入(无自有请假类型字典)时,按默认 7 类初始化一份自有字典。 + *

初始化后学校在自己的副本上增删改/启停用/排序,互不影响;schId=0 全局种子 + * 仅作为未初始化学校的兜底展示。schId=0 上下文(无登录态)不播种。

+ */ + private void ensureSeeded(String dictType) { + Integer schId = currentSchId(); + if (schId == 0 || sysDictRepository.existsByDictTypeAndSchId(dictType, schId)) { + return; + } + List seeds = new ArrayList<>(); + for (int i = 0; i < DEFAULT_LEAVE_TYPES.length; i++) { + SysDict dict = new SysDict(); + dict.setSchId(schId); + dict.setDictType(dictType); + dict.setDictKey(String.format("LT%03d", i + 1)); + dict.setDictValue(DEFAULT_LEAVE_TYPES[i]); + dict.setSortOrder(i + 1); + dict.setEnableFlag(1); + seeds.add(dict); + } + sysDictRepository.saveAll(seeds); + log.info("[ensureSeeded] 学校 {} 无自有请假类型,已按默认 {} 类初始化", schId, DEFAULT_LEAVE_TYPES.length); + } + + /** + * 可见范围:登录学校(schId≠0)一律读自有行——列表入口已先经 + * {@link #ensureSeeded} 播种,自有行必然非空,绝不与全局种子混显; + * 仅 schId=0(无登录态)读全局种子兜底。 + */ + private List listAll(String dictType) { + Integer schId = currentSchId(); + if (schId != 0) { + return sysDictRepository.findByDictTypeAndSchIdOrderBySortOrderAsc(dictType, schId); + } + return sysDictRepository.findByDictTypeAndSchIdOrderBySortOrderAsc(dictType, 0); + } + + private List listEnabled(String dictType) { + Integer schId = currentSchId(); + if (schId != 0) { + // 自有行全部停用时下拉为空是预期行为,不回退全局种子 + return sysDictRepository.findByDictTypeAndSchIdAndEnableFlagOrderBySortOrderAsc( + dictType, schId, 1); + } + return sysDictRepository.findByDictTypeAndSchIdAndEnableFlagOrderBySortOrderAsc(dictType, 0, 1); + } + + private boolean existsName(String dictType, String name, Integer excludeId) { + return listAll(dictType).stream().anyMatch(d -> + (excludeId == null || !d.getId().equals(excludeId)) + && name.equals(d.getDictValue())); + } + + private SysDict findOwned(String dictType, Integer serId) { + SysDict dict = sysDictRepository.findById(serId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); + if (!dictType.equals(dict.getDictType())) { + throw new BizException(ErrorCode.PARAM_ERROR, "字典类型不匹配"); + } + // 学校隔离:只能操作本校自有行;schId=0 全局种子保留兼容(历史数据即种子行) + Integer schId = currentSchId(); + if (dict.getSchId() != null && dict.getSchId() != 0 && !dict.getSchId().equals(schId)) { + throw new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"); + } + return dict; + } + + /** 字典类型归一化:仅 leaveType 域(兼容前端历史数字入参 1/2) */ + private String normalizeType(String type) { + if (!StringUtils.hasText(type)) { + return TYPE_LEAVE_TYPE; + } + String t = cleanName(type).toLowerCase(Locale.ROOT); + if ("1".equals(t) || "2".equals(t) || TYPE_LEAVE_TYPE.toLowerCase(Locale.ROOT).equals(t)) { + return TYPE_LEAVE_TYPE; + } + throw new BizException(ErrorCode.PARAM_ERROR, "字典类型错误:" + type); + } + + /** + * 清洗前端传参:去首尾空白/引号; + * 兼容 query+body 双发被 Spring 合并成 "x,x" 的取值(仅当两段完全相同时取第一段)。 + */ + private String cleanName(String raw) { + if (!StringUtils.hasText(raw)) { + return null; + } + String s = raw.trim(); + if (s.contains(",")) { + String[] parts = s.split(",", -1); + boolean allSame = true; + for (String p : parts) { + if (!parts[0].equals(p)) { + allSame = false; + break; + } + } + if (allSame) { + s = parts[0].trim(); + } + } + while (s.length() >= 2 && (s.startsWith("'") && s.endsWith("'") || s.startsWith("\"") && s.endsWith("\""))) { + s = s.substring(1, s.length() - 1).trim(); + } + return s.isEmpty() ? null : s; + } + + /** dictKey 生成:LT001、LT002…(在现有 key 基础上递增) */ + private String nextDictKey(String dictType, List existing) { + String prefix = "LT"; + int max = existing.stream() + .map(SysDict::getDictKey) + .filter(k -> k != null && k.startsWith(prefix)) + .map(k -> { + try { + return Integer.parseInt(k.substring(prefix.length())); + } catch (NumberFormatException e) { + return 0; + } + }) + .max(Integer::compareTo).orElse(0); + return String.format("%s%03d", prefix, max + 1); + } + + private Map toOption(SysDict d) { + Map item = new LinkedHashMap<>(); + item.put("k", d.getDictKey()); + item.put("v", d.getDictValue()); + return item; + } + + private Map toManageItem(SysDict d) { + Map item = new LinkedHashMap<>(); + item.put("SerID", d.getId()); + item.put("CodeID", d.getDictKey()); + item.put("Name", d.getDictValue()); + item.put("Des", d.getSortOrder() != null ? d.getSortOrder().toString() : ""); + item.put("Enable", d.getEnableFlag()); + return item; + } + + private static int sortOf(SysDict d) { + return d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder(); + } + + private Integer currentSchId() { + Integer schId = UserContext.getSchId(); + return schId == null ? 0 : schId; + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/FileService.java b/src/main/java/com/yunhai/workleave/service/impl/FileService.java new file mode 100644 index 0000000..f7ea653 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/FileService.java @@ -0,0 +1,309 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.entity.UpFile; +import com.yunhai.workleave.model.file.UpFileResultVo; +import com.yunhai.workleave.model.file.UpFileVo; +import com.yunhai.workleave.repository.UpFileRepository; +import com.yunhai.workleave.service.IFileService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.FileInputStream; +import java.io.OutputStream; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 文件上传/下载/删除业务实现。 + *

+ * 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext} + * 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。 + */ +@Service +public class FileService implements IFileService { + + private static final Logger log = LoggerFactory.getLogger(FileService.class); + + @Autowired + private UpFileRepository upFileRepository; + + @Value("${FILE_PATH}") + private String uploadPath; + + // ============================ 上传 ============================ + + @Override + @Transactional + public UpFileVo upload(MultipartFile file, String appType, Integer topicId) { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空"); + } + String originalName = file.getOriginalFilename(); + if (!StringUtils.hasText(originalName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空"); + } + // 提取后缀 + String ext = ""; + int dotIdx = originalName.lastIndexOf('.'); + if (dotIdx >= 0) { + ext = originalName.substring(dotIdx + 1).toLowerCase(); + } + + String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext); + + // 物理写入 + Path fullPath = Paths.get(uploadPath, savedName); + try { + Files.createDirectories(fullPath.getParent()); + file.transferTo(fullPath.toFile()); + } catch (Exception e) { + log.error("[upload] 文件保存失败: {}", fullPath, e); + throw new BizException(ErrorCode.ERROR, "文件保存失败", e); + } + + // 入库 + UpFile entity = new UpFile(); + entity.setTopicType(appType); + entity.setTopicId(topicId); + entity.setFileTrueName(originalName); + entity.setFileName(savedName); + entity.setFileSize((int) file.getSize()); + entity.setFileType(ext); + upFileRepository.save(entity); + + return toVo(entity); + } + + // ============================ 绑定主题 ============================ + + @Override + @Transactional + public void bindToTopic(List fileIds, Integer topicId) { + if (fileIds == null || fileIds.isEmpty() || topicId == null) { + return; + } + for (Integer fileId : fileIds) { + upFileRepository.findById(fileId).ifPresent(f -> { + f.setTopicId(topicId); + upFileRepository.save(f); + }); + } + } + + // ============================ 查询附件列表 ============================ + + @Override + public List listByTopic(String appType, Integer topicId) { + return upFileRepository.findByTopicTypeAndTopicId(appType, topicId).stream() + .map(this::toVo) + .collect(Collectors.toList()); + } + + // ============================ 下载 ============================ + + @Override + public void download(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, false); + } + + @Override + public void preview(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, true); + } + + /** + * 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type + * (浏览器/前端组件可直接渲染),否则 attachment 强制下载。 + */ + private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + Path fullPath = Paths.get(uploadPath, file.getFileName()); + if (!Files.exists(fullPath)) { + throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName()); + } + try (FileInputStream fis = new FileInputStream(fullPath.toFile()); + OutputStream os = response.getOutputStream()) { + String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8") + .replaceAll("\\+", "%20"); + if (inline) { + response.setContentType(resolveContentType(file.getFileName())); + response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\""); + } else { + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\""); + } + response.setContentLengthLong(file.getFileSize()); + byte[] buffer = new byte[4096]; + int len; + while ((len = fis.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + os.flush(); + } catch (Exception e) { + log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e); + throw new BizException(ErrorCode.ERROR, "文件下载失败", e); + } + } + + /** + * 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。 + */ + private String resolveContentType(String fileName) { + String name = String.valueOf(fileName).toLowerCase(); + Map table = new HashMap<>(); + table.put("pdf", "application/pdf"); + table.put("png", "image/png"); + table.put("jpg", "image/jpeg"); + table.put("jpeg", "image/jpeg"); + table.put("gif", "image/gif"); + table.put("bmp", "image/bmp"); + table.put("webp", "image/webp"); + table.put("svg", "image/svg+xml"); + table.put("txt", "text/plain;charset=UTF-8"); + table.put("mp4", "video/mp4"); + table.put("webm", "video/webm"); + table.put("ogg", "video/ogg"); + table.put("doc", "application/msword"); + table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + table.put("xls", "application/vnd.ms-excel"); + table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + table.put("ppt", "application/vnd.ms-powerpoint"); + table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + String type = table.get(name.substring(name.lastIndexOf('.') + 1)); + if (type != null) { + return type; + } + String guessed = URLConnection.guessContentTypeFromName(name); + return guessed != null ? guessed : "application/octet-stream"; + } + + // ============================ 删除 ============================ + + @Override + @Transactional + public void delete(Integer fileId) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + // 删物理文件 + Path fullPath = Paths.get(uploadPath, file.getFileName()); + try { + Files.deleteIfExists(fullPath); + } catch (Exception e) { + log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e); + } + // 删数据库记录 + upFileRepository.delete(file); + } + + // ============================ 打包下载 ZIP ============================ + + @Override + public void downloadZip(List files, String zipName, HttpServletResponse response) { + if (files == null || files.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); + } + try { + response.setContentType("application/zip"); + String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\""); + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) { + Set used = new HashSet<>(); + for (UpFile f : files) { + Path p = Paths.get(uploadPath, f.getFileName()); + if (!Files.exists(p)) { + log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); + continue; + } + String entryName = sanitizeEntryName(f.getFileTrueName()); + if (used.contains(entryName)) { + entryName = f.getId() + "_" + entryName; + } + used.add(entryName); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(p, zos); + zos.closeEntry(); + } + } + } catch (Exception e) { + log.error("[downloadZip] 打包下载失败", e); + throw new BizException(ErrorCode.ERROR, "打包下载失败", e); + } + } + + /** + * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 + */ + private String sanitizeEntryName(String name) { + if (name == null || name.trim().isEmpty()) { + return "file"; + } + return name.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + // ============================ 按主题打包下载 ============================ + + @Override + public void downloadZipByTopic(String topicType, Integer topicId, String zipName, + HttpServletResponse response) { + List files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId); + downloadZip(files, zipName, response); + } + + // ============================ 批量上传图片 ============================ + + @Override + @Transactional + public List uploadImages(MultipartFile[] files, String appType) { + List results = new ArrayList<>(); + if (files == null || files.length == 0) { + return results; + } + for (MultipartFile file : files) { + results.add(upload(file, appType, null)); + } + return results; + } + + // ============================ 上传(返回前端兼容 VO) ============================ + + @Override + @Transactional + public UpFileResultVo uploadToResult(MultipartFile file, String appType) { + UpFileVo vo = upload(file, appType, null); + UpFileResultVo result = new UpFileResultVo(); + result.setFileid(vo.getId()); + result.setName(vo.getFileTrueName()); + result.setFileurl(vo.getFileName()); + result.setHost(""); + result.setType(vo.getFileType()); + result.setSize(vo.getFileSize()); + return result; + } + + // ============================ 转换 ============================ + + private UpFileVo toVo(UpFile entity) { + UpFileVo vo = new UpFileVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/LeaveApplyService.java b/src/main/java/com/yunhai/workleave/service/impl/LeaveApplyService.java new file mode 100644 index 0000000..d1ce2f9 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/LeaveApplyService.java @@ -0,0 +1,956 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.openapi.flow.model.FlowApplyResult; +import com.yunhai.platform.openapi.flow.model.FlowProgress; +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.entity.LeaveAuditTrace; +import com.yunhai.workleave.entity.LeaveCc; +import com.yunhai.workleave.entity.LeaveSetting; +import com.yunhai.workleave.entity.LeaveTimeSet; +import com.yunhai.workleave.entity.UpFile; +import com.yunhai.workleave.enums.LeaveStatusEnum; +import com.yunhai.workleave.flow.ILeaveFlowService; +import com.yunhai.workleave.service.ILeaveFlowBindService; +import com.yunhai.workleave.model.BasePageResult; +import com.yunhai.workleave.model.leave.FlowProgressVo; +import com.yunhai.workleave.model.leave.LeaveAuditRequest; +import com.yunhai.workleave.model.leave.LeaveCancelRequest; +import com.yunhai.workleave.model.leave.LeaveDetailVo; +import com.yunhai.workleave.model.leave.LeaveQuery; +import com.yunhai.workleave.model.leave.LeaveRequest; +import com.yunhai.workleave.model.leave.LeaveStatsVo; +import com.yunhai.workleave.model.leave.LeaveVo; +import com.yunhai.workleave.model.leave.TodoVo; +import com.yunhai.workleave.model.leave.UserTimeSumVo; +import com.yunhai.workleave.repository.LeaveApplyRepository; +import com.yunhai.workleave.repository.LeaveAuditTraceRepository; +import com.yunhai.workleave.repository.LeaveCcRepository; +import com.yunhai.workleave.repository.LeaveSettingRepository; +import com.yunhai.workleave.repository.LeaveTimeSetRepository; +import com.yunhai.workleave.repository.UpFileRepository; +import com.yunhai.workleave.service.IDictService; +import com.yunhai.workleave.service.ILeaveApplyService; +import com.yunhai.workleave.service.ILeaveUserTimeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 请假申请域服务实现。 + *

核心规则(历史口径 + 新方案收敛):

+ *
    + *
  • 冲突校验:同人、状态 ∈ {审批中, 已通过},区间重叠即拒绝;
  • + *
  • 时长校验:管控类型校验「在途 + 本次 ≤ 可用」;
  • + *
  • 时长落库以服务端复核值为准(前端试算仅提示);
  • + *
  • 审核流转全部交流程中心,本地状态由回调推进;重新提交换 bizId(attempt+1)。
  • + *
+ */ +@Service +public class LeaveApplyService implements ILeaveApplyService { + + private static final Logger log = LoggerFactory.getLogger(LeaveApplyService.class); + + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DATE_TIME_MIN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + @Value("${leave.flow.cancel-limit-days:50}") + private int cancelLimitDays; + + @Autowired + private LeaveApplyRepository leaveApplyRepository; + + @Autowired + private LeaveCcRepository leaveCcRepository; + + @Autowired + private LeaveAuditTraceRepository auditTraceRepository; + + @Autowired + private LeaveTimeSetRepository timeSetRepository; + + @Autowired + private LeaveSettingRepository settingRepository; + + @Autowired + private UpFileRepository upFileRepository; + + @Autowired + private ILeaveFlowService flowService; + + @Autowired + private ILeaveFlowBindService flowBindService; + + @Autowired + private ILeaveUserTimeService userTimeService; + + @Autowired + private IDictService dictService; + + @Autowired + private LeaveTimeCalculator timeCalculator; + + // ============================ 列表 / 详情 ============================ + + @Override + public BasePageResult list(Integer schId, Integer currentUserId, LeaveQuery query) { + LeaveQuery q = query == null ? new LeaveQuery() : query; + String scope = StringUtils.hasText(q.getScope()) ? q.getScope() : "user"; + + // 抄送视角:先取抄送留痕的 leaveId 集合 + Set ccLeaveIds = null; + if ("cc".equalsIgnoreCase(scope)) { + ccLeaveIds = leaveCcRepository.findBySchIdAndCcUserIdOrderByIdDesc(schId, currentUserId).stream() + .map(LeaveCc::getLeaveId).collect(Collectors.toCollection(LinkedHashSet::new)); + if (ccLeaveIds.isEmpty()) { + return new BasePageResult<>(Collections.emptyList(), 0, q.getPageIndex(), q.getPageSize(), 0, false); + } + } + final Set ccIds = ccLeaveIds; + final String finalScope = scope; + + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("schId"), schId)); + + if ("user".equalsIgnoreCase(finalScope)) { + predicates.add(cb.equal(root.get("userId"), currentUserId)); + } else if ("cc".equalsIgnoreCase(finalScope)) { + predicates.add(root.get("id").in(ccIds)); + // 抄送口径对齐历史:只展示已通过/销假类有效单据 + predicates.add(root.get("status").in(Arrays.asList( + LeaveStatusEnum.PASSED.getId(), LeaveStatusEnum.CANCELED.getId()))); + } + // all:仅按学校过滤(管理端) + + if (q.getUserId() != null) { + predicates.add(cb.equal(root.get("userId"), q.getUserId())); + } + if (q.getStatus() != null && q.getStatus() >= 0) { + predicates.add(cb.equal(root.get("status"), q.getStatus())); + } + if (StringUtils.hasText(q.getLeaveType())) { + predicates.add(cb.equal(root.get("leaveType"), q.getLeaveType().trim())); + } + if (StringUtils.hasText(q.getStartTime()) || StringUtils.hasText(q.getEndTime())) { + // 重叠语义:startTime <= 单据结束 && 单据开始 <= endTime + if (StringUtils.hasText(q.getStartTime())) { + predicates.add(cb.greaterThanOrEqualTo(root.get("endTime"), dayStart(q.getStartTime()))); + } + if (StringUtils.hasText(q.getEndTime())) { + predicates.add(cb.lessThanOrEqualTo(root.get("startTime"), dayEnd(q.getEndTime()))); + } + } + if (StringUtils.hasText(q.getKeyword())) { + String like = "%" + q.getKeyword().trim() + "%"; + predicates.add(cb.or( + cb.like(root.get("userName"), like), + cb.like(root.get("reason"), like), + cb.like(root.get("leaveTypeName"), like))); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + + Page page = leaveApplyRepository.findAll(spec, + PageRequest.of(Math.max(q.getPageIndex() - 1, 0), Math.max(q.getPageSize(), 1), + Sort.by(Sort.Direction.DESC, "applyTime").and(Sort.by(Sort.Direction.DESC, "id")))); + + Map ccTimeMap = new HashMap<>(); + if ("cc".equalsIgnoreCase(finalScope)) { + for (LeaveCc cc : leaveCcRepository.findBySchIdAndCcUserIdOrderByIdDesc(schId, currentUserId)) { + ccTimeMap.putIfAbsent(cc.getLeaveId(), cc.getCreateTime()); + } + } + List content = page.getContent().stream() + .map(l -> toVo(l, currentUserId, ccTimeMap.get(l.getId()))) + .collect(Collectors.toList()); + return new BasePageResult<>(content, (int) page.getTotalElements(), page.getNumber() + 1, + page.getSize(), page.getTotalPages(), page.hasNext()); + } + + @Override + public LeaveDetailVo detail(Integer schId, Integer currentUserId, Integer leaveId) { + LeaveApply leave = requireLeave(schId, leaveId); + LeaveDetailVo vo = new LeaveDetailVo(); + vo.setId(leave.getId()); + vo.setUserId(leave.getUserId()); + vo.setUserName(leave.getUserName()); + vo.setUserPhoto(leave.getUserPhoto()); + vo.setLeaveType(leave.getLeaveType()); + vo.setLeaveTypeName(resolveTypeName(leave)); + vo.setStartTime(leave.getStartTime()); + vo.setEndTime(leave.getEndTime()); + vo.setTotalHours(leave.getTotalHours()); + vo.setReason(leave.getReason()); + vo.setCourseArrange(leave.getCourseArrange()); + vo.setCourseArrangeName(courseArrangeName(leave.getCourseArrange())); + vo.setStatus(leave.getStatus()); + vo.setStatusName(LeaveStatusEnum.nameById(leave.getStatus())); + vo.setApplyTime(leave.getApplyTime() == null ? null : leave.getApplyTime().format(DATE_TIME)); + vo.setSnapshot(leave.getSnapshot()); + vo.setFlowGuid(leave.getFlowGuid()); + vo.setCancelTime(leave.getCancelTime() == null ? null : leave.getCancelTime().format(DATE_TIME)); + vo.setCancelReason(leave.getCancelReason()); + + // 附件 + vo.setFiles(loadFiles(leave)); + + // 抄送人:发起选择 ∪ 回调留痕 + vo.setCcUsers(loadCcUsers(leave)); + + // 流程进度(不可达时降级 null) + FlowProgress progress = flowService.progress(leave); + if (progress != null) { + vo.setProgress(flowService.toProgressVo(progress)); + } else if (leave.getInstanceId() != null) { // 中心不可达时至少回显本地审核痕迹 + FlowProgressVo fallback = new FlowProgressVo(); + fallback.setInstanceId(leave.getInstanceId()); + fallback.setSteps(auditTraceRepository.findBySchIdAndLeaveIdOrderByAuditTimeAsc(schId, leave.getId()) + .stream().map(t -> { + FlowProgressVo.StepVo s = new FlowProgressVo.StepVo(); + s.setNodeName("approve".equals(t.getAction()) ? "审核通过" : "审核拒绝"); + s.setAssigneeName(t.getOperatorName()); + s.setAction("approve".equals(t.getAction()) ? "同意" : "拒绝"); + s.setOpinion(t.getOpinion()); + s.setTime(t.getAuditTime() == null ? null : t.getAuditTime().format(DATE_TIME)); + s.setFinished(true); + return s; + }).collect(Collectors.toList())); + vo.setProgress(fallback); + } + // 流程图(10 分钟短时 token,可直接 ) + vo.setDiagramUrl(flowService.diagramUrl(leave)); + + // 操作权限 + boolean owner = currentUserId != null && currentUserId.equals(leave.getUserId()); + vo.setCanEdit(owner && LeaveStatusEnum.editable(leave.getStatus())); + vo.setCanCancel(owner && LeaveStatusEnum.PASSED.getId().equals(leave.getStatus())); + vo.setCanWithdraw(owner && LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())); + vo.setCanDelete(owner && LeaveStatusEnum.deletable(leave.getStatus()) + && !LeaveStatusEnum.CANCELED.getId().equals(leave.getStatus())); + return vo; + } + + // ============================ 新建 / 编辑 / 删除 ============================ + + @Override + @Transactional + public Integer create(Integer schId, Integer currentUserId, String userName, LeaveRequest request) { + if (request == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + // 申请人只信任服务端登录态,绝不接受请求体指定 userId,防止替他人发起。 + Integer userId = currentUserId; + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取当前用户信息失败"); + } + + LeaveTimeSet timeSet = requireTimeSet(schId); + double hours = timeCalculator.calc(request.getStartTime(), request.getEndTime(), + timeSet.getForenoon1(), timeSet.getForenoon2(), timeSet.getAfternoon1(), timeSet.getAfternoon2()); + hours = resolveHours(hours, request.getTotalHours()); + + LeaveApply leave = new LeaveApply(); + applyRequest(leave, request); + leave.setSchId(schId); + leave.setUserId(userId); + leave.setUserName(StringUtils.hasText(userName) ? userName : resolveSelfName(userId)); + leave.setUserPhoto(null); + leave.setLeaveTypeName(dictService.resolveTypeName(request.getLeaveType())); + leave.setTotalHours(hours); + leave.setStatus(LeaveStatusEnum.APPROVING.getId()); + leave.setApplyTime(LocalDateTime.now()); + leave.setFlowAttempt(1); + + validateBeforeSave(leave); + leaveApplyRepository.save(leave); + + // 发起流程(失败则整体回滚,避免留下不回流的"孤儿单") + startFlow(leave, 1); + leaveApplyRepository.save(leave); + + // 抄送人留痕(发起时选择的人;流程 NOTIFY 节点的抄送由回调另落) + saveSelectedCc(leave); + + log.info("[leave.create] 提交成功: leaveId={}, userId={}, hours={}, bizId={}", + leave.getId(), userId, hours, leave.getBizId()); + return leave.getId(); + } + + @Override + @Transactional + public void edit(Integer schId, Integer currentUserId, String userName, LeaveRequest request) { + if (request == null || request.getId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + LeaveApply leave = requireLeave(schId, request.getId()); + if (!leave.getUserId().equals(currentUserId)) { + throw new BizException(ErrorCode.FORBIDDEN, "只能编辑本人请假申请"); + } + if (!LeaveStatusEnum.editable(leave.getStatus())) { + throw new BizException(ErrorCode.FORBIDDEN, + "当前状态(" + LeaveStatusEnum.nameById(leave.getStatus()) + ")不允许编辑"); + } + + LeaveTimeSet timeSet = requireTimeSet(schId); + double hours = timeCalculator.calc(request.getStartTime(), request.getEndTime(), + timeSet.getForenoon1(), timeSet.getForenoon2(), timeSet.getAfternoon1(), timeSet.getAfternoon2()); + hours = resolveHours(hours, request.getTotalHours()); + applyRequest(leave, request); + leave.setLeaveTypeName(dictService.resolveTypeName(request.getLeaveType())); + leave.setTotalHours(hours); + if (!StringUtils.hasText(leave.getUserName())) { + leave.setUserName(StringUtils.hasText(userName) ? userName : resolveSelfName(leave.getUserId())); + } + + validateBeforeSave(leave); + // 重新提交:attempt+1,bizId 变化 → 流程中心新建实例(原实例已终态) + int nextAttempt = (leave.getFlowAttempt() == null ? 1 : leave.getFlowAttempt()) + 1; + leave.setStatus(LeaveStatusEnum.APPROVING.getId()); + leave.setSnapshot(null); + leave.setFlowAttempt(nextAttempt); + leaveApplyRepository.save(leave); + + startFlow(leave, nextAttempt); + leaveApplyRepository.save(leave); + saveSelectedCc(leave); + log.info("[leave.edit] 重新提交成功: leaveId={}, attempt={}, hours={}", leave.getId(), nextAttempt, hours); + } + + @Override + @Transactional + public void delete(Integer schId, Integer currentUserId, List ids) { + if (ids == null || ids.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + for (Integer id : ids) { + LeaveApply leave = leaveApplyRepository.findByIdAndSchId(id, schId).orElse(null); + if (leave == null) { + continue; + } + if (!leave.getUserId().equals(currentUserId)) { + throw new BizException(ErrorCode.FORBIDDEN, "只能删除本人请假申请"); + } + if (LeaveStatusEnum.PASSED.getId().equals(leave.getStatus())) { + throw new BizException(ErrorCode.FORBIDDEN, "已通过的请假只能销假,不能删除"); + } + leaveApplyRepository.delete(leave); + } + log.info("[leave.delete] 删除完成: ids={}, operator={}", ids, currentUserId); + } + + // ============================ 撤回 / 销假 ============================ + + @Override + @Transactional + public void withdraw(Integer schId, Integer currentUserId, Integer leaveId) { + LeaveApply leave = requireLeave(schId, leaveId); + if (!leave.getUserId().equals(currentUserId)) { + throw new BizException(ErrorCode.FORBIDDEN, "只能撤回本人请假申请"); + } + if (!LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅审批中的请假可以撤回"); + } + flowService.withdraw(schId, leave.getInstanceId(), currentUserId); + // 状态由回调 flow.withdrawn 推进;此处不本地直改 + } + + @Override + @Transactional + public void cancel(Integer schId, Integer currentUserId, LeaveCancelRequest request) { + LeaveApply leave = requireLeave(schId, request.getId()); + if (!leave.getUserId().equals(currentUserId)) { + throw new BizException(ErrorCode.FORBIDDEN, "只能销假本人请假申请"); + } + if (!LeaveStatusEnum.PASSED.getId().equals(leave.getStatus())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅已通过的请假可以销假"); + } + if (leave.getApplyTime() != null && cancelLimitDays > 0 + && leave.getApplyTime().plusDays(cancelLimitDays).isBefore(LocalDateTime.now())) { + throw new BizException(ErrorCode.FORBIDDEN, + "请在 " + cancelLimitDays + " 天内发起销假,记录已失效!"); + } + + leave.setStatus(LeaveStatusEnum.CANCELED.getId()); + leave.setCancelTime(LocalDateTime.now()); + leave.setCancelReason(request.getReason()); + leaveApplyRepository.save(leave); + + // 返还时长 + userTimeService.refund(schId, leave.getUserId(), leave.getUserName(), leave.getId(), + leave.getLeaveType(), leave.getTotalHours() == null ? 0D : leave.getTotalHours(), + currentUserId, "销假返还"); + log.info("[leave.cancel] 销假成功: leaveId={}, userId={}", leave.getId(), leave.getUserId()); + } + + // ============================ 待办 / 审核 ============================ + + @Override + public BasePageResult todoList(Integer schId, Integer currentUserId, String keyword, + String startTime, String endTime, int pageIndex, int pageSize) { + FlowTodoTaskPage page = flowService.todoTasks(schId, currentUserId, pageIndex, pageSize, "leave"); + List tasks = flowService.contentOf(page); + if (tasks.isEmpty()) { + int total = page == null ? 0 : page.getTotal(); + return new BasePageResult<>(Collections.emptyList(), total, pageIndex, pageSize, + page == null ? 0 : page.getTotalPages(), page != null && page.isHasNext()); + } + + // 本地单据批量补全 + Map applyMap = new LinkedHashMap<>(); + for (FlowTodoTask task : tasks) { + Integer leaveId = parseLeaveId(task.getBizId()); + if (leaveId != null) { + leaveApplyRepository.findById(leaveId).ifPresent(l -> applyMap.put(l.getId(), l)); + } + } + + List content = new ArrayList<>(); + boolean dateFilter = StringUtils.hasText(startTime) || StringUtils.hasText(endTime); + for (FlowTodoTask task : tasks) { + Integer leaveId = parseLeaveId(task.getBizId()); + LeaveApply leave = leaveId == null ? null : applyMap.get(leaveId); + if (dateFilter && (leave == null || !inDateRange(leave, startTime, endTime))) { + continue; + } + if (StringUtils.hasText(keyword)) { + String kw = keyword.trim(); + boolean hit = (leave != null && ((leave.getUserName() != null && leave.getUserName().contains(kw)) + || (leave.getReason() != null && leave.getReason().contains(kw)))) + || (task.getBizTitle() != null && task.getBizTitle().contains(kw)); + if (!hit) { + continue; + } + } + TodoVo vo = new TodoVo(); + vo.setTaskId(task.getTaskId()); + vo.setInstanceId(task.getInstanceId()); + vo.setInstanceVersion(task.getInstanceVersion()); + vo.setLeaveId(leaveId); + vo.setNodeName(task.getNodeName()); + vo.setArriveTime(task.getArriveTime()); + vo.setBizTitle(task.getBizTitle()); + if (leave != null) { + vo.setUserId(leave.getUserId()); + vo.setUserName(leave.getUserName()); + vo.setLeaveType(leave.getLeaveType()); + vo.setLeaveTypeName(resolveTypeName(leave)); + vo.setStartTime(leave.getStartTime()); + vo.setEndTime(leave.getEndTime()); + vo.setTotalHours(leave.getTotalHours()); + vo.setReason(leave.getReason()); + vo.setStatus(leave.getStatus()); + vo.setStatusName(LeaveStatusEnum.nameById(leave.getStatus())); + } + content.add(vo); + } + int total = page == null ? content.size() : page.getTotal(); + return new BasePageResult<>(content, total, pageIndex, pageSize, + page == null ? 1 : page.getTotalPages(), page != null && page.isHasNext()); + } + + @Override + public BasePageResult auditedList(Integer schId, Integer currentUserId, String keyword, + String startTime, String endTime, int pageIndex, int pageSize) { + Page tracePage = auditTraceRepository.findBySchIdAndOperatorIdOrderByAuditTimeDesc( + schId, currentUserId, + PageRequest.of(Math.max(pageIndex - 1, 0), Math.max(pageSize, 1))); + + List leaveIds = tracePage.getContent().stream() + .map(LeaveAuditTrace::getLeaveId).distinct().collect(Collectors.toList()); + Map applyMap = leaveIds.isEmpty() ? Collections.emptyMap() + : leaveApplyRepository.findAllById(leaveIds).stream() + .filter(l -> schId.equals(l.getSchId())) + .collect(Collectors.toMap(LeaveApply::getId, l -> l, (a, b) -> a)); + + List content = new ArrayList<>(); + for (LeaveAuditTrace trace : tracePage.getContent()) { + LeaveApply leave = applyMap.get(trace.getLeaveId()); + if (leave == null) { + continue; + } + if ((StringUtils.hasText(startTime) || StringUtils.hasText(endTime)) + && !inDateRange(leave, startTime, endTime)) { + continue; + } + if (StringUtils.hasText(keyword)) { + String kw = keyword.trim(); + boolean hit = (leave.getUserName() != null && leave.getUserName().contains(kw)) + || (leave.getReason() != null && leave.getReason().contains(kw)); + if (!hit) { + continue; + } + } + content.add(toVo(leave, currentUserId, null)); + } + return new BasePageResult<>(content, (int) tracePage.getTotalElements(), pageIndex, pageSize, + tracePage.getTotalPages(), tracePage.hasNext()); + } + + @Override + @Transactional + public void audit(Integer schId, Integer currentUserId, String userName, LeaveAuditRequest request) { + if (request == null || request.getTaskId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + String action = request.getAction() == null ? "" : request.getAction().trim().toLowerCase(Locale.ROOT); + if (!"approve".equals(action) && !"reject".equals(action)) { + throw new BizException(ErrorCode.PARAM_ERROR, "审核动作非法"); + } + if ("reject".equals(action) && !StringUtils.hasText(request.getOpinion())) { + // 手册 §5.1 第 5 条:拒绝时意见必填 + throw new BizException(ErrorCode.PARAM_ERROR, "拒绝时必须填写审批意见"); + } + + if ("approve".equals(action)) { + flowService.approveTask(schId, request.getTaskId(), currentUserId, userName, request.getOpinion()); + } else { + flowService.rejectTask(schId, request.getTaskId(), currentUserId, userName, request.getOpinion()); + } + + // 本地留痕(流程中心进度仍是权威;此表用于"我已审核"列表) + Integer leaveId = request.getLeaveId() != null ? request.getLeaveId() + : resolveLeaveIdByTask(schId, currentUserId, request.getTaskId()); + if (leaveId != null && !auditTraceRepository.existsByLeaveIdAndTaskIdAndAction( + leaveId, request.getTaskId(), action)) { + LeaveAuditTrace trace = new LeaveAuditTrace(); + trace.setSchId(schId); + trace.setLeaveId(leaveId); + trace.setTaskId(request.getTaskId()); + trace.setAction(action); + trace.setOpinion(request.getOpinion()); + trace.setOperatorId(currentUserId); + trace.setOperatorName(userName); + trace.setAuditTime(LocalDateTime.now()); + auditTraceRepository.save(trace); + } + } + + // ============================ 余额 / 统计 ============================ + + @Override + public UserTimeSumVo myTime(Integer schId, Integer currentUserId) { + UserTimeSumVo vo = new UserTimeSumVo(); + vo.setRemainHours(round2(userTimeService.usableOf(schId, currentUserId) + - userTimeService.inTransitHoursOf(schId, currentUserId))); + vo.setAddHours(round2(userTimeService.usableOf(schId, currentUserId))); + return vo; + } + + @Override + public LeaveStatsVo statistics(Integer schId, String startTime, String endTime) { + LocalDateTime start = parseDateBoundary(startTime, LocalDate.now().withDayOfMonth(1).atStartOfDay()); + LocalDateTime end = parseDateBoundary(endTime, LocalDate.now().plusDays(1).atStartOfDay()); + // 结束日期按「含当天」:前端只传 yyyy-MM-dd,解析后是当天 00:00, + // 直接 lessThan 会把结束日当天申请的单全部漏掉(用户选今天=漏今天) + if (StringUtils.hasText(endTime) && endTime.trim().replace('T', ' ').length() == 10) { + end = end.plusDays(1); + } + final LocalDateTime endBound = end; + + Specification spec = (root, cq, cb) -> cb.and( + cb.equal(root.get("schId"), schId), + cb.equal(root.get("status"), LeaveStatusEnum.PASSED.getId()), + cb.greaterThanOrEqualTo(root.get("applyTime"), start), + cb.lessThan(root.get("applyTime"), endBound)); + List list = leaveApplyRepository.findAll(spec); + + LeaveStatsVo vo = new LeaveStatsVo(); + vo.setTotalCount(list.size()); + double totalHours = list.stream().mapToDouble(l -> l.getTotalHours() == null ? 0D : l.getTotalHours()).sum(); + vo.setTotalHours(round2(totalHours)); + vo.setTotalDays(Math.round(totalHours / 8D * 10D) / 10D); + + // 按类型分布 + Map typeMap = new LinkedHashMap<>(); + for (LeaveApply leave : list) { + String key = leave.getLeaveType() == null ? "" : leave.getLeaveType(); + LeaveStatsVo.TypeStat stat = typeMap.computeIfAbsent(key, k -> { + LeaveStatsVo.TypeStat s = new LeaveStatsVo.TypeStat(); + s.setLeaveType(k); + s.setLeaveTypeName(resolveTypeName(leave)); + s.setCount(0); + s.setHours(0D); + return s; + }); + stat.setCount(stat.getCount() + 1); + stat.setHours(round2(stat.getHours() + (leave.getTotalHours() == null ? 0D : leave.getTotalHours()))); + } + List typeStats = new ArrayList<>(typeMap.values()); + typeStats.sort((a, b) -> Integer.compare(b.getCount(), a.getCount())); + vo.setTypeStats(typeStats); + vo.setTopTypeName(typeStats.isEmpty() ? null : typeStats.get(0).getLeaveTypeName()); + + // 按月份趋势(申请时间的年月) + Map monthMap = new TreeMap<>(); + YearMonth cursorMonth = YearMonth.from(start); + YearMonth endMonth = YearMonth.from(end.minusSeconds(1)); + while (!cursorMonth.isAfter(endMonth)) { + LeaveStatsVo.MonthStat stat = new LeaveStatsVo.MonthStat(); + stat.setMonth(cursorMonth.toString()); + stat.setCount(0); + stat.setHours(0D); + monthMap.put(cursorMonth, stat); + cursorMonth = cursorMonth.plusMonths(1); + } + for (LeaveApply leave : list) { + if (leave.getApplyTime() == null) { + continue; + } + LeaveStatsVo.MonthStat stat = monthMap.get(YearMonth.from(leave.getApplyTime())); + if (stat != null) { + stat.setCount(stat.getCount() + 1); + stat.setHours(round2(stat.getHours() + (leave.getTotalHours() == null ? 0D : leave.getTotalHours()))); + } + } + vo.setMonthStats(new ArrayList<>(monthMap.values())); + return vo; + } + + // ============================ 内部方法 ============================ + + /** + * 时长取值:前端时长输入框已开放手工编辑(默认自动试算值), + * 请求带了有效 totalHours(>0)时以手工值为准,否则沿用按作息试算值。统一保留 2 位小数。 + */ + private double resolveHours(double calculated, Double requested) { + double hours = (requested != null && requested > 0) ? requested : calculated; + return Math.round(hours * 100D) / 100D; + } + + /** 提交/编辑的公共前置校验:事由必填 + 时间冲突 + 时长余额 */ + private void validateBeforeSave(LeaveApply leave) { + LeaveSetting setting = settingRepository.findBySchId(leave.getSchId()).orElse(null); + boolean reasonMust = setting == null || !Integer.valueOf(0).equals(setting.getReasonMust()); + if (reasonMust && !StringUtils.hasText(leave.getReason())) { + throw new BizException(ErrorCode.PARAM_ERROR, "请填写请假事由!"); + } + if (leave.getReason() != null && leave.getReason().length() > 50) { + throw new BizException(ErrorCode.PARAM_ERROR, "请假事由不能超过 50 字!"); + } + + // 最少请假时间(分钟;0 不限制),对应历史「学校时间设置-最少请假时间」 + LeaveTimeSet timeSet = requireTimeSet(leave.getSchId()); + int minMinutes = timeSet.getMinMinutes() == null ? 0 : timeSet.getMinMinutes(); + if (minMinutes > 0 && (leave.getTotalHours() == null || leave.getTotalHours() * 60 < minMinutes)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请假时长不能少于 " + minMinutes + " 分钟!"); + } + + // 冲突校验:同人、状态 ∈ {审批中, 已通过} + List existing = leaveApplyRepository.findBySchIdAndUserIdAndStatusIn( + leave.getSchId(), leave.getUserId(), + Arrays.asList(LeaveStatusEnum.APPROVING.getId(), LeaveStatusEnum.PASSED.getId())); + for (LeaveApply other : existing) { + if (leave.getId() != null && leave.getId().equals(other.getId())) { + continue; + } + if (timeCalculator.overlap(leave.getStartTime(), leave.getEndTime(), + other.getStartTime(), other.getEndTime())) { + throw new BizException(ErrorCode.FORBIDDEN, + other.getStartTime() + " ~ " + other.getEndTime() + " 请假时间冲突!"); + } + } + + // 时长余额(管控类型) + userTimeService.checkEnough(leave.getSchId(), leave.getUserId(), leave.getLeaveType(), + leave.getTotalHours() == null ? 0D : leave.getTotalHours()); + } + + /** 发起流程并回写实例信息 */ + private void startFlow(LeaveApply leave, int attempt) { + // 流程选用优先级:单据已定(重提沿用) → 按请假类型绑定 → 学校默认绑定 → 配置 leave.flow.guid + String flowGuid = StringUtils.hasText(leave.getFlowGuid()) ? leave.getFlowGuid() + : flowBindService.resolveFlowGuid(leave.getSchId(), leave.getLeaveType()); + leave.setFlowGuid(flowGuid); + String bizId = leave.getId() + "-" + attempt; + String title = leave.getUserName() + "的" + (leave.getLeaveTypeName() == null ? "请假" : leave.getLeaveTypeName()) + "申请"; + + flowService.ensureRegistered(leave.getSchId(), flowGuid); + FlowApplyResult result = flowService.apply(leave, bizId, title); + if (result == null || result.getInstanceId() == null) { + // 门面层已有同样校验;此处再守住服务契约,便于 mock 单测及后续替换实现。 + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "发起审批流程失败:流程中心未返回流程实例ID"); + } + leave.setBizId(bizId); + leave.setInstanceId(result.getInstanceId()); + leave.setInstanceVersion(0); + } + + /** 请求字段 → 实体 */ + private void applyRequest(LeaveApply leave, LeaveRequest request) { + leave.setLeaveType(request.getLeaveType() == null ? null : request.getLeaveType().trim()); + leave.setStartTime(normalizeTime(request.getStartTime())); + leave.setEndTime(normalizeTime(request.getEndTime())); + leave.setReason(request.getReason() == null ? null : request.getReason().trim()); + leave.setFileIds(normalizeCsv(request.getFileIds())); + leave.setCcUserIds(normalizeCsv(request.getCcUserIds())); + // 课务安排:不传视为「无课务安排」(与旧版默认一致) + leave.setCourseArrange(request.getCourseArrange() == null ? 0 : request.getCourseArrange()); + // 审批流程:申请页下拉选择(可空);为空时保留原值(新建则由 startFlow 按绑定解析) + if (StringUtils.hasText(request.getFlowGuid())) { + leave.setFlowGuid(request.getFlowGuid().trim()); + } + } + + /** 课务安排名称:0 无课务安排 / 1 自行安排 / 2 教务安排 */ + private String courseArrangeName(Integer arrange) { + if (arrange == null) return "无课务安排"; + switch (arrange) { + case 1: return "自行安排"; + case 2: return "教务安排"; + default: return "无课务安排"; + } + } + + private void saveSelectedCc(LeaveApply leave) { + if (!StringUtils.hasText(leave.getCcUserIds())) { + return; + } + for (String part : leave.getCcUserIds().split(",")) { + Integer ccUserId = parseInteger(part); + if (ccUserId == null) { + continue; + } + if (leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(leave.getId(), ccUserId, leave.getInstanceId())) { + continue; + } + LeaveCc cc = new LeaveCc(); + cc.setSchId(leave.getSchId()); + cc.setLeaveId(leave.getId()); + cc.setCcUserId(ccUserId); + cc.setInstanceId(leave.getInstanceId()); + leaveCcRepository.save(cc); + } + } + + private List loadFiles(LeaveApply leave) { + if (!StringUtils.hasText(leave.getFileIds())) { + return Collections.emptyList(); + } + List fileIds = Arrays.stream(leave.getFileIds().split(",")) + .map(this::parseInteger).filter(Objects::nonNull).collect(Collectors.toList()); + if (fileIds.isEmpty()) { + return Collections.emptyList(); + } + List files = upFileRepository.findAllById(fileIds); + List items = new ArrayList<>(); + for (UpFile file : files) { + LeaveDetailVo.FileItem item = new LeaveDetailVo.FileItem(); + item.setFileId(file.getId()); + item.setFileName(StringUtils.hasText(file.getFileTrueName()) ? file.getFileTrueName() : file.getFileName()); + item.setFileUrl(file.getFileName()); + item.setFileSize(file.getFileSize()); + item.setFileType(file.getFileType()); + items.add(item); + } + return items; + } + + private List loadCcUsers(LeaveApply leave) { + Map merged = new LinkedHashMap<>(); + if (StringUtils.hasText(leave.getCcUserIds())) { + for (String part : leave.getCcUserIds().split(",")) { + Integer id = parseInteger(part); + if (id != null) { + merged.putIfAbsent(id, null); + } + } + } + for (LeaveCc cc : leaveCcRepository.findBySchIdAndLeaveId(leave.getSchId(), leave.getId())) { + if (cc.getCcUserId() != null) { + if (!merged.containsKey(cc.getCcUserId()) || merged.get(cc.getCcUserId()) == null) { + merged.put(cc.getCcUserId(), cc.getCcUserName()); + } + } + } + List items = new ArrayList<>(); + for (Map.Entry entry : merged.entrySet()) { + LeaveDetailVo.CcItem item = new LeaveDetailVo.CcItem(); + item.setUserId(entry.getKey()); + item.setUserName(entry.getValue()); + items.add(item); + } + return items; + } + + private LeaveApply requireLeave(Integer schId, Integer leaveId) { + if (leaveId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + return leaveApplyRepository.findByIdAndSchId(leaveId, schId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "请假记录不存在")); + } + + private LeaveTimeSet requireTimeSet(Integer schId) { + return timeSetRepository.findBySchId(schId).orElseGet(() -> { + LeaveTimeSet entity = new LeaveTimeSet(); + entity.setSchId(schId); + return timeSetRepository.save(entity); + }); + } + + private LeaveVo toVo(LeaveApply leave, Integer currentUserId, LocalDateTime ccTime) { + LeaveVo vo = new LeaveVo(); + vo.setId(leave.getId()); + vo.setUserId(leave.getUserId()); + vo.setUserName(leave.getUserName()); + vo.setUserPhoto(leave.getUserPhoto()); + vo.setLeaveType(leave.getLeaveType()); + vo.setLeaveTypeName(resolveTypeName(leave)); + vo.setStartTime(leave.getStartTime()); + vo.setEndTime(leave.getEndTime()); + vo.setTotalHours(leave.getTotalHours()); + vo.setReason(leave.getReason()); + vo.setCourseArrange(leave.getCourseArrange()); + vo.setCourseArrangeName(courseArrangeName(leave.getCourseArrange())); + vo.setFileIds(leave.getFileIds()); + vo.setStatus(leave.getStatus()); + vo.setStatusName(LeaveStatusEnum.nameById(leave.getStatus())); + vo.setApplyTime(leave.getApplyTime()); + vo.setInstanceId(leave.getInstanceId()); + vo.setCcTime(ccTime); + + boolean owner = currentUserId != null && currentUserId.equals(leave.getUserId()); + vo.setCanEdit(owner && LeaveStatusEnum.editable(leave.getStatus())); + vo.setCanCancel(owner && LeaveStatusEnum.PASSED.getId().equals(leave.getStatus())); + vo.setCanWithdraw(owner && LeaveStatusEnum.APPROVING.getId().equals(leave.getStatus())); + vo.setCanDelete(owner && LeaveStatusEnum.deletable(leave.getStatus()) + && !LeaveStatusEnum.CANCELED.getId().equals(leave.getStatus())); + return vo; + } + + private String resolveTypeName(LeaveApply leave) { + if (StringUtils.hasText(leave.getLeaveTypeName())) { + return leave.getLeaveTypeName(); + } + String name = dictService.resolveTypeName(leave.getLeaveType()); + return name == null ? leave.getLeaveType() : name; + } + + /** 兜底显示名:用户中心不可用时用 "用户{id}",不落账号 */ + private String resolveSelfName(Integer userId) { + return userId == null ? "" : "用户" + userId; + } + + private Integer resolveLeaveIdByTask(Integer schId, Integer operatorId, Long taskId) { + // 审核后按 taskId 反查单据:从该操作人的待办缓存里命中(任务刚办结仍可能在返回列表中) + try { + FlowTodoTaskPage page = flowService.todoTasks(schId, operatorId, 1, 200, "leave"); + for (FlowTodoTask task : flowService.contentOf(page)) { + if (taskId.equals(task.getTaskId())) { + Integer leaveId = parseLeaveId(task.getBizId()); + if (leaveId != null) { + return leaveId; + } + } + } + } catch (Exception e) { + log.warn("[leave.audit] 反查单据失败(留痕跳过): taskId={}, err={}", taskId, e.getMessage()); + } + return null; + } + + private Integer parseLeaveId(String bizId) { + if (!StringUtils.hasText(bizId)) { + return null; + } + return parseInteger(bizId.trim().split("-")[0]); + } + + private Integer parseInteger(String value) { + try { + return Integer.parseInt(value.trim()); + } catch (Exception e) { + return null; + } + } + + private String normalizeTime(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + String v = value.trim().replace('T', ' '); + if (v.length() == 16) { + return v + ":00"; + } + return v; + } + + private String normalizeCsv(String csv) { + if (!StringUtils.hasText(csv)) { + return null; + } + String normalized = Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .distinct() + .collect(Collectors.joining(",")); + return normalized.isEmpty() ? null : normalized; + } + + /** 开始边界:yyyy-MM-dd → 当日 00:00:00;yyyy-MM-dd HH:mm → 原值 */ + private String dayStart(String value) { + String v = value.trim(); + return v.length() == 10 ? v + " 00:00:00" : normalizeTime(v); + } + + /** 结束边界:yyyy-MM-dd → 当日 23:59:59 */ + private String dayEnd(String value) { + String v = value.trim(); + return v.length() == 10 ? v + " 23:59:59" : normalizeTime(v); + } + + /** 请假区间与查询区间重叠:查询开始 <= 单据结束 && 单据开始 <= 查询结束(与列表页口径一致) */ + private boolean inDateRange(LeaveApply leave, String startTime, String endTime) { + if (leave.getStartTime() == null || leave.getEndTime() == null) { + return false; + } + if (StringUtils.hasText(startTime) && leave.getEndTime().compareTo(dayStart(startTime)) < 0) { + return false; + } + return !StringUtils.hasText(endTime) || leave.getStartTime().compareTo(dayEnd(endTime)) <= 0; + } + + private LocalDateTime parseDateBoundary(String value, LocalDateTime defaultValue) { + if (!StringUtils.hasText(value)) { + return defaultValue; + } + String v = value.trim().replace('T', ' '); + try { + if (v.length() == 10) { + return LocalDate.parse(v).atStartOfDay(); + } + if (v.length() == 16) { + return LocalDateTime.of(LocalDate.parse(v.substring(0, 10)), + LocalTime.parse(v.substring(11, 16))); + } + return LocalDateTime.parse(normalizeTime(v), DATE_TIME); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "时间格式错误(yyyy-MM-dd)"); + } + } + + private static double round2(double value) { + return Math.round(value * 100D) / 100D; + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/LeaveFlowBindService.java b/src/main/java/com/yunhai/workleave/service/impl/LeaveFlowBindService.java new file mode 100644 index 0000000..dc34d76 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/LeaveFlowBindService.java @@ -0,0 +1,444 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.flow.model.FlowSummary; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.workleave.entity.LeaveFlowBind; +import com.yunhai.workleave.entity.LeaveFlowEnable; +import com.yunhai.workleave.flow.ILeaveFlowService; +import com.yunhai.workleave.flow.LeaveFlowService; +import com.yunhai.workleave.model.leave.FlowBindRequest; +import com.yunhai.workleave.model.leave.FlowBindVo; +import com.yunhai.workleave.model.leave.FlowEnableRequest; +import com.yunhai.workleave.model.leave.FlowItemVo; +import com.yunhai.workleave.model.leave.FlowManageVo; +import com.yunhai.workleave.model.leave.LeaveTypeOptionVo; +import com.yunhai.workleave.repository.LeaveFlowBindRepository; +import com.yunhai.workleave.repository.LeaveFlowEnableRepository; +import com.yunhai.workleave.service.IDictService; +import com.yunhai.workleave.service.ILeaveFlowBindService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +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; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 审批流程绑定服务实现。 + *

数据源两侧:流程中心({@link ILeaveFlowService#listFlows})提供可选流程, + * 本地 {@code leave_flow_bind} 表记录本校的绑定结果。

+ *

流程中心不可达时降级:流程列表为空、{@code centerReachable=false}, + * 已绑定关系仍照常展示并可解除——避免平台抖动让管理页整体不可用。

+ */ +@Service +public class LeaveFlowBindService implements ILeaveFlowBindService { + + private static final Logger log = LoggerFactory.getLogger(LeaveFlowBindService.class); + + /** 默认流程行的 scopeKey 占位(与实体注释一致) */ + private static final String DEFAULT_SCOPE_KEY = "-"; + + @Autowired + private LeaveFlowBindRepository bindRepository; + + @Autowired + private LeaveFlowEnableRepository enableRepository; + + @Autowired + private ILeaveFlowService flowService; + + @Autowired + private IDictService dictService; + + // ============================ 查询 ============================ + + @Override + @Transactional + public FlowManageVo manage(Integer schId) { + FlowManageVo vo = new FlowManageVo(); + vo.setBizType(LeaveFlowService.BIZ_TYPE); + vo.setConfigFlowGuid(flowService.defaultFlowGuid()); + + // 1) 流程中心列表(不可达时降级,不影响页面其余部分) + List summaries; + try { + summaries = flowService.listFlows(schId); + } catch (Exception e) { + log.warn("[flow-bind] 流程中心不可达,降级为只读绑定关系: schId={}, err={}", schId, e.getMessage()); + summaries = Collections.emptyList(); + vo.setCenterReachable(false); + vo.setCenterError(firstLine(e.getMessage())); + } + + // 2) 本校绑定关系 + 启用绑定记录(自愈:存量绑定的流程若无启用记录则补一条, + // 保证「已绑定 ⇒ 已启用」的不变式,老数据无需手工迁移) + List binds = bindRepository.findBySchId(schId); + Map typeNames = leaveTypeNames(); + Map enableMap = new LinkedHashMap<>(); + for (LeaveFlowEnable en : enableRepository.findBySchId(schId)) { + if (StringUtils.hasText(en.getFlowGuid())) { + enableMap.put(en.getFlowGuid(), en); + } + } + for (LeaveFlowBind b : binds) { + if (StringUtils.hasText(b.getFlowGuid()) && !enableMap.containsKey(b.getFlowGuid())) { + LeaveFlowEnable backfill = new LeaveFlowEnable(); + backfill.setSchId(schId); + backfill.setFlowGuid(b.getFlowGuid()); + backfill.setFlowName(b.getFlowName()); + backfill.setFlowVersion(b.getFlowVersion()); + backfill.setNodeSummary(b.getNodeSummary()); + enableRepository.save(backfill); + enableMap.put(b.getFlowGuid(), backfill); + log.info("[flow-bind] 启用绑定自愈回填: schId={}, flowGuid={}", schId, b.getFlowGuid()); + } + } + + // 3) 流程列表 + 绑定标记 + Map flowIndex = new LinkedHashMap<>(); + for (FlowSummary s : summaries) { + if (s == null || !StringUtils.hasText(s.getFlowGuid())) { + continue; + } + FlowItemVo item = new FlowItemVo(); + item.setFlowGuid(s.getFlowGuid()); + item.setName(s.getName()); + item.setVersion(s.getVersion()); + item.setNodeSummary(s.getNodeSummary()); + item.setEnabled(enableMap.containsKey(s.getFlowGuid())); + flowIndex.put(s.getFlowGuid(), item); + } + for (LeaveFlowBind b : binds) { + if (b.getBindScope() != null && LeaveFlowBind.SCOPE_TYPE.equals(b.getBindScope())) { + FlowItemVo hit = flowIndex.get(b.getFlowGuid()); + if (hit != null) { + hit.getTypeKeys().add(b.getScopeKey()); + hit.getTypeNames().add(typeNames.getOrDefault(b.getScopeKey(), b.getScopeKey())); + } + } + if (LeaveFlowBind.SCOPE_DEFAULT.equals(b.getBindScope())) { + FlowItemVo hit = flowIndex.get(b.getFlowGuid()); + if (hit != null) { + hit.setDefaultBind(true); + } + } + } + // 流程中心「真实存在」的 flowGuid 快照——必须在补入幽灵条目之前取, + // 否则幽灵条目会被误判为「流程中心仍存在」。 + final java.util.Set centerGuids = + vo.isCenterReachable() ? new java.util.LinkedHashSet<>(flowIndex.keySet()) : null; + + // 已绑定但流程中心已下线的流程也要露出,避免「绑了但列表里找不到,以为丢了」 + for (LeaveFlowBind b : binds) { + if (StringUtils.hasText(b.getFlowGuid()) && !flowIndex.containsKey(b.getFlowGuid())) { + FlowItemVo ghost = new FlowItemVo(); + ghost.setFlowGuid(b.getFlowGuid()); + ghost.setName(b.getFlowName() == null ? b.getFlowGuid() : b.getFlowName()); + ghost.setVersion(b.getFlowVersion()); + ghost.setNodeSummary(b.getNodeSummary()); + ghost.setDefaultBind(LeaveFlowBind.SCOPE_DEFAULT.equals(b.getBindScope())); + // 下线流程同样回填按类型绑定标记,保证列表「绑定类型」列完整 + for (LeaveFlowBind tb : binds) { + if (LeaveFlowBind.SCOPE_TYPE.equals(tb.getBindScope()) + && b.getFlowGuid().equals(tb.getFlowGuid()) && StringUtils.hasText(tb.getScopeKey())) { + ghost.getTypeKeys().add(tb.getScopeKey()); + ghost.getTypeNames().add(typeNames.getOrDefault(tb.getScopeKey(), tb.getScopeKey())); + } + } + flowIndex.put(b.getFlowGuid(), ghost); + } + } + vo.setFlows(new ArrayList<>(flowIndex.values())); + + // 4) 绑定关系视图(默认流程排最前,其余按请假类型顺序) + List bindVos = binds.stream() + .sorted((a, b) -> { + int sa = LeaveFlowBind.SCOPE_DEFAULT.equals(a.getBindScope()) ? 0 : 1; + int sb = LeaveFlowBind.SCOPE_DEFAULT.equals(b.getBindScope()) ? 0 : 1; + return sa != sb ? sa - sb : a.getId() - b.getId(); + }) + .map(b -> toBindVo(b, typeNames, centerGuids)) + .collect(Collectors.toList()); + vo.setBinds(bindVos); + + // 5) 请假类型选项 + List types = new ArrayList<>(); + typeNames.forEach((k, v) -> types.add(new LeaveTypeOptionVo(k, v))); + vo.setLeaveTypes(types); + return vo; + } + + // ============================ 绑定 / 解绑 ============================ + + @Override + @Transactional + public void bind(Integer schId, FlowBindRequest request) { + if (request == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + Integer scope = request.getScope(); + if (scope == null || (!LeaveFlowBind.SCOPE_DEFAULT.equals(scope) && !LeaveFlowBind.SCOPE_TYPE.equals(scope))) { + throw new BizException(ErrorCode.PARAM_ERROR, "绑定范围错误"); + } + String flowGuid = request.getFlowGuid() == null ? null : request.getFlowGuid().trim(); + if (!StringUtils.hasText(flowGuid)) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程标识不能为空"); + } + // 启用绑定门禁:未启用的流程仅展示,不可被设为默认 / 按类型绑定 + if (!enableRepository.findBySchIdAndFlowGuid(schId, flowGuid).isPresent()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该流程未启用绑定,请先在流程列表中开启「启用绑定」开关"); + } + List scopeKeys; + if (LeaveFlowBind.SCOPE_TYPE.equals(scope)) { + scopeKeys = normalizeScopeKeys(request); + if (scopeKeys.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请先选择请假类型"); + } + } else { + scopeKeys = Collections.singletonList(DEFAULT_SCOPE_KEY); + } + + // 名称/版本/节点摘要优先取流程中心实时值,前端传值仅作兜底 + FlowSummary matched = findInCenter(schId, flowGuid); + if (matched == null && request.getFlowName() == null && !hasLocal(schId, flowGuid)) { + // 流程中心里查不到且本地也没记过:多半是手填了一个不存在的标识 + log.warn("[flow-bind] 绑定了流程中心不存在的 flowGuid: schId={}, flowGuid={}", schId, flowGuid); + } + + for (String scopeKey : scopeKeys) { + LeaveFlowBind entity = bindRepository + .findBySchIdAndBindScopeAndScopeKey(schId, scope, scopeKey) + .orElseGet(() -> { + LeaveFlowBind created = new LeaveFlowBind(); + created.setSchId(schId); + created.setBindScope(scope); + created.setScopeKey(scopeKey); + return created; + }); + entity.setFlowGuid(flowGuid); + if (matched != null) { + entity.setFlowName(matched.getName()); + entity.setFlowVersion(matched.getVersion()); + entity.setNodeSummary(matched.getNodeSummary()); + } else { + if (StringUtils.hasText(request.getFlowName())) { + entity.setFlowName(request.getFlowName().trim()); + } else if (!StringUtils.hasText(entity.getFlowName())) { + entity.setFlowName(flowGuid); + } + if (request.getFlowVersion() != null) { + entity.setFlowVersion(request.getFlowVersion()); + } + if (StringUtils.hasText(request.getNodeSummary())) { + entity.setNodeSummary(request.getNodeSummary().trim()); + } + } + bindRepository.save(entity); + } + + // 手册 §2.4:首次发起前完成回调登记;登记失败不影响绑定落库(同一 flowGuid 登记一次即可) + try { + flowService.ensureRegistered(schId, flowGuid); + } catch (Exception e) { + log.warn("[flow-bind] 回调登记异常(绑定已保存): schId={}, flowGuid={}, err={}", schId, flowGuid, e.getMessage()); + } + log.info("[flow-bind] 流程绑定成功: schId={}, scope={}, scopeKeys={}, flowGuid={}", schId, scope, scopeKeys, flowGuid); + } + + @Override + @Transactional + public void enable(Integer schId, FlowEnableRequest request) { + if (request == null || !StringUtils.hasText(request.getFlowGuid())) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程标识不能为空"); + } + String flowGuid = request.getFlowGuid().trim(); + LeaveFlowEnable entity = enableRepository + .findBySchIdAndFlowGuid(schId, flowGuid) + .orElseGet(() -> { + LeaveFlowEnable created = new LeaveFlowEnable(); + created.setSchId(schId); + created.setFlowGuid(flowGuid); + return created; + }); + // 名称/版本/节点摘要优先取流程中心实时值;中心不可达时用入参/存量兜底(启用不受中心可用性阻塞) + FlowSummary matched = findInCenter(schId, flowGuid); + if (matched != null) { + entity.setFlowName(matched.getName()); + entity.setFlowVersion(matched.getVersion()); + entity.setNodeSummary(matched.getNodeSummary()); + } else { + if (!StringUtils.hasText(entity.getFlowName()) && StringUtils.hasText(request.getFlowName())) { + entity.setFlowName(request.getFlowName().trim()); + } + if (entity.getFlowVersion() == null && request.getFlowVersion() != null) { + entity.setFlowVersion(request.getFlowVersion()); + } + if (!StringUtils.hasText(entity.getNodeSummary()) && StringUtils.hasText(request.getNodeSummary())) { + entity.setNodeSummary(request.getNodeSummary().trim()); + } + if (!StringUtils.hasText(entity.getFlowName())) { + entity.setFlowName(flowGuid); + } + } + enableRepository.save(entity); + + // 启用即完成回调登记(手册 §2.4);登记失败不影响启用落库 + try { + flowService.ensureRegistered(schId, flowGuid); + } catch (Exception e) { + log.warn("[flow-bind] 启用绑定的回调登记异常: schId={}, flowGuid={}, err={}", schId, flowGuid, e.getMessage()); + } + log.info("[flow-bind] 流程启用绑定: schId={}, flowGuid={}", schId, flowGuid); + } + + @Override + @Transactional + public void disable(Integer schId, String flowGuid) { + if (!StringUtils.hasText(flowGuid)) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程标识不能为空"); + } + String guid = flowGuid.trim(); + enableRepository.findBySchIdAndFlowGuid(schId, guid).ifPresent(enableRepository::delete); + // 已有的默认/按类型绑定一并解除(「未启用 ⇒ 不被本应用使用」语义保持一致) + List related = bindRepository.findBySchIdAndFlowGuid(schId, guid); + if (!related.isEmpty()) { + bindRepository.deleteAll(related); + } + log.info("[flow-bind] 关闭流程启用绑定: schId={}, flowGuid={}, 解除绑定 {} 条", schId, guid, related.size()); + } + + @Override + @Transactional + public void unbind(Integer schId, Integer scope, String scopeKey) { + if (scope == null || (!LeaveFlowBind.SCOPE_DEFAULT.equals(scope) && !LeaveFlowBind.SCOPE_TYPE.equals(scope))) { + throw new BizException(ErrorCode.PARAM_ERROR, "绑定范围错误"); + } + String key = LeaveFlowBind.SCOPE_TYPE.equals(scope) + ? (StringUtils.hasText(scopeKey) ? scopeKey.trim() : null) + : DEFAULT_SCOPE_KEY; + if (key == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "请先选择请假类型"); + } + Optional hit = bindRepository.findBySchIdAndBindScopeAndScopeKey(schId, scope, key); + if (!hit.isPresent()) { + // 幂等:重复解绑不报错(前端可能出现连点) + return; + } + bindRepository.delete(hit.get()); + log.info("[flow-bind] 解除流程绑定: schId={}, scope={}, scopeKey={}", schId, scope, key); + } + + // ============================ 发起时解析 ============================ + + @Override + @Transactional(readOnly = true) + public String resolveFlowGuid(Integer schId, String leaveType) { + if (StringUtils.hasText(leaveType)) { + Optional byType = bindRepository + .findBySchIdAndBindScopeAndScopeKey(schId, LeaveFlowBind.SCOPE_TYPE, leaveType.trim()); + if (byType.isPresent() && StringUtils.hasText(byType.get().getFlowGuid())) { + return byType.get().getFlowGuid(); + } + } + Optional byDefault = bindRepository + .findBySchIdAndBindScopeAndScopeKey(schId, LeaveFlowBind.SCOPE_DEFAULT, DEFAULT_SCOPE_KEY); + if (byDefault.isPresent() && StringUtils.hasText(byDefault.get().getFlowGuid())) { + return byDefault.get().getFlowGuid(); + } + return flowService.defaultFlowGuid(); + } + + // ============================ 内部方法 ============================ + + /** 按类型绑定的范围键:优先 scopeKeys 列表(去空白/去重),兼容旧单选 scopeKey */ + private List normalizeScopeKeys(FlowBindRequest request) { + List keys = new ArrayList<>(); + if (request.getScopeKeys() != null) { + for (String k : request.getScopeKeys()) { + if (!StringUtils.hasText(k)) { + continue; + } + String trimmed = k.trim(); + if (!keys.contains(trimmed)) { + keys.add(trimmed); + } + } + } + if (keys.isEmpty() && StringUtils.hasText(request.getScopeKey())) { + keys.add(request.getScopeKey().trim()); + } + return keys; + } + + private FlowBindVo toBindVo(LeaveFlowBind b, Map typeNames, java.util.Set centerGuids) { + FlowBindVo vo = new FlowBindVo(); + vo.setId(b.getId()); + vo.setScope(b.getBindScope()); + vo.setScopeKey(b.getScopeKey()); + vo.setScopeName(LeaveFlowBind.SCOPE_DEFAULT.equals(b.getBindScope()) + ? "全校默认" : typeNames.getOrDefault(b.getScopeKey(), b.getScopeKey())); + vo.setFlowGuid(b.getFlowGuid()); + vo.setFlowName(b.getFlowName()); + vo.setFlowVersion(b.getFlowVersion()); + vo.setNodeSummary(b.getNodeSummary()); + vo.setExistsInCenter(centerGuids == null ? null : centerGuids.contains(b.getFlowGuid())); + return vo; + } + + /** 请假类型 key → 名称(保持字典顺序);禁用项也列出,便于发现残留绑定 */ + private Map leaveTypeNames() { + Map map = new LinkedHashMap<>(); + try { + List> list = dictService.getDictMangeList("leaveType"); + if (list != null) { + for (Map item : list) { + Object k = item.get("CodeID"); + Object v = item.get("Name"); + if (k != null) { + map.put(String.valueOf(k), v == null ? String.valueOf(k) : String.valueOf(v)); + } + } + } + } catch (Exception e) { + log.warn("[flow-bind] 请假类型字典读取失败(按类型绑定将不可选): err={}", e.getMessage()); + } + return map; + } + + /** 在流程中心列表里定位指定流程;不可达返回 null(不抛,绑定仍要能保存) */ + private FlowSummary findInCenter(Integer schId, String flowGuid) { + try { + return flowService.listFlows(schId).stream() + .filter(s -> s != null && flowGuid.equals(s.getFlowGuid())) + .findFirst().orElse(null); + } catch (Exception e) { + log.warn("[flow-bind] 绑定时流程中心不可达,按入参落库: schId={}, flowGuid={}", schId, flowGuid); + return null; + } + } + + /** 本地是否已存在该 flowGuid 的绑定(用于判断是否「凭空手填」) */ + private boolean hasLocal(Integer schId, String flowGuid) { + return bindRepository.findBySchId(schId).stream() + .anyMatch(b -> flowGuid.equals(b.getFlowGuid())); + } + + /** 异常摘要只取首行,避免把整段堆栈回显给前端 */ + private String firstLine(String msg) { + if (!StringUtils.hasText(msg)) { + return null; + } + int nl = msg.indexOf('\n'); + String line = nl > 0 ? msg.substring(0, nl) : msg; + return line.length() > 200 ? line.substring(0, 200) : line; + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/LeaveSettingService.java b/src/main/java/com/yunhai/workleave/service/impl/LeaveSettingService.java new file mode 100644 index 0000000..2fe1f2e --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/LeaveSettingService.java @@ -0,0 +1,318 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.entity.LeaveSection; +import com.yunhai.workleave.entity.LeaveSetting; +import com.yunhai.workleave.entity.LeaveTimeSet; +import com.yunhai.workleave.model.leave.LeaveSettingVo; +import com.yunhai.workleave.model.leave.SectionVo; +import com.yunhai.workleave.model.leave.TimeSetVo; +import com.yunhai.workleave.repository.LeaveSectionRepository; +import com.yunhai.workleave.repository.LeaveSettingRepository; +import com.yunhai.workleave.repository.LeaveTimeSetRepository; +import com.yunhai.workleave.service.ILeaveSettingService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 请假设置域服务实现。 + *

作息时间影响时长计算与前端校验,保存前做顺序校验(F1<F2<A1<A2); + * 节次缺省按历史口径(8 节,8:00-8:45 … 16:45-17:30)种子初始化。

+ */ +@Service +public class LeaveSettingService implements ILeaveSettingService { + + private static final Logger log = LoggerFactory.getLogger(LeaveSettingService.class); + + private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm"); + private static final Pattern TIME_RE = Pattern.compile("^([01]?\\d|2[0-3]):[0-5]\\d$"); + + /** 节次默认种子(历史 Setting_CourseSection 口径,起止成对) */ + private static final String[][] DEFAULT_SECTIONS = { + {"第一节", "08:00", "08:45"}, + {"第二节", "08:55", "09:40"}, + {"第三节", "10:00", "10:45"}, + {"第四节", "10:55", "11:40"}, + {"第五节", "14:00", "14:45"}, + {"第六节", "14:55", "15:40"}, + {"第七节", "15:50", "16:35"}, + {"第八节", "16:45", "17:30"}, + }; + + @Autowired + private LeaveTimeSetRepository timeSetRepository; + + @Autowired + private LeaveSettingRepository settingRepository; + + @Autowired + private LeaveSectionRepository sectionRepository; + + // ============================ 作息时间 ============================ + + @Override + @Transactional + public TimeSetVo getTimeSet(Integer schId) { + return toVo(requireTimeSet(schId)); + } + + @Override + @Transactional + public void saveTimeSet(Integer schId, TimeSetVo vo) { + if (vo == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + validateTime(vo.getForenoon1(), "上午开始时间"); + validateTime(vo.getForenoon2(), "上午结束时间"); + validateTime(vo.getAfternoon1(), "下午开始时间"); + validateTime(vo.getAfternoon2(), "下午结束时间"); + + LocalTime f1 = LocalTime.parse(vo.getForenoon1(), HHMM); + LocalTime f2 = LocalTime.parse(vo.getForenoon2(), HHMM); + LocalTime a1 = LocalTime.parse(vo.getAfternoon1(), HHMM); + LocalTime a2 = LocalTime.parse(vo.getAfternoon2(), HHMM); + if (!f1.isBefore(f2)) { + throw new BizException(ErrorCode.PARAM_ERROR, "上午结束时间必须晚于上午开始时间!"); + } + if (!f2.isBefore(a1)) { + throw new BizException(ErrorCode.PARAM_ERROR, "下午开始时间必须晚于上午结束时间!"); + } + if (!a1.isBefore(a2)) { + throw new BizException(ErrorCode.PARAM_ERROR, "下午结束时间必须晚于下午开始时间!"); + } + + LeaveTimeSet entity = requireTimeSet(schId); + entity.setForenoon1(vo.getForenoon1()); + entity.setForenoon2(vo.getForenoon2()); + entity.setAfternoon1(vo.getAfternoon1()); + entity.setAfternoon2(vo.getAfternoon2()); + if (vo.getIntervalMinutes() != null && vo.getIntervalMinutes() > 0) { + entity.setIntervalMinutes(vo.getIntervalMinutes()); + } + // 最少请假时间(分钟):null 视为 0 不限制 + entity.setMinMinutes(vo.getMinMinutes() == null ? 0 : Math.max(0, vo.getMinMinutes())); + timeSetRepository.save(entity); + } + + // ============================ 其他设置 ============================ + + @Override + @Transactional + public LeaveSettingVo getOption(Integer schId) { + return toVo(requireSetting(schId)); + } + + @Override + @Transactional + public void saveOption(Integer schId, LeaveSettingVo vo) { + if (vo == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + LeaveSetting entity = requireSetting(schId); + // 0/1 字段:null 视为「不修改」,保持库中原值(前端全量回传时不会误清) + if (vo.getReasonMust() != null) { + entity.setReasonMust(bit(vo.getReasonMust(), 1)); + } + if (vo.getShowExplain() != null) { + entity.setShowExplain(bit(vo.getShowExplain(), 1)); + } + if (vo.getEnableCourse() != null) { + entity.setEnableCourse(bit(vo.getEnableCourse(), 0)); + } + if (vo.getAllowExchange() != null) { + entity.setAllowExchange(bit(vo.getAllowExchange(), 0)); + } + if (vo.getTimeLimitEnabled() != null) { + entity.setTimeLimitEnabled(bit(vo.getTimeLimitEnabled(), 0)); + } + // 前端清空时传 "";显式 null 也视为清空,避免管控类型残留在库 + entity.setTimeManagedTypes(vo.getTimeManagedTypes() == null + ? "" : normalizeCsv(vo.getTimeManagedTypes())); + // 请假计算口径:1 按小时 / 0 按天;null 视为默认按小时 + if (vo.getCalcWay() != null) { + entity.setCalcWay(vo.getCalcWay() == 0 ? 0 : 1); + } + settingRepository.save(entity); + } + + // ============================ 节次 ============================ + + @Override + @Transactional + public List sectionList(Integer schId) { + List list = sectionRepository.findBySchIdOrderByRankNoAsc(schId); + if (list.isEmpty()) { + list = seedSections(schId); + } + return list.stream().map(this::toVo).collect(Collectors.toList()); + } + + @Override + @Transactional + public void sectionSave(Integer schId, SectionVo vo) { + if (vo == null || !StringUtils.hasText(vo.getName())) { + throw new BizException(ErrorCode.PARAM_ERROR, "节次名称不得为空!"); + } + LeaveSection entity; + if (vo.getId() != null) { + entity = sectionRepository.findById(vo.getId()) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "节次不存在")); + if (!schId.equals(entity.getSchId())) { + throw new BizException(ErrorCode.FORBIDDEN, "节次不存在"); + } + } else { + entity = new LeaveSection(); + entity.setSchId(schId); + entity.setRankNo((int) sectionRepository.countBySchId(schId) + 1); + } + entity.setName(vo.getName().trim()); + if (StringUtils.hasText(vo.getBeginTime())) { + entity.setBeginTime(vo.getBeginTime()); + } + if (StringUtils.hasText(vo.getEndTime())) { + entity.setEndTime(vo.getEndTime()); + } + if (vo.getRankNo() != null) { + entity.setRankNo(vo.getRankNo()); + } + sectionRepository.save(entity); + } + + @Override + @Transactional + public void sectionDelete(Integer schId, Integer id) { + LeaveSection entity = sectionRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "节次不存在")); + if (!schId.equals(entity.getSchId())) { + throw new BizException(ErrorCode.FORBIDDEN, "节次不存在"); + } + sectionRepository.delete(entity); + } + + @Override + @Transactional + public void sectionSort(Integer schId, String ids) { + if (!StringUtils.hasText(ids)) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + int[] idArr = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .mapToInt(Integer::parseInt) + .toArray(); + int rank = 1; + for (int id : idArr) { + LeaveSection entity = sectionRepository.findById(id).orElse(null); + if (entity != null && schId.equals(entity.getSchId())) { + entity.setRankNo(rank++); + sectionRepository.save(entity); + } + } + } + + // ============================ 内部方法 ============================ + + private LeaveTimeSet requireTimeSet(Integer schId) { + return timeSetRepository.findBySchId(schId).orElseGet(() -> { + LeaveTimeSet entity = new LeaveTimeSet(); + entity.setSchId(schId); + return timeSetRepository.save(entity); + }); + } + + private LeaveSetting requireSetting(Integer schId) { + return settingRepository.findBySchId(schId).orElseGet(() -> { + LeaveSetting entity = new LeaveSetting(); + entity.setSchId(schId); + return settingRepository.save(entity); + }); + } + + private List seedSections(Integer schId) { + List seeds = new ArrayList<>(); + int rank = 1; + for (String[] row : DEFAULT_SECTIONS) { + LeaveSection entity = new LeaveSection(); + entity.setSchId(schId); + entity.setName(row[0]); + entity.setBeginTime(row[1]); + entity.setEndTime(row[2]); + entity.setRankNo(rank++); + seeds.add(entity); + } + List saved = sectionRepository.saveAll(seeds); + log.info("[sectionList] 学校 {} 节次缺失,已按默认 8 节初始化", schId); + return saved; + } + + private void validateTime(String value, String label) { + if (!StringUtils.hasText(value) || !TIME_RE.matcher(value.trim()).matches()) { + throw new BizException(ErrorCode.PARAM_ERROR, label + "格式错误(HH:mm)"); + } + } + + /** CSV 清洗:去空白项、去重保序 */ + private String normalizeCsv(String csv) { + return Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .distinct() + .collect(Collectors.joining(",")); + } + + private TimeSetVo toVo(LeaveTimeSet entity) { + TimeSetVo vo = new TimeSetVo(); + vo.setForenoon1(entity.getForenoon1()); + vo.setForenoon2(entity.getForenoon2()); + vo.setAfternoon1(entity.getAfternoon1()); + vo.setAfternoon2(entity.getAfternoon2()); + vo.setIntervalMinutes(entity.getIntervalMinutes()); + vo.setMinMinutes(entity.getMinMinutes() == null ? 0 : entity.getMinMinutes()); + return vo; + } + + private LeaveSettingVo toVo(LeaveSetting entity) { + LeaveSettingVo vo = new LeaveSettingVo(); + // 出参一律非 null:库里历史行可能存 NULL,前端 i-switch 用 ===1 判定, + // 若下发 null 会渲染成「关闭」且回传后仍不落库(不持久化的直接原因) + vo.setReasonMust(bit(entity.getReasonMust(), 1)); + vo.setShowExplain(bit(entity.getShowExplain(), 1)); + vo.setEnableCourse(bit(entity.getEnableCourse(), 0)); + vo.setAllowExchange(bit(entity.getAllowExchange(), 0)); + vo.setTimeManagedTypes(entity.getTimeManagedTypes() == null ? "" : entity.getTimeManagedTypes()); + vo.setTimeLimitEnabled(bit(entity.getTimeLimitEnabled(), 0)); + vo.setCalcWay(entity.getCalcWay() == null ? 1 : entity.getCalcWay()); + return vo; + } + + /** 0/1 归一:null 取默认值,其余折叠为 0/1 */ + private int bit(Integer value, int defaultValue) { + if (value == null) { + return defaultValue; + } + return value == 0 ? 0 : 1; + } + + private SectionVo toVo(LeaveSection entity) { + SectionVo vo = new SectionVo(); + vo.setId(entity.getId()); + vo.setName(entity.getName()); + vo.setRankNo(entity.getRankNo()); + vo.setBeginTime(entity.getBeginTime()); + vo.setEndTime(entity.getEndTime()); + return vo; + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/LeaveTimeCalculator.java b/src/main/java/com/yunhai/workleave/service/impl/LeaveTimeCalculator.java new file mode 100644 index 0000000..9ea6a8c --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/LeaveTimeCalculator.java @@ -0,0 +1,160 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; + +/** + * 请假时长计算器(小时制)。 + *

与前端 {@code utils/leaveTime.ts} 同一套规则:按学校作息(上午 F1-F2 / 下午 A1-A2) + * 计算有效工作时长;同日剔除午休,跨天首末按实际、中间整天按 8 小时。

+ *

历史算法见 C# 版 leaveTime(前端 JS);本类为服务端权威实现, + * 提交时按本类复核并落库(前端试算仅用于即时提示)。

+ */ +@Component +public class LeaveTimeCalculator { + + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** 跨天中间整天的小时数(由作息推导,默认 8:00-12:00 + 13:00-17:00 = 8h) */ + private static final double FULL_DAY_HOURS = 8D; + + /** + * 计算时长(小时,保留 2 位)。 + * + * @param start 开始时间 yyyy-MM-dd HH:mm + * @param end 结束时间 yyyy-MM-dd HH:mm + * @param f1, f2 上午起止 "HH:mm" + * @param a1, a2 下午起止 "HH:mm" + */ + public double calc(String start, String end, String f1, String f2, String a1, String a2) { + LocalDateTime startDt = parse(start, "开始时间格式错误(应为 yyyy-MM-dd HH:mm)"); + LocalDateTime endDt = parse(end, "结束时间格式错误(应为 yyyy-MM-dd HH:mm)"); + if (!endDt.isAfter(startDt)) { + throw new BizException(ErrorCode.PARAM_ERROR, "结束时间必须晚于开始时间!"); + } + + LocalTime forenoon1 = LocalTime.parse(f1); + LocalTime forenoon2 = LocalTime.parse(f2); + LocalTime afternoon1 = LocalTime.parse(a1); + LocalTime afternoon2 = LocalTime.parse(a2); + + // 午休分钟数(下午开始 - 上午结束) + long noonMinutes = Math.max(0, ChronoUnit.MINUTES.between(forenoon2, afternoon1)); + long morningMinutes = ChronoUnit.MINUTES.between(forenoon1, forenoon2); + long afternoonMinutes = ChronoUnit.MINUTES.between(afternoon1, afternoon2); + long minutes; + + if (startDt.toLocalDate().equals(endDt.toLocalDate())) { + // 同日:两端 clamp 到工作时段 + LocalTime s = clampStart(startDt.toLocalTime(), forenoon1, forenoon2, afternoon1, afternoon2); + LocalTime e = clampEnd(endDt.toLocalTime(), forenoon1, forenoon2, afternoon1, afternoon2); + long raw = ChronoUnit.MINUTES.between(s, e); + // 跨越午休扣除 + if (!s.isAfter(forenoon2) && !e.isBefore(afternoon1)) { + raw -= noonMinutes; + } + minutes = Math.max(0, raw); + } else { + // 跨天:首日剩余 + 中间整天 + 末日已用 + LocalTime s = clampStart(startDt.toLocalTime(), forenoon1, forenoon2, afternoon1, afternoon2); + LocalTime e = clampEnd(endDt.toLocalTime(), forenoon1, forenoon2, afternoon1, afternoon2); + + long firstDay = Math.max(0, ChronoUnit.MINUTES.between(s, afternoon2)); + if (!s.isAfter(forenoon2)) { + // 起始落在上午:首日仍完整覆盖午休,扣除 + firstDay -= noonMinutes; + } + long lastDay = Math.max(0, ChronoUnit.MINUTES.between(forenoon1, e)); + if (!e.isBefore(afternoon1)) { + // 结束落在下午:末日覆盖午休,扣除 + lastDay -= noonMinutes; + } + long middleDays = Math.max(0, ChronoUnit.DAYS.between(startDt.toLocalDate(), endDt.toLocalDate()) - 1); + long perDay = (morningMinutes + afternoonMinutes) > 0 ? (morningMinutes + afternoonMinutes) : (long) (FULL_DAY_HOURS * 60); + minutes = Math.max(0, firstDay) + Math.max(0, lastDay) + middleDays * perDay; + } + + double hours = minutes / 60D; + return Math.round(hours * 100D) / 100D; + } + + /** + * 起始时间 clamp:早于 F1→F1;F1~F2 之间保持;F2~A1 之间→A1;A1~A2 保持;晚于 A2→A2。 + */ + private LocalTime clampStart(LocalTime t, LocalTime f1, LocalTime f2, LocalTime a1, LocalTime a2) { + if (t.isBefore(f1)) { + return f1; + } + if (t.isBefore(f2) || t.equals(f2)) { + return t; + } + if (t.isBefore(a1)) { + return a1; + } + if (t.isAfter(a2)) { + return a2; + } + return t; + } + + /** + * 结束时间 clamp:早于 F1→F1;F1~F2 保持;F2~A1→F2;A1~A2 保持;晚于 A2→A2。 + */ + private LocalTime clampEnd(LocalTime t, LocalTime f1, LocalTime f2, LocalTime a1, LocalTime a2) { + if (t.isBefore(f1)) { + return f1; + } + if (t.isBefore(f2) || t.equals(f2)) { + return t; + } + if (t.isBefore(a1)) { + return f2; + } + if (t.isAfter(a2)) { + return a2; + } + return t; + } + + private LocalDateTime parse(String value, String errorMsg) { + try { + return LocalDateTime.parse(normalize(value), DATE_TIME); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, errorMsg); + } + } + + /** 兼容 "yyyy-MM-dd HH:mm:ss" / "yyyy-MM-ddTHH:mm" */ + private String normalize(String value) { + String v = value == null ? "" : value.trim().replace('T', ' '); + if (v.length() > 16) { + v = v.substring(0, 16); + } + return v; + } + + /** 时间区间是否重叠(冲突校验) */ + public boolean overlap(String aStart, String aEnd, String bStart, String bEnd) { + LocalDateTime as = parseLenient(aStart); + LocalDateTime ae = parseLenient(aEnd); + LocalDateTime bs = parseLenient(bStart); + LocalDateTime be = parseLenient(bEnd); + return as.isBefore(be) && bs.isBefore(ae); + } + + private LocalDateTime parseLenient(String value) { + String v = normalize(value); + if (v.length() == 10) { + return LocalDate.parse(v, DATE).atStartOfDay(); + } + return LocalDateTime.parse(v, DATE_TIME); + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/LeaveUserTimeService.java b/src/main/java/com/yunhai/workleave/service/impl/LeaveUserTimeService.java new file mode 100644 index 0000000..8a737a9 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/LeaveUserTimeService.java @@ -0,0 +1,672 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.entity.LeaveApply; +import com.yunhai.workleave.entity.LeaveSetting; +import com.yunhai.workleave.entity.LeaveUserTime; +import com.yunhai.workleave.entity.LeaveUserTimeRecord; +import com.yunhai.workleave.enums.LeaveStatusEnum; +import com.yunhai.workleave.model.BasePageResult; +import com.yunhai.workleave.model.SchTeaList; +import com.yunhai.workleave.model.leave.UserTimeAdjustItem; +import com.yunhai.workleave.model.leave.UserTimeImportResult; +import com.yunhai.workleave.model.leave.UserTimeRecordVo; +import com.yunhai.workleave.model.leave.UserTimeSumVo; +import com.yunhai.workleave.model.leave.UserTimeVo; +import com.alibaba.excel.EasyExcel; +import com.yunhai.workleave.repository.LeaveApplyRepository; +import com.yunhai.workleave.repository.LeaveSettingRepository; +import com.yunhai.workleave.repository.LeaveUserTimeRecordRepository; +import com.yunhai.workleave.repository.LeaveUserTimeRepository; +import com.yunhai.workleave.service.IClassService; +import com.yunhai.workleave.service.IDictService; +import com.yunhai.workleave.service.ILeaveUserTimeService; +import com.yunhai.workleave.service.IUserCenterService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 教师时长域服务实现。 + *

关键口径(与历史一致,缺陷已修正): + *

    + *
  • 池记录不存在按 0 计,新建默认 0(不出现 null 相加);
  • + *
  • 在途占用 = 审批中单据时长之和(历史 0 状态口径);
  • + *
  • 通过扣减、销假返还,均落流水;拒绝不扣不还。
  • + *

+ */ +@Service +public class LeaveUserTimeService implements ILeaveUserTimeService { + + private static final Logger log = LoggerFactory.getLogger(LeaveUserTimeService.class); + + /** 一天工时(按 8 小时折算展示口径;时长本身存小时) */ + private static final double HOURS_PER_DAY = 8D; + + @Autowired + private LeaveUserTimeRepository userTimeRepository; + + @Autowired + private LeaveUserTimeRecordRepository recordRepository; + + @Autowired + private LeaveApplyRepository leaveApplyRepository; + + @Autowired + private LeaveSettingRepository settingRepository; + + @Autowired + private IClassService classService; + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IDictService dictService; + + // ============================ 列表 ============================ + + @Override + public List list(Integer schId, String keyword) { + // 1. 教师名单(班级服务;不可达时降级) + Map teacherNames = new LinkedHashMap<>(); + try { + for (SchTeaList tea : classService.getSchTeaList(schId)) { + if (tea.getUserTeaSerID() != null) { + teacherNames.putIfAbsent(tea.getUserTeaSerID(), + tea.getTeaName() != null ? tea.getTeaName() : ""); + } + } + } catch (Exception e) { + log.warn("[userTime.list] 教师名单拉取失败,降级为时长池记录, schId={}, err={}", schId, e.getMessage()); + } + + // 2. 时长池记录 + Map timeMap = userTimeRepository.findAll((Specification) (root, query, cb) -> + cb.equal(root.get("schId"), schId)).stream() + .collect(Collectors.toMap(LeaveUserTime::getUserId, t -> t, (a, b) -> a, LinkedHashMap::new)); + + // 3. 合并(教师名单为准;名单不可用时以池记录为准) + Map merged = new LinkedHashMap<>(); + for (Map.Entry entry : teacherNames.entrySet()) { + Integer userId = entry.getKey(); + LeaveUserTime t = timeMap.get(userId); + UserTimeVo vo = new UserTimeVo(); + vo.setId(t == null ? null : t.getId()); + vo.setUserId(userId); + vo.setUserName(StringUtils.hasText(entry.getValue()) ? entry.getValue() + : (t != null ? t.getUserName() : "")); + vo.setUsableHours(t == null || t.getUsableHours() == null ? 0D : t.getUsableHours()); + vo.setRemark(t == null ? null : t.getRemark()); + merged.put(userId, vo); + } + for (Map.Entry entry : timeMap.entrySet()) { + if (!merged.containsKey(entry.getKey())) { + LeaveUserTime t = entry.getValue(); + UserTimeVo vo = new UserTimeVo(); + vo.setId(t.getId()); + vo.setUserId(t.getUserId()); + vo.setUserName(t.getUserName()); + vo.setUsableHours(t.getUsableHours() == null ? 0D : t.getUsableHours()); + vo.setRemark(t.getRemark()); + merged.put(t.getUserId(), vo); + } + } + + // 4. 关键字过滤(姓名 / 备注) + List result = new ArrayList<>(merged.values()); + if (StringUtils.hasText(keyword)) { + String kw = keyword.trim(); + result = result.stream() + .filter(v -> (v.getUserName() != null && v.getUserName().contains(kw)) + || (v.getRemark() != null && v.getRemark().contains(kw))) + .collect(Collectors.toList()); + } + // 姓名有值者优先、按时长倒序 + result.sort((a, b) -> { + boolean an = StringUtils.hasText(a.getUserName()); + boolean bn = StringUtils.hasText(b.getUserName()); + if (an != bn) { + return an ? -1 : 1; + } + return Double.compare(b.getUsableHours() == null ? 0 : b.getUsableHours(), + a.getUsableHours() == null ? 0 : a.getUsableHours()); + }); + return result; + } + + // ============================ 调整 ============================ + + @Override + @Transactional + public void adjust(Integer schId, Integer operatorId, List items) { + if (items == null || items.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "调整列表不得为空!"); + } + for (UserTimeAdjustItem item : items) { + if (item == null || item.getUserId() == null || item.getEditHours() == null) { + continue; + } + double edit = round2(item.getEditHours()); + if (Math.abs(edit) < 0.001) { + continue; + } + LeaveUserTime entity = lockOrCreate(schId, item.getUserId()); + double old = entity.getUsableHours() == null ? 0D : entity.getUsableHours(); + double now = round2(old + edit); + if (now < 0) { + throw new BizException(ErrorCode.FORBIDDEN, + (StringUtils.hasText(entity.getUserName()) ? entity.getUserName() : "该教师") + + " 可用时长不足,扣减后为负!"); + } + entity.setUsableHours(now); + if (StringUtils.hasText(item.getRemark())) { + entity.setRemark(item.getRemark().trim()); + } + userTimeRepository.save(entity); + saveRecord(schId, item.getUserId(), entity.getUserName(), null, "", + edit, old, now, + StringUtils.hasText(item.getRemark()) ? item.getRemark().trim() : "新加", + operatorId); + } + } + + // ============================ 导入 / 模板 ============================ + + @Override + public byte[] importTemplate(Integer schId) { + Map groupNames = userCenterService.groupNames(schId); + List teachers = Collections.emptyList(); + try { + teachers = classService.getSchTeaList(schId); + } catch (Exception e) { + log.warn("[importTemplate] 教师名单拉取失败,输出仅表头模板, schId={}, err={}", schId, e.getMessage()); + } + + List> head = new ArrayList<>(); + for (String h : new String[]{"部门", "教师名称", "新加时长(小时)", "备注"}) { + head.add(Collections.singletonList(h)); + } + List> data = new ArrayList<>(); + Set seen = new HashSet<>(); + for (SchTeaList tea : teachers) { + if (tea.getUserTeaSerID() == null || !seen.add(tea.getUserTeaSerID())) { + continue; + } + String dept = ""; + List gids = tea.getGroupIds(); + if ((gids == null || gids.isEmpty()) && tea.getGroupId() != null) { + gids = Collections.singletonList(tea.getGroupId()); + } + if (gids != null) { + for (Integer gid : gids) { + if (gid != null && groupNames.containsKey(gid)) { + dept = groupNames.get(gid); + break; + } + } + } + data.add(new ArrayList<>(Arrays.asList(dept, tea.getTeaName() == null ? "" : tea.getTeaName(), "", ""))); + } + + try (java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream()) { + EasyExcel.write(bos).head(head).sheet("教师时长导入模板").doWrite(data); + return bos.toByteArray(); + } catch (java.io.IOException e) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "模板生成失败"); + } + } + + @Override + public UserTimeImportResult importExcel(Integer schId, Integer operatorId, java.io.InputStream in) { + UserTimeImportResult result = new UserTimeImportResult(); + + List> rows; + try { + rows = EasyExcel.read(in).headRowNumber(0).sheet().doReadSync(); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "Excel 文件解析失败,请下载模板填写后上传(支持 xls/xlsx)"); + } + if (rows == null || rows.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "Excel文件无数据!"); + } + Map head = rows.get(0); + if (!"部门".equals(cell(head, 0)) || !"教师名称".equals(cell(head, 1)) + || !"新加时长(小时)".equals(cell(head, 2)) || !"备注".equals(cell(head, 3))) { + throw new BizException(ErrorCode.PARAM_ERROR, "Excel文件栏目不正确!表头应为:部门 / 教师名称 / 新加时长(小时) / 备注"); + } + + // 教师与组索引 + Map teaMap = new LinkedHashMap<>(); + Map> teaGroups = new LinkedHashMap<>(); + try { + for (SchTeaList tea : classService.getSchTeaList(schId)) { + if (tea.getUserTeaSerID() == null) { + continue; + } + teaMap.putIfAbsent(tea.getUserTeaSerID(), tea); + List gids = tea.getGroupIds(); + if ((gids == null || gids.isEmpty()) && tea.getGroupId() != null) { + gids = Collections.singletonList(tea.getGroupId()); + } + if (gids != null && !gids.isEmpty()) { + teaGroups.computeIfAbsent(tea.getUserTeaSerID(), k -> new ArrayList<>()).addAll(gids); + } + } + } catch (Exception e) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败,无法导入"); + } + Map groupIdByName = new LinkedHashMap<>(); + for (Map.Entry e : userCenterService.groupNames(schId).entrySet()) { + groupIdByName.putIfAbsent(e.getValue(), e.getKey()); + } + + List items = new ArrayList<>(); + List errors = result.getErrors(); + for (int i = 1; i < rows.size(); i++) { + Map r = rows.get(i); + String dept = cell(r, 0); + String name = cell(r, 1); + String hoursStr = cell(r, 2); + String remark = cell(r, 3); + if (!StringUtils.hasText(hoursStr)) { + continue; // 同旧版:新加时长为空的行跳过 + } + int rowNo = i; // 表头为第 0 行,数据行号从 1 计 + Double hours = null; + try { + hours = Math.round(Double.parseDouble(hoursStr) * 100D) / 100D; + } catch (NumberFormatException ignore) { + // 落入下方错误分支 + } + if (hours == null) { + errors.add(new UserTimeImportResult.RowError(rowNo, "教师" + name + "时长 “" + hoursStr + "”异常!")); + continue; + } + List candidates; + if (!StringUtils.hasText(dept)) { + candidates = teaMap.values().stream() + .filter(t -> name.equals(t.getTeaName())) + .collect(Collectors.toList()); + } else { + Integer gid = groupIdByName.get(dept); + if (gid == null) { + errors.add(new UserTimeImportResult.RowError(rowNo, "部门" + dept + " 不存在!")); + continue; + } + candidates = teaMap.values().stream() + .filter(t -> name.equals(t.getTeaName())) + .filter(t -> { + List gids = teaGroups.get(t.getUserTeaSerID()); + return gids != null && gids.contains(gid); + }) + .collect(Collectors.toList()); + } + if (candidates.size() > 1) { + errors.add(new UserTimeImportResult.RowError(rowNo, "部门" + dept + " 教师 " + name + " 存在多个!")); + } else if (candidates.isEmpty()) { + errors.add(new UserTimeImportResult.RowError(rowNo, "部门" + dept + " 教师 " + name + " 不存在!")); + } else { + UserTimeAdjustItem item = new UserTimeAdjustItem(); + item.setUserId(candidates.get(0).getUserTeaSerID()); + item.setEditHours(hours); + item.setRemark(remark); + items.add(item); + } + } + + if (!errors.isEmpty()) { + result.setSuccess(false); + result.setMessage("导入失败!"); + return result; + } + if (items.isEmpty()) { + result.setSuccess(false); + result.setMessage("Excel文件无有效数据!"); + return result; + } + try { + adjust(schId, operatorId, items); + } catch (BizException e) { + result.setSuccess(false); + result.setMessage(e.getMessage()); + return result; + } + result.setSuccess(true); + result.setMessage("导入成功!"); + return result; + } + + private String cell(Map row, int index) { + String v = row.get(index); + return v == null ? "" : v.trim(); + } + + // ============================ 流水 / 合计 ============================ + + @Override + public BasePageResult records(Integer schId, Integer rqType, String keyword, + String startTime, String endTime, int pageIndex, int pageSize) { + LocalDateTime[] range = resolveRange(rqType, startTime, endTime); + Specification spec = buildRecordSpec(schId, keyword, range); + Page page = recordRepository.findAll(spec, + PageRequest.of(Math.max(pageIndex - 1, 0), Math.max(pageSize, 1), + Sort.by(Sort.Direction.DESC, "occurTime").and(Sort.by(Sort.Direction.DESC, "id")))); + + List content = page.getContent().stream().map(this::toVo).collect(Collectors.toList()); + // 关联请假单起止时间(列表展示"时长可点看详情"用) + fillLeaveTime(content); + return new BasePageResult<>(content, (int) page.getTotalElements(), page.getNumber() + 1, + page.getSize(), page.getTotalPages(), page.hasNext()); + } + + @Override + public UserTimeSumVo sum(Integer schId, Integer rqType, String startTime, String endTime) { + LocalDateTime[] range = resolveRange(rqType, startTime, endTime); + UserTimeSumVo vo = new UserTimeSumVo(); + Double editSum = recordRepository.sumEditHours(schId, range[0], range[1]); + vo.setAddHours(round2(editSum == null ? 0D : editSum)); + double remain = userTimeRepository.findAll((Specification) (root, query, cb) -> + cb.equal(root.get("schId"), schId)).stream() + .mapToDouble(t -> t.getUsableHours() == null ? 0D : t.getUsableHours()) + .sum(); + vo.setRemainHours(round2(remain)); + return vo; + } + + // ============================ 查询 / 校验 ============================ + + @Override + public double usableOf(Integer schId, Integer userId) { + return userTimeRepository.findBySchIdAndUserId(schId, userId) + .map(t -> t.getUsableHours() == null ? 0D : t.getUsableHours()) + .orElse(0D); + } + + @Override + public double passedHoursOf(Integer schId, Integer userId) { + return leaveApplyRepository.findBySchIdAndUserIdAndStatusIn(schId, userId, + Collections.singletonList(LeaveStatusEnum.PASSED.getId())).stream() + .mapToDouble(l -> l.getTotalHours() == null ? 0D : l.getTotalHours()) + .sum(); + } + + @Override + public double inTransitHoursOf(Integer schId, Integer userId) { + List approving = leaveApplyRepository.findBySchIdAndUserIdAndStatusIn(schId, userId, + Collections.singletonList(LeaveStatusEnum.APPROVING.getId())); + Set managed = managedTypes(schId); + return approving.stream() + .filter(l -> managed.contains(l.getLeaveType())) + .mapToDouble(l -> l.getTotalHours() == null ? 0D : l.getTotalHours()) + .sum(); + } + + @Override + public Set managedTypes(Integer schId) { + LeaveSetting setting = settingRepository.findBySchId(schId).orElse(null); + if (setting == null || !Integer.valueOf(1).equals(setting.getTimeLimitEnabled())) { + return Collections.emptySet(); + } + if (!StringUtils.hasText(setting.getTimeManagedTypes())) { + return Collections.emptySet(); + } + return new LinkedHashSet<>(Arrays.asList(setting.getTimeManagedTypes().split(","))); + } + + @Override + public boolean isManaged(Integer schId, String leaveType) { + return StringUtils.hasText(leaveType) && managedTypes(schId).contains(leaveType.trim()); + } + + @Override + public void checkEnough(Integer schId, Integer userId, String leaveType, double hours) { + if (!isManaged(schId, leaveType)) { + return; + } + double usable = usableOf(schId, userId); + double inTransit = inTransitHoursOf(schId, userId); + double remain = round2(usable - inTransit); + if (round2(hours) > remain + 0.0001) { + throw new BizException(ErrorCode.FORBIDDEN, + "您有 " + trim(inTransit) + " 小时待审核,可申请 " + trim(Math.max(0, remain)) + + " 小时,时间不足!"); + } + } + + // ============================ 扣减 / 返还 ============================ + + @Override + @Transactional + public void deduct(Integer schId, Integer userId, String userName, Integer leaveId, String leaveType, + double hours, Integer operatorId) { + if (!isManaged(schId, leaveType) || hours <= 0) { + return; + } + if (leaveId != null && recordRepository.existsByLeaveIdAndDescribeText(leaveId, describeOf(leaveType))) { + log.info("[deduct] 单据 {} 已扣减过,跳过(幂等)", leaveId); + return; + } + LeaveUserTime entity = lockOrCreate(schId, userId); + double old = entity.getUsableHours() == null ? 0D : entity.getUsableHours(); + double now = round2(old - hours); + entity.setUsableHours(now); + if (StringUtils.hasText(userName)) { + entity.setUserName(userName); + } + userTimeRepository.save(entity); + saveRecord(schId, userId, entity.getUserName(), leaveId, leaveType, + -round2(hours), old, now, describeOf(leaveType), operatorId); + } + + @Override + @Transactional + public void refund(Integer schId, Integer userId, String userName, Integer leaveId, String leaveType, + double hours, Integer operatorId, String describe) { + if (!isManaged(schId, leaveType) || hours <= 0) { + return; + } + LeaveUserTime entity = lockOrCreate(schId, userId); + double old = entity.getUsableHours() == null ? 0D : entity.getUsableHours(); + double now = round2(old + hours); + entity.setUsableHours(now); + if (StringUtils.hasText(userName)) { + entity.setUserName(userName); + } + userTimeRepository.save(entity); + saveRecord(schId, userId, entity.getUserName(), leaveId, leaveType, + round2(hours), old, now, StringUtils.hasText(describe) ? describe : "销假返还", operatorId); + } + + // ============================ 内部方法 ============================ + + private String describeOf(String leaveType) { + String typeName = dictService.resolveTypeName(leaveType); + return (StringUtils.hasText(typeName) ? typeName : "请假") + "扣除"; + } + + private LeaveUserTime lockOrCreate(Integer schId, Integer userId) { + LeaveUserTime entity = userTimeRepository.findBySchIdAndUserId(schId, userId).orElse(null); + if (entity == null) { + entity = new LeaveUserTime(); + entity.setSchId(schId); + entity.setUserId(userId); + entity.setUsableHours(0D); + // 教师姓名冗余 + try { + for (SchTeaList tea : classService.getSchTeaList(schId)) { + if (userId.equals(tea.getUserTeaSerID())) { + entity.setUserName(tea.getTeaName()); + break; + } + } + } catch (Exception ignore) { + /* 名单不可达时留空 */ + } + entity = userTimeRepository.save(entity); + } + return entity; + } + + private void saveRecord(Integer schId, Integer userId, String userName, Integer leaveId, String leaveType, + double editHours, double oldHours, double newHours, String describe, Integer operatorId) { + LeaveUserTimeRecord record = new LeaveUserTimeRecord(); + record.setSchId(schId); + record.setUserId(userId); + record.setUserName(userName); + record.setLeaveId(leaveId); + record.setLeaveType(leaveType == null ? "" : leaveType); + record.setEditHours(round2(editHours)); + record.setOldHours(round2(oldHours)); + record.setNewHours(round2(newHours)); + record.setDescribeText(describe); + record.setOperatorId(operatorId); + record.setOccurTime(LocalDateTime.now()); + recordRepository.save(record); + } + + private Specification buildRecordSpec(Integer schId, String keyword, LocalDateTime[] range) { + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("schId"), schId)); + if (range != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("occurTime"), range[0])); + predicates.add(cb.lessThan(root.get("occurTime"), range[1])); + } + if (StringUtils.hasText(keyword)) { + String like = "%" + keyword.trim() + "%"; + predicates.add(cb.or( + cb.like(root.get("userName"), like), + cb.like(root.get("describeText"), like), + cb.like(root.get("leaveType"), like))); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + + /** + * 时间区间口径(历史 rqType):0 今日 / 1 本周 / 2 本月 / 3 累计(可带自定义区间)。 + */ + private LocalDateTime[] resolveRange(Integer rqType, String startTime, String endTime) { + int type = rqType == null ? 3 : rqType; + LocalDate today = LocalDate.now(); + switch (type) { + case 0: + return new LocalDateTime[]{today.atStartOfDay(), today.plusDays(1).atStartOfDay()}; + case 1: + LocalDate monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + return new LocalDateTime[]{monday.atStartOfDay(), monday.plusWeeks(1).atStartOfDay()}; + case 2: + LocalDate first = today.withDayOfMonth(1); + return new LocalDateTime[]{first.atStartOfDay(), first.plusMonths(1).atStartOfDay()}; + default: + LocalDateTime start = parseDate(startTime, LocalDateTime.of(1970, 1, 1, 0, 0)); + LocalDateTime end = parseDate(endTime, LocalDateTime.of(2999, 12, 31, 23, 59)); + if (StringUtils.hasText(endTime) && endTime.trim().length() == 10) { + end = end.plusDays(1); + } + return new LocalDateTime[]{start, end}; + } + } + + private LocalDateTime parseDate(String value, LocalDateTime defaultValue) { + if (!StringUtils.hasText(value)) { + return defaultValue; + } + String v = value.trim().replace('T', ' '); + try { + if (v.length() == 10) { + return LocalDate.parse(v).atStartOfDay(); + } + if (v.length() == 16) { + return LocalDateTime.of(LocalDate.parse(v.substring(0, 10)), + LocalTime.parse(v.substring(11, 16))); + } + if (v.length() >= 19) { + return LocalDateTime.of(LocalDate.parse(v.substring(0, 10)), + LocalTime.parse(v.substring(11, 19))); + } + return LocalDate.parse(v).atStartOfDay(); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "时间格式错误(yyyy-MM-dd)"); + } + } + + private void fillLeaveTime(List content) { + Set leaveIds = content.stream() + .map(UserTimeRecordVo::getLeaveId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); + if (leaveIds.isEmpty()) { + return; + } + Map applyMap = leaveApplyRepository.findAllById(leaveIds).stream() + .collect(Collectors.toMap(LeaveApply::getId, l -> l, (a, b) -> a)); + for (UserTimeRecordVo vo : content) { + LeaveApply apply = vo.getLeaveId() == null ? null : applyMap.get(vo.getLeaveId()); + if (apply != null) { + vo.setStartTime(apply.getStartTime()); + vo.setEndTime(apply.getEndTime()); + if (!StringUtils.hasText(vo.getLeaveTypeName())) { + vo.setLeaveTypeName(dictService.resolveTypeName(apply.getLeaveType())); + } + } + } + } + + private UserTimeRecordVo toVo(LeaveUserTimeRecord entity) { + UserTimeRecordVo vo = new UserTimeRecordVo(); + vo.setId(entity.getId()); + vo.setUserId(entity.getUserId()); + vo.setUserName(entity.getUserName()); + vo.setLeaveId(entity.getLeaveId()); + vo.setLeaveType(entity.getLeaveType()); + vo.setLeaveTypeName(dictService.resolveTypeName(entity.getLeaveType())); + vo.setEditHours(entity.getEditHours()); + vo.setOldHours(entity.getOldHours()); + vo.setNewHours(entity.getNewHours()); + vo.setDescribeText(entity.getDescribeText()); + vo.setOperatorId(entity.getOperatorId()); + vo.setOccurTime(entity.getOccurTime()); + return vo; + } + + private static double round2(double value) { + return Math.round(value * 100D) / 100D; + } + + /** 去尾零展示:3.0 → 3,3.50 → 3.5 */ + private static String trim(double value) { + String s = String.valueOf(round2(value)); + if (s.endsWith(".0")) { + s = s.substring(0, s.length() - 2); + } + return s; + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/SysOperationLogService.java b/src/main/java/com/yunhai/workleave/service/impl/SysOperationLogService.java new file mode 100644 index 0000000..bc00a7a --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/SysOperationLogService.java @@ -0,0 +1,73 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.aspect.SysOperationLogAspect; +import com.yunhai.workleave.entity.SysOperationLog; +import com.yunhai.workleave.model.BasePageQuery; +import com.yunhai.workleave.model.BasePageResult; +import com.yunhai.workleave.repository.SysOperationLogRepository; +import com.yunhai.workleave.service.ISysOperationLogService; +import com.yunhai.workleave.utils.PageUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统审计日志业务实现:@Async 异步落库,失败仅记日志不抛出。 + */ +@Service +public class SysOperationLogService implements ISysOperationLogService { + + private static final Logger log = LoggerFactory.getLogger(SysOperationLogService.class); + + @Autowired + private SysOperationLogRepository repository; + + @Override + @Async + public void saveAsync(SysOperationLog entity) { + // 不加 @Transactional:save() 单条自原子;若外层事务在异常后提交 rollback-only + // 会抛 UnexpectedRollbackException 逃出 try/catch,被异步线程兜底处理器当成未捕获异常 + try { + repository.save(entity); + } catch (Exception e) { + log.warn("[saveAsync] 审计日志落库失败, uri={}", entity == null ? null : entity.getRequestUri()); + } + } + + @Override + public BasePageResult page(BasePageQuery query, String operationType, Integer statusCode) { + Specification spec = (root, cq, cb) -> { + List ps = new ArrayList<>(); + if (StringUtils.hasText(operationType)) { + ps.add(cb.equal(root.get("operationType"), operationType)); + } + if (statusCode != null) { + ps.add(cb.equal(root.get("statusCode"), statusCode)); + } + if (StringUtils.hasText(query.getKeyword())) { + String kw = "%" + query.getKeyword().trim() + "%"; + Predicate uriLike = cb.like(root.get("requestUri"), kw); + Predicate nameLike = cb.like(root.get("userName"), kw); + Predicate methodLike = cb.like(root.get("classMethod"), kw); + ps.add(cb.or(uriLike, nameLike, methodLike)); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + Pageable pageable = PageUtils.toPageable(query, Sort.by(Sort.Direction.DESC, "createTime")); + Page page = repository.findAll(spec, pageable); + page.getContent().forEach(e -> e.setResponseData(null)); + return PageUtils.toResult(page); + } +} diff --git a/src/main/java/com/yunhai/workleave/service/impl/UserCenterService.java b/src/main/java/com/yunhai/workleave/service/impl/UserCenterService.java new file mode 100644 index 0000000..2b61e1e --- /dev/null +++ b/src/main/java/com/yunhai/workleave/service/impl/UserCenterService.java @@ -0,0 +1,295 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.model.SchTeaList; +import com.yunhai.workleave.service.IClassService; +import com.yunhai.workleave.service.IUserCenterService; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户中心门面服务实现。 + *

原 BaseApiController.getSchoolAll 的组树组装、教师挂组、递归建树、排序逻辑整体下沉至此; + * GetGroupUser 空桩改为显式报错(未接入用户中心对应开放接口时提示,而非静默返回空列表)。 + * 跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。

+ */ +@Service +public class UserCenterService implements IUserCenterService { + + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */ + private static final int GROUP_TYPE_STUDENT = 8; + + @Autowired + private IClassService classService; + + // ============================ 学校组织树 ============================ + + @Override + public List> getSchoolAllTree(Integer schId) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + + // 1. userbase 组树(整棵学校子树),剔除学生组 + List> flatNodes = new ArrayList<>(); + BoaGroup root = YschBaseOpenApi.getGroupById(schId, true); + if (root == null) { + log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId); + return Collections.emptyList(); + } + flattenGroup(root, 0, flatNodes); + + // 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点 + Set treeGroupIds = new HashSet<>(); + for (Map node : flatNodes) { + treeGroupIds.add((Integer) node.get("id")); + } + Map teacherMap = new LinkedHashMap<>(); + Map> teacherPids = new LinkedHashMap<>(); + List teachers = classService.getSchTeaList(schId); + for (SchTeaList tea : teachers) { + if (tea.getUserTeaSerID() == null) { + continue; + } + // 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重 + teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea); + // 接口现行字段为单个 GroupID,兼容旧字段 GroupIds + List teaGroupIds = tea.getGroupIds(); + if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) { + teaGroupIds = Collections.singletonList(tea.getGroupId()); + } + if (teaGroupIds != null) { + for (Integer gid : teaGroupIds) { + if (gid != null && treeGroupIds.contains(gid)) { + teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid); + } + } + } + } + for (Map.Entry entry : teacherMap.entrySet()) { + int teaId = entry.getKey(); + SchTeaList tea = entry.getValue(); + Set pids = teacherPids.get(teaId); + if (pids == null || pids.isEmpty()) { + // 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点 + pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0)); + } + for (Integer pid : pids) { + Map node = new LinkedHashMap<>(); + node.put("id", teaId); + node.put("name", tea.getTeaName() != null ? tea.getTeaName() : ""); + node.put("pid", pid); + node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + flatNodes.add(node); + } + } + + // 3. 扁平节点递归组装为树 + return formatUserTree(flatNodes, 0); + } + + @Override + public Map groupNames(Integer schId) { + Map names = new LinkedHashMap<>(); + BoaGroup root = YschBaseOpenApi.getGroupById(schId, true); + if (root == null) { + log.warn("[groupNames] 用户中心未找到学校组, schId={}", schId); + return names; + } + collectGroupNames(root, names); + return names; + } + + private void collectGroupNames(BoaGroup group, Map names) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + names.putIfAbsent(group.getId(), group.getName() != null ? group.getName() : ""); + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + collectGroupNames(child, names); + } + } + } + + /** + * 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。 + * isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。 + */ + private void flattenGroup(BoaGroup group, int pid, List> nodes) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + Map node = new LinkedHashMap<>(); + node.put("id", group.getId()); + node.put("name", group.getName() != null ? group.getName() : ""); + node.put("pid", pid); + node.put("no", group.getSort() != null ? group.getSort() : 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + nodes.add(node); + + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + flattenGroup(child, group.getId(), nodes); + } + } + } + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + * 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。 + */ + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + Set allIds = new HashSet<>(); + for (Map n : treeList) { + allIds.add((Integer) n.get("id")); + } + data = new ArrayList<>(); + for (Map n : treeList) { + int p = (Integer) n.get("pid"); + if (p == 0 || !allIds.contains(p)) { + data.add(n); + } + } + } else { + data = new ArrayList<>(); + for (Map n : treeList) { + if ((Integer) n.get("pid") == pid) { + data.add(n); + } + } + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序(数字优先,无法解析的排最后) + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) { + return (Integer) no; + } + if (no instanceof String) { + try { + return Integer.parseInt((String) no); + } catch (NumberFormatException e) { + return 9999; + } + } + return 9999; + })); + + return result; + } + + // ============================ 组内用户 ============================ + + @Override + public List> getGroupUser(Integer gid, Integer role) { + if (gid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空"); + } + log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role); + // 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户 + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试"); + } + + // ============================ 用户模块树 ============================ + + @Override + public List> getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + List modules = YschBaseOpenApi.getUserModule(userId, schId); + if (modules == null || modules.isEmpty()) { + return Collections.emptyList(); + } + // 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望 + return buildModuleTree(modules); + } + + /** + * 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。 + * 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。 + */ + private List> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + // 每个模块节点(含 children 占位) + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + + // 挂父子关系 + List> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排 + return roots; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/workleave/utils/GzipUtil.java b/src/main/java/com/yunhai/workleave/utils/GzipUtil.java new file mode 100644 index 0000000..47c4be7 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/utils/GzipUtil.java @@ -0,0 +1,27 @@ +package com.yunhai.workleave.utils; + +import java.io.ByteArrayOutputStream; +import java.util.zip.GZIPOutputStream; + +/** + * GZIP 压缩工具(审计日志响应体压缩落库用)。 + */ +public class GzipUtil { + + private GzipUtil() { + } + + public static byte[] compress(String data) { + if (data == null || data.isEmpty()) { + return new byte[0]; + } + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(bos)) { + gzip.write(data.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + gzip.finish(); + return bos.toByteArray(); + } catch (Exception e) { + return new byte[0]; + } + } +} diff --git a/src/main/java/com/yunhai/workleave/utils/IpUtils.java b/src/main/java/com/yunhai/workleave/utils/IpUtils.java new file mode 100644 index 0000000..ac45875 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/utils/IpUtils.java @@ -0,0 +1,44 @@ +package com.yunhai.workleave.utils; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; + +/** + * 客户端 IP 解析(移植自 UserclassService OtherUtil.getClientIp)。 + * 按 X-Forwarded-For 等代理头链路取真实 IP,多级代理取第一个。 + */ +public class IpUtils { + + private IpUtils() { + } + + public static String getClientIp() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return "unknown"; + } + HttpServletRequest request = attributes.getRequest(); + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/src/main/java/com/yunhai/workleave/utils/PageUtils.java b/src/main/java/com/yunhai/workleave/utils/PageUtils.java new file mode 100644 index 0000000..96f1b35 --- /dev/null +++ b/src/main/java/com/yunhai/workleave/utils/PageUtils.java @@ -0,0 +1,37 @@ +package com.yunhai.workleave.utils; + +import com.yunhai.workleave.model.BasePageQuery; +import com.yunhai.workleave.model.BasePageResult; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * 分页辅助类:BasePageQuery <-> Pageable,Page <-> BasePageResult。 + */ +public class PageUtils { + + private PageUtils() { + } + + public static Pageable toPageable(BasePageQuery query, Sort sort) { + int page = Math.max(query.getPageIndex() - 1, 0); + int size = Math.max(query.getPageSize(), 1); + if (sort == null) { + sort = Sort.unsorted(); + } + return PageRequest.of(page, size, sort); + } + + public static BasePageResult toResult(Page page) { + return new BasePageResult( + page.getContent(), + (int) page.getTotalElements(), + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.hasNext() + ); + } +} diff --git a/src/main/java/com/yunhai/workleave/utils/UserContext.java b/src/main/java/com/yunhai/workleave/utils/UserContext.java new file mode 100644 index 0000000..2e1e2fb --- /dev/null +++ b/src/main/java/com/yunhai/workleave/utils/UserContext.java @@ -0,0 +1,140 @@ +package com.yunhai.workleave.utils; + +import lombok.Value; + +/** + * 当前登录用户上下文(ThreadLocal 静态持有器)。 + *

移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext), + * 由 {@link com.yunhai.workleave.aspect.ControllerUserContextAspect} 在每个请求进入 controller 前填充, + * 请求结束后 clear,防止线程复用串号。

+ */ +public class UserContext { + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + private static final ThreadLocal USER_NAME = new ThreadLocal<>(); + private static final ThreadLocal ROLE_ID = new ThreadLocal<>(); + private static final ThreadLocal SCH_ID = new ThreadLocal<>(); + private static final ThreadLocal IP = new ThreadLocal<>(); + private static final ThreadLocal TYPE = new ThreadLocal<>(); + + /** 当前角色切换上下文(来自 currentrole header,平台统一携带) */ + private static final ThreadLocal CHILD_ID = new ThreadLocal<>(); + + public static void setUserId(Integer userId) { + USER_ID.set(userId); + } + + public static Integer getUserId() { + return USER_ID.get(); + } + + public static void setUserName(String userName) { + USER_NAME.set(userName); + } + + public static String getUserName() { + return USER_NAME.get(); + } + + public static void setRoleId(Integer roleId) { + ROLE_ID.set(roleId); + } + + public static Integer getRoleId() { + return ROLE_ID.get(); + } + + public static void setSchId(Integer schId) { + SCH_ID.set(schId); + } + + public static Integer getSchId() { + return SCH_ID.get(); + } + + public static void setIp(String ip) { + IP.set(ip); + } + + public static String getIp() { + return IP.get(); + } + + public static void setType(Integer type) { + TYPE.set(type); + } + + public static Integer getType() { + return TYPE.get(); + } + + public static void setChildId(Integer childId) { + CHILD_ID.set(childId); + } + + /** 当前角色上下文中的 childId(平台统一字段,请假模块不使用) */ + public static Integer getChildId() { + return CHILD_ID.get(); + } + + /** 用户信息快照(用于异步任务传递) */ + @Value + public static class UserInfo { + Integer userId; + String userName; + Integer roleId; + Integer schId; + String ip; + Integer type; + Integer childId; + } + + public static UserInfo getCurrentUser() { + return new UserInfo( + USER_ID.get(), + USER_NAME.get(), + ROLE_ID.get(), + SCH_ID.get(), + IP.get(), + TYPE.get(), + CHILD_ID.get() + ); + } + + public static void setUserInfo(UserInfo info) { + if (info == null) { + return; + } + USER_ID.set(info.getUserId()); + USER_NAME.set(info.getUserName()); + ROLE_ID.set(info.getRoleId()); + SCH_ID.set(info.getSchId()); + IP.set(info.getIp()); + TYPE.set(info.getType()); + CHILD_ID.set(info.getChildId()); + } + + /** + * 把当前用户快照带入子线程执行异步任务,执行完 clear。 + */ + public static Runnable wrap(Runnable task) { + UserInfo info = getCurrentUser(); + return () -> { + try { + setUserInfo(info); + task.run(); + } finally { + clear(); + } + }; + } + + public static void clear() { + USER_ID.remove(); + USER_NAME.remove(); + ROLE_ID.remove(); + SCH_ID.remove(); + IP.remove(); + TYPE.remove(); + CHILD_ID.remove(); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..38227bd --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,18 @@ +# 本地开发联调(bootstrap 默认激活 profile=local;线上用 SPRING_PROFILES_ACTIVE 切走,不读本文件)。 +# 这里只放「仅本地生效」的覆盖项,其余共用配置见 application.yml。 +yh: + app-auth: + # 本地回调预检绕行:流程中心探测回调地址携带 X-App-Key(100009),本地连不上 + # 160 infra Redis 的签名密钥链会被 401 拒。仅剥 /oapi/flow/callback 的 X-App 头, + # 该路径仍由 yh-flow-callback 的 X-Flw 验签保护(见 LocalCallbackBypassConfig)。 + local-callback-bypass: true + # 本地关闭 /oapi/** 强制验签(base 为 [/oapi/*]):无密钥链,保持 oapi 端点 + # (dept-judge / callback)可无签名联调。不能置空——base 的索引键仍在会使条件命中 + # 而空 pattern 列表又会让 FilterRegistration 启动报错,故给一个永不匹配的 pattern。 + server: + enforce-patterns: [/__local_no_match__] + flow-callback: + # 160 平台与开发机同网段(192.168.10.x),可直连回调;地址须给局域网 IP + # (localhost 对 160 不可达)。启用后提交/绑定会自动向流程中心登记回调并换取 secret。 + enabled: true + callback-url: http://192.168.10.166:8108/oapi/flow/callback diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..c887b3c --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,109 @@ +server: + port: 8108 + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + datasource: + url: jdbc:mysql://localhost:3306/work_leave?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai + username: root + password: 123123 + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + #jpa + jpa: + database: MySQL + show-sql: true + hibernate: + # 开发期使用 update 自动建表;生产环境设置成 none + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + open-in-view: true + + #文件上传 + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + + #本地关闭 rabbit + rabbitmq: + listener: + direct: + auto-startup: false + simple: + auto-startup: false + + +# 短信模块配置 +sms: + enabled: true + module-id: workleave + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://localhost:8108 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100020 + app-secret: 99b976315ec84dd1b7e4eb761584a2eb + # 提供方强制验签(两级模型,对齐 noticeservice):/oapi/** 无签名一律 401(RequireAppSignFilter), + # 有签名由 AppAuthFilter 走中央密钥链校验(flow-center 100019)。保护 + # /oapi/flow/dept-judge(条件路由判断)与 /oapi/flow/callback(流程回调)。 + # 本地连不上 160 infra Redis 密钥链,application-local.yml 置空关闭,线上保持本配置。 + server: + enforce-patterns: [/oapi/*] + open: + enabled: true + # 开放网关(服务间 oapi 正门,手册 §5.0):flow-center / usermsg 的 oapi 只在 open.* + # 上有路由,指到 base.*(业务门户)会返回 404 → 流程列表/发起/待办全部失败。 + base-host: http://open.yunhaixingtong.com + # 出站地址三级解析的第二级:yh.call.targets → yh.open.services → 枚举兜底 + # (key 取 PlatformService.key():flow-center / usermsg / userbase / userclass) + services: + flow-center: http://open.yunhaixingtong.com/flow-center + usermsg: http://open.yunhaixingtong.com/usermsg + # 用户中心、班级管理是业务接口(非 oapi),仍在门户域名下 + user-center-url: http://base.yunhaixingtong.com/userbase + # 班级管理(bschclass)服务基址 + bschclass: http://base.yunhaixingtong.com/userclass + # 流程中心回调接收端(yh-flow-callback-spring-boot-starter) + # 默认关闭:验签密钥链依赖 infra Redis(容器网内)。本地联调在 application-local.yml + # 打开并指定局域网回调地址;线上由 Nacos workleave.yaml 覆盖本段。 + flow-callback: + enabled: false + biz-types: leave + # 回调地址:不配 callback-url 时由 open-host + path 拼(/oapi/flow/callback) + open-host: ${yh.open.base-host} +management: + health: + redis: + enabled: false + +# 请假模块自身配置 +leave: + flow: + # 兜底默认流程:流程中心「流程管理-流程列表」中 code=work-leave 且 status=1(已发布) 的流程。 + # 仅当 leave_flow_bind 未配置任何绑定时兜底;真正生效优先走绑定表(全校默认 / 按请假类型)。 + # d2237a5b2b95492e845da9caeaa35590 = 「请假申请(小于3天或大于等于3天)」v4(2026-10-09 17:45 发布) + # 另一条已发布的 work-leave 流程:28bc3ca8acc24611809d7dd1b6b9a597 = 「产假(10天)」v5 + guid: d2237a5b2b95492e845da9caeaa35590 + # 销假时效(天):末次通过超过该天数不允许发起销假 + cancel-limit-days: 30 + +FILE_PATH: D:/data/upload +FILE_WEB_PATH: http://base.yunhaixingtong.com/userbase/upload/ \ No newline at end of file diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..10b9177 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,33 @@ +spring: + application: + name: workleave + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + cloud: + nacos: + config: + enabled: ${NACOS_CONFIG_ENABLED:false} + server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848} + namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc} + # 应用自身配置所在分组 + group: OA_APP + # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 workleave.yaml + file-extension: yaml + encode: UTF-8 + # nacos 不可达时不强制退出,回退到本地 application.yml 兜底 + fail-fast: false + # 只读账号 + username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader} + password: ${SPRING_CLOUD_NACOS_PASSWORD:123456} + # 共享公共配置:public 空间下的 common.properties + shared-configs: + # ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml + - data-id: application-default.yaml + group: DEFAULT_GROUP + namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间,不读上面的全局 prod namespace + refresh: false + + # ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 prod namespace id)的 application-infra.yaml + - data-id: application-infra.yaml + group: DEFAULT_GROUP + refresh: false diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..24b20e7 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,52 @@ + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + /app/logs/app.log + + /app/logs/app.%d{yyyy-MM-dd}.log + 30 + 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + ${LOG_PATH:-.}/logs/workleave-local.log + + ${LOG_PATH:-.}/logs/workleave-local.%d{yyyy-MM-dd}.log + 7 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql/sys_operation_log.sql b/src/main/resources/sql/sys_operation_log.sql new file mode 100644 index 0000000..05d66b9 --- /dev/null +++ b/src/main/resources/sql/sys_operation_log.sql @@ -0,0 +1,31 @@ +-- ============================================================ +-- WorkLeave(教师请假)审计日志表 +-- 与业务表物理隔离;索引在此创建,实体不含 @Table(indexes), +-- 避免 ddl-auto=update 启动时重复建索引告警。 +-- MySQL 5.7+;幂等(不存在才建)。 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `sys_operation_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `log_level` TINYINT NOT NULL COMMENT '1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)', + `operation_type` VARCHAR(16) NOT NULL COMMENT 'CREATE/UPDATE/DELETE/QUERY', + `user_id` INT NULL COMMENT '操作人用户ID(UserContext)', + `user_name` VARCHAR(64) NULL COMMENT '操作人姓名', + `sch_id` INT NULL COMMENT '学校ID(多校隔离)', + `request_ip` VARCHAR(64) NOT NULL COMMENT '客户端IP', + `request_uri` VARCHAR(512) NOT NULL COMMENT '请求URI', + `request_method` VARCHAR(16) NOT NULL COMMENT 'HTTP方法', + `class_method` VARCHAR(512) NOT NULL COMMENT '类.方法', + `request_params` TEXT NULL COMMENT '请求参数', + `response_data` MEDIUMBLOB NULL COMMENT '响应JSON(GZIP 压缩)', + `status_code` INT NOT NULL COMMENT '业务状态码:0 成功 / 非 0 失败', + `cost_time` BIGINT NOT NULL COMMENT '耗时 ms', + `user_agent` VARCHAR(512) NULL, + `error_msg` TEXT NULL COMMENT '异常信息(截断 8000)', + `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_sol_create_time` (`create_time`), + KEY `idx_sol_sch_level` (`sch_id`, `log_level`), + KEY `idx_sol_user` (`user_id`), + KEY `idx_sol_op_type` (`operation_type`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='系统审计日志(双轨之审计轨)'; diff --git a/src/main/resources/sql/workleave_schema.sql b/src/main/resources/sql/workleave_schema.sql new file mode 100644 index 0000000..b91709d --- /dev/null +++ b/src/main/resources/sql/workleave_schema.sql @@ -0,0 +1,293 @@ +-- ============================================================ +-- WorkLeave(教师请假)业务表结构 +-- 实体由 ddl-auto=update 自动建表,本脚本用于人工建库/核对结构 +-- (MySQL 5.7+,utf8mb4)。表清单: +-- leave_apply 请假申请主表 +-- leave_setting 学校请假设置(一校一行) +-- leave_time_set 学校作息时间(一校一行) +-- leave_user_time 教师时长池(一校一人一行) +-- leave_user_time_record 时长变更流水 +-- leave_section 节次 +-- leave_cc 抄送留痕 +-- leave_audit_trace 审核痕迹("我已审核"列表) +-- leave_flow_bind 审批流程绑定(流程管理页:默认流程 / 按请假类型) +-- leave_flow_enable 流程启用绑定(流程管理页:启用后才可绑定) +-- up_file / sys_dict / sys_operation_log 保留的通用表 +-- flow_callback_event / flow_callback_reg 由 yh-flow-callback starter 自动建 +-- 说明:所有业务表不保存教师/学生账号,只存平台用户ID。 +-- ============================================================ + +-- ------------------------------------------------------------ +-- 请假申请主表 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_apply` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `sch_id` INT NOT NULL COMMENT '学校ID', + `user_id` INT NOT NULL COMMENT '申请人用户ID(不存账号)', + `user_name` VARCHAR(64) NULL COMMENT '申请人姓名(冗余)', + `user_photo` VARCHAR(255) NULL COMMENT '申请人头像(冗余)', + `leave_type` VARCHAR(50) NOT NULL COMMENT '请假类型(leaveType 字典 key)', + `leave_type_name` VARCHAR(100) NULL COMMENT '请假类型名称(冗余)', + `start_time` VARCHAR(19) NOT NULL COMMENT '开始时间 yyyy-MM-dd HH:mm:ss', + `end_time` VARCHAR(19) NOT NULL COMMENT '结束时间 yyyy-MM-dd HH:mm:ss', + `total_hours` DOUBLE NULL COMMENT '请假时长(小时,服务端复核)', + `reason` VARCHAR(500) NULL COMMENT '请假事由', + `file_ids` VARCHAR(500) NULL COMMENT '附件ID CSV', + `cc_user_ids` VARCHAR(500) NULL COMMENT '抄送人用户ID CSV', + `status` INT NOT NULL COMMENT '0审批中 1已通过 2已拒绝 3已销假 4已撤回', + `biz_id` VARCHAR(64) NULL COMMENT '{leaveId}-{attempt},流程中心幂等键', + `instance_id` BIGINT NULL COMMENT '流程实例ID', + `instance_version` INT NULL COMMENT '已处理最大流程版本(乱序门)', + `flow_guid` VARCHAR(64) NULL COMMENT '流程定义标识', + `flow_attempt` INT NULL COMMENT '第几次提交', + `snapshot` TEXT NULL COMMENT '终态快照 JSON(审批意见等)', + `apply_time` DATETIME NOT NULL COMMENT '申请时间', + `cancel_time` DATETIME NULL COMMENT '销假时间', + `cancel_reason` VARCHAR(500) NULL COMMENT '销假事由', + `create_by` INT NULL COMMENT '创建人用户ID', + `create_time` DATETIME NULL COMMENT '创建时间', + `update_by` INT NULL COMMENT '更新人用户ID', + `update_time` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_la_sch_user_status` (`sch_id`, `user_id`, `status`), + KEY `idx_la_sch_apply_time` (`sch_id`, `apply_time`), + KEY `idx_la_biz_id` (`biz_id`), + KEY `idx_la_instance` (`instance_id`), + KEY `idx_la_sch_type` (`sch_id`, `leave_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='请假申请主表'; + +-- ------------------------------------------------------------ +-- 学校请假设置(一校一行) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_setting` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL COMMENT '学校ID', + `reason_must` INT NULL COMMENT '事由必填 0/1(默认1)', + `show_explain` INT NULL COMMENT '显示请假说明 0/1(默认1)', + `enable_course` INT NULL COMMENT '启用课务安排 0/1(P7)', + `allow_exchange` INT NULL COMMENT '允许换课 0/1(P7)', + `time_managed_types` VARCHAR(500) NULL COMMENT '时长管控类型 CSV(dictKey)', + `time_limit_enabled` INT NULL COMMENT '启用时长管控 0/1', + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ls_sch` (`sch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学校请假设置'; + +-- ------------------------------------------------------------ +-- 学校作息时间(一校一行) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_time_set` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL, + `forenoon1` VARCHAR(10) NULL COMMENT '上午开始 HH:mm(默认08:00)', + `forenoon2` VARCHAR(10) NULL COMMENT '上午结束 HH:mm(默认12:00)', + `afternoon1` VARCHAR(10) NULL COMMENT '下午开始 HH:mm(默认13:00)', + `afternoon2` VARCHAR(10) NULL COMMENT '下午结束 HH:mm(默认17:00)', + `interval_minutes` INT NULL COMMENT '最短请假间隔(分钟,默认10)', + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_lts_sch` (`sch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学校请假作息时间'; + +-- ------------------------------------------------------------ +-- 教师时长池(一校一人一行) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_user_time` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL, + `user_id` INT NOT NULL COMMENT '教师用户ID', + `user_name` VARCHAR(64) NULL COMMENT '教师姓名(冗余)', + `usable_hours` DOUBLE NULL DEFAULT 0 COMMENT '剩余可用小时(默认0)', + `remark` VARCHAR(500) NULL, + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_lut_sch_user` (`sch_id`, `user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教师可用时长池'; + +-- ------------------------------------------------------------ +-- 时长变更流水 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_user_time_record` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL, + `user_id` INT NOT NULL COMMENT '教师用户ID', + `user_name` VARCHAR(64) NULL COMMENT '教师姓名(冗余)', + `leave_id` INT NULL COMMENT '关联请假单ID(手动调整为空)', + `leave_type` VARCHAR(50) NULL COMMENT '请假类型 dictKey(手动调整为空串)', + `edit_hours` DOUBLE NULL COMMENT '本次增减(正加负减)', + `old_hours` DOUBLE NULL COMMENT '变更前', + `new_hours` DOUBLE NULL COMMENT '变更后', + `describe_text` VARCHAR(500) NULL COMMENT '备注(扣除/销假返还/新加…)', + `operator_id` INT NULL COMMENT '操作人用户ID', + `occur_time` DATETIME NOT NULL COMMENT '发生时间(统计口径)', + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_lutr_sch_time` (`sch_id`, `occur_time`), + KEY `idx_lutr_leave` (`leave_id`), + KEY `idx_lutr_sch_user` (`sch_id`, `user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教师时长变更流水'; + +-- ------------------------------------------------------------ +-- 节次(P7 指派代课数据源;设置页可维护) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_section` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL, + `name` VARCHAR(100) NULL COMMENT '节次名称', + `rank_no` INT NULL COMMENT '排序号', + `begin_time` VARCHAR(10) NULL COMMENT '开始 HH:mm', + `end_time` VARCHAR(10) NULL COMMENT '结束 HH:mm', + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_lsec_sch` (`sch_id`, `rank_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节次'; + +-- ------------------------------------------------------------ +-- 抄送留痕 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_cc` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL, + `leave_id` INT NOT NULL COMMENT '请假单ID', + `cc_user_id` INT NULL COMMENT '被抄送人用户ID', + `cc_user_name` VARCHAR(64) NULL COMMENT '被抄送人姓名(冗余)', + `instance_id` BIGINT NULL COMMENT '流程实例ID(幂等)', + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_lcc_sch_leave` (`sch_id`, `leave_id`), + KEY `idx_lcc_sch_user` (`sch_id`, `cc_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抄送留痕'; + +-- ------------------------------------------------------------ +-- 审核痕迹("我已审核"列表 / 详情时间线补全) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_audit_trace` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL, + `leave_id` INT NOT NULL COMMENT '请假单ID', + `task_id` BIGINT NULL COMMENT '流程任务ID', + `action` VARCHAR(20) NULL COMMENT 'approve / reject', + `opinion` VARCHAR(500) NULL COMMENT '审批意见', + `operator_id` INT NULL COMMENT '操作人用户ID', + `operator_name` VARCHAR(64) NULL COMMENT '操作人姓名(冗余)', + `audit_time` DATETIME NOT NULL COMMENT '审核时间', + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_lat_sch_operator` (`sch_id`, `operator_id`, `audit_time`), + KEY `idx_lat_leave` (`leave_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审核痕迹'; + +-- ------------------------------------------------------------ +-- 审批流程绑定(流程管理页) +-- 手册 §1.3/§3.2:bizType 恒为 leave,多套审批流用 flowGuid 区分; +-- 这里只记录「本校/某请假类型选用哪条 flowGuid」,流程本体在流程中心编排。 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_flow_bind` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL COMMENT '学校ID', + `bind_scope` INT NOT NULL DEFAULT 0 COMMENT '绑定范围:0=学校默认流程 / 1=按请假类型指定', + `scope_key` VARCHAR(100) NULL COMMENT '范围键:scope=1 为请假类型 dictKey;scope=0 固定 "-”', + `flow_guid` VARCHAR(100) NOT NULL COMMENT '流程定义标识(流程中心编排注册所得)', + `flow_name` VARCHAR(200) NULL COMMENT '流程名称(冗余展示)', + `flow_version` INT NULL COMMENT '流程版本(冗余展示)', + `node_summary` VARCHAR(500) NULL COMMENT '节点摘要,如「发起 → 部门审核 → 校长审批」', + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_lfb_sch` (`sch_id`, `bind_scope`), + KEY `idx_lfb_unique` (`sch_id`, `bind_scope`, `scope_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流程绑定(流程管理)'; + +-- ------------------------------------------------------------ +-- 流程启用绑定(流程管理页) +-- 标记哪条流程「启用并绑定到本应用」:开启后才允许设为默认/按类型绑定; +-- 关闭时服务层会同时解除该流程的全部绑定。唯一性由 (sch_id, flow_guid) 逻辑保证。 +-- 存量数据自愈:LeaveFlowBindService.manage() 会对已绑定但缺启用记录的流程自动回填。 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `leave_flow_enable` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL COMMENT '学校ID', + `flow_guid` VARCHAR(100) NOT NULL COMMENT '流程定义标识(流程中心编排注册所得)', + `flow_name` VARCHAR(200) NULL COMMENT '流程名称(冗余展示)', + `flow_version` INT NULL COMMENT '流程版本(冗余展示)', + `node_summary` VARCHAR(500) NULL COMMENT '节点摘要,如「发起 → 部门审核 → 校长审批」', + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_lfe_sch` (`sch_id`, `flow_guid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程启用绑定(流程管理)'; + +-- ------------------------------------------------------------ +-- 字典种子:请假类型(sch_id=0 全局兜底;学校首次进入时由服务初始化自有 7 类副本) +-- ------------------------------------------------------------ +INSERT IGNORE INTO `sys_dict` (`sch_id`, `dict_type`, `dict_key`, `dict_value`, `sort_order`, `enable_flag`) VALUES + (0, 'leaveType', 'LT001', '事假', 1, 1), + (0, 'leaveType', 'LT002', '病假', 2, 1), + (0, 'leaveType', 'LT003', '调休', 3, 1), + (0, 'leaveType', 'LT004', '公假', 4, 1), + (0, 'leaveType', 'LT005', '婚假', 5, 1), + (0, 'leaveType', 'LT006', '产假', 6, 1), + (0, 'leaveType', 'LT007', '丧假', 7, 1); + +-- ------------------------------------------------------------ +-- 通用表:附件(沿用);若由 ddl-auto 创建过则忽略 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `up_file` ( + `id` INT NOT NULL AUTO_INCREMENT, + `topic_type` VARCHAR(50) NULL COMMENT '主题类型(leave)', + `topic_id` INT NULL COMMENT '主题ID(请假单ID)', + `file_true_name` VARCHAR(255) NULL COMMENT '原始文件名', + `file_name` VARCHAR(255) NULL COMMENT '存储相对路径', + `file_size` INT NULL, + `file_type` VARCHAR(50) NULL, + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_uf_topic` (`topic_type`, `topic_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='附件元信息'; + +-- ------------------------------------------------------------ +-- 通用表:字典 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `sys_dict` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NULL DEFAULT 0 COMMENT '0=全局种子', + `dict_type` VARCHAR(50) NULL COMMENT 'leaveType', + `dict_key` VARCHAR(100) NULL, + `dict_value` VARCHAR(200) NULL, + `sort_order` INT NULL DEFAULT 0, + `enable_flag` INT NULL DEFAULT 1, + `create_by` INT NULL, + `create_time` DATETIME NULL, + `update_by` INT NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_sd_type` (`dict_type`, `sch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统字典'; diff --git a/src/test/java/com/yunhai/workleave/controller/oapi/FlowJudgeControllerTest.java b/src/test/java/com/yunhai/workleave/controller/oapi/FlowJudgeControllerTest.java new file mode 100644 index 0000000..b5c6c0a --- /dev/null +++ b/src/test/java/com/yunhai/workleave/controller/oapi/FlowJudgeControllerTest.java @@ -0,0 +1,168 @@ +package com.yunhai.workleave.controller.oapi; + +import com.yunhai.workleave.controller.oapi.FlowJudgeController.JudgeBody; +import com.yunhai.workleave.entity.LeaveApply; +import com.yunhai.workleave.repository.LeaveApplyRepository; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 条件路由判断端点单测:出口契约 / 时长口径 / variables 免查库 / bizId 解析。 + */ +class FlowJudgeControllerTest { + + private static final String LT = FlowJudgeController.OUTLET_LT_3_DAYS; + private static final String GE = FlowJudgeController.OUTLET_GE_3_DAYS; + + private LeaveApplyRepository leaveApplyRepository; + private FlowJudgeController controller; + + private LeaveApply leave; + + @BeforeEach + void setUp() { + leaveApplyRepository = mock(LeaveApplyRepository.class); + controller = new FlowJudgeController(); + inject(controller, "leaveApplyRepository", leaveApplyRepository); + + leave = new LeaveApply(); + leave.setId(100); + leave.setTotalHours(3.0); + when(leaveApplyRepository.findById(100)).thenReturn(Optional.of(leave)); + } + + // ============================ 时长口径 ============================ + + /** formData 透传 totalHours:小于3天(24h)走「小于3天」出口,免查库 */ + @Test + void variablesLt3Days_picksLtOutletWithoutDb() { + JudgeBody body = body(Arrays.asList(LT, GE)); + body.setVariables(variables(23.9)); + + Map result = controller.deptJudge(body); + + assertEquals(LT, result.get("outlet")); + verify(leaveApplyRepository, never()).findById(org.mockito.ArgumentMatchers.anyInt()); + } + + /** 恰好3天(24h)归「大于等于3天」出口 */ + @Test + void exactly3Days_picksGeOutlet() { + JudgeBody body = body(Arrays.asList(LT, GE)); + body.setVariables(variables(24.0)); + + assertEquals(GE, controller.deptJudge(body).get("outlet")); + } + + /** 超过3天走「大于等于3天」出口 */ + @Test + void over3Days_picksGeOutlet() { + JudgeBody body = body(Arrays.asList(LT, GE)); + body.setVariables(variables(72.5)); + + assertEquals(GE, controller.deptJudge(body).get("outlet")); + } + + // ============================ 回落查表 ============================ + + /** variables 缺 totalHours(旧实例)回落按 bizId 首段查表 */ + @Test + void missingVariables_fallsBackToDb() { + JudgeBody body = body(Arrays.asList(LT, GE)); + leave.setTotalHours(16.0); + + assertEquals(LT, controller.deptJudge(body).get("outlet")); + verify(leaveApplyRepository).findById(100); + } + + /** bizId 形态 {leaveId}-{attempt},取首段定位 */ + @Test + void bizIdWithAttemptSuffix_resolvesLeaveId() { + JudgeBody body = body(Arrays.asList(LT, GE)); + leave.setTotalHours(40.0); + + assertEquals(GE, controller.deptJudge(body).get("outlet")); + verify(leaveApplyRepository).findById(100); + } + + // ============================ 契约纪律 ============================ + + /** 出口名与流程定义出边不一致:抛业务错挂起,不默认放行 */ + @Test + void outletNotInContract_throws() { + JudgeBody body = body(Arrays.asList("出口一", "出口二")); + body.setVariables(variables(3.0)); + + assertThrows(BizException.class, () -> controller.deptJudge(body)); + } + + /** 单据不存在:抛业务错(flow-center 侧挂起) */ + @Test + void leaveNotFound_throws() { + JudgeBody body = body(Arrays.asList(LT, GE)); + body.setBizId("999999-1"); + + assertThrows(BizException.class, () -> controller.deptJudge(body)); + } + + /** 表里也无时长:无法判断即挂起 */ + @Test + void noHoursAnywhere_throws() { + JudgeBody body = body(Arrays.asList(LT, GE)); + leave.setTotalHours(null); + + assertThrows(BizException.class, () -> controller.deptJudge(body)); + } + + /** bizId 非法:抛参数错 */ + @Test + void illegalBizId_throws() { + JudgeBody body = body(Arrays.asList(LT, GE)); + body.setBizId("not-a-number"); + + assertThrows(BizException.class, () -> controller.deptJudge(body)); + } + + // ============================ 工具 ============================ + + private static JudgeBody body(java.util.List outlets) { + JudgeBody body = new JudgeBody(); + body.setSchId(9L); + body.setInstanceId(5001L); + body.setBizType("leave"); + body.setBizId("100-1"); + body.setRouterKey("work-leave.dept-judge"); + body.setOutlets(outlets); + return body; + } + + private static Map variables(double totalHours) { + Map vars = new HashMap<>(); + vars.put("totalHours", totalHours); + vars.put("days", Math.round(totalHours / 8D * 100D) / 100D); + return vars; + } + + private void inject(Object target, String field, Object value) { + try { + java.lang.reflect.Field f = target.getClass().getDeclaredField(field); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/yunhai/workleave/flow/LeaveFlowCallbackHandlerTest.java b/src/test/java/com/yunhai/workleave/flow/LeaveFlowCallbackHandlerTest.java new file mode 100644 index 0000000..950726b --- /dev/null +++ b/src/test/java/com/yunhai/workleave/flow/LeaveFlowCallbackHandlerTest.java @@ -0,0 +1,261 @@ +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.yunhai.platform.openapi.flow.model.FlowCallbackEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 流程回调状态机单测(mock FlowCallbackEvent,覆盖手册 §5.1 第 1 条)。 + */ +class LeaveFlowCallbackHandlerTest { + + private LeaveApplyRepository leaveApplyRepository; + private LeaveCcRepository leaveCcRepository; + private ILeaveUserTimeService userTimeService; + private LeaveFlowCallbackHandler handler; + + private LeaveApply leave; + + @BeforeEach + void setUp() { + leaveApplyRepository = mock(LeaveApplyRepository.class); + leaveCcRepository = mock(LeaveCcRepository.class); + userTimeService = mock(ILeaveUserTimeService.class); + + handler = new LeaveFlowCallbackHandler(); + inject(handler, "leaveApplyRepository", leaveApplyRepository); + inject(handler, "leaveCcRepository", leaveCcRepository); + inject(handler, "userTimeService", userTimeService); + + leave = new LeaveApply(); + leave.setId(100); + leave.setSchId(9); + leave.setUserId(1001); + leave.setUserName("张三"); + leave.setLeaveType("LT001"); + leave.setLeaveTypeName("事假"); + leave.setStartTime("2026-10-08 09:00:00"); + leave.setEndTime("2026-10-08 12:00:00"); + leave.setTotalHours(3.0); + leave.setStatus(LeaveStatusEnum.APPROVING.getId()); + leave.setBizId("100-1"); + leave.setInstanceId(5001L); + leave.setFlowAttempt(1); + + when(leaveApplyRepository.findByInstanceId(5001L)).thenReturn(Optional.of(leave)); + when(leaveApplyRepository.findById(100)).thenReturn(Optional.of(leave)); + when(leaveApplyRepository.save(any(LeaveApply.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + // ============================ 状态推进 ============================ + + @Test + void completed_marksPassedAndDeductsHours() { + handler.onEvent(event("flow.completed", 2, "同意", 2001)); + + assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus()); + assertNotNull(leave.getSnapshot()); + verify(userTimeService).deduct(eq(9), eq(1001), eq("张三"), eq(100), eq("LT001"), + eq(3.0), eq(2001)); + } + + @Test + void duplicatedCompleted_deductsOnlyOnce() { + FlowCallbackEvent completed = event("flow.completed", 2, "同意", 2001); + handler.onEvent(completed); + handler.onEvent(completed); + + assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus()); + verify(userTimeService, times(1)).deduct(eq(9), eq(1001), eq("张三"), eq(100), eq("LT001"), + eq(3.0), eq(2001)); + } + + @Test + void conflictingFinalEvent_doesNotOverwritePassed() { + handler.onEvent(event("flow.completed", 2, "同意", 2001)); + handler.onEvent(event("flow.rejected", 3, "迟到的拒绝事件", 2002)); + + assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus()); + verify(userTimeService, times(1)).deduct(anyInt(), anyInt(), anyString(), any(), anyString(), + anyDouble(), any()); + } + + @Test + void rejected_marksRejectedWithoutDeductOrRefund() { + handler.onEvent(event("flow.rejected", 2, "事由不充分", 2002)); + + assertEquals(LeaveStatusEnum.REJECTED.getId(), leave.getStatus()); + verify(userTimeService, never()).deduct(anyInt(), anyInt(), anyString(), any(), anyString(), anyDouble(), any()); + verify(userTimeService, never()).refund(anyInt(), anyInt(), anyString(), any(), anyString(), + anyDouble(), any(), anyString()); + } + + @Test + void withdrawn_marksWithdrawnWhenApproving() { + handler.onEvent(event("flow.withdrawn", 3, null, 1001)); + assertEquals(LeaveStatusEnum.WITHDRAWN.getId(), leave.getStatus()); + } + + @Test + void withdrawn_ignoredWhenNotApproving() { + leave.setStatus(LeaveStatusEnum.PASSED.getId()); + handler.onEvent(event("flow.withdrawn", 3, null, 1001)); + assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus()); + } + + @Test + void taskCreated_keepsApproving() { + handler.onEvent(event("flow.task.created", 2, null, 3001)); + assertEquals(LeaveStatusEnum.APPROVING.getId(), leave.getStatus()); + } + + /** 终态不回退:已通过后再来非终态事件(乱序到达)不得打回审批中 */ + @Test + void lateNonFinalEvent_doesNotRollbackFinalStatus() { + leave.setStatus(LeaveStatusEnum.PASSED.getId()); + handler.onEvent(event("flow.task.handled", 2, "同意", 3002)); + assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus()); + } + + @Test + void lateNonFinalEvent_doesNotRollbackWithdrawnStatus() { + leave.setStatus(LeaveStatusEnum.WITHDRAWN.getId()); + handler.onEvent(event("flow.task.handled", 2, "同意", 3002)); + assertEquals(LeaveStatusEnum.WITHDRAWN.getId(), leave.getStatus()); + } + + @Test + void staleAttemptEvent_doesNotTouchCurrentAttempt() { + leave.setBizId("100-2"); + leave.setInstanceId(6002L); + leave.setFlowAttempt(2); + + handler.onEvent(event("flow.completed", 3, "旧实例迟到", 2001)); + + assertEquals(LeaveStatusEnum.APPROVING.getId(), leave.getStatus()); + verify(userTimeService, never()).deduct(anyInt(), anyInt(), anyString(), any(), anyString(), + anyDouble(), any()); + verify(leaveApplyRepository, never()).save(any(LeaveApply.class)); + } + + /** 乱序门:事件版本低于已记录版本则忽略 */ + @Test + void staleEventVersion_isIgnored() { + leave.setStatus(LeaveStatusEnum.APPROVING.getId()); + leave.setInstanceVersion(5); + handler.onEvent(event("flow.rejected", 4, "拒绝", 3003)); + assertEquals(LeaveStatusEnum.APPROVING.getId(), leave.getStatus()); + } + + /** 脏消息:单据不存在时正常返回不抛异常(避免触发无限重推) */ + @Test + void unknownBizId_returnsQuietly() { + FlowCallbackEvent event = event("flow.completed", 2, "同意", 3004); + event.setBizId("999999-1"); + event.setInstanceId(88888L); + handler.onEvent(event); + verify(leaveApplyRepository, never()).save(any(LeaveApply.class)); + } + + @Test + void unknownEvent_isIgnored() { + handler.onEvent(event("flow.unknown.event", 2, null, 3005)); + assertEquals(LeaveStatusEnum.APPROVING.getId(), leave.getStatus()); + } + + // ============================ 抄送留痕 ============================ + + @Test + void ccSent_recordsTrace() { + when(leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(100, 4001, 5001L)).thenReturn(false); + FlowCallbackEvent event = event("flow.cc.sent", 2, null, 4001); + event.setOperatorName("李四"); + + handler.onEvent(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LeaveCc.class); + verify(leaveCcRepository).save(captor.capture()); + LeaveCc cc = captor.getValue(); + assertEquals(100, cc.getLeaveId()); + assertEquals(4001, cc.getCcUserId()); + assertEquals("李四", cc.getCcUserName()); + assertEquals(5001L, cc.getInstanceId()); + } + + @Test + void ccSent_isIdempotent() { + when(leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(100, 4001, 5001L)).thenReturn(true); + handler.onEvent(event("flow.cc.sent", 2, null, 4001)); + verify(leaveCcRepository, never()).save(any(LeaveCc.class)); + } + + /** 抄送事件也支持 data.ccUserIds 列表形态 */ + @Test + void ccSent_supportsDataList() { + when(leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(eq(100), anyInt(), eq(5001L))) + .thenReturn(false); + FlowCallbackEvent event = event("flow.cc.sent", 2, null, null); + Map data = new HashMap<>(); + data.put("ccUserIds", "5001,5002"); + event.setData(data); + + handler.onEvent(event); + + verify(leaveCcRepository, times(2)).save(any(LeaveCc.class)); + } + + // ============================ 辅助 ============================ + + private FlowCallbackEvent event(String eventCode, Integer version, String opinion, Integer operatorId) { + FlowCallbackEvent event = new FlowCallbackEvent(); + event.setEvent(eventCode); + event.setBizId("100-1"); + event.setInstanceId(5001L); + event.setInstanceVersion(version); + event.setBizType("leave"); + event.setSchId(9); + event.setOperatorUserId(operatorId); + event.setOperatorName("审核人"); + if (opinion != null) { + Map data = new HashMap<>(); + data.put("opinion", opinion); + event.setData(data); + } + return event; + } + + private void inject(Object target, String field, Object value) { + try { + java.lang.reflect.Field f = target.getClass().getDeclaredField(field); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/yunhai/workleave/flow/LeaveFlowServiceTest.java b/src/test/java/com/yunhai/workleave/flow/LeaveFlowServiceTest.java new file mode 100644 index 0000000..1514f95 --- /dev/null +++ b/src/test/java/com/yunhai/workleave/flow/LeaveFlowServiceTest.java @@ -0,0 +1,53 @@ +package com.yunhai.workleave.flow; + +import com.yunhai.platform.openapi.flow.model.FlowApplyBody; +import com.yunhai.workleave.entity.LeaveApply; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** 流程发起请求契约测试(手册 §2.3、§3.1、§3.2、§5.1)。 */ +class LeaveFlowServiceTest { + + @Test + @SuppressWarnings("unchecked") + void buildApplyBody_matchesPlatformContract() { + LeaveFlowService service = new LeaveFlowService(); + ReflectionTestUtils.setField(service, "defaultFlowGuid", "leave-apply-v1"); + + LeaveApply leave = new LeaveApply(); + leave.setId(100); + leave.setSchId(9); + leave.setUserId(1001); + leave.setUserName("张三"); + leave.setLeaveType("LT001"); + leave.setLeaveTypeName("事假"); + leave.setStartTime("2026-10-09 08:00:00"); + leave.setEndTime("2026-10-09 17:00:00"); + leave.setTotalHours(8D); + leave.setReason("家中有事"); + + FlowApplyBody body = service.buildApplyBody(leave, "100-1", "张三的事假申请"); + + assertEquals("leave-apply-v1", body.getFlowGuid()); + assertEquals("leave", body.getBizType()); + assertEquals("100-1", body.getBizId()); + assertEquals("张三的事假申请", body.getBizTitle()); + assertEquals("/detail?leaveId=100", body.getBizUrl()); + assertEquals(1001, body.getSubmitterId()); + assertEquals("张三", body.getSubmitterName()); + + Map snapshot = (Map) body.getBizSnapshot(); + assertNotNull(snapshot); + assertEquals("事假", snapshot.get("leaveTypeName")); + assertEquals(8D, snapshot.get("totalHours")); + + assertEquals("LT001", body.getFormData().get("leaveType")); + assertEquals(8D, body.getFormData().get("totalHours")); + assertEquals(1D, body.getFormData().get("days")); + } +} diff --git a/src/test/java/com/yunhai/workleave/service/impl/DictServiceSeedTest.java b/src/test/java/com/yunhai/workleave/service/impl/DictServiceSeedTest.java new file mode 100644 index 0000000..ddc813e --- /dev/null +++ b/src/test/java/com/yunhai/workleave/service/impl/DictServiceSeedTest.java @@ -0,0 +1,155 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.workleave.entity.SysDict; +import com.yunhai.workleave.repository.LeaveApplyRepository; +import com.yunhai.workleave.repository.SysDictRepository; +import com.yunhai.workleave.utils.UserContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 请假类型首次进入初始化测试: + * 学校无自有 leaveType 字典时播种默认 7 类;有自有字典则完全替代全局种子,互不混显。 + */ +class DictServiceSeedTest { + + private static final int SCH_ID = 9; + + private final SysDictRepository repo = mock(SysDictRepository.class); + private final LeaveApplyRepository leaveApplyRepo = mock(LeaveApplyRepository.class); + private final DictService service = new DictService(); + + @AfterEach + void tearDown() { + UserContext.clear(); + } + + @Test + void firstEnter_seedsSevenDefaultTypes() throws Exception { + inject(); + UserContext.setSchId(SCH_ID); + List seeded = new ArrayList<>(); + when(repo.existsByDictTypeAndSchId("leaveType", SCH_ID)).thenReturn(false); + when(repo.saveAll(anyList())).thenAnswer(inv -> { + seeded.addAll(new ArrayList<>((List) inv.getArgument(0))); + return seeded; + }); + when(repo.findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", SCH_ID)) + .thenAnswer(inv -> seeded); + + List> list = service.getDictMangeList("leaveType"); + + assertEquals(7, list.size()); + String[] names = {"事假", "病假", "调休", "公假", "婚假", "产假", "丧假"}; + for (int i = 0; i < names.length; i++) { + java.util.Map item = list.get(i); + assertEquals(names[i], item.get("Name")); + assertEquals(String.format("LT%03d", i + 1), item.get("CodeID")); + assertEquals(1, item.get("Enable")); + } + verify(repo).saveAll(anyList()); + } + + @Test + void hasOwnTypes_neverSeedsAgain() throws Exception { + inject(); + UserContext.setSchId(SCH_ID); + when(repo.existsByDictTypeAndSchId("leaveType", SCH_ID)).thenReturn(true); + when(repo.findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", SCH_ID)) + .thenReturn(new ArrayList<>()); + + service.getDictMangeList("leaveType"); + + verify(repo, never()).saveAll(anyList()); + } + + @Test + void ownTypes_shownInsteadOfGlobalSeed() throws Exception { + inject(); + UserContext.setSchId(SCH_ID); + SysDict own = dict(SCH_ID, "LT001", "事假(本校改)", 1); + when(repo.existsByDictTypeAndSchId("leaveType", SCH_ID)).thenReturn(true); + when(repo.findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", SCH_ID)) + .thenReturn(new ArrayList<>(Arrays.asList(own))); + when(repo.findByDictTypeAndSchIdAndEnableFlagOrderBySortOrderAsc("leaveType", SCH_ID, 1)) + .thenReturn(new ArrayList<>(Arrays.asList(own))); + + List> manage = service.getDictMangeList("leaveType"); + assertEquals(1, manage.size()); + assertEquals("事假(本校改)", manage.get(0).get("Name")); + + List> options = service.getSysDic(1); + assertEquals(1, options.size()); + assertEquals("事假(本校改)", options.get(0).get("v")); + // 全局种子查询不应被触发(有自有字典即完全替代) + verify(repo, never()).findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", 0); + } + + @Test + void resolveTypeName_prefersOwnRow() throws Exception { + inject(); + UserContext.setSchId(SCH_ID); + SysDict own = dict(SCH_ID, "LT001", "事假(本校改)", 1); + when(repo.findFirstByDictTypeAndDictKeyAndSchId("leaveType", "LT001", SCH_ID)) + .thenReturn(Optional.of(own)); + assertEquals("事假(本校改)", service.resolveTypeName("LT001")); + + // 本校无自有行时回退全局种子 + when(repo.findFirstByDictTypeAndDictKeyAndSchId("leaveType", "LT002", SCH_ID)) + .thenReturn(Optional.empty()); + when(repo.findFirstByDictTypeAndDictKeyAndSchId("leaveType", "LT002", 0)) + .thenReturn(Optional.of(dict(0, "LT002", "病假", 2))); + assertEquals("病假", service.resolveTypeName("LT002")); + } + + @Test + void noSchoolContext_keepsGlobalFallbackWithoutSeeding() throws Exception { + inject(); + when(repo.existsByDictTypeAndSchId(anyString(), anyInt())).thenReturn(false); + when(repo.findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", 0)) + .thenReturn(new ArrayList<>(Arrays.asList(dict(0, "LT001", "事假", 1)))); + + List> list = service.getDictMangeList("leaveType"); + + assertEquals(1, list.size()); + verify(repo, never()).saveAll(anyList()); + } + + private SysDict dict(Integer schId, String key, String value, int sort) { + SysDict d = new SysDict(); + d.setSchId(schId); + d.setDictType("leaveType"); + d.setDictKey(key); + d.setDictValue(value); + d.setSortOrder(sort); + d.setEnableFlag(1); + return d; + } + + private void inject() throws Exception { + Field f = DictService.class.getDeclaredField("sysDictRepository"); + f.setAccessible(true); + f.set(service, repo); + Field f2 = DictService.class.getDeclaredField("leaveApplyRepository"); + f2.setAccessible(true); + f2.set(service, leaveApplyRepo); + } +} diff --git a/src/test/java/com/yunhai/workleave/service/impl/LeaveApplyServiceFlowTest.java b/src/test/java/com/yunhai/workleave/service/impl/LeaveApplyServiceFlowTest.java new file mode 100644 index 0000000..a73740c --- /dev/null +++ b/src/test/java/com/yunhai/workleave/service/impl/LeaveApplyServiceFlowTest.java @@ -0,0 +1,54 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.flow.model.FlowApplyResult; +import com.yunhai.workleave.entity.LeaveApply; +import com.yunhai.workleave.flow.ILeaveFlowService; +import com.yunhai.workleave.service.ILeaveFlowBindService; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** 流程发起结果边界测试:无 instanceId 必须失败,交由外层事务回滚。 */ +class LeaveApplyServiceFlowTest { + + @Test + void startFlow_withoutInstanceId_throwsBizException() throws Exception { + LeaveApplyService service = new LeaveApplyService(); + ILeaveFlowService flowService = mock(ILeaveFlowService.class); + inject(service, "flowService", flowService); + // startFlow 的流程选用走绑定服务(单据已定 → 按类型 → 学校默认),需一并注入 + ILeaveFlowBindService flowBindService = mock(ILeaveFlowBindService.class); + inject(service, "flowBindService", flowBindService); + + LeaveApply leave = new LeaveApply(); + leave.setId(100); + leave.setSchId(9); + leave.setUserName("张三"); + leave.setLeaveTypeName("事假"); + + when(flowBindService.resolveFlowGuid(any(), any())).thenReturn("leave-apply-v1"); + when(flowService.apply(any(LeaveApply.class), anyString(), anyString())) + .thenReturn(new FlowApplyResult()); + + Method startFlow = LeaveApplyService.class.getDeclaredMethod("startFlow", LeaveApply.class, int.class); + startFlow.setAccessible(true); + InvocationTargetException thrown = assertThrows(InvocationTargetException.class, + () -> startFlow.invoke(service, leave, 1)); + assertTrue(thrown.getCause() instanceof BizException); + } + + private void inject(Object target, String field, Object value) throws Exception { + java.lang.reflect.Field f = target.getClass().getDeclaredField(field); + f.setAccessible(true); + f.set(target, value); + } +} diff --git a/src/test/java/com/yunhai/workleave/service/impl/LeaveTimeCalculatorTest.java b/src/test/java/com/yunhai/workleave/service/impl/LeaveTimeCalculatorTest.java new file mode 100644 index 0000000..155da9c --- /dev/null +++ b/src/test/java/com/yunhai/workleave/service/impl/LeaveTimeCalculatorTest.java @@ -0,0 +1,118 @@ +package com.yunhai.workleave.service.impl; + +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 请假时长计算器单测(作息 08:00-12:00 / 13:00-17:00,一天 8 小时)。 + */ +class LeaveTimeCalculatorTest { + + private final LeaveTimeCalculator calculator = new LeaveTimeCalculator(); + + private double calc(String start, String end) { + return calculator.calc(start, end, "08:00", "12:00", "13:00", "17:00"); + } + + // ============================ 同日 ============================ + + @Test + void sameDay_morningOnly() { + assertEquals(3.0, calc("2026-10-08 09:00", "2026-10-08 12:00"), 0.001); + } + + @Test + void sameDay_afternoonOnly() { + assertEquals(2.5, calc("2026-10-08 14:30", "2026-10-08 17:00"), 0.001); + } + + @Test + void sameDay_fullDay_excludesNoon() { + // 08:00-17:00 跨午休 1 小时 → 8 小时 + assertEquals(8.0, calc("2026-10-08 08:00", "2026-10-08 17:00"), 0.001); + } + + @Test + void sameDay_crossNoon_excludesNoon() { + // 11:00-14:00 = 3 小时 - 1 小时午休 = 2 小时 + assertEquals(2.0, calc("2026-10-08 11:00", "2026-10-08 14:00"), 0.001); + } + + @Test + void sameDay_startBeforeWork_clampedToForenoon1() { + assertEquals(4.0, calc("2026-10-08 07:00", "2026-10-08 12:00"), 0.001); + } + + @Test + void sameDay_endAfterWork_clampedToAfternoon2() { + assertEquals(4.0, calc("2026-10-08 13:00", "2026-10-08 20:00"), 0.001); + } + + @Test + void sameDay_startInNoon_clampedToAfternoon1() { + assertEquals(1.0, calc("2026-10-08 12:30", "2026-10-08 14:00"), 0.001); + } + + @Test + void sameDay_endInNoon_clampedToForenoon2() { + assertEquals(1.0, calc("2026-10-08 11:00", "2026-10-08 12:30"), 0.001); + } + + // ============================ 跨天 ============================ + + @Test + void crossDay_oneFullMiddleDay() { + // 10-08 08:00 → 10-10 17:00:首日 8h + 中间 10-09 整天 8h + 末日 8h = 24h + assertEquals(24.0, calc("2026-10-08 08:00", "2026-10-10 17:00"), 0.001); + } + + @Test + void crossDay_afternoonToNextMorning_noMiddleDay() { + // 10-08 14:00→17:00 = 3h;10-09 08:00→12:00 = 4h;无中间整天 → 7h + assertEquals(7.0, calc("2026-10-08 14:00", "2026-10-09 12:00"), 0.001); + } + + @Test + void crossDay_morningToNextAfternoon() { + // 首日 09:00-17:00 = 7h(扣午休);末日 08:00-15:00 = 6h(扣午休)→ 13h + assertEquals(13.0, calc("2026-10-08 09:00", "2026-10-09 15:00"), 0.001); + } + + @Test + void crossDay_endMorning_coversLastDayNoonNot() { + // 10-08 13:00 → 10-09 11:00:首日 4h + 末日 3h = 7h + assertEquals(7.0, calc("2026-10-08 13:00", "2026-10-09 11:00"), 0.001); + } + + // ============================ 校验与重叠 ============================ + + @Test + void invalid_endNotAfterStart() { + BizException ex = assertThrows(BizException.class, () -> calc("2026-10-08 15:00", "2026-10-08 10:00")); + assertTrue(ex.getMessage().contains("结束时间必须晚于开始时间")); + } + + @Test + void invalid_badFormat() { + assertThrows(BizException.class, () -> calc("2026/10/08 09:00", "2026/10/08 10:00")); + } + + @Test + void overlap_detectsIntersection() { + assertTrue(calculator.overlap("2026-10-08 09:00", "2026-10-08 12:00", + "2026-10-08 11:00", "2026-10-08 14:00")); + assertFalse(calculator.overlap("2026-10-08 09:00", "2026-10-08 12:00", + "2026-10-08 13:00", "2026-10-08 14:00")); + } + + @Test + void overlap_touchingEndpointsIsNotOverlap() { + assertFalse(calculator.overlap("2026-10-08 09:00", "2026-10-08 12:00", + "2026-10-08 12:00", "2026-10-08 14:00")); + } +}