diff --git a/.image/common/oa-feature.png b/.image/common/oa-feature.png
new file mode 100644
index 0000000000..e276412c00
Binary files /dev/null and b/.image/common/oa-feature.png differ
diff --git a/.image/common/oa-preview.png b/.image/common/oa-preview.png
new file mode 100644
index 0000000000..cec427328d
Binary files /dev/null and b/.image/common/oa-preview.png differ
diff --git a/README.md b/README.md
index dbb87e8be6..4eebc75fa3 100644
--- a/README.md
+++ b/README.md
@@ -28,8 +28,8 @@
| 【完整版】[ruoyi-vue-pro](https://gitee.com/zhijiantianya/ruoyi-vue-pro) | [`master`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master-jdk17/) 分支 | [`master-jdk25`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master-jdk25/) 分支 |
| 【精简版】[yudao-boot-mini](https://gitee.com/yudaocode/yudao-boot-mini) | [`master`](https://gitee.com/yudaocode/yudao-boot-mini/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/yudaocode/yudao-boot-mini/tree/master-jdk17/) 分支 | [`master-jdk25`](https://gitee.com/yudaocode/yudao-boot-mini/tree/master-jdk25/) 分支 |
-* 【完整版】:包括系统功能、基础设施、会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、HRM、FMS、PMS、IM 即时通讯、AI 大模型、IoT 物联网等功能
-* 【精简版】:只包括系统功能、基础设施功能,不包括会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、HRM、FMS、PMS、IM 即时通讯、AI 大模型、IoT 物联网等功能
+* 【完整版】:包括系统功能、基础设施、工作流程、支付系统、数据报表、微信公众号、商城系统、会员中心、ERP、WMS、CRM、MES、HRM、FMS、PMS、OA 协同办公、AI 大模型、IoT 物联网、IM 即时通讯等功能
+* 【精简版】:只包括系统功能、基础设施功能,不包括工作流程、支付系统、数据报表、微信公众号、商城系统、会员中心、ERP、WMS、CRM、MES、HRM、FMS、PMS、OA 协同办公、AI 大模型、IoT 物联网、IM 即时通讯等功能
可参考 [《迁移文档》](https://doc.iocoder.cn/migrate-module/) ,只需要 5-10 分钟,即可将【完整版】按需迁移到【精简版】
@@ -112,7 +112,7 @@
* 通用模块(必选):系统功能、基础设施
* 通用模块(可选):工作流程、支付系统、数据报表、会员中心
-* 业务系统(按需):Mall 电子商城、OA 办公自动化、ERP 企业资源计划系统、WMS 仓库管理系统、CRM 客户关系管理、CMS 内容管理系统、MES 执行制造系统、HRM 人力资源管理、FMS 财务管理、PMS 项目管理、AI 大模型平台、IoT 物联网系统、IM 即时通讯系统、Mobile 手机移动端、Report 数据大屏
+* 业务系统(按需):Mall 电子商城、ERP 企业资源计划系统、WMS 仓库管理系统、CRM 客户关系管理、CMS 内容管理系统、MES 执行制造系统、HRM 人力资源管理、FMS 财务管理、PMS 项目管理、OA 办公自动化、AI 大模型平台、IoT 物联网系统、IM 即时通讯系统、Mobile 手机移动端、Report 数据大屏
> 友情提示:本项目基于 RuoYi-Vue 修改,**重构优化**后端的代码,**美化**前端的界面。
>
@@ -147,6 +147,31 @@

+### 基础设施
+
+| | 功能 | 描述 |
+|-----|-----------|----------------------------------------------|
+| 🚀 | 代码生成 | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 |
+| 🚀 | 系统接口 | 基于 Swagger 自动生成相关的 RESTful API 接口文档 |
+| 🚀 | 数据库文档 | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 |
+| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 |
+| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 |
+| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 |
+| 🚀 | 文件服务 | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 |
+| 🚀 | WebSocket | 提供 WebSocket 接入示例,支持一对一、一对多发送方式 |
+| 🚀 | API 日志 | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 |
+| | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 |
+| | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 |
+| 🚀 | 消息队列 | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 |
+| 🚀 | Java 监控 | 基于 Spring Boot Admin 实现 Java 应用的监控 |
+| 🚀 | 链路追踪 | 接入 SkyWalking 组件,实现链路追踪 |
+| 🚀 | 日志中心 | 接入 SkyWalking 组件,实现日志中心 |
+| 🚀 | 服务保障 | 基于 Redis 实现分布式锁、幂等、限流功能,满足高并发场景 |
+| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 |
+| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 |
+
+
+
### 工作流程

@@ -198,31 +223,6 @@
| 🚀 | 回调通知 | 查看支付回调业务的【支付】【退款】的通知结果 |
| 🚀 | 接入示例 | 提供接入支付系统的【支付】【退款】的功能实战 |
-### 基础设施
-
-| | 功能 | 描述 |
-|-----|-----------|----------------------------------------------|
-| 🚀 | 代码生成 | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 |
-| 🚀 | 系统接口 | 基于 Swagger 自动生成相关的 RESTful API 接口文档 |
-| 🚀 | 数据库文档 | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 |
-| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 |
-| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 |
-| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 |
-| 🚀 | 文件服务 | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 |
-| 🚀 | WebSocket | 提供 WebSocket 接入示例,支持一对一、一对多发送方式 |
-| 🚀 | API 日志 | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 |
-| | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 |
-| | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 |
-| 🚀 | 消息队列 | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 |
-| 🚀 | Java 监控 | 基于 Spring Boot Admin 实现 Java 应用的监控 |
-| 🚀 | 链路追踪 | 接入 SkyWalking 组件,实现链路追踪 |
-| 🚀 | 日志中心 | 接入 SkyWalking 组件,实现日志中心 |
-| 🚀 | 服务保障 | 基于 Redis 实现分布式锁、幂等、限流功能,满足高并发场景 |
-| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 |
-| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 |
-
-
-
### 数据报表
| | 功能 | 描述 |
@@ -316,6 +316,14 @@

+### OA 协同办公
+
+演示地址:
+
+
+
+
+
### AI 大模型
演示地址:
@@ -355,23 +363,24 @@
| `yudao-framework` | Java 框架拓展 |
| `yudao-server` | 管理后台 + 用户 APP 的服务端 |
| `yudao-module-system` | 系统功能的 Module 模块 |
-| `yudao-module-member` | 会员中心的 Module 模块 |
| `yudao-module-infra` | 基础设施的 Module 模块 |
| `yudao-module-bpm` | 工作流程的 Module 模块 |
| `yudao-module-pay` | 支付系统的 Module 模块 |
+| `yudao-module-report` | 大屏报表 Module 模块 |
+| `yudao-module-mp` | 微信公众号的 Module 模块 |
| `yudao-module-mall` | 商城系统的 Module 模块 |
+| `yudao-module-member` | 会员中心的 Module 模块 |
| `yudao-module-erp` | ERP 系统的 Module 模块 |
+| `yudao-module-wms` | WMS 系统的 Module 模块 |
| `yudao-module-crm` | CRM 系统的 Module 模块 |
| `yudao-module-mes` | MES 系统的 Module 模块 |
-| `yudao-module-wms` | WMS 系统的 Module 模块 |
| `yudao-module-hrm` | HRM 人力资源管理的 Module 模块 |
| `yudao-module-fms` | FMS 财务管理的 Module 模块 |
| `yudao-module-pms` | PMS 项目管理的 Module 模块 |
-| `yudao-module-im` | IM 即时通讯的 Module 模块 |
+| `yudao-module-oa` | OA 协同办公的 Module 模块 |
| `yudao-module-ai` | AI 大模型的 Module 模块 |
| `yudao-module-iot` | IoT 物联网的 Module 模块 |
-| `yudao-module-mp` | 微信公众号的 Module 模块 |
-| `yudao-module-report` | 大屏报表 Module 模块 |
+| `yudao-module-im` | IM 即时通讯的 Module 模块 |
### 框架
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java
index a78eda11a3..181a108027 100644
--- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java
@@ -10,6 +10,8 @@ import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils;
import lombok.AllArgsConstructor;
import lombok.Getter;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
import java.sql.Timestamp;
import java.time.*;
import java.time.format.DateTimeFormatter;
@@ -32,6 +34,54 @@ import static cn.hutool.core.date.DatePattern.*;
*/
public class LocalDateTimeUtils {
+ /**
+ * 按经过的时长计算天数,以 24 小时为一天,按指定精度四舍五入
+ *
+ * @param beginTime 开始时间
+ * @param endTime 结束时间
+ * @param scale 保留的小数位数
+ * @return 按毫秒精度计算的天数,时间缺失时返回 null
+ */
+ public static BigDecimal getDaysBetween(LocalDateTime beginTime, LocalDateTime endTime, int scale) {
+ if (beginTime == null || endTime == null) {
+ return null;
+ }
+ return BigDecimal.valueOf(Duration.between(beginTime, endTime).toMillis())
+ .divide(BigDecimal.valueOf(Duration.ofDays(1).toMillis()), scale, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * 按经过的时长计算天数,以 24 小时为一天,结果向上取整
+ *
+ * 与 {@link #getDaysBetweenInclusive(LocalDateTime, LocalDateTime)} 不同,本方法不按自然日期计数。
+ * 例如跨午夜的 2 小时为 1 天,恰好 24 小时为 1 天,相同时刻为 0 天。
+ *
+ * @param beginTime 开始时间
+ * @param endTime 结束时间
+ * @return 按毫秒精度计算的天数,时间缺失时返回 null;反向区间按相同规则向上取整
+ */
+ public static Integer getDaysBetweenCeiling(LocalDateTime beginTime, LocalDateTime endTime) {
+ if (beginTime == null || endTime == null) {
+ return null;
+ }
+ return BigDecimal.valueOf(Duration.between(beginTime, endTime).toMillis())
+ .divide(BigDecimal.valueOf(Duration.ofDays(1).toMillis()), 0, RoundingMode.CEILING).intValueExact();
+ }
+
+ /**
+ * 计算包含首尾日期的自然日数,不足一天按对应自然日期计算
+ *
+ * @param beginTime 开始时间
+ * @param endTime 结束时间
+ * @return 自然日数,时间缺失时返回 null;结束日期早于开始日期时返回非正数
+ */
+ public static Integer getDaysBetweenInclusive(LocalDateTime beginTime, LocalDateTime endTime) {
+ if (beginTime == null || endTime == null) {
+ return null;
+ }
+ return Math.toIntExact(ChronoUnit.DAYS.between(beginTime.toLocalDate(), endTime.toLocalDate()) + 1);
+ }
+
/**
* 空的 LocalDateTime 对象,主要用于 DB 唯一索引的默认值
*/
@@ -543,6 +593,16 @@ public class LocalDateTimeUtils {
return date == null ? null : date.atTime(LocalTime.MAX);
}
+ /**
+ * 获得指定时间所在日期的结束时间
+ *
+ * @param time 时间
+ * @return 当日结束时间;时间为空时返回 {@code null}
+ */
+ public static LocalDateTime getDayEndTime(LocalDateTime time) {
+ return time == null ? null : getDayEndTime(time.toLocalDate());
+ }
+
/**
* 获得自然日期范围对应的日期时间闭区间
*
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/PageUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/PageUtils.java
index 472fff8be4..23c1ae3a0a 100644
--- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/PageUtils.java
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/PageUtils.java
@@ -28,14 +28,14 @@ public class PageUtils {
}
/**
- * 对内存列表进行分页
+ * 对内存列表分页,不改变列表顺序
*
- * @param list 数据列表
- * @param pageParam 分页参数
+ * @param pageParam 分页参数,支持 {@link PageParam#PAGE_SIZE_NONE} 返回全部数据
+ * @param list 已排序的完整列表
* @param 数据类型
- * @return 分页结果
+ * @return 分页结果,超出末页时保留总数并返回空列表
*/
- public static PageResult buildPageResult(List list, PageParam pageParam) {
+ public static PageResult buildPageResult(PageParam pageParam, List list) {
if (CollUtil.isEmpty(list)) {
return PageResult.empty();
}
@@ -43,8 +43,9 @@ public class PageUtils {
if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
return new PageResult<>(list, total);
}
- int fromIndex = Math.toIntExact(Math.min(getStart(pageParam), total));
- int toIndex = Math.toIntExact(Math.min((long) fromIndex + pageParam.getPageSize(), total));
+ long offset = (long) (pageParam.getPageNo() - 1) * pageParam.getPageSize();
+ int fromIndex = (int) Math.min(offset, total);
+ int toIndex = (int) Math.min((long) fromIndex + pageParam.getPageSize(), total);
return new PageResult<>(list.subList(fromIndex, toIndex), total);
}
diff --git a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtilsTest.java b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtilsTest.java
index abb7a668ff..704044fdf7 100644
--- a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtilsTest.java
+++ b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtilsTest.java
@@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.TimeRange;
import org.junit.jupiter.api.Test;
+import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -25,6 +26,56 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*/
public class LocalDateTimeUtilsTest {
+ @Test
+ public void testGetDaysBetween() {
+ // 准备参数
+ LocalDateTime beginTime = LocalDateTime.of(2026, 12, 31, 23, 0);
+
+ // 调用,并断言:按时长四舍五入,跨日不改变计算规则
+ assertEquals(new BigDecimal("0.0"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime, 1));
+ assertEquals(new BigDecimal("0.5"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusHours(12), 1));
+ assertEquals(new BigDecimal("0.1"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusMinutes(72), 1));
+ assertEquals(new BigDecimal("0.0"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusMinutes(71), 1));
+ assertEquals(new BigDecimal("1.1"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusHours(26), 1));
+ assertEquals(new BigDecimal("-0.5"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.minusHours(12), 1));
+ assertEquals(new BigDecimal("0.50"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusHours(12), 2));
+ assertNull(LocalDateTimeUtils.getDaysBetween(null, beginTime, 1));
+ assertNull(LocalDateTimeUtils.getDaysBetween(beginTime, null, 1));
+ }
+
+ @Test
+ public void testGetDaysBetweenCeiling() {
+ // 准备参数
+ LocalDateTime beginTime = LocalDateTime.of(2026, 12, 31, 23, 0);
+
+ // 调用,并断言:按时长取整,不因跨午夜额外增加一天
+ assertEquals(0, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime));
+ assertEquals(1, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.plusHours(2)));
+ assertEquals(1, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.plusDays(1)));
+ assertEquals(2, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.plusDays(1).plusNanos(1_000_000)));
+ assertEquals(2, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.plusHours(26)));
+ assertEquals(0, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.minusHours(2)));
+ assertEquals(-1, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.minusHours(26)));
+ assertNull(LocalDateTimeUtils.getDaysBetweenCeiling(null, beginTime));
+ assertNull(LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, null));
+ }
+
+ @Test
+ public void testGetDaysBetweenInclusive() {
+ // 准备参数
+ LocalDateTime beginTime = LocalDateTime.of(2026, 12, 31, 23, 0);
+ LocalDateTime endTime = LocalDateTime.of(2027, 1, 2, 1, 0);
+
+ // 调用,并断言:按自然日计算,包含首尾日期
+ assertEquals(3, LocalDateTimeUtils.getDaysBetweenInclusive(beginTime, endTime));
+ assertEquals(1, LocalDateTimeUtils.getDaysBetweenInclusive(beginTime, beginTime.minusHours(1)));
+ assertEquals(-1, LocalDateTimeUtils.getDaysBetweenInclusive(endTime, beginTime));
+ assertNull(LocalDateTimeUtils.getDaysBetweenInclusive(null, endTime));
+ assertNull(LocalDateTimeUtils.getDaysBetweenInclusive(beginTime, null));
+ assertEquals(3, LocalDateTimeUtils.getDaysBetweenInclusive(
+ LocalDateTime.of(2024, 2, 28, 0, 0), LocalDateTime.of(2024, 3, 1, 0, 0)));
+ }
+
@Test
public void testDateCompare() {
// 准备参数
@@ -99,6 +150,10 @@ public class LocalDateTimeUtilsTest {
assertNull(LocalDateTimeUtils.getDayBeginTime((LocalDateTime) null));
assertEquals(LocalDateTime.of(date, LocalTime.MAX),
LocalDateTimeUtils.getDayEndTime(date));
+ assertEquals(LocalDateTime.of(date, LocalTime.MAX),
+ LocalDateTimeUtils.getDayEndTime(time));
+ assertNull(LocalDateTimeUtils.getDayEndTime((LocalDateTime) null));
+ assertNull(LocalDateTimeUtils.getDayEndTime((LocalDate) null));
}
@Test
diff --git a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/object/PageUtilsTest.java b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/object/PageUtilsTest.java
new file mode 100644
index 0000000000..172835e467
--- /dev/null
+++ b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/object/PageUtilsTest.java
@@ -0,0 +1,68 @@
+package cn.iocoder.yudao.framework.common.util.object;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link PageUtils} 的单元测试
+ */
+public class PageUtilsTest {
+
+ @Test
+ public void testBuildPageResult() {
+ // 准备参数
+ PageParam pageParam = new PageParam();
+ pageParam.setPageSize(2);
+ List list = Arrays.asList(1, 2, 3);
+
+ // 调用并断言首页和末页
+ PageResult result = PageUtils.buildPageResult(pageParam, list);
+ assertEquals(3L, result.getTotal());
+ assertEquals(Arrays.asList(1, 2), result.getList());
+ pageParam.setPageNo(2);
+ result = PageUtils.buildPageResult(pageParam, list);
+ assertEquals(3L, result.getTotal());
+ assertEquals(Collections.singletonList(3), result.getList());
+ assertEquals(Arrays.asList(1, 2, 3), list);
+ }
+
+ @Test
+ public void testBuildPageResult_empty() {
+ PageParam pageParam = new PageParam();
+ assertEquals(0L, PageUtils.buildPageResult(pageParam, Collections.emptyList()).getTotal());
+ assertTrue(PageUtils.buildPageResult(pageParam, null).getList().isEmpty());
+ }
+
+ @Test
+ public void testBuildPageResult_pastLastPage() {
+ PageParam pageParam = new PageParam();
+ pageParam.setPageNo(Integer.MAX_VALUE);
+ pageParam.setPageSize(200);
+
+ PageResult result = PageUtils.buildPageResult(pageParam, Arrays.asList(1, 2, 3));
+
+ assertEquals(3L, result.getTotal());
+ assertTrue(result.getList().isEmpty());
+ }
+
+ @Test
+ public void testBuildPageResult_noPagination() {
+ PageParam pageParam = new PageParam();
+ pageParam.setPageNo(2);
+ pageParam.setPageSize(PageParam.PAGE_SIZE_NONE);
+
+ PageResult result = PageUtils.buildPageResult(pageParam, Arrays.asList(1, 2, 3));
+
+ assertEquals(3L, result.getTotal());
+ assertEquals(Arrays.asList(1, 2, 3), result.getList());
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
index 79e95fecda..7dfb51846a 100644
--- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
+++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
@@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
import cn.iocoder.yudao.framework.common.pojo.SortingField;
+import cn.iocoder.yudao.framework.common.util.object.PageUtils;
import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import com.baomidou.mybatisplus.annotation.DbType;
@@ -44,7 +45,7 @@ public interface BaseMapperX extends MPJBaseMapper {
if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
MyBatisUtils.addOrder(queryWrapper, sortingFields);
List list = selectList(queryWrapper);
- return new PageResult<>(list, (long) list.size());
+ return PageUtils.buildPageResult(pageParam, list);
}
// MyBatis Plus 查询
@@ -58,7 +59,7 @@ public interface BaseMapperX extends MPJBaseMapper {
// 特殊:不分页,直接查询全部
if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
List list = selectJoinList(clazz, lambdaWrapper);
- return new PageResult<>(list, (long) list.size());
+ return PageUtils.buildPageResult(pageParam, list);
}
// MyBatis Plus Join 查询
@@ -81,7 +82,7 @@ public interface BaseMapperX extends MPJBaseMapper {
// 特殊:不分页,直接查询全部
if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
List list = selectJoinList(clazz, lambdaWrapper);
- return new PageResult<>(list, (long) list.size());
+ return PageUtils.buildPageResult(pageParam, list);
}
// MyBatis Plus Join 查询
diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java
index 92092bbaec..1d5922594c 100644
--- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java
+++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java
@@ -30,13 +30,21 @@ public class BannerApplicationRunner implements ApplicationRunner {
"https://doc.iocoder.cn",
"https://t.zsxq.com/02Yf6M7Qn");
+ // 工作流
+ if (isNotPresent("cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmFlowableConfiguration")) {
+ System.out.println("[工作流模块 yudao-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]");
+ }
+ // 支付平台
+ if (isNotPresent("cn.iocoder.yudao.module.pay.framework.pay.config.PayConfiguration")) {
+ System.out.println("[支付系统 yudao-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]");
+ }
// 数据报表
if (isNotPresent("cn.iocoder.yudao.module.report.framework.security.config.SecurityConfiguration")) {
System.out.println("[报表模块 yudao-module-report - 已禁用][参考 https://doc.iocoder.cn/report/ 开启]");
}
- // 工作流
- if (isNotPresent("cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmFlowableConfiguration")) {
- System.out.println("[工作流模块 yudao-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]");
+ // 微信公众号
+ if (isNotPresent("cn.iocoder.yudao.module.mp.framework.mp.config.MpConfiguration")) {
+ System.out.println("[微信公众号 yudao-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]");
}
// 商城系统
if (isNotPresent("cn.iocoder.yudao.module.trade.framework.web.config.TradeWebConfiguration")) {
@@ -50,10 +58,6 @@ public class BannerApplicationRunner implements ApplicationRunner {
if (isNotPresent("cn.iocoder.yudao.module.wms.framework.web.config.WmsWebConfiguration")) {
System.out.println("[WMS 仓库管理系统 yudao-module-wms - 已禁用][参考 https://doc.iocoder.cn/wms/build/ 开启]");
}
- // PMS 系统
- if (isNotPresent("cn.iocoder.yudao.module.pms.framework.web.config.PmsWebConfiguration")) {
- System.out.println("[PMS 项目管理系统 yudao-module-pms - 已禁用][请在 yudao-server 中开启]");
- }
// CRM 系统
if (isNotPresent("cn.iocoder.yudao.module.crm.framework.web.config.CrmWebConfiguration")) {
System.out.println("[CRM 系统 yudao-module-crm - 已禁用][参考 https://doc.iocoder.cn/crm/build/ 开启]");
@@ -62,13 +66,21 @@ public class BannerApplicationRunner implements ApplicationRunner {
if (isNotPresent("cn.iocoder.yudao.module.mes.framework.web.config.MesWebConfiguration")) {
System.out.println("[MES 系统 yudao-module-mes - 已禁用][参考 https://doc.iocoder.cn/mes/build/ 开启]");
}
- // 微信公众号
- if (isNotPresent("cn.iocoder.yudao.module.mp.framework.mp.config.MpConfiguration")) {
- System.out.println("[微信公众号 yudao-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]");
+ // HRM 人力资源管理系统
+ if (isNotPresent("cn.iocoder.yudao.module.hrm.framework.web.config.HrmWebConfiguration")) {
+ System.out.println("[HRM 人力资源管理系统 yudao-module-hrm - 已禁用][参考 https://doc.iocoder.cn/hrm/build/ 开启]");
}
- // 支付平台
- if (isNotPresent("cn.iocoder.yudao.module.pay.framework.pay.config.PayConfiguration")) {
- System.out.println("[支付系统 yudao-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]");
+ // FMS 财务管理系统
+ if (isNotPresent("cn.iocoder.yudao.module.fms.framework.web.config.FmsWebConfiguration")) {
+ System.out.println("[FMS 财务管理系统 yudao-module-fms - 已禁用][参考 https://doc.iocoder.cn/fms/build/ 开启]");
+ }
+ // PMS 系统
+ if (isNotPresent("cn.iocoder.yudao.module.pms.framework.web.config.PmsWebConfiguration")) {
+ System.out.println("[PMS 项目管理系统 yudao-module-pms - 已禁用][参考 https://doc.iocoder.cn/pms/build/ 开启]");
+ }
+ // OA 办公自动化
+ if (isNotPresent("cn.iocoder.yudao.module.oa.framework.config.OaProperties")) {
+ System.out.println("[OA 协同办公 yudao-module-oa - 已禁用][参考 https://doc.iocoder.cn/oa/build/ 开启]");
}
// AI 大模型
if (isNotPresent("cn.iocoder.yudao.module.ai.framework.web.config.AiWebConfiguration")) {
diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java
index 4b3ca20958..e7d8dd143c 100644
--- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java
+++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java
@@ -387,78 +387,102 @@ public class GlobalExceptionHandler {
if (!message.contains("doesn't exist")) {
return null;
}
- // 1. 数据报表
- if (message.contains("report_")) {
- log.error("[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]");
- return CommonResult.error(NOT_IMPLEMENTED.getCode(),
- "[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]");
- }
- // 2. 工作流
+ // 1. 工作流
if (message.contains("bpm_")) {
log.error("[工作流模块 yudao-module-bpm - 表结构未导入][参考 https://cloud.iocoder.cn/bpm/ 开启]");
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
"[工作流模块 yudao-module-bpm - 表结构未导入][参考 https://cloud.iocoder.cn/bpm/ 开启]");
}
- // 3. 微信公众号
- if (message.contains("mp_")) {
- log.error("[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]");
- return CommonResult.error(NOT_IMPLEMENTED.getCode(),
- "[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]");
- }
- // 4. 商城系统
- if (StrUtil.containsAny(message, "product_", "promotion_", "trade_")) {
- log.error("[商城系统 yudao-module-mall - 已禁用][参考 https://cloud.iocoder.cn/mall/build/ 开启]");
- return CommonResult.error(NOT_IMPLEMENTED.getCode(),
- "[商城系统 yudao-module-mall - 已禁用][参考 https://cloud.iocoder.cn/mall/build/ 开启]");
- }
- // 5. ERP 系统
- if (message.contains("erp_")) {
- log.error("[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]");
- return CommonResult.error(NOT_IMPLEMENTED.getCode(),
- "[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]");
- }
- // 6. WMS 仓库管理系统
- if (message.contains("wms_")) {
- log.error("[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://doc.iocoder.cn/wms/build/ 开启]");
- return CommonResult.error(NOT_IMPLEMENTED.getCode(),
- "[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://doc.iocoder.cn/wms/build/ 开启]");
- }
- // 7. CRM 系统
- if (message.contains("crm_")) {
- log.error("[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]");
- return CommonResult.error(NOT_IMPLEMENTED.getCode(),
- "[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]");
- }
- // 8. MES 系统
- if (message.contains("mes_")) {
- log.error("[MES 系统 yudao-module-mes - 表结构未导入][参考 https://doc.iocoder.cn/mes/build/ 开启]");
- return CommonResult.error(NOT_IMPLEMENTED.getCode(),
- "[MES 系统 yudao-module-mes - 表结构未导入][参考 https://doc.iocoder.cn/mes/build/ 开启]");
- }
- // 9. IM 即时通讯
- if (message.contains("im_")) {
- log.error("[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://doc.iocoder.cn/im/build/ 开启]");
- return CommonResult.error(NOT_IMPLEMENTED.getCode(),
- "[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://doc.iocoder.cn/im/build/ 开启]");
- }
- // 10. 支付平台
+ // 2. 支付平台
if (message.contains("pay_")) {
log.error("[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]");
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
"[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]");
}
- // 11. AI 大模型
+ // 3. 数据报表
+ if (message.contains("report_")) {
+ log.error("[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]");
+ }
+ // 4. 微信公众号
+ if (message.contains("mp_")) {
+ log.error("[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]");
+ }
+ // 5. 商城系统
+ if (StrUtil.containsAny(message, "product_", "promotion_", "trade_")) {
+ log.error("[商城系统 yudao-module-mall - 表结构未导入][参考 https://cloud.iocoder.cn/mall/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[商城系统 yudao-module-mall - 表结构未导入][参考 https://cloud.iocoder.cn/mall/build/ 开启]");
+ }
+ // 6. ERP 系统
+ if (message.contains("erp_")) {
+ log.error("[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]");
+ }
+ // 7. WMS 仓库管理系统
+ if (message.contains("wms_")) {
+ log.error("[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://doc.iocoder.cn/wms/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://doc.iocoder.cn/wms/build/ 开启]");
+ }
+ // 8. CRM 系统
+ if (message.contains("crm_")) {
+ log.error("[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]");
+ }
+ // 9. MES 系统
+ if (message.contains("mes_")) {
+ log.error("[MES 系统 yudao-module-mes - 表结构未导入][参考 https://doc.iocoder.cn/mes/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[MES 系统 yudao-module-mes - 表结构未导入][参考 https://doc.iocoder.cn/mes/build/ 开启]");
+ }
+ // 10. HRM 人力资源管理系统
+ if (message.contains("hrm_")) {
+ log.error("[HRM 人力资源管理系统 yudao-module-hrm - 表结构未导入][参考 https://doc.iocoder.cn/hrm/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[HRM 人力资源管理系统 yudao-module-hrm - 表结构未导入][参考 https://doc.iocoder.cn/hrm/build/ 开启]");
+ }
+ // 11. FMS 财务管理系统
+ if (message.contains("fms_")) {
+ log.error("[FMS 财务管理系统 yudao-module-fms - 表结构未导入][参考 https://doc.iocoder.cn/fms/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[FMS 财务管理系统 yudao-module-fms - 表结构未导入][参考 https://doc.iocoder.cn/fms/build/ 开启]");
+ }
+ // 12. PMS 项目管理系统
+ if (message.contains("pms_")) {
+ log.error("[PMS 项目管理系统 yudao-module-pms - 表结构未导入][参考 https://doc.iocoder.cn/pms/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[PMS 项目管理系统 yudao-module-pms - 表结构未导入][参考 https://doc.iocoder.cn/pms/build/ 开启]");
+ }
+ // 13. OA 协同办公
+ if (message.contains("oa_")) {
+ log.error("[OA 协同办公 yudao-module-oa - 表结构未导入][参考 https://doc.iocoder.cn/oa/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[OA 协同办公 yudao-module-oa - 表结构未导入][参考 https://doc.iocoder.cn/oa/build/ 开启]");
+ }
+ // 14. AI 大模型
if (message.contains("ai_")) {
log.error("[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]");
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
"[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]");
}
- // 12. IoT 物联网
+ // 15. IoT 物联网
if (message.contains("iot_")) {
log.error("[IoT 物联网 yudao-module-iot - 表结构未导入][参考 https://doc.iocoder.cn/iot/build/ 开启]");
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
"[IoT 物联网 yudao-module-iot - 表结构未导入][参考 https://doc.iocoder.cn/iot/build/ 开启]");
}
+ // 16. IM 即时通讯
+ if (message.contains("im_")) {
+ log.error("[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://doc.iocoder.cn/im/build/ 开启]");
+ return CommonResult.error(NOT_IMPLEMENTED.getCode(),
+ "[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://doc.iocoder.cn/im/build/ 开启]");
+ }
return null;
}
diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/statistics/HrmAttendanceStatisticsServiceImpl.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/statistics/HrmAttendanceStatisticsServiceImpl.java
index 8feed1cece..46addf0ca3 100644
--- a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/statistics/HrmAttendanceStatisticsServiceImpl.java
+++ b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/statistics/HrmAttendanceStatisticsServiceImpl.java
@@ -135,7 +135,7 @@ public class HrmAttendanceStatisticsServiceImpl implements HrmAttendanceStatisti
buildMonthSummaryList(reqVO.getYear(), reqVO.getMonth(), employeeList);
List filteredMonthSummaryList = filterList(monthSummaryList,
record -> Objects.equals(record.getFullAttendance(), reqVO.getFullAttendance()));
- return PageUtils.buildPageResult(filteredMonthSummaryList, reqVO);
+ return PageUtils.buildPageResult(reqVO, filteredMonthSummaryList);
}
@Override
diff --git a/yudao-module-oa/pom.xml b/yudao-module-oa/pom.xml
new file mode 100644
index 0000000000..6e291c6839
--- /dev/null
+++ b/yudao-module-oa/pom.xml
@@ -0,0 +1,81 @@
+
+
+
+ cn.iocoder.boot
+ yudao
+ ${revision}
+
+ 4.0.0
+ yudao-module-oa
+ jar
+
+ ${project.artifactId}
+
+ oa 包下,办公自动化(Office Automation),提供协同办公、行政管理和业务审批功能。
+ 例如说:日程计划、企业云盘、请假报销、会议室、车辆、印章、公文等等
+
+
+
+
+ cn.iocoder.boot
+ yudao-module-system
+ ${revision}
+
+
+ cn.iocoder.boot
+ yudao-module-infra
+ ${revision}
+
+
+ cn.iocoder.boot
+ yudao-module-bpm
+ ${revision}
+
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-web
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-security
+
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-mybatis
+
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-job
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
+ org.jsoup
+ jsoup
+
+
+ com.belerweb
+ pinyin4j
+
+
+
+
+ cn.iocoder.boot
+ yudao-spring-boot-starter-test
+ test
+
+
+
+
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/OaAnnouncementController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/OaAnnouncementController.java
new file mode 100644
index 0000000000..a531bcb06a
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/OaAnnouncementController.java
@@ -0,0 +1,214 @@
+package cn.iocoder.yudao.module.oa.controller.admin.announcement;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementPageReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementRespVO;
+import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementSaveReqVO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementDO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementReceiverDO;
+import cn.iocoder.yudao.module.oa.service.announcement.OaAnnouncementService;
+import cn.iocoder.yudao.module.system.api.dept.DeptApi;
+import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
+import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+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.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - OA 公告")
+@RestController
+@RequestMapping("/oa/announcement")
+@Validated
+public class OaAnnouncementController {
+
+ @Resource
+ private OaAnnouncementService announcementService;
+
+ @Resource
+ private AdminUserApi adminUserApi;
+ @Resource
+ private DeptApi deptApi;
+
+ // ==================== 我发布的 ====================
+
+ @PostMapping("/create")
+ @Operation(summary = "创建公告")
+ @PreAuthorize("@ss.hasPermission('oa:announcement:create')")
+ public CommonResult createAnnouncement(@Valid @RequestBody OaAnnouncementSaveReqVO createReqVO) {
+ return success(announcementService.createAnnouncement(createReqVO, getLoginUserId()));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "更新公告")
+ @PreAuthorize("@ss.hasPermission('oa:announcement:update')")
+ public CommonResult updateAnnouncement(@Valid @RequestBody OaAnnouncementSaveReqVO updateReqVO) {
+ announcementService.updateAnnouncement(updateReqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除发布的公告")
+ @Parameter(name = "id", description = "公告编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:announcement:delete')")
+ public CommonResult deleteAnnouncement(@RequestParam("id") Long id) {
+ announcementService.deleteAnnouncement(id, getLoginUserId());
+ return success(true);
+ }
+
+ @GetMapping("/published-page")
+ @Operation(summary = "获得我发布的公告分页")
+ @PreAuthorize("@ss.hasPermission('oa:announcement:query')")
+ public CommonResult> getPublishedAnnouncementPage(
+ @Valid OaAnnouncementPageReqVO pageReqVO) {
+ Long userId = getLoginUserId();
+ PageResult pageResult = announcementService
+ .getPublishedAnnouncementPage(pageReqVO, userId);
+ return success(new PageResult<>(buildAnnouncementRespVOList(pageResult.getList(), userId),
+ pageResult.getTotal()));
+ }
+
+ // ==================== 我收到的 ====================
+
+ @DeleteMapping("/delete-received")
+ @Operation(summary = "删除接收的公告")
+ @Parameter(name = "id", description = "公告编号", required = true, example = "1024")
+ public CommonResult deleteReceivedAnnouncement(@RequestParam("id") Long id) {
+ announcementService.deleteReceivedAnnouncement(id, getLoginUserId());
+ return success(true);
+ }
+
+ @GetMapping("/received-page")
+ @Operation(summary = "获得接收的公告分页")
+ @PreAuthorize("@ss.hasPermission('oa:announcement:query')")
+ public CommonResult> getReceivedAnnouncementPage(
+ @Valid OaAnnouncementPageReqVO pageReqVO) {
+ Long userId = getLoginUserId();
+ PageResult pageResult = announcementService
+ .getReceivedAnnouncementPage(pageReqVO, userId);
+ return success(new PageResult<>(buildAnnouncementRespVOList(pageResult.getList(), userId),
+ pageResult.getTotal()));
+ }
+
+ @PutMapping("/update-read-status")
+ @Operation(summary = "标记公告为已读")
+ @Parameter(name = "id", description = "公告编号", required = true, example = "1024")
+ public CommonResult updateAnnouncementReadStatus(@RequestParam("id") Long id) {
+ announcementService.updateAnnouncementReadStatus(id, getLoginUserId());
+ return success(true);
+ }
+
+ @PostMapping("/forward")
+ @Operation(summary = "转发公告给下属")
+ @Parameter(name = "id", description = "公告编号", required = true, example = "1024")
+ public CommonResult forwardAnnouncement(@RequestParam("id") Long id) {
+ return success(announcementService.forwardAnnouncement(id, getLoginUserId()));
+ }
+
+ // ==================== 公共详情 ====================
+
+ @GetMapping("/get")
+ @Operation(summary = "获得公告详情")
+ @Parameter(name = "id", description = "公告编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:announcement:query')")
+ public CommonResult getAnnouncement(@RequestParam("id") Long id) {
+ Long userId = getLoginUserId();
+ OaAnnouncementDO announcement = announcementService.getAnnouncement(id, userId);
+ return success(buildAnnouncementRespVO(announcement, userId));
+ }
+
+ // ==================== 拼接 VO ====================
+
+ /**
+ * 拼接公告详情
+ *
+ * @param announcement 公告
+ * @param loginUserId 当前登录用户编号
+ * @return 公告响应
+ */
+ private OaAnnouncementRespVO buildAnnouncementRespVO(OaAnnouncementDO announcement, Long loginUserId) {
+ if (announcement == null) {
+ return null;
+ }
+ return CollUtil.getFirst(buildAnnouncementRespVOList(Collections.singletonList(announcement), loginUserId));
+ }
+
+ /**
+ * 拼接公告响应列表
+ *
+ * @param announcements 公告列表
+ * @param loginUserId 当前登录用户编号
+ * @return 公告响应列表
+ */
+ private List buildAnnouncementRespVOList(List announcements,
+ Long loginUserId) {
+ if (CollUtil.isEmpty(announcements)) {
+ return Collections.emptyList();
+ }
+ // 1. 查询公告接收关系、发布人、接收人和发布人部门信息
+ Map> receiverListMap = announcementService
+ .getAnnouncementReceiverListMap(convertSet(announcements, OaAnnouncementDO::getId));
+ Collection userIds = new ArrayList<>(convertSet(announcements,
+ announcement -> NumberUtils.parseLong(announcement.getCreator())));
+ receiverListMap.values().forEach(receivers -> userIds.addAll(
+ convertSet(receivers, OaAnnouncementReceiverDO::getReceiverUserId)));
+ Map userMap = adminUserApi.getUserMap(userIds);
+ Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId));
+ Set subordinateUserIds = convertSet(adminUserApi.getUserListBySubordinate(loginUserId), AdminUserRespDTO::getId);
+ // 2. 拼接公告、发布人、接收人和当前接收状态
+ return convertList(announcements, announcementDO -> {
+ OaAnnouncementRespVO announcement = BeanUtils.toBean(announcementDO, OaAnnouncementRespVO.class)
+ .setPublisherUserId(NumberUtils.parseLong(announcementDO.getCreator()));
+ MapUtils.findAndThen(userMap, announcement.getPublisherUserId(), publisher -> {
+ announcement.setPublisherUserName(publisher.getNickname());
+ announcement.setPublisherDeptId(publisher.getDeptId());
+ MapUtils.findAndThen(deptMap, publisher.getDeptId(),
+ dept -> announcement.setPublisherDeptName(dept.getName()));
+ });
+ List receivers = receiverListMap.getOrDefault(announcement.getId(),
+ Collections.emptyList());
+ List receiverUserIds = convertList(receivers, OaAnnouncementReceiverDO::getReceiverUserId);
+ announcement.setReceiverUserIds(receiverUserIds);
+ announcement.setReceiverUserNames(convertList(receiverUserIds, receiverUserId -> {
+ AdminUserRespDTO receiverUser = userMap.get(receiverUserId);
+ return receiverUser != null ? receiverUser.getNickname() : null;
+ }));
+ OaAnnouncementReceiverDO loginUserReceiver = CollUtil.findOne(receivers,
+ receiver -> receiver.getReceiverUserId().equals(loginUserId));
+ if (loginUserReceiver != null) {
+ announcement.setReadStatus(loginUserReceiver.getReadStatus());
+ }
+ announcement.setForwarded(CollUtil.containsAny(receiverUserIds, subordinateUserIds));
+ return announcement;
+ });
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementPageReqVO.java
new file mode 100644
index 0000000000..45f7d97aaa
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementPageReqVO.java
@@ -0,0 +1,39 @@
+package cn.iocoder.yudao.module.oa.controller.admin.announcement.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.announcement.OaAnnouncementTypeEnum;
+import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - OA 公告分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OaAnnouncementPageReqVO extends PageParam {
+
+ @Schema(description = "公告标题,模糊匹配", example = "国庆节放假通知")
+ private String title;
+
+ @Schema(description = "公告类型", example = "1")
+ @InEnum(value = OaAnnouncementTypeEnum.class, message = "公告类型必须是 {value}")
+ private Integer type;
+
+ @Schema(description = "优先级", example = "1")
+ @InEnum(value = OaPriorityEnum.class, message = "优先级必须是 {value}")
+ private Integer priority;
+
+ @Schema(description = "是否已读", example = "false")
+ private Boolean readStatus;
+
+ @Schema(description = "发布时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]")
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+ private LocalDateTime[] createTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementRespVO.java
new file mode 100644
index 0000000000..9896d1d7ee
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementRespVO.java
@@ -0,0 +1,61 @@
+package cn.iocoder.yudao.module.oa.controller.admin.announcement.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 公告 Response VO")
+@Data
+public class OaAnnouncementRespVO {
+
+ @Schema(description = "公告编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "发布人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long publisherUserId;
+
+ @Schema(description = "发布人用户昵称", example = "芋道源码")
+ private String publisherUserName;
+
+ @Schema(description = "发布人部门编号", example = "1")
+ private Long publisherDeptId;
+
+ @Schema(description = "发布人部门名称", example = "研发部")
+ private String publisherDeptName;
+
+ @Schema(description = "公告类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer type;
+
+ @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer priority;
+
+ @Schema(description = "公告标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "国庆节放假通知")
+ private String title;
+
+ @Schema(description = "公告内容", example = "请各部门提前安排好假期值班工作。")
+ private String content;
+
+ @Schema(description = "相关链接", example = "https://example.com/file.pdf")
+ private String url;
+
+ @Schema(description = "是否置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
+ private Boolean top;
+
+ @Schema(description = "接收人用户编号列表", example = "[1, 2]")
+ private List receiverUserIds;
+
+ @Schema(description = "接收人用户昵称列表", example = "[\"张三\", \"李四\"]")
+ private List receiverUserNames;
+
+ @Schema(description = "当前接收人是否已读", example = "false")
+ private Boolean readStatus;
+
+ @Schema(description = "当前接收人是否已转发给下属", example = "false")
+ private Boolean forwarded;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime createTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementSaveReqVO.java
new file mode 100644
index 0000000000..75c494361c
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementSaveReqVO.java
@@ -0,0 +1,45 @@
+package cn.iocoder.yudao.module.oa.controller.admin.announcement.vo;
+
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.announcement.OaAnnouncementTypeEnum;
+import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 公告新增/修改 Request VO")
+@Data
+public class OaAnnouncementSaveReqVO {
+
+ @Schema(description = "公告编号", example = "1024")
+ private Long id;
+
+ @Schema(description = "公告类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "公告类型不能为空")
+ @InEnum(value = OaAnnouncementTypeEnum.class, message = "公告类型必须是 {value}")
+ private Integer type;
+
+ @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "优先级不能为空")
+ @InEnum(value = OaPriorityEnum.class, message = "优先级必须是 {value}")
+ private Integer priority;
+
+ @Schema(description = "公告标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "国庆节放假通知")
+ @NotBlank(message = "公告标题不能为空")
+ @Size(max = 255, message = "公告标题长度不能超过 255 个字符")
+ private String title;
+
+ @Schema(description = "公告内容", example = "请各部门提前安排好假期值班工作。")
+ private String content;
+
+ @Schema(description = "相关链接", example = "https://www.iocoder.cn")
+ @Size(max = 512, message = "相关链接长度不能超过 512 个字符")
+ private String url;
+
+ @Schema(description = "是否置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
+ @NotNull(message = "是否置顶不能为空")
+ private Boolean top;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/OaAttendanceController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/OaAttendanceController.java
new file mode 100644
index 0000000000..8335ed4c54
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/OaAttendanceController.java
@@ -0,0 +1,260 @@
+package cn.iocoder.yudao.module.oa.controller.admin.attendance;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportRespVO;
+import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendancePageReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceRespVO;
+import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceUpdateReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportRespVO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.attendance.OaAttendanceDO;
+import cn.iocoder.yudao.module.oa.service.attendance.OaAttendanceService;
+import cn.iocoder.yudao.module.system.api.dept.DeptApi;
+import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
+import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+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.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - OA 考勤")
+@RestController
+@RequestMapping("/oa/attendance")
+@Validated
+public class OaAttendanceController {
+
+ @Resource
+ private OaAttendanceService attendanceService;
+
+ @Resource
+ private AdminUserApi adminUserApi;
+ @Resource
+ private DeptApi deptApi;
+
+ // ==================== 我的考勤 ====================
+
+ @PostMapping("/clock")
+ @Operation(summary = "执行当前用户打卡")
+ public CommonResult clockAttendance() {
+ return success(attendanceService.clockAttendance(getLoginUserId(), getClientIP()));
+ }
+
+ @GetMapping("/my-page")
+ @Operation(summary = "获得我的考勤分页列表")
+ public CommonResult> getMyAttendancePage(
+ @Valid OaAttendancePageReqVO pageReqVO) {
+ PageResult pageResult = attendanceService.getMyAttendancePage(
+ pageReqVO, getLoginUserId());
+ return success(new PageResult<>(buildAttendanceRespVOList(pageResult.getList()), pageResult.getTotal()));
+ }
+
+ @GetMapping("/my-today-list")
+ @Operation(summary = "获得我的今日考勤列表")
+ public CommonResult> getMyTodayAttendanceList() {
+ return success(buildAttendanceRespVOList(
+ attendanceService.getTodayAttendanceList(getLoginUserId())));
+ }
+
+ // ==================== 考勤管理 ====================
+
+ @PutMapping("/update")
+ @Operation(summary = "更新考勤记录")
+ @PreAuthorize("@ss.hasPermission('oa:attendance:update')")
+ public CommonResult updateAttendance(@Valid @RequestBody OaAttendanceUpdateReqVO updateReqVO) {
+ attendanceService.updateAttendance(updateReqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除考勤记录")
+ @Parameter(name = "id", description = "考勤记录编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:attendance:delete')")
+ public CommonResult deleteAttendance(@RequestParam("id") Long id) {
+ attendanceService.deleteAttendance(id, getLoginUserId());
+ return success(true);
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "获得考勤详情")
+ @Parameter(name = "id", description = "考勤记录编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:attendance:query')")
+ public CommonResult getAttendance(@RequestParam("id") Long id) {
+ OaAttendanceDO attendance = attendanceService.getAttendance(id, getLoginUserId());
+ return success(buildAttendanceRespVO(attendance));
+ }
+
+ @GetMapping("/page")
+ @Operation(summary = "获得考勤分页列表")
+ @PreAuthorize("@ss.hasPermission('oa:attendance:query')")
+ public CommonResult> getAttendancePage(
+ @Valid OaAttendancePageReqVO pageReqVO) {
+ PageResult pageResult = attendanceService.getAttendancePage(pageReqVO, getLoginUserId());
+ return success(new PageResult<>(buildAttendanceRespVOList(pageResult.getList()), pageResult.getTotal()));
+ }
+
+ // ==================== 考勤报表 ====================
+
+ @GetMapping("/week-report")
+ @Operation(summary = "获得考勤周报")
+ @PreAuthorize("@ss.hasPermission('oa:attendance:query')")
+ public CommonResult> getAttendanceWeekReport(
+ @Valid OaAttendanceWeekReportReqVO reqVO) {
+ return success(buildAttendanceWeekReportRespVOList(
+ attendanceService.getAttendanceWeekReport(reqVO, getLoginUserId())));
+ }
+
+ @GetMapping("/month-report")
+ @Operation(summary = "获得考勤月报")
+ @PreAuthorize("@ss.hasPermission('oa:attendance:query')")
+ public CommonResult> getAttendanceMonthReport(
+ @Valid OaAttendanceMonthReportReqVO reqVO) {
+ return success(buildAttendanceMonthReportRespVOList(
+ attendanceService.getAttendanceMonthReport(reqVO, getLoginUserId())));
+ }
+
+ // ==================== 拼接 VO ====================
+
+ /**
+ * 拼接考勤详情
+ *
+ * @param attendance 考勤记录
+ * @return 考勤响应
+ */
+ private OaAttendanceRespVO buildAttendanceRespVO(OaAttendanceDO attendance) {
+ if (attendance == null) {
+ return null;
+ }
+ return CollUtil.getFirst(buildAttendanceRespVOList(Collections.singletonList(attendance)));
+ }
+
+ /**
+ * 拼接考勤响应列表
+ *
+ * @param attendances 考勤记录列表
+ * @return 考勤响应列表
+ */
+ private List buildAttendanceRespVOList(List attendances) {
+ if (CollUtil.isEmpty(attendances)) {
+ return Collections.emptyList();
+ }
+ Map userMap = adminUserApi.getUserMap(
+ convertSet(attendances, OaAttendanceDO::getUserId));
+ Map deptMap = deptApi.getDeptMap(
+ convertSet(userMap.values(), AdminUserRespDTO::getDeptId));
+ return BeanUtils.toBean(attendances, OaAttendanceRespVO.class, attendance ->
+ fillAttendanceUserInfo(attendance, userMap, deptMap));
+ }
+
+ /**
+ * 拼接考勤周报响应列表
+ *
+ * @param reports 考勤周报列表
+ * @return 包含用户和部门信息的考勤周报列表
+ */
+ private List buildAttendanceWeekReportRespVOList(
+ List reports) {
+ if (CollUtil.isEmpty(reports)) {
+ return Collections.emptyList();
+ }
+ Map userMap = adminUserApi.getUserMap(
+ convertSet(reports, OaAttendanceWeekReportRespVO::getUserId));
+ Map deptMap = deptApi.getDeptMap(
+ convertSet(userMap.values(), AdminUserRespDTO::getDeptId));
+ reports.forEach(report -> fillAttendanceUserInfo(report, userMap, deptMap));
+ return reports;
+ }
+
+ /**
+ * 拼接考勤月报响应列表
+ *
+ * @param reports 考勤月报列表
+ * @return 包含用户和部门信息的考勤月报列表
+ */
+ private List buildAttendanceMonthReportRespVOList(
+ List reports) {
+ if (CollUtil.isEmpty(reports)) {
+ return Collections.emptyList();
+ }
+ Map userMap = adminUserApi.getUserMap(
+ convertSet(reports, OaAttendanceMonthReportRespVO::getUserId));
+ Map deptMap = deptApi.getDeptMap(
+ convertSet(userMap.values(), AdminUserRespDTO::getDeptId));
+ reports.forEach(report -> fillAttendanceUserInfo(report, userMap, deptMap));
+ return reports;
+ }
+
+ /**
+ * 拼接考勤响应的用户和部门信息
+ *
+ * @param attendance 考勤响应
+ * @param userMap 用户 Map
+ * @param deptMap 部门 Map
+ */
+ private void fillAttendanceUserInfo(OaAttendanceRespVO attendance,
+ Map userMap,
+ Map deptMap) {
+ MapUtils.findAndThen(userMap, attendance.getUserId(), user -> {
+ attendance.setUserName(user.getNickname()).setDeptId(user.getDeptId());
+ MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> attendance.setDeptName(dept.getName()));
+ });
+ }
+
+ /**
+ * 拼接考勤周报响应的用户和部门信息
+ *
+ * @param report 考勤周报响应
+ * @param userMap 用户 Map
+ * @param deptMap 部门 Map
+ */
+ private void fillAttendanceUserInfo(OaAttendanceWeekReportRespVO report,
+ Map userMap,
+ Map deptMap) {
+ MapUtils.findAndThen(userMap, report.getUserId(), user -> {
+ report.setUserName(user.getNickname()).setDeptId(user.getDeptId());
+ MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> report.setDeptName(dept.getName()));
+ });
+ }
+
+ /**
+ * 拼接考勤月报响应的用户和部门信息
+ *
+ * @param report 考勤月报响应
+ * @param userMap 用户 Map
+ * @param deptMap 部门 Map
+ */
+ private void fillAttendanceUserInfo(OaAttendanceMonthReportRespVO report,
+ Map userMap,
+ Map deptMap) {
+ MapUtils.findAndThen(userMap, report.getUserId(), user -> {
+ report.setUserName(user.getNickname()).setDeptId(user.getDeptId());
+ MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> report.setDeptName(dept.getName()));
+ });
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportReqVO.java
new file mode 100644
index 0000000000..0d76246d18
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportReqVO.java
@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 考勤月报 Request VO")
+@Data
+public class OaAttendanceMonthReportReqVO {
+
+ @Schema(description = "年份", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026")
+ @NotNull(message = "年份不能为空")
+ @Min(value = 2000, message = "年份不能小于 2000")
+ private Integer year;
+
+ @Schema(description = "月份", requiredMode = Schema.RequiredMode.REQUIRED, example = "9")
+ @NotNull(message = "月份不能为空")
+ @Min(value = 1, message = "月份不能小于 1")
+ @Max(value = 12, message = "月份不能大于 12")
+ private Integer month;
+
+ @Schema(description = "用户编号", example = "1")
+ private Long userId;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportRespVO.java
new file mode 100644
index 0000000000..15152680af
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportRespVO.java
@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+@Schema(description = "管理后台 - OA 考勤月报 Response VO")
+@Data
+@Accessors(chain = true)
+public class OaAttendanceMonthReportRespVO {
+
+ @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long userId;
+
+ @Schema(description = "用户昵称", example = "芋道")
+ private String userName;
+
+ @Schema(description = "部门编号", example = "100")
+ private Long deptId;
+
+ @Schema(description = "部门名称", example = "研发部")
+ private String deptName;
+
+ @Schema(description = "上班打卡次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "20")
+ private Integer clockInCount;
+
+ @Schema(description = "下班打卡次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "19")
+ private Integer clockOutCount;
+
+ @Schema(description = "正常次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "36")
+ private Integer normalCount;
+
+ @Schema(description = "迟到次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
+ private Integer lateCount;
+
+ @Schema(description = "早退次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer earlyCount;
+
+ @Schema(description = "请假天数,审批通过且开始时间在当月的申请天数合计", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
+ private Integer leaveDays;
+
+ @Schema(description = "出差天数,审批通过且开始时间在当月的申请天数合计", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
+ private Integer travelDays;
+
+ @Schema(description = "旷工天数", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer absentDays;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendancePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendancePageReqVO.java
new file mode 100644
index 0000000000..5b3c65bfaa
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendancePageReqVO.java
@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceStatusEnum;
+import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - OA 考勤分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OaAttendancePageReqVO extends PageParam {
+
+ @Schema(description = "用户编号", example = "1")
+ private Long userId;
+
+ @Schema(description = "考勤类型", example = "1")
+ @InEnum(value = OaAttendanceTypeEnum.class, message = "考勤类型必须是 {value}")
+ private Integer type;
+
+ @Schema(description = "考勤状态", example = "1")
+ @InEnum(value = OaAttendanceStatusEnum.class, message = "考勤状态必须是 {value}")
+ private Integer status;
+
+ @Schema(description = "考勤时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]")
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+ private LocalDateTime[] attendanceTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceRespVO.java
new file mode 100644
index 0000000000..35c5cea492
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceRespVO.java
@@ -0,0 +1,47 @@
+package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - OA 考勤 Response VO")
+@Data
+@Accessors(chain = true)
+public class OaAttendanceRespVO {
+
+ @Schema(description = "考勤记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long userId;
+
+ @Schema(description = "用户昵称", example = "芋道")
+ private String userName;
+
+ @Schema(description = "部门编号", example = "100")
+ private Long deptId;
+
+ @Schema(description = "部门名称", example = "研发部")
+ private String deptName;
+
+ @Schema(description = "考勤类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer type;
+
+ @Schema(description = "考勤状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer status;
+
+ @Schema(description = "考勤时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000")
+ private LocalDateTime attendanceTime;
+
+ @Schema(description = "考勤 IP", example = "127.0.0.1")
+ private String attendanceIp;
+
+ @Schema(description = "考勤备注", example = "正常打卡")
+ private String remark;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime createTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceUpdateReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceUpdateReqVO.java
new file mode 100644
index 0000000000..534c093c2e
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceUpdateReqVO.java
@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo;
+
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceStatusEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 考勤修改 Request VO")
+@Data
+public class OaAttendanceUpdateReqVO {
+
+ @Schema(description = "考勤记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "考勤记录编号不能为空")
+ private Long id;
+
+ @Schema(description = "考勤状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "考勤状态不能为空")
+ @InEnum(value = OaAttendanceStatusEnum.class, message = "考勤状态必须是 {value}")
+ private Integer status;
+
+ @Schema(description = "考勤备注", example = "因交通拥堵迟到")
+ @Size(max = 500, message = "考勤备注不能超过 500 个字符")
+ private String remark;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportReqVO.java
new file mode 100644
index 0000000000..6ea5ecfae4
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportReqVO.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDate;
+
+@Schema(description = "管理后台 - OA 考勤周报 Request VO")
+@Data
+public class OaAttendanceWeekReportReqVO {
+
+ @Schema(description = "周开始日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-07")
+ @NotNull(message = "周开始日期不能为空")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ private LocalDate startDate;
+
+ @Schema(description = "用户编号", example = "1")
+ private Long userId;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportRespVO.java
new file mode 100644
index 0000000000..85ed5d6603
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportRespVO.java
@@ -0,0 +1,59 @@
+package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 考勤周报 Response VO")
+@Data
+@Accessors(chain = true)
+public class OaAttendanceWeekReportRespVO {
+
+ @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long userId;
+
+ @Schema(description = "用户昵称", example = "芋道")
+ private String userName;
+
+ @Schema(description = "部门编号", example = "100")
+ private Long deptId;
+
+ @Schema(description = "部门名称", example = "研发部")
+ private String deptName;
+
+ @Schema(description = "每日考勤列表", requiredMode = Schema.RequiredMode.REQUIRED)
+ private List dailyAttendances;
+
+ @Schema(description = "管理后台 - OA 每日考勤 Response VO")
+ @Data
+ @Accessors(chain = true)
+ public static class DailyAttendance {
+
+ @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-07")
+ private LocalDate date;
+
+ @Schema(description = "上班考勤记录编号", example = "1024")
+ private Long clockInId;
+
+ @Schema(description = "上班打卡时间", type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime clockInTime;
+
+ @Schema(description = "上班打卡状态", example = "1")
+ private Integer clockInStatus;
+
+ @Schema(description = "下班考勤记录编号", example = "1025")
+ private Long clockOutId;
+
+ @Schema(description = "下班打卡时间", type = "integer", format = "int64", example = "1789380000000")
+ private LocalDateTime clockOutTime;
+
+ @Schema(description = "下班打卡状态", example = "1")
+ private Integer clockOutStatus;
+
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactCategoryController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactCategoryController.java
new file mode 100644
index 0000000000..e7ba296aad
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactCategoryController.java
@@ -0,0 +1,88 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category.OaContactCategoryRespVO;
+import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category.OaContactCategorySaveReqVO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactCategoryDO;
+import cn.iocoder.yudao.module.oa.service.contact.OaContactCategoryService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+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;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - OA 联系人分类")
+@RestController
+@RequestMapping("/oa/contact-category")
+@Validated
+public class OaContactCategoryController {
+
+ @Resource
+ private OaContactCategoryService contactCategoryService;
+
+ @PostMapping("/create")
+ @Operation(summary = "创建联系人分类")
+ public CommonResult createContactCategory(
+ @Valid @RequestBody OaContactCategorySaveReqVO createReqVO) {
+ return success(contactCategoryService.createContactCategory(createReqVO, getLoginUserId()));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "更新联系人分类")
+ public CommonResult updateContactCategory(
+ @Valid @RequestBody OaContactCategorySaveReqVO updateReqVO) {
+ contactCategoryService.updateContactCategory(updateReqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除联系人分类")
+ @Parameter(name = "id", description = "分类编号", required = true, example = "1024")
+ public CommonResult deleteContactCategory(@RequestParam("id") Long id) {
+ contactCategoryService.deleteContactCategory(id, getLoginUserId());
+ return success(true);
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "获得联系人分类")
+ @Parameter(name = "id", description = "分类编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:contact:query')")
+ public CommonResult getContactCategory(@RequestParam("id") Long id) {
+ OaContactCategoryDO category = contactCategoryService.getContactCategory(id, getLoginUserId());
+ return success(BeanUtils.toBean(category, OaContactCategoryRespVO.class));
+ }
+
+ @GetMapping("/simple-list")
+ @Operation(summary = "获得分类精简列表")
+ public CommonResult> getSimpleContactCategoryList() {
+ List categories = contactCategoryService.getContactCategoryList(getLoginUserId());
+ return success(convertList(categories, category -> new OaContactCategoryRespVO()
+ .setId(category.getId()).setName(category.getName())));
+ }
+
+ @GetMapping("/list")
+ @Operation(summary = "获得联系人分类列表")
+ @PreAuthorize("@ss.hasPermission('oa:contact:query')")
+ public CommonResult> getContactCategoryList() {
+ List categories = contactCategoryService.getContactCategoryList(getLoginUserId());
+ return success(BeanUtils.toBean(categories, OaContactCategoryRespVO.class));
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactController.java
new file mode 100644
index 0000000000..3d206f3190
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactController.java
@@ -0,0 +1,234 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
+import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactHandleReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactPageReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactRespVO;
+import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactSaveReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactShareReqVO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactDO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactCategoryDO;
+import cn.iocoder.yudao.module.oa.service.contact.OaContactCategoryService;
+import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactShareDO;
+import cn.iocoder.yudao.module.oa.service.contact.OaContactService;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
+import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+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.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertListByFlatMap;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - OA 通讯录")
+@RestController
+@RequestMapping("/oa/contact")
+@Validated
+public class OaContactController {
+
+ @Resource
+ private OaContactService contactService;
+ @Resource
+ private OaContactCategoryService contactCategoryService;
+
+ @Resource
+ private AdminUserApi adminUserApi;
+
+ // ==================== 我的联系人 ====================
+
+ @PostMapping("/create")
+ @Operation(summary = "创建外部联系人")
+ @PreAuthorize("@ss.hasPermission('oa:contact:create')")
+ public CommonResult createContact(@Valid @RequestBody OaContactSaveReqVO createReqVO) {
+ return success(contactService.createContact(createReqVO, getLoginUserId()));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "更新外部联系人")
+ @PreAuthorize("@ss.hasPermission('oa:contact:update')")
+ public CommonResult updateContact(@Valid @RequestBody OaContactSaveReqVO updateReqVO) {
+ contactService.updateContact(updateReqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除外部联系人")
+ @Parameter(name = "id", description = "联系人编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:contact:delete')")
+ public CommonResult deleteContact(@RequestParam("id") Long id) {
+ contactService.deleteContact(id, getLoginUserId());
+ return success(true);
+ }
+
+ @PostMapping("/share")
+ @Operation(summary = "共享外部联系人")
+ public CommonResult shareContact(@Valid @RequestBody OaContactShareReqVO shareReqVO) {
+ contactService.shareContact(shareReqVO.getContactId(), shareReqVO.getUserIds(), getLoginUserId());
+ return success(true);
+ }
+
+ // ==================== 共享与我 ====================
+
+ @DeleteMapping("/delete-received")
+ @Operation(summary = "删除接收到的共享联系人")
+ @Parameter(name = "contactId", description = "联系人编号", required = true, example = "1024")
+ public CommonResult deleteReceivedContact(@RequestParam("contactId") Long contactId) {
+ contactService.deleteReceivedContact(contactId, getLoginUserId());
+ return success(true);
+ }
+
+ @PutMapping("/handle-share")
+ @Operation(summary = "处理外部联系人共享")
+ public CommonResult handleContactShare(@Valid @RequestBody OaContactHandleReqVO handleReqVO) {
+ contactService.handleContactShare(handleReqVO.getContactId(), handleReqVO.getCategoryId(), getLoginUserId());
+ return success(true);
+ }
+
+ // ==================== 公共查询 ====================
+
+ @GetMapping("/get")
+ @Operation(summary = "获得外部联系人详情")
+ @Parameter(name = "id", description = "联系人编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:contact:query')")
+ public CommonResult getContact(@RequestParam("id") Long id) {
+ OaContactDO contact = contactService.getContact(id, getLoginUserId());
+ return success(buildContactRespVO(contact));
+ }
+
+ @GetMapping("/my-page")
+ @Operation(summary = "获得我的联系人分页")
+ @PreAuthorize("@ss.hasPermission('oa:contact:query')")
+ public CommonResult> getMyContactPage(@Valid OaContactPageReqVO pageReqVO) {
+ PageResult pageResult = contactService.getMyContactPage(pageReqVO, getLoginUserId());
+ return success(new PageResult<>(buildContactRespVOList(pageResult.getList()), pageResult.getTotal()));
+ }
+
+ @GetMapping("/received-page")
+ @Operation(summary = "获得共享给我的联系人分页")
+ @PreAuthorize("@ss.hasPermission('oa:contact:query')")
+ public CommonResult> getReceivedContactPage(@Valid OaContactPageReqVO pageReqVO) {
+ PageResult pageResult = contactService.getReceivedContactPage(pageReqVO, getLoginUserId());
+ return success(new PageResult<>(buildContactRespVOList(pageResult.getList()), pageResult.getTotal()));
+ }
+
+ @GetMapping("/shared-page")
+ @Operation(summary = "获得我共享的联系人分页")
+ @PreAuthorize("@ss.hasPermission('oa:contact:query')")
+ public CommonResult> getSharedContactPage(@Valid OaContactPageReqVO pageReqVO) {
+ PageResult pageResult = contactService.getSharedContactPage(pageReqVO, getLoginUserId());
+ List contacts = contactService.getContactList(convertSet(pageResult.getList(), OaContactShareDO::getContactId));
+ // 拼接 VO
+ Map contactMap = convertMap(buildContactRespVOList(contacts), OaContactRespVO::getId);
+ return success(new PageResult<>(convertList(pageResult.getList(), share -> {
+ OaContactRespVO contact = BeanUtils.toBean(contactMap.get(share.getContactId()), OaContactRespVO.class);
+ contact.setShare(CollUtil.findOne(contact.getShares(), item -> ObjectUtil.equal(item.getId(), share.getId())));
+ return contact;
+ }), pageResult.getTotal()));
+ }
+
+ // ==================== 拼接 VO ====================
+
+ /**
+ * 拼接联系人详情
+ *
+ * @param contact 联系人
+ * @return 联系人响应
+ */
+ private OaContactRespVO buildContactRespVO(OaContactDO contact) {
+ if (contact == null) {
+ return null;
+ }
+ return CollUtil.getFirst(buildContactRespVOList(Collections.singletonList(contact)));
+ }
+
+ /**
+ * 拼接外部联系人响应列表
+ *
+ * @param contacts 联系人列表
+ * @return 联系人响应列表
+ */
+ private List buildContactRespVOList(List contacts) {
+ if (CollUtil.isEmpty(contacts)) {
+ return Collections.emptyList();
+ }
+ // 1.1 批量查询共享关系
+ Map> contactShareListMap = contactService.getContactShareListMap(
+ convertSet(contacts, OaContactDO::getId));
+ List shares = convertListByFlatMap(contactShareListMap.values(), List::stream);
+ // 1.2 批量查询创建人和接收人信息
+ Set userIds = convertSet(contacts, contact -> NumberUtils.parseLong(contact.getCreator()));
+ userIds.addAll(convertSet(shares, OaContactShareDO::getUserId));
+ userIds.addAll(convertSet(shares, share -> NumberUtils.parseLong(share.getCreator())));
+ Map userMap = adminUserApi.getUserMap(userIds);
+ // 1.3 批量查询创建人和接收人的分类
+ Set categoryIds = convertSet(contacts, OaContactDO::getCategoryId);
+ categoryIds.addAll(convertSet(shares, OaContactShareDO::getCategoryId));
+ Map categoryMap = contactCategoryService.getContactCategoryMap(categoryIds);
+ Long loginUserId = getLoginUserId();
+ // 2. 拼接展示字段,接收人仅能查看自己的共享关系
+ return convertList(contacts, contactDO -> {
+ OaContactRespVO contact = BeanUtils.toBean(contactDO, OaContactRespVO.class)
+ .setOwnerUserId(NumberUtils.parseLong(contactDO.getCreator()));
+ MapUtils.findAndThen(categoryMap, contact.getCategoryId(),
+ category -> contact.setCategoryName(category.getName()));
+ MapUtils.findAndThen(userMap, contact.getOwnerUserId(),
+ user -> contact.setOwnerUserName(user.getNickname()));
+ List contactShares = contactShareListMap.getOrDefault(contact.getId(),
+ Collections.emptyList());
+ // 仅展示本人发出的共享,不将初始化的本人关联计入共享人数
+ List visibleContactShares = filterList(contactShares,
+ share -> ObjectUtil.equal(NumberUtils.parseLong(share.getCreator()), loginUserId)
+ && ObjectUtil.notEqual(share.getUserId(), loginUserId));
+ contact.setShares(convertList(visibleContactShares, share -> {
+ OaContactRespVO.Share shareVO = BeanUtils.toBean(share, OaContactRespVO.Share.class);
+ MapUtils.findAndThen(categoryMap, share.getCategoryId(),
+ category -> shareVO.setCategoryName(category.getName()));
+ MapUtils.findAndThen(userMap, NumberUtils.parseLong(share.getCreator()),
+ user -> shareVO.setCreatorName(user.getNickname()));
+ MapUtils.findAndThen(userMap, share.getUserId(),
+ user -> shareVO.setUserName(user.getNickname()).setUserAvatar(user.getAvatar()));
+ return shareVO;
+ }));
+ OaContactShareDO currentUserShare = CollUtil.findOne(contactShares,
+ share -> share.getUserId().equals(loginUserId));
+ if (currentUserShare != null) {
+ MapUtils.findAndThen(userMap, NumberUtils.parseLong(currentUserShare.getCreator()),
+ user -> contact.setSharerName(user.getNickname()));
+ contact.setHandleStatus(currentUserShare.getHandleStatus())
+ .setSharedCategoryId(currentUserShare.getCategoryId());
+ MapUtils.findAndThen(categoryMap, currentUserShare.getCategoryId(),
+ category -> contact.setSharedCategoryName(category.getName()));
+ }
+ return contact;
+ });
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactHandleReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactHandleReqVO.java
new file mode 100644
index 0000000000..8dad5259e1
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactHandleReqVO.java
@@ -0,0 +1,18 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 联系人共享处理 Request VO")
+@Data
+public class OaContactHandleReqVO {
+
+ @Schema(description = "联系人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "联系人编号不能为空")
+ private Long contactId;
+
+ @Schema(description = "接收人分类编号", example = "1024")
+ private Long categoryId;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactPageReqVO.java
new file mode 100644
index 0000000000..5737b4c3e4
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactPageReqVO.java
@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Schema(description = "管理后台 - OA 外部联系人分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OaContactPageReqVO extends PageParam {
+
+ @Schema(description = "姓名、拼音、手机或公司名称关键字", example = "张三")
+ private String keyword;
+
+ @Schema(description = "分类编号", example = "1024")
+ private Long categoryId;
+
+ @Schema(description = "是否已处理", example = "false")
+ private Boolean handleStatus;
+
+ @Schema(description = "姓名拼音首字母,空字符串表示全部", example = "A")
+ @Pattern(regexp = "^[A-Z]?$", message = "姓名拼音首字母必须为 A-Z")
+ private String alphabet;
+
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactRespVO.java
new file mode 100644
index 0000000000..288292728d
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactRespVO.java
@@ -0,0 +1,112 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 外部联系人 Response VO")
+@Data
+public class OaContactRespVO {
+
+ @Schema(description = "联系人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "创建人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long ownerUserId;
+
+ @Schema(description = "创建人用户昵称", example = "芋道源码")
+ private String ownerUserName;
+
+ @Schema(description = "分类编号", example = "1024")
+ private Long categoryId;
+
+ @Schema(description = "分类名称", example = "客户")
+ private String categoryName;
+
+ @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
+ private String name;
+
+ @Schema(description = "姓名拼音", example = "zhangsan")
+ private String pinyin;
+
+ @Schema(description = "性别", example = "1")
+ private Integer sex;
+
+ @Schema(description = "手机号码", example = "13800138000")
+ private String mobile;
+
+ @Schema(description = "邮箱", example = "yudao@example.com")
+ private String email;
+
+ @Schema(description = "地址", example = "上海市浦东新区世纪大道 100 号")
+ private String address;
+
+ @Schema(description = "公司名称", example = "芋道科技有限公司")
+ private String companyName;
+
+ @Schema(description = "公司电话", example = "021-12345678")
+ private String companyPhone;
+
+ @Schema(description = "头像地址", example = "https://example.com/image.png")
+ private String avatar;
+
+ @Schema(description = "备注", example = "请提前联系行政部确认")
+ private String remark;
+
+ @Schema(description = "共享记录列表")
+ private List shares;
+
+ @Schema(description = "当前行的共享关系,仅我共享的列表返回")
+ private Share share;
+
+ @Schema(description = "分享给当前用户的共享人昵称", example = "芋道源码")
+ private String sharerName;
+
+ @Schema(description = "当前接收人的处理状态", example = "false")
+ private Boolean handleStatus;
+
+ @Schema(description = "当前接收人的分类编号", example = "1024")
+ private Long sharedCategoryId;
+
+ @Schema(description = "当前接收人的分类名称", example = "客户联系人")
+ private String sharedCategoryName;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime createTime;
+
+ @Schema(description = "管理后台 - OA 联系人共享 Response VO")
+ @Data
+ public static class Share {
+
+ @Schema(description = "共享记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "共享接收人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long userId;
+
+ @Schema(description = "实际共享人昵称", example = "张三")
+ private String creatorName;
+
+ @Schema(description = "共享接收人用户昵称", example = "芋道源码")
+ private String userName;
+
+ @Schema(description = "共享接收人用户头像", example = "https://example.com/avatar.png")
+ private String userAvatar;
+
+ @Schema(description = "分类编号", example = "1024")
+ private Long categoryId;
+
+ @Schema(description = "接收人的分类名称", example = "客户")
+ private String categoryName;
+
+ @Schema(description = "处理状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
+ private Boolean handleStatus;
+
+ @Schema(description = "共享时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "1789347600000")
+ private LocalDateTime createTime;
+
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactSaveReqVO.java
new file mode 100644
index 0000000000..9f4079235f
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactSaveReqVO.java
@@ -0,0 +1,60 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact.vo;
+
+import cn.iocoder.yudao.framework.common.validation.Mobile;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 外部联系人新增/修改 Request VO")
+@Data
+public class OaContactSaveReqVO {
+
+ @Schema(description = "联系人编号", example = "1024")
+ private Long id;
+
+ @Schema(description = "分类编号", example = "1024")
+ private Long categoryId;
+
+ @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
+ @NotBlank(message = "姓名不能为空")
+ @Size(max = 50, message = "姓名长度不能超过 50 个字符")
+ private String name;
+
+ @Schema(description = "性别", example = "1")
+ private Integer sex;
+
+ @Schema(description = "手机号码", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800138000")
+ @NotBlank(message = "手机号码不能为空")
+ @Mobile
+ @Size(max = 20, message = "手机号码长度不能超过 20 个字符")
+ private String mobile;
+
+ @Schema(description = "邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "user@example.com")
+ @NotBlank(message = "邮箱不能为空")
+ @Email(message = "邮箱格式不正确")
+ @Size(max = 100, message = "邮箱长度不能超过 100 个字符")
+ private String email;
+
+ @Schema(description = "地址", example = "上海市浦东新区世纪大道 100 号")
+ @Size(max = 255, message = "地址长度不能超过 255 个字符")
+ private String address;
+
+ @Schema(description = "公司名称", example = "芋道科技有限公司")
+ @Size(max = 100, message = "公司名称长度不能超过 100 个字符")
+ private String companyName;
+
+ @Schema(description = "公司电话", example = "021-12345678")
+ @Size(max = 30, message = "公司电话长度不能超过 30 个字符")
+ private String companyPhone;
+
+ @Schema(description = "头像地址", example = "https://example.com/image.png")
+ @Size(max = 512, message = "头像地址长度不能超过 512 个字符")
+ private String avatar;
+
+ @Schema(description = "备注", example = "请提前联系行政部确认")
+ @Size(max = 500, message = "备注长度不能超过 500 个字符")
+ private String remark;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactShareReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactShareReqVO.java
new file mode 100644
index 0000000000..c763d63aa4
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactShareReqVO.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 联系人共享 Request VO")
+@Data
+public class OaContactShareReqVO {
+
+ @Schema(description = "联系人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "联系人编号不能为空")
+ private Long contactId;
+
+ @Schema(description = "共享接收人用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]")
+ @NotEmpty(message = "共享接收人不能为空")
+ private List<@NotNull(message = "共享接收人编号不能为空") Long> userIds;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategoryRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategoryRespVO.java
new file mode 100644
index 0000000000..471f785953
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategoryRespVO.java
@@ -0,0 +1,24 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - OA 联系人分类 Response VO")
+@Data
+public class OaContactCategoryRespVO {
+
+ @Schema(description = "分类编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "分类名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户")
+ private String name;
+
+ @Schema(description = "显示排序", example = "10")
+ private Integer sort;
+
+ @Schema(description = "创建时间", type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime createTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategorySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategorySaveReqVO.java
new file mode 100644
index 0000000000..f1ba3319dc
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategorySaveReqVO.java
@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 联系人分类新增/修改 Request VO")
+@Data
+public class OaContactCategorySaveReqVO {
+
+ @Schema(description = "分类编号", example = "1024")
+ private Long id;
+
+ @Schema(description = "分类名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户")
+ @NotBlank(message = "分类名称不能为空")
+ @Size(max = 50, message = "分类名称长度不能超过 50 个字符")
+ private String name;
+
+ @Schema(description = "显示排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
+ @NotNull(message = "显示排序不能为空")
+ private Integer sort;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionController.java
new file mode 100644
index 0000000000..00bdf9d1b7
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionController.java
@@ -0,0 +1,223 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionPageReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionRespVO;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionSaveReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaVoteOptionRespVO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionLikeDO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteOptionDO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteRecordDO;
+import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionService;
+import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionReplyService;
+import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionLikeService;
+import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionVoteService;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
+import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+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.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
+
+@Tag(name = "管理后台 - OA 讨论")
+@RestController
+@RequestMapping("/oa/discussion")
+@Validated
+public class OaDiscussionController {
+
+ @Resource
+ private OaDiscussionService discussionService;
+ @Resource
+ private AdminUserApi adminUserApi;
+ @Resource
+ private OaDiscussionLikeService discussionLikeService;
+ @Resource
+ private OaDiscussionReplyService discussionReplyService;
+ @Resource
+ private OaDiscussionVoteService discussionVoteService;
+
+ @PostMapping("/create")
+ @Operation(summary = "创建讨论")
+ @PreAuthorize("@ss.hasPermission('oa:discussion:create')")
+ public CommonResult createDiscussion(@Valid @RequestBody OaDiscussionSaveReqVO createReqVO) {
+ return success(discussionService.createDiscussion(createReqVO, getLoginUserId()));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "更新讨论")
+ @PreAuthorize("@ss.hasPermission('oa:discussion:update')")
+ public CommonResult updateDiscussion(@Valid @RequestBody OaDiscussionSaveReqVO updateReqVO) {
+ discussionService.updateDiscussion(updateReqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除讨论")
+ @Parameter(name = "id", description = "讨论编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:discussion:delete')")
+ public CommonResult deleteDiscussion(@RequestParam("id") Long id) {
+ discussionService.deleteDiscussion(id, getLoginUserId());
+ return success(true);
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "获得讨论详情")
+ @Parameter(name = "visit", description = "是否记录本次访问,默认不记录", example = "true")
+ @Parameter(name = "id", description = "讨论编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:discussion:query')")
+ public CommonResult getDiscussion(@RequestParam("id") Long id,
+ @RequestParam(value = "visit", defaultValue = "false") Boolean visit) {
+ // 1.1 查询讨论
+ OaDiscussionDO discussion = discussionService.validateDiscussionExists(id);
+ // 1.2 按需记录本次访问
+ if (Boolean.TRUE.equals(visit)) {
+ discussionService.increaseDiscussionVisitCount(id);
+ discussion.setVisitCount(discussion.getVisitCount() + 1);
+ }
+
+ // 2. 拼接讨论详情
+ return success(buildDiscussionRespVO(discussion));
+ }
+
+ @GetMapping("/page")
+ @Operation(summary = "获得讨论分页")
+ @PreAuthorize("@ss.hasPermission('oa:discussion:query')")
+ public CommonResult> getDiscussionPage(
+ @Valid OaDiscussionPageReqVO pageReqVO) {
+ PageResult pageResult = discussionService.getDiscussionPage(pageReqVO);
+ return success(new PageResult<>(buildDiscussionRespVOList(pageResult.getList()), pageResult.getTotal()));
+ }
+
+ @GetMapping("/manage-page")
+ @Operation(summary = "获得管理范围内的讨论分页")
+ @PreAuthorize("@ss.hasPermission('oa:discussion:query')")
+ public CommonResult> getDiscussionManagePage(
+ @Valid OaDiscussionPageReqVO pageReqVO) {
+ PageResult pageResult = discussionService.getDiscussionManagePage(pageReqVO, getLoginUserId());
+ return success(new PageResult<>(buildDiscussionRespVOList(pageResult.getList()), pageResult.getTotal()));
+ }
+
+ // ==================== 拼接 VO ====================
+
+ /**
+ * 拼接讨论详情
+ *
+ * @param discussion 讨论
+ * @return 讨论响应
+ */
+ private OaDiscussionRespVO buildDiscussionRespVO(OaDiscussionDO discussion) {
+ if (discussion == null) {
+ return null;
+ }
+ return CollUtil.getFirst(buildDiscussionRespVOList(Collections.singletonList(discussion)));
+ }
+
+ /**
+ * 拼接讨论响应列表
+ *
+ * @param discussions 讨论列表
+ * @return 讨论响应列表
+ */
+ private List buildDiscussionRespVOList(List discussions) {
+ if (CollUtil.isEmpty(discussions)) {
+ return Collections.emptyList();
+ }
+ // 1.1 批量查询回复和点赞
+ Set discussionIds = convertSet(discussions, OaDiscussionDO::getId);
+ Map> replyListMap = convertMultiMap(
+ discussionReplyService.getDiscussionReplyList(discussionIds), OaDiscussionReplyDO::getDiscussionId);
+ List discussionLikes = discussionLikeService.getDiscussionLikeList(discussionIds);
+ Map> likeListMap = convertMultiMap(discussionLikes,
+ OaDiscussionLikeDO::getDiscussionId);
+ // 1.2 批量查询投票选项和记录
+ Map> voteOptionListMap = convertMultiMap(
+ discussionVoteService.getVoteOptionList(discussionIds), OaVoteOptionDO::getDiscussionId);
+ List voteRecords = discussionVoteService.getVoteRecordList(discussionIds);
+ Map> voteRecordListMap = convertMultiMap(voteRecords,
+ OaVoteRecordDO::getDiscussionId);
+ // 1.3 批量查询发布人、点赞人和投票人
+ Set userIds = convertSet(discussions, OaDiscussionDO::getUserId);
+ userIds.addAll(convertSet(discussionLikes, OaDiscussionLikeDO::getUserId));
+ userIds.addAll(convertSet(voteRecords, OaVoteRecordDO::getUserId));
+ Map userMap = adminUserApi.getUserMap(userIds);
+ Long loginUserId = getLoginUserId();
+ // 2. 转换并拼接讨论展示字段
+ return BeanUtils.toBean(discussions, OaDiscussionRespVO.class, discussion -> {
+ findAndThen(userMap, discussion.getUserId(), user -> discussion.setUserName(user.getNickname()));
+ discussion.setReplyCount(CollUtil.size(replyListMap.get(discussion.getId())));
+ List likes = likeListMap.getOrDefault(discussion.getId(), Collections.emptyList());
+ discussion.setLikeCount(likes.size());
+ discussion.setLiked(CollUtil.findOne(likes, like -> like.getUserId().equals(loginUserId)) != null);
+ discussion.setLikeUserNames(convertList(likes,
+ like -> getUserNickname(userMap, like.getUserId())));
+ discussion.setVoteOptions(buildVoteOptionRespVOList(
+ voteOptionListMap.getOrDefault(discussion.getId(), Collections.emptyList()),
+ voteRecordListMap.getOrDefault(discussion.getId(), Collections.emptyList()), userMap, loginUserId));
+ });
+ }
+
+ /**
+ * 拼接投票选项响应列表
+ *
+ * @param voteOptions 投票选项列表
+ * @param voteRecords 投票记录列表
+ * @param userMap 用户 Map
+ * @param loginUserId 当前登录用户编号
+ * @return 投票选项响应列表
+ */
+ private List buildVoteOptionRespVOList(List voteOptions,
+ List voteRecords,
+ Map userMap,
+ Long loginUserId) {
+ Map> optionVoteRecordListMap = convertMultiMap(voteRecords,
+ OaVoteRecordDO::getOptionId);
+ return BeanUtils.toBean(voteOptions, OaVoteOptionRespVO.class, voteOption -> {
+ voteOption.setVoteCount(0).setVoted(false).setVoterUserNames(Collections.emptyList());
+ findAndThen(optionVoteRecordListMap, voteOption.getId(), records ->
+ voteOption.setVoteCount(records.size())
+ .setVoted(CollUtil.findOne(records, record -> record.getUserId().equals(loginUserId)) != null)
+ .setVoterUserNames(convertList(records, record -> getUserNickname(userMap, record.getUserId()))));
+ });
+ }
+
+ /**
+ * 获得用户昵称
+ *
+ * @param userMap 用户 Map
+ * @param userId 用户编号
+ * @return 用户昵称
+ */
+ private static String getUserNickname(Map userMap, Long userId) {
+ AdminUserRespDTO user = userMap.get(userId);
+ return user != null ? user.getNickname() : null;
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionLikeController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionLikeController.java
new file mode 100644
index 0000000000..8f36a7a51f
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionLikeController.java
@@ -0,0 +1,43 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.like.OaDiscussionLikeReqVO;
+import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionLikeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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 static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - OA 讨论点赞")
+@RestController
+@RequestMapping("/oa/discussion-like")
+@Validated
+public class OaDiscussionLikeController {
+
+ @Resource
+ private OaDiscussionLikeService discussionLikeService;
+
+ @PostMapping("/create")
+ @Operation(summary = "点赞讨论或主回复")
+ public CommonResult createDiscussionLike(@Valid @RequestBody OaDiscussionLikeReqVO reqVO) {
+ discussionLikeService.createDiscussionLike(reqVO.getDiscussionId(), reqVO.getReplyId(), getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "取消点赞讨论或主回复")
+ public CommonResult deleteDiscussionLike(@Valid OaDiscussionLikeReqVO reqVO) {
+ discussionLikeService.deleteDiscussionLike(reqVO.getDiscussionId(), reqVO.getReplyId(), getLoginUserId());
+ return success(true);
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionReplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionReplyController.java
new file mode 100644
index 0000000000..6164e31c55
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionReplyController.java
@@ -0,0 +1,133 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyPageReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyRespVO;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyCreateReqVO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionLikeDO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO;
+import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionReplyService;
+import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionLikeService;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
+import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.Collections;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap;
+import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - OA 讨论回复")
+@RestController
+@RequestMapping("/oa/discussion-reply")
+@Validated
+public class OaDiscussionReplyController {
+
+ @Resource
+ private OaDiscussionReplyService discussionReplyService;
+ @Resource
+ private AdminUserApi adminUserApi;
+ @Resource
+ private OaDiscussionLikeService discussionLikeService;
+
+ @PostMapping("/create")
+ @Operation(summary = "创建讨论回复")
+ public CommonResult createDiscussionReply(
+ @Valid @RequestBody OaDiscussionReplyCreateReqVO createReqVO) {
+ return success(discussionReplyService.createDiscussionReply(createReqVO, getLoginUserId()));
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "删除讨论回复")
+ @Parameter(name = "id", description = "回复编号", required = true, example = "1024")
+ public CommonResult deleteDiscussionReply(@RequestParam("id") Long id) {
+ discussionReplyService.deleteDiscussionReply(id, getLoginUserId());
+ return success(true);
+ }
+
+ @GetMapping("/page")
+ @Operation(summary = "获得讨论回复分页")
+ @PreAuthorize("@ss.hasPermission('oa:discussion:query')")
+ public CommonResult> getDiscussionReplyPage(
+ @Valid OaDiscussionReplyPageReqVO pageReqVO) {
+ PageResult pageResult = discussionReplyService.getDiscussionReplyPage(pageReqVO);
+ // 1. 批量查询当前页楼层内的子回复
+ List replies = new ArrayList<>(pageResult.getList());
+ replies.addAll(discussionReplyService.getDiscussionReplyDescendantList(pageResult.getList()));
+
+ // 2. 批量拼接展示字段,再按照父回复关系组织楼层
+ List replyVOs = buildDiscussionReplyRespVOList(replies);
+ Map replyMap = convertMap(replyVOs, OaDiscussionReplyRespVO::getId);
+ replyVOs.forEach(reply -> reply.setChildren(new ArrayList<>()));
+ replyVOs.forEach(reply -> findAndThen(replyMap, reply.getParentId(),
+ parent -> parent.getChildren().add(reply)));
+ return success(new PageResult<>(convertList(pageResult.getList(),
+ reply -> replyMap.get(reply.getId())), pageResult.getTotal()));
+ }
+
+ // ==================== 拼接 VO ====================
+
+ /**
+ * 拼接讨论回复响应列表
+ *
+ * @param replies 讨论回复列表
+ * @return 讨论回复响应列表
+ */
+ private List buildDiscussionReplyRespVOList(List replies) {
+ if (CollUtil.isEmpty(replies)) {
+ return Collections.emptyList();
+ }
+ // 1.1 批量查询回复点赞
+ Map> replyLikeListMap = discussionLikeService.getReplyLikeListMap(
+ convertSet(replies, OaDiscussionReplyDO::getId,
+ reply -> ObjectUtil.equal(reply.getParentId(), OaDiscussionReplyDO.PARENT_ID_ROOT)));
+ // 1.2 批量查询回复人、被回复人和点赞人
+ Set userIds = convertSetByFlatMap(replies,
+ reply -> Stream.of(reply.getUserId(), reply.getReplyUserId()));
+ userIds.addAll(convertSetByFlatMap(replyLikeListMap.values(),
+ likes -> likes.stream().map(OaDiscussionLikeDO::getUserId)));
+ Map userMap = adminUserApi.getUserMap(userIds);
+ Long loginUserId = getLoginUserId();
+
+ // 2. 转换并拼接回复展示字段
+ return BeanUtils.toBean(replies, OaDiscussionReplyRespVO.class, reply -> {
+ findAndThen(userMap, reply.getUserId(), user -> reply.setUserName(user.getNickname()));
+ findAndThen(userMap, reply.getReplyUserId(), user -> reply.setReplyUserName(user.getNickname()));
+ List likes = replyLikeListMap.getOrDefault(reply.getId(), Collections.emptyList());
+ reply.setLikeCount(likes.size())
+ .setLiked(CollUtil.findOne(likes, like -> like.getUserId().equals(loginUserId)) != null)
+ .setLikeUserNames(convertList(likes, like -> {
+ AdminUserRespDTO user = userMap.get(like.getUserId());
+ return user != null ? user.getNickname() : null;
+ }));
+ });
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionVoteController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionVoteController.java
new file mode 100644
index 0000000000..bb3bbbdeea
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionVoteController.java
@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaDiscussionVoteReqVO;
+import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionVoteService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.validation.annotation.Validated;
+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 static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - OA 讨论投票")
+@RestController
+@RequestMapping("/oa/discussion-vote")
+@Validated
+public class OaDiscussionVoteController {
+
+ @Resource
+ private OaDiscussionVoteService discussionVoteService;
+
+ @PostMapping("/create")
+ @Operation(summary = "参与讨论投票")
+ public CommonResult voteDiscussion(@Valid @RequestBody OaDiscussionVoteReqVO voteReqVO) {
+ discussionVoteService.voteDiscussion(voteReqVO.getDiscussionId(), voteReqVO.getOptionIds(), getLoginUserId());
+ return success(true);
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionPageReqVO.java
new file mode 100644
index 0000000000..78d0f6a76d
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionPageReqVO.java
@@ -0,0 +1,34 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion;
+
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - OA 讨论分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OaDiscussionPageReqVO extends SortablePageParam {
+
+ @Schema(description = "标题", example = "产品方案讨论")
+ private String title;
+
+ @Schema(description = "讨论类型", example = "1")
+ @InEnum(value = OaDiscussionTypeEnum.class, message = "讨论类型必须是 {value}")
+ private Integer type;
+
+ @Schema(description = "发布人用户编号", example = "1")
+ private Long userId;
+
+ @Schema(description = "创建时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]")
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+ private LocalDateTime[] createTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionRespVO.java
new file mode 100644
index 0000000000..b591fe2289
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionRespVO.java
@@ -0,0 +1,65 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion;
+
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaVoteOptionRespVO;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 讨论 Response VO")
+@Data
+public class OaDiscussionRespVO {
+
+ @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "发布人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long userId;
+
+ @Schema(description = "发布人用户昵称", example = "芋道源码")
+ private String userName;
+
+ @Schema(description = "讨论类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer type;
+
+ @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "下周团队会议时间征集")
+ private String title;
+
+ @Schema(description = "内容", example = "请大家反馈下周团队会议的时间安排。")
+ private String content;
+
+ @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]")
+ private List fileUrls;
+
+ @Schema(description = "访问次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
+ private Integer visitCount;
+
+ @Schema(description = "回复数", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
+ private Integer replyCount;
+
+ @Schema(description = "点赞数", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
+ private Integer likeCount;
+
+ @Schema(description = "当前用户是否已点赞", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
+ private Boolean liked;
+
+ @Schema(description = "点赞人用户昵称列表", example = "[\"张三\", \"李四\"]")
+ private List likeUserNames;
+
+ @Schema(description = "投票是否允许多选", example = "false")
+ private Boolean voteMultiple;
+
+ @Schema(description = "投票开始时间", type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime voteStartTime;
+
+ @Schema(description = "投票结束时间", type = "integer", format = "int64", example = "1789380000000")
+ private LocalDateTime voteEndTime;
+
+ @Schema(description = "投票选项列表")
+ private List voteOptions;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime createTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionSaveReqVO.java
new file mode 100644
index 0000000000..1c31bc679c
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionSaveReqVO.java
@@ -0,0 +1,69 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion;
+
+import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaVoteOptionSaveReqVO;
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 讨论新增/修改 Request VO")
+@Data
+public class OaDiscussionSaveReqVO {
+
+ @Schema(description = "讨论编号", example = "1024")
+ private Long id;
+
+ @Schema(description = "讨论类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "讨论类型不能为空")
+ @InEnum(value = OaDiscussionTypeEnum.class, message = "讨论类型必须是 {value}")
+ private Integer type;
+
+ @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "下周团队会议时间征集")
+ @NotBlank(message = "标题不能为空")
+ @Size(max = 255, message = "标题长度不能超过 255 个字符")
+ private String title;
+
+ @Schema(description = "内容", example = "请大家反馈下周团队会议的时间安排。")
+ private String content;
+
+ @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]")
+ private List<@NotBlank(message = "附件地址不能为空") @Size(max = 512, message = "附件地址长度不能超过 512 个字符") String> fileUrls;
+
+ @Schema(description = "投票是否允许多选", example = "false")
+ private Boolean voteMultiple;
+
+ @Schema(description = "投票开始时间", type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime voteStartTime;
+
+ @Schema(description = "投票结束时间", type = "integer", format = "int64", example = "1789380000000")
+ private LocalDateTime voteEndTime;
+
+ @Schema(description = "投票选项列表")
+ @Valid
+ private List<@NotNull(message = "投票选项不能为空") OaVoteOptionSaveReqVO> voteOptions;
+
+ @JsonIgnore
+ @AssertTrue(message = "投票至少需要两个选项,并设置正确的开始和结束时间")
+ @Schema(hidden = true)
+ public boolean isVoteConfigValid() {
+ if (ObjectUtil.notEqual(OaDiscussionTypeEnum.VOTE.getType(), type)) {
+ return true;
+ }
+ if (voteMultiple == null || voteStartTime == null || voteEndTime == null) {
+ return false;
+ }
+ return voteEndTime.isAfter(voteStartTime) && CollUtil.size(voteOptions) >= 2;
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/like/OaDiscussionLikeReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/like/OaDiscussionLikeReqVO.java
new file mode 100644
index 0000000000..2985fd893f
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/like/OaDiscussionLikeReqVO.java
@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.like;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.AssertTrue;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 讨论点赞 Request VO")
+@Data
+public class OaDiscussionLikeReqVO {
+
+ @Schema(description = "讨论编号,与回复编号必须且只能填写一个", example = "1024")
+ private Long discussionId;
+
+ @Schema(description = "回复编号,与讨论编号必须且只能填写一个", example = "2048")
+ private Long replyId;
+
+ @JsonIgnore
+ @AssertTrue(message = "讨论编号和回复编号必须且只能填写一个")
+ @Schema(hidden = true)
+ public boolean isTargetValid() {
+ if (discussionId != null) {
+ return replyId == null;
+ }
+ return replyId != null;
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyCreateReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyCreateReqVO.java
new file mode 100644
index 0000000000..559fcd14e2
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyCreateReqVO.java
@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.AssertTrue;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 讨论回复新增 Request VO")
+@Data
+public class OaDiscussionReplyCreateReqVO {
+
+ @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "讨论编号不能为空")
+ private Long discussionId;
+
+ @Schema(description = "父回复编号", example = "0")
+ private Long parentId;
+
+ @Schema(description = "回复内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "已收到,将按时完成。")
+ @NotBlank(message = "回复内容不能为空")
+ private String content;
+
+ /**
+ * 楼内评论对齐源端 255 字限制,根回复使用 TEXT 正文。
+ *
+ * @return 评论长度是否合法
+ */
+ @AssertTrue(message = "楼内评论不能超过 255 个字符")
+ @JsonIgnore
+ public boolean isCommentLengthValid() {
+ return parentId == null || parentId == 0L || content == null || content.length() <= 255;
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyPageReqVO.java
new file mode 100644
index 0000000000..d238d075c4
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyPageReqVO.java
@@ -0,0 +1,21 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply;
+
+import cn.iocoder.yudao.framework.common.pojo.SortablePageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Schema(description = "管理后台 - OA 讨论回复分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OaDiscussionReplyPageReqVO extends SortablePageParam {
+
+ @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "讨论编号不能为空")
+ private Long discussionId;
+
+ @Schema(description = "回复人用户编号", example = "1")
+ private Long userId;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyRespVO.java
new file mode 100644
index 0000000000..7c735ae174
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyRespVO.java
@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 讨论回复 Response VO")
+@Data
+public class OaDiscussionReplyRespVO {
+
+ @Schema(description = "回复编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
+ private Long discussionId;
+
+ @Schema(description = "回复人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Long userId;
+
+ @Schema(description = "回复人用户昵称", example = "芋道源码")
+ private String userName;
+
+ @Schema(description = "父回复编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
+ private Long parentId;
+
+ @Schema(description = "被回复人用户编号", example = "2")
+ private Long replyUserId;
+
+ @Schema(description = "被回复人用户昵称", example = "管理员")
+ private String replyUserName;
+
+ @Schema(description = "回复内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "已收到,将按时完成。")
+ private String content;
+
+ @Schema(description = "点赞数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
+ private Integer likeCount;
+
+ @Schema(description = "当前用户是否已点赞", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
+ private Boolean liked;
+
+ @Schema(description = "点赞人用户昵称列表", example = "[\"张三\", \"李四\"]")
+ private List likeUserNames;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime createTime;
+
+ @Schema(description = "楼层内的子回复")
+ private List children;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaDiscussionVoteReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaDiscussionVoteReqVO.java
new file mode 100644
index 0000000000..c6f9970664
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaDiscussionVoteReqVO.java
@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 讨论投票 Request VO")
+@Data
+public class OaDiscussionVoteReqVO {
+
+ @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "讨论编号不能为空")
+ private Long discussionId;
+
+ @Schema(description = "投票选项编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]")
+ @NotEmpty(message = "投票选项不能为空")
+ private List<@NotNull(message = "投票选项编号不能为空") Long> optionIds;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionRespVO.java
new file mode 100644
index 0000000000..dafa10bf39
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionRespVO.java
@@ -0,0 +1,33 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+@Schema(description = "管理后台 - OA 投票选项 Response VO")
+@Data
+public class OaVoteOptionRespVO {
+
+ @Schema(description = "投票选项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "选项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "周五下午")
+ private String title;
+
+ @Schema(description = "选项颜色", example = "#409EFF")
+ private String color;
+
+ @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
+ private Integer sort;
+
+ @Schema(description = "投票数", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
+ private Integer voteCount;
+
+ @Schema(description = "当前用户是否已选择", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
+ private Boolean voted;
+
+ @Schema(description = "投票人用户昵称列表", example = "[\"张三\", \"李四\"]")
+ private List voterUserNames;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionSaveReqVO.java
new file mode 100644
index 0000000000..986503663d
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionSaveReqVO.java
@@ -0,0 +1,26 @@
+package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Schema(description = "管理后台 - OA 投票选项新增/修改 Request VO")
+@Data
+public class OaVoteOptionSaveReqVO {
+
+ @Schema(description = "选项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "周五下午")
+ @NotBlank(message = "投票选项标题不能为空")
+ @Size(max = 200, message = "投票选项标题长度不能超过 200 个字符")
+ private String title;
+
+ @Schema(description = "选项颜色", example = "#409EFF")
+ @Size(max = 20, message = "投票选项颜色长度不能超过 20 个字符")
+ private String color;
+
+ @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
+ @NotNull(message = "显示顺序不能为空")
+ private Integer sort;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileFavoriteController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileFavoriteController.java
new file mode 100644
index 0000000000..13261b3504
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileFavoriteController.java
@@ -0,0 +1,43 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.module.oa.service.file.OaFileFavoriteService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - 云盘收藏")
+@RestController
+@RequestMapping("/oa/file-favorite")
+@Validated
+public class OaFileFavoriteController {
+
+ @Resource
+ private OaFileFavoriteService fileFavoriteService;
+
+ @PostMapping("/create")
+ @Operation(summary = "收藏文件")
+ @Parameter(name = "nodeId", description = "节点编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:file:query')")
+ public CommonResult createFileFavorite(@RequestParam("nodeId") Long nodeId) {
+ fileFavoriteService.createFileFavorite(nodeId, getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "取消收藏文件")
+ @Parameter(name = "nodeId", description = "节点编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:file:query')")
+ public CommonResult deleteFileFavorite(@RequestParam("nodeId") Long nodeId) {
+ fileFavoriteService.deleteFileFavorite(nodeId, getLoginUserId());
+ return success(true);
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileNodeController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileNodeController.java
new file mode 100644
index 0000000000..8acb165617
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileNodeController.java
@@ -0,0 +1,116 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.oa.controller.admin.file.vo.node.*;
+import cn.iocoder.yudao.module.oa.service.file.OaFileNodeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - 云盘文件")
+@RestController
+@RequestMapping("/oa/file-node")
+@Validated
+public class OaFileNodeController {
+
+ @Resource
+ private OaFileNodeService fileNodeService;
+
+ @GetMapping("/get-storage")
+ @Operation(summary = "获得本人云盘空间")
+ @PreAuthorize("@ss.hasPermission('oa:file:query')")
+ public CommonResult getFileStorage() {
+ return success(fileNodeService.getFileStorage(getLoginUserId()));
+ }
+
+ @GetMapping("/page")
+ @Operation(summary = "获得云盘文件分页")
+ @PreAuthorize("@ss.hasPermission('oa:file:query')")
+ public CommonResult> getFileNodePage(@Valid OaFileNodePageReqVO pageReqVO) {
+ return success(fileNodeService.getFileNodePage(pageReqVO, getLoginUserId()));
+ }
+
+ @GetMapping("/directory-list")
+ @Operation(summary = "获得本人可用目录列表")
+ @PreAuthorize("@ss.hasPermission('oa:file:query')")
+ public CommonResult> getFileDirectoryList() {
+ return success(fileNodeService.getFileDirectoryList(getLoginUserId()));
+ }
+
+ @PostMapping("/create")
+ @Operation(summary = "创建云盘文件或目录")
+ @PreAuthorize("@ss.hasPermission('oa:file:create')")
+ public CommonResult createFileNode(@Valid @RequestBody OaFileNodeSaveReqVO reqVO) {
+ return success(fileNodeService.createFileNode(reqVO, getLoginUserId()));
+ }
+
+ @PutMapping("/update-name")
+ @Operation(summary = "重命名云盘文件")
+ @PreAuthorize("@ss.hasPermission('oa:file:update')")
+ public CommonResult updateFileNodeName(@Valid @RequestBody OaFileNodeRenameReqVO reqVO) {
+ fileNodeService.updateFileNodeName(reqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @PutMapping("/update-parent")
+ @Operation(summary = "移动云盘文件")
+ @PreAuthorize("@ss.hasPermission('oa:file:update')")
+ public CommonResult updateFileNodeParent(@Valid @RequestBody OaFileNodeMoveReqVO reqVO) {
+ fileNodeService.updateFileNodeParent(reqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @PostMapping("/copy")
+ @Operation(summary = "复制云盘文件或目录")
+ @PreAuthorize("@ss.hasPermission('oa:file:create')")
+ public CommonResult copyFileNode(@Valid @RequestBody OaFileNodeCopyReqVO reqVO) {
+ return success(fileNodeService.copyFileNode(reqVO, getLoginUserId()));
+ }
+
+ @PutMapping("/recycle")
+ @Operation(summary = "移入回收站")
+ @Parameter(name = "id", description = "节点编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:file:delete')")
+ public CommonResult recycleFileNode(@RequestParam("id") Long id) {
+ fileNodeService.recycleFileNode(id, getLoginUserId());
+ return success(true);
+ }
+
+ @PutMapping("/restore")
+ @Operation(summary = "恢复回收站文件")
+ @Parameter(name = "id", description = "节点编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:file:delete')")
+ public CommonResult restoreFileNode(@RequestParam("id") Long id) {
+ fileNodeService.restoreFileNode(id, getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "彻底删除回收站文件")
+ @Parameter(name = "id", description = "节点编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:file:delete')")
+ public CommonResult deleteFileNode(@RequestParam("id") Long id) {
+ fileNodeService.deleteFileNode(id, getLoginUserId());
+ return success(true);
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "获得文件详情")
+ @Parameter(name = "id", description = "节点编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:file:query')")
+ public CommonResult getFileNode(@RequestParam("id") Long id) {
+ return success(fileNodeService.getFileNode(id, getLoginUserId()));
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFilePermissionController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFilePermissionController.java
new file mode 100644
index 0000000000..b9ee5a8fdc
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFilePermissionController.java
@@ -0,0 +1,57 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission.OaFilePermissionRespVO;
+import cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission.OaFilePermissionSaveReqVO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFilePermissionDO;
+import cn.iocoder.yudao.module.oa.service.file.OaFilePermissionService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - 云盘共享权限")
+@RestController
+@RequestMapping("/oa/file-permission")
+@Validated
+public class OaFilePermissionController {
+
+ @Resource
+ private OaFilePermissionService filePermissionService;
+
+ @GetMapping("/list")
+ @Operation(summary = "获得文件共享权限")
+ @Parameter(name = "nodeId", description = "节点编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:file:share')")
+ public CommonResult> getFilePermissionList(@RequestParam("nodeId") Long nodeId) {
+ List permissions = filePermissionService.getFilePermissionList(nodeId, getLoginUserId());
+ return success(BeanUtils.toBean(permissions, OaFilePermissionRespVO.class));
+ }
+
+ @PostMapping("/save")
+ @Operation(summary = "保存文件共享权限")
+ @PreAuthorize("@ss.hasPermission('oa:file:share')")
+ public CommonResult saveFilePermission(@Valid @RequestBody OaFilePermissionSaveReqVO reqVO) {
+ return success(filePermissionService.saveFilePermission(reqVO, getLoginUserId()));
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "取消文件共享权限")
+ @Parameter(name = "id", description = "权限编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:file:share')")
+ public CommonResult deleteFilePermission(@RequestParam("id") Long id) {
+ filePermissionService.deleteFilePermission(id, getLoginUserId());
+ return success(true);
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeCopyReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeCopyReqVO.java
new file mode 100644
index 0000000000..212d82a9ef
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeCopyReqVO.java
@@ -0,0 +1,19 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Schema(description = "管理后台 - 云盘复制 Request VO")
+@Data
+public class OaFileNodeCopyReqVO {
+
+ @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "节点编号不能为空")
+ private Long id;
+
+ @Schema(description = "目标目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "目标目录编号不能为空")
+ private Long parentId;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeMoveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeMoveReqVO.java
new file mode 100644
index 0000000000..eda52f3904
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeMoveReqVO.java
@@ -0,0 +1,19 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Schema(description = "管理后台 - 云盘移动 Request VO")
+@Data
+public class OaFileNodeMoveReqVO {
+
+ @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "节点编号不能为空")
+ private Long id;
+
+ @Schema(description = "目标目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "目标目录编号不能为空")
+ private Long parentId;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodePageReqVO.java
new file mode 100644
index 0000000000..94ab7eac6c
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodePageReqVO.java
@@ -0,0 +1,42 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.file.OaFileCategoryEnum;
+import cn.iocoder.yudao.module.oa.enums.file.OaFileScopeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 云盘文件分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OaFileNodePageReqVO extends PageParam {
+
+ @Schema(description = "列表范围:my、shared、favorite、recycle", requiredMode = Schema.RequiredMode.REQUIRED, example = "my")
+ @NotBlank(message = "列表范围不能为空")
+ @InEnum(value = OaFileScopeEnum.class, message = "列表范围必须是 {value}")
+ private String scope;
+
+ @Schema(description = "父目录编号,不传时查询范围内全部文件", example = "1024")
+ private Long parentId;
+
+ @Schema(description = "名称", example = "办公用品盘点结果.pdf")
+ @Size(max = 255, message = "名称长度不能超过 255 个字符")
+ private String name;
+
+ @Schema(description = "文件分类", example = "1")
+ @InEnum(value = OaFileCategoryEnum.class, message = "文件分类必须是 {value}")
+ private Integer category;
+
+ @Schema(description = "创建时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]")
+ @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+ private LocalDateTime[] createTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRenameReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRenameReqVO.java
new file mode 100644
index 0000000000..abce8c0cd3
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRenameReqVO.java
@@ -0,0 +1,20 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import lombok.Data;
+
+@Schema(description = "管理后台 - 云盘重命名 Request VO")
+@Data
+public class OaFileNodeRenameReqVO {
+
+ @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "节点编号不能为空")
+ private Long id;
+
+ @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "办公用品盘点结果.pdf")
+ @NotBlank(message = "名称不能为空")
+ @Size(max = 255, message = "名称长度不能超过 255 个字符")
+ private String name;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRespVO.java
new file mode 100644
index 0000000000..c7829ab132
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRespVO.java
@@ -0,0 +1,54 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 云盘文件 Response VO")
+@Data
+public class OaFileNodeRespVO {
+
+ @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "父目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long parentId;
+
+ @Schema(description = "节点类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
+ private Integer type;
+
+ @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "办公用品盘点结果.pdf")
+ private String name;
+
+ @Schema(description = "临时文件地址,仅详情中具有下载权限时返回", example = "https://example.com/file.pdf")
+ private String url;
+
+ @Schema(description = "扩展名", example = "pdf")
+ private String extension;
+
+ @Schema(description = "文件分类", example = "1")
+ private Integer category;
+
+ @Schema(description = "大小,单位字节", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long size;
+
+ @Schema(description = "节点状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
+ private Integer status;
+
+ @Schema(description = "创建人", example = "1024")
+ private String creator;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime createTime;
+
+ @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime updateTime;
+
+ @Schema(description = "当前用户权限级别", example = "1")
+ private Integer level;
+
+ @Schema(description = "是否收藏", example = "false")
+ private Boolean favorite;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeSaveReqVO.java
new file mode 100644
index 0000000000..db75607fbb
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeSaveReqVO.java
@@ -0,0 +1,49 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node;
+
+import cn.hutool.core.util.ObjUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.file.OaFileNodeTypeEnum;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import lombok.Data;
+
+@Schema(description = "管理后台 - 云盘文件新增 Request VO")
+@Data
+public class OaFileNodeSaveReqVO {
+
+ @Schema(description = "父目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "父目录编号不能为空")
+ private Long parentId;
+
+ @Schema(description = "节点类型:0 文件夹、1 文件", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
+ @NotNull(message = "节点类型不能为空")
+ @InEnum(value = OaFileNodeTypeEnum.class, message = "节点类型必须是 {value}")
+ private Integer type;
+
+ @Schema(description = "节点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "办公用品盘点结果.pdf")
+ @NotBlank(message = "节点名称不能为空")
+ @Size(max = 255, message = "节点名称长度不能超过 255 个字符")
+ private String name;
+
+ @Schema(description = "上传文件地址,文件夹不传", example = "https://example.com/file.pdf")
+ @Size(max = 2048, message = "文件地址长度不能超过 2048 个字符")
+ private String url;
+
+ @Schema(description = "文件大小,单位字节,文件夹不传", example = "1024")
+ private Long size;
+
+ @AssertTrue(message = "文件地址不能为空")
+ @JsonIgnore
+ public boolean isUrlValid() {
+ return ObjUtil.notEqual(type, OaFileNodeTypeEnum.FILE.getType()) || StrUtil.isNotBlank(url);
+ }
+
+ @AssertTrue(message = "文件大小不能为空且不能小于 0")
+ @JsonIgnore
+ public boolean isSizeValid() {
+ return ObjUtil.notEqual(type, OaFileNodeTypeEnum.FILE.getType()) || (size != null && size >= 0);
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileStorageRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileStorageRespVO.java
new file mode 100644
index 0000000000..7fb485f55d
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileStorageRespVO.java
@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Schema(description = "管理后台 - 云盘空间 Response VO")
+@Data
+public class OaFileStorageRespVO {
+
+ @Schema(description = "已用容量,单位字节", example = "1024")
+ private Long usedSize;
+
+ @Schema(description = "总容量,单位字节", example = "1073741824")
+ private Long totalSize;
+
+ @Schema(description = "本人正常文件数量,不含目录和回收站", example = "1")
+ private Long fileCount;
+
+ @Schema(description = "本人有效共享节点数量,按文件或目录去重", example = "1")
+ private Long sharedCount;
+
+ @Schema(description = "收到的共享入口数量,不重复统计子节点", example = "1")
+ private Long receivedCount;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionRespVO.java
new file mode 100644
index 0000000000..520009cea0
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionRespVO.java
@@ -0,0 +1,33 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 云盘共享权限 Response VO")
+@Data
+public class OaFilePermissionRespVO {
+
+ @Schema(description = "权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long nodeId;
+
+ @Schema(description = "主体类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer subjectType;
+
+ @Schema(description = "主体编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long subjectId;
+
+ @Schema(description = "权限级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ private Integer level;
+
+ @Schema(description = "是否继承", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
+ private Boolean inherit;
+
+ @Schema(description = "到期时间", type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime expireTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionSaveReqVO.java
new file mode 100644
index 0000000000..c1ce3fef04
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionSaveReqVO.java
@@ -0,0 +1,42 @@
+package cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission;
+
+import cn.iocoder.yudao.framework.common.validation.InEnum;
+import cn.iocoder.yudao.module.oa.enums.file.OaFilePermissionLevelEnum;
+import cn.iocoder.yudao.module.oa.enums.file.OaFileSubjectTypeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 云盘共享权限 Request VO")
+@Data
+public class OaFilePermissionSaveReqVO {
+
+ @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "节点编号不能为空")
+ private Long nodeId;
+
+ @Schema(description = "共享主体", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "共享主体类型不能为空")
+ @InEnum(value = OaFileSubjectTypeEnum.class, message = "共享主体类型必须是 {value}")
+ private Integer subjectType;
+
+ @Schema(description = "主体编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "主体编号不能为空")
+ private Long subjectId;
+
+ @Schema(description = "权限", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "权限级别不能为空")
+ @InEnum(value = OaFilePermissionLevelEnum.class, message = "权限级别必须是 {value}")
+ private Integer level;
+
+ @Schema(description = "是否继承到子目录及文件", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
+ @NotNull(message = "是否继承不能为空")
+ private Boolean inherit;
+
+ @Schema(description = "到期时间", type = "integer", format = "int64", example = "1789347600000")
+ @Future(message = "到期时间必须晚于当前时间")
+ private LocalDateTime expireTime;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/OaLeaveApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/OaLeaveApplyController.java
new file mode 100644
index 0000000000..3fd7699846
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/OaLeaveApplyController.java
@@ -0,0 +1,122 @@
+package cn.iocoder.yudao.module.oa.controller.admin.leave;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySaveReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySubmitReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplyPageReqVO;
+import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplyRespVO;
+import cn.iocoder.yudao.module.oa.dal.dataobject.leave.OaLeaveApplyDO;
+import cn.iocoder.yudao.module.oa.service.leave.OaLeaveApplyService;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
+import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+
+@Tag(name = "管理后台 - 请假申请")
+@RestController
+@RequestMapping("/oa/leave-apply")
+@Validated
+public class OaLeaveApplyController {
+
+ @Resource
+ private OaLeaveApplyService leaveApplyService;
+
+ @Resource
+ private AdminUserApi adminUserApi;
+
+ @PostMapping("/create")
+ @Operation(summary = "创建请假申请草稿")
+ @PreAuthorize("@ss.hasPermission('oa:leave-apply:create')")
+ public CommonResult createLeaveApply(@Valid @RequestBody OaLeaveApplySaveReqVO createReqVO) {
+ return success(leaveApplyService.createLeaveApply(createReqVO));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "更新请假申请草稿")
+ @PreAuthorize("@ss.hasPermission('oa:leave-apply:create')")
+ public CommonResult updateLeaveApply(@Valid @RequestBody OaLeaveApplySaveReqVO saveReqVO) {
+ leaveApplyService.updateLeaveApply(saveReqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @PostMapping("/submit")
+ @Operation(summary = "提交请假申请")
+ @PreAuthorize("@ss.hasPermission('oa:leave-apply:create')")
+ public CommonResult submitLeaveApply(@Valid @RequestBody OaLeaveApplySubmitReqVO submitReqVO) {
+ leaveApplyService.submitLeaveApply(submitReqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @GetMapping("/page")
+ @Operation(summary = "获得本人请假申请分页")
+ @PreAuthorize("@ss.hasPermission('oa:leave-apply:query')")
+ public CommonResult> getLeaveApplyPage(@Valid OaLeaveApplyPageReqVO pageReqVO) {
+ PageResult page = leaveApplyService.getLeaveApplyPage(getLoginUserId(), pageReqVO);
+ return success(new PageResult<>(buildLeaveApplyRespVOList(page.getList()), page.getTotal()));
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "获得请假申请详情")
+ @Parameter(name = "id", description = "申请编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:leave-apply:query')")
+ public CommonResult getLeaveApply(@RequestParam("id") Long id) {
+ OaLeaveApplyDO application = leaveApplyService.getLeaveApply(id);
+ return success(buildLeaveApplyRespVO(application));
+ }
+
+ // ==================== 拼接 VO ====================
+
+ /**
+ * 拼接请假申请详情
+ *
+ * @param application 申请
+ * @return 请假申请响应
+ */
+ private OaLeaveApplyRespVO buildLeaveApplyRespVO(OaLeaveApplyDO application) {
+ if (application == null) {
+ return null;
+ }
+ return CollUtil.getFirst(buildLeaveApplyRespVOList(Collections.singletonList(application)));
+ }
+
+ /**
+ * 批量拼接申请人展示信息
+ *
+ * @param applications 申请列表
+ * @return 响应列表
+ */
+ private List buildLeaveApplyRespVOList(List applications) {
+ if (CollUtil.isEmpty(applications)) {
+ return Collections.emptyList();
+ }
+ // 1. 批量查询申请人
+ Map users = adminUserApi.getUserMap(
+ convertSet(applications, application -> Long.valueOf(application.getCreator())));
+ // 2. 转换并拼接 VO
+ return convertList(applications, application -> {
+ OaLeaveApplyRespVO vo = BeanUtils.toBean(application, OaLeaveApplyRespVO.class);
+ MapUtils.findAndThen(users, Long.valueOf(application.getCreator()), user -> vo.setCreatorName(user.getNickname()));
+ return vo;
+ });
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyPageReqVO.java
new file mode 100644
index 0000000000..875af8eb2d
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyPageReqVO.java
@@ -0,0 +1,19 @@
+package cn.iocoder.yudao.module.oa.controller.admin.leave.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Schema(description = "管理后台 - 请假申请分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OaLeaveApplyPageReqVO extends PageParam {
+
+ @Schema(description = "标题", example = "个人事务请假申请")
+ private String title;
+
+ @Schema(description = "审批状态", example = "-1")
+ private Integer status;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyRespVO.java
new file mode 100644
index 0000000000..9a523cb6e6
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyRespVO.java
@@ -0,0 +1,54 @@
+package cn.iocoder.yudao.module.oa.controller.admin.leave.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - 请假申请 Response VO")
+@Data
+public class OaLeaveApplyRespVO {
+
+ @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ private Long id;
+
+ @Schema(description = "标题", example = "个人事务请假申请")
+ private String title;
+
+ @Schema(description = "紧急程度", example = "1")
+ private Integer urgency;
+
+ @Schema(description = "请假类型", example = "1")
+ private Integer type;
+
+ @Schema(description = "开始时间", type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime startTime;
+
+ @Schema(description = "结束时间", type = "integer", format = "int64", example = "1789380000000")
+ private LocalDateTime endTime;
+
+ @Schema(description = "天数", example = "1")
+ private Integer days;
+
+ @Schema(description = "申请原因", example = "处理个人事务")
+ private String reason;
+
+ @Schema(description = "审批状态", example = "-1")
+ private Integer status;
+
+ @Schema(description = "流程实例编号", example = "1024")
+ private String processInstanceId;
+
+ @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]")
+ private List fileUrls;
+
+ @Schema(description = "申请人编号", example = "1024")
+ private String creator;
+
+ @Schema(description = "申请人昵称", example = "张三")
+ private String creatorName;
+
+ @Schema(description = "申请时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ private LocalDateTime createTime;
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySaveReqVO.java
new file mode 100644
index 0000000000..e832790fce
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySaveReqVO.java
@@ -0,0 +1,59 @@
+package cn.iocoder.yudao.module.oa.controller.admin.leave.vo;
+
+import cn.iocoder.yudao.framework.dict.validation.InDict;
+import cn.iocoder.yudao.module.oa.enums.DictTypeConstants;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Schema(description = "管理后台 - 请假申请新增/修改 Request VO")
+@Data
+public class OaLeaveApplySaveReqVO {
+
+ @Schema(description = "申请编号", example = "1024")
+ private Long id;
+
+ @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "个人事务请假申请")
+ @NotBlank(message = "标题不能为空")
+ @Size(max = 255, message = "标题不能超过 255 个字符")
+ private String title;
+
+ @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "紧急程度不能为空")
+ @InDict(type = DictTypeConstants.APPLY_URGENCY, message = "紧急程度必须是 {value}")
+ private Integer urgency;
+
+ @Schema(description = "请假类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+ @NotNull(message = "请假类型不能为空")
+ @InDict(type = DictTypeConstants.LEAVE_TYPE, message = "请假类型必须是 {value}")
+ private Integer type;
+
+ @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000")
+ @NotNull(message = "开始时间不能为空")
+ private LocalDateTime startTime;
+
+ @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000")
+ @NotNull(message = "结束时间不能为空")
+ private LocalDateTime endTime;
+
+ @Schema(description = "申请原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "处理个人事务")
+ @NotBlank(message = "申请原因不能为空")
+ @Size(max = 5000, message = "申请原因不能超过 5000 个字符")
+ private String reason;
+
+ @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]")
+ @Size(max = 5, message = "附件不能超过 5 个")
+ private List<@NotBlank(message = "附件地址不能为空") @Size(max = 1024, message = "附件地址不能超过 1024 个字符") String> fileUrls;
+
+ @AssertTrue(message = "结束时间不能早于开始时间")
+ @JsonIgnore
+ public boolean isTimeRangeValid() {
+ // 缺失值交由字段上的必填校验处理
+ return startTime == null || endTime == null || !endTime.isBefore(startTime);
+ }
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySubmitReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySubmitReqVO.java
new file mode 100644
index 0000000000..04205a278a
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySubmitReqVO.java
@@ -0,0 +1,21 @@
+package cn.iocoder.yudao.module.oa.controller.admin.leave.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Schema(description = "管理后台 - 请假申请提交 Request VO")
+@Data
+public class OaLeaveApplySubmitReqVO {
+
+ @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+ @NotNull(message = "申请编号不能为空")
+ private Long id;
+
+ @Schema(description = "发起人自选审批人")
+ private Map> startUserSelectAssignees;
+
+}
diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailAccountController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailAccountController.java
new file mode 100644
index 0000000000..e1baab86cf
--- /dev/null
+++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailAccountController.java
@@ -0,0 +1,132 @@
+package cn.iocoder.yudao.module.oa.controller.admin.mail;
+
+import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
+import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.account.*;
+import cn.iocoder.yudao.module.oa.service.mail.OaMailAccountService;
+import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailAccountDO;
+import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
+import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
+import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import cn.hutool.core.util.StrUtil;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
+import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
+
+@Tag(name = "管理后台 - 企业邮箱账号")
+@RestController
+@RequestMapping("/oa/mail-account")
+@Validated
+public class OaMailAccountController {
+
+ @Resource
+ private OaMailAccountService mailAccountService;
+
+ @Resource
+ private AdminUserApi adminUserApi;
+
+ @GetMapping("/list")
+ @Operation(summary = "获得本人邮箱账号列表")
+ @Parameter(name = "status", description = "状态,未传时查询全部状态")
+ @PreAuthorize("@ss.hasPermission('oa:mail-account:query')")
+ public CommonResult> getMailAccountList(@RequestParam(value = "status", required = false) Integer status) {
+ List accounts = mailAccountService.getMailAccountListByUserIdAndStatus(getLoginUserId(), status);
+ return success(BeanUtils.toBean(accounts, OaMailAccountRespVO.class));
+ }
+
+ @GetMapping("/simple-list")
+ @Operation(summary = "获得当前租户邮箱地址精简列表")
+ public CommonResult> getSimpleMailAccountList() {
+ // 1.1 查询邮箱及归属人,批量获取用户展示信息
+ List accounts = mailAccountService.getMailAccountListByStatus(CommonStatusEnum.ENABLE.getStatus());
+ Map users = adminUserApi.getUserMap(
+ convertSet(accounts, account -> NumberUtils.parseLong(account.getCreator())));
+ // 1.2 全员选择器只返回邮箱和姓名,不暴露登录名及连接配置
+ List result = convertList(accounts, account -> {
+ OaMailAccountRespVO respVO = new OaMailAccountRespVO().setMail(account.getMail());
+ MapUtils.findAndThen(users, NumberUtils.parseLong(account.getCreator()),
+ user -> respVO.setUserName(user.getNickname()));
+ return respVO;
+ });
+
+ // 2. 补充已配置邮箱的启用用户
+ Set mails = convertSet(result, account -> account.getMail().toLowerCase(java.util.Locale.ROOT));
+ List systemUsers = adminUserApi.getUserListByStatus(CommonStatusEnum.ENABLE.getStatus());
+ for (AdminUserRespDTO user : systemUsers) {
+ if (StrUtil.isNotBlank(user.getEmail()) && mails.add(user.getEmail().toLowerCase(java.util.Locale.ROOT))) {
+ result.add(new OaMailAccountRespVO().setMail(user.getEmail()).setUserName(user.getNickname()));
+ }
+ }
+ return success(result);
+ }
+
+ @GetMapping("/get")
+ @Operation(summary = "获得本人邮箱账号")
+ @Parameter(name = "id", description = "邮箱账号编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:mail-account:query')")
+ public CommonResult getMailAccount(@RequestParam("id") Long id) {
+ OaMailAccountDO account = mailAccountService.validateMailAccount(id, getLoginUserId());
+ return success(BeanUtils.toBean(account, OaMailAccountRespVO.class));
+ }
+
+ @PostMapping("/create")
+ @ApiAccessLog(requestEnable = false) // 特殊:password 密码,避免日志记录
+ @Operation(summary = "绑定本人邮箱账号")
+ @PreAuthorize("@ss.hasPermission('oa:mail-account:create')")
+ public CommonResult createMailAccount(@Valid @RequestBody OaMailAccountSaveReqVO reqVO) {
+ return success(mailAccountService.createMailAccount(reqVO, getLoginUserId()));
+ }
+
+ @PutMapping("/update")
+ @ApiAccessLog(requestEnable = false) // 特殊:password 密码,避免日志记录
+ @Operation(summary = "更新本人邮箱账号")
+ @PreAuthorize("@ss.hasPermission('oa:mail-account:update')")
+ public CommonResult updateMailAccount(@Valid @RequestBody OaMailAccountSaveReqVO reqVO) {
+ mailAccountService.updateMailAccount(reqVO, getLoginUserId());
+ return success(true);
+ }
+
+ @PutMapping("/update-default")
+ @Operation(summary = "设置本人默认发件账号")
+ @Parameter(name = "id", description = "邮箱账号编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:mail-account:update')")
+ public CommonResult updateMailAccountDefault(@RequestParam("id") Long id) {
+ mailAccountService.updateMailAccountDefault(id, getLoginUserId());
+ return success(true);
+ }
+
+ @DeleteMapping("/delete")
+ @Operation(summary = "移除本人邮箱绑定")
+ @Parameter(name = "id", description = "邮箱账号编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:mail-account:delete')")
+ public CommonResult deleteMailAccount(@RequestParam("id") Long id) {
+ mailAccountService.deleteMailAccount(id, getLoginUserId());
+ return success(true);
+ }
+
+ @PostMapping("/test-connection")
+ @Operation(summary = "测试本人邮箱连接")
+ @Parameter(name = "id", description = "邮箱账号编号", required = true, example = "1024")
+ @PreAuthorize("@ss.hasPermission('oa:mail-account:query')")
+ public CommonResult