修复冲突

Signed-off-by: CT <164434275@qq.com>
This commit is contained in:
CT 2026-05-22 07:12:34 +00:00 committed by Gitee
commit 6db4e834c7
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
246 changed files with 9180 additions and 3601 deletions

View File

@ -0,0 +1,42 @@
---
name: backend-common-infrastructure
description: 公共基础模块专家。用于修改 ruoyi-common 下的 mybatis、translation、json enhance、excel、oss、redis、web、encrypt 等公共能力,强调 API 兼容、调用点检查和同包风格一致。
---
你负责 `ruoyi-common` 公共基础模块的增量修改。
## 核心原则
1. 先阅读同包接口、实现类和调用点,再改公共 API。
2. 优先保持公开方法签名、泛型、返回值、异常语义兼容。
3. 新增能力要贴合已有命名和链式调用风格,不自造平行体系。
4. 只补注释时不改实现、不重排 import、不运行无关格式化。
## common-mybatis
- 链式查询能力沿用 `BaseMapperPlus#lambda()`、`LambdaCrudChainWrapper`、`LambdaQueryBuilder`、`LambdaQueryCondition`。
- 条件辅助方法命名沿用 `eqIfPresent`、`eqIfText`、`likeIfText`、`betweenIfPresent`、`inIfNotEmpty`、`findInSetIfPresent`。
- `LambdaCrudChainWrapper` 同时维护查询字段和更新 set 片段;新增状态时必须检查 `instance()``clear()`
- 返回链式对象时保持 `this` / `typedThis`,不要暴露底层 wrapper 破坏调用链。
## translation / JSON 增强
- 翻译实现类实现 `TranslationInterface<T>` 并标注 `@TranslationType(type = ...)`
- 批量翻译优先实现 `translationBatch(Set<Object> keys, String other)`,避免默认逐条查询。
- 支持逗号分隔 ID 时复用 `collectLongIds`、`parseLongIds`、`joinMappedValues`。
- `TranslationJsonFieldProcessor``collect` / `prepare` / `process` 三阶段组织。
- 翻译失败应降级返回原值或 `null`,不要让响应增强中断主流程。
## excel / oss / json / web
- Excel 导入监听器保持 `ExcelListener#getExcelResult()` 回执语义和错误聚合方式。
- OSS 结果、异常、配置对象的工厂方法保持现有 `form...` 命名,不随意改成通用 builder。
- JSON 响应增强处理器优先实现 `JsonFieldProcessor`,并按字段上下文读取注解。
- Web、Redis、Encrypt、Sa-Token 自动配置类新增 bean 时检查条件注解、配置属性和已有命名。
## 自检
- 是否破坏已有调用点。
- 是否遗漏 `instance()` / `clear()` / 批量翻译 / 缓存失效等公共模块关键路径。
- 是否新增了与现有工具重复的临时类或临时方法。
- JavaDoc 是否简洁说明公共 API 的参数、返回值和兼容语义。

View File

@ -15,10 +15,12 @@ description: 标准后端 CRUD 专家。用于当前项目中的新增单表 CRU
## 结构约定
- entity 默认继承 `BaseEntity`
- entity 使用 `@TableName`,主键使用 `@TableId`;存在 `delFlag`、乐观锁字段时保留 `@TableLogic`、`@Version`
- mapper 默认继承 `BaseMapperPlus<Entity, Vo>`
- BO 使用 `@AutoMapper(target = Entity.class, reverseConvertGenerate = false)`
- VO 使用 `@AutoMapper(target = Entity.class)`
- service 使用 `baseMapper`
- BO/VO/Entity 职责分离:请求、查询扩展字段放 BO展示派生字段和 `@Translation` 放 VO
- 代码生成器模板按类名首字母小写命名 Mapper 字段,例如 `SysRoleMapper` -> `sysRoleMapper`;手写业务代码可使用具体业务短名
## 默认方法集合
@ -32,8 +34,11 @@ description: 标准后端 CRUD 专家。用于当前项目中的新增单表 CRU
## 查询规则
- 单表查询优先用 `LambdaQueryWrapper`
- 项目公共链式查询可使用 `BaseMapperPlus#lambda()`、`LambdaCrudChainWrapper`、`LambdaQueryCondition` 的 `IfPresent` / `IfText` / `IfNotEmpty` 风格
- 日期范围默认从 `bo.getParams()` 中读取 begin/end
- 分页优先返回 `PageResult<Vo>`
- BO 转实体使用 `MapstructUtils.convert(bo, Entity.class)`
- 写入前校验优先放在 `validEntityBeforeSave(...)`
## 接口规则
@ -47,6 +52,8 @@ description: 标准后端 CRUD 专家。用于当前项目中的新增单表 CRU
`PUT`
`DELETE /{ids}`
- 默认检查是否需要 `@SaCheckPermission`、`@Log`、`@RepeatSubmit`
- 权限标识遵循 `${module}:${business}:${action}`
- 导出接口通常保持 `POST /export`
## 自检
@ -54,3 +61,4 @@ description: 标准后端 CRUD 专家。用于当前项目中的新增单表 CRU
- BO / VO / Entity 是否职责分离
- 导出、分页、删除前校验是否齐全
- 是否只是 generator 裸产物,如果是要继续补齐项目约定
- 前端 `api/types/index.vue` 如需同步,接口路径、返回结构、日期范围参数要与后端一致

View File

@ -1,6 +1,6 @@
---
name: backend-engineering
description: 后端工程总入口。用于在当前项目中识别任务属于标准 CRUD、复杂模块增强、联表与数据权限、或前后端联动并选择合适的后端子 agent。
description: 后端工程总入口。用于在当前 RuoYi-Vue-Plus 项目中识别任务属于标准 CRUD、复杂模块增强、联表与数据权限、公共 common 模块、JavaDoc 注释、或前后端联动,并选择合适的后端子 agent。
---
你是当前后端工程的总入口 agent。
@ -10,10 +10,15 @@ description: 后端工程总入口。用于在当前项目中识别任务属于
1. 如果是新增标准单表 CRUD、从表结构补 entity/bo/vo/mapper/service/controller优先使用 `backend-crud.md` 的规则。
2. 如果是修改 `system`、`workflow` 等已经很复杂的模块,优先使用 `backend-module-enhancement.md` 的规则。
3. 如果重点在 MPJ 联表、`@DataPermission`、复杂查询、数据范围控制,优先使用 `backend-query-permission.md` 的规则。
4. 如果同时要求同步前端接口或前端页面骨架,保持后端路由与 generator 风格稳定,便于前端 agent 对接。
4. 如果是修改 `ruoyi-common` 公共基础能力,例如 `common-mybatis`、`common-translation`、`common-json`、`common-excel`、`common-oss`,优先使用 `backend-common-infrastructure.md` 的规则。
5. 如果只要求补充或修正 JavaDoc 注释,优先使用 `backend-javadoc.md` 的规则。
6. 如果同时要求同步前端接口或前端页面骨架,保持后端路由与 generator 风格稳定,便于前端 agent 对接。
通用要求:
- 先读同模块最近似实现,再动代码。
- 发生冲突时优先相信当前模块真实代码,其次是公共基础设施,再其次才是 generator 模板。
- 默认直接产出可落地代码,而不是只给抽象建议。
- 不要把 `BaseMapperPlus`、`PageQuery`、`PageResult`、`R`、`MapstructUtils`、`StringUtils`、`StreamUtils` 等项目工具替换成临时自造方案。
- import、注解顺序、文件结构以附近代码为准不做无关重排。
- 修改公开 API 前先查调用点;公共模块优先保持方法签名、泛型、返回值和异常语义兼容。

View File

@ -0,0 +1,42 @@
---
name: backend-javadoc
description: JavaDoc 注释专家。用于当前项目中补充或修正 JavaDoc 注释,覆盖公共 API、接口、BO/VO/Entity 字段、Mapper 默认方法、Service/Controller 方法和复杂私有辅助方法。
---
你负责只补充或修正注释,不改变代码行为。
## 核心原则
1. 默认补 JavaDoc保持当前文件和同包注释风格。
2. 不改方法签名、泛型、返回值、实现逻辑。
3. 不重排 import不格式化全文件不改无关注解顺序。
4. 先确认注释是否准确反映当前实现,发现明显错注释要修正。
## 优先补充范围
- 公共 API、接口方法、record 参数、构造器。
- BO / VO / Entity 字段,尤其是导入导出、翻译、权限相关字段。
- Mapper 默认方法、Service/Controller 公开方法。
- 公共模块里的私有辅助方法,如果涉及映射、批量处理、状态复制、降级语义。
## 注释风格
- 描述“做什么”和关键参数语义,不复述每行实现。
- `@param` 名称必须和方法签名一致。
- `void` 方法不要写 `@return`
- 布尔返回值说明 true/false 含义。
- 简单框架覆写方法可不重复注释;如果当前文件已有统一注释风格,保持一致。
- 只修正错注释时,尽量保持最小 diff。
## 常见错注释
- 方法实际查询全部数据,注释写成“根据用户查询”。
- 方法返回树、映射、分页,`@return` 只写笼统“结果”。
- 参数从 ID 集合变成 Collection注释还写“ID 串”。
- `translationBatch`、`buildValue`、`collect/prepare/process` 这类公共扩展点缺少批量语义说明。
## 自检
- 运行 `git diff --check`
- 检查是否只改注释或文档。
- 检查新增 JavaDoc 是否和当前实现一致。

View File

@ -11,6 +11,7 @@ description: 复杂后端模块增强专家。用于修改当前项目中已经
2. 增量修改,不重写整块 service/controller。
3. 保留已有的数据权限、事务、缓存、导入导出、唯一性校验、删除前校验。
4. 不能为了“简洁”把复杂模块退化成模板式单表 CRUD。
5. 修改 workflow 模块时检查同包是否需要 `@ConditionalOnEnable`
## 常见任务
@ -18,6 +19,7 @@ description: 复杂后端模块增强专家。用于修改当前项目中已经
- 新增或调整写入前校验
- 维护角色、岗位、用户等关联数据
- 增加复杂页面所需的特殊接口
- 增加或调整缓存、翻译、字典、OSS、导入导出能力
## 约束
@ -25,9 +27,13 @@ description: 复杂后端模块增强专家。用于修改当前项目中已经
- service 里的旧逻辑要先理解再改
- 如果附近已有 `ServiceException`、缓存注解、事务注解、数据权限判断,新增逻辑默认保持一致
- 如果存在联动前端页面,接口路径与返回结构尽量稳定
- 已有 `@Cacheable`、`@CacheEvict`、`@Caching` 的 service新增写操作要同步考虑缓存失效
- 工作流分类、任务、实例等查询常带分类权限或用户维度过滤,不要绕过旧逻辑
- 翻译实现仍遵守 `TranslationInterface` + `@TranslationType` + `translationBatch` 批量查询规则
## 自检
- 是否破坏了原模块的权限边界
- 是否误删了旧逻辑中的事务或校验
- 是否错误简化了复杂关系维护
- 是否漏掉缓存失效、导入导出回执、翻译字段、前端调用路径等联动点

View File

@ -11,18 +11,32 @@ description: 后端查询、联表与数据权限专家。用于当前项目中
2. 涉及数据权限时优先复用 `@DataPermission` 与已有字段映射方式。
3. 复杂联表优先参考 MPJ 风格,不轻易改回手写零散 SQL。
4. 如果 `BaseMapperPlus + wrapper` 足够,不要额外补 XML。
5. wrapper 查询条件优先使用项目已有工具和命名,不自造查询 DSL。
## 重点关注
- `BaseMapperPlus`
- `LambdaCrudChainWrapper`
- `LambdaQueryBuilder`
- `LambdaQueryCondition`
- `@DataPermission`
- `DataColumn`
- `MPJBaseMapper`
- `JoinWrappers.lambda(...)`
- 复杂分页与列表查询
## 项目写法
- MPJ 联表查询沿用别名风格,例如 `JoinWrappers.lambda("u", SysUser.class)`
- 带别名字段条件使用 `.eq("u", Entity::getField, value)`、`.orderByAsc("m", SysMenu::getOrderNum)`。
- 数据权限列名要和真实 SQL 别名一致,例如 `d.dept_id`、`u.create_by`。
- `ruoyi-system` 的用户、角色、菜单、部门查询常带角色状态、删除标识、部门权限过滤,修改前先读对应 mapper/service。
- 日期范围参数继续从 `bo.getParams()` 获取,避免前端 `addDateRange` 对不上。
- 简单查询默认留在 service wrapper短小且复用性强的 mapper 默认方法可以保留在 mapper。
## 输出要求
- 明确说明查询是单表、联表还是带权限控制
- 保持与当前模块 mapper 风格一致
- 不要让查询参数风格和前端现有调用脱节
- 不要为了“易读”移除已有数据权限、角色状态、删除标识过滤

View File

@ -1,6 +1,6 @@
---
name: ruoyi-plus-ai-coding
description: 在仓库内按代码生成器模板和项目既有约定生成或修改代码。用于新增 CRUD 模块、补全 controller/service/mapper/BO/VO/entity、编写 MyBatis-Plus 查询,以及新增与后端接口配套的 Vue 3 + TypeScript 页面、types 和 api 文件。
description: 在仓库内按代码生成器模板和项目既有约定生成或修改代码。用于新增或修改 CRUD 模块、controller/service/mapper/BO/VO/entity、MyBatis-Plus/MPJ 查询、数据权限、缓存、翻译/JSON 增强、公共 common 模块能力、JavaDoc 注释,以及与后端接口配套的 Vue 3 + TypeScript 页面、types 和 api 文件。
---
# RuoYi Plus AI 编码规范
@ -14,6 +14,8 @@ description: 在仓库内按代码生成器模板和项目既有约定生成或
- 新增标准 CRUD 模块。
- 根据新表结构补齐 entity、bo、vo、mapper、service、controller。
- 修改已有模块的查询、校验、导入导出、数据权限、事务逻辑。
- 修改 `ruoyi-common` 公共能力,例如 mybatis 查询构造器、translation、json enhance、excel、oss、redis、web 配置。
- 补充或修正 JavaDoc 注释,尤其是公共 API、接口、BO/VO/Entity 字段、Mapper 默认方法、Service/Controller 方法。
- 在系统、监控、工作流、demo 等模块内按现有约定扩展业务代码。
- 为后端新增接口同步补前端 `api/types/index.vue` 骨架。
@ -34,6 +36,8 @@ description: 在仓库内按代码生成器模板和项目既有约定生成或
`domain` entity、`domain.bo`、`domain.vo`、`mapper`、`service`、`service.impl`、`controller`。
4. 优先在生成器结构上扩展,不要自行发明新的分层。
5. 修改 `ruoyi-system` 这类复杂模块前,先阅读同类现有实现,因为这些模块通常比生成器默认产物多出数据权限、联表、缓存、安全校验等逻辑。
6. 修改 `ruoyi-common` 公共模块前,先阅读同包接口、实现类和调用点,优先保持已有 API 语义与兼容性。
7. 只补注释或文档时,不运行无关格式化,不重排 import不改代码逻辑。
## 优先级规则
@ -76,6 +80,8 @@ Vue 3、TypeScript API 文件、生成式列表页、表单状态、字典和日
- 如果目标模块已经存在自定义校验、数据权限、事务、缓存、Excel 导入导出、联表查询等逻辑,应在此基础上扩展,不要为了“简洁”把它们削平。
- 如果附近 controller 接口已经带有权限、日志、防重、加密、分组校验等注解,新接口默认同步保持一致,除非有明确理由不这样做。
- 如果 BO 或 VO 需要字段校验、翻译、Excel 注解,应优先参考同模块同用途对象,不要机械套通用注解。
- 如果修改公共基础模块,优先保持公开 API 兼容,新增能力要查调用点和同包风格。
- 如果任务只涉及注释,默认补 JavaDoc 并保持实现不变;框架覆写方法不强行重复注释,除非业务语义不直观。
## 目录映射规则
@ -114,6 +120,14 @@ Vue 3、TypeScript API 文件、生成式列表页、表单状态、字典和日
如果涉及数据权限、缓存、事务、导入导出、字典、翻译、加密、分组校验,优先查项目已有做法并复用公共能力。
### 4. 公共基础模块修改
修改 `ruoyi-common` 下的基础能力时,优先保证二进制/API 兼容:不要轻易改公开方法签名、泛型、返回值或异常语义。新增注释和小范围能力时,先查同包现有风格,例如 `common-mybatis` 的链式 wrapper、`common-translation` 的 `TranslationInterface` 实现、`common-json` 的字段处理器。
### 5. 注释修正任务
只要求“加注释/完善注释”时,默认补 JavaDoc不改实现。优先补公共 API、接口方法、字段含义、复杂私有辅助方法覆写框架回调方法只有在当前文件已有注释风格或业务语义不直观时才补。
## 输出要求
使用本 skill 时,默认期望产出应满足:
@ -129,8 +143,12 @@ Vue 3、TypeScript API 文件、生成式列表页、表单状态、字典和日
- 包路径和 `@RequestMapping` 与模块保持一致。
- 权限标识遵循 `${module}:${business}:${action}`
- Mapper 继承 `BaseMapperPlus<Entity, Vo>`
- Service 使用 `baseMapper`,并按场景返回 `PageResult``List<Vo>`
- 手写 Service 注入 Mapper 时使用具体业务短名;代码生成器模板按类名首字母小写命名,例如 `SysRoleMapper` 生成 `sysRoleMapper`
- Service 按场景返回 `PageResult``List<Vo>`
- 查询代码优先使用 `LambdaQueryWrapper`,复杂模块沿用既有 MPJ 联表风格。
- 公共 Mapper 链式能力优先沿用 `LambdaCrudChainWrapper`、`LambdaQueryBuilder`、`LambdaQueryCondition` 的 `IfPresent` / `IfText` / `IfNotEmpty` 风格。
- 翻译能力优先沿用 `TranslationInterface` + `@TranslationType` + `@Translation`,批量翻译实现 `translationBatch`,避免退化成逐条查询。
- JSON 响应增强优先沿用 `JsonFieldProcessor``collect` / `prepare` / `process` 三阶段模型。
- BO 使用 `@AutoMapper(target = Entity.class, reverseConvertGenerate = false)`
- VO 使用 `@AutoMapper(target = Entity.class)`
- 前端 API 路径与后端路由完全对应。

View File

@ -1,7 +1,7 @@
interface:
display_name: "RuoYi Plus 编码"
short_description: "按生成器与仓库约定编写代码"
default_prompt: "使用 $ruoyi-plus-ai-coding 在这个仓库里按现有约定实现代码修改。"
short_description: "按生成器、common 与仓库约定编写代码"
default_prompt: "使用 $ruoyi-plus-ai-coding 在这个仓库里按现有约定实现代码修改,保持生成器、公共模块和业务模块风格一致。"
policy:
allow_implicit_invocation: true

View File

@ -64,6 +64,8 @@
- 模块已经使用 `@DataPermission` 时,在重写方法和自定义查询上继续保留。
- 复杂模块里 mapper 可能同时继承 `MPJBaseMapper<Entity>` 并使用 `JoinWrappers.lambda(...)`,遇到这种风格要延续,不要换一种写法。
- 只有在 `selectVoList/selectVoPage` 不够用时,才补 XML 或自定义 mapper 方法。
- Mapper 默认方法可以承载短小的 wrapper 查询;涉及复杂业务编排、缓存、事务或跨 mapper 写入时放到 service。
- `ruoyi-system` 的用户、角色、菜单、部门等模块常带数据权限、MPJ 联表、角色状态过滤,修改前先读对应 mapper/service。
### Mapper 建议结构
@ -86,13 +88,15 @@
## Service 规则
- 类声明通常是 `@RequiredArgsConstructor`、`@Service`,按需补 `@Slf4j`
- mapper 注入字段命名为 `private final XxxMapper baseMapper;`
- 手写 mapper 注入字段使用具体业务短名;代码生成器模板按类名首字母小写命名。
- 命名时去掉清晰的模块/系统前缀后使用 lowerCamel + `Mapper`,例如 `SysRoleMapper` -> `roleMapper`、`SysDictDataMapper` -> `dictDataMapper`
- 如果去掉前缀会产生歧义或命名冲突,保留必要前缀。
- 读操作通常返回 `Vo`、`List<Vo>` 或 `PageResult<Vo>`
- BO 转实体用 `MapstructUtils.convert(bo, Entity.class)`
- 查询条件优先用 `LambdaQueryWrapper``Wrappers.lambdaQuery()`
- 在 wrapper 条件里直接写 `StringUtils.isNotBlank(...)` 和 null 判断。
- 分页查询优先采用:
`Page<Vo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);`
`Page<Vo> result = entityMapper.selectVoPage(pageQuery.build(), lqw);`
`return PageResult.build(result.getRecords(), result.getTotal());`
- 生成器风格模块保留 `validEntityBeforeSave(...)` 这种扩展点。
- 多表写操作使用 `@Transactional(rollbackFor = Exception.class)`
@ -163,6 +167,46 @@
- 数组转列表按附近代码习惯使用 `List.of(ids)``Arrays.asList(ids)`
- 日期范围查询通常从 `bo.getParams()` 中读取 `beginTime`、`endTime` 或 `beginFieldName`、`endFieldName`。
## common-mybatis 规则
- 链式查询能力优先沿用 `BaseMapperPlus#lambda()`、`LambdaCrudChainWrapper`、`LambdaQueryBuilder`、`LambdaQueryCondition`。
- 条件辅助方法使用项目已有命名:`eqIfPresent`、`eqIfText`、`likeIfText`、`betweenIfPresent`、`inIfNotEmpty`、`findInSetIfPresent`。
- 新增 wrapper 方法时保持链式返回 `this` / `typedThis`,不要返回底层 `LambdaQueryWrapper` 破坏调用链。
- `LambdaCrudChainWrapper` 既承担查询又承担更新 set 片段,新增能力时要同时考虑 `getSqlSelect`、`getSqlSet`、`clear`、`instance` 的状态复制和清理。
- MPJ 联表查询沿用别名风格,例如 `JoinWrappers.lambda("u", SysUser.class)`、`.leftJoin(..., "d", ...)`、`.eq("u", Entity::getField, value)`。
- 数据权限注解使用 `@DataPermission` + `@DataColumn`,列名需和实际 SQL 别名一致,例如 `d.dept_id`、`u.create_by`。
## translation / JSON 增强规则
- 翻译实现类实现 `TranslationInterface<T>` 并标注 `@TranslationType(type = ...)`
- 使用方在 VO 字段上通过 `@Translation(type = ..., mapper = "...", other = "...")` 指定翻译来源。
- 批量翻译必须优先实现 `translationBatch(Set<Object> keys, String other)`,避免默认逐条查询。
- 支持逗号分隔 ID 的翻译实现应复用 `collectLongIds`、`parseLongIds`、`joinMappedValues`。
- `TranslationJsonFieldProcessor` 遵循三阶段:`collect` 收集待翻译值,`prepare` 批量查询,`process` 写入翻译结果;新增处理器也应优先套这个模型。
- 翻译失败时保持降级返回原值或 `null` 的现有语义,不要让响应增强中断主流程。
## 缓存与异步/监听规则
- 已有 service 使用 `@Cacheable`、`@CacheEvict`、`@Caching` 时,新增写操作要同步考虑缓存失效。
- 部门、字典、OSS 配置等模块已有缓存初始化或失效逻辑,不要只改数据库不处理缓存。
- Excel 导入监听器实现 `ExcelListener` 时,保留 `getExcelResult()` 的回执语义和错误聚合方式。
- 定时任务、MQTT、SSE、异步回调等框架方法一般按接口覆写语义实现除非业务不直观不要添加冗长注释。
## 工作流模块规则
- `ruoyi-workflow` 通常带 `@ConditionalOnEnable`,新增 workflow bean、controller、service 时检查同包是否需要该条件。
- 流程分类、任务、实例等查询常带分类权限或用户维度过滤,先读同类 mapper/service 再改。
- 工作流的翻译实现可以放在 workflow 模块内,例如流程分类 ID 到名称,仍应遵守 `TranslationInterface` 批量翻译规则。
## JavaDoc 注释规则
- 公共 API、接口、VO/BO/Entity 字段、Mapper 默认方法、Service/Controller 方法应有简洁 JavaDoc。
- 注释描述“做什么”和关键参数语义,不复述显而易见的实现细节。
- `void` 方法不要写 `@return`;返回布尔值时说明 `true/false` 含义。
- 私有方法只有在业务规则、算法、映射关系不直观时补注释。
- 框架覆写方法如果只是标准回调,可不重复注释;但当前文件已有统一注释风格时保持一致。
- 只改注释时,不重排 import、不格式化全文件、不修改代码行为。
## 前后端联动规则
- 新增后端接口时,路径和权限前缀尽量保持 generator 约定,方便前端目录和 API 命名同步。

46
pom.xml
View File

@ -14,7 +14,7 @@
<properties>
<revision>5.5.3</revision>
<spring-boot.version>4.0.3</spring-boot.version>
<spring-boot.version>4.0.6</spring-boot.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>21</java.version>
@ -27,12 +27,12 @@
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<mybatis-plus-join.version>1.5.6</mybatis-plus-join.version>
<p6spy.version>3.9.1</p6spy.version>
<hutool.version>5.8.43</hutool.version>
<spring-boot-admin.version>4.0.2</spring-boot-admin.version>
<redisson.version>4.3.0</redisson.version>
<hutool.version>5.8.44</hutool.version>
<spring-boot-admin.version>4.0.4</spring-boot-admin.version>
<redisson.version>4.4.0</redisson.version>
<lock4j.version>2.2.7</lock4j.version>
<dynamic-ds.version>4.5.0</dynamic-ds.version>
<snailjob.version>1.9.0</snailjob.version>
<snailjob.version>2.0.0</snailjob.version>
<snailai.version>0.0.1</snailai.version>
<mapstruct-plus.version>1.5.0</mapstruct-plus.version>
<mapstruct-plus.lombok.version>0.2.0</mapstruct-plus.lombok.version>
@ -53,6 +53,10 @@
<warm-flow.version>1.8.7</warm-flow.version>
<!-- mqtt客户端 -->
<mica-mqtt.version>2.5.12</mica-mqtt.version>
<easy-es.version>3.0.2</easy-es.version>
<elasticsearch-client.version>7.17.28</elasticsearch-client.version>
<!-- Spring AI 2.0 预览版,正式版发布后仅需调整此版本号 -->
<spring-ai.version>2.0.0-M6</spring-ai.version>
<!-- 插件版本 -->
<maven-jar-plugin.version>3.5.0</maven-jar-plugin.version>
@ -114,6 +118,15 @@
<scope>import</scope>
</dependency>
<!-- Spring AI MCP 依赖配置 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- hutool 的依赖配置-->
<dependency>
<groupId>cn.hutool</groupId>
@ -273,6 +286,11 @@
<artifactId>snail-job-client-starter</artifactId>
<version>${snailjob.version}</version>
</dependency>
<dependency>
<groupId>com.aizuda</groupId>
<artifactId>snail-job-client-retry-core</artifactId>
<version>${snailjob.version}</version>
</dependency>
<dependency>
<groupId>com.aizuda</groupId>
<artifactId>snail-job-client-job-core</artifactId>
@ -322,6 +340,24 @@
<version>${mica-mqtt.version}</version>
</dependency>
<dependency>
<groupId>org.dromara.easy-es</groupId>
<artifactId>easy-es-boot-starter</artifactId>
<version>${easy-es.version}</version>
</dependency>
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>${elasticsearch-client.version}</version>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>${elasticsearch-client.version}</version>
</dependency>
<!-- JustAuth 的依赖配置-->
<dependency>
<groupId>me.zhyd.oauth</groupId>

View File

@ -60,6 +60,11 @@
<artifactId>ruoyi-common-mail</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-mcp</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-api</artifactId>

View File

@ -17,7 +17,7 @@ import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.regex.RegexValidator;
import org.dromara.common.mail.config.properties.MailProperties;
import org.dromara.common.mail.utils.MailUtils;
import org.dromara.common.mail.core.MailBuilder;
import org.dromara.common.redis.annotation.RateLimiter;
import org.dromara.common.redis.enums.LimitType;
import org.dromara.common.redis.utils.RedisUtils;
@ -109,7 +109,11 @@ public class CaptchaController {
String code = RandomUtil.randomNumbers(4);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
try {
MailUtils.sendText(email, "登录验证码", "您本次验证码为:" + code + ",有效性为" + Constants.CAPTCHA_EXPIRATION + "分钟,请尽快填写。");
MailBuilder.of()
.to(email)
.subject("登录验证码")
.text("您本次验证码为:" + code + ",有效性为" + Constants.CAPTCHA_EXPIRATION + "分钟,请尽快填写。")
.send();
} catch (Exception e) {
log.error("验证码短信发送异常 => {}", e.getMessage());
throw new ServiceException(e.getMessage());

View File

@ -0,0 +1,13 @@
package org.dromara.web.event;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
/**
* 用户登录成功事件
*
* @param loginId 登录标识
* @param tokenValue token
* @param loginParameter 登录参数
*/
public record UserLoginSuccessEvent(Object loginId, String tokenValue, SaLoginParameter loginParameter) {
}

View File

@ -2,73 +2,29 @@ package org.dromara.web.listener;
import cn.dev33.satoken.listener.SaTokenListener;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.CacheNames;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ip.AddressUtils;
import org.dromara.common.log.event.LoginInfoEvent;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.api.domain.UserOnlineDTO;
import org.dromara.web.service.SysLoginService;
import org.dromara.web.event.UserLoginSuccessEvent;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 用户行为监听器用于同步在线状态和登录日志
*
* @author Lion Li
*/
@RequiredArgsConstructor
@Component
@Slf4j
public class UserActionListener implements SaTokenListener {
private final SysLoginService loginService;
/**
* 登录成功后记录在线信息并写入登录日志
*/
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter) {
UserAgent userAgent = UserAgentUtil.parse(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = ServletUtils.getClientIP();
UserOnlineDTO dto = new UserOnlineDTO();
dto.setIpaddr(ip);
dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
dto.setBrowser(userAgent.getBrowser().getName());
dto.setOs(userAgent.getOs().getName());
dto.setLoginTime(System.currentTimeMillis());
dto.setTokenId(tokenValue);
String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY);
dto.setUserName(username);
dto.setClientKey((String) loginParameter.getExtra(LoginHelper.CLIENT_KEY));
dto.setDeviceType(loginParameter.getDeviceType());
dto.setDeptName((String) loginParameter.getExtra(LoginHelper.DEPT_NAME_KEY));
if (loginParameter.getTimeout() == -1) {
RedisUtils.setCacheObject(CacheNames.ONLINE_TOKEN_KEY + tokenValue, dto);
} else {
RedisUtils.setCacheObject(CacheNames.ONLINE_TOKEN_KEY + tokenValue, dto, Duration.ofSeconds(loginParameter.getTimeout()));
}
// 记录登录日志
LoginInfoEvent loginInfoEvent = new LoginInfoEvent();
loginInfoEvent.setUsername(username);
loginInfoEvent.setStatus(Constants.LOGIN_SUCCESS);
loginInfoEvent.setMessage(MessageUtils.message("user.login.success"));
loginInfoEvent.setRequest(ServletUtils.getRequest());
SpringUtils.context().publishEvent(loginInfoEvent);
// 更新登录信息
loginService.recordLoginInfo((Long) loginParameter.getExtra(LoginHelper.USER_KEY), ip);
log.info("user doLogin, userId:{}, token:***{}", loginId, StringUtils.right(tokenValue, 8));
SpringUtils.context().publishEvent(new UserLoginSuccessEvent(loginId, tokenValue, loginParameter));
}
/**

View File

@ -0,0 +1,71 @@
package org.dromara.web.listener;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.CacheNames;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ip.AddressUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.api.domain.UserOnlineDTO;
import org.dromara.web.event.UserLoginSuccessEvent;
import org.dromara.web.service.SysLoginService;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 用户登录成功监听器
*
* @author Lion Li
*/
@RequiredArgsConstructor
@Component
@Slf4j
public class UserLoginSuccessListener {
private final SysLoginService loginService;
/**
* 登录成功后记录在线信息登录日志与最近登录信息
*
* @param event 用户登录成功事件
*/
@EventListener
public void handleLoginSuccess(UserLoginSuccessEvent event) {
SaLoginParameter loginParameter = event.loginParameter();
UserAgent userAgent = UserAgentUtil.parse(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = ServletUtils.getClientIP();
String username = (String) loginParameter.getExtra(LoginHelper.USER_NAME_KEY);
String tokenValue = event.tokenValue();
UserOnlineDTO dto = new UserOnlineDTO();
dto.setIpaddr(ip);
dto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
dto.setBrowser(userAgent.getBrowser().getName());
dto.setOs(userAgent.getOs().getName());
dto.setLoginTime(System.currentTimeMillis());
dto.setTokenId(tokenValue);
dto.setUserName(username);
dto.setClientKey((String) loginParameter.getExtra(LoginHelper.CLIENT_KEY));
dto.setDeviceType(loginParameter.getDeviceType());
dto.setDeptName((String) loginParameter.getExtra(LoginHelper.DEPT_NAME_KEY));
if (loginParameter.getTimeout() == -1) {
RedisUtils.setCacheObject(CacheNames.ONLINE_TOKEN_KEY + tokenValue, dto);
} else {
RedisUtils.setCacheObject(CacheNames.ONLINE_TOKEN_KEY + tokenValue, dto, Duration.ofSeconds(loginParameter.getTimeout()));
}
loginService.recordLoginInfo(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success"));
loginService.updateLastLoginInfo((Long) loginParameter.getExtra(LoginHelper.USER_KEY), ip);
log.info("user doLogin, userId:{}, token:***{}", event.loginId(), StringUtils.right(tokenValue, 8));
}
}

View File

@ -153,7 +153,7 @@ public class SysLoginService {
loginUser.setDeptName(deptOpt.map(SysDeptVo::getDeptName).orElse(StringUtils.EMPTY));
loginUser.setDeptCategory(deptOpt.map(SysDeptVo::getDeptCategory).orElse(StringUtils.EMPTY));
}
ThreadUtils.virtualSubmit(() -> {
ThreadUtils.virtualInvokeAll(() -> {
loginUser.setMenuPermission(permissionService.getMenuPermission(userId));
}, () -> {
loginUser.setRolePermission(permissionService.getRolePermission(userId));
@ -175,7 +175,7 @@ public class SysLoginService {
* @param userId 用户ID
* @param ip 登录IP
*/
public void recordLoginInfo(Long userId, String ip) {
public void updateLastLoginInfo(Long userId, String ip) {
SysUser sysUser = new SysUser();
sysUser.setUserId(userId);
sysUser.setLoginIp(ip);

View File

@ -1,7 +1,6 @@
package org.dromara.web.service;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
@ -58,8 +57,9 @@ public class SysRegisterService {
sysUser.setPassword(BCrypt.hashpw(password));
sysUser.setUserType(userType);
boolean exist = userMapper.exists(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUserName, sysUser.getUserName()));
boolean exist = userMapper.lambda()
.eq(SysUser::getUserName, sysUser.getUserName())
.exists();
if (exist) {
throw new UserException("user.register.save.error", username);
}

View File

@ -3,7 +3,6 @@ package org.dromara.web.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
@ -95,7 +94,9 @@ public class EmailAuthStrategy implements IAuthStrategy {
* @return 用户信息
*/
private SysUserVo loadUserByEmail(String email) {
SysUserVo user = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, email));
SysUserVo user = userMapper.lambda()
.eq(SysUser::getEmail, email)
.voOne();
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", email);
throw new UserException("user.not.exists", email);

View File

@ -4,7 +4,6 @@ import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
@ -112,7 +111,9 @@ public class PasswordAuthStrategy implements IAuthStrategy {
* @return 用户信息
*/
private SysUserVo loadUserByUsername(String username) {
SysUserVo user = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, username));
SysUserVo user = userMapper.lambda()
.eq(SysUser::getUserName, username)
.voOne();
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", username);
throw new UserException("user.not.exists", username);

View File

@ -3,7 +3,6 @@ package org.dromara.web.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
@ -95,7 +94,9 @@ public class SmsAuthStrategy implements IAuthStrategy {
* @return 用户信息
*/
private SysUserVo loadUserByPhoneNumber(String phoneNumber) {
SysUserVo user = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhoneNumber, phoneNumber));
SysUserVo user = userMapper.lambda()
.eq(SysUser::getPhoneNumber, phoneNumber)
.voOne();
if (ObjectUtil.isNull(user)) {
log.info("登录用户:{} 不存在.", phoneNumber);
throw new UserException("user.not.exists", phoneNumber);

View File

@ -221,6 +221,14 @@ message:
path: /resource/message
# websocket 允许的跨域来源
allowedOrigins: '*'
# SSE 连接超时时间,单位毫秒
sse-timeout: 86400000
# 本地连接心跳检测间隔,单位秒
heartbeat-interval: 60
# WebSocket 单次发送超时时间,单位毫秒
web-socket-send-time-limit: 10000
# WebSocket 发送缓冲区大小
web-socket-buffer-size-limit: 64000
--- # warm-flow工作流配置
warm-flow:
@ -274,3 +282,95 @@ mqtt.client:
keystore-pass:
truststore-path:
truststore-pass:
--- # elasticsearch 功能配置
# 文档地址: https://www.easy-es.cn/
# 更改包名需要去 EasyEsConfiguration 修改包扫描(后续版本支持配置文件读取)
easy-es:
# 是否开启EE自动配置
enable: false
# 兼容模式
compatible: true
# es连接地址+端口 格式必须为ip:port,如果是集群则可用逗号隔开
address: localhost:9200
# 默认为http
schema: http
# 注意ES建议使用账号认证 不使用会报警告日志
# 如果无账号密码则可不配置此行
# username:
# 如果无账号密码则可不配置此行
# password:
# 心跳策略时间 单位:ms
keep-alive-millis: 18000
# 连接超时时间 单位:ms
connectTimeout: 5000
# 通信超时时间 单位:ms
socketTimeout: 5000
# 连接请求超时时间 单位:ms
connectionRequestTimeout: 5000
# 最大连接数 单位:个
maxConnTotal: 100
# 最大连接路由数 单位:个
maxConnPerRoute: 100
global-config:
# 开启控制台打印通过本框架生成的DSL语句,默认为开启,测试稳定后的生产环境建议关闭,以提升少量性能
print-dsl: true
# 异步处理索引是否阻塞主线程 默认阻塞 数据量过大时调整为非阻塞异步进行 项目启动更快
asyncProcessIndexBlocking: true
db-config:
# 是否开启下划线转驼峰 默认为false
map-underscore-to-camel-case: true
# id生成策略 customize为自定义,id值由用户生成,比如取MySQL中的数据id,如缺省此项配置,则id默认策略为es自动生成
id-type: customize
# 字段更新策略 默认为not_null
field-strategy: not_null
# 默认开启,查询若指定了size超过1w条时也会自动开启,开启后查询所有匹配数据,若不开启,会导致无法获取数据总条数,其它功能不受影响.
enable-track-total-hits: true
# 数据刷新策略,默认为不刷新
refresh-policy: immediate
--- # MCP 服务端配置
spring.ai.mcp:
server:
# 与 ruoyi-admin 共用端口,默认端点为 /mcp
enabled: true
protocol: STREAMABLE
name: ${spring.application.name}
version: ${project.version}
type: SYNC
annotation-scanner:
enabled: true
streamable-http:
mcp-endpoint: /mcp
--- # MCP 客户端配置
spring.ai.mcp:
client:
# 需要接入外部 MCP Server 时再打开,并配置 streamable-http/sse/stdio connections
enabled: false
name: ${spring.application.name}-mcp-client
version: ${project.version}
type: SYNC
toolcallback:
enabled: false
# Streamable HTTP 多服务端示例
streamable-http:
connections:
knowledge:
url: http://localhost:9001
crm:
url: http://localhost:9002
# SSE 多服务端示例
sse:
connections:
search:
url: http://localhost:9003
# STDIO 多服务端示例
stdio:
connections:
filesystem:
command: npx
args:
- -y
- '@modelcontextprotocol/server-filesystem'
- D:/data

View File

@ -30,6 +30,7 @@
<module>ruoyi-common-satoken</module>
<module>ruoyi-common-security</module>
<module>ruoyi-common-sms</module>
<module>ruoyi-common-elasticsearch</module>
<module>ruoyi-common-web</module>
<module>ruoyi-common-translation</module>
<module>ruoyi-common-sensitive</module>
@ -38,6 +39,7 @@
<module>ruoyi-common-push</module>
<module>ruoyi-common-mqtt</module>
<module>ruoyi-common-ai</module>
<module>ruoyi-common-mcp</module>
</modules>
</project>

View File

@ -103,6 +103,13 @@
<version>${revision}</version>
</dependency>
<!-- ES搜索引擎服务 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-elasticsearch</artifactId>
<version>${revision}</version>
</dependency>
<!-- 授权认证 -->
<dependency>
<groupId>org.dromara</groupId>
@ -163,6 +170,12 @@
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-ai</artifactId>
</dependency>
<!-- mcp模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-mcp</artifactId>
<version>${revision}</version>
</dependency>

View File

@ -26,19 +26,16 @@ public class ValidatorConfig {
*/
@Bean
public Validator validator(MessageSource messageSource) {
try (LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean()) {
// 国际化
factoryBean.setValidationMessageSource(messageSource);
// 设置使用 HibernateValidator 校验器
factoryBean.setProviderClass(HibernateValidator.class);
Properties properties = new Properties();
// 设置快速失败模式fail-fast即校验过程中一旦遇到失败立即停止并返回错误
properties.setProperty("hibernate.validator.fail_fast", "true");
factoryBean.setValidationProperties(properties);
// 加载配置
factoryBean.afterPropertiesSet();
return factoryBean.getValidator();
}
LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
// 国际化
factoryBean.setValidationMessageSource(messageSource);
// 设置使用 HibernateValidator 校验器
factoryBean.setProviderClass(HibernateValidator.class);
Properties properties = new Properties();
// 设置快速失败模式fail-fast即校验过程中一旦遇到失败立即停止并返回错误
properties.setProperty("hibernate.validator.fail_fast", "true");
factoryBean.setValidationProperties(properties);
return factoryBean;
}
}

View File

@ -6,6 +6,7 @@ import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
/**
* 表格分页数据对象
@ -36,7 +37,7 @@ public class PageResult<T> implements Serializable {
* @param total 总记录数
*/
public PageResult(Collection<T> list, long total) {
this.rows = list;
this.rows = emptyIfNull(list);
this.total = total;
}
@ -45,7 +46,7 @@ public class PageResult<T> implements Serializable {
*/
public static <T> PageResult<T> build(Collection<T> list, long total) {
PageResult<T> rspData = new PageResult<>();
rspData.setRows(list);
rspData.setRows(emptyIfNull(list));
rspData.setTotal(total);
return rspData;
}
@ -55,8 +56,9 @@ public class PageResult<T> implements Serializable {
*/
public static <T> PageResult<T> build(Collection<T> list) {
PageResult<T> rspData = new PageResult<>();
rspData.setRows(list);
rspData.setTotal(list.size());
Collection<T> rows = emptyIfNull(list);
rspData.setRows(rows);
rspData.setTotal(rows.size());
return rspData;
}
@ -67,4 +69,8 @@ public class PageResult<T> implements Serializable {
return new PageResult<>();
}
private static <T> Collection<T> emptyIfNull(Collection<T> list) {
return list == null ? Collections.emptyList() : list;
}
}

View File

@ -197,7 +197,7 @@ public class R<T> implements Serializable {
* @return true=成功false=失败
*/
public static <T> Boolean isSuccess(R<T> ret) {
return SUCCESS == ret.getCode();
return ret != null && SUCCESS == ret.getCode();
}
}

View File

@ -135,6 +135,12 @@ public enum FormatsType {
*/
private final String timeFormat;
/**
* 根据字符串内容匹配时间格式类型
*
* @param str 字符串内容
* @return 时间格式类型
*/
public static FormatsType getFormatsType(String str) {
for (FormatsType value : values()) {
if (StringUtils.contains(str, value.getTimeFormat())) {

View File

@ -57,6 +57,17 @@ public final class ServiceException extends RuntimeException {
this.code = code;
}
/**
* 使用错误消息和根因构造业务异常
*
* @param message 错误消息
* @param cause 根因
*/
public ServiceException(String message, Throwable cause) {
super(cause);
this.message = message;
}
/**
* 使用占位符参数格式化错误消息
*

View File

@ -156,7 +156,7 @@ public class DateUtils extends org.apache.commons.lang3.time.DateUtils {
try {
return new SimpleDateFormat(format.getTimeFormat()).parse(ts);
} catch (ParseException e) {
throw new RuntimeException(e);
throw new ServiceException("日期时间解析失败:" + ts, e);
}
}

View File

@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedCaseInsensitiveMap;
import org.springframework.web.context.request.RequestAttributes;
@ -31,6 +32,7 @@ import java.util.Map;
* @author ruoyi
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Slf4j
public class ServletUtils extends JakartaServletUtil {
/**
@ -223,7 +225,7 @@ public class ServletUtils extends JakartaServletUtil {
response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
response.getWriter().print(string);
} catch (IOException e) {
e.printStackTrace();
log.error("渲染响应内容异常", e);
}
}
@ -249,7 +251,7 @@ public class ServletUtils extends JakartaServletUtil {
// 判断 URI 后缀是否为 .json .xml
String uri = request.getRequestURI();
if (StringUtils.equalsAnyIgnoreCase(uri, ".json", ".xml")) {
if (StringUtils.endsWithAny(StringUtils.lowerCase(uri), ".json", ".xml")) {
return true;
}

View File

@ -60,6 +60,11 @@ public final class SpringUtils extends SpringUtil {
return getApplicationContext();
}
/**
* 当前是否启用虚拟线程
*
* @return true 启用 false 未启用
*/
public static boolean isVirtual() {
return Threading.VIRTUAL.isActive(getBean(Environment.class));
}

View File

@ -4,8 +4,10 @@ import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.*;
import java.util.function.Supplier;
/**
* 线程工具
@ -18,7 +20,7 @@ public class ThreadUtils {
/**
* 批量执行任务
*/
public static void virtualSubmit(Runnable ...runnableList) {
public static void virtualInvokeAll(Runnable... runnableList) {
List<Future<?>> callableList = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Runnable runnable : runnableList) {
@ -27,10 +29,53 @@ public class ThreadUtils {
for (Future<?> future : callableList) {
future.get();
}
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程执行被中断", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
throw new RuntimeException("线程执行异常:" + cause.getMessage(), cause);
}
}
/**
* 批量执行有返回值的任务
*
* @param supplierList 任务列表
* @param <T> 返回值类型
* @return 按提交顺序返回的任务结果
*/
@SafeVarargs
public static <T> List<T> virtualSubmitAll(Supplier<T>... supplierList) {
return virtualSubmitAll(List.of(supplierList));
}
/**
* 批量执行有返回值的任务
*
* @param supplierList 任务列表
* @param <T> 返回值类型
* @return 按提交顺序返回的任务结果
*/
public static <T> List<T> virtualSubmitAll(Collection<? extends Supplier<T>> supplierList) {
List<Future<T>> futureList = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Supplier<T> supplier : supplierList) {
futureList.add(executor.submit(supplier::get));
}
List<T> resultList = new ArrayList<>(futureList.size());
for (Future<T> future : futureList) {
resultList.add(future.get());
}
return resultList;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程执行被中断", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
throw new RuntimeException("线程执行异常:" + cause.getMessage(), cause);
}
}
}

View File

@ -21,6 +21,12 @@ public class AddressUtils {
// 内网地址
public static final String LOCAL_ADDRESS = "内网IP";
/**
* 根据 IP 查询真实地址
*
* @param ip IP 地址
* @return 真实地址
*/
public static String getRealAddressByIP(String ip) {
// 处理空串并过滤HTML标签
ip = HtmlUtil.cleanHtmlTag(StringUtils.blankToDefault(ip,""));

View File

@ -26,7 +26,7 @@ public @interface DictPattern {
/**
* 分隔符
*/
String separator();
String separator() default ",";
/**
* 默认校验失败提示信息

View File

@ -45,7 +45,10 @@ public class DictPatternValidator implements ConstraintValidator<DictPattern, St
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (StringUtils.isBlank(dictType) || StringUtils.isBlank(value)) {
if (StringUtils.isBlank(value)) {
return true;
}
if (StringUtils.isBlank(dictType)) {
return false;
}
String dictLabel = SpringUtils.getBean(DictService.class).getDictLabel(dictType, value, separator);

View File

@ -5,6 +5,9 @@ import jakarta.validation.ConstraintValidatorContext;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.reflect.ReflectUtils;
import java.util.HashSet;
import java.util.Set;
/**
* 自定义枚举校验注解实现
*
@ -13,25 +16,29 @@ import org.dromara.common.core.utils.reflect.ReflectUtils;
*/
public class EnumPatternValidator implements ConstraintValidator<EnumPattern, String> {
private EnumPattern annotation;
private final Set<String> values = new HashSet<>();
@Override
public void initialize(EnumPattern annotation) {
ConstraintValidator.super.initialize(annotation);
this.annotation = annotation;
String fieldName = annotation.fieldName();
if (StringUtils.isBlank(fieldName)) {
return;
}
for (Object e : annotation.type().getEnumConstants()) {
Object fieldValue = ReflectUtils.invokeGetter(e, fieldName);
if (fieldValue != null) {
values.add(String.valueOf(fieldValue));
}
}
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if (StringUtils.isNotBlank(value)) {
String fieldName = annotation.fieldName();
for (Object e : annotation.type().getEnumConstants()) {
if (value.equals(ReflectUtils.invokeGetter(e, fieldName))) {
return true;
}
}
if (StringUtils.isBlank(value)) {
return true;
}
return false;
return values.contains(value);
}
}

View File

@ -2,9 +2,9 @@ package org.dromara.common.core.xss;
import cn.hutool.core.util.ReUtil;
import cn.hutool.http.HtmlUtil;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.dromara.common.core.utils.StringUtils;
/**
* 自定义xss校验注解实现
@ -15,6 +15,9 @@ public class XssValidator implements ConstraintValidator<Xss, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if (StringUtils.isBlank(value)) {
return true;
}
return !ReUtil.contains(HtmlUtil.RE_HTML_MARK, value);
}

View File

@ -6,18 +6,14 @@ import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.doc.core.customizer.ClassTagOperationCustomizer;
import org.dromara.common.doc.core.customizer.JavadocOperationCustomizer;
import org.dromara.common.doc.config.properties.SpringDocProperties;
import org.dromara.common.doc.core.resolver.JavadocResolver;
import org.dromara.common.doc.core.resolver.SaTokenAnnotationMetadataJavadocResolver;
import org.dromara.common.doc.handler.OpenApiHandler;
import org.springdoc.core.configuration.SpringDocConfiguration;
import org.springdoc.core.customizers.OpenApiBuilderCustomizer;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springdoc.core.customizers.ServerBaseUrlCustomizer;
import org.springdoc.core.properties.SpringDocConfigProperties;
import org.springdoc.core.providers.JavadocProvider;
import org.springdoc.core.service.OpenAPIService;
import org.springdoc.core.service.SecurityService;
import org.springdoc.core.utils.PropertyResolverUtils;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@ -91,16 +87,21 @@ public class SpringDocConfig {
}
/**
* 自定义 openapi 处理器
* Controller 类级标签增强
*/
@Bean
public OpenAPIService openApiBuilder(Optional<OpenAPI> openAPI,
SecurityService securityParser,
SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils,
Optional<List<OpenApiBuilderCustomizer>> openApiBuilderCustomisers,
Optional<List<ServerBaseUrlCustomizer>> serverBaseUrlCustomisers, Optional<JavadocProvider> javadocProvider,
List<JavadocResolver> javadocResolvers) {
return new OpenApiHandler(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomisers, serverBaseUrlCustomisers, javadocProvider, javadocResolvers);
public ClassTagOperationCustomizer classTagOperationCustomizer(Optional<JavadocProvider> javadocProvider,
PropertyResolverUtils propertyResolverUtils) {
return new ClassTagOperationCustomizer(javadocProvider, propertyResolverUtils);
}
/**
* 方法 JavaDoc 与权限描述增强
*/
@Bean
public JavadocOperationCustomizer javadocOperationCustomizer(Optional<JavadocProvider> javadocProvider,
List<JavadocResolver> javadocResolvers) {
return new JavadocOperationCustomizer(javadocProvider, javadocResolvers);
}
/**
@ -142,6 +143,9 @@ public class SpringDocConfig {
*/
static class PlusPaths extends Paths {
/**
* 构造路径缓存标记对象
*/
public PlusPaths() {
super();
}

View File

@ -0,0 +1,148 @@
package org.dromara.common.doc.core.customizer;
import cn.hutool.core.io.IoUtil;
import io.swagger.v3.core.util.AnnotationsUtils;
import io.swagger.v3.oas.annotations.tags.Tags;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springdoc.core.customizers.OperationCustomizer;
import org.springdoc.core.providers.JavadocProvider;
import org.springdoc.core.service.OpenAPIService;
import org.springdoc.core.utils.PropertyResolverUtils;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.web.method.HandlerMethod;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Controller 类级标签增强
*
* @author Lion Li
*/
@RequiredArgsConstructor
public class ClassTagOperationCustomizer implements OperationCustomizer, OpenApiCustomizer {
private final Optional<JavadocProvider> javadocProvider;
private final PropertyResolverUtils propertyResolverUtils;
private final Map<String, Tag> tags = new ConcurrentHashMap<>();
private final Set<String> replacedAutoTagNames = ConcurrentHashMap.newKeySet();
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
Class<?> beanType = handlerMethod.getBeanType();
List<io.swagger.v3.oas.annotations.tags.Tag> classTags = getClassTags(beanType);
if (!CollectionUtils.isEmpty(classTags)) {
// 优先使用 Controller 类上的 @Tag / @Tags保持 Swagger 原生注解语义
addAnnotationTags(operation, classTags);
return operation;
}
String tagName = getClassJavadocTagName(beanType);
if (StringUtils.isBlank(tagName)) {
return operation;
}
String autoTagName = OpenAPIService.splitCamelCase(beanType.getSimpleName());
if (!shouldUseClassJavadocTag(operation, autoTagName)) {
return operation;
}
// 无显式 @Tag springdoc 自动生成的类名 tag 替换为类 JavaDoc 第一行
removeOperationTag(operation, autoTagName);
addOperationTag(operation, tagName);
replacedAutoTagNames.add(autoTagName);
tags.putIfAbsent(tagName, new Tag().name(tagName).description(javadocProvider.get().getClassJavadoc(beanType)));
return operation;
}
@Override
public void customise(OpenAPI openApi) {
if (!CollectionUtils.isEmpty(openApi.getTags()) && !CollectionUtils.isEmpty(replacedAutoTagNames)) {
// 移除已被 JavaDoc tag 替换的默认类名 tag避免 Swagger UI 出现空分组
openApi.getTags().removeIf(tag -> replacedAutoTagNames.contains(tag.getName()));
}
// 将类级 @Tag 描述或 JavaDoc 描述补充到 OpenAPI 顶层 tags
tags.values().forEach(tag -> {
if (openApi.getTags() == null || openApi.getTags().stream().noneMatch(item -> Objects.equals(item.getName(), tag.getName()))) {
openApi.addTagsItem(tag);
}
});
}
private List<io.swagger.v3.oas.annotations.tags.Tag> getClassTags(Class<?> beanType) {
Set<Tags> tagsSet = AnnotatedElementUtils.findAllMergedAnnotations(beanType, Tags.class);
Set<io.swagger.v3.oas.annotations.tags.Tag> mergedTags = tagsSet.stream()
.flatMap(item -> Stream.of(item.value()))
.collect(Collectors.toSet());
mergedTags.addAll(AnnotatedElementUtils.findAllMergedAnnotations(beanType, io.swagger.v3.oas.annotations.tags.Tag.class));
return new ArrayList<>(mergedTags);
}
private void addAnnotationTags(Operation operation, List<io.swagger.v3.oas.annotations.tags.Tag> classTags) {
classTags.stream()
.map(io.swagger.v3.oas.annotations.tags.Tag::name)
.map(name -> propertyResolverUtils.resolve(name, Locale.getDefault()))
.filter(StringUtils::isNotBlank)
.forEach(name -> addOperationTag(operation, name));
AnnotationsUtils.getTags(classTags.toArray(new io.swagger.v3.oas.annotations.tags.Tag[0]), true)
.ifPresent(items -> items.forEach(tag -> {
tag.name(propertyResolverUtils.resolve(tag.getName(), Locale.getDefault()));
tag.description(propertyResolverUtils.resolve(tag.getDescription(), Locale.getDefault()));
if (StringUtils.isNotBlank(tag.getName())) {
tags.putIfAbsent(tag.getName(), tag);
}
}));
}
private String getClassJavadocTagName(Class<?> beanType) {
if (javadocProvider.isEmpty()) {
return null;
}
String description = javadocProvider.get().getClassJavadoc(beanType);
if (StringUtils.isBlank(description)) {
return null;
}
// 与原 OpenApiHandler 保持一致 JavaDoc 第一行作为 tag 完整 JavaDoc 作为 tag 描述
List<String> lines = IoUtil.readLines(new StringReader(description), new ArrayList<>());
return lines.stream().filter(StringUtils::isNotBlank).findFirst().orElse(null);
}
private boolean shouldUseClassJavadocTag(Operation operation, String autoTagName) {
return CollectionUtils.isEmpty(operation.getTags()) || operation.getTags().contains(autoTagName);
}
private void addOperationTag(Operation operation, String tagName) {
if (operation.getTags() == null) {
operation.setTags(new ArrayList<>());
}
if (!operation.getTags().contains(tagName)) {
operation.addTagsItem(tagName);
}
}
private void removeOperationTag(Operation operation, String tagName) {
if (!CollectionUtils.isEmpty(operation.getTags())) {
operation.getTags().removeIf(item -> Objects.equals(item, tagName));
}
}
}

View File

@ -0,0 +1,58 @@
package org.dromara.common.doc.core.customizer;
import io.swagger.v3.oas.models.Operation;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.dromara.common.doc.core.resolver.JavadocResolver;
import org.springdoc.core.customizers.OperationCustomizer;
import org.springdoc.core.providers.JavadocProvider;
import org.springframework.util.CollectionUtils;
import org.springframework.web.method.HandlerMethod;
import java.util.List;
import java.util.Optional;
/**
* 方法 JavaDoc 与扩展描述增强
*
* @author Lion Li
*/
@RequiredArgsConstructor
public class JavadocOperationCustomizer implements OperationCustomizer {
private final Optional<JavadocProvider> javadocProvider;
private final List<JavadocResolver> javadocResolvers;
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
javadocProvider.ifPresent(provider -> {
String description = provider.getMethodJavadocDescription(handlerMethod.getMethod());
if (StringUtils.isNotBlank(description)) {
// 使用方法 JavaDoc 首句作为接口摘要完整 JavaDoc 作为接口描述
operation.setSummary(provider.getFirstSentence(description));
operation.setDescription(description);
}
});
if (CollectionUtils.isEmpty(javadocResolvers)) {
return operation;
}
StringBuilder description = new StringBuilder(Optional.ofNullable(operation.getDescription()).orElse(""));
List<String> resolvedDescriptions = javadocResolvers.stream()
.sorted()
// 只执行支持当前 HandlerMethod 的扩展解析器避免无注解接口被追加权限说明
.filter(resolver -> resolver.supports(handlerMethod))
.map(resolver -> resolver.resolve(handlerMethod, operation))
.filter(StringUtils::isNotBlank)
.toList();
if (!resolvedDescriptions.isEmpty()) {
// 在原方法 JavaDoc 后追加权限等扩展描述保持现有 resolver 扩展点
resolvedDescriptions.forEach(description::append);
operation.setDescription(description.toString());
}
return operation;
}
}

View File

@ -1,302 +0,0 @@
package org.dromara.common.doc.handler;
import cn.hutool.core.io.IoUtil;
import io.swagger.v3.core.jackson.TypeNameResolver;
import io.swagger.v3.core.util.AnnotationsUtils;
import io.swagger.v3.oas.annotations.tags.Tags;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.doc.core.resolver.JavadocResolver;
import org.springdoc.core.customizers.OpenApiBuilderCustomizer;
import org.springdoc.core.customizers.ServerBaseUrlCustomizer;
import org.springdoc.core.properties.SpringDocConfigProperties;
import org.springdoc.core.providers.JavadocProvider;
import org.springdoc.core.service.OpenAPIService;
import org.springdoc.core.service.SecurityService;
import org.springdoc.core.utils.PropertyResolverUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.web.method.HandlerMethod;
import java.io.StringReader;
import java.lang.reflect.Method;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 自定义 openapi 处理器
* 对源码功能进行修改 增强使用
*/
@Slf4j
@SuppressWarnings("all")
public class OpenApiHandler extends OpenAPIService {
/**
* The Basic error controller.
*/
private static Class<?> basicErrorController;
/**
* The Security parser.
*/
private final SecurityService securityParser;
/**
* The Mappings map.
*/
private final Map<String, Object> mappingsMap = new HashMap<>();
/**
* The Springdoc tags.
*/
private final Map<HandlerMethod, Tag> springdocTags = new HashMap<>();
/**
* The Open api builder customisers.
*/
private final Optional<List<OpenApiBuilderCustomizer>> openApiBuilderCustomisers;
/**
* The server base URL customisers.
*/
private final Optional<List<ServerBaseUrlCustomizer>> serverBaseUrlCustomizers;
/**
* The Spring doc config properties.
*/
private final SpringDocConfigProperties springDocConfigProperties;
/**
* The Cached open api map.
*/
private final Map<String, OpenAPI> cachedOpenAPI = new HashMap<>();
/**
* The Property resolver utils.
*/
private final PropertyResolverUtils propertyResolverUtils;
/**
* Javadoc解析器接口
*/
private final List<JavadocResolver> javadocResolvers;
/**
* The javadoc provider.
*/
private final Optional<JavadocProvider> javadocProvider;
/**
* The Context.
*/
private ApplicationContext context;
/**
* The Open api.
*/
private OpenAPI openAPI;
/**
* The Is servers present.
*/
private boolean isServersPresent;
/**
* The Server base url.
*/
private String serverBaseUrl;
/**
* Instantiates a new Open api builder.
*
* @param openAPI the open api
* @param securityParser the security parser
* @param springDocConfigProperties the spring doc config properties
* @param propertyResolverUtils the property resolver utils
* @param openApiBuilderCustomizers the open api builder customisers
* @param serverBaseUrlCustomizers the server base url customizers
* @param javadocProvider the javadoc provider
* @param javadocResolvers Javadoc 解析器列表
*/
public OpenApiHandler(Optional<OpenAPI> openAPI, SecurityService securityParser,
SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils,
Optional<List<OpenApiBuilderCustomizer>> openApiBuilderCustomizers,
Optional<List<ServerBaseUrlCustomizer>> serverBaseUrlCustomizers,
Optional<JavadocProvider> javadocProvider,
List<JavadocResolver> javadocResolvers) {
super(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomizers, serverBaseUrlCustomizers, javadocProvider);
if (openAPI.isPresent()) {
this.openAPI = openAPI.get();
if (this.openAPI.getComponents() == null)
this.openAPI.setComponents(new Components());
if (this.openAPI.getPaths() == null)
this.openAPI.setPaths(new Paths());
if (!CollectionUtils.isEmpty(this.openAPI.getServers()))
this.isServersPresent = true;
}
this.propertyResolverUtils = propertyResolverUtils;
this.securityParser = securityParser;
this.springDocConfigProperties = springDocConfigProperties;
this.openApiBuilderCustomisers = openApiBuilderCustomizers;
this.serverBaseUrlCustomizers = serverBaseUrlCustomizers;
this.javadocProvider = javadocProvider;
this.javadocResolvers = javadocResolvers == null ? new ArrayList<>() : javadocResolvers;
if (springDocConfigProperties.isUseFqn())
TypeNameResolver.std.setUseFqn(true);
}
/**
* 构建接口标签权限描述与方法摘要
*
* @param handlerMethod Handler 方法
* @param operation OpenAPI 操作对象
* @param openAPI OpenAPI 文档对象
* @param locale 当前语言环境
* @return 处理后的操作对象
*/
@Override
public Operation buildTags(HandlerMethod handlerMethod, Operation operation, OpenAPI openAPI, Locale locale) {
Set<Tag> tags = new HashSet<>();
Set<String> tagsStr = new HashSet<>();
buildTagsFromMethod(handlerMethod.getMethod(), tags, tagsStr, locale);
buildTagsFromClass(handlerMethod.getBeanType(), tags, tagsStr, locale);
if (!CollectionUtils.isEmpty(tagsStr))
tagsStr = tagsStr.stream()
.map(str -> propertyResolverUtils.resolve(str, locale))
.collect(Collectors.toSet());
if (springdocTags.containsKey(handlerMethod)) {
io.swagger.v3.oas.models.tags.Tag tag = springdocTags.get(handlerMethod);
tagsStr.add(tag.getName());
if (openAPI.getTags() == null || !openAPI.getTags().contains(tag)) {
openAPI.addTagsItem(tag);
}
}
if (!CollectionUtils.isEmpty(tagsStr)) {
if (CollectionUtils.isEmpty(operation.getTags()))
operation.setTags(new ArrayList<>(tagsStr));
else {
Set<String> operationTagsSet = new HashSet<>(operation.getTags());
operationTagsSet.addAll(tagsStr);
operation.getTags().clear();
operation.getTags().addAll(operationTagsSet);
}
}
if (isAutoTagClasses(operation)) {
if (javadocProvider.isPresent()) {
String description = javadocProvider.get().getClassJavadoc(handlerMethod.getBeanType());
if (StringUtils.isNotBlank(description)) {
io.swagger.v3.oas.models.tags.Tag tag = new io.swagger.v3.oas.models.tags.Tag();
// 自定义部分 修改使用java注释当tag名
List<String> list = IoUtil.readLines(new StringReader(description), new ArrayList<>());
// tag.setName(tagAutoName);
tag.setName(list.get(0));
operation.addTagsItem(list.get(0));
tag.setDescription(description);
if (openAPI.getTags() == null || !openAPI.getTags().contains(tag)) {
openAPI.addTagsItem(tag);
}
}
} else {
String tagAutoName = splitCamelCase(handlerMethod.getBeanType().getSimpleName());
operation.addTagsItem(tagAutoName);
}
}
if (!CollectionUtils.isEmpty(tags)) {
// Existing tags
List<io.swagger.v3.oas.models.tags.Tag> openApiTags = openAPI.getTags();
if (!CollectionUtils.isEmpty(openApiTags))
tags.addAll(openApiTags);
openAPI.setTags(new ArrayList<>(tags));
}
// Handle SecurityRequirement at operation level
io.swagger.v3.oas.annotations.security.SecurityRequirement[] securityRequirements = securityParser
.getSecurityRequirements(handlerMethod);
if (securityRequirements != null) {
if (securityRequirements.length == 0)
operation.setSecurity(Collections.emptyList());
else
securityParser.buildSecurityRequirement(securityRequirements, operation);
}
if (javadocProvider.isPresent()) {
String description = javadocProvider.get().getMethodJavadocDescription(handlerMethod.getMethod());
String summary = javadocProvider.get().getFirstSentence(description);
if (StringUtils.isNotBlank(description)){
operation.setSummary(summary);
}
// 调用解析器提取JavaDoc中的权限信息
if (javadocResolvers != null && !javadocResolvers.isEmpty()) {
for (JavadocResolver resolver : javadocResolvers) {
String desc = resolver.resolve(handlerMethod, operation);
description = description + desc;
}
operation.setDescription(description);
}
}
return operation;
}
/**
* 从方法注解中提取标签信息
*
* @param method 方法对象
* @param tags 标签集合
* @param tagsStr 标签名称集合
* @param locale 当前语言环境
*/
private void buildTagsFromMethod(Method method, Set<io.swagger.v3.oas.models.tags.Tag> tags, Set<String> tagsStr, Locale locale) {
// method tags
Set<Tags> tagsSet = AnnotatedElementUtils
.findAllMergedAnnotations(method, Tags.class);
Set<io.swagger.v3.oas.annotations.tags.Tag> methodTags = tagsSet.stream()
.flatMap(x -> Stream.of(x.value())).collect(Collectors.toSet());
methodTags.addAll(AnnotatedElementUtils.findAllMergedAnnotations(method, io.swagger.v3.oas.annotations.tags.Tag.class));
if (!CollectionUtils.isEmpty(methodTags)) {
tagsStr.addAll(StreamUtils.toSet(methodTags, tag -> propertyResolverUtils.resolve(tag.name(), locale)));
List<io.swagger.v3.oas.annotations.tags.Tag> allTags = new ArrayList<>(methodTags);
addTags(allTags, tags, locale);
}
}
/**
* 将注解标签转换并合并到 OpenAPI 标签集合
*
* @param sourceTags 注解标签列表
* @param tags OpenAPI 标签集合
* @param locale 当前语言环境
*/
private void addTags(List<io.swagger.v3.oas.annotations.tags.Tag> sourceTags, Set<io.swagger.v3.oas.models.tags.Tag> tags, Locale locale) {
Optional<Set<io.swagger.v3.oas.models.tags.Tag>> optionalTagSet = AnnotationsUtils
.getTags(sourceTags.toArray(new io.swagger.v3.oas.annotations.tags.Tag[0]), true);
optionalTagSet.ifPresent(tagsSet -> {
tagsSet.forEach(tag -> {
tag.name(propertyResolverUtils.resolve(tag.getName(), locale));
tag.description(propertyResolverUtils.resolve(tag.getDescription(), locale));
if (tags.stream().noneMatch(t -> t.getName().equals(tag.getName())))
tags.add(tag);
});
});
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-common-elasticsearch</artifactId>
<description>
ruoyi-common-elasticsearch ES搜索引擎服务
</description>
<dependencies>
<dependency>
<groupId>org.dromara.easy-es</groupId>
<artifactId>easy-es-boot-starter</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,26 @@
package org.dromara.common.elasticsearch.config;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
/**
* 健康检查配置注入
*
* @author Lion Li
*/
public class ActuatorEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
String enable = environment.getProperty("easy-es.enable", "false");
System.setProperty("management.health.elasticsearch.enabled", enable);
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}

View File

@ -0,0 +1,17 @@
package org.dromara.common.elasticsearch.config;
import org.dromara.easyes.spring.annotation.EsMapperScan;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
/**
* easy-es 配置
*
* @author Lion Li
*/
@AutoConfiguration
@ConditionalOnProperty(value = "easy-es.enable", havingValue = "true")
@EsMapperScan("org.dromara.**.esmapper")
public class EasyEsConfiguration {
}

View File

@ -0,0 +1,2 @@
org.springframework.boot.EnvironmentPostProcessor=\
org.dromara.common.elasticsearch.config.ActuatorEnvironmentPostProcessor

View File

@ -0,0 +1 @@
org.dromara.common.elasticsearch.config.EasyEsConfiguration

View File

@ -9,6 +9,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.web.servlet.FilterRegistration;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
/**
* api 解密自动配置
@ -20,6 +22,14 @@ import org.springframework.context.annotation.Bean;
@ConditionalOnProperty(value = "api-decrypt.enabled", havingValue = "true")
public class ApiDecryptAutoConfiguration {
/**
* 注册 API 加解密过滤器
*
* @param properties API 解密配置
* @param requestMappingHandlerMapping 请求映射处理器
* @param handlerExceptionResolver 异常处理器
* @return API 加解密过滤器
*/
@Bean
@FilterRegistration(
name = "cryptoFilter",
@ -27,8 +37,10 @@ public class ApiDecryptAutoConfiguration {
order = FilterRegistrationBean.HIGHEST_PRECEDENCE,
dispatcherTypes = DispatcherType.REQUEST
)
public CryptoFilter cryptoFilter(ApiDecryptProperties properties) {
return new CryptoFilter(properties);
public CryptoFilter cryptoFilter(ApiDecryptProperties properties,
RequestMappingHandlerMapping requestMappingHandlerMapping,
HandlerExceptionResolver handlerExceptionResolver) {
return new CryptoFilter(properties, requestMappingHandlerMapping, handlerExceptionResolver);
}
}

View File

@ -3,7 +3,11 @@ package org.dromara.common.encrypt.config;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusProperties;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.encrypt.core.EncryptContextFactory;
import org.dromara.common.encrypt.core.EncryptedFieldProcessor;
import org.dromara.common.encrypt.core.EncryptorManager;
import org.dromara.common.encrypt.enums.AlgorithmType;
import org.dromara.common.encrypt.interceptor.MybatisDecryptInterceptor;
import org.dromara.common.encrypt.interceptor.MybatisEncryptInterceptor;
import org.dromara.common.encrypt.properties.EncryptorProperties;
@ -16,8 +20,7 @@ import org.springframework.context.annotation.Bean;
/**
* 加解密配置
*
* @author 老马
* @version 4.6.0
* @author Lion Li
*/
@AutoConfiguration(after = MybatisPlusAutoConfiguration.class)
@EnableConfigurationProperties(EncryptorProperties.class)
@ -28,22 +31,76 @@ public class EncryptorAutoConfiguration {
@Autowired
private EncryptorProperties properties;
/**
* 创建字段加解密管理器
*
* @param mybatisPlusProperties MyBatis-Plus 配置
* @return 字段加解密管理器
*/
@Bean
public EncryptorManager encryptorManager(MybatisPlusProperties mybatisPlusProperties) {
validateEncryptorProperties(properties);
return new EncryptorManager(mybatisPlusProperties.getTypeAliasesPackage());
}
/**
* 创建加密上下文工厂
*
* @return 加密上下文工厂
*/
@Bean
public MybatisEncryptInterceptor mybatisEncryptInterceptor(EncryptorManager encryptorManager) {
return new MybatisEncryptInterceptor(encryptorManager, properties);
public EncryptContextFactory encryptContextFactory() {
return new EncryptContextFactory(properties);
}
/**
* 创建加密字段处理器
*
* @param encryptorManager 加解密管理器
* @param encryptContextFactory 加密上下文工厂
* @return 加密字段处理器
*/
@Bean
public MybatisDecryptInterceptor mybatisDecryptInterceptor(EncryptorManager encryptorManager) {
return new MybatisDecryptInterceptor(encryptorManager, properties);
public EncryptedFieldProcessor encryptedFieldProcessor(EncryptorManager encryptorManager, EncryptContextFactory encryptContextFactory) {
return new EncryptedFieldProcessor(encryptorManager, encryptContextFactory);
}
/**
* 创建 MyBatis 入参加密拦截器
*
* @param encryptedFieldProcessor 加密字段处理器
* @return MyBatis 入参加密拦截器
*/
@Bean
public MybatisEncryptInterceptor mybatisEncryptInterceptor(EncryptedFieldProcessor encryptedFieldProcessor) {
return new MybatisEncryptInterceptor(encryptedFieldProcessor);
}
/**
* 创建 MyBatis 出参解密拦截器
*
* @param encryptedFieldProcessor 加密字段处理器
* @return MyBatis 出参解密拦截器
*/
@Bean
public MybatisDecryptInterceptor mybatisDecryptInterceptor(EncryptedFieldProcessor encryptedFieldProcessor) {
return new MybatisDecryptInterceptor(encryptedFieldProcessor);
}
private void validateEncryptorProperties(EncryptorProperties properties) {
AlgorithmType algorithm = properties.getAlgorithm();
if (algorithm == AlgorithmType.AES || algorithm == AlgorithmType.SM4) {
if (StringUtils.isBlank(properties.getPassword())) {
throw new IllegalArgumentException("mybatis-encryptor.password 不能为空");
}
}
if (algorithm == AlgorithmType.RSA || algorithm == AlgorithmType.SM2) {
if (StringUtils.isAnyBlank(properties.getPublicKey(), properties.getPrivateKey())) {
throw new IllegalArgumentException("mybatis-encryptor.publicKey 与 privateKey 不能为空");
}
}
}
}

View File

@ -0,0 +1,45 @@
package org.dromara.common.encrypt.core;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.encrypt.annotation.EncryptField;
import org.dromara.common.encrypt.enums.AlgorithmType;
import org.dromara.common.encrypt.enums.EncodeType;
import org.dromara.common.encrypt.properties.EncryptorProperties;
import java.lang.reflect.Field;
/**
* 加密上下文工厂
*
* @author Lion Li
*/
public class EncryptContextFactory {
private final EncryptorProperties defaultProperties;
/**
* 构造加密上下文工厂
*
* @param defaultProperties 默认加密配置
*/
public EncryptContextFactory(EncryptorProperties defaultProperties) {
this.defaultProperties = defaultProperties;
}
/**
* 根据字段注解和默认配置创建加密上下文
*
* @param field 加密字段
* @return 加密上下文
*/
public EncryptContext create(Field field) {
EncryptField encryptField = field.getAnnotation(EncryptField.class);
EncryptContext encryptContext = new EncryptContext();
encryptContext.setAlgorithm(encryptField.algorithm() == AlgorithmType.DEFAULT ? defaultProperties.getAlgorithm() : encryptField.algorithm());
encryptContext.setEncode(encryptField.encode() == EncodeType.DEFAULT ? defaultProperties.getEncode() : encryptField.encode());
encryptContext.setPassword(StringUtils.isBlank(encryptField.password()) ? defaultProperties.getPassword() : encryptField.password());
encryptContext.setPrivateKey(StringUtils.isBlank(encryptField.privateKey()) ? defaultProperties.getPrivateKey() : encryptField.privateKey());
encryptContext.setPublicKey(StringUtils.isBlank(encryptField.publicKey()) ? defaultProperties.getPublicKey() : encryptField.publicKey());
return encryptContext;
}
}

View File

@ -0,0 +1,115 @@
package org.dromara.common.encrypt.core;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Field;
import java.util.*;
/**
* 加密字段处理器
*
* @author Lion Li
*/
@Slf4j
public class EncryptedFieldProcessor {
private final EncryptorManager encryptorManager;
private final EncryptContextFactory contextFactory;
/**
* 构造加密字段处理器
*
* @param encryptorManager 加解密管理器
* @param contextFactory 加密上下文工厂
*/
public EncryptedFieldProcessor(EncryptorManager encryptorManager, EncryptContextFactory contextFactory) {
this.encryptorManager = encryptorManager;
this.contextFactory = contextFactory;
}
/**
* 加密对象字段并返回原始字段快照
*
* @param sourceObject 待加密对象
* @return 原始字段快照
*/
public List<FieldSnapshot> encrypt(Object sourceObject) {
List<FieldSnapshot> snapshots = new ArrayList<>();
handle(sourceObject, Collections.newSetFromMap(new IdentityHashMap<>()), (target, field, value) -> {
String encrypt = encryptorManager.encrypt(value, contextFactory.create(field));
if (!Objects.equals(value, encrypt)) {
snapshots.add(new FieldSnapshot(target, field, value));
field.set(target, encrypt);
}
});
return snapshots;
}
/**
* 解密对象字段
*
* @param sourceObject 待解密对象
*/
public void decrypt(Object sourceObject) {
handle(sourceObject, Collections.newSetFromMap(new IdentityHashMap<>()), (target, field, value) ->
field.set(target, encryptorManager.decrypt(value, contextFactory.create(field))));
}
private void handle(Object sourceObject, Set<Object> visited, FieldHandler fieldHandler) {
if (ObjectUtil.isNull(sourceObject) || sourceObject instanceof String || visited.contains(sourceObject)) {
return;
}
visited.add(sourceObject);
if (sourceObject instanceof Map<?, ?> map) {
new HashSet<>(map.values()).forEach(value -> handle(value, visited, fieldHandler));
return;
}
if (sourceObject instanceof Collection<?> collection) {
if (CollUtil.isEmpty(collection)) {
return;
}
collection.forEach(item -> handle(item, visited, fieldHandler));
return;
}
Set<Field> fields = encryptorManager.getFieldCache(sourceObject.getClass());
if (CollUtil.isEmpty(fields)) {
return;
}
try {
for (Field field : fields) {
String value = Convert.toStr(field.get(sourceObject));
if (ObjectUtil.isNotNull(value)) {
fieldHandler.handle(sourceObject, field, value);
}
}
} catch (Exception e) {
log.error("处理加密字段时出错", e);
}
}
@FunctionalInterface
private interface FieldHandler {
void handle(Object target, Field field, String value) throws IllegalAccessException;
}
/**
* 字段原始值快照
*/
public record FieldSnapshot(Object target, Field field, Object value) {
/**
* 恢复原始字段值
*/
public void restore() {
try {
field.set(target, value);
} catch (IllegalAccessException e) {
log.error("恢复加密字段时出错", e);
}
}
}
}

View File

@ -2,13 +2,13 @@ package org.dromara.common.encrypt.core;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ReflectUtil;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.io.Resources;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.utils.ObjectUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.encrypt.annotation.EncryptField;
import org.dromara.common.encrypt.enums.AlgorithmType;
import org.dromara.common.encrypt.enums.EncodeType;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
@ -28,17 +28,15 @@ import java.util.stream.Collectors;
/**
* 加密管理类
*
* @author 老马
* @version 4.6.0
* @author Lion Li
*/
@Slf4j
@NoArgsConstructor
public class EncryptorManager {
/**
* 缓存加密器
*/
Map<Integer, IEncryptor> encryptorMap = new ConcurrentHashMap<>();
Map<EncryptorCacheKey, IEncryptor> encryptorMap = new ConcurrentHashMap<>();
/**
* 类加密字段缓存
@ -59,7 +57,13 @@ public class EncryptorManager {
* 获取类加密字段缓存
*/
public Set<Field> getFieldCache(Class<?> sourceClazz) {
return ObjectUtils.notNullGetter(fieldCache, f -> f.get(sourceClazz));
if (sourceClazz == null || sourceClazz.isPrimitive() || sourceClazz.isArray()
|| sourceClazz.isEnum() || sourceClazz.isAnnotation()
|| ClassUtils.isPrimitiveOrWrapper(sourceClazz)
|| sourceClazz.getName().startsWith("java.")) {
return Set.of();
}
return fieldCache.computeIfAbsent(sourceClazz, this::getEncryptFieldSetFromClazz);
}
/**
@ -68,13 +72,9 @@ public class EncryptorManager {
* @param encryptContext 加密执行者需要的相关配置参数
*/
public IEncryptor registAndGetEncryptor(EncryptContext encryptContext) {
int key = encryptContext.hashCode();
if (encryptorMap.containsKey(key)) {
return encryptorMap.get(key);
}
IEncryptor encryptor = ReflectUtil.newInstance(encryptContext.getAlgorithm().getClazz(), encryptContext);
encryptorMap.put(key, encryptor);
return encryptor;
EncryptorCacheKey key = EncryptorCacheKey.of(encryptContext);
return encryptorMap.computeIfAbsent(key, cacheKey ->
ReflectUtil.newInstance(cacheKey.algorithm().getClazz(), encryptContext));
}
/**
@ -83,7 +83,7 @@ public class EncryptorManager {
* @param encryptContext 加密执行者需要的相关配置参数
*/
public void removeEncryptor(EncryptContext encryptContext) {
this.encryptorMap.remove(encryptContext.hashCode());
this.encryptorMap.remove(EncryptorCacheKey.of(encryptContext));
}
/**
@ -120,6 +120,9 @@ public class EncryptorManager {
* 通过 typeAliasesPackage 设置的扫描包 扫描缓存实体
*/
private void scanEncryptClasses(String typeAliasesPackage) {
if (StringUtils.isBlank(typeAliasesPackage)) {
return;
}
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
CachingMetadataReaderFactory factory = new CachingMetadataReaderFactory();
String[] packagePatternArray = StringUtils.splitPreserveAllTokens(typeAliasesPackage, ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
@ -149,7 +152,7 @@ public class EncryptorManager {
Set<Field> fieldSet = new HashSet<>();
// 判断clazz如果是接口,内部类,匿名类就直接返回
if (clazz.isInterface() || clazz.isMemberClass() || clazz.isAnonymousClass()) {
return fieldSet;
return Set.of();
}
while (clazz != null) {
Field[] fields = clazz.getDeclaredFields();
@ -165,4 +168,22 @@ public class EncryptorManager {
return fieldSet;
}
private record EncryptorCacheKey(
AlgorithmType algorithm,
EncodeType encode,
String password,
String publicKey,
String privateKey
) {
private static EncryptorCacheKey of(EncryptContext encryptContext) {
return new EncryptorCacheKey(
encryptContext.getAlgorithm(),
encryptContext.getEncode(),
encryptContext.getPassword(),
encryptContext.getPublicKey(),
encryptContext.getPrivateKey());
}
}
}

View File

@ -6,7 +6,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.encrypt.annotation.ApiEncrypt;
import org.dromara.common.encrypt.properties.ApiDecryptProperties;
@ -27,9 +26,22 @@ import java.io.IOException;
*/
public class CryptoFilter implements Filter {
private final ApiDecryptProperties properties;
private final RequestMappingHandlerMapping requestMappingHandlerMapping;
private final HandlerExceptionResolver handlerExceptionResolver;
public CryptoFilter(ApiDecryptProperties properties) {
/**
* 构造加解密过滤器
*
* @param properties API 解密配置
* @param requestMappingHandlerMapping 请求映射处理器
* @param handlerExceptionResolver 异常处理器
*/
public CryptoFilter(ApiDecryptProperties properties,
RequestMappingHandlerMapping requestMappingHandlerMapping,
HandlerExceptionResolver handlerExceptionResolver) {
this.properties = properties;
this.requestMappingHandlerMapping = requestMappingHandlerMapping;
this.handlerExceptionResolver = handlerExceptionResolver;
EncryptUtils.validateRsaPublicKey(properties.getPublicKey());
EncryptUtils.validateRsaPrivateKey(properties.getPrivateKey());
}
@ -55,8 +67,7 @@ public class CryptoFilter implements Filter {
} else {
// 是否有注解有就报错没有放行
if (ObjectUtil.isNotNull(apiEncrypt)) {
HandlerExceptionResolver exceptionResolver = SpringUtils.getBean("handlerExceptionResolver", HandlerExceptionResolver.class);
exceptionResolver.resolveException(
handlerExceptionResolver.resolveException(
servletRequest, servletResponse, null,
new ServiceException("没有访问权限,请联系管理员授权", HttpStatus.FORBIDDEN));
return;
@ -75,7 +86,6 @@ public class CryptoFilter implements Filter {
ObjectUtil.defaultIfNull(responseWrapper, response));
if (responseFlag) {
servletResponse.reset();
// 对原始内容加密
String encryptContent = responseBodyWrapper.getEncryptContent(
servletResponse, properties.getPublicKey(), properties.getHeaderFlag());
@ -88,10 +98,9 @@ public class CryptoFilter implements Filter {
* 获取 ApiEncrypt 注解
*/
private ApiEncrypt getApiEncryptAnnotation(HttpServletRequest servletRequest) {
RequestMappingHandlerMapping handlerMapping = SpringUtils.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping.class);
// 获取注解
try {
HandlerExecutionChain mappingHandler = handlerMapping.getHandler(servletRequest);
HandlerExecutionChain mappingHandler = requestMappingHandlerMapping.getHandler(servletRequest);
if (ObjectUtil.isNotNull(mappingHandler)) {
Object handler = mappingHandler.getHandler();
if (ObjectUtil.isNotNull(handler)) {

View File

@ -13,6 +13,7 @@ import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
@ -24,6 +25,14 @@ public class DecryptRequestBodyWrapper extends HttpServletRequestWrapper {
private final byte[] body;
/**
* 解密请求体并缓存为可重复读取的 JSON 请求体
*
* @param request 原始请求
* @param privateKey RSA 私钥
* @param headerFlag 加密密钥请求头
* @throws IOException 读取请求体异常
*/
public DecryptRequestBodyWrapper(HttpServletRequest request, String privateKey, String headerFlag) throws IOException {
super(request);
// 获取 AES 密码 采用 RSA 加密
@ -41,7 +50,8 @@ public class DecryptRequestBodyWrapper extends HttpServletRequestWrapper {
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream()));
Charset charset = Charset.forName(getCharacterEncoding());
return new BufferedReader(new InputStreamReader(getInputStream(), charset));
}
@ -72,17 +82,17 @@ public class DecryptRequestBodyWrapper extends HttpServletRequestWrapper {
@Override
public int available() {
return body.length;
return bais.available();
}
@Override
public boolean isFinished() {
return false;
return bais.available() == 0;
}
@Override
public boolean isReady() {
return false;
return true;
}
@Override

View File

@ -1,6 +1,5 @@
package org.dromara.common.encrypt.filter;
import cn.hutool.core.util.RandomUtil;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletResponse;
@ -8,7 +7,10 @@ import jakarta.servlet.http.HttpServletResponseWrapper;
import org.dromara.common.encrypt.utils.EncryptUtils;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 加密响应参数包装类
@ -17,19 +19,32 @@ import java.nio.charset.StandardCharsets;
*/
public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final ByteArrayOutputStream byteArrayOutputStream;
private final ServletOutputStream servletOutputStream;
private final PrintWriter printWriter;
private PrintWriter printWriter;
private Charset charset;
/**
* 构造加密响应包装器
*
* @param response 原始响应
* @throws IOException 创建输出流异常
*/
public EncryptResponseBodyWrapper(HttpServletResponse response) throws IOException {
super(response);
this.byteArrayOutputStream = new ByteArrayOutputStream();
this.servletOutputStream = this.getOutputStream();
this.printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream));
this.charset = resolveCharset(response);
}
@Override
public PrintWriter getWriter() {
if (printWriter == null) {
charset = resolveCharset((HttpServletResponse) getResponse());
printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream, charset));
}
return printWriter;
}
@ -48,14 +63,31 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
byteArrayOutputStream.reset();
}
@Override
public void resetBuffer() {
byteArrayOutputStream.reset();
}
/**
* 获取已缓存的响应字节
*
* @return 响应字节数组
* @throws IOException 刷新响应缓冲异常
*/
public byte[] getResponseData() throws IOException {
flushBuffer();
return byteArrayOutputStream.toByteArray();
}
/**
* 获取已缓存的响应内容
*
* @return 响应文本
* @throws IOException 刷新响应缓冲异常
*/
public String getContent() throws IOException {
flushBuffer();
return byteArrayOutputStream.toString();
return byteArrayOutputStream.toString(charset);
}
/**
@ -69,7 +101,7 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
*/
public String getEncryptContent(HttpServletResponse servletResponse, String publicKey, String headerFlag) throws IOException {
// 生成秘钥
String aesPassword = RandomUtil.randomString(32);
String aesPassword = generateAesPassword();
// 秘钥使用 Base64 编码
String encryptAes = EncryptUtils.encryptByBase64(aesPassword);
// Rsa 公钥加密 Base64 编码
@ -78,16 +110,16 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
// 设置响应头
// vue版本需要设置
servletResponse.addHeader("Access-Control-Expose-Headers", headerFlag);
servletResponse.setHeader("Access-Control-Allow-Origin", "*");
servletResponse.setHeader("Access-Control-Allow-Methods", "*");
servletResponse.setHeader(headerFlag, encryptPassword);
servletResponse.setCharacterEncoding(StandardCharsets.UTF_8.toString());
servletResponse.setCharacterEncoding(charset.name());
// 获取原始内容
String originalBody = this.getContent();
// 对内容进行加密
return EncryptUtils.encryptByAes(originalBody, aesPassword);
String encryptContent = EncryptUtils.encryptByAes(originalBody, aesPassword);
servletResponse.setContentLengthLong(encryptContent.getBytes(charset).length);
return encryptContent;
}
@Override
@ -95,7 +127,7 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
return new ServletOutputStream() {
@Override
public boolean isReady() {
return false;
return true;
}
@Override
@ -120,4 +152,18 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
};
}
private Charset resolveCharset(HttpServletResponse response) {
String characterEncoding = response.getCharacterEncoding();
if (characterEncoding == null) {
return StandardCharsets.UTF_8;
}
return Charset.forName(characterEncoding);
}
private String generateAesPassword() {
byte[] bytes = new byte[24];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getEncoder().encodeToString(bytes);
}
}

View File

@ -1,32 +1,18 @@
package org.dromara.common.encrypt.interceptor;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.*;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.encrypt.annotation.EncryptField;
import org.dromara.common.encrypt.core.EncryptContext;
import org.dromara.common.encrypt.core.EncryptorManager;
import org.dromara.common.encrypt.enums.AlgorithmType;
import org.dromara.common.encrypt.enums.EncodeType;
import org.dromara.common.encrypt.properties.EncryptorProperties;
import org.dromara.common.encrypt.core.EncryptedFieldProcessor;
import java.lang.reflect.Field;
import java.sql.Statement;
import java.util.*;
import java.util.Properties;
/**
* 出参解密拦截器
*
* @author 老马
* @version 4.6.0
* @author Lion Li
*/
@Slf4j
@Intercepts({@Signature(
type = ResultSetHandler.class,
method = "handleResultSets",
@ -35,91 +21,19 @@ import java.util.*;
@AllArgsConstructor
public class MybatisDecryptInterceptor implements Interceptor {
private final EncryptorManager encryptorManager;
private final EncryptorProperties defaultProperties;
private final EncryptedFieldProcessor encryptedFieldProcessor;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 开始进行参数解密
ResultSetHandler resultSetHandler = (ResultSetHandler) invocation.getTarget();
Field parameterHandlerField = resultSetHandler.getClass().getDeclaredField("parameterHandler");
parameterHandlerField.setAccessible(true);
Object target = parameterHandlerField.get(resultSetHandler);
if (target instanceof ParameterHandler parameterHandler) {
Object parameterObject = parameterHandler.getParameterObject();
if (ObjectUtil.isNotNull(parameterObject) && !(parameterObject instanceof String)) {
this.decryptHandler(parameterObject);
}
}
// 获取执行mysql执行结果
Object result = invocation.proceed();
if (result == null) {
return null;
}
this.decryptHandler(result);
encryptedFieldProcessor.decrypt(result);
return result;
}
/**
* 解密对象
*
* @param sourceObject 待加密对象
*/
private void decryptHandler(Object sourceObject) {
if (ObjectUtil.isNull(sourceObject)) {
return;
}
if (sourceObject instanceof Map<?, ?> map) {
new HashSet<>(map.values()).forEach(this::decryptHandler);
return;
}
if (sourceObject instanceof List<?> list) {
if(CollUtil.isEmpty(list)) {
return;
}
// 判断第一个元素是否含有注解如果没有直接返回提高效率
Object firstItem = list.get(0);
if (ObjectUtil.isNull(firstItem) || CollUtil.isEmpty(encryptorManager.getFieldCache(firstItem.getClass()))) {
return;
}
list.forEach(this::decryptHandler);
return;
}
// 不在缓存中的类,就是没有加密注解的类(当然也有可能是typeAliasesPackage写错)
Set<Field> fields = encryptorManager.getFieldCache(sourceObject.getClass());
if(ObjectUtil.isNull(fields)){
return;
}
try {
for (Field field : fields) {
field.set(sourceObject, this.decryptField(Convert.toStr(field.get(sourceObject)), field));
}
} catch (Exception e) {
log.error("处理解密字段时出错", e);
}
}
/**
* 字段值进行加密通过字段的批注注册新的加密算法
*
* @param value 待加密的值
* @param field 待加密字段
* @return 加密后结果
*/
private String decryptField(String value, Field field) {
if (ObjectUtil.isNull(value)) {
return null;
}
EncryptField encryptField = field.getAnnotation(EncryptField.class);
EncryptContext encryptContext = new EncryptContext();
encryptContext.setAlgorithm(encryptField.algorithm() == AlgorithmType.DEFAULT ? defaultProperties.getAlgorithm() : encryptField.algorithm());
encryptContext.setEncode(encryptField.encode() == EncodeType.DEFAULT ? defaultProperties.getEncode() : encryptField.encode());
encryptContext.setPassword(StringUtils.isBlank(encryptField.password()) ? defaultProperties.getPassword() : encryptField.password());
encryptContext.setPrivateKey(StringUtils.isBlank(encryptField.privateKey()) ? defaultProperties.getPrivateKey() : encryptField.privateKey());
encryptContext.setPublicKey(StringUtils.isBlank(encryptField.publicKey()) ? defaultProperties.getPublicKey() : encryptField.publicKey());
return this.encryptorManager.decrypt(value, encryptContext);
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);

View File

@ -1,31 +1,20 @@
package org.dromara.common.encrypt.interceptor;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.plugin.*;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.encrypt.annotation.EncryptField;
import org.dromara.common.encrypt.core.EncryptContext;
import org.dromara.common.encrypt.core.EncryptorManager;
import org.dromara.common.encrypt.enums.AlgorithmType;
import org.dromara.common.encrypt.enums.EncodeType;
import org.dromara.common.encrypt.properties.EncryptorProperties;
import org.dromara.common.encrypt.core.EncryptedFieldProcessor;
import java.lang.reflect.Field;
import java.sql.PreparedStatement;
import java.util.*;
import java.util.List;
import java.util.Properties;
/**
* 入参加密拦截器
*
* @author 老马
* @version 4.6.0
* @author Lion Li
*/
@Slf4j
@Intercepts({@Signature(
type = ParameterHandler.class,
method = "setParameters",
@ -34,19 +23,25 @@ import java.util.*;
@AllArgsConstructor
public class MybatisEncryptInterceptor implements Interceptor {
private final EncryptorManager encryptorManager;
private final EncryptorProperties defaultProperties;
private final EncryptedFieldProcessor encryptedFieldProcessor;
@Override
public Object intercept(Invocation invocation) throws Throwable {
List<EncryptedFieldProcessor.FieldSnapshot> snapshots = List.of();
Object target = invocation.getTarget();
if (target instanceof ParameterHandler parameterHandler) {
Object parameterObject = parameterHandler.getParameterObject();
if (ObjectUtil.isNotNull(parameterObject) && !(parameterObject instanceof String)) {
this.encryptHandler(parameterObject);
snapshots = encryptedFieldProcessor.encrypt(parameterObject);
}
}
try {
return invocation.proceed();
} finally {
for (EncryptedFieldProcessor.FieldSnapshot snapshot : snapshots) {
snapshot.restore();
}
}
return invocation.proceed();
}
@Override
@ -54,67 +49,6 @@ public class MybatisEncryptInterceptor implements Interceptor {
return Plugin.wrap(target, this);
}
/**
* 加密对象
*
* @param sourceObject 待加密对象
*/
private void encryptHandler(Object sourceObject) {
if (ObjectUtil.isNull(sourceObject)) {
return;
}
if (sourceObject instanceof Map<?, ?> map) {
new HashSet<>(map.values()).forEach(this::encryptHandler);
return;
}
if (sourceObject instanceof List<?> list) {
if(CollUtil.isEmpty(list)) {
return;
}
// 判断第一个元素是否含有注解如果没有直接返回提高效率
Object firstItem = list.get(0);
if (ObjectUtil.isNull(firstItem) || CollUtil.isEmpty(encryptorManager.getFieldCache(firstItem.getClass()))) {
return;
}
list.forEach(this::encryptHandler);
return;
}
// 不在缓存中的类,就是没有加密注解的类(当然也有可能是typeAliasesPackage写错)
Set<Field> fields = encryptorManager.getFieldCache(sourceObject.getClass());
if(ObjectUtil.isNull(fields)){
return;
}
try {
for (Field field : fields) {
field.set(sourceObject, this.encryptField(Convert.toStr(field.get(sourceObject)), field));
}
} catch (Exception e) {
log.error("处理加密字段时出错", e);
}
}
/**
* 字段值进行加密通过字段的批注注册新的加密算法
*
* @param value 待加密的值
* @param field 待加密字段
* @return 加密后结果
*/
private String encryptField(String value, Field field) {
if (ObjectUtil.isNull(value)) {
return null;
}
EncryptField encryptField = field.getAnnotation(EncryptField.class);
EncryptContext encryptContext = new EncryptContext();
encryptContext.setAlgorithm(encryptField.algorithm() == AlgorithmType.DEFAULT ? defaultProperties.getAlgorithm() : encryptField.algorithm());
encryptContext.setEncode(encryptField.encode() == EncodeType.DEFAULT ? defaultProperties.getEncode() : encryptField.encode());
encryptContext.setPassword(StringUtils.isBlank(encryptField.password()) ? defaultProperties.getPassword() : encryptField.password());
encryptContext.setPrivateKey(StringUtils.isBlank(encryptField.privateKey()) ? defaultProperties.getPrivateKey() : encryptField.privateKey());
encryptContext.setPublicKey(StringUtils.isBlank(encryptField.publicKey()) ? defaultProperties.getPublicKey() : encryptField.publicKey());
return this.encryptorManager.encrypt(value, encryptContext);
}
@Override
public void setProperties(Properties properties) {
}

View File

@ -19,7 +19,7 @@ public class ApiDecryptProperties {
/**
* 头部标识
*/
private String headerFlag;
private String headerFlag = "encrypt-key";
/**
* 响应加密公钥

View File

@ -23,7 +23,7 @@ public class EncryptorProperties {
/**
* 默认算法
*/
private AlgorithmType algorithm;
private AlgorithmType algorithm = AlgorithmType.BASE64;
/**
* 安全秘钥
@ -43,6 +43,6 @@ public class EncryptorProperties {
/**
* 编码方式base64/hex
*/
private EncodeType encode;
private EncodeType encode = EncodeType.BASE64;
}

View File

@ -13,10 +13,12 @@ import org.dromara.common.excel.annotation.ExcelDictFormat;
import org.dromara.common.core.service.DictService;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.excel.utils.ExcelUtil;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 字典格式化转换处理
@ -26,6 +28,8 @@ import java.lang.reflect.Field;
@Slf4j
public class ExcelDictConvert implements Converter<Object> {
private DictService dictService;
@Override
public Class<Object> supportJavaTypeKey() {
return Object.class;
@ -43,9 +47,9 @@ public class ExcelDictConvert implements Converter<Object> {
String label = cellData.getStringValue();
String value;
if (StringUtils.isBlank(type)) {
value = ExcelUtil.reverseByExp(label, anno.readConverterExp(), anno.separator());
value = reverseByExp(label, anno.readConverterExp(), anno.separator());
} else {
value = SpringUtils.getBean(DictService.class).getDictValue(type, label, anno.separator());
value = getDictService().getDictValue(type, label, anno.separator());
}
return Convert.convert(contentProperty.getField().getType(), value);
}
@ -60,9 +64,9 @@ public class ExcelDictConvert implements Converter<Object> {
String value = Convert.toStr(object);
String label;
if (StringUtils.isBlank(type)) {
label = ExcelUtil.convertByExp(value, anno.readConverterExp(), anno.separator());
label = convertByExp(value, anno.readConverterExp(), anno.separator());
} else {
label = SpringUtils.getBean(DictService.class).getDictLabel(type, value, anno.separator());
label = getDictService().getDictLabel(type, value, anno.separator());
}
return new WriteCellData<>(label);
}
@ -70,4 +74,76 @@ public class ExcelDictConvert implements Converter<Object> {
private ExcelDictFormat getAnnotation(Field field) {
return AnnotationUtil.getAnnotation(field, ExcelDictFormat.class);
}
private DictService getDictService() {
if (dictService == null) {
dictService = SpringUtils.getBean(DictService.class);
}
return dictService;
}
/**
* 解析导出值 0=,1=,2=未知
*/
private static String convertByExp(String propertyValue, String converterExp, String separator) {
StringBuilder propertyString = new StringBuilder();
Map<String, String> convertSource = parseConverterExp(converterExp);
for (Map.Entry<String, String> item : convertSource.entrySet()) {
if (StringUtils.contains(propertyValue, separator)) {
for (String value : splitPropertyValue(propertyValue, separator)) {
if (item.getKey().equals(value)) {
propertyString.append(item.getValue()).append(separator);
break;
}
}
} else {
if (item.getKey().equals(propertyValue)) {
return item.getValue();
}
}
}
return StringUtils.stripEnd(propertyString.toString(), separator);
}
/**
* 反向解析值 =0,=1,未知=2
*/
private static String reverseByExp(String propertyValue, String converterExp, String separator) {
StringBuilder propertyString = new StringBuilder();
Map<String, String> convertSource = parseConverterExp(converterExp);
for (Map.Entry<String, String> item : convertSource.entrySet()) {
if (StringUtils.contains(propertyValue, separator)) {
for (String value : splitPropertyValue(propertyValue, separator)) {
if (item.getValue().equals(value)) {
propertyString.append(item.getKey()).append(separator);
break;
}
}
} else {
if (item.getValue().equals(propertyValue)) {
return item.getKey();
}
}
}
return StringUtils.stripEnd(propertyString.toString(), separator);
}
private static Map<String, String> parseConverterExp(String converterExp) {
Map<String, String> result = new LinkedHashMap<>();
if (StringUtils.isBlank(converterExp)) {
return result;
}
for (String item : converterExp.split(StringUtils.SEPARATOR)) {
String[] itemArray = item.split("=", 2);
if (itemArray.length != 2) {
throw new IllegalArgumentException("Excel转换表达式格式错误: " + item);
}
result.put(itemArray[0], itemArray[1]);
}
return result;
}
private static String[] splitPropertyValue(String propertyValue, String separator) {
return propertyValue.split(Pattern.quote(separator));
}
}

View File

@ -27,7 +27,7 @@ import java.util.concurrent.ConcurrentHashMap;
public class ExcelEnumConvert implements Converter<Object> {
private static final Map<Field, Map<Object, String>> ENUM_MAP_CACHE = new ConcurrentHashMap<>();
private static final Map<Field, Map<Object, Object>> ENUM_REVERSE_MAP_CACHE = new ConcurrentHashMap<>();
private static final Map<Field, Map<String, Object>> ENUM_REVERSE_MAP_CACHE = new ConcurrentHashMap<>();
@Override
public Class<Object> supportJavaTypeKey() {
@ -55,16 +55,24 @@ public class ExcelEnumConvert implements Converter<Object> {
}
Map<Object, String> enumCodeToTextMap = beforeConvert(contentProperty);
// 从Java输出至Excel是code转text从Excel转Java应将text与code对调
Map<Object, Object> enumTextToCodeMap = ENUM_REVERSE_MAP_CACHE.computeIfAbsent(
Map<String, Object> enumTextToCodeMap = ENUM_REVERSE_MAP_CACHE.computeIfAbsent(
contentProperty.getField(),
f -> {
Map<Object, Object> reverseMap = new HashMap<>();
enumCodeToTextMap.forEach((key, value) -> reverseMap.put(value, key));
Map<String, Object> reverseMap = new HashMap<>();
enumCodeToTextMap.forEach((key, value) -> {
Object oldValue = reverseMap.put(value, key);
if (ObjectUtil.isNotNull(oldValue)) {
throw new IllegalArgumentException("枚举导入文本值重复: " + value);
}
});
return reverseMap;
}
);
// 应该从text -> code中查找
Object codeValue = enumTextToCodeMap.get(textValue);
Object codeValue = enumTextToCodeMap.get(Convert.toStr(textValue));
if (ObjectUtil.isNull(codeValue)) {
throw new IllegalArgumentException("枚举值不匹配: " + textValue + ",允许值: " + enumTextToCodeMap.keySet());
}
return Convert.convert(contentProperty.getField().getType(), codeValue);
}
@ -86,6 +94,9 @@ public class ExcelEnumConvert implements Converter<Object> {
for (Enum<?> enumConstant : enumConstants) {
Object codeValue = ReflectUtils.invokeGetter(enumConstant, anno.codeField());
String textValue = ReflectUtils.invokeGetter(enumConstant, anno.textField());
if (ObjectUtil.isNull(codeValue) || ObjectUtil.isNull(textValue)) {
throw new IllegalArgumentException("枚举字段 code/text 不能为空: " + enumConstant.name());
}
enumValueMap.put(codeValue, textValue);
}
return enumValueMap;

View File

@ -33,6 +33,13 @@ public class CellMergeHandler {
this.hasTitle = hasTitle;
this.rowIndex = hasTitle ? rowIndex : 0;
}
/**
* 计算需要合并的单元格区域
*
* @param rows 数据行
* @return 单元格合并区域列表
*/
@SneakyThrows
public List<CellRangeAddress> handle(List<?> rows) {
// 如果入参为空集合则返回空集
@ -122,11 +129,14 @@ public class CellMergeHandler {
continue;
}
CellMerge cm = field.getAnnotation(CellMerge.class);
int index = cm.index() == -1 ? i : cm.index();
ExcelProperty property = field.getAnnotation(ExcelProperty.class);
int index = cm.index();
if (index == -1) {
index = property != null && property.index() != -1 ? property.index() : i;
}
mergeFields.put(field, FieldColumnIndex.of(index, cm));
if (hasTitle) {
ExcelProperty property = field.getAnnotation(ExcelProperty.class);
if (hasTitle && property != null && property.value().length > 0) {
rowIndex = Math.max(rowIndex, property.value().length);
}
}

View File

@ -39,13 +39,33 @@ public class DefaultExcelListener<T> extends AnalysisEventListener<T> implements
/**
* 导入回执
*/
private ExcelResult<T> excelResult;
private final ExcelResult<T> excelResult = new DefaultExcelResult<>();
/**
* 发生异常时是否立即终止读取默认保持原有快速失败行为
*/
private Boolean failFast = Boolean.TRUE;
/**
* 构造 Excel 导入监听器
*
* @param isValidate 是否执行 Validator 校验
*/
public DefaultExcelListener(boolean isValidate) {
this.excelResult = new DefaultExcelResult<>();
this.isValidate = isValidate;
}
/**
* 构造 Excel 导入监听器
*
* @param isValidate 是否执行 Validator 校验
* @param failFast 发生异常时是否立即终止读取
*/
public DefaultExcelListener(boolean isValidate, boolean failFast) {
this.isValidate = isValidate;
this.failFast = failFast;
}
/**
* 处理异常
*
@ -60,7 +80,7 @@ public class DefaultExcelListener<T> extends AnalysisEventListener<T> implements
Integer rowIndex = excelDataConvertException.getRowIndex();
Integer columnIndex = excelDataConvertException.getColumnIndex();
errMsg = StrUtil.format("第{}行-第{}列-表头{}: 解析异常<br/>",
rowIndex + 1, columnIndex + 1, headMap.get(columnIndex));
rowIndex + 1, columnIndex + 1, headMap == null ? "" : headMap.get(columnIndex));
if (log.isDebugEnabled()) {
log.warn(errMsg);
}
@ -78,7 +98,9 @@ public class DefaultExcelListener<T> extends AnalysisEventListener<T> implements
log.warn(errMsg, exception);
}
excelResult.getErrorList().add(errMsg);
throw new ExcelAnalysisException(errMsg);
if (failFast) {
throw new ExcelAnalysisException(errMsg);
}
}
@Override

View File

@ -15,6 +15,7 @@ import org.apache.fesod.sheet.write.metadata.holder.WriteWorkbookHolder;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.ss.util.CellReference;
import org.apache.poi.ss.util.WorkbookUtil;
import org.apache.poi.xssf.usermodel.XSSFDataValidation;
import org.dromara.common.core.exception.ServiceException;
@ -40,11 +41,8 @@ import java.util.*;
@Slf4j
public class ExcelDownHandler implements SheetWriteHandler {
/**
* Excel表格中的列名英文
* 仅为了解析列英文禁止修改
*/
private static final String EXCEL_COLUMN_NAME = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int FIRST_DATA_ROW_INDEX = 1;
private static final int LAST_DATA_ROW_INDEX = 1000;
/**
* 单选数据Sheet名
*/
@ -57,7 +55,7 @@ public class ExcelDownHandler implements SheetWriteHandler {
* 下拉可选项
*/
private final List<DropDownOptions> dropDownOptions;
private final DictService dictService;
private DictService dictService;
/**
* 当前单选进度
*/
@ -67,11 +65,15 @@ public class ExcelDownHandler implements SheetWriteHandler {
*/
private int currentLinkedOptionsSheetIndex;
/**
* 构造 Excel 下拉选处理器
*
* @param options 外部指定的下拉选项
*/
public ExcelDownHandler(List<DropDownOptions> options) {
this.dropDownOptions = options;
this.currentOptionsColumnIndex = 0;
this.currentLinkedOptionsSheetIndex = 0;
this.dictService = SpringUtils.getBean(DictService.class);
}
/**
@ -79,7 +81,7 @@ public class ExcelDownHandler implements SheetWriteHandler {
* 1.通过解析传入的@ExcelProperty同级是否标注有@DropDown选项
* 如果有且设置了value值则将其直接置为下拉可选项
* <p>
* 2.或者在调用ExcelUtil时指定了可选项将依据传入的可选项做下拉
* 2.或者在调用ExcelBuilder时指定了可选项将依据传入的可选项做下拉
* <p>
* 3.二者并存注意调用方式
*/
@ -104,14 +106,20 @@ public class ExcelDownHandler implements SheetWriteHandler {
String converterExp = format.readConverterExp();
if (StringUtils.isNotBlank(dictType)) {
// 如果传递了字典名则依据字典建立下拉
Collection<String> values = Optional.ofNullable(dictService.getAllDictByDictType(dictType))
Collection<String> values = Optional.ofNullable(getDictService().getAllDictByDictType(dictType))
.orElseThrow(() -> new ServiceException("字典 {} 不存在", dictType))
.values();
options = new ArrayList<>(values);
} else if (StringUtils.isNotBlank(converterExp)) {
// 如果指定了确切的值则直接解析确切的值
List<String> strList = StringUtils.splitList(converterExp, format.separator());
options = StreamUtils.toList(strList, s -> StringUtils.split(s, "=")[1]);
List<String> strList = StringUtils.splitList(converterExp, StringUtils.SEPARATOR);
options = StreamUtils.toList(strList, s -> {
String[] itemArray = s.split("=", 2);
if (itemArray.length != 2) {
throw new ServiceException("Excel转换表达式格式错误: {}", s);
}
return itemArray[1];
});
}
} else if (field.isAnnotationPresent(ExcelEnumFormat.class)) {
// 否则如果指定了@ExcelEnumFormat则使用枚举的逻辑
@ -145,10 +153,10 @@ public class ExcelDownHandler implements SheetWriteHandler {
}
dropDownOptions.forEach(everyOptions -> {
// 如果传递了下拉框选择器参数
if (!everyOptions.getNextOptions().isEmpty()) {
if (CollUtil.isNotEmpty(everyOptions.getNextOptions())) {
// 当二级选项不为空时使用额外关联表的形式
dropDownLinkedOptions(helper, workbook, sheet, everyOptions);
} else if (everyOptions.getOptions().size() > 10) {
} else if (CollUtil.isNotEmpty(everyOptions.getOptions()) && everyOptions.getOptions().size() > 10) {
// 当一级选项参数个数大于10使用额外表的形式
dropDownWithSheet(helper, workbook, sheet, everyOptions.getIndex(), everyOptions.getOptions());
} else {
@ -185,6 +193,9 @@ public class ExcelDownHandler implements SheetWriteHandler {
workbook.setSheetHidden(workbook.getSheetIndex(linkedOptionsDataSheet), true);
// 选项数据使用副本避免修改调用方的原始数据
List<String> firstOptions = options.getOptions();
if (CollUtil.isEmpty(firstOptions)) {
return;
}
Map<String, List<String>> secoundOptionsMap = new HashMap<>();
options.getNextOptions().forEach((k, v) -> secoundOptionsMap.put(k, new ArrayList<>(v)));
@ -210,7 +221,7 @@ public class ExcelDownHandler implements SheetWriteHandler {
String firstOptionsFunction = String.format("%s!$%s$1:$%s$1",
linkedOptionsSheetName,
getExcelColumnName(0),
getExcelColumnName(firstOptions.size())
getExcelColumnName(firstOptions.size() - 1)
);
// 设置名称管理器的引用位置
name.setRefersToFormula(firstOptionsFunction);
@ -242,7 +253,7 @@ public class ExcelDownHandler implements SheetWriteHandler {
// 数据验证为序列模式引用到每一个主表中的二级选项位置
// 创建子项的名称管理器只是为了使得Excel可以识别到数据
String mainSheetFirstOptionsColumnName = getExcelColumnName(options.getIndex());
for (int i = 0; i < 100; i++) {
for (int i = FIRST_DATA_ROW_INDEX; i <= LAST_DATA_ROW_INDEX; i++) {
// 以一级选项对应的主体所在位置创建二级下拉
String secondOptionsFunction = String.format("=INDIRECT(%s%d)", mainSheetFirstOptionsColumnName, i + 1);
// 二级只能主表每一行的每一列添加二级校验
@ -348,7 +359,7 @@ public class ExcelDownHandler implements SheetWriteHandler {
private void markOptionsToSheet(DataValidationHelper helper, Sheet sheet, Integer celIndex,
DataValidationConstraint constraint) {
// 设置数据有效性加载在哪个单元格上,四个参数分别是起始行终止行起始列终止列
CellRangeAddressList addressList = new CellRangeAddressList(1, 1000, celIndex, celIndex);
CellRangeAddressList addressList = new CellRangeAddressList(FIRST_DATA_ROW_INDEX, LAST_DATA_ROW_INDEX, celIndex, celIndex);
markDataValidationToSheet(helper, sheet, constraint, addressList);
}
@ -397,17 +408,13 @@ public class ExcelDownHandler implements SheetWriteHandler {
* @return 列index所在得英文名
*/
private String getExcelColumnName(int columnIndex) {
// 26一循环的次数
int columnCircleCount = columnIndex / 26;
// 26一循环内的位置
int thisCircleColumnIndex = columnIndex % 26;
// 26一循环的次数大于0则视为栏名至少两位
String columnPrefix = columnCircleCount == 0
? StrUtil.EMPTY
: StrUtil.subWithLength(EXCEL_COLUMN_NAME, columnCircleCount - 1, 1);
// 从26一循环内取对应的栏位名
String columnNext = StrUtil.subWithLength(EXCEL_COLUMN_NAME, thisCircleColumnIndex, 1);
// 将二者拼接即为最终的栏位名
return columnPrefix + columnNext;
return CellReference.convertNumToColString(columnIndex);
}
private DictService getDictService() {
if (dictService == null) {
dictService = SpringUtils.getBean(DictService.class);
}
return dictService;
}
}

View File

@ -16,6 +16,7 @@ import org.apache.poi.xssf.usermodel.XSSFClientAnchor;
import org.apache.poi.xssf.usermodel.XSSFRichTextString;
import org.dromara.common.excel.annotation.ExcelNotation;
import org.dromara.common.excel.annotation.ExcelRequired;
import org.dromara.common.core.utils.reflect.ReflectUtils;
import java.lang.reflect.Field;
import java.util.HashMap;
@ -39,6 +40,11 @@ public class DataWriteHandler implements SheetWriteHandler, CellWriteHandler {
private final Map<String, Short> headColumnMap;
/**
* 构造批注与必填样式处理器
*
* @param clazz 表头类型
*/
public DataWriteHandler(Class<?> clazz) {
notationMap = getNotationMap(clazz);
headColumnMap = getRequiredMap(clazz);
@ -51,6 +57,9 @@ public class DataWriteHandler implements SheetWriteHandler, CellWriteHandler {
}
// 第一行
WriteCellData<?> cellData = context.getFirstCellData();
if (cellData == null) {
return;
}
// 第一个格子
WriteCellStyle writeCellStyle = cellData.getOrCreateStyle();
@ -92,7 +101,7 @@ public class DataWriteHandler implements SheetWriteHandler, CellWriteHandler {
*/
private static Map<String, Short> getRequiredMap(Class<?> clazz) {
Map<String, Short> requiredMap = new HashMap<>();
Field[] fields = clazz.getDeclaredFields();
Field[] fields = ReflectUtils.getFields(clazz);
for (Field field : fields) {
if (!field.isAnnotationPresent(ExcelRequired.class)) {
continue;
@ -112,7 +121,7 @@ public class DataWriteHandler implements SheetWriteHandler, CellWriteHandler {
*/
private static Map<String, String> getNotationMap(Class<?> clazz) {
Map<String, String> notationMap = new HashMap<>();
Field[] fields = clazz.getDeclaredFields();
Field[] fields = ReflectUtils.getFields(clazz);
for (Field field : fields) {
if (!field.isAnnotationPresent(ExcelNotation.class)) {
continue;

View File

@ -0,0 +1,895 @@
package org.dromara.common.excel.utils;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.io.resource.ClassPathResource;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ZipUtil;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.fesod.sheet.ExcelWriter;
import org.apache.fesod.sheet.FesodSheet;
import org.apache.fesod.sheet.converters.Converter;
import org.apache.fesod.sheet.read.builder.ExcelReaderBuilder;
import org.apache.fesod.sheet.read.builder.ExcelReaderSheetBuilder;
import org.apache.fesod.sheet.write.builder.ExcelWriterBuilder;
import org.apache.fesod.sheet.write.builder.ExcelWriterSheetBuilder;
import org.apache.fesod.sheet.write.handler.WriteHandler;
import org.apache.fesod.sheet.write.metadata.WriteSheet;
import org.apache.fesod.sheet.write.metadata.fill.FillConfig;
import org.apache.fesod.sheet.write.metadata.fill.FillWrapper;
import org.apache.fesod.sheet.write.style.column.LongestMatchColumnWidthStyleStrategy;
import org.apache.fesod.sheet.write.style.column.SimpleColumnWidthStyleStrategy;
import org.apache.fesod.sheet.write.style.row.SimpleRowHeightStyleStrategy;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.file.FileUtils;
import org.dromara.common.excel.convert.ExcelBigNumberConvert;
import org.dromara.common.excel.core.CellMergeStrategy;
import org.dromara.common.excel.core.DefaultExcelListener;
import org.dromara.common.excel.core.DropDownOptions;
import org.dromara.common.excel.core.ExcelDownHandler;
import org.dromara.common.excel.core.ExcelListener;
import org.dromara.common.excel.core.ExcelResult;
import org.dromara.common.excel.handler.DataWriteHandler;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Excel 导出构造器
*
* @author Lion Li
*/
public final class ExcelBuilder<T> {
private static final String DEFAULT_SHEET_NAME = "sheet1";
private static final int DEFAULT_ZIP_PAGE_SIZE = 999;
private final List<T> data;
private final Class<T> headType;
private String sheetName = DEFAULT_SHEET_NAME;
private Integer sheetNo;
private boolean merge;
private List<DropDownOptions> options;
private boolean zip;
private int pageSize = DEFAULT_ZIP_PAGE_SIZE;
private String password;
private Boolean needHead;
private Boolean automaticMergeHead;
private Collection<String> includeFields;
private Collection<String> excludeFields;
private Collection<Integer> includeIndexes;
private Collection<Integer> excludeIndexes;
private Boolean orderByIncludeColumn;
private Integer columnWidth;
private Short headRowHeight;
private Short contentRowHeight;
private List<WriteHandler> writeHandlers;
private List<Converter<?>> converters;
private ExcelBuilder(List<T> data, Class<T> headType) {
this.data = data;
this.headType = headType;
}
/**
* 创建导出构造器
*
* @param data 导出数据
* @param headType 表头类型
* @return 导出构造器
*/
public static <T> ExcelBuilder<T> of(List<T> data, Class<T> headType) {
return new ExcelBuilder<>(data, headType);
}
/**
* 创建自定义写出构造器
*
* @param headType 表头类型
* @return 导出构造器
*/
public static <T> ExcelBuilder<T> writer(Class<T> headType) {
return new ExcelBuilder<>(null, headType);
}
/**
* 创建模板导出构造器
*
* @param templatePath 模板路径
* @return 模板导出构造器
*/
public static TemplateBuilder template(String templatePath) {
return new TemplateBuilder(templatePath);
}
/**
* 创建导入读取构造器
*
* @param is 文件流
* @param clazz 接收实体类
* @return 导入读取构造器
*/
public static <T> ReadBuilder<T> read(InputStream is, Class<T> clazz) {
return new ReadBuilder<>(is, clazz);
}
/**
* 设置工作表名称
*/
public ExcelBuilder<T> sheetName(String sheetName) {
this.sheetName = StringUtils.blankToDefault(sheetName, DEFAULT_SHEET_NAME);
return this;
}
/**
* 设置工作表编号
*/
public ExcelBuilder<T> sheetNo(Integer sheetNo) {
this.sheetNo = sheetNo;
return this;
}
/**
* 开启单元格合并
*/
public ExcelBuilder<T> merge() {
return merge(true);
}
/**
* 设置是否合并单元格
*/
public ExcelBuilder<T> merge(boolean merge) {
this.merge = merge;
return this;
}
/**
* 设置下拉选项
*/
public ExcelBuilder<T> options(List<DropDownOptions> options) {
this.options = options;
return this;
}
/**
* 设置导出文件密码
*/
public ExcelBuilder<T> password(String password) {
this.password = password;
return this;
}
/**
* 设置是否写出表头
*/
public ExcelBuilder<T> needHead(boolean needHead) {
this.needHead = needHead;
return this;
}
/**
* 设置是否自动合并多级表头
*/
public ExcelBuilder<T> automaticMergeHead(boolean automaticMergeHead) {
this.automaticMergeHead = automaticMergeHead;
return this;
}
/**
* 仅导出指定字段
*/
public ExcelBuilder<T> includeFields(Collection<String> includeFields) {
this.includeFields = includeFields;
return this;
}
/**
* 排除指定字段
*/
public ExcelBuilder<T> excludeFields(Collection<String> excludeFields) {
this.excludeFields = excludeFields;
return this;
}
/**
* 仅导出指定列索引
*/
public ExcelBuilder<T> includeIndexes(Collection<Integer> includeIndexes) {
this.includeIndexes = includeIndexes;
return this;
}
/**
* 排除指定列索引
*/
public ExcelBuilder<T> excludeIndexes(Collection<Integer> excludeIndexes) {
this.excludeIndexes = excludeIndexes;
return this;
}
/**
* 设置是否按 include 列顺序导出
*/
public ExcelBuilder<T> orderByIncludeColumn(boolean orderByIncludeColumn) {
this.orderByIncludeColumn = orderByIncludeColumn;
return this;
}
/**
* 设置固定列宽
*/
public ExcelBuilder<T> columnWidth(Integer columnWidth) {
if (columnWidth != null && columnWidth <= 0) {
throw new IllegalArgumentException("columnWidth 必须大于 0");
}
this.columnWidth = columnWidth;
return this;
}
/**
* 设置固定行高
*/
public ExcelBuilder<T> rowHeight(short headRowHeight, short contentRowHeight) {
if (headRowHeight <= 0 || contentRowHeight <= 0) {
throw new IllegalArgumentException("rowHeight 必须大于 0");
}
this.headRowHeight = headRowHeight;
this.contentRowHeight = contentRowHeight;
return this;
}
/**
* 注册自定义写处理器
*/
public ExcelBuilder<T> registerWriteHandler(WriteHandler writeHandler) {
if (writeHandler == null) {
return this;
}
if (writeHandlers == null) {
writeHandlers = new ArrayList<>();
}
writeHandlers.add(writeHandler);
return this;
}
/**
* 注册自定义转换器
*/
public ExcelBuilder<T> registerConverter(Converter<?> converter) {
if (converter == null) {
return this;
}
if (converters == null) {
converters = new ArrayList<>();
}
converters.add(converter);
return this;
}
/**
* 开启 ZIP 分页导出
*/
public ExcelBuilder<T> zip() {
return zip(DEFAULT_ZIP_PAGE_SIZE);
}
/**
* 开启 ZIP 分页导出
*
* @param pageSize 每个 Excel 文件的数据量
*/
public ExcelBuilder<T> zip(int pageSize) {
if (pageSize <= 0) {
throw new IllegalArgumentException("pageSize 必须大于 0");
}
this.zip = true;
this.pageSize = pageSize;
return this;
}
/**
* 写入 HTTP 响应
*/
public void toResponse(HttpServletResponse response) {
if (zip) {
exportZipToResponse(response);
return;
}
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
writeSheet(os);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 写入输出流
*/
public void toStream(OutputStream outputStream) {
if (zip) {
throw new UnsupportedOperationException("ZIP导出请使用 toResponse(HttpServletResponse)");
}
writeSheet(outputStream);
}
/**
* 使用自定义写出逻辑写入输出流
*/
public void toStream(OutputStream outputStream, Consumer<ExcelWriterWrapper<T>> consumer) {
try (ExcelWriter writer = createWriter(outputStream)) {
consumer.accept(ExcelWriterWrapper.of(writer));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 使用自定义写出逻辑写入 HTTP 响应
*/
public void toResponse(HttpServletResponse response, Consumer<ExcelWriterWrapper<T>> consumer) {
try {
resetResponse(sheetName, response);
toStream(response.getOutputStream(), consumer);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
private void writeSheet(OutputStream outputStream) {
ExcelWriterSheetBuilder builder = createSheetBuilder(createWriterBuilder(outputStream));
if (merge) {
// 合并处理器
builder.registerWriteHandler(new CellMergeStrategy(data, true));
}
builder.doWrite(data);
}
private ExcelWriter createWriter(OutputStream outputStream) {
return createWriterBuilder(outputStream).build();
}
private ExcelWriterBuilder createWriterBuilder(OutputStream outputStream) {
ExcelWriterBuilder builder = FesodSheet.write(outputStream, headType)
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
// 批注必填项处理
.registerWriteHandler(new DataWriteHandler(headType));
if (columnWidth == null) {
// 自动适配
builder.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy());
} else {
builder.registerWriteHandler(new SimpleColumnWidthStyleStrategy(columnWidth));
}
if (headRowHeight != null || contentRowHeight != null) {
builder.registerWriteHandler(new SimpleRowHeightStyleStrategy(headRowHeight, contentRowHeight));
}
// 添加下拉框操作
builder.registerWriteHandler(new ExcelDownHandler(options));
if (StringUtils.isNotBlank(password)) {
builder.password(password);
}
if (needHead != null) {
builder.needHead(needHead);
}
if (automaticMergeHead != null) {
builder.automaticMergeHead(automaticMergeHead);
}
if (CollUtil.isNotEmpty(includeFields)) {
builder.includeColumnFieldNames(includeFields);
}
if (CollUtil.isNotEmpty(excludeFields)) {
builder.excludeColumnFieldNames(excludeFields);
}
if (CollUtil.isNotEmpty(includeIndexes)) {
builder.includeColumnIndexes(includeIndexes);
}
if (CollUtil.isNotEmpty(excludeIndexes)) {
builder.excludeColumnIndexes(excludeIndexes);
}
if (orderByIncludeColumn != null) {
builder.orderByIncludeColumn(orderByIncludeColumn);
}
if (CollUtil.isNotEmpty(converters)) {
converters.forEach(builder::registerConverter);
}
if (CollUtil.isNotEmpty(writeHandlers)) {
writeHandlers.forEach(builder::registerWriteHandler);
}
return builder;
}
private ExcelWriterSheetBuilder createSheetBuilder(ExcelWriterBuilder builder) {
if (sheetNo != null) {
return builder.sheet(sheetNo, sheetName);
}
return builder.sheet(sheetName);
}
private void exportZipToResponse(HttpServletResponse response) {
if (pageSize <= 0) {
throw new IllegalArgumentException("pageSize 必须大于 0");
}
List<List<T>> pageList = ListUtil.partition(data, pageSize);
if (pageList.size() <= 1) {
zip = false;
toResponse(response);
return;
}
try {
response.setContentType("application/zip");
response.setHeader("Content-Disposition",
"attachment;filename*=UTF-8''" + URLEncoder.encode(sheetName, StandardCharsets.UTF_8) + ".zip");
try (ZipOutputStream zipOut = ZipUtil.getZipOutputStream(response.getOutputStream(), CharsetUtil.CHARSET_UTF_8)) {
for (int i = 0; i < pageList.size(); i++) {
int pageNum = i + 1;
String exportSheetName = sheetName + "_第" + pageNum + "";
byte[] bytes = buildZipEntry(pageList.get(i), exportSheetName);
zipOut.putNextEntry(new ZipEntry(exportSheetName + ".xlsx"));
zipOut.write(bytes);
zipOut.closeEntry();
}
}
} catch (IOException e) {
throw new RuntimeException("导出Zip异常", e);
}
}
private byte[] buildZipEntry(List<T> pageData, String exportSheetName) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
ExcelBuilder<T> builder = ExcelBuilder.of(pageData, headType)
.sheetName(exportSheetName);
copyOptionsTo(builder);
builder.toStream(bos);
return bos.toByteArray();
} catch (Exception e) {
throw new RuntimeException(exportSheetName + "Excel生成失败", e);
}
}
private void copyOptionsTo(ExcelBuilder<T> builder) {
builder.merge = merge;
builder.options = options;
builder.password = password;
builder.needHead = needHead;
builder.automaticMergeHead = automaticMergeHead;
builder.includeFields = includeFields;
builder.excludeFields = excludeFields;
builder.includeIndexes = includeIndexes;
builder.excludeIndexes = excludeIndexes;
builder.orderByIncludeColumn = orderByIncludeColumn;
builder.columnWidth = columnWidth;
builder.headRowHeight = headRowHeight;
builder.contentRowHeight = contentRowHeight;
builder.writeHandlers = writeHandlers;
builder.converters = converters;
}
/**
* 重置响应体
*/
private static void resetResponse(String filename, HttpServletResponse response) throws UnsupportedEncodingException {
FileUtils.setAttachmentResponseHeader(response, encodingFilename(filename));
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
}
private static String encodingFilename(String filename) {
return IdUtil.fastSimpleUUID() + "_" + filename + ".xlsx";
}
/**
* Excel 导入读取构造器
*/
public static final class ReadBuilder<T> {
private final InputStream inputStream;
private final Class<T> headType;
private boolean validate = true;
private boolean failFast = true;
private ExcelListener<T> listener;
private Integer sheetNo;
private String sheetName;
private Integer headRowNumber;
private Boolean ignoreEmptyRow;
private String password;
private Boolean autoTrim;
private Boolean autoStrip;
private Integer numRows;
private List<Converter<?>> converters;
private ReadBuilder(InputStream inputStream, Class<T> headType) {
this.inputStream = inputStream;
this.headType = headType;
}
/**
* 设置是否校验导入数据
*/
public ReadBuilder<T> validate(boolean validate) {
this.validate = validate;
return this;
}
/**
* 设置解析异常时是否立即终止读取
*/
public ReadBuilder<T> failFast(boolean failFast) {
this.failFast = failFast;
return this;
}
/**
* 设置自定义导入监听器
*/
public ReadBuilder<T> listener(ExcelListener<T> listener) {
this.listener = listener;
return this;
}
/**
* 设置读取的工作表编号
*/
public ReadBuilder<T> sheetNo(Integer sheetNo) {
this.sheetNo = sheetNo;
return this;
}
/**
* 设置读取的工作表名称
*/
public ReadBuilder<T> sheetName(String sheetName) {
this.sheetName = sheetName;
return this;
}
/**
* 设置表头行数
*/
public ReadBuilder<T> headRowNumber(Integer headRowNumber) {
if (headRowNumber != null && headRowNumber < 0) {
throw new IllegalArgumentException("headRowNumber 不能小于 0");
}
this.headRowNumber = headRowNumber;
return this;
}
/**
* 设置是否忽略空行
*/
public ReadBuilder<T> ignoreEmptyRow(boolean ignoreEmptyRow) {
this.ignoreEmptyRow = ignoreEmptyRow;
return this;
}
/**
* 设置读取文件密码
*/
public ReadBuilder<T> password(String password) {
this.password = password;
return this;
}
/**
* 设置是否自动 trim 字符串
*/
public ReadBuilder<T> autoTrim(boolean autoTrim) {
this.autoTrim = autoTrim;
return this;
}
/**
* 设置是否自动 strip 字符串
*/
public ReadBuilder<T> autoStrip(boolean autoStrip) {
this.autoStrip = autoStrip;
return this;
}
/**
* 设置最多读取行数
*/
public ReadBuilder<T> numRows(Integer numRows) {
if (numRows != null && numRows <= 0) {
throw new IllegalArgumentException("numRows 必须大于 0");
}
this.numRows = numRows;
return this;
}
/**
* 注册自定义转换器
*/
public ReadBuilder<T> registerConverter(Converter<?> converter) {
if (converter == null) {
return this;
}
if (converters == null) {
converters = new ArrayList<>();
}
converters.add(converter);
return this;
}
/**
* 读取Excel并返回对象集合
*/
public List<T> doReadSync() {
return createSheetBuilder(createReaderBuilder(null)).doReadSync();
}
/**
* 读取Excel并返回解析结果
*/
public ExcelResult<T> doRead() {
ExcelListener<T> readListener = listener;
if (readListener == null) {
readListener = new DefaultExcelListener<>(validate, failFast);
}
createSheetBuilder(createReaderBuilder(readListener)).doRead();
return readListener.getExcelResult();
}
/**
* 读取所有工作表并返回解析结果
*/
public ExcelResult<T> doReadAll() {
ExcelListener<T> readListener = listener;
if (readListener == null) {
readListener = new DefaultExcelListener<>(validate, failFast);
}
createReaderBuilder(readListener).doReadAll();
return readListener.getExcelResult();
}
/**
* 读取所有工作表并返回对象集合
*/
public List<T> doReadAllSync() {
return createReaderBuilder(null).doReadAllSync();
}
private ExcelReaderBuilder createReaderBuilder(ExcelListener<T> readListener) {
ExcelReaderBuilder builder = FesodSheet.read(inputStream)
.head(headType)
.autoCloseStream(false);
if (readListener != null) {
builder.registerReadListener(readListener);
}
if (headRowNumber != null) {
builder.headRowNumber(headRowNumber);
}
if (ignoreEmptyRow != null) {
builder.ignoreEmptyRow(ignoreEmptyRow);
}
if (StringUtils.isNotBlank(password)) {
builder.password(password);
}
if (autoTrim != null) {
builder.autoTrim(autoTrim);
}
if (autoStrip != null) {
builder.autoStrip(autoStrip);
}
if (numRows != null) {
builder.numRows(numRows);
}
if (CollUtil.isNotEmpty(converters)) {
converters.forEach(builder::registerConverter);
}
return builder;
}
private ExcelReaderSheetBuilder createSheetBuilder(ExcelReaderBuilder builder) {
ExcelReaderSheetBuilder sheetBuilder;
if (sheetNo != null && StringUtils.isNotBlank(sheetName)) {
sheetBuilder = builder.sheet(sheetNo, sheetName);
} else if (sheetNo != null) {
sheetBuilder = builder.sheet(sheetNo);
} else if (StringUtils.isNotBlank(sheetName)) {
sheetBuilder = builder.sheet(sheetName);
} else {
sheetBuilder = builder.sheet();
}
if (numRows != null) {
sheetBuilder.numRows(numRows);
}
return sheetBuilder;
}
}
/**
* Excel 模板导出构造器
*/
public static final class TemplateBuilder {
private final String templatePath;
private String filename = DEFAULT_SHEET_NAME;
private TemplateMode mode;
private Object data;
private TemplateBuilder(String templatePath) {
this.templatePath = templatePath;
}
/**
* 设置下载文件名
*/
public TemplateBuilder filename(String filename) {
this.filename = StringUtils.blankToDefault(filename, DEFAULT_SHEET_NAME);
return this;
}
/**
* 设置单表多数据模板数据模板格式为 {.属性}
*/
public <T> TemplateBuilder data(List<T> data) {
this.mode = TemplateMode.LIST;
this.data = data;
return this;
}
/**
* 设置多表多数据模板数据模板格式为 {key.属性}
*/
public TemplateBuilder multiList(Map<String, Object> data) {
this.mode = TemplateMode.MULTI_LIST;
this.data = data;
return this;
}
/**
* 设置多 sheet 模板数据模板格式为 {key.属性}
*/
public TemplateBuilder multiSheet(List<Map<String, Object>> data) {
this.mode = TemplateMode.MULTI_SHEET;
this.data = data;
return this;
}
/**
* 写入 HTTP 响应
*/
public void toResponse(HttpServletResponse response) {
try {
validateData();
resetResponse(filename, response);
ServletOutputStream os = response.getOutputStream();
toStream(os);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 写入输出流
*/
public void toStream(OutputStream outputStream) {
validateData();
ClassPathResource templateResource = new ClassPathResource(templatePath);
try (InputStream templateStream = templateResource.getStream()) {
ExcelWriter excelWriter = FesodSheet.write(outputStream)
.withTemplate(templateStream)
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.build();
try {
fill(excelWriter);
} finally {
excelWriter.finish();
}
} catch (IOException e) {
throw new RuntimeException("读取Excel模板异常", e);
}
}
@SuppressWarnings("unchecked")
private void fill(ExcelWriter excelWriter) {
if (mode == TemplateMode.LIST) {
WriteSheet writeSheet = FesodSheet.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
for (Object item : (List<?>) data) {
excelWriter.fill(item, fillConfig, writeSheet);
}
return;
}
if (mode == TemplateMode.MULTI_LIST) {
WriteSheet writeSheet = FesodSheet.writerSheet().build();
for (Map.Entry<String, Object> map : ((Map<String, Object>) data).entrySet()) {
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
if (map.getValue() instanceof Collection) {
excelWriter.fill(new FillWrapper(map.getKey(), (Collection<?>) map.getValue()), fillConfig, writeSheet);
} else {
excelWriter.fill(map.getValue(), fillConfig, writeSheet);
}
}
return;
}
List<Map<String, Object>> sheetData = (List<Map<String, Object>>) data;
for (int i = 0; i < sheetData.size(); i++) {
WriteSheet writeSheet = FesodSheet.writerSheet(i).build();
for (Map.Entry<String, Object> map : sheetData.get(i).entrySet()) {
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
if (map.getValue() instanceof Collection) {
excelWriter.fill(new FillWrapper(map.getKey(), (Collection<?>) map.getValue()), fillConfig, writeSheet);
} else {
excelWriter.fill(map.getValue(), writeSheet);
}
}
}
}
private void validateData() {
if (mode == null || data == null) {
throw new IllegalArgumentException("数据为空");
}
if (data instanceof Collection<?> collection && CollUtil.isEmpty(collection)) {
throw new IllegalArgumentException("数据为空");
}
if (data instanceof Map<?, ?> map && CollUtil.isEmpty(map)) {
throw new IllegalArgumentException("数据为空");
}
}
private enum TemplateMode {
LIST,
MULTI_LIST,
MULTI_SHEET
}
}
}

View File

@ -1,626 +0,0 @@
package org.dromara.common.excel.utils;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.io.resource.ClassPathResource;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ZipUtil;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.apache.fesod.sheet.ExcelWriter;
import org.apache.fesod.sheet.FesodSheet;
import org.apache.fesod.sheet.write.builder.ExcelWriterSheetBuilder;
import org.apache.fesod.sheet.write.metadata.WriteSheet;
import org.apache.fesod.sheet.write.metadata.fill.FillConfig;
import org.apache.fesod.sheet.write.metadata.fill.FillWrapper;
import org.apache.fesod.sheet.write.style.column.LongestMatchColumnWidthStyleStrategy;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.file.FileUtils;
import org.dromara.common.excel.convert.ExcelBigNumberConvert;
import org.dromara.common.excel.core.*;
import org.dromara.common.excel.handler.DataWriteHandler;
import java.io.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.function.Consumer;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Excel相关处理
*
* @author Lion Li
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ExcelUtil {
/**
* 读取Excel并返回对象集合
*
* @param is 文件流
* @param clazz 接收实体类
* @return 数据列表
*/
public static <T> List<T> importExcel(InputStream is, Class<T> clazz) {
return FesodSheet.read(is).head(clazz).autoCloseStream(false).sheet().doReadSync();
}
/**
* 读取Excel并返回解析结果带默认校验功能
*
* @param is 文件流
* @param clazz 接收实体类
* @param isValidate 是否开启校验
* @return 解析结果含成功数据错误信息
*/
public static <T> ExcelResult<T> importExcel(InputStream is, Class<T> clazz, boolean isValidate) {
DefaultExcelListener<T> listener = new DefaultExcelListener<>(isValidate);
FesodSheet.read(is, clazz, listener).sheet().doRead();
return listener.getExcelResult();
}
/**
* 读取Excel并返回解析结果使用自定义监听器
*
* @param is 文件流
* @param clazz 接收实体类
* @param listener 自定义监听器
* @return 解析结果含成功数据错误信息
*/
public static <T> ExcelResult<T> importExcel(InputStream is, Class<T> clazz, ExcelListener<T> listener) {
FesodSheet.read(is, clazz, listener).sheet().doRead();
return listener.getExcelResult();
}
/**
* 导出excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param response 响应体
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, HttpServletResponse response) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, false, os, null);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 大数据量Excel导出
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
*/
public static <T> void exportExcelZip(List<T> list, String sheetName, Class<T> clazz, HttpServletResponse response) {
exportExcelZip(list, sheetName, clazz, response, 999);
}
/**
* 大数据量Excel导出
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param pageSize 每页条数
*/
public static <T> void exportExcelZip(List<T> list, String sheetName, Class<T> clazz, HttpServletResponse response, int pageSize) {
// 数据分页
List<List<T>> pageList = ListUtil.partition(list, pageSize);
// 只有一页直接导出普通Excel
if (pageList.size() <= 1) {
exportSingleExcel(list, sheetName, clazz, response);
return;
}
// 多线程生成所有Excel文件字节数组
Map<String, byte[]> excelMap = buildExcelZipData(pageList, sheetName, clazz);
// 写入ZIP并下载
writeExcelZipResponse(sheetName, response, excelMap);
}
/**
* 导出单文件Excel
*/
private static <T> void exportSingleExcel(List<T> list, String sheetName, Class<T> clazz, HttpServletResponse response) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, false, os, null);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 多线程并行生成多个Excel文件
* 使用虚拟线程高并发低资源占用
*
* @return Map<文件名, 文件字节数组>
*/
private static <T> Map<String, byte[]> buildExcelZipData(List<List<T>> pageList, String sheetName, Class<T> clazz) {
// 有序Map保证文件按页码顺序打包
Map<String, byte[]> excelMap = new LinkedHashMap<>(pageList.size());
List<Future<Map.Entry<String, byte[]>>> futures = new ArrayList<>(pageList.size());
// 使用虚拟线程池执行导出任务
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 1. 提交所有分页导出任务
for (int i = 0; i < pageList.size(); i++) {
int pageNum = i + 1;
List<T> pageData = pageList.get(i);
futures.add(executor.submit(() -> buildExcelZipEntry(pageData, sheetName, clazz, pageNum)));
}
// 2. 获取所有线程执行结果
for (Future<Map.Entry<String, byte[]>> future : futures) {
Map.Entry<String, byte[]> excel = getExcelZipEntry(future);
excelMap.put(excel.getKey(), excel.getValue());
}
}
return excelMap;
}
/**
* 单页Excel生成任务线程执行单元
*
* @param pageData 当前页数据
* @param pageNum 当前页码
* @return 文件名 + 文件字节
*/
private static <T> Map.Entry<String, byte[]> buildExcelZipEntry(List<T> pageData, String sheetName, Class<T> clazz, int pageNum) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
String exportSheetName = sheetName + "_第" + pageNum + "";
exportExcel(pageData, exportSheetName, clazz, false, bos, null);
return Map.entry(exportSheetName + ".xlsx", bos.toByteArray());
} catch (Exception e) {
throw new RuntimeException("" + pageNum + "页Excel生成失败", e);
}
}
/**
* 安全获取异步任务结果
* 处理中断异常执行异常保证任务稳定
*/
private static Map.Entry<String, byte[]> getExcelZipEntry(Future<Map.Entry<String, byte[]>> future) {
try {
return future.get();
} catch (InterruptedException e) {
// 恢复中断标志
Thread.currentThread().interrupt();
throw new RuntimeException("Excel导出线程被中断", e);
} catch (ExecutionException e) {
throw new RuntimeException("Excel导出失败", e.getCause());
}
}
/**
* 将多个Excel文件打包成ZIP并输出到浏览器下载
*
* @param excelMap 文件名 -> 文件字节
*/
private static void writeExcelZipResponse(String sheetName, HttpServletResponse response, Map<String, byte[]> excelMap) {
try {
// 设置ZIP下载响应头
response.setContentType("application/zip");
response.setHeader("Content-Disposition",
"attachment;filename*=UTF-8''" + URLEncoder.encode(sheetName, StandardCharsets.UTF_8) + ".zip");
// 压缩写入多个Excel文件
try (ZipOutputStream zipOut = ZipUtil.getZipOutputStream(response.getOutputStream(), CharsetUtil.CHARSET_UTF_8)) {
for (Map.Entry<String, byte[]> entry : excelMap.entrySet()) {
zipOut.putNextEntry(new ZipEntry(entry.getKey()));
zipOut.write(entry.getValue());
zipOut.closeEntry();
}
}
} catch (IOException e) {
throw new RuntimeException("导出Zip异常", e);
}
}
/**
* 导出excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param response 响应体
* @param options 级联下拉选
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, HttpServletResponse response, List<DropDownOptions> options) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, false, os, options);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 导出excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param merge 是否合并单元格
* @param response 响应体
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, boolean merge, HttpServletResponse response) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, merge, os, null);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 导出excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param merge 是否合并单元格
* @param response 响应体
* @param options 级联下拉选
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, boolean merge, HttpServletResponse response, List<DropDownOptions> options) {
try {
resetResponse(sheetName, response);
ServletOutputStream os = response.getOutputStream();
exportExcel(list, sheetName, clazz, merge, os, options);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 导出excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param os 输出流
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, OutputStream os) {
exportExcel(list, sheetName, clazz, false, os, null);
}
/**
* 导出excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param os 输出流
* @param options 级联下拉选内容
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, OutputStream os, List<DropDownOptions> options) {
exportExcel(list, sheetName, clazz, false, os, options);
}
/**
* 导出excel
*
* @param list 导出数据集合
* @param sheetName 工作表的名称
* @param clazz 实体类
* @param merge 是否合并单元格
* @param os 输出流
*/
public static <T> void exportExcel(List<T> list, String sheetName, Class<T> clazz, boolean merge,
OutputStream os, List<DropDownOptions> options) {
ExcelWriterSheetBuilder builder = FesodSheet.write(os, clazz)
.autoCloseStream(false)
// 自动适配
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.registerWriteHandler(new DataWriteHandler(clazz))
.sheet(sheetName);
if (merge) {
// 合并处理器
builder.registerWriteHandler(new CellMergeStrategy(list, true));
}
// 添加下拉框操作
builder.registerWriteHandler(new ExcelDownHandler(options));
builder.doWrite(list);
}
/**
* 导出excel
*
* @param headType 带Excel注解的类型
* @param os 输出流
* @param options Excel下拉可选项
* @param consumer 导出助手消费函数
*/
public static <T> void exportExcel(Class<T> headType, OutputStream os, List<DropDownOptions> options, Consumer<ExcelWriterWrapper<T>> consumer) {
try (ExcelWriter writer = FesodSheet.write(os, headType)
.autoCloseStream(false)
// 自动适配
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
// 批注必填项处理
.registerWriteHandler(new DataWriteHandler(headType))
// 添加下拉框操作
.registerWriteHandler(new ExcelDownHandler(options))
.build()) {
// 执行消费函数
consumer.accept(ExcelWriterWrapper.of(writer));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 导出excel
*
* @param headType 带Excel注解的类型
* @param os 输出流
* @param consumer 导出助手消费函数
*/
public static <T> void exportExcel(Class<T> headType, OutputStream os, Consumer<ExcelWriterWrapper<T>> consumer) {
exportExcel(headType, os, null, consumer);
}
/**
* 单表多数据模板导出 模板格式为 {.属性}
*
* @param filename 文件名
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param response 响应体
*/
public static <T> void exportTemplate(List<T> data, String filename, String templatePath, HttpServletResponse response) {
try {
if (CollUtil.isEmpty(data)) {
throw new IllegalArgumentException("数据为空");
}
resetResponse(filename, response);
ServletOutputStream os = response.getOutputStream();
exportTemplate(data, templatePath, os);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 单表多数据模板导出 模板格式为 {.属性}
*
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param os 输出流
*/
public static <T> void exportTemplate(List<T> data, String templatePath, OutputStream os) {
ClassPathResource templateResource = new ClassPathResource(templatePath);
ExcelWriter excelWriter = FesodSheet.write(os)
.withTemplate(templateResource.getStream())
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.registerWriteHandler(new DataWriteHandler(data.getFirst().getClass()))
.build();
try {
WriteSheet writeSheet = FesodSheet.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
// 单表多数据导出 模板格式为 {.属性}
for (T d : data) {
excelWriter.fill(d, fillConfig, writeSheet);
}
} finally {
excelWriter.finish();
}
}
/**
* 多表多数据模板导出 模板格式为 {key.属性}
*
* @param filename 文件名
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param response 响应体
*/
public static void exportTemplateMultiList(Map<String, Object> data, String filename, String templatePath, HttpServletResponse response) {
try {
if (CollUtil.isEmpty(data)) {
throw new IllegalArgumentException("数据为空");
}
resetResponse(filename, response);
ServletOutputStream os = response.getOutputStream();
exportTemplateMultiList(data, templatePath, os);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 多sheet模板导出 模板格式为 {key.属性}
*
* @param filename 文件名
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param response 响应体
*/
public static void exportTemplateMultiSheet(List<Map<String, Object>> data, String filename, String templatePath, HttpServletResponse response) {
try {
if (CollUtil.isEmpty(data)) {
throw new IllegalArgumentException("数据为空");
}
resetResponse(filename, response);
ServletOutputStream os = response.getOutputStream();
exportTemplateMultiSheet(data, templatePath, os);
} catch (IOException e) {
throw new RuntimeException("导出Excel异常", e);
}
}
/**
* 多表多数据模板导出 模板格式为 {key.属性}
*
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param os 输出流
*/
public static void exportTemplateMultiList(Map<String, Object> data, String templatePath, OutputStream os) {
ClassPathResource templateResource = new ClassPathResource(templatePath);
ExcelWriter excelWriter = FesodSheet.write(os)
.withTemplate(templateResource.getStream())
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.build();
try {
WriteSheet writeSheet = FesodSheet.writerSheet().build();
for (Map.Entry<String, Object> map : data.entrySet()) {
// 设置列表后续还有数据
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
if (map.getValue() instanceof Collection) {
// 多表导出必须使用 FillWrapper
excelWriter.fill(new FillWrapper(map.getKey(), (Collection<?>) map.getValue()), fillConfig, writeSheet);
} else {
excelWriter.fill(map.getValue(), fillConfig, writeSheet);
}
}
} finally {
excelWriter.finish();
}
}
/**
* 多sheet模板导出 模板格式为 {key.属性}
*
* @param templatePath 模板路径 resource 目录下的路径包括模板文件名
* 例如: excel/temp.xlsx
* 重点: 模板文件必须放置到启动类对应的 resource 目录下
* @param data 模板需要的数据
* @param os 输出流
*/
public static void exportTemplateMultiSheet(List<Map<String, Object>> data, String templatePath, OutputStream os) {
ClassPathResource templateResource = new ClassPathResource(templatePath);
ExcelWriter excelWriter = FesodSheet.write(os)
.withTemplate(templateResource.getStream())
.autoCloseStream(false)
// 大数值自动转换 防止失真
.registerConverter(new ExcelBigNumberConvert())
.build();
try {
for (int i = 0; i < data.size(); i++) {
WriteSheet writeSheet = FesodSheet.writerSheet(i).build();
for (Map.Entry<String, Object> map : data.get(i).entrySet()) {
// 设置列表后续还有数据
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
if (map.getValue() instanceof Collection) {
// 多表导出必须使用 FillWrapper
excelWriter.fill(new FillWrapper(map.getKey(), (Collection<?>) map.getValue()), fillConfig, writeSheet);
} else {
excelWriter.fill(map.getValue(), writeSheet);
}
}
}
} finally {
excelWriter.finish();
}
}
/**
* 重置响应体
*/
private static void resetResponse(String sheetName, HttpServletResponse response) throws UnsupportedEncodingException {
String filename = encodingFilename(sheetName);
FileUtils.setAttachmentResponseHeader(response, filename);
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
}
/**
* 解析导出值 0=,1=,2=未知
*
* @param propertyValue 参数值
* @param converterExp 翻译注解
* @param separator 分隔符
* @return 解析后值
*/
public static String convertByExp(String propertyValue, String converterExp, String separator) {
StringBuilder propertyString = new StringBuilder();
String[] convertSource = converterExp.split(StringUtils.SEPARATOR);
for (String item : convertSource) {
String[] itemArray = item.split("=");
if (StringUtils.containsAny(propertyValue, separator)) {
for (String value : propertyValue.split(separator)) {
if (itemArray[0].equals(value)) {
propertyString.append(itemArray[1]).append(separator);
break;
}
}
} else {
if (itemArray[0].equals(propertyValue)) {
return itemArray[1];
}
}
}
return StringUtils.stripEnd(propertyString.toString(), separator);
}
/**
* 反向解析值 =0,=1,未知=2
*
* @param propertyValue 参数值
* @param converterExp 翻译注解
* @param separator 分隔符
* @return 解析后值
*/
public static String reverseByExp(String propertyValue, String converterExp, String separator) {
StringBuilder propertyString = new StringBuilder();
String[] convertSource = converterExp.split(StringUtils.SEPARATOR);
for (String item : convertSource) {
String[] itemArray = item.split("=");
if (StringUtils.containsAny(propertyValue, separator)) {
for (String value : propertyValue.split(separator)) {
if (itemArray[1].equals(value)) {
propertyString.append(itemArray[0]).append(separator);
break;
}
}
} else {
if (itemArray[1].equals(propertyValue)) {
return itemArray[0];
}
}
}
return StringUtils.stripEnd(propertyString.toString(), separator);
}
/**
* 编码文件名
*/
public static String encodingFilename(String filename) {
return IdUtil.fastSimpleUUID() + "_" + filename + ".xlsx";
}
}

View File

@ -22,38 +22,95 @@ import java.util.function.Supplier;
*/
public record ExcelWriterWrapper<T>(ExcelWriter excelWriter) {
/**
* 写出集合数据到指定工作表
*
* @param data 数据集合
* @param writeSheet 工作表
*/
public void write(Collection<T> data, WriteSheet writeSheet) {
excelWriter.write(data, writeSheet);
}
/**
* 通过数据提供器写出集合数据到指定工作表
*
* @param supplier 数据提供器
* @param writeSheet 工作表
*/
public void write(Supplier<Collection<T>> supplier, WriteSheet writeSheet) {
excelWriter.write(supplier.get(), writeSheet);
}
/**
* 写出集合数据到指定工作表和表格
*
* @param data 数据集合
* @param writeSheet 工作表
* @param writeTable 表格
*/
public void write(Collection<T> data, WriteSheet writeSheet, WriteTable writeTable) {
excelWriter.write(data, writeSheet, writeTable);
}
/**
* 通过数据提供器写出集合数据到指定工作表和表格
*
* @param supplier 数据提供器
* @param writeSheet 工作表
* @param writeTable 表格
*/
public void write(Supplier<Collection<T>> supplier, WriteSheet writeSheet, WriteTable writeTable) {
excelWriter.write(supplier.get(), writeSheet, writeTable);
}
/**
* 填充数据到指定工作表
*
* @param data 填充数据
* @param writeSheet 工作表
*/
public void fill(Object data, WriteSheet writeSheet) {
excelWriter.fill(data, writeSheet);
}
/**
* 按填充配置填充数据到指定工作表
*
* @param data 填充数据
* @param fillConfig 填充配置
* @param writeSheet 工作表
*/
public void fill(Object data, FillConfig fillConfig, WriteSheet writeSheet) {
excelWriter.fill(data, fillConfig, writeSheet);
}
/**
* 通过数据提供器填充数据到指定工作表
*
* @param supplier 数据提供器
* @param writeSheet 工作表
*/
public void fill(Supplier<Object> supplier, WriteSheet writeSheet) {
excelWriter.fill(supplier, writeSheet);
}
/**
* 通过数据提供器按填充配置填充数据到指定工作表
*
* @param supplier 数据提供器
* @param fillConfig 填充配置
* @param writeSheet 工作表
*/
public void fill(Supplier<Object> supplier, FillConfig fillConfig, WriteSheet writeSheet) {
excelWriter.fill(supplier, fillConfig, writeSheet);
}
/**
* 获取写出上下文
*
* @return 写出上下文
*/
public WriteContext writeContext() {
return excelWriter.writeContext();
}
@ -70,34 +127,82 @@ public record ExcelWriterWrapper<T>(ExcelWriter excelWriter) {
// -------------------------------- sheet start
/**
* 创建工作表
*
* @param sheetNo 工作表编号
* @param sheetName 工作表名称
* @return 工作表
*/
public static WriteSheet buildSheet(Integer sheetNo, String sheetName) {
return sheetBuilder(sheetNo, sheetName).build();
}
/**
* 创建工作表
*
* @param sheetNo 工作表编号
* @return 工作表
*/
public static WriteSheet buildSheet(Integer sheetNo) {
return sheetBuilder(sheetNo).build();
}
/**
* 创建工作表
*
* @param sheetName 工作表名称
* @return 工作表
*/
public static WriteSheet buildSheet(String sheetName) {
return sheetBuilder(sheetName).build();
}
/**
* 创建工作表
*
* @return 工作表
*/
public static WriteSheet buildSheet() {
return sheetBuilder().build();
}
/**
* 创建工作表构造器
*
* @param sheetNo 工作表编号
* @param sheetName 工作表名称
* @return 工作表构造器
*/
public static ExcelWriterSheetBuilder sheetBuilder(Integer sheetNo, String sheetName) {
return FesodSheet.writerSheet(sheetNo, sheetName);
}
/**
* 创建工作表构造器
*
* @param sheetNo 工作表编号
* @return 工作表构造器
*/
public static ExcelWriterSheetBuilder sheetBuilder(Integer sheetNo) {
return FesodSheet.writerSheet(sheetNo);
}
/**
* 创建工作表构造器
*
* @param sheetName 工作表名称
* @return 工作表构造器
*/
public static ExcelWriterSheetBuilder sheetBuilder(String sheetName) {
return FesodSheet.writerSheet(sheetName);
}
/**
* 创建工作表构造器
*
* @return 工作表构造器
*/
public static ExcelWriterSheetBuilder sheetBuilder() {
return FesodSheet.writerSheet();
}
@ -106,18 +211,40 @@ public record ExcelWriterWrapper<T>(ExcelWriter excelWriter) {
// -------------------------------- table start
/**
* 创建表格
*
* @param tableNo 表格编号
* @return 表格
*/
public static WriteTable buildTable(Integer tableNo) {
return tableBuilder(tableNo).build();
}
/**
* 创建表格
*
* @return 表格
*/
public static WriteTable buildTable() {
return tableBuilder().build();
}
/**
* 创建表格构造器
*
* @param tableNo 表格编号
* @return 表格构造器
*/
public static ExcelWriterTableBuilder tableBuilder(Integer tableNo) {
return FesodSheet.writerTable(tableNo);
}
/**
* 创建表格构造器
*
* @return 表格构造器
*/
public static ExcelWriterTableBuilder tableBuilder() {
return FesodSheet.writerTable();
}

View File

@ -5,6 +5,7 @@ import tools.jackson.databind.json.JsonMapper;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
/**
* 单次响应增强上下文
@ -16,17 +17,72 @@ public class JsonEnhancementContext {
private final Map<String, Object> attributes = new LinkedHashMap<>();
private boolean processingRequired;
/**
* 构造响应增强上下文
*
* @param jsonMapper JSON 映射器
*/
public JsonEnhancementContext(JsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
}
/**
* 获取上下文属性
*
* @param key 属性键
* @param <T> 属性值类型
* @return 属性值
*/
@SuppressWarnings("unchecked")
public <T> T getAttribute(String key) {
return (T) attributes.get(key);
}
/**
* 获取上下文属性不存在时创建并写入
*
* @param key 属性键
* @param supplier 属性值创建器
* @param <T> 属性值类型
* @return 属性值
*/
@SuppressWarnings("unchecked")
public <T> T getOrCreateAttribute(String key, Supplier<T> supplier) {
Object value = attributes.computeIfAbsent(key, ignored -> supplier.get());
return (T) value;
}
/**
* 设置上下文属性
*
* @param key 属性键
* @param value 属性值
*/
public void setAttribute(String key, Object value) {
attributes.put(key, value);
}
/**
* 判断上下文是否包含指定属性
*/
public boolean containsAttribute(String key) {
return attributes.containsKey(key);
}
/**
* 移除上下文属性
*/
public void removeAttribute(String key) {
attributes.remove(key);
}
/**
* 标记本次响应存在需要处理的字段
*/
public void markProcessingRequired() {
this.processingRequired = true;
}
}

View File

@ -9,6 +9,13 @@ import java.lang.annotation.Annotation;
*/
public record JsonFieldContext(Object owner, String propertyName, AnnotatedMember member, Object value) {
/**
* 获取字段上的指定注解
*
* @param annotationType 注解类型
* @param <A> 注解类型
* @return 注解对象
*/
public <A extends Annotation> A getAnnotation(Class<A> annotationType) {
return member == null ? null : member.getAnnotation(annotationType);
}

View File

@ -20,6 +20,14 @@ package org.dromara.common.json.enhance;
*/
public interface JsonFieldProcessor {
/**
* 判断当前处理器是否需要处理该字段
* 默认返回 true 以兼容无注解驱动的自定义处理器
*/
default boolean supports(JsonFieldContext fieldContext) {
return true;
}
/**
* 收集阶段扫描字段将需要处理的 key 存入 context
* 每个字段调用一次整个对象树扫描完成后才会进入 prepare 阶段

View File

@ -31,6 +31,12 @@ public class JsonValueEnhancer {
private final Map<Class<?>, List<PropertyMetadata>> propertyCache = new ConcurrentHashMap<>();
/**
* 构造统一响应增强器
*
* @param jsonMapper JSON 映射器
* @param processors 字段处理器列表
*/
public JsonValueEnhancer(JsonMapper jsonMapper, List<JsonFieldProcessor> processors) {
this.jsonMapper = jsonMapper;
List<JsonFieldProcessor> sortedProcessors = new ArrayList<>(processors);
@ -38,13 +44,31 @@ public class JsonValueEnhancer {
this.processors = Collections.unmodifiableList(sortedProcessors);
}
/**
* 增强响应对象
*
* @param body 响应对象
* @return 增强后的响应对象
*/
public Object enhance(Object body) {
if (body == null || body instanceof JsonNode || processors.isEmpty()) {
return body;
}
return enhanceTree(body);
JsonEnhancementContext context = new JsonEnhancementContext(jsonMapper);
collectValue(body, context, new IdentityHashMap<>());
if (!context.isProcessingRequired()) {
return body;
}
processors.forEach(processor -> processor.prepare(context));
return renderValue(body, context, new IdentityHashMap<>());
}
/**
* 判断消息转换器是否支持响应增强
*
* @param converterType 消息转换器类型
* @return true 支持 false 不支持
*/
public boolean supports(Class<?> converterType) {
return !processors.isEmpty()
&& !ByteArrayHttpMessageConverter.class.isAssignableFrom(converterType)
@ -55,6 +79,9 @@ public class JsonValueEnhancer {
private JsonNode enhanceTree(Object value) {
JsonEnhancementContext context = new JsonEnhancementContext(jsonMapper);
collectValue(value, context, new IdentityHashMap<>());
if (!context.isProcessingRequired()) {
return jsonMapper.valueToTree(value);
}
processors.forEach(processor -> processor.prepare(context));
return renderValue(value, context, new IdentityHashMap<>());
}
@ -85,7 +112,7 @@ public class JsonValueEnhancer {
for (PropertyMetadata metadata : getProperties(value.getClass())) {
Object propertyValue = metadata.getValue(value);
JsonFieldContext fieldContext = new JsonFieldContext(value, metadata.propertyName(), metadata.member(), propertyValue);
processors.forEach(processor -> processor.collect(fieldContext, context));
collectField(fieldContext, context);
collectValue(propertyValue, context, visited);
}
} finally {
@ -93,6 +120,15 @@ public class JsonValueEnhancer {
}
}
private void collectField(JsonFieldContext fieldContext, JsonEnhancementContext context) {
for (JsonFieldProcessor processor : processors) {
if (processor.supports(fieldContext)) {
context.markProcessingRequired();
processor.collect(fieldContext, context);
}
}
}
private JsonNode renderValue(Object value, JsonEnhancementContext context, IdentityHashMap<Object, Boolean> visited) {
switch (value) {
case null -> {
@ -102,27 +138,16 @@ public class JsonValueEnhancer {
return jsonNode;
}
case Map<?, ?> map -> {
ObjectNode objectNode = jsonMapper.createObjectNode();
map.forEach((key, childValue) -> objectNode.set(String.valueOf(key), renderValue(childValue, context, visited)));
return objectNode;
return renderMap(map, context, visited);
}
case Iterable<?> iterable -> {
ArrayNode arrayNode = jsonMapper.createArrayNode();
for (Object child : iterable) {
arrayNode.add(renderValue(child, context, visited));
}
return arrayNode;
return renderIterable(iterable, context, visited);
}
default -> {
}
}
if (value.getClass().isArray()) {
ArrayNode arrayNode = jsonMapper.createArrayNode();
int length = Array.getLength(value);
for (int i = 0; i < length; i++) {
arrayNode.add(renderValue(Array.get(value, i), context, visited));
}
return arrayNode;
return renderArray(value, context, visited);
}
if (isSimpleValue(value.getClass())) {
return jsonMapper.valueToTree(value);
@ -131,28 +156,58 @@ public class JsonValueEnhancer {
return jsonMapper.valueToTree(value);
}
try {
ObjectNode objectNode = jsonMapper.createObjectNode();
for (PropertyMetadata metadata : getProperties(value.getClass())) {
Object originalValue = metadata.getValue(value);
JsonFieldContext fieldContext = new JsonFieldContext(value, metadata.propertyName(), metadata.member(), originalValue);
Object processedValue = originalValue;
boolean changed = false;
for (JsonFieldProcessor processor : processors) {
Object nextValue = processor.process(fieldContext, processedValue, context);
changed = changed || !Objects.equals(processedValue, nextValue);
processedValue = nextValue;
}
JsonNode childNode = changed
? enhanceTranslatedValue(processedValue, context, visited)
: renderValue(processedValue, context, visited);
objectNode.set(metadata.propertyName(), childNode);
}
return objectNode;
return renderPojo(value, context, visited);
} finally {
visited.remove(value);
}
}
private ObjectNode renderMap(Map<?, ?> map, JsonEnhancementContext context, IdentityHashMap<Object, Boolean> visited) {
ObjectNode objectNode = jsonMapper.createObjectNode();
map.forEach((key, childValue) -> objectNode.set(String.valueOf(key), renderValue(childValue, context, visited)));
return objectNode;
}
private ArrayNode renderIterable(Iterable<?> iterable, JsonEnhancementContext context, IdentityHashMap<Object, Boolean> visited) {
ArrayNode arrayNode = jsonMapper.createArrayNode();
for (Object child : iterable) {
arrayNode.add(renderValue(child, context, visited));
}
return arrayNode;
}
private ArrayNode renderArray(Object value, JsonEnhancementContext context, IdentityHashMap<Object, Boolean> visited) {
ArrayNode arrayNode = jsonMapper.createArrayNode();
int length = Array.getLength(value);
for (int i = 0; i < length; i++) {
arrayNode.add(renderValue(Array.get(value, i), context, visited));
}
return arrayNode;
}
private ObjectNode renderPojo(Object value, JsonEnhancementContext context, IdentityHashMap<Object, Boolean> visited) {
ObjectNode objectNode = jsonMapper.createObjectNode();
for (PropertyMetadata metadata : getProperties(value.getClass())) {
Object originalValue = metadata.getValue(value);
JsonFieldContext fieldContext = new JsonFieldContext(value, metadata.propertyName(), metadata.member(), originalValue);
Object processedValue = originalValue;
boolean changed = false;
for (JsonFieldProcessor processor : processors) {
if (!processor.supports(fieldContext)) {
continue;
}
Object nextValue = processor.process(fieldContext, processedValue, context);
changed = changed || !Objects.equals(processedValue, nextValue);
processedValue = nextValue;
}
JsonNode childNode = changed
? enhanceTranslatedValue(processedValue, context, visited)
: renderValue(processedValue, context, visited);
objectNode.set(metadata.propertyName(), childNode);
}
return objectNode;
}
private JsonNode enhanceTranslatedValue(Object value, JsonEnhancementContext context, IdentityHashMap<Object, Boolean> visited) {
if (value == null || value instanceof JsonNode || isSimpleValue(value.getClass())) {
return renderValue(value, context, visited);

View File

@ -2,7 +2,6 @@ package org.dromara.common.json.handler;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import org.dromara.common.core.utils.ObjectUtils;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.ValueDeserializer;
@ -25,10 +24,11 @@ public class CustomDateDeserializer extends ValueDeserializer<Date> {
*/
@Override
public Date deserialize(JsonParser p, DeserializationContext ctxt) {
DateTime parse = DateUtil.parse(p.getString());
if (ObjectUtils.isNull(parse)) {
String text = p.getString();
if (text == null || text.isBlank()) {
return null;
}
DateTime parse = DateUtil.parse(text.trim());
return parse.toJdkDate();
}

View File

@ -19,6 +19,10 @@ import java.util.List;
*/
public class CustomLocalDateTimeDeserializer extends ValueDeserializer<LocalDateTime> {
private static final int SECOND_TIMESTAMP_LENGTH = 10;
private static final int MILLIS_TIMESTAMP_LENGTH = 13;
/** 支持时间的格式列表(直接解析为 LocalDateTime */
private static final List<DateTimeFormatter> DATETIME_FORMATTERS = List.of(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
@ -52,11 +56,10 @@ public class CustomLocalDateTimeDeserializer extends ValueDeserializer<LocalDate
}
text = text.trim();
// 纯数字按时间戳处理毫秒
if (text.chars().allMatch(Character::isDigit)) {
return Instant.ofEpochMilli(Long.parseLong(text))
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
// 纯数字支持秒级与毫秒级时间戳
LocalDateTime timestamp = parseTimestamp(text);
if (timestamp != null) {
return timestamp;
}
// 尝试带时间的格式
@ -78,4 +81,27 @@ public class CustomLocalDateTimeDeserializer extends ValueDeserializer<LocalDate
return null;
}
private LocalDateTime parseTimestamp(String text) {
int startIndex = text.startsWith("-") ? 1 : 0;
if (startIndex == text.length()) {
return null;
}
for (int i = startIndex; i < text.length(); i++) {
if (!Character.isDigit(text.charAt(i))) {
return null;
}
}
int digitLength = text.length() - startIndex;
long timestamp = Long.parseLong(text);
Instant instant;
if (digitLength == SECOND_TIMESTAMP_LENGTH) {
instant = Instant.ofEpochSecond(timestamp);
} else if (digitLength == MILLIS_TIMESTAMP_LENGTH) {
instant = Instant.ofEpochMilli(timestamp);
} else {
return null;
}
return instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
}
}

View File

@ -146,15 +146,7 @@ public class JsonUtils {
* @return true = 合法 JSONfalse = 非法或空
*/
public static boolean isJson(String str) {
if (StringUtils.isBlank(str)) {
return false;
}
try {
JSON_MAPPER.readTree(str);
return true;
} catch (Exception e) {
return false;
}
return readTreeQuietly(str) != null;
}
/**
@ -164,15 +156,8 @@ public class JsonUtils {
* @return true = JSON 对象
*/
public static boolean isJsonObject(String str) {
if (StringUtils.isBlank(str)) {
return false;
}
try {
JsonNode node = JSON_MAPPER.readTree(str);
return node.isObject();
} catch (Exception e) {
return false;
}
JsonNode node = readTreeQuietly(str);
return node != null && node.isObject();
}
/**
@ -185,11 +170,18 @@ public class JsonUtils {
if (StringUtils.isBlank(str)) {
return false;
}
JsonNode node = readTreeQuietly(str);
return node != null && node.isArray();
}
private static JsonNode readTreeQuietly(String str) {
if (StringUtils.isBlank(str)) {
return null;
}
try {
JsonNode node = JSON_MAPPER.readTree(str);
return node.isArray();
return JSON_MAPPER.readTree(str);
} catch (Exception e) {
return false;
return null;
}
}

View File

@ -9,10 +9,9 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.StopWatch;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.SpringUtils;
@ -28,6 +27,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.validation.BindingResult;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Array;
import java.util.*;
/**
@ -41,45 +41,40 @@ import java.util.*;
public class LogAspect {
/**
* 计时 key
* URL 最大记录长度
*/
private static final ThreadLocal<StopWatch> KEY_CACHE = new ThreadLocal<>();
private static final int MAX_URL_LENGTH = 255;
/**
* 在目标方法执行前启动耗时统计
* 客户端标识最大记录长度
*/
private static final int MAX_CLIENT_KEY_LENGTH = 32;
/**
* 日志内容最大记录长度
*/
private static final int MAX_CONTENT_LENGTH = 3800;
/**
* 执行目标方法并记录操作日志
*
* @param joinPoint 切点
* @param controllerLog 日志注解
* @return 目标方法返回值
* @throws Throwable 目标方法异常
*/
@Before(value = "@annotation(controllerLog)")
public void doBefore(JoinPoint joinPoint, Log controllerLog) {
@Around(value = "@annotation(controllerLog)")
public Object doAround(ProceedingJoinPoint joinPoint, Log controllerLog) throws Throwable {
StopWatch stopWatch = new StopWatch();
KEY_CACHE.set(stopWatch);
stopWatch.start();
}
/**
* 在目标方法正常返回后记录操作日志
*
* @param joinPoint 切点
* @param controllerLog 日志注解
* @param jsonResult 返回结果
*/
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) {
handleLog(joinPoint, controllerLog, null, jsonResult);
}
/**
* 在目标方法抛出异常后记录操作日志
*
* @param joinPoint 切点
* @param controllerLog 日志注解
* @param e 异常
*/
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) {
handleLog(joinPoint, controllerLog, e, null);
try {
Object jsonResult = joinPoint.proceed();
handleLog(joinPoint, controllerLog, null, jsonResult, stopWatch);
return jsonResult;
} catch (Exception e) {
handleLog(joinPoint, controllerLog, e, null, stopWatch);
throw e;
}
}
/**
@ -89,8 +84,9 @@ public class LogAspect {
* @param controllerLog 日志注解
* @param e 异常信息
* @param jsonResult 返回结果
* @param stopWatch 耗时统计
*/
protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult, StopWatch stopWatch) {
try {
// *========数据库日志=========*//
@ -100,8 +96,8 @@ public class LogAspect {
// 请求的地址
String ip = ServletUtils.getClientIP();
operLog.setOperIp(ip);
operLog.setOperUrl(StringUtils.substring(request.getRequestURI(), 0, 255));
operLog.setClientKey(StringUtils.substring(request.getHeader(LoginHelper.CLIENT_KEY), 0, 32));
operLog.setOperUrl(limit(request.getRequestURI(), MAX_URL_LENGTH));
operLog.setClientKey(limit(request.getHeader(LoginHelper.CLIENT_KEY), MAX_CLIENT_KEY_LENGTH));
LoginUser loginUser = LoginHelper.getLoginUser();
if (ObjectUtil.isNotNull(loginUser)) {
operLog.setOperName(loginUser.getUsername());
@ -118,7 +114,7 @@ public class LogAspect {
if (e != null) {
operLog.setStatus(BusinessStatus.FAIL.ordinal());
operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 3800));
operLog.setErrorMsg(limit(e.getMessage(), MAX_CONTENT_LENGTH));
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
@ -129,16 +125,13 @@ public class LogAspect {
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
// 设置消耗时间
StopWatch stopWatch = KEY_CACHE.get();
stopWatch.stop();
operLog.setCostTime(stopWatch.getDuration().toMillis());
// 发布事件保存数据库
SpringUtils.context().publishEvent(operLog);
} catch (Exception exp) {
// 记录本地异常日志
log.error("异常信息:{}", exp.getMessage());
} finally {
KEY_CACHE.remove();
log.error("记录操作日志异常", exp);
}
}
@ -165,7 +158,7 @@ public class LogAspect {
}
// 是否需要保存response参数和值
if (log.isSaveResponseData() && ObjectUtil.isNotNull(jsonResult)) {
operLog.setJsonResult(StringUtils.substring(JsonUtils.toJsonString(jsonResult), 0, 3800));
operLog.setJsonResult(limit(JsonUtils.toJsonString(jsonResult), MAX_CONTENT_LENGTH));
}
}
@ -182,11 +175,11 @@ public class LogAspect {
String requestMethod = operLog.getRequestMethod();
if (MapUtil.isEmpty(paramsMap) && StringUtils.equalsAny(requestMethod, HttpMethod.PUT.name(), HttpMethod.POST.name(), HttpMethod.DELETE.name())) {
String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames);
operLog.setOperParam(StringUtils.substring(params, 0, 3800));
operLog.setOperParam(limit(params, MAX_CONTENT_LENGTH));
} else {
MapUtil.removeAny(paramsMap, SystemConstants.EXCLUDE_PROPERTIES);
MapUtil.removeAny(paramsMap, excludeParamNames);
operLog.setOperParam(StringUtils.substring(JsonUtils.toJsonString(paramsMap), 0, 3800));
operLog.setOperParam(limit(JsonUtils.toJsonString(paramsMap), MAX_CONTENT_LENGTH));
}
}
@ -205,55 +198,111 @@ public class LogAspect {
String[] exclude = ArrayUtil.addAll(excludeParamNames, SystemConstants.EXCLUDE_PROPERTIES);
for (Object o : paramsArray) {
if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {
String str = "";
if (o instanceof List<?> list) {
List<Dict> list1 = new ArrayList<>();
for (Object obj : list) {
String str1 = JsonUtils.toJsonString(obj);
Dict dict = JsonUtils.parseMap(str1);
if (MapUtil.isNotEmpty(dict)) {
MapUtil.removeAny(dict, exclude);
list1.add(dict);
}
}
str = JsonUtils.toJsonString(list1);
} else {
str = JsonUtils.toJsonString(o);
Dict dict = JsonUtils.parseMap(str);
if (MapUtil.isNotEmpty(dict)) {
MapUtil.removeAny(dict, exclude);
str = JsonUtils.toJsonString(dict);
}
}
params.add(str);
params.add(serializeArg(o, exclude));
}
}
return params.toString();
}
/**
* 序列化单个方法参数并移除排除字段
*
* @param arg 参数对象
* @param exclude 排除字段名
* @return 参数日志字符串
*/
private String serializeArg(Object arg, String[] exclude) {
if (arg instanceof Collection<?> collection) {
List<Dict> list = new ArrayList<>(collection.size());
for (Object item : collection) {
Dict dict = toFilteredDict(item, exclude);
if (MapUtil.isNotEmpty(dict)) {
list.add(dict);
}
}
return JsonUtils.toJsonString(list);
}
String str = JsonUtils.toJsonString(arg);
Dict dict = JsonUtils.parseMap(str);
if (MapUtil.isNotEmpty(dict)) {
MapUtil.removeAny(dict, exclude);
return JsonUtils.toJsonString(dict);
}
return str;
}
/**
* 将参数转为已排除指定字段的字典
*
* @param value 参数值
* @param exclude 排除字段名
* @return 已过滤字段的字典
*/
private Dict toFilteredDict(Object value, String[] exclude) {
String str = JsonUtils.toJsonString(value);
Dict dict = JsonUtils.parseMap(str);
if (MapUtil.isNotEmpty(dict)) {
MapUtil.removeAny(dict, exclude);
}
return dict;
}
/**
* 限制日志字段长度
*
* @param value 原始字符串
* @param maxLength 最大长度
* @return 截断后的字符串
*/
private String limit(String value, int maxLength) {
return StringUtils.substring(value, 0, maxLength);
}
/**
* 判断是否需要过滤的对象
*
* @param o 对象信息
* @return 如果是需要过滤的对象则返回true否则返回false
*/
@SuppressWarnings("rawtypes")
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return MultipartFile.class.isAssignableFrom(clazz.getComponentType());
if (MultipartFile.class.isAssignableFrom(clazz.getComponentType())) {
return true;
}
int length = Array.getLength(o);
for (int i = 0; i < length; i++) {
if (isFilterValue(Array.get(o, i))) {
return true;
}
}
return false;
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
Collection<?> collection = (Collection<?>) o;
for (Object value : collection) {
return value instanceof MultipartFile;
if (isFilterValue(value)) {
return true;
}
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
Map<?, ?> map = (Map<?, ?>) o;
for (Object value : map.values()) {
return value instanceof MultipartFile;
if (isFilterValue(value)) {
return true;
}
}
}
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
|| o instanceof BindingResult;
return isFilterValue(o);
}
/**
* 判断是否为日志参数过滤类型
*
* @param value 参数值
* @return true 需要过滤 false 不需要过滤
*/
private boolean isFilterValue(Object value) {
return value instanceof MultipartFile || value instanceof HttpServletRequest || value instanceof HttpServletResponse
|| value instanceof BindingResult;
}
}

View File

@ -16,22 +16,16 @@ import org.springframework.context.annotation.Bean;
@EnableConfigurationProperties(MailProperties.class)
public class MailConfig {
/**
* 创建邮件账户配置
*
* @param mailProperties 邮件配置属性
* @return 邮件账户
*/
@Bean
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true")
public MailAccount mailAccount(MailProperties mailProperties) {
MailAccount account = new MailAccount();
account.setHost(mailProperties.getHost());
account.setPort(mailProperties.getPort());
account.setAuth(mailProperties.getAuth());
account.setFrom(mailProperties.getFrom());
account.setUser(mailProperties.getUser());
account.setPass(mailProperties.getPass());
account.setSocketFactoryPort(mailProperties.getPort());
account.setStarttlsEnable(mailProperties.getStarttlsEnable());
account.setSslEnable(mailProperties.getSslEnable());
account.setTimeout(mailProperties.getTimeout());
account.setConnectionTimeout(mailProperties.getConnectionTimeout());
return account;
return mailProperties.toMailAccount();
}
}

View File

@ -1,5 +1,6 @@
package org.dromara.common.mail.config.properties;
import cn.hutool.extra.mail.MailAccount;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ -72,4 +73,25 @@ public class MailProperties {
* Socket连接超时值单位毫秒缺省值不超时
*/
private Long connectionTimeout;
/**
* 转换为 Hutool 邮件账户配置
*
* @return 邮件账户配置
*/
public MailAccount toMailAccount() {
MailAccount account = new MailAccount();
account.setHost(host);
account.setPort(port);
account.setAuth(auth);
account.setFrom(from);
account.setUser(user);
account.setPass(pass);
account.setSocketFactoryPort(port);
account.setStarttlsEnable(starttlsEnable);
account.setSslEnable(sslEnable);
account.setTimeout(timeout);
account.setConnectionTimeout(connectionTimeout);
return account;
}
}

View File

@ -0,0 +1,347 @@
package org.dromara.common.mail.core;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.mail.JakartaMail;
import cn.hutool.extra.mail.MailAccount;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import java.io.File;
import java.io.InputStream;
import java.util.*;
/**
* 邮件发送构建器
*/
public final class MailBuilder {
private MailAccount mailAccount;
private boolean useGlobalSession = true;
private final List<String> tos = new ArrayList<>();
private final List<String> ccs = new ArrayList<>();
private final List<String> bccs = new ArrayList<>();
private final Map<String, InputStream> images = new LinkedHashMap<>();
private File[] files = new File[0];
private String from;
private String user;
private String pass;
private String subject;
private String content;
private boolean html;
private MailBuilder() {
}
/**
* 创建邮件构建器默认使用配置文件中的邮件账户
*
* @return 邮件构建器
*/
public static MailBuilder of() {
return new MailBuilder();
}
/**
* 创建邮件构建器使用指定邮件账户
*
* @param mailAccount 邮件账户
* @return 邮件构建器
*/
public static MailBuilder of(MailAccount mailAccount) {
return new MailBuilder().account(mailAccount);
}
/**
* 设置自定义邮件账户
*
* @param mailAccount 邮件账户
* @return 当前构建器
*/
public MailBuilder account(MailAccount mailAccount) {
this.mailAccount = Objects.requireNonNull(mailAccount, "mailAccount must not be null");
this.useGlobalSession = false;
return this;
}
/**
* 覆盖发件人
*
* @param from 发件人
* @return 当前构建器
*/
public MailBuilder from(String from) {
this.from = from;
this.useGlobalSession = false;
return this;
}
/**
* 覆盖登录用户名
*
* @param user 用户名
* @return 当前构建器
*/
public MailBuilder user(String user) {
this.user = user;
this.useGlobalSession = false;
return this;
}
/**
* 覆盖登录密码或授权码
*
* @param pass 密码或授权码
* @return 当前构建器
*/
public MailBuilder pass(String pass) {
this.pass = pass;
this.useGlobalSession = false;
return this;
}
/**
* 添加收件人多个邮箱可使用逗号或分号分隔
*
* @param addresses 收件人
* @return 当前构建器
*/
public MailBuilder to(String addresses) {
this.tos.addAll(splitAddress(addresses));
return this;
}
/**
* 添加收件人
*
* @param addresses 收件人集合
* @return 当前构建器
*/
public MailBuilder to(Collection<String> addresses) {
this.tos.addAll(normalizeAddresses(addresses));
return this;
}
/**
* 添加抄送人多个邮箱可使用逗号或分号分隔
*
* @param addresses 抄送人
* @return 当前构建器
*/
public MailBuilder cc(String addresses) {
this.ccs.addAll(splitAddress(addresses));
return this;
}
/**
* 添加抄送人
*
* @param addresses 抄送人集合
* @return 当前构建器
*/
public MailBuilder cc(Collection<String> addresses) {
this.ccs.addAll(normalizeAddresses(addresses));
return this;
}
/**
* 添加密送人多个邮箱可使用逗号或分号分隔
*
* @param addresses 密送人
* @return 当前构建器
*/
public MailBuilder bcc(String addresses) {
this.bccs.addAll(splitAddress(addresses));
return this;
}
/**
* 添加密送人
*
* @param addresses 密送人集合
* @return 当前构建器
*/
public MailBuilder bcc(Collection<String> addresses) {
this.bccs.addAll(normalizeAddresses(addresses));
return this;
}
/**
* 设置邮件标题
*
* @param subject 标题
* @return 当前构建器
*/
public MailBuilder subject(String subject) {
this.subject = subject;
return this;
}
/**
* 设置文本正文
*
* @param content 正文
* @return 当前构建器
*/
public MailBuilder text(String content) {
this.content = content;
this.html = false;
return this;
}
/**
* 设置 HTML 正文
*
* @param content 正文
* @return 当前构建器
*/
public MailBuilder html(String content) {
this.content = content;
this.html = true;
return this;
}
/**
* 设置正文
*
* @param content 正文
* @param html 是否 HTML
* @return 当前构建器
*/
public MailBuilder content(String content, boolean html) {
this.content = content;
this.html = html;
return this;
}
/**
* 添加内联图片
*
* @param cid 图片 cid
* @param inputStream 图片输入流
* @return 当前构建器
*/
public MailBuilder image(String cid, InputStream inputStream) {
if (StrUtil.isNotBlank(cid) && inputStream != null) {
this.images.put(cid, inputStream);
}
return this;
}
/**
* 添加内联图片
*
* @param imageMap 图片 cid 与输入流映射
* @return 当前构建器
*/
public MailBuilder images(Map<String, InputStream> imageMap) {
if (MapUtil.isNotEmpty(imageMap)) {
imageMap.forEach(this::image);
}
return this;
}
/**
* 设置附件
*
* @param files 附件列表
* @return 当前构建器
*/
public MailBuilder files(File... files) {
this.files = files == null ? new File[0] : files;
return this;
}
/**
* 发送邮件
*
* @return message-id
*/
public String send() {
validate();
MailAccount account = resolveMailAccount();
JakartaMail mail = JakartaMail.create(account).setUseGlobalSession(useGlobalSession);
mail.setTos(tos.toArray(new String[0]));
if (CollUtil.isNotEmpty(ccs)) {
mail.setCcs(ccs.toArray(new String[0]));
}
if (CollUtil.isNotEmpty(bccs)) {
mail.setBccs(bccs.toArray(new String[0]));
}
mail.setTitle(subject);
mail.setContent(content);
mail.setHtml(html);
mail.setFiles(files);
try {
if (MapUtil.isNotEmpty(images)) {
images.forEach(mail::addImage);
}
return mail.send();
} finally {
images.values().forEach(IoUtil::close);
}
}
private void validate() {
if (CollUtil.isEmpty(tos)) {
throw new IllegalArgumentException("邮件收件人不能为空");
}
if (StrUtil.isBlank(subject)) {
throw new IllegalArgumentException("邮件标题不能为空");
}
if (content == null) {
throw new IllegalArgumentException("邮件正文不能为空");
}
}
private MailAccount resolveMailAccount() {
MailAccount account = mailAccount;
if (account == null) {
account = SpringUtils.getBean(MailAccount.class);
}
if (StringUtils.isAllBlank(from, user, pass)) {
return account;
}
MailAccount copy = ObjectUtil.clone(account);
copy.setFrom(StringUtils.blankToDefault(from, copy.getFrom()));
copy.setUser(StringUtils.blankToDefault(user, copy.getUser()));
copy.setPass(StringUtils.blankToDefault(pass, copy.getPass()));
return copy;
}
private List<String> splitAddress(String addresses) {
if (StrUtil.isBlank(addresses)) {
return Collections.emptyList();
}
return normalizeAddresses(StrUtil.splitTrim(addresses.replace(';', ','), ','));
}
private List<String> normalizeAddresses(Collection<String> addresses) {
if (CollUtil.isEmpty(addresses)) {
return Collections.emptyList();
}
List<String> result = new ArrayList<>(addresses.size());
for (String address : addresses) {
if (StrUtil.isNotBlank(address)) {
result.add(address.trim());
}
}
return result;
}
}

View File

@ -1,473 +0,0 @@
package org.dromara.common.mail.utils;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.CharUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.mail.JakartaMail;
import cn.hutool.extra.mail.JakartaUserPassAuthenticator;
import cn.hutool.extra.mail.MailAccount;
import jakarta.mail.Authenticator;
import jakarta.mail.Session;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import java.io.File;
import java.io.InputStream;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
/**
* 邮件工具类
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MailUtils {
private static final MailAccount ACCOUNT = SpringUtils.getBean(MailAccount.class);
/**
* 获取默认邮件账户配置
*
* @return 邮件账户配置
*/
public static MailAccount getMailAccount() {
return ACCOUNT;
}
/**
* 获取邮件发送实例 (自定义发送人以及授权码)
*
* @param from 发送人
* @param user 发送人
* @param pass 授权码
* @return 邮件账户配置
*/
public static MailAccount getMailAccount(String from, String user, String pass) {
ACCOUNT.setFrom(StringUtils.blankToDefault(from, ACCOUNT.getFrom()));
ACCOUNT.setUser(StringUtils.blankToDefault(user, ACCOUNT.getUser()));
ACCOUNT.setPass(StringUtils.blankToDefault(pass, ACCOUNT.getPass()));
return ACCOUNT;
}
/**
* 使用配置文件中设置的账户发送文本邮件发送给单个或多个收件人<br>
* 多个收件人可以使用逗号,分隔也可以通过分号;分隔
*
* @param to 收件人
* @param subject 标题
* @param content 正文
* @param files 附件列表
* @return message-id
* @since 3.2.0
*/
public static String sendText(String to, String subject, String content, File... files) {
return send(to, subject, content, false, files);
}
/**
* 使用配置文件中设置的账户发送HTML邮件发送给单个或多个收件人<br>
* 多个收件人可以使用逗号,分隔也可以通过分号;分隔
*
* @param to 收件人
* @param subject 标题
* @param content 正文
* @param files 附件列表
* @return message-id
* @since 3.2.0
*/
public static String sendHtml(String to, String subject, String content, File... files) {
return send(to, subject, content, true, files);
}
/**
* 使用配置文件中设置的账户发送邮件发送单个或多个收件人<br>
* 多个收件人可以使用逗号,分隔也可以通过分号;分隔
*
* @param to 收件人
* @param subject 标题
* @param content 正文
* @param isHtml 是否为HTML
* @param files 附件列表
* @return message-id
*/
public static String send(String to, String subject, String content, boolean isHtml, File... files) {
return send(splitAddress(to), subject, content, isHtml, files);
}
/**
* 使用配置文件中设置的账户发送邮件发送单个或多个收件人<br>
* 多个收件人抄送人密送人可以使用逗号,分隔也可以通过分号;分隔
*
* @param to 收件人可以使用逗号,分隔也可以通过分号;分隔
* @param cc 抄送人可以使用逗号,分隔也可以通过分号;分隔
* @param bcc 密送人可以使用逗号,分隔也可以通过分号;分隔
* @param subject 标题
* @param content 正文
* @param isHtml 是否为HTML
* @param files 附件列表
* @return message-id
* @since 4.0.3
*/
public static String send(String to, String cc, String bcc, String subject, String content, boolean isHtml, File... files) {
return send(splitAddress(to), splitAddress(cc), splitAddress(bcc), subject, content, isHtml, files);
}
/**
* 使用配置文件中设置的账户发送文本邮件发送给多人
*
* @param tos 收件人列表
* @param subject 标题
* @param content 正文
* @param files 附件列表
* @return message-id
*/
public static String sendText(Collection<String> tos, String subject, String content, File... files) {
return send(tos, subject, content, false, files);
}
/**
* 使用配置文件中设置的账户发送HTML邮件发送给多人
*
* @param tos 收件人列表
* @param subject 标题
* @param content 正文
* @param files 附件列表
* @return message-id
* @since 3.2.0
*/
public static String sendHtml(Collection<String> tos, String subject, String content, File... files) {
return send(tos, subject, content, true, files);
}
/**
* 使用配置文件中设置的账户发送邮件发送给多人
*
* @param tos 收件人列表
* @param subject 标题
* @param content 正文
* @param isHtml 是否为HTML
* @param files 附件列表
* @return message-id
*/
public static String send(Collection<String> tos, String subject, String content, boolean isHtml, File... files) {
return send(tos, null, null, subject, content, isHtml, files);
}
/**
* 使用配置文件中设置的账户发送邮件发送给多人
*
* @param tos 收件人列表
* @param ccs 抄送人列表可以为null或空
* @param bccs 密送人列表可以为null或空
* @param subject 标题
* @param content 正文
* @param isHtml 是否为HTML
* @param files 附件列表
* @return message-id
* @since 4.0.3
*/
public static String send(Collection<String> tos, Collection<String> ccs, Collection<String> bccs, String subject, String content, boolean isHtml, File... files) {
return send(getMailAccount(), true, tos, ccs, bccs, subject, content, null, isHtml, files);
}
// ------------------------------------------------------------------------------------------------------------------------------- Custom MailAccount
/**
* 发送邮件给多人
*
* @param mailAccount 邮件认证对象
* @param to 收件人多个收件人逗号或者分号隔开
* @param subject 标题
* @param content 正文
* @param isHtml 是否为HTML格式
* @param files 附件列表
* @return message-id
* @since 3.2.0
*/
public static String send(MailAccount mailAccount, String to, String subject, String content, boolean isHtml, File... files) {
return send(mailAccount, splitAddress(to), subject, content, isHtml, files);
}
/**
* 发送邮件给多人
*
* @param mailAccount 邮件账户信息
* @param tos 收件人列表
* @param subject 标题
* @param content 正文
* @param isHtml 是否为HTML格式
* @param files 附件列表
* @return message-id
*/
public static String send(MailAccount mailAccount, Collection<String> tos, String subject, String content, boolean isHtml, File... files) {
return send(mailAccount, tos, null, null, subject, content, isHtml, files);
}
/**
* 发送邮件给多人
*
* @param mailAccount 邮件账户信息
* @param tos 收件人列表
* @param ccs 抄送人列表可以为null或空
* @param bccs 密送人列表可以为null或空
* @param subject 标题
* @param content 正文
* @param isHtml 是否为HTML格式
* @param files 附件列表
* @return message-id
* @since 4.0.3
*/
public static String send(MailAccount mailAccount, Collection<String> tos, Collection<String> ccs, Collection<String> bccs, String subject, String content, boolean isHtml, File... files) {
return send(mailAccount, false, tos, ccs, bccs, subject, content, null, isHtml, files);
}
/**
* 使用配置文件中设置的账户发送HTML邮件发送给单个或多个收件人<br>
* 多个收件人可以使用逗号,分隔也可以通过分号;分隔
*
* @param to 收件人
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param files 附件列表
* @return message-id
* @since 3.2.0
*/
public static String sendHtml(String to, String subject, String content, Map<String, InputStream> imageMap, File... files) {
return send(to, subject, content, imageMap, true, files);
}
/**
* 使用配置文件中设置的账户发送邮件发送单个或多个收件人<br>
* 多个收件人可以使用逗号,分隔也可以通过分号;分隔
*
* @param to 收件人
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param isHtml 是否为HTML
* @param files 附件列表
* @return message-id
*/
public static String send(String to, String subject, String content, Map<String, InputStream> imageMap, boolean isHtml, File... files) {
return send(splitAddress(to), subject, content, imageMap, isHtml, files);
}
/**
* 使用配置文件中设置的账户发送邮件发送单个或多个收件人<br>
* 多个收件人抄送人密送人可以使用逗号,分隔也可以通过分号;分隔
*
* @param to 收件人可以使用逗号,分隔也可以通过分号;分隔
* @param cc 抄送人可以使用逗号,分隔也可以通过分号;分隔
* @param bcc 密送人可以使用逗号,分隔也可以通过分号;分隔
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param isHtml 是否为HTML
* @param files 附件列表
* @return message-id
* @since 4.0.3
*/
public static String send(String to, String cc, String bcc, String subject, String content, Map<String, InputStream> imageMap, boolean isHtml, File... files) {
return send(splitAddress(to), splitAddress(cc), splitAddress(bcc), subject, content, imageMap, isHtml, files);
}
/**
* 使用配置文件中设置的账户发送HTML邮件发送给多人
*
* @param tos 收件人列表
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param files 附件列表
* @return message-id
* @since 3.2.0
*/
public static String sendHtml(Collection<String> tos, String subject, String content, Map<String, InputStream> imageMap, File... files) {
return send(tos, subject, content, imageMap, true, files);
}
/**
* 使用配置文件中设置的账户发送邮件发送给多人
*
* @param tos 收件人列表
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param isHtml 是否为HTML
* @param files 附件列表
* @return message-id
*/
public static String send(Collection<String> tos, String subject, String content, Map<String, InputStream> imageMap, boolean isHtml, File... files) {
return send(tos, null, null, subject, content, imageMap, isHtml, files);
}
/**
* 使用配置文件中设置的账户发送邮件发送给多人
*
* @param tos 收件人列表
* @param ccs 抄送人列表可以为null或空
* @param bccs 密送人列表可以为null或空
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param isHtml 是否为HTML
* @param files 附件列表
* @return message-id
* @since 4.0.3
*/
public static String send(Collection<String> tos, Collection<String> ccs, Collection<String> bccs, String subject, String content, Map<String, InputStream> imageMap, boolean isHtml, File... files) {
return send(getMailAccount(), true, tos, ccs, bccs, subject, content, imageMap, isHtml, files);
}
// ------------------------------------------------------------------------------------------------------------------------------- Custom MailAccount
/**
* 发送邮件给多人
*
* @param mailAccount 邮件认证对象
* @param to 收件人多个收件人逗号或者分号隔开
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param isHtml 是否为HTML格式
* @param files 附件列表
* @return message-id
* @since 3.2.0
*/
public static String send(MailAccount mailAccount, String to, String subject, String content, Map<String, InputStream> imageMap, boolean isHtml, File... files) {
return send(mailAccount, splitAddress(to), subject, content, imageMap, isHtml, files);
}
/**
* 发送邮件给多人
*
* @param mailAccount 邮件账户信息
* @param tos 收件人列表
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param isHtml 是否为HTML格式
* @param files 附件列表
* @return message-id
* @since 4.6.3
*/
public static String send(MailAccount mailAccount, Collection<String> tos, String subject, String content, Map<String, InputStream> imageMap, boolean isHtml, File... files) {
return send(mailAccount, tos, null, null, subject, content, imageMap, isHtml, files);
}
/**
* 发送邮件给多人
*
* @param mailAccount 邮件账户信息
* @param tos 收件人列表
* @param ccs 抄送人列表可以为null或空
* @param bccs 密送人列表可以为null或空
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:$IMAGE_PLACEHOLDER
* @param isHtml 是否为HTML格式
* @param files 附件列表
* @return message-id
* @since 4.6.3
*/
public static String send(MailAccount mailAccount, Collection<String> tos, Collection<String> ccs, Collection<String> bccs, String subject, String content, Map<String, InputStream> imageMap,
boolean isHtml, File... files) {
return send(mailAccount, false, tos, ccs, bccs, subject, content, imageMap, isHtml, files);
}
/**
* 根据配置文件获取邮件客户端会话
*
* @param mailAccount 邮件账户配置
* @param isSingleton 是否单例全局共享会话
* @return {@link Session}
* @since 5.5.7
*/
public static Session getSession(MailAccount mailAccount, boolean isSingleton) {
Authenticator authenticator = null;
if (mailAccount.isAuth()) {
authenticator = new JakartaUserPassAuthenticator(mailAccount.getUser(), mailAccount.getPass());
}
return isSingleton ? Session.getDefaultInstance(mailAccount.getSmtpProps(), authenticator) //
: Session.getInstance(mailAccount.getSmtpProps(), authenticator);
}
// ------------------------------------------------------------------------------------------------------------------------ Private method start
/**
* 发送邮件给多人
*
* @param mailAccount 邮件账户信息
* @param useGlobalSession 是否全局共享Session
* @param tos 收件人列表
* @param ccs 抄送人列表可以为null或空
* @param bccs 密送人列表可以为null或空
* @param subject 标题
* @param content 正文
* @param imageMap 图片与占位符占位符格式为cid:${cid}
* @param isHtml 是否为HTML格式
* @param files 附件列表
* @return message-id
* @since 4.6.3
*/
private static String send(MailAccount mailAccount, boolean useGlobalSession, Collection<String> tos, Collection<String> ccs, Collection<String> bccs, String subject, String content,
Map<String, InputStream> imageMap, boolean isHtml, File... files) {
final JakartaMail mail = JakartaMail.create(mailAccount).setUseGlobalSession(useGlobalSession);
// 可选抄送人
if (CollUtil.isNotEmpty(ccs)) {
mail.setCcs(ccs.toArray(new String[0]));
}
// 可选密送人
if (CollUtil.isNotEmpty(bccs)) {
mail.setBccs(bccs.toArray(new String[0]));
}
mail.setTos(tos.toArray(new String[0]));
mail.setTitle(subject);
mail.setContent(content);
mail.setHtml(isHtml);
mail.setFiles(files);
// 图片
if (MapUtil.isNotEmpty(imageMap)) {
for (Entry<String, InputStream> entry : imageMap.entrySet()) {
mail.addImage(entry.getKey(), entry.getValue());
// 关闭流
IoUtil.close(entry.getValue());
}
}
return mail.send();
}
/**
* 将多个联系人转为列表分隔符为逗号或者分号
*
* @param addresses 多个联系人如果为空返回null
* @return 联系人列表
*/
private static List<String> splitAddress(String addresses) {
if (StrUtil.isBlank(addresses)) {
return null;
}
List<String> result;
if (StrUtil.contains(addresses, CharUtil.COMMA)) {
result = StrUtil.splitTrim(addresses, CharUtil.COMMA);
} else if (StrUtil.contains(addresses, ';')) {
result = StrUtil.splitTrim(addresses, ';');
} else {
result = CollUtil.newArrayList(addresses);
}
return result;
}
// ------------------------------------------------------------------------------------------------------------------------ Private method end
}

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-common-mcp</artifactId>
<description>
ruoyi-common-mcp mcp模块
</description>
<dependencies>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,29 @@
package org.dromara.common.mcp.config;
import io.modelcontextprotocol.client.McpSyncClient;
import org.dromara.common.mcp.core.McpClientTemplate;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import java.util.List;
/**
* MCP 公共模块自动配置
*
* @author Lion Li
*/
@AutoConfiguration
public class McpAutoConfiguration {
/**
* MCP Client 通用操作模板
*/
@Bean
@ConditionalOnBean(McpSyncClient.class)
@ConditionalOnMissingBean
public McpClientTemplate mcpClientTemplate(List<McpSyncClient> mcpSyncClients) {
return new McpClientTemplate(mcpSyncClients);
}
}

View File

@ -0,0 +1,125 @@
package org.dromara.common.mcp.core;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import lombok.RequiredArgsConstructor;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* MCP Client 通用操作模板
* <p>
* Spring AI 已经负责 MCP Client 的创建初始化与连接管理本模板只做项目内常用调用封装
* 避免业务模块直接遍历 `McpSyncClient` 或重复处理返回结构
*
* @author Lion Li
*/
@RequiredArgsConstructor
public class McpClientTemplate {
private final List<McpSyncClient> mcpSyncClients;
/**
* 查询所有已连接 MCP Server 的工具列表
*
* @return Server 名称与工具名称列表
*/
public Map<String, List<String>> listTools() {
Map<String, List<String>> result = new LinkedHashMap<>();
for (McpSyncClient client : mcpSyncClients) {
List<String> tools = client.listTools().tools().stream()
.map(McpSchema.Tool::name)
.toList();
result.put(getServerName(client), tools);
}
return result;
}
/**
* 调用所有 MCP Server 上的同名工具
*
* @param toolName 工具名称
* @param arguments 工具参数
* @return Server 的工具调用结果
*/
public Map<String, McpToolCallResult> callTool(String toolName, Map<String, Object> arguments) {
Map<String, McpToolCallResult> result = new LinkedHashMap<>();
for (McpSyncClient client : mcpSyncClients) {
McpSchema.CallToolResult callResult = client.callTool(new McpSchema.CallToolRequest(toolName, arguments));
result.put(getServerName(client), McpToolCallResult.of(getServerName(client), callResult));
}
return result;
}
/**
* 调用指定 MCP Server 上的工具
*
* @param serverName Server 名称
* @param toolName 工具名称
* @param arguments 工具参数
* @return 工具调用结果
*/
public Optional<McpToolCallResult> callTool(String serverName, String toolName, Map<String, Object> arguments) {
return findClient(serverName)
.map(client -> McpToolCallResult.of(serverName, client.callTool(new McpSchema.CallToolRequest(toolName, arguments))));
}
/**
* 读取所有 MCP Server 上的同名资源
*
* @param uri 资源地址
* @return Server 的资源内容
*/
public Map<String, McpResourceReadResult> readResource(String uri) {
Map<String, McpResourceReadResult> result = new LinkedHashMap<>();
for (McpSyncClient client : mcpSyncClients) {
McpSchema.ReadResourceResult readResult = client.readResource(new McpSchema.ReadResourceRequest(uri));
result.put(getServerName(client), McpResourceReadResult.of(getServerName(client), readResult));
}
return result;
}
/**
* 读取指定 MCP Server 上的资源
*
* @param serverName Server 名称
* @param uri 资源地址
* @return 资源内容
*/
public Optional<McpResourceReadResult> readResource(String serverName, String uri) {
return findClient(serverName)
.map(client -> McpResourceReadResult.of(serverName, client.readResource(new McpSchema.ReadResourceRequest(uri))));
}
/**
* Server 名称查找 MCP Client
*
* @param serverName Server 名称
* @return MCP Client
*/
public Optional<McpSyncClient> findClient(String serverName) {
return mcpSyncClients.stream()
.filter(client -> serverName.equals(getServerName(client)))
.findFirst();
}
/**
* 查询已连接的 MCP Client
*
* @return MCP Client 列表
*/
public List<McpSyncClient> getClients() {
return mcpSyncClients;
}
private String getServerName(McpSyncClient client) {
McpSchema.Implementation serverInfo = client.getServerInfo();
if (serverInfo == null || serverInfo.name() == null) {
return "unknown";
}
return serverInfo.name();
}
}

View File

@ -0,0 +1,20 @@
package org.dromara.common.mcp.core;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.List;
/**
* MCP 资源读取结果
*
* @author Lion Li
*/
public record McpResourceReadResult(
String serverName,
List<McpSchema.ResourceContents> contents
) {
public static McpResourceReadResult of(String serverName, McpSchema.ReadResourceResult result) {
return new McpResourceReadResult(serverName, result.contents());
}
}

View File

@ -0,0 +1,27 @@
package org.dromara.common.mcp.core;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.List;
/**
* MCP 工具调用结果
*
* @author Lion Li
*/
public record McpToolCallResult(
String serverName,
boolean error,
List<McpSchema.Content> content,
Object structuredContent
) {
public static McpToolCallResult of(String serverName, McpSchema.CallToolResult result) {
return new McpToolCallResult(
serverName,
Boolean.TRUE.equals(result.isError()),
result.content(),
result.structuredContent()
);
}
}

View File

@ -0,0 +1 @@
org.dromara.common.mcp.config.McpAutoConfiguration

View File

@ -1,6 +1,5 @@
package org.dromara.common.mybatis.aspect;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.dromara.common.mybatis.annotation.DataPermission;
@ -14,16 +13,14 @@ import java.lang.reflect.Proxy;
*
* @author 秋辞未寒
*/
@Slf4j
public class DataPermissionAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Object target = invocation.getThis();
Method method = invocation.getMethod();
Object[] args = invocation.getArguments();
// 设置权限注解
DataPermissionHelper.setPermission(getDataPermissionAnnotation(target, method, args));
DataPermissionHelper.setPermission(getDataPermissionAnnotation(target, method));
try {
// 执行代理方法
return invocation.proceed();
@ -36,7 +33,7 @@ public class DataPermissionAdvice implements MethodInterceptor {
/**
* 获取数据权限注解
*/
private DataPermission getDataPermissionAnnotation(Object target, Method method,Object[] args){
private DataPermission getDataPermissionAnnotation(Object target, Method method) {
DataPermission dataPermission = method.getAnnotation(DataPermission.class);
// 优先获取方法上的注解
if (dataPermission != null) {
@ -46,9 +43,18 @@ public class DataPermissionAdvice implements MethodInterceptor {
Class<?> targetClass = target.getClass();
// 如果是 JDK 动态代理则获取真实的Class实例
if (Proxy.isProxyClass(targetClass)) {
targetClass = targetClass.getInterfaces()[0];
return getProxyClassDataPermission(targetClass);
}
dataPermission = targetClass.getAnnotation(DataPermission.class);
return dataPermission;
return targetClass.getAnnotation(DataPermission.class);
}
private DataPermission getProxyClassDataPermission(Class<?> targetClass) {
for (Class<?> interfaceClass : targetClass.getInterfaces()) {
DataPermission dataPermission = interfaceClass.getAnnotation(DataPermission.class);
if (dataPermission != null) {
return dataPermission;
}
}
return null;
}
}

View File

@ -1,6 +1,5 @@
package org.dromara.common.mybatis.aspect;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.mybatis.annotation.DataPermission;
import org.springframework.aop.support.StaticMethodMatcherPointcut;
@ -12,8 +11,6 @@ import java.lang.reflect.Proxy;
*
* @author 秋辞未寒
*/
@Slf4j
@SuppressWarnings("all")
public class DataPermissionPointcut extends StaticMethodMatcherPointcut {
@Override
@ -25,15 +22,23 @@ public class DataPermissionPointcut extends StaticMethodMatcherPointcut {
}
// MyBatis Mapper 就是通过 JDK 动态代理实现的所以这里需要检查是否匹配 JDK 的动态代理
Class<?> targetClassRef = targetClass;
if (Proxy.isProxyClass(targetClassRef)) {
// 数据权限注解不对继承生效但由于 SpringIOC 容器拿到的实际上是 MyBatis 代理过后的 Mapper targetClass.isAnnotationPresent 实际匹配的是 Proxy 类的注解不会查找代理类
// 所以这里不能用 targetClass.isAnnotationPresent只能用 AnnotatedElementUtils.hasAnnotation targetClass.getInterfaces()[0].isAnnotationPresent 去做匹配以检查被代理的 MapperClass 是否具有注解
// 原理JDK 动态代理本质上就是对接口进行实现然后对具体的接口实现做代理所以直接通过接口可以拿到实际的 MapperClass
targetClassRef = targetClass.getInterfaces()[0];
}
Class<?> targetClassRef = resolveTargetClass(targetClass);
return targetClassRef.isAnnotationPresent(DataPermission.class);
}
private Class<?> resolveTargetClass(Class<?> targetClass) {
if (!Proxy.isProxyClass(targetClass)) {
return targetClass;
}
for (Class<?> interfaceClass : targetClass.getInterfaces()) {
// 数据权限注解不对继承生效但由于 SpringIOC 容器拿到的实际上是 MyBatis 代理过后的 Mapper targetClass.isAnnotationPresent 实际匹配的是 Proxy 类的注解不会查找代理类
// 所以这里不能用 targetClass.isAnnotationPresent只能用 AnnotatedElementUtils.hasAnnotation targetClass.getInterfaces()[0].isAnnotationPresent 去做匹配以检查被代理的 MapperClass 是否具有注解
// 原理JDK 动态代理本质上就是对接口进行实现然后对具体的接口实现做代理所以直接通过接口可以拿到实际的 MapperClass
if (interfaceClass.isAnnotationPresent(DataPermission.class)) {
return interfaceClass;
}
}
return targetClass;
}
}

View File

@ -18,6 +18,11 @@ public record DataPermissionAccess(Set<String> perms, Set<String> roleKeys) impl
public static final DataPermissionAccess EMPTY = new DataPermissionAccess(Set.of(), Set.of());
/**
* 是否存在数据权限约束
*
* @return true 存在权限约束 false 不存在权限约束
*/
public boolean constrained() {
return CollUtil.isNotEmpty(perms) || CollUtil.isNotEmpty(roleKeys);
}

View File

@ -7,7 +7,9 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.reflect.GenericTypeUtils;
import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.toolkit.ChainWrappers;
import com.baomidou.mybatisplus.extension.toolkit.Db;
import org.apache.ibatis.logging.Log;
import org.apache.ibatis.logging.LogFactory;
@ -33,13 +35,20 @@ public interface BaseMapperPlus<T, V> extends BaseMapper<T> {
Log log = LogFactory.getLog(BaseMapperPlus.class);
ClassValue<Class<?>[]> TYPE_ARGUMENT_CACHE = new ClassValue<>() {
@Override
protected Class<?>[] computeValue(Class<?> type) {
return GenericTypeUtils.resolveTypeArguments(type, BaseMapperPlus.class);
}
};
/**
* 获取当前实例对象关联的泛型类型 V Class 对象
*
* @return 返回当前实例对象关联的泛型类型 V Class 对象
*/
default Class<V> currentVoClass() {
return (Class<V>) GenericTypeUtils.resolveTypeArguments(this.getClass(), BaseMapperPlus.class)[1];
return (Class<V>) currentMapperTypes()[1];
}
/**
@ -48,7 +57,20 @@ public interface BaseMapperPlus<T, V> extends BaseMapper<T> {
* @return 返回当前实例对象关联的泛型类型 T Class 对象
*/
default Class<T> currentModelClass() {
return (Class<T>) GenericTypeUtils.resolveTypeArguments(this.getClass(), BaseMapperPlus.class)[0];
return (Class<T>) currentMapperTypes()[0];
}
/**
* 获取当前 Mapper 的实体与 VO 泛型类型
*
* @return 泛型类型数组
*/
private Class<?>[] currentMapperTypes() {
Class<?>[] types = TYPE_ARGUMENT_CACHE.get(this.getClass());
if (types == null || types.length < 2) {
throw new IllegalStateException("无法解析 Mapper 泛型类型: " + this.getClass().getName());
}
return types;
}
/**
@ -60,6 +82,24 @@ public interface BaseMapperPlus<T, V> extends BaseMapper<T> {
return this.selectList(new QueryWrapper<>());
}
/**
* 创建当前 Mapper 绑定的 Lambda CRUD 链式操作
*
* @return Lambda CRUD 链式包装器
*/
default LambdaCrudChainWrapper<T, V> lambda() {
return new LambdaCrudChainWrapper<>(this);
}
/**
* 创建当前 Mapper 绑定的 Lambda 链式更新
*
* @return Lambda 链式更新包装器
*/
default LambdaUpdateChainWrapper<T> lambdaUpdate() {
return ChainWrappers.lambdaUpdateChain(this);
}
/**
* 批量插入实体对象集合
*

View File

@ -0,0 +1,520 @@
package org.dromara.common.mybatis.core.mapper;
import com.baomidou.mybatisplus.core.conditions.AbstractLambdaWrapper;
import com.baomidou.mybatisplus.core.conditions.SharedString;
import com.baomidou.mybatisplus.core.conditions.query.Query;
import com.baomidou.mybatisplus.core.conditions.segments.MergeSegments;
import com.baomidou.mybatisplus.core.conditions.update.Update;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.toolkit.Assert;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import org.dromara.common.mybatis.core.query.LambdaQueryCondition;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Predicate;
/**
* Mapper Lambda CRUD 链式包装器
*
* @param <T> table 泛型
* @param <V> vo 泛型
* @author Lion Li
*/
public class LambdaCrudChainWrapper<T, V> extends AbstractLambdaWrapper<T, LambdaCrudChainWrapper<T, V>>
implements Query<LambdaCrudChainWrapper<T, V>, T, SFunction<T, ?>>,
Update<LambdaCrudChainWrapper<T, V>, SFunction<T, ?>>,
LambdaQueryCondition<T, LambdaCrudChainWrapper<T, V>> {
private final BaseMapperPlus<T, V> crudMapper;
private final List<String> sqlSet;
private SharedString sqlSelect = new SharedString();
/**
* 构造 Mapper Lambda CRUD 链式包装器
*
* @param crudMapper Mapper 对象
*/
public LambdaCrudChainWrapper(BaseMapperPlus<T, V> crudMapper) {
this.crudMapper = crudMapper;
super.setEntityClass(crudMapper.currentModelClass());
super.initNeed();
this.sqlSet = new ArrayList<>();
}
/**
* 构造 Mapper Lambda CRUD 链式包装器实例
*
* @param crudMapper Mapper 对象
* @param entity 实体对象
* @param entityClass 实体类型
* @param sqlSelect 查询字段 SQL 片段
* @param sqlSet 更新 set SQL 片段集合
* @param paramNameSeq 参数名称序列
* @param paramNameValuePairs 参数名称与参数值映射
* @param mergeSegments 查询条件表达式
* @param paramAlias 参数别名
* @param lastSql SQL 尾部片段
* @param sqlComment SQL 注释片段
* @param sqlFirst SQL 起始片段
*/
LambdaCrudChainWrapper(BaseMapperPlus<T, V> crudMapper, T entity, Class<T> entityClass, SharedString sqlSelect,
List<String> sqlSet, AtomicInteger paramNameSeq, Map<String, Object> paramNameValuePairs,
MergeSegments mergeSegments, SharedString paramAlias, SharedString lastSql,
SharedString sqlComment, SharedString sqlFirst) {
this.crudMapper = crudMapper;
super.setEntity(entity);
super.setEntityClass(entityClass);
this.sqlSelect = sqlSelect == null ? new SharedString() : sqlSelect;
this.sqlSet = sqlSet == null ? new ArrayList<>() : sqlSet;
this.paramNameSeq = paramNameSeq;
this.paramNameValuePairs = paramNameValuePairs;
this.expression = mergeSegments;
this.paramAlias = paramAlias;
this.lastSql = lastSql;
this.sqlComment = sqlComment;
this.sqlFirst = sqlFirst;
}
/**
* 按条件选择查询字段
*
* @param condition 是否选择字段
* @param columns 查询字段集合
* @return this
*/
@Override
public LambdaCrudChainWrapper<T, V> select(boolean condition, List<SFunction<T, ?>> columns) {
if (condition && CollectionUtils.isNotEmpty(columns)) {
this.sqlSelect.setStringValue(columnsToString(false, columns));
}
return typedThis;
}
/**
* 选择查询字段
*
* @param columns 查询字段
* @return this
*/
@SafeVarargs
public final LambdaCrudChainWrapper<T, V> select(SFunction<T, ?>... columns) {
return select(true, CollectionUtils.toList(columns));
}
/**
* 按条件选择查询字段
*
* @param condition 是否选择字段
* @param columns 查询字段
* @return this
*/
@SafeVarargs
public final LambdaCrudChainWrapper<T, V> select(boolean condition, SFunction<T, ?>... columns) {
return select(condition, CollectionUtils.toList(columns));
}
/**
* 按字段过滤条件选择查询字段
*
* @param entityClass 实体类型
* @param predicate 字段过滤条件
* @return this
*/
@Override
public LambdaCrudChainWrapper<T, V> select(Class<T> entityClass, Predicate<TableFieldInfo> predicate) {
if (entityClass == null) {
entityClass = getEntityClass();
} else {
setEntityClass(entityClass);
}
Assert.notNull(entityClass, "entityClass can not be null");
this.sqlSelect.setStringValue(TableInfoHelper.getTableInfo(entityClass).chooseSelect(predicate));
return typedThis;
}
/**
* 获取查询字段 SQL 片段
*
* @return 查询字段 SQL 片段
*/
@Override
public String getSqlSelect() {
return sqlSelect.getStringValue();
}
/**
* 按条件设置更新字段
*
* @param condition 是否设置该字段
* @param column 字段
* @param val 字段值
* @param mapping 参数映射
* @return this
*/
@Override
public LambdaCrudChainWrapper<T, V> set(boolean condition, SFunction<T, ?> column, Object val, String mapping) {
return maybeDo(condition, () -> {
String sql = formatParam(mapping, val);
sqlSet.add(columnToString(column) + Constants.EQUALS + sql);
});
}
/**
* 值不为 null 时设置更新字段
*
* @param column 字段
* @param value
* @return this
*/
public LambdaCrudChainWrapper<T, V> setIfPresent(SFunction<T, ?> column, Object value) {
return set(value != null, column, value);
}
/**
* 文本不为空时设置更新字段
*
* @param column 字段
* @param value
* @return this
*/
public LambdaCrudChainWrapper<T, V> setIfText(SFunction<T, ?> column, String value) {
return set(StringUtils.isNotBlank(value), column, value);
}
/**
* 按条件设置自定义 SQL 更新片段
*
* @param condition 是否设置该片段
* @param setSql SQL 更新片段
* @param params SQL 片段参数
* @return this
*/
@Override
public LambdaCrudChainWrapper<T, V> setSql(boolean condition, String setSql, Object... params) {
return maybeDo(condition && StringUtils.isNotBlank(setSql), () -> sqlSet.add(formatSqlMaybeWithParam(setSql, params)));
}
/**
* 按条件设置字段自增
*
* @param condition 是否设置该字段
* @param column 字段
* @param val 自增值
* @return this
*/
@Override
public LambdaCrudChainWrapper<T, V> setIncrBy(boolean condition, SFunction<T, ?> column, Number val) {
return maybeDo(condition, () -> {
String realColumn = columnToString(column);
String realVal = val instanceof BigDecimal ? ((BigDecimal) val).toPlainString() : String.valueOf(val);
sqlSet.add(String.format("%s=%s + %s", realColumn, realColumn, realVal));
});
}
/**
* 按条件设置字段自减
*
* @param condition 是否设置该字段
* @param column 字段
* @param val 自减值
* @return this
*/
@Override
public LambdaCrudChainWrapper<T, V> setDecrBy(boolean condition, SFunction<T, ?> column, Number val) {
return maybeDo(condition, () -> {
String realColumn = columnToString(column);
String realVal = val instanceof BigDecimal ? ((BigDecimal) val).toPlainString() : String.valueOf(val);
sqlSet.add(String.format("%s=%s - %s", realColumn, realColumn, realVal));
});
}
/**
* 获取更新 set SQL 片段
*
* @return 更新 set SQL 片段
*/
@Override
public String getSqlSet() {
if (CollectionUtils.isEmpty(sqlSet)) {
return null;
}
return String.join(Constants.COMMA, sqlSet);
}
/**
* 获取查询条件 Wrapper
*
* @return this
*/
public LambdaCrudChainWrapper<T, V> getWrapper() {
return typedThis;
}
/**
* 添加 FIND_IN_SET 条件
*
* @param value 匹配值
* @param column 字段
* @return this
*/
public LambdaCrudChainWrapper<T, V> findInSet(Object value, SFunction<T, ?> column) {
return findInSet(true, value, column);
}
/**
* 添加 FIND_IN_SET 条件
*
* @param condition 是否添加该条件
* @param value 匹配值
* @param column 字段
* @return this
*/
public LambdaCrudChainWrapper<T, V> findInSet(boolean condition, Object value, SFunction<T, ?> column) {
return findInSet(condition, value, columnToString(column));
}
/**
* 值不为空时添加 FIND_IN_SET 条件
*
* @param value 匹配值
* @param column 字段
* @return this
*/
public LambdaCrudChainWrapper<T, V> findInSetIfPresent(Object value, SFunction<T, ?> column) {
return findInSet(value != null, value, column);
}
/**
* 获取当前 Wrapper
*
* @return this
*/
public LambdaCrudChainWrapper<T, V> build() {
return typedThis;
}
/**
* 查询实体列表
*
* @return 实体列表
*/
public List<T> list() {
return crudMapper.selectList(typedThis);
}
/**
* 查询实体分页记录
*
* @param page 分页条件
* @return 实体分页记录
*/
public List<T> list(IPage<T> page) {
return crudMapper.selectList(page, typedThis);
}
/**
* 查询 VO 列表
*
* @return VO 列表
*/
public List<V> voList() {
return crudMapper.selectVoList(typedThis);
}
/**
* 查询单列对象列表
*
* @return 单列对象列表
*/
public List<Object> objs() {
return crudMapper.selectObjs(typedThis);
}
/**
* 查询单列对象列表并转换类型
*
* @param mapper 转换函数
* @param <C> 转换后的类型
* @return 单列对象列表
*/
public <C> List<C> objs(Function<? super Object, C> mapper) {
return crudMapper.selectObjs(typedThis, mapper);
}
/**
* 查询单个实体
*
* @return 实体
*/
public T one() {
return crudMapper.selectOne(typedThis);
}
/**
* 查询单个实体
*
* @param throwEx 查询到多条时是否抛异常
* @return 实体
*/
public T one(boolean throwEx) {
return crudMapper.selectOne(typedThis, throwEx);
}
/**
* 查询单个实体 Optional
*
* @return Optional 实体
*/
public Optional<T> oneOpt() {
return Optional.ofNullable(one());
}
/**
* 查询单个 VO
*
* @return VO
*/
public V voOne() {
return crudMapper.selectVoOne(typedThis);
}
/**
* 查询单个 VO
*
* @param throwEx 查询到多条时是否抛异常
* @return VO
*/
public V voOne(boolean throwEx) {
return crudMapper.selectVoOne(typedThis, throwEx);
}
/**
* 查询数量
*
* @return 数量
*/
public Long count() {
return crudMapper.selectCount(typedThis);
}
/**
* 判断是否存在
*
* @return 是否存在
*/
public boolean exists() {
return crudMapper.exists(typedThis);
}
/**
* 查询实体分页
*
* @param page 分页条件
* @param <P> 分页类型
* @return 实体分页
*/
public <P extends IPage<T>> P page(P page) {
return crudMapper.selectPage(page, typedThis);
}
/**
* 查询 VO 分页
*
* @param page 分页条件
* @param <P> 分页类型
* @return VO 分页
*/
public <P extends IPage<V>> P voPage(IPage<T> page) {
return crudMapper.selectVoPage(page, typedThis);
}
/**
* 删除数据
*
* @return 是否删除成功
*/
public boolean delete() {
return deleteCount() > 0;
}
/**
* 删除数据
*
* @return 影响行数
*/
public int deleteCount() {
return crudMapper.delete(typedThis);
}
/**
* 使用 set 片段更新数据
*
* @return 是否更新成功
*/
public boolean update() {
return updateCount() > 0;
}
/**
* 使用实体和查询条件更新数据
*
* @param entity 实体
* @return 是否更新成功
*/
public boolean update(T entity) {
return updateCount(entity) > 0;
}
/**
* 使用 set 片段更新数据
*
* @return 影响行数
*/
public int updateCount() {
return crudMapper.update(typedThis);
}
/**
* 使用实体和查询条件更新数据
*
* @param entity 实体
* @return 影响行数
*/
public int updateCount(T entity) {
return crudMapper.update(entity, typedThis);
}
/**
* 创建新的链式包装器实例
*
* @return 新的链式包装器实例
*/
@Override
protected LambdaCrudChainWrapper<T, V> instance() {
return new LambdaCrudChainWrapper<>(crudMapper, getEntity(), getEntityClass(), null, null, paramNameSeq,
paramNameValuePairs, new MergeSegments(), paramAlias, SharedString.emptyString(), SharedString.emptyString(),
SharedString.emptyString());
}
/**
* 清空当前 Wrapper 状态
*/
@Override
public void clear() {
super.clear();
sqlSelect.toNull();
sqlSet.clear();
}
}

View File

@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.sql.SqlUtil;
@ -21,6 +22,7 @@ import java.util.List;
* @author Lion Li
*/
@Data
@NoArgsConstructor
public class PageQuery implements Serializable {
@Serial
@ -84,16 +86,16 @@ public class PageQuery implements Serializable {
*/
private List<OrderItem> buildOrderItem() {
if (StringUtils.isBlank(orderByColumn) || StringUtils.isBlank(isAsc)) {
return null;
return List.of();
}
String orderBy = SqlUtil.escapeOrderBySql(orderByColumn);
orderBy = StringUtils.toUnderScoreCase(orderBy);
// 兼容前端排序类型
isAsc = StringUtils.replaceEach(isAsc, new String[]{"ascending", "descending"}, new String[]{"asc", "desc"});
String orderDirection = StringUtils.replaceEach(isAsc, new String[]{"ascending", "descending"}, new String[]{"asc", "desc"});
String[] orderByArr = orderBy.split(StringUtils.SEPARATOR);
String[] isAscArr = isAsc.split(StringUtils.SEPARATOR);
String[] isAscArr = orderDirection.split(StringUtils.SEPARATOR);
if (isAscArr.length != 1 && isAscArr.length != orderByArr.length) {
throw new ServiceException("排序参数有误");
}
@ -114,11 +116,27 @@ public class PageQuery implements Serializable {
return list;
}
/**
* 获取当前页起始行号
*
* @return 起始行号
*/
@JsonIgnore
public Integer getFirstNum() {
return (pageNum - 1) * pageSize;
Integer currentPageNum = ObjectUtil.defaultIfNull(getPageNum(), DEFAULT_PAGE_NUM);
Integer currentPageSize = ObjectUtil.defaultIfNull(getPageSize(), DEFAULT_PAGE_SIZE);
if (currentPageNum <= 0) {
currentPageNum = DEFAULT_PAGE_NUM;
}
return (currentPageNum - 1) * currentPageSize;
}
/**
* 构造分页查询对象
*
* @param pageSize 分页大小
* @param pageNum 当前页码
*/
public PageQuery(Integer pageSize, Integer pageNum) {
this.pageSize = pageSize;
this.pageNum = pageNum;

View File

@ -0,0 +1,580 @@
package org.dromara.common.mybatis.core.query;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.github.yulichang.toolkit.LambdaUtils;
import com.github.yulichang.toolkit.support.ColumnCache;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import com.github.yulichang.wrapper.segments.SelectCache;
import com.github.yulichang.wrapper.segments.SelectNormal;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.helper.DataBaseHelper;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* MPJ Lambda 联表查询构造辅助类
*
* @param <T> 主表实体类型
* @author Lion Li
*/
public final class LambdaJoinQueryBuilder<T> {
/**
* MyBatis-Plus-Join Lambda 查询包装器
*/
private final MPJLambdaWrapper<T> wrapper;
/**
* 构造 MPJ Lambda 联表查询构造辅助对象
*
* @param wrapper MPJ Lambda 查询包装器
*/
LambdaJoinQueryBuilder(MPJLambdaWrapper<T> wrapper) {
this.wrapper = wrapper;
}
/**
* 添加去重查询
*
* @return 当前联表查询构造辅助对象
*/
public LambdaJoinQueryBuilder<T> distinct() {
wrapper.distinct();
return this;
}
/**
* 指定主表查询字段
*
* @param columns 查询字段
* @return 当前联表查询构造辅助对象
*/
@SafeVarargs
public final <E> LambdaJoinQueryBuilder<T> select(SFunction<E, ?>... columns) {
wrapper.select(columns);
return this;
}
/**
* 指定带表别名的同名映射查询字段
*
* @param alias 表别名
* @param columns 查询字段
* @return 当前联表查询构造辅助对象
*/
@SafeVarargs
public final <E> LambdaJoinQueryBuilder<T> select(String alias, SFunction<E, ?>... columns) {
if (columns == null || columns.length == 0) {
return this;
}
Class<?> entityClass = LambdaUtils.getEntityClass(columns[0]);
Map<String, SelectCache> cacheMap = ColumnCache.getMapField(entityClass);
for (SFunction<E, ?> column : columns) {
wrapper.getSelectColum().add(new SelectNormal(cacheMap.get(LambdaUtils.getName(column)), wrapper.getIndex(), true, alias));
}
return this;
}
/**
* 查询主表全部字段
*
* @return 当前联表查询构造辅助对象
*/
public LambdaJoinQueryBuilder<T> selectAll() {
wrapper.selectAll();
return this;
}
/**
* 查询指定实体全部字段
*
* @param entityClass 实体类型
* @return 当前联表查询构造辅助对象
*/
public LambdaJoinQueryBuilder<T> selectAll(Class<?> entityClass) {
wrapper.selectAll(entityClass);
return this;
}
/**
* 查询指定别名实体全部字段
*
* @param entityClass 实体类型
* @param alias 表别名
* @return 当前联表查询构造辅助对象
*/
public LambdaJoinQueryBuilder<T> selectAll(Class<?> entityClass, String alias) {
wrapper.selectAll(entityClass, alias);
return this;
}
/**
* 指定查询字段并映射到返回对象字段
*
* @param column 查询字段
* @param alias 返回对象字段
* @return 当前联表查询构造辅助对象
*/
public <S, X> LambdaJoinQueryBuilder<T> selectAs(SFunction<S, ?> column, SFunction<X, ?> alias) {
wrapper.selectAs(column, alias);
return this;
}
/**
* 指定带表别名的查询字段并映射到返回对象字段
*
* @param tableAlias 表别名
* @param column 查询字段
* @param alias 返回对象字段
* @return 当前联表查询构造辅助对象
*/
public <S, X> LambdaJoinQueryBuilder<T> selectAs(String tableAlias, SFunction<S, ?> column, SFunction<X, ?> alias) {
wrapper.selectAs(tableAlias, column, alias);
return this;
}
/**
* 指定 SQL 查询片段并映射到返回对象字段
*
* @param column SQL 查询片段
* @param alias 返回对象字段
* @return 当前联表查询构造辅助对象
*/
public <X> LambdaJoinQueryBuilder<T> selectAs(String column, SFunction<X, ?> alias) {
wrapper.selectAs(column, alias);
return this;
}
/**
* 添加左联表
*
* @param entityClass 关联实体类型
* @param left 关联实体字段
* @param right 当前查询字段
* @return 当前联表查询构造辅助对象
*/
public <S, X> LambdaJoinQueryBuilder<T> leftJoin(Class<S> entityClass, SFunction<S, ?> left, SFunction<X, ?> right) {
wrapper.leftJoin(entityClass, left, right);
return this;
}
/**
* 添加带别名的左联表
*
* @param entityClass 关联实体类型
* @param alias 关联表别名
* @param left 关联实体字段
* @param right 当前查询字段
* @return 当前联表查询构造辅助对象
*/
public <S, X> LambdaJoinQueryBuilder<T> leftJoin(Class<S> entityClass, String alias, SFunction<S, ?> left, SFunction<X, ?> right) {
wrapper.leftJoin(entityClass, alias, left, right);
return this;
}
/**
* 添加等于条件
*
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> eq(String alias, SFunction<S, ?> column, Object value) {
return eq(true, alias, column, value);
}
/**
* 添加等于条件
*
* @param condition 是否添加该条件
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> eq(boolean condition, String alias, SFunction<S, ?> column, Object value) {
wrapper.eq(condition, alias, column, value);
return this;
}
/**
* 值不为空时添加等于条件
*
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> eqIfPresent(String alias, SFunction<S, ?> column, Object value) {
return eq(value != null, alias, column, value);
}
/**
* 文本不为空白时添加等于条件
*
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> eqIfText(String alias, SFunction<S, ?> column, String value) {
return eq(StringUtils.isNotBlank(value), alias, column, value);
}
/**
* 添加不等于条件
*
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> ne(String alias, SFunction<S, ?> column, Object value) {
return ne(true, alias, column, value);
}
/**
* 添加不等于条件
*
* @param condition 是否添加该条件
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> ne(boolean condition, String alias, SFunction<S, ?> column, Object value) {
wrapper.ne(condition, alias, column, value);
return this;
}
/**
* 文本不为空白时添加不等于条件
*
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> neIfText(String alias, SFunction<S, ?> column, String value) {
return ne(StringUtils.isNotBlank(value), alias, column, value);
}
/**
* 添加模糊匹配条件
*
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> like(String alias, SFunction<S, ?> column, Object value) {
return like(true, alias, column, value);
}
/**
* 添加模糊匹配条件
*
* @param condition 是否添加该条件
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> like(boolean condition, String alias, SFunction<S, ?> column, Object value) {
wrapper.like(condition, alias, column, value);
return this;
}
/**
* 文本不为空白时添加模糊匹配条件
*
* @param alias 表别名
* @param column 字段
* @param value 条件值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> likeIfText(String alias, SFunction<S, ?> column, String value) {
return like(StringUtils.isNotBlank(value), alias, column, value);
}
/**
* 添加区间条件
*
* @param condition 是否添加该条件
* @param alias 表别名
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> between(boolean condition, String alias, SFunction<S, ?> column, Object begin, Object end) {
wrapper.between(condition, alias, column, begin, end);
return this;
}
/**
* 起止值均不为空时添加区间条件
*
* @param alias 表别名
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> betweenIfPresent(String alias, SFunction<S, ?> column, Object begin, Object end) {
return between(begin != null && end != null, alias, column, begin, end);
}
/**
* 从参数 Map 中读取起止值均不为空时添加区间条件
*
* @param alias 表别名
* @param column 字段
* @param params 参数 Map
* @param beginKey 起始值参数名
* @param endKey 结束值参数名
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> betweenParams(String alias, SFunction<S, ?> column, Map<String, Object> params, String beginKey, String endKey) {
if (params == null) {
return between(false, alias, column, null, null);
}
Object begin = params.get(beginKey);
Object end = params.get(endKey);
return between(begin != null && end != null, alias, column, begin, end);
}
/**
* 添加包含集合条件
*
* @param condition 是否添加该条件
* @param alias 表别名
* @param column 字段
* @param values 条件值集合
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> in(boolean condition, String alias, SFunction<S, ?> column, Collection<?> values) {
wrapper.in(condition, alias, column, values);
return this;
}
/**
* 添加包含数组条件
*
* @param condition 是否添加该条件
* @param alias 表别名
* @param column 字段
* @param values 条件值数组
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> in(boolean condition, String alias, SFunction<S, ?> column, Object... values) {
wrapper.in(condition, alias, column, values);
return this;
}
/**
* 添加包含集合条件
*
* @param alias 表别名
* @param column 字段
* @param values 条件值集合
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> in(String alias, SFunction<S, ?> column, Collection<?> values) {
return in(true, alias, column, values);
}
/**
* 添加包含数组条件
*
* @param alias 表别名
* @param column 字段
* @param values 条件值数组
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> in(String alias, SFunction<S, ?> column, Object... values) {
return in(true, alias, column, values);
}
/**
* 集合不为空时添加包含条件
*
* @param alias 表别名
* @param column 字段
* @param values 条件值集合
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> inIfNotEmpty(String alias, SFunction<S, ?> column, Collection<?> values) {
return in(values != null && !values.isEmpty(), alias, column, values);
}
/**
* 数组不为空时添加包含条件
*
* @param alias 表别名
* @param column 字段
* @param values 条件值数组
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> inIfNotEmpty(String alias, SFunction<S, ?> column, Object... values) {
return in(values != null && values.length > 0, alias, column, values);
}
/**
* 添加不包含集合条件
*
* @param condition 是否添加该条件
* @param alias 表别名
* @param column 字段
* @param values 条件值集合
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> notIn(boolean condition, String alias, SFunction<S, ?> column, Collection<?> values) {
wrapper.notIn(condition, alias, column, values);
return this;
}
/**
* 集合不为空时添加不包含条件
*
* @param alias 表别名
* @param column 字段
* @param values 条件值集合
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> notInIfNotEmpty(String alias, SFunction<S, ?> column, Collection<?> values) {
return notIn(values != null && !values.isEmpty(), alias, column, values);
}
/**
* 添加字段非空条件
*
* @param alias 表别名
* @param column 字段
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> isNotNull(String alias, SFunction<S, ?> column) {
return isNotNull(true, alias, column);
}
/**
* 添加字段非空条件
*
* @param condition 是否添加该条件
* @param alias 表别名
* @param column 字段
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> isNotNull(boolean condition, String alias, SFunction<S, ?> column) {
wrapper.isNotNull(condition, alias, column);
return this;
}
/**
* 添加升序排序
*
* @param alias 表别名
* @param column 字段
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> orderByAsc(String alias, SFunction<S, ?> column) {
wrapper.orderByAsc(alias, column);
return this;
}
/**
* 添加降序排序
*
* @param alias 表别名
* @param column 字段
* @return 当前联表查询构造辅助对象
*/
public <S> LambdaJoinQueryBuilder<T> orderByDesc(String alias, SFunction<S, ?> column) {
wrapper.orderByDesc(alias, column);
return this;
}
/**
* 拼接 SQL 片段条件
*
* @param condition 是否添加该条件
* @param applySql SQL 片段
* @param values SQL 片段参数
* @return 当前联表查询构造辅助对象
*/
public LambdaJoinQueryBuilder<T> apply(boolean condition, String applySql, Object... values) {
wrapper.apply(condition, applySql, values);
return this;
}
/**
* 添加 FIND_IN_SET 条件
*
* @param condition 是否添加该条件
* @param value 匹配值
* @param column 字段名
* @return 当前联表查询构造辅助对象
*/
public LambdaJoinQueryBuilder<T> findInSet(boolean condition, Object value, String column) {
return apply(condition, DataBaseHelper.findInSet(value, column));
}
/**
* 使用函数式方式追加 MPJ 原生能力
*
* @param consumer MPJ 查询包装器消费函数
* @return 当前联表查询构造辅助对象
*/
public LambdaJoinQueryBuilder<T> apply(Consumer<MPJLambdaWrapper<T>> consumer) {
consumer.accept(wrapper);
return this;
}
/**
* 查询列表
*
* @param resultClass 返回对象类型
* @param <R> 返回对象类型
* @return 查询结果
*/
public <R> List<R> list(Class<R> resultClass) {
return wrapper.list(resultClass);
}
/**
* 分页查询
*
* @param page 分页对象
* @param resultClass 返回对象类型
* @param <R> 返回对象类型
* @param <P> 分页类型
* @return 分页结果
*/
public <R, P extends IPage<R>> P page(P page, Class<R> resultClass) {
return wrapper.page(page, resultClass);
}
/**
* 查询数量
*
* @return 数量
*/
public Long count() {
return wrapper.count();
}
/**
* 获取底层 MPJ Lambda 查询包装器
*
* @return MPJ Lambda 查询包装器
*/
public MPJLambdaWrapper<T> build() {
return wrapper;
}
}

View File

@ -0,0 +1,990 @@
package org.dromara.common.mybatis.core.query;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
/**
* LambdaQueryWrapper 条件构造辅助类
*
* @param <T> 实体类型
* @author Lion Li
*/
public final class LambdaQueryBuilder<T> implements LambdaQueryCondition<T, LambdaQueryBuilder<T>> {
/**
* MyBatis-Plus Lambda 查询包装器
*/
private final LambdaQueryWrapper<T> wrapper;
/**
* 构造 Lambda 查询构造辅助对象
*
* @param wrapper Lambda 查询包装器
*/
LambdaQueryBuilder(LambdaQueryWrapper<T> wrapper) {
this.wrapper = wrapper;
}
/**
* 指定查询字段
*
* @param columns 查询字段
* @return 当前查询构造辅助对象
*/
@SafeVarargs
public final LambdaQueryBuilder<T> select(SFunction<T, ?>... columns) {
wrapper.select(columns);
return this;
}
/**
* 添加等于条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> eq(SFunction<T, ?> column, Object value) {
wrapper.eq(column, value);
return this;
}
/**
* 添加等于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> eq(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.eq(condition, column, value);
return this;
}
/**
* 添加不等于条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> ne(SFunction<T, ?> column, Object value) {
wrapper.ne(column, value);
return this;
}
/**
* 添加不等于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> ne(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.ne(condition, column, value);
return this;
}
/**
* 添加大于条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> gt(SFunction<T, ?> column, Object value) {
wrapper.gt(column, value);
return this;
}
/**
* 添加大于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> gt(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.gt(condition, column, value);
return this;
}
/**
* 添加大于等于条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> ge(SFunction<T, ?> column, Object value) {
wrapper.ge(column, value);
return this;
}
/**
* 添加大于等于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> ge(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.ge(condition, column, value);
return this;
}
/**
* 添加小于条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> lt(SFunction<T, ?> column, Object value) {
wrapper.lt(column, value);
return this;
}
/**
* 添加小于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> lt(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.lt(condition, column, value);
return this;
}
/**
* 添加小于等于条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> le(SFunction<T, ?> column, Object value) {
wrapper.le(column, value);
return this;
}
/**
* 添加小于等于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> le(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.le(condition, column, value);
return this;
}
/**
* 添加模糊匹配条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> like(SFunction<T, ?> column, Object value) {
wrapper.like(column, value);
return this;
}
/**
* 添加模糊匹配条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> like(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.like(condition, column, value);
return this;
}
/**
* 添加非模糊匹配条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notLike(SFunction<T, ?> column, Object value) {
wrapper.notLike(column, value);
return this;
}
/**
* 添加非模糊匹配条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notLike(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.notLike(condition, column, value);
return this;
}
/**
* 添加左模糊匹配条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> likeLeft(SFunction<T, ?> column, Object value) {
wrapper.likeLeft(column, value);
return this;
}
/**
* 添加左模糊匹配条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> likeLeft(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.likeLeft(condition, column, value);
return this;
}
/**
* 添加右模糊匹配条件
*
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> likeRight(SFunction<T, ?> column, Object value) {
wrapper.likeRight(column, value);
return this;
}
/**
* 添加右模糊匹配条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> likeRight(boolean condition, SFunction<T, ?> column, Object value) {
wrapper.likeRight(condition, column, value);
return this;
}
/**
* 添加区间条件
*
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> between(SFunction<T, ?> column, Object begin, Object end) {
wrapper.between(column, begin, end);
return this;
}
/**
* 添加区间条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> between(boolean condition, SFunction<T, ?> column, Object begin, Object end) {
wrapper.between(condition, column, begin, end);
return this;
}
/**
* 添加非区间条件
*
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notBetween(SFunction<T, ?> column, Object begin, Object end) {
wrapper.notBetween(column, begin, end);
return this;
}
/**
* 添加非区间条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notBetween(boolean condition, SFunction<T, ?> column, Object begin, Object end) {
wrapper.notBetween(condition, column, begin, end);
return this;
}
/**
* 添加全部等于条件
*
* @param params 字段和值映射
* @param null2IsNull 值为空时是否转为 IS NULL 条件
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> allEq(Map<?, ?> params, boolean null2IsNull) {
wrapper.allEq(true, (Map) params, null2IsNull);
return this;
}
/**
* 添加全部等于条件
*
* @param condition 是否添加该条件
* @param params 字段和值映射
* @param null2IsNull 值为空时是否转为 IS NULL 条件
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> allEq(boolean condition, Map<?, ?> params, boolean null2IsNull) {
wrapper.allEq(condition, (Map) params, null2IsNull);
return this;
}
/**
* 添加经过过滤的全部等于条件
*
* @param filter 字段和值过滤器
* @param params 字段和值映射
* @param null2IsNull 值为空时是否转为 IS NULL 条件
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> allEq(BiPredicate<SFunction<T, ?>, Object> filter, Map<?, ?> params, boolean null2IsNull) {
wrapper.allEq(true, (BiPredicate) filter, (Map) params, null2IsNull);
return this;
}
/**
* 添加经过过滤的全部等于条件
*
* @param condition 是否添加该条件
* @param filter 字段和值过滤器
* @param params 字段和值映射
* @param null2IsNull 值为空时是否转为 IS NULL 条件
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> allEq(boolean condition, BiPredicate<SFunction<T, ?>, Object> filter, Map<?, ?> params, boolean null2IsNull) {
wrapper.allEq(condition, (BiPredicate) filter, (Map) params, null2IsNull);
return this;
}
/**
* 添加字段为空条件
*
* @param column 字段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> isNull(SFunction<T, ?> column) {
wrapper.isNull(column);
return this;
}
/**
* 添加字段为空条件
*
* @param condition 是否添加该条件
* @param column 字段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> isNull(boolean condition, SFunction<T, ?> column) {
wrapper.isNull(condition, column);
return this;
}
/**
* 添加字段非空条件
*
* @param column 字段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> isNotNull(SFunction<T, ?> column) {
wrapper.isNotNull(column);
return this;
}
/**
* 添加字段非空条件
*
* @param condition 是否添加该条件
* @param column 字段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> isNotNull(boolean condition, SFunction<T, ?> column) {
wrapper.isNotNull(condition, column);
return this;
}
/**
* 添加包含集合条件
*
* @param column 字段
* @param values 条件值集合
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> in(SFunction<T, ?> column, Collection<?> values) {
wrapper.in(column, values);
return this;
}
/**
* 添加包含集合条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param values 条件值集合
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> in(boolean condition, SFunction<T, ?> column, Collection<?> values) {
wrapper.in(condition, column, values);
return this;
}
/**
* 添加包含数组条件
*
* @param column 字段
* @param values 条件值数组
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> in(SFunction<T, ?> column, Object... values) {
wrapper.in(column, values);
return this;
}
/**
* 添加包含数组条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param values 条件值数组
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> in(boolean condition, SFunction<T, ?> column, Object... values) {
wrapper.in(condition, column, values);
return this;
}
/**
* 添加不包含集合条件
*
* @param column 字段
* @param values 条件值集合
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notIn(SFunction<T, ?> column, Collection<?> values) {
wrapper.notIn(column, values);
return this;
}
/**
* 添加不包含集合条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param values 条件值集合
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notIn(boolean condition, SFunction<T, ?> column, Collection<?> values) {
wrapper.notIn(condition, column, values);
return this;
}
/**
* 添加不包含数组条件
*
* @param column 字段
* @param values 条件值数组
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notIn(SFunction<T, ?> column, Object... values) {
wrapper.notIn(column, values);
return this;
}
/**
* 添加不包含数组条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param values 条件值数组
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notIn(boolean condition, SFunction<T, ?> column, Object... values) {
wrapper.notIn(condition, column, values);
return this;
}
/**
* 添加分组字段
*
* @param columns 分组字段
* @return 当前查询构造辅助对象
*/
@SafeVarargs
public final LambdaQueryBuilder<T> groupBy(SFunction<T, ?>... columns) {
wrapper.groupBy(Arrays.asList(columns));
return this;
}
/**
* 添加分组字段
*
* @param condition 是否添加该分组
* @param columns 分组字段
* @return 当前查询构造辅助对象
*/
@SafeVarargs
public final LambdaQueryBuilder<T> groupBy(boolean condition, SFunction<T, ?>... columns) {
wrapper.groupBy(condition, Arrays.asList(columns));
return this;
}
/**
* 添加排序条件
*
* @param condition 是否添加该排序
* @param isAsc 是否升序
* @param column 排序字段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> orderBy(boolean condition, boolean isAsc, SFunction<T, ?> column) {
wrapper.orderBy(condition, isAsc, column);
return this;
}
/**
* 添加升序排序字段
*
* @param columns 排序字段
* @return 当前查询构造辅助对象
*/
@SafeVarargs
public final LambdaQueryBuilder<T> orderByAsc(SFunction<T, ?>... columns) {
wrapper.orderByAsc(Arrays.asList(columns));
return this;
}
/**
* 添加升序排序字段
*
* @param condition 是否添加该排序
* @param columns 排序字段
* @return 当前查询构造辅助对象
*/
@SafeVarargs
public final LambdaQueryBuilder<T> orderByAsc(boolean condition, SFunction<T, ?>... columns) {
wrapper.orderByAsc(condition, Arrays.asList(columns));
return this;
}
/**
* 添加降序排序字段
*
* @param columns 排序字段
* @return 当前查询构造辅助对象
*/
@SafeVarargs
public final LambdaQueryBuilder<T> orderByDesc(SFunction<T, ?>... columns) {
wrapper.orderByDesc(Arrays.asList(columns));
return this;
}
/**
* 添加降序排序字段
*
* @param condition 是否添加该排序
* @param columns 排序字段
* @return 当前查询构造辅助对象
*/
@SafeVarargs
public final LambdaQueryBuilder<T> orderByDesc(boolean condition, SFunction<T, ?>... columns) {
wrapper.orderByDesc(condition, Arrays.asList(columns));
return this;
}
/**
* 添加 HAVING 条件
*
* @param sqlHaving HAVING SQL 片段
* @param params SQL 片段参数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> having(String sqlHaving, Object... params) {
wrapper.having(sqlHaving, params);
return this;
}
/**
* 添加 HAVING 条件
*
* @param condition 是否添加该条件
* @param sqlHaving HAVING SQL 片段
* @param params SQL 片段参数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> having(boolean condition, String sqlHaving, Object... params) {
wrapper.having(condition, sqlHaving, params);
return this;
}
/**
* 添加 AND 嵌套条件
*
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> and(Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.and(consumer);
return this;
}
/**
* 添加 AND 嵌套条件
*
* @param condition 是否添加该条件
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> and(boolean condition, Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.and(condition, consumer);
return this;
}
/**
* 添加 OR 拼接
*
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> or() {
wrapper.or();
return this;
}
/**
* 添加 OR 嵌套条件
*
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> or(Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.or(consumer);
return this;
}
/**
* 添加 OR 嵌套条件
*
* @param condition 是否添加该条件
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> or(boolean condition, Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.or(condition, consumer);
return this;
}
/**
* 添加普通嵌套条件
*
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> nested(Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.nested(consumer);
return this;
}
/**
* 添加普通嵌套条件
*
* @param condition 是否添加该条件
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> nested(boolean condition, Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.nested(condition, consumer);
return this;
}
/**
* 添加 EXISTS 条件
*
* @param existsSql EXISTS SQL 片段
* @param values SQL 片段参数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> exists(String existsSql, Object... values) {
wrapper.exists(existsSql, values);
return this;
}
/**
* 添加 EXISTS 条件
*
* @param condition 是否添加该条件
* @param existsSql EXISTS SQL 片段
* @param values SQL 片段参数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> exists(boolean condition, String existsSql, Object... values) {
wrapper.exists(condition, existsSql, values);
return this;
}
/**
* 添加字段等于 SQL 片段条件
*
* @param column 字段
* @param inValue SQL 片段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> eqSql(SFunction<T, ?> column, String inValue) {
wrapper.eqSql(true, column, inValue);
return this;
}
/**
* 添加字段等于 SQL 片段条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param inValue SQL 片段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> eqSql(boolean condition, SFunction<T, ?> column, String inValue) {
wrapper.eqSql(condition, column, inValue);
return this;
}
/**
* 添加字段 IN SQL 片段条件
*
* @param column 字段
* @param inValue SQL 片段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> inSql(SFunction<T, ?> column, String inValue) {
wrapper.inSql(true, column, inValue);
return this;
}
/**
* 添加字段 IN SQL 片段条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param inValue SQL 片段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> inSql(boolean condition, SFunction<T, ?> column, String inValue) {
wrapper.inSql(condition, column, inValue);
return this;
}
/**
* 添加字段 NOT IN SQL 片段条件
*
* @param column 字段
* @param inValue SQL 片段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notInSql(SFunction<T, ?> column, String inValue) {
wrapper.notInSql(true, column, inValue);
return this;
}
/**
* 添加字段 NOT IN SQL 片段条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param inValue SQL 片段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notInSql(boolean condition, SFunction<T, ?> column, String inValue) {
wrapper.notInSql(condition, column, inValue);
return this;
}
/**
* 添加 NOT EXISTS 条件
*
* @param existsSql EXISTS SQL 片段
* @param values SQL 片段参数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notExists(String existsSql, Object... values) {
wrapper.notExists(existsSql, values);
return this;
}
/**
* 添加 NOT EXISTS 条件
*
* @param condition 是否添加该条件
* @param existsSql EXISTS SQL 片段
* @param values SQL 片段参数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> notExists(boolean condition, String existsSql, Object... values) {
wrapper.notExists(condition, existsSql, values);
return this;
}
/**
* 拼接 SQL 片段条件
*
* @param applySql SQL 片段
* @param values SQL 片段参数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> apply(String applySql, Object... values) {
wrapper.apply(applySql, values);
return this;
}
/**
* 拼接 SQL 片段条件
*
* @param condition 是否添加该条件
* @param applySql SQL 片段
* @param values SQL 片段参数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> apply(boolean condition, String applySql, Object... values) {
wrapper.apply(condition, applySql, values);
return this;
}
/**
* 直接对底层查询包装器应用自定义处理
*
* @param consumer 查询包装器处理函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> apply(Consumer<LambdaQueryWrapper<T>> consumer) {
consumer.accept(wrapper);
return this;
}
/**
* 使用函数式方式追加条件
*
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> func(Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.func(true, consumer);
return this;
}
/**
* 使用函数式方式追加条件
*
* @param condition 是否添加该条件
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> func(boolean condition, Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.func(condition, consumer);
return this;
}
/**
* 添加 NOT 嵌套条件
*
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> not(Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.not(true, consumer);
return this;
}
/**
* 添加 NOT 嵌套条件
*
* @param condition 是否添加该条件
* @param consumer 条件构造函数
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> not(boolean condition, Consumer<LambdaQueryWrapper<T>> consumer) {
wrapper.not(condition, consumer);
return this;
}
/**
* 添加 SQL 注释
*
* @param comment SQL 注释内容
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> comment(String comment) {
wrapper.comment(true, comment);
return this;
}
/**
* 添加 SQL 注释
*
* @param condition 是否添加该注释
* @param comment SQL 注释内容
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> comment(boolean condition, String comment) {
wrapper.comment(condition, comment);
return this;
}
/**
* 拼接 SQL 尾部片段
*
* @param lastSql SQL 尾部片段
* @return 当前查询构造辅助对象
*/
public LambdaQueryBuilder<T> last(String lastSql) {
wrapper.last(lastSql);
return this;
}
/**
* 获取底层 Lambda 查询包装器
*
* @return Lambda 查询包装器
*/
public LambdaQueryWrapper<T> build() {
return wrapper;
}
}

View File

@ -0,0 +1,443 @@
package org.dromara.common.mybatis.core.query;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.helper.DataBaseHelper;
import java.util.Collection;
import java.util.Map;
/**
* Lambda 查询常用条件扩展
*
* @param <T> 实体类型
* @param <Children> 链式返回类型
* @author Lion Li
*/
public interface LambdaQueryCondition<T, Children> {
/**
* 等于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children eq(boolean condition, SFunction<T, ?> column, Object value);
/**
* 不等于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children ne(boolean condition, SFunction<T, ?> column, Object value);
/**
* 大于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children gt(boolean condition, SFunction<T, ?> column, Object value);
/**
* 大于等于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children ge(boolean condition, SFunction<T, ?> column, Object value);
/**
* 小于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children lt(boolean condition, SFunction<T, ?> column, Object value);
/**
* 小于等于条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children le(boolean condition, SFunction<T, ?> column, Object value);
/**
* 模糊匹配条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children like(boolean condition, SFunction<T, ?> column, Object value);
/**
* 非模糊匹配条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children notLike(boolean condition, SFunction<T, ?> column, Object value);
/**
* 左模糊匹配条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children likeLeft(boolean condition, SFunction<T, ?> column, Object value);
/**
* 右模糊匹配条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
Children likeRight(boolean condition, SFunction<T, ?> column, Object value);
/**
* 区间条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 链式返回对象
*/
Children between(boolean condition, SFunction<T, ?> column, Object begin, Object end);
/**
* 非区间条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 链式返回对象
*/
Children notBetween(boolean condition, SFunction<T, ?> column, Object begin, Object end);
/**
* 包含集合条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param values 条件值集合
* @return 链式返回对象
*/
Children in(boolean condition, SFunction<T, ?> column, Collection<?> values);
/**
* 包含数组条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param values 条件值数组
* @return 链式返回对象
*/
Children in(boolean condition, SFunction<T, ?> column, Object... values);
/**
* 不包含集合条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param values 条件值集合
* @return 链式返回对象
*/
Children notIn(boolean condition, SFunction<T, ?> column, Collection<?> values);
/**
* 不包含数组条件
*
* @param condition 是否添加该条件
* @param column 字段
* @param values 条件值数组
* @return 链式返回对象
*/
Children notIn(boolean condition, SFunction<T, ?> column, Object... values);
/**
* 拼接 SQL 片段条件
*
* @param condition 是否添加该条件
* @param applySql SQL 片段
* @param values SQL 片段参数
* @return 链式返回对象
*/
Children apply(boolean condition, String applySql, Object... values);
/**
* 值不为空时添加等于条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children eqIfPresent(SFunction<T, ?> column, Object value) {
return eq(value != null, column, value);
}
/**
* 文本不为空白时添加等于条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children eqIfText(SFunction<T, ?> column, String value) {
return eq(StringUtils.isNotBlank(value), column, value);
}
/**
* 值不为空时添加不等于条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children neIfPresent(SFunction<T, ?> column, Object value) {
return ne(value != null, column, value);
}
/**
* 文本不为空白时添加不等于条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children neIfText(SFunction<T, ?> column, String value) {
return ne(StringUtils.isNotBlank(value), column, value);
}
/**
* 值不为空时添加大于条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children gtIfPresent(SFunction<T, ?> column, Object value) {
return gt(value != null, column, value);
}
/**
* 值不为空时添加大于等于条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children geIfPresent(SFunction<T, ?> column, Object value) {
return ge(value != null, column, value);
}
/**
* 值不为空时添加小于条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children ltIfPresent(SFunction<T, ?> column, Object value) {
return lt(value != null, column, value);
}
/**
* 值不为空时添加小于等于条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children leIfPresent(SFunction<T, ?> column, Object value) {
return le(value != null, column, value);
}
/**
* 文本不为空白时添加模糊匹配条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children likeIfText(SFunction<T, ?> column, String value) {
return like(StringUtils.isNotBlank(value), column, value);
}
/**
* 文本不为空白时添加非模糊匹配条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children notLikeIfText(SFunction<T, ?> column, String value) {
return notLike(StringUtils.isNotBlank(value), column, value);
}
/**
* 文本不为空白时添加左模糊匹配条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children likeLeftIfText(SFunction<T, ?> column, String value) {
return likeLeft(StringUtils.isNotBlank(value), column, value);
}
/**
* 文本不为空白时添加右模糊匹配条件
*
* @param column 字段
* @param value 条件值
* @return 链式返回对象
*/
default Children likeRightIfText(SFunction<T, ?> column, String value) {
return likeRight(StringUtils.isNotBlank(value), column, value);
}
/**
* 起止值均不为空时添加区间条件
*
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 链式返回对象
*/
default Children betweenIfPresent(SFunction<T, ?> column, Object begin, Object end) {
return between(begin != null && end != null, column, begin, end);
}
/**
* 从参数 Map 中读取起止值均不为空时添加区间条件
*
* @param column 字段
* @param params 参数 Map
* @param beginKey 起始值参数名
* @param endKey 结束值参数名
* @return 链式返回对象
*/
default Children betweenParams(SFunction<T, ?> column, Map<String, Object> params, String beginKey, String endKey) {
if (params == null) {
return between(false, column, null, null);
}
Object begin = params.get(beginKey);
Object end = params.get(endKey);
return between(begin != null && end != null, column, begin, end);
}
/**
* 起止值均不为空时添加非区间条件
*
* @param column 字段
* @param begin 起始值
* @param end 结束值
* @return 链式返回对象
*/
default Children notBetweenIfPresent(SFunction<T, ?> column, Object begin, Object end) {
return notBetween(begin != null && end != null, column, begin, end);
}
/**
* 集合不为空时添加包含条件
*
* @param column 字段
* @param values 条件值集合
* @return 链式返回对象
*/
default Children inIfNotEmpty(SFunction<T, ?> column, Collection<?> values) {
return in(values != null && !values.isEmpty(), column, values);
}
/**
* 数组不为空时添加包含条件
*
* @param column 字段
* @param values 条件值数组
* @return 链式返回对象
*/
default Children inIfNotEmpty(SFunction<T, ?> column, Object... values) {
return in(values != null && values.length > 0, column, values);
}
/**
* 集合不为空时添加不包含条件
*
* @param column 字段
* @param values 条件值集合
* @return 链式返回对象
*/
default Children notInIfNotEmpty(SFunction<T, ?> column, Collection<?> values) {
return notIn(values != null && !values.isEmpty(), column, values);
}
/**
* 数组不为空时添加不包含条件
*
* @param column 字段
* @param values 条件值数组
* @return 链式返回对象
*/
default Children notInIfNotEmpty(SFunction<T, ?> column, Object... values) {
return notIn(values != null && values.length > 0, column, values);
}
/**
* 添加 FIND_IN_SET 条件
*
* @param value 匹配值
* @param column 字段名
* @return 链式返回对象
*/
default Children findInSet(Object value, String column) {
return findInSet(true, value, column);
}
/**
* 添加 FIND_IN_SET 条件
*
* @param condition 是否添加该条件
* @param value 匹配值
* @param column 字段名
* @return 链式返回对象
*/
default Children findInSet(boolean condition, Object value, String column) {
return apply(condition, DataBaseHelper.findInSet(value, column));
}
/**
* 值不为空时添加 FIND_IN_SET 条件
*
* @param value 匹配值
* @param column 字段名
* @return 链式返回对象
*/
default Children findInSetIfPresent(Object value, String column) {
return findInSet(value != null, value, column);
}
}

View File

@ -0,0 +1,50 @@
package org.dromara.common.mybatis.core.query;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.github.yulichang.toolkit.JoinWrappers;
/**
* MyBatis-Plus 查询构造器入口
*
* @author Lion Li
*/
public final class QueryBuilder {
private QueryBuilder() {
}
/**
* 创建 Lambda 查询构造辅助对象
*
* @param entityClass 实体类型
* @param <T> 实体类型
* @return Lambda 查询构造辅助对象
*/
public static <T> LambdaQueryBuilder<T> lambda(Class<T> entityClass) {
return new LambdaQueryBuilder<>(Wrappers.lambdaQuery(entityClass));
}
/**
* 创建 MPJ Lambda 联表查询构造辅助对象
*
* @param entityClass 主表实体类型
* @param <T> 主表实体类型
* @return MPJ Lambda 联表查询构造辅助对象
*/
public static <T> LambdaJoinQueryBuilder<T> lambdaJoin(Class<T> entityClass) {
return new LambdaJoinQueryBuilder<>(JoinWrappers.lambda(entityClass));
}
/**
* 创建带主表别名的 MPJ Lambda 联表查询构造辅助对象
*
* @param alias 主表别名
* @param entityClass 主表实体类型
* @param <T> 主表实体类型
* @return MPJ Lambda 联表查询构造辅助对象
*/
public static <T> LambdaJoinQueryBuilder<T> lambdaJoin(String alias, Class<T> entityClass) {
return new LambdaJoinQueryBuilder<>(JoinWrappers.lambda(alias, entityClass));
}
}

View File

@ -2,18 +2,12 @@ package org.dromara.common.mybatis.helper;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.context.model.SaStorage;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.plugins.IgnoreStrategy;
import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.dromara.common.mybatis.core.domain.DataPermissionAccess;
import org.dromara.common.core.utils.reflect.ReflectUtils;
import org.dromara.common.mybatis.annotation.DataPermission;
import org.dromara.common.mybatis.core.domain.DataPermissionAccess;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
@ -31,8 +25,6 @@ public class DataPermissionHelper {
private static final String DATA_PERMISSION_KEY = "data:permission";
private static final String ACCESS_KEY = "data:permission:access";
private static final ThreadLocal<Deque<Integer>> REENTRANT_IGNORE = ThreadLocal.withInitial(ArrayDeque::new);
private static final ThreadLocal<DataPermission> PERMISSION_CACHE = new ThreadLocal<>();
/**
@ -120,68 +112,17 @@ public class DataPermissionHelper {
throw new IllegalStateException("data permission context type exception");
}
/**
* 获取当前忽略策略
*
* @return 忽略策略
*/
private static IgnoreStrategy getIgnoreStrategy() {
Object ignoreStrategyLocal = ReflectUtils.getStaticFieldValue(ReflectUtils.getField(InterceptorIgnoreHelper.class, "IGNORE_STRATEGY_LOCAL"));
if (ignoreStrategyLocal instanceof ThreadLocal<?> IGNORE_STRATEGY_LOCAL) {
if (IGNORE_STRATEGY_LOCAL.get() instanceof IgnoreStrategy ignoreStrategy) {
return ignoreStrategy;
}
}
return null;
}
/**
* 开启忽略数据权限(开启后需手动调用 {@link #disableIgnore()} 关闭)
*/
private static void enableIgnore() {
IgnoreStrategy ignoreStrategy = getIgnoreStrategy();
if (ObjectUtil.isNull(ignoreStrategy)) {
InterceptorIgnoreHelper.handle(IgnoreStrategy.builder().dataPermission(true).build());
} else {
ignoreStrategy.setDataPermission(true);
}
Deque<Integer> reentrantStack = REENTRANT_IGNORE.get();
reentrantStack.push(reentrantStack.size() + 1);
}
/**
* 关闭忽略数据权限
*/
private static void disableIgnore() {
IgnoreStrategy ignoreStrategy = getIgnoreStrategy();
if (ObjectUtil.isNotNull(ignoreStrategy)) {
boolean noOtherIgnoreStrategy = !Boolean.TRUE.equals(ignoreStrategy.getDynamicTableName())
&& !Boolean.TRUE.equals(ignoreStrategy.getBlockAttack())
&& !Boolean.TRUE.equals(ignoreStrategy.getIllegalSql())
&& !Boolean.TRUE.equals(ignoreStrategy.getTenantLine())
&& CollectionUtil.isEmpty(ignoreStrategy.getOthers());
Deque<Integer> reentrantStack = REENTRANT_IGNORE.get();
boolean empty = reentrantStack.isEmpty() || reentrantStack.pop() == 1;
if (noOtherIgnoreStrategy && empty) {
InterceptorIgnoreHelper.clearIgnoreStrategy();
} else if (empty) {
ignoreStrategy.setDataPermission(false);
}
}
}
/**
* 在忽略数据权限中执行
*
* @param handle 处理执行方法
*/
public static void ignore(Runnable handle) {
enableIgnore();
DataPermissionIgnoreContext.enable();
try {
handle.run();
} finally {
disableIgnore();
DataPermissionIgnoreContext.disable();
}
}
@ -192,11 +133,11 @@ public class DataPermissionHelper {
* @return 执行结果
*/
public static <T> T ignore(Supplier<T> handle) {
enableIgnore();
DataPermissionIgnoreContext.enable();
try {
return handle.get();
} finally {
disableIgnore();
DataPermissionIgnoreContext.disable();
}
}

View File

@ -0,0 +1,73 @@
package org.dromara.common.mybatis.helper;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.plugins.IgnoreStrategy;
import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.dromara.common.core.utils.reflect.ReflectUtils;
import java.util.ArrayDeque;
import java.util.Deque;
/**
* 数据权限忽略状态适配器
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
final class DataPermissionIgnoreContext {
private static final ThreadLocal<Deque<Boolean>> DATA_PERMISSION_STACK = ThreadLocal.withInitial(ArrayDeque::new);
/**
* 开启忽略数据权限
*/
static void enable() {
IgnoreStrategy ignoreStrategy = getIgnoreStrategy();
DATA_PERMISSION_STACK.get().push(ignoreStrategy != null && Boolean.TRUE.equals(ignoreStrategy.getDataPermission()));
if (ObjectUtil.isNull(ignoreStrategy)) {
InterceptorIgnoreHelper.handle(IgnoreStrategy.builder().dataPermission(true).build());
} else {
ignoreStrategy.setDataPermission(true);
}
}
/**
* 关闭忽略数据权限并恢复进入前的数据权限忽略状态
*/
static void disable() {
Deque<Boolean> stack = DATA_PERMISSION_STACK.get();
boolean previousDataPermission = !stack.isEmpty() && stack.pop();
IgnoreStrategy ignoreStrategy = getIgnoreStrategy();
if (ObjectUtil.isNotNull(ignoreStrategy)) {
if (previousDataPermission) {
ignoreStrategy.setDataPermission(true);
} else if (isOnlyDataPermissionIgnored(ignoreStrategy) && stack.isEmpty()) {
InterceptorIgnoreHelper.clearIgnoreStrategy();
} else {
ignoreStrategy.setDataPermission(false);
}
}
if (stack.isEmpty()) {
DATA_PERMISSION_STACK.remove();
}
}
private static IgnoreStrategy getIgnoreStrategy() {
Object ignoreStrategyLocal = ReflectUtils.getStaticFieldValue(ReflectUtils.getField(InterceptorIgnoreHelper.class, "IGNORE_STRATEGY_LOCAL"));
if (ignoreStrategyLocal instanceof ThreadLocal<?> ignoreStrategyThreadLocal
&& ignoreStrategyThreadLocal.get() instanceof IgnoreStrategy ignoreStrategy) {
return ignoreStrategy;
}
return null;
}
private static boolean isOnlyDataPermissionIgnored(IgnoreStrategy ignoreStrategy) {
return !Boolean.TRUE.equals(ignoreStrategy.getDynamicTableName())
&& !Boolean.TRUE.equals(ignoreStrategy.getBlockAttack())
&& !Boolean.TRUE.equals(ignoreStrategy.getIllegalSql())
&& !Boolean.TRUE.equals(ignoreStrategy.getTenantLine())
&& CollectionUtil.isEmpty(ignoreStrategy.getOthers());
}
}

View File

@ -37,7 +37,9 @@ import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiFunction;
import java.util.function.Consumer;
@ -84,6 +86,12 @@ public abstract class AbstractOssClientImpl implements OssClient {
*/
protected ExecutorService asyncExecutor;
/**
* 构造 S3 存储客户端基础实现
*
* @param clientId 客户端 ID
* @param config S3 存储客户端配置
*/
public AbstractOssClientImpl(String clientId, OssClientConfig config) {
Assert.notNull(config, () -> S3StorageException.form("S3StorageClientConfig must not be null"));
// 如果没有设置存储客户端ID则随机生成一个
@ -119,10 +127,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
// 将状态转为已初始化
initialized.compareAndSet(false, true);
} catch (Exception e) {
if (e instanceof S3StorageException) {
throw e;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -168,10 +173,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
.handleAsync(handleAsyncAction)
.join();
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -229,10 +231,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
options.setLength(file.length());
return bucketUpload(bucket, key, file.getChannel(), -1L, options);
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -253,10 +252,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
}
return bucketUpload(bucket, key, in, size, options);
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -282,10 +278,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
try (ByteArrayInputStream in = new ByteArrayInputStream(data)) {
return bucketUpload(bucket, key, in, data.length, options);
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -341,10 +334,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
.join()
.result();
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -356,25 +346,18 @@ public abstract class AbstractOssClientImpl implements OssClient {
publisher.subscribe(downloadSubscriber).join();
return getObjectResult;
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@Override
public <T> T bucketDownload(String bucket, String key, BiFunction<GetObjectResult, InputStream, T> downloadTransformer) {
try {
ResponseInputStream<GetObjectResponse> responseInputStream = doCustomDownload(builder -> builder.bucket(bucket).key(key), AsyncResponseTransformer.toBlockingInputStream(), null);
try (ResponseInputStream<GetObjectResponse> responseInputStream = doCustomDownload(builder -> builder.bucket(bucket).key(key), AsyncResponseTransformer.toBlockingInputStream(), null)) {
GetObjectResponse response = responseInputStream.response();
GetObjectResult getObjectResult = buildGetObjectResult(key, response);
return downloadTransformer.apply(getObjectResult, responseInputStream);
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -383,10 +366,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
try (OutputStream out = Files.newOutputStream(path)) {
return bucketDownload(bucket, key, out);
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -395,10 +375,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
try (FileOutputStream out = new FileOutputStream(file)) {
return bucketDownload(bucket, key, out);
} catch (Exception e) {
if (e instanceof S3StorageException ex) {
throw ex;
}
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -438,7 +415,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
s3AsyncClient.deleteObject(builder -> builder.bucket(bucket).key(key)).join();
return true;
} catch (Exception e) {
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -452,7 +429,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
.url()
.toExternalForm();
} catch (Exception e) {
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -466,7 +443,7 @@ public abstract class AbstractOssClientImpl implements OssClient {
.url()
.toExternalForm();
} catch (Exception e) {
throw S3StorageException.form(e);
throw toStorageException(e);
}
}
@ -623,6 +600,22 @@ public abstract class AbstractOssClientImpl implements OssClient {
return fileName.substring(index);
}
private S3StorageException toStorageException(Throwable e) {
Throwable cause = unwrapAsyncException(e);
if (cause instanceof S3StorageException ex) {
return ex;
}
return S3StorageException.form(cause);
}
private Throwable unwrapAsyncException(Throwable e) {
Throwable cause = e;
while ((cause instanceof CompletionException || cause instanceof ExecutionException) && cause.getCause() != null) {
cause = cause.getCause();
}
return cause;
}
@Override
public void close() throws Exception {
if (s3TransferManager != null) {

View File

@ -5,6 +5,8 @@ import org.dromara.common.oss.config.OssClientConfig;
import org.dromara.common.oss.exception.S3StorageException;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
import software.amazon.awssdk.core.checksums.ResponseChecksumValidation;
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3AsyncClient;
@ -23,6 +25,12 @@ import java.util.concurrent.Executors;
*/
public class DefaultOssClientImpl extends AbstractOssClientImpl {
/**
* 构造默认 S3 存储客户端
*
* @param clientId 客户端 ID
* @param config S3 存储客户端配置
*/
public DefaultOssClientImpl(String clientId, OssClientConfig config) {
super(clientId, config);
}
@ -44,6 +52,10 @@ public class DefaultOssClientImpl extends AbstractOssClientImpl {
// 创建 AWS 认证信息
StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey));
S3Configuration s3Configuration = S3Configuration.builder()
.chunkedEncodingEnabled(false)
.pathStyleAccessEnabled(usePathStyleAccess)
.build();
// 创建AWS基于 Netty S3 客户端
this.s3AsyncClient = S3AsyncClient.builder()
@ -51,6 +63,9 @@ public class DefaultOssClientImpl extends AbstractOssClientImpl {
.endpointOverride(URI.create(endpointUrl))
.region(region)
.forcePathStyle(usePathStyleAccess)
.serviceConfiguration(s3Configuration)
.requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED)
.responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED)
.httpClient(
NettyNioAsyncHttpClient.builder()
.connectionTimeout(Duration.ofSeconds(60))
@ -69,13 +84,7 @@ public class DefaultOssClientImpl extends AbstractOssClientImpl {
.region(region)
.credentialsProvider(credentialsProvider)
.endpointOverride(URI.create(domainUrl))
.serviceConfiguration(
// 创建 S3 配置对象
S3Configuration.builder()
.chunkedEncodingEnabled(false)
.pathStyleAccessEnabled(usePathStyleAccess)
.build()
)
.serviceConfiguration(s3Configuration)
.build();
// 创建异步调度器对象

View File

@ -32,7 +32,7 @@ public record OssAsyncExecutorConfig(
* 默认异步执行器配置
*/
public static final OssAsyncExecutorConfig DEFAULT = OssAsyncExecutorConfig.builder()
.enabledVirtualThread(false)
.enabledVirtualThread(true)
.corePoolSize(DEFAULT_CORE_POOL_SIZE)
.build();

Some files were not shown because too many files have changed in this diff Show More