mirror of
https://gitee.com/dromara/RuoYi-Vue-Plus.git
synced 2026-09-20 02:08:15 +08:00
Compare commits
No commits in common. "6.X" and "v6.0.0-BETA" have entirely different histories.
6.X
...
v6.0.0-BET
@ -61,4 +61,4 @@ description: 标准后端 CRUD 专家。用于当前项目中的新增单表 CRU
|
||||
- BO / VO / Entity 是否职责分离
|
||||
- 导出、分页、删除前校验是否齐全
|
||||
- 是否只是 generator 裸产物,如果是要继续补齐项目约定
|
||||
- 前端 `api/types` 和 Vue `index.vue` 或 React `index.tsx` 如需同步,接口路径、返回结构、日期范围参数要与后端一致
|
||||
- 前端 `api/types/index.vue` 如需同步,接口路径、返回结构、日期范围参数要与后端一致
|
||||
|
||||
@ -12,12 +12,12 @@ description: 后端工程总入口。用于在当前 RuoYi-Vue-Plus 项目中识
|
||||
3. 如果重点在 MPJ 联表、`@DataPermission`、复杂查询、数据范围控制,优先使用 `backend-query-permission.md` 的规则。
|
||||
4. 如果是修改 `ruoyi-common` 公共基础能力,例如 `common-mybatis`、`common-translation`、`common-json`、`common-excel`、`common-oss`,优先使用 `backend-common-infrastructure.md` 的规则。
|
||||
5. 如果只要求补充或修正 JavaDoc 注释,优先使用 `backend-javadoc.md` 的规则。
|
||||
6. 如果同时要求同步前端接口或前端页面骨架,先确认目标前端是 Vue 还是 React,保持后端路由与 generator 风格稳定,便于前端 agent 对接。
|
||||
6. 如果同时要求同步前端接口或前端页面骨架,保持后端路由与 generator 风格稳定,便于前端 agent 对接。
|
||||
|
||||
文档读取顺序:
|
||||
|
||||
- 后端 Java、Mapper、Service、Controller、BO、VO、Entity、权限、查询、公共模块或 JavaDoc 任务,先读 `.codex/skills/ruoyi-plus-ai-coding/references/backend.md`。
|
||||
- 同步前端 Vue、React、TypeScript、api、types 或页面骨架时,再读 `.codex/skills/ruoyi-plus-ai-coding/references/frontend.md`。
|
||||
- 同步前端 Vue、TypeScript、api、types 或页面骨架时,再读 `.codex/skills/ruoyi-plus-ai-coding/references/frontend.md`。
|
||||
- 任务边界不清晰或需要标准场景示例时,再读 `.codex/skills/ruoyi-plus-ai-coding/references/examples.md`。
|
||||
- 只读取当前任务相关的 reference,不一次性展开全部文档。
|
||||
- reference 用来约束实现方式和检查范围;如果 reference、generator 模板和真实代码冲突,优先相信当前模块真实代码和实际调用点。
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ruoyi-plus-ai-coding
|
||||
description: 在仓库内按代码生成器模板、项目 reference 文档和既有约定生成或修改代码。用于新增或修改 CRUD 模块、controller/service/mapper/BO/VO/entity、MyBatis-Plus/MPJ 查询、数据权限、缓存、翻译/JSON 增强、公共 common 模块能力、JavaDoc 注释,以及与后端接口配套的 Vue 或 React 前端页面、types 和 api 文件;触发后应先按任务类型读取对应 references,再阅读目标模块真实代码和 generator 模板。
|
||||
description: 在仓库内按代码生成器模板、项目 reference 文档和既有约定生成或修改代码。用于新增或修改 CRUD 模块、controller/service/mapper/BO/VO/entity、MyBatis-Plus/MPJ 查询、数据权限、缓存、翻译/JSON 增强、公共 common 模块能力、JavaDoc 注释,以及与后端接口配套的 Vue 3 + TypeScript 页面、types 和 api 文件;触发后应先按任务类型读取对应 references,再阅读目标模块真实代码和 generator 模板。
|
||||
---
|
||||
|
||||
# RuoYi Plus AI 编码规范
|
||||
@ -17,7 +17,7 @@ description: 在仓库内按代码生成器模板、项目 reference 文档和
|
||||
- 修改 `ruoyi-common` 公共能力,例如 mybatis 查询构造器、translation、json enhance、excel、oss、redis、web 配置。
|
||||
- 补充或修正 JavaDoc 注释,尤其是公共 API、接口、BO/VO/Entity 字段、Mapper 默认方法、Service/Controller 方法。
|
||||
- 在系统、监控、工作流、demo 等模块内按现有约定扩展业务代码。
|
||||
- 为后端新增接口同步补前端 `api/types` 和 Vue `index.vue` 或 React `index.tsx` 页面骨架。
|
||||
- 为后端新增接口同步补前端 `api/types/index.vue` 骨架。
|
||||
|
||||
## 不适用场景
|
||||
|
||||
@ -45,7 +45,7 @@ description: 在仓库内按代码生成器模板、项目 reference 文档和
|
||||
使用本 skill 时,先按任务类型读取适用 reference,不一次性展开所有文档:
|
||||
|
||||
- 后端 Java、Mapper、Service、Controller、BO、VO、Entity、权限、查询、公共模块或 JavaDoc 任务,先读 [references/backend.md](references/backend.md)。
|
||||
- 前端 Vue、React、TypeScript、api、types 或页面任务,先读 [references/frontend.md](references/frontend.md)。
|
||||
- 前端 Vue、TypeScript、api、types 或页面任务,先读 [references/frontend.md](references/frontend.md)。
|
||||
- 不确定任务边界、需要标准调用方式或需要对照典型场景时,再读 [references/examples.md](references/examples.md)。
|
||||
|
||||
reference 用来约束实现方式和自检范围;发生冲突时,仍以当前模块真实代码和实际调用点为准。
|
||||
@ -71,7 +71,7 @@ Java、MyBatis-Plus、BO/VO/entity、controller、mapper、service 的具体规
|
||||
|
||||
## 前端规则
|
||||
|
||||
Vue 3、React、TypeScript API 文件、生成式列表页、表单状态、字典和日期范围约定见 [references/frontend.md](references/frontend.md)。
|
||||
Vue 3、TypeScript API 文件、生成式列表页、表单状态、字典和日期范围约定见 [references/frontend.md](references/frontend.md)。
|
||||
|
||||
## 使用案例
|
||||
|
||||
@ -163,7 +163,7 @@ Vue 3、React、TypeScript API 文件、生成式列表页、表单状态、字
|
||||
- BO 使用 `@AutoMapper(target = Entity.class, reverseConvertGenerate = false)`。
|
||||
- VO 使用 `@AutoMapper(target = Entity.class)`。
|
||||
- 前端 API 路径与后端路由完全对应。
|
||||
- 前端列表页继续使用对应前端工程已有工具:Vue 侧如 `proxy?.addDateRange`、`proxy?.$modal`、`proxy?.download`、`useDict`、`pagination`;React 侧如 `ProTable`、`ModalForm`、`useTableSelection`、`useDateRangeQuery`、`useTableExport`。
|
||||
- 前端列表页继续使用仓库里的 `proxy?.addDateRange`、`proxy?.$modal`、`proxy?.download`、`useDict`、`pagination` 等工具。
|
||||
|
||||
## 推荐提问方式
|
||||
|
||||
|
||||
@ -62,13 +62,13 @@
|
||||
### 用户提问示例
|
||||
|
||||
```text
|
||||
使用 $ruoyi-plus-ai-coding 为 monitor/cache 新增一个导出接口,并同步补齐 Vue 或 React 前端 api/types 调用骨架。
|
||||
使用 $ruoyi-plus-ai-coding 为 monitor/cache 新增一个导出接口,并同步补齐前端 api/types 调用骨架。
|
||||
```
|
||||
|
||||
### 期望执行方式
|
||||
|
||||
- 先补后端 `controller/service`。
|
||||
- 再根据后端路由和目标前端类型补 `src/api` 或 generator 风格的前端骨架。
|
||||
- 再根据后端路由补前端 `src/api` 或 generator 风格的前端骨架。
|
||||
- 保证导出接口路径和前端下载调用一致。
|
||||
|
||||
## 案例 6:推荐的高质量任务描述
|
||||
|
||||
@ -6,10 +6,7 @@
|
||||
- 默认 Vue 模板在 `fm/vue`,React 模板在 `fm/react`
|
||||
- 前端工程中与目标模块最接近的现有页面
|
||||
|
||||
当前 boot4 仓库通常只含后端与 generator 前端模板;如果前端工程不在当前仓库根目录,先以 generator 模板约定为准,再对照用户提供的前端工程或官方前端分支:
|
||||
|
||||
- Vue 前端:`https://gitee.com/JavaLionLi/plus-ui/tree/6.X-Vue`
|
||||
- React 前端:`https://gitee.com/JavaLionLi/plus-ui/tree/6.X-React`
|
||||
当前 boot4 仓库通常只含后端与 generator 前端模板;如果前端工程不在当前 root,先以 generator 模板约定为准,再对照用户提供的前端目录或相邻仓库。
|
||||
|
||||
## 前端模板选择规则
|
||||
|
||||
@ -20,10 +17,11 @@
|
||||
|
||||
## API 文件规则
|
||||
|
||||
- Vue 模板从 `@/utils/request` 引入 `request`,从 `@/utils/api-types` 引入 `AxiosPromise`,从 `@/api/types` 引入 `PageResult`。
|
||||
- React 模板从 `@/api/request` 引入 `request`,从 `@/api/types` 引入 `R`、`PageResult`。
|
||||
- 本模块类型:Vue 模板从 `@/api/<module>/<business>/types` 引入,React 模板从 `./types` 引入。
|
||||
- Vue 列表接口通常返回 `AxiosPromise<PageResult<Vo>>`;React 列表接口通常返回 `request<R<PageResult<Vo>>>(...)`。
|
||||
- 从 `@/utils/request` 引入 `request`。
|
||||
- 从 `@/utils/api-types` 引入 `AxiosPromise`。
|
||||
- 从 `@/api/types` 引入 `PageResult`。
|
||||
- 从 `@/api/<module>/<business>/types` 引入本模块类型。
|
||||
- 列表接口通常返回 `AxiosPromise<PageResult<Vo>>`。
|
||||
- 常规接口命名和路由保持:
|
||||
`listXxx` -> `GET /<module>/<business>/list`
|
||||
`getXxx` -> `GET /<module>/<business>/{id}`
|
||||
@ -40,7 +38,7 @@
|
||||
- Java 数值类型通常映射为 `number`。
|
||||
- Boolean 映射为 `boolean`。
|
||||
- 其他生成字段默认多为 `string`。
|
||||
- 存在日期范围查询时保留 `params`:Vue 模板通常是 `params?: any`,React 模板通常是 `params?: Record<string, unknown>`。
|
||||
- 存在日期范围查询时保留 `params?: any`。
|
||||
|
||||
## Vue 页面规则
|
||||
|
||||
@ -53,19 +51,7 @@
|
||||
- 弹窗状态优先由 `useFormDialog` 返回的 `dialog`、`openDialog`、`showDialog`、`closeDialog` 管理。
|
||||
- 表单引用通常命名为 `queryFormRef` 和 `<business>FormRef`。
|
||||
|
||||
## React 页面规则
|
||||
|
||||
- 使用 `index.tsx`,组件默认导出 `<BusinessName>Page`。
|
||||
- 页面主体优先沿用 Ant Design Pro:`PageContainer`、`ProTable`、`ModalForm`、`ProColumns`、`ActionType`。
|
||||
- 表单优先使用 `Form.useForm<Form>()`,弹窗开关优先使用 `ahooks` 的 `useBoolean`。
|
||||
- 权限通过 `useUserStore` 取 `userInfo`,再用 `hasPermi(userInfo, ['module:business:action'])` 生成 `canAdd`、`canEdit`、`canRemove`、`canExport`。
|
||||
- 表格选择使用 `useTableSelection<VO>(row => row.id)`;表格刷新使用 `actionRef.current?.reload()` 或 `reloadAndRest?.()`。
|
||||
- 字典使用 `useDict` 和 `dictOptions`,展示使用 `DictTag`。
|
||||
- 日期范围使用 `useDateRangeQuery`,在 `ProTable` 的 `request` 中由 `toPageQuery(params)` 转查询参数后再应用范围字段。
|
||||
- 导出使用 `useTableExport`,路径保持 `/<module>/<business>/export`。
|
||||
- 文件、图片、富文本组件使用 React 工程已有的 `FileUpload`、`ImageUpload`、`ImagePreview`、`RichTextEditor`。
|
||||
|
||||
## Vue 页面行为规则
|
||||
## 页面行为规则
|
||||
|
||||
- `getList` 负责通过 `withLoading` 设置 loading、处理日期范围参数、调用列表接口、回填 `rows` 和 `total`。
|
||||
- `handleQuery` 通常先把 `pageNum` 重置为 `1`,再重新查询。
|
||||
@ -77,20 +63,13 @@
|
||||
- `handleDelete` 使用 `modal.confirm(...)` 确认,再调用删除接口并刷新。
|
||||
- `handleExport` 使用 `download as requestDownload` 从 `@/utils/request` 导出的下载方法。
|
||||
|
||||
## React 页面行为规则
|
||||
|
||||
- React `ProTable` 页面通过 `request` 回调加载列表并返回 `toTableData(res)`;新增、修改、删除成功后调用 `actionRef` 刷新。
|
||||
- React 弹窗提交函数根据主键判断调用新增还是更新,成功后 `message.success('操作成功')` 并重置表单。
|
||||
|
||||
## 模板结构规则
|
||||
|
||||
- 优先保持生成器的页面布局结构,不在 Vue 和 React 之间互相移植组件体系。
|
||||
- Vue 保留 `v-hasPermi="['module:business:add']"` 这类权限指令。
|
||||
- Vue 继续使用仓库已有组件:`right-toolbar`、`pagination`、`dict-tag`、`image-preview`、`image-upload`、`file-upload`、`editor`。
|
||||
- React 继续使用仓库已有组件:`RowActions`、`DictTag`、`ImagePreview`、`ImageUpload`、`FileUpload`、`RichTextEditor`。
|
||||
- 优先保持生成器的页面布局结构:搜索区卡片、表格区卡片、工具栏、分页、弹窗表单。
|
||||
- 保留 `v-hasPermi="['module:business:add']"` 这类权限指令。
|
||||
- 继续使用仓库已有组件:`right-toolbar`、`pagination`、`dict-tag`、`image-preview`、`image-upload`、`file-upload`、`editor`。
|
||||
- 已有页面对时间列使用 `parseTime` 时,新页面保持一致。
|
||||
- Vue BETWEEN 日期查询继续使用 `el-date-picker`,脚本侧通过 `useDateRangeQuery` 生成 `dateRangeXxx`、`applyXxxDateRange`、`resetXxxDateRange`。
|
||||
- React BETWEEN 日期查询继续使用 `ProTable` 的 `dateTimeRange` 搜索列,查询侧通过 `useDateRangeQuery` 写入 `params`。
|
||||
- BETWEEN 日期查询继续使用 `el-date-picker`,脚本侧通过 `useDateRangeQuery` 生成 `dateRangeXxx`、`applyXxxDateRange`、`resetXxxDateRange`。
|
||||
|
||||
## 避免事项
|
||||
|
||||
@ -98,4 +77,3 @@
|
||||
- 模块已使用字典时,不要把选项文案硬编码到页面里。
|
||||
- 不要让 API 函数名和路由段偏离后端约定。
|
||||
- 后端 BO/service 依赖 begin/end 参数时,不要从查询对象里删掉 `params` 和日期范围处理。
|
||||
- 不要把 Vue 的 `proxy`、`v-hasPermi`、Element Plus 组件写进 React 页面,也不要把 React 的 `ProTable`、`ModalForm`、Ant Design 权限判断写进 Vue 页面。
|
||||
|
||||
@ -22,8 +22,7 @@
|
||||
|
||||
> 系统演示: [传送门](https://plus-doc.dromara.org/#/common/demo_system)
|
||||
|
||||
> 官方前端项目地址: 基于 Vue + ElementPlus 版本前端项目 [gitee](https://gitee.com/JavaLionLi/plus-ui) - [github](https://github.com/CrazyLionCat/plus-ui) - [gitcode](https://gitcode.com/dromara/plus-ui)<br>
|
||||
> 官方前端项目地址: 基于 React + Ant Design 版本前端项目 [plus-ui-react](https://gitee.com/JavaLionLi/plus-ui/tree/6.X-React/)
|
||||
> 官方前端项目地址: [gitee](https://gitee.com/JavaLionLi/plus-ui) - [github](https://github.com/CrazyLionCat/plus-ui) - [gitcode](https://gitcode.com/dromara/plus-ui)<br>
|
||||
> 成员前端项目地址: 基于vben5 [ruoyi-plus-vben5](https://github.com/imdap/ruoyi-plus-vben5)<br>
|
||||
> 成员前端项目地址: 基于soybean [ruoyi-plus-soybean](https://gitee.com/xlsea/ruoyi-plus-soybean)<br>
|
||||
> 成员项目地址: 删除多租户与工作流 [RuoYi-Vue-Plus-Single](https://gitee.com/ColorDreams/RuoYi-Vue-Plus-Single)<br>
|
||||
|
||||
60
pom.xml
60
pom.xml
@ -14,21 +14,21 @@
|
||||
|
||||
<properties>
|
||||
<!-- 项目基础配置 -->
|
||||
<revision>6.0.0</revision>
|
||||
<revision>6.0.0-BETA</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<java.version>21</java.version>
|
||||
|
||||
<!-- Spring 生态依赖版本 -->
|
||||
<spring-boot.version>4.1.0</spring-boot.version>
|
||||
<spring-boot-admin.version>4.1.2</spring-boot-admin.version>
|
||||
<spring-boot-admin.version>4.1.0</spring-boot-admin.version>
|
||||
<spring-ai.version>2.0.0</spring-ai.version>
|
||||
<springdoc.version>3.0.3</springdoc.version>
|
||||
|
||||
<!-- 数据访问与持久化相关依赖版本 -->
|
||||
<mybatis.version>3.5.19</mybatis.version>
|
||||
<mybatis-plus.version>3.5.17</mybatis-plus.version>
|
||||
<mybatis-plus-join.version>1.5.9</mybatis-plus-join.version>
|
||||
<mybatis-plus.version>3.5.16</mybatis-plus.version>
|
||||
<mybatis-plus-join.version>1.5.7</mybatis-plus-join.version>
|
||||
<dynamic-ds.version>4.5.0</dynamic-ds.version>
|
||||
<anyline.version>8.7.3-20260306</anyline.version>
|
||||
<easy-es.version>3.0.2</easy-es.version>
|
||||
@ -36,36 +36,35 @@
|
||||
|
||||
<!-- 安全认证与加密相关依赖版本 -->
|
||||
<satoken.version>1.45.0</satoken.version>
|
||||
<justauth.version>3.0.1</justauth.version>
|
||||
<bouncycastle.version>1.85</bouncycastle.version>
|
||||
<justauth.version>1.16.7</justauth.version>
|
||||
<bouncycastle.version>1.84</bouncycastle.version>
|
||||
|
||||
<!-- 缓存、锁与任务调度相关依赖版本 -->
|
||||
<redisson.version>4.6.1</redisson.version>
|
||||
<lock4j.version>2.2.7</lock4j.version>
|
||||
<snailjob.version>2.0.2</snailjob.version>
|
||||
<snailai.version>1.1.1</snailai.version>
|
||||
<snailjob.version>2.0.0</snailjob.version>
|
||||
<snailai.version>0.0.6</snailai.version>
|
||||
|
||||
<!-- 通用工具、编译增强与代码生成相关依赖版本 -->
|
||||
<hutool.version>5.8.47</hutool.version>
|
||||
<hutool.version>5.8.46</hutool.version>
|
||||
<mapstruct-plus.version>1.5.1</mapstruct-plus.version>
|
||||
<mapstruct-plus.lombok.version>0.2.0</mapstruct-plus.lombok.version>
|
||||
<lombok.version>1.18.46</lombok.version>
|
||||
<lombok.version>1.18.42</lombok.version>
|
||||
<therapi-javadoc.version>0.15.0</therapi-javadoc.version>
|
||||
<fesod.version>2.0.2-incubating</fesod.version>
|
||||
<fory.version>1.3.0</fory.version>
|
||||
<fory.version>1.2.0</fory.version>
|
||||
|
||||
<!-- 三方集成与业务扩展相关依赖版本 -->
|
||||
<!-- 离线IP地址定位库 -->
|
||||
<ip2region.version>3.3.7</ip2region.version>
|
||||
<ip2region.version>3.3.6</ip2region.version>
|
||||
<!-- OSS 配置 -->
|
||||
<aws.sdk.version>2.48.1</aws.sdk.version>
|
||||
<aws.sdk.version>2.42.9</aws.sdk.version>
|
||||
<!-- SMS 配置 -->
|
||||
<sms4j.version>3.3.5</sms4j.version>
|
||||
<!-- 工作流配置 -->
|
||||
<warm-flow.version>1.8.9</warm-flow.version>
|
||||
<liteflow.version>2.16.1.2</liteflow.version>
|
||||
<warm-flow.version>1.8.8</warm-flow.version>
|
||||
<!-- mqtt客户端 -->
|
||||
<mica-mqtt.version>2.6.8</mica-mqtt.version>
|
||||
<mica-mqtt.version>2.6.6</mica-mqtt.version>
|
||||
|
||||
<!-- Maven 构建插件版本 -->
|
||||
<maven-jar-plugin.version>3.5.0</maven-jar-plugin.version>
|
||||
@ -75,7 +74,7 @@
|
||||
<!-- 统一版本号管理:Maven3.X需要,Maven4.0之后已原生支持 -->
|
||||
<flatten-maven-plugin.version>1.7.3</flatten-maven-plugin.version>
|
||||
<!-- 打包默认跳过测试 -->
|
||||
<maven.test.skip>false</maven.test.skip>
|
||||
<maven.test.skip>true</maven.test.skip>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
@ -114,15 +113,6 @@
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<dependencies>
|
||||
<!-- 各子模块统一使用 JUnit 5、Mockito 与 Spring Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<!-- 依赖声明 -->
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
@ -358,7 +348,7 @@
|
||||
<!-- Warm-Flow国产工作流引擎, 在线文档:http://warm-flow.cn/ -->
|
||||
<dependency>
|
||||
<groupId>org.dromara.warm</groupId>
|
||||
<artifactId>warm-flow-mybatis-plus-sb4-starter</artifactId>
|
||||
<artifactId>warm-flow-mybatis-plus-sb3-starter</artifactId>
|
||||
<version>${warm-flow.version}</version>
|
||||
</dependency>
|
||||
<!-- Warm-Flow UI 插件 -->
|
||||
@ -368,13 +358,6 @@
|
||||
<version>${warm-flow.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- LiteFlow 规则编排引擎 -->
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-spring-boot-starter</artifactId>
|
||||
<version>${liteflow.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- mqtt客户端 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara.mica-mqtt</groupId>
|
||||
@ -405,7 +388,7 @@
|
||||
|
||||
<!-- JustAuth 的依赖配置-->
|
||||
<dependency>
|
||||
<groupId>io.github.windtool</groupId>
|
||||
<groupId>me.zhyd.oauth</groupId>
|
||||
<artifactId>JustAuth</artifactId>
|
||||
<version>${justauth.version}</version>
|
||||
</dependency>
|
||||
@ -531,11 +514,8 @@
|
||||
<version>${maven-surefire-plugin.version}</version>
|
||||
<configuration>
|
||||
<argLine>-Dfile.encoding=UTF-8</argLine>
|
||||
<!--
|
||||
暂不启用 groups 标签筛选,确保各模块新增的无标签单元测试默认执行。
|
||||
需要按环境筛选 @Tag 时,可恢复下方配置并通过 -Dgroups=dev 等参数指定标签。
|
||||
<groups>${groups}</groups>
|
||||
-->
|
||||
<!-- 根据打包环境执行对应的@Tag测试方法 -->
|
||||
<groups>${profiles.active}</groups>
|
||||
<!-- 排除标签 -->
|
||||
<excludedGroups>exclude</excludedGroups>
|
||||
</configuration>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/
|
||||
FROM bellsoft/liberica-openjdk-rocky:21.0.12-cds
|
||||
# FROM bellsoft/liberica-openjdk-rocky:25.0.4-cds
|
||||
FROM bellsoft/liberica-openjdk-rocky:21.0.11-cds
|
||||
# FROM bellsoft/liberica-openjdk-rocky:25.0.3-cds
|
||||
# FROM findepi/graalvm:java21-native
|
||||
|
||||
LABEL maintainer="Lion Li"
|
||||
|
||||
@ -110,6 +110,13 @@
|
||||
<artifactId>spring-boot-admin-starter-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 单元测试 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- skywalking 整合 logback -->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.apache.skywalking</groupId>-->
|
||||
@ -136,10 +143,6 @@
|
||||
<artifactId>ruoyi-gen</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<activation>
|
||||
<!-- 默认激活 -->
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
|
||||
@ -116,7 +116,7 @@ public class CaptchaController {
|
||||
.send();
|
||||
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
|
||||
} catch (Exception e) {
|
||||
log.error("验证码邮件发送异常 => {}", e.getMessage());
|
||||
log.error("验证码短信发送异常 => {}", e.getMessage());
|
||||
throw new ServiceException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.lang.Opt;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import me.zhyd.oauth.model.AuthUser;
|
||||
@ -137,12 +136,7 @@ public class SysLoginService {
|
||||
loginInfoEvent.setUsername(username);
|
||||
loginInfoEvent.setStatus(status);
|
||||
loginInfoEvent.setMessage(message);
|
||||
HttpServletRequest request = ServletUtils.getRequest();
|
||||
if (request != null) {
|
||||
loginInfoEvent.setIp(ServletUtils.getClientIP(request));
|
||||
loginInfoEvent.setUserAgent(request.getHeader("User-Agent"));
|
||||
loginInfoEvent.setClientId(request.getHeader(LoginHelper.CLIENT_KEY));
|
||||
}
|
||||
loginInfoEvent.setRequest(ServletUtils.getRequest());
|
||||
SpringUtils.context().publishEvent(loginInfoEvent);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package org.dromara.web.service;
|
||||
|
||||
import cn.hutool.crypto.digest.BCrypt;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.constant.Constants;
|
||||
import org.dromara.common.core.constant.GlobalConstants;
|
||||
@ -15,7 +14,6 @@ import org.dromara.common.core.utils.SpringUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.log.event.LoginInfoEvent;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.common.web.config.properties.CaptchaProperties;
|
||||
import org.dromara.system.api.model.RegisterBody;
|
||||
import org.dromara.system.domain.SysUser;
|
||||
@ -105,12 +103,7 @@ public class SysRegisterService {
|
||||
loginInfoEvent.setUsername(username);
|
||||
loginInfoEvent.setStatus(status);
|
||||
loginInfoEvent.setMessage(message);
|
||||
HttpServletRequest request = ServletUtils.getRequest();
|
||||
if (request != null) {
|
||||
loginInfoEvent.setIp(ServletUtils.getClientIP(request));
|
||||
loginInfoEvent.setUserAgent(request.getHeader("User-Agent"));
|
||||
loginInfoEvent.setClientId(request.getHeader(LoginHelper.CLIENT_KEY));
|
||||
}
|
||||
loginInfoEvent.setRequest(ServletUtils.getRequest());
|
||||
SpringUtils.context().publishEvent(loginInfoEvent);
|
||||
}
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ snail-ai:
|
||||
app-id: 1
|
||||
# 认证令牌(在 Server「应用管理」页面创建时自动生成)
|
||||
token: SAI_566a6bfbc26e4998b4841cc927d50c5d
|
||||
port: 3${server.port}
|
||||
port: 18889
|
||||
# Skill 文件临时目录
|
||||
skill-temp-dir: /tmp/snail-ai-agent/skills
|
||||
# ==================== Chat 嵌入模式配置 ====================
|
||||
@ -55,7 +55,7 @@ snail-ai:
|
||||
# 嵌入模式下隐藏侧边栏用户信息
|
||||
show-sidebar-user: false
|
||||
# 嵌入模式下隐藏智能体市场入口
|
||||
show-agent-market: true
|
||||
show-agent-market: false
|
||||
# 嵌入模式下使用紧凑输入框
|
||||
compact-input: true
|
||||
# 是否锁定当前智能体
|
||||
|
||||
@ -46,7 +46,7 @@ snail-ai:
|
||||
app-id: 1
|
||||
# 认证令牌(在 Server「应用管理」页面创建时自动生成)
|
||||
token: SAI_566a6bfbc26e4998b4841cc927d50c5d
|
||||
port: 3${server.port}
|
||||
port: 18889
|
||||
# Skill 文件临时目录
|
||||
skill-temp-dir: /tmp/snail-ai-agent/skills
|
||||
# ==================== Chat 嵌入模式配置 ====================
|
||||
@ -58,7 +58,7 @@ snail-ai:
|
||||
# 嵌入模式下隐藏侧边栏用户信息
|
||||
show-sidebar-user: false
|
||||
# 嵌入模式下隐藏智能体市场入口
|
||||
show-agent-market: true
|
||||
show-agent-market: false
|
||||
# 嵌入模式下使用紧凑输入框
|
||||
compact-input: true
|
||||
# 是否锁定当前智能体
|
||||
|
||||
@ -10,9 +10,10 @@ server:
|
||||
# HTTP post内容的最大大小。当值为-1时,默认值为大小是无限的
|
||||
max-http-form-post-size: -1
|
||||
threads:
|
||||
# 开启虚拟线程后,该值表示最大并发任务数,而非平台线程池大小
|
||||
# SSE 等长连接会持续占用任务槽位,请根据在线用户量和请求峰值调整
|
||||
max: 20000
|
||||
# 最小线程数
|
||||
min: 8
|
||||
# 最大线程数
|
||||
max: 256
|
||||
|
||||
captcha:
|
||||
# 是否启用验证码校验
|
||||
@ -181,7 +182,7 @@ springdoc:
|
||||
- group: 3.系统模块
|
||||
packages-to-scan: org.dromara.system
|
||||
- group: 4.代码生成模块
|
||||
packages-to-scan: org.dromara.gen
|
||||
packages-to-scan: org.dromara.generator
|
||||
- group: 5.工作流模块
|
||||
packages-to-scan: org.dromara.workflow
|
||||
|
||||
@ -244,18 +245,6 @@ warm-flow:
|
||||
# 默认Authorization,如果有多个token,用逗号分隔
|
||||
token-name: ${sa-token.token-name},clientid
|
||||
|
||||
--- # LiteFlow 业务规则编排
|
||||
liteflow:
|
||||
# 跟随工作流模块开关,避免禁用工作流时仍加载任务办理链
|
||||
enable: ${warm-flow.enabled:true}
|
||||
metrics:
|
||||
# 指标自动配置使用独立开关,需同步关闭以避免初始化未启用的 LiteFlow
|
||||
enabled: ${liteflow.enable:true}
|
||||
# 工作流模块用于编排任务办理链路
|
||||
rule-source: classpath:liteflow/*.el.xml
|
||||
# 开启虚拟线程
|
||||
enable-virtual-thread: true
|
||||
|
||||
--- # mqtt 配置
|
||||
# 具体配置还需查看文档
|
||||
# https://mica-mqtt.dreamlu.net/guide/spring/client.html
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
package org.dromara.test;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 断言单元测试案例
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@DisplayName("断言单元测试案例")
|
||||
public class AssertUnitTest {
|
||||
|
||||
/**
|
||||
* 验证相等与不相等断言,确保值比较语义清晰。
|
||||
*/
|
||||
@DisplayName("测试 assertEquals 方法")
|
||||
@Test
|
||||
public void testAssertEquals() {
|
||||
Assertions.assertEquals("666", new String("666"));
|
||||
Assertions.assertNotEquals("666", "777");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同一对象引用与不同对象引用的断言。
|
||||
*/
|
||||
@DisplayName("测试 assertSame 方法")
|
||||
@Test
|
||||
public void testAssertSame() {
|
||||
Object obj = new Object();
|
||||
Object obj1 = obj;
|
||||
Object obj2 = new Object();
|
||||
Assertions.assertSame(obj, obj1);
|
||||
Assertions.assertNotSame(obj, obj2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证布尔条件断言,覆盖 true 与 false 两类结果。
|
||||
*/
|
||||
@DisplayName("测试 assertTrue 方法")
|
||||
@Test
|
||||
public void testAssertTrue() {
|
||||
Assertions.assertTrue(true);
|
||||
Assertions.assertFalse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空值与非空值断言,避免空指针场景被误判。
|
||||
*/
|
||||
@DisplayName("测试 assertNull 方法")
|
||||
@Test
|
||||
public void testAssertNull() {
|
||||
Assertions.assertNull(null);
|
||||
Assertions.assertNotNull("not null");
|
||||
}
|
||||
|
||||
}
|
||||
102
ruoyi-admin/src/test/java/org/dromara/test/DemoUnitTest.java
Normal file
102
ruoyi-admin/src/test/java/org/dromara/test/DemoUnitTest.java
Normal file
@ -0,0 +1,102 @@
|
||||
package org.dromara.test;
|
||||
|
||||
import org.dromara.common.web.config.properties.CaptchaProperties;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 单元测试基础案例。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@DisplayName("单元测试案例")
|
||||
public class DemoUnitTest {
|
||||
|
||||
/**
|
||||
* 所有测试执行前的初始化示例。
|
||||
*/
|
||||
@BeforeAll
|
||||
public static void testBeforeAll() {
|
||||
System.out.println("@BeforeAll ==================");
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有测试执行后的清理示例。
|
||||
*/
|
||||
@AfterAll
|
||||
public static void testAfterAll() {
|
||||
System.out.println("@AfterAll ==================");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通 {@link Test} 与 {@link DisplayName} 注解的使用方式。
|
||||
*/
|
||||
@DisplayName("测试 @Test @DisplayName 注解")
|
||||
@Test
|
||||
public void testTest() {
|
||||
CaptchaProperties captchaProperties = new CaptchaProperties();
|
||||
captchaProperties.setEnable(Boolean.TRUE);
|
||||
captchaProperties.setType("math");
|
||||
captchaProperties.setNumberLength(1);
|
||||
captchaProperties.setCharLength(4);
|
||||
|
||||
assertAll("验证码配置属性",
|
||||
() -> assertTrue(captchaProperties.getEnable()),
|
||||
() -> assertEquals("math", captchaProperties.getType()),
|
||||
() -> assertEquals(1, captchaProperties.getNumberLength()),
|
||||
() -> assertEquals(4, captchaProperties.getCharLength())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示 {@link Disabled} 注解,保留一个不会被执行的测试占位。
|
||||
*/
|
||||
@Disabled
|
||||
@DisplayName("测试 @Disabled 注解")
|
||||
@Test
|
||||
public void testDisabled() {
|
||||
fail("禁用测试不应被执行");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 {@link Timeout} 注解在指定时间内可以正常通过。
|
||||
*
|
||||
* @throws InterruptedException 线程等待被中断时抛出
|
||||
*/
|
||||
@Timeout(value = 2L, unit = TimeUnit.SECONDS)
|
||||
@DisplayName("测试 @Timeout 注解")
|
||||
@Test
|
||||
public void testTimeout() throws InterruptedException {
|
||||
Thread.sleep(100);
|
||||
assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 {@link RepeatedTest} 注解会按指定次数重复执行。
|
||||
*/
|
||||
@DisplayName("测试 @RepeatedTest 注解")
|
||||
@RepeatedTest(3)
|
||||
public void testRepeatedTest() {
|
||||
assertDoesNotThrow(() -> Integer.parseInt("666"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个测试执行前的初始化示例。
|
||||
*/
|
||||
@BeforeEach
|
||||
public void testBeforeEach() {
|
||||
System.out.println("@BeforeEach ==================");
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个测试执行后的清理示例。
|
||||
*/
|
||||
@AfterEach
|
||||
public void testAfterEach() {
|
||||
System.out.println("@AfterEach ==================");
|
||||
}
|
||||
|
||||
}
|
||||
106
ruoyi-admin/src/test/java/org/dromara/test/ParamUnitTest.java
Normal file
106
ruoyi-admin/src/test/java/org/dromara/test/ParamUnitTest.java
Normal file
@ -0,0 +1,106 @@
|
||||
package org.dromara.test;
|
||||
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 带参数单元测试案例
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@DisplayName("带参数单元测试案例")
|
||||
public class ParamUnitTest {
|
||||
|
||||
/**
|
||||
* 参数化测试共用的字符串样例。
|
||||
*/
|
||||
private static final List<String> TEST_VALUES = List.of("t1", "t2", "t3");
|
||||
|
||||
/**
|
||||
* 提供 {@link MethodSource} 参数化测试数据。
|
||||
*
|
||||
* @return 测试参数流
|
||||
*/
|
||||
public static Stream<String> getParam() {
|
||||
return TEST_VALUES.stream();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 {@link ValueSource} 能按固定字符串集合逐个传参。
|
||||
*
|
||||
* @param str 当前参数值
|
||||
*/
|
||||
@DisplayName("测试 @ValueSource 注解")
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"t1", "t2", "t3"})
|
||||
public void testValueSource(String str) {
|
||||
assertTrue(TEST_VALUES.contains(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 {@link NullSource} 能传入空值参数。
|
||||
*
|
||||
* @param str 当前参数值
|
||||
*/
|
||||
@DisplayName("测试 @NullSource 注解")
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
public void testNullSource(String str) {
|
||||
assertNull(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 {@link EnumSource} 能遍历用户类型枚举。
|
||||
*
|
||||
* @param type 当前用户类型
|
||||
*/
|
||||
@DisplayName("测试 @EnumSource 注解")
|
||||
@ParameterizedTest
|
||||
@EnumSource(UserType.class)
|
||||
public void testEnumSource(UserType type) {
|
||||
assertNotNull(type);
|
||||
assertFalse(type.getUserType().isBlank());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 {@link MethodSource} 能读取方法提供的参数流。
|
||||
*
|
||||
* @param str 当前参数值
|
||||
*/
|
||||
@DisplayName("测试 @MethodSource 注解")
|
||||
@ParameterizedTest
|
||||
@MethodSource("getParam")
|
||||
public void testMethodSource(String str) {
|
||||
assertTrue(TEST_VALUES.contains(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个参数化测试执行前的初始化示例。
|
||||
*/
|
||||
@BeforeEach
|
||||
public void testBeforeEach() {
|
||||
System.out.println("@BeforeEach ==================");
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个参数化测试执行后的清理示例。
|
||||
*/
|
||||
@AfterEach
|
||||
public void testAfterEach() {
|
||||
System.out.println("@AfterEach ==================");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
72
ruoyi-admin/src/test/java/org/dromara/test/TagUnitTest.java
Normal file
72
ruoyi-admin/src/test/java/org/dromara/test/TagUnitTest.java
Normal file
@ -0,0 +1,72 @@
|
||||
package org.dromara.test;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 标签单元测试案例
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@DisplayName("标签单元测试案例")
|
||||
public class TagUnitTest {
|
||||
|
||||
/**
|
||||
* 验证 dev 标签测试可以独立筛选执行。
|
||||
*/
|
||||
@Tag("dev")
|
||||
@DisplayName("测试 @Tag dev")
|
||||
@Test
|
||||
public void testTagDev() {
|
||||
assertEquals("dev", "dev");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 prod 标签测试可以独立筛选执行。
|
||||
*/
|
||||
@Tag("prod")
|
||||
@DisplayName("测试 @Tag prod")
|
||||
@Test
|
||||
public void testTagProd() {
|
||||
assertEquals("prod", "prod");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 local 标签测试可以独立筛选执行。
|
||||
*/
|
||||
@Tag("local")
|
||||
@DisplayName("测试 @Tag local")
|
||||
@Test
|
||||
public void testTagLocal() {
|
||||
assertEquals("local", "local");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 exclude 标签测试可以配合构建配置排除。
|
||||
*/
|
||||
@Tag("exclude")
|
||||
@DisplayName("测试 @Tag exclude")
|
||||
@Test
|
||||
public void testTagExclude() {
|
||||
assertEquals("exclude", "exclude");
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个标签测试执行前的初始化示例。
|
||||
*/
|
||||
@BeforeEach
|
||||
public void testBeforeEach() {
|
||||
System.out.println("@BeforeEach ==================");
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个标签测试执行后的清理示例。
|
||||
*/
|
||||
@AfterEach
|
||||
public void testAfterEach() {
|
||||
System.out.println("@AfterEach ==================");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -37,7 +37,6 @@
|
||||
<module>ruoyi-common-json</module>
|
||||
<module>ruoyi-common-encrypt</module>
|
||||
<module>ruoyi-common-push</module>
|
||||
<module>ruoyi-common-liteflow</module>
|
||||
<module>ruoyi-common-mqtt</module>
|
||||
<module>ruoyi-common-ai</module>
|
||||
<module>ruoyi-common-mcp</module>
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
package org.dromara.common.ai.handler;
|
||||
|
||||
import com.aizuda.snail.ai.agent.chat.starter.SnailAiChatGatewayController;
|
||||
import com.aizuda.snail.ai.common.execption.BaseSnailAiException;
|
||||
import com.aizuda.snail.ai.common.execption.SnailAiAuthenticationException;
|
||||
import com.aizuda.snail.ai.common.model.ModelCallException;
|
||||
import com.aizuda.snail.ai.common.model.Result;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Snail AI Chat 网关异常处理器。
|
||||
* <p>
|
||||
* /api/snail/chat/** 接口由 Snail AI SDK 前端消费,响应结构需要保持 SDK Result(status/message/data),
|
||||
* 不能落到 RuoYi 通用 R(code/msg/data) 格式。
|
||||
*/
|
||||
@Slf4j
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@RestControllerAdvice(assignableTypes = SnailAiChatGatewayController.class)
|
||||
public class SnailAiChatExceptionHandler {
|
||||
|
||||
private static final int AUTHENTICATION_ERROR_STATUS = 5001;
|
||||
|
||||
@ExceptionHandler(SnailAiAuthenticationException.class)
|
||||
public Result<Void> handleAuthenticationException(SnailAiAuthenticationException e) {
|
||||
log.warn("Snail AI Chat authentication failed: {}", e.getMessage());
|
||||
return Result.fail(AUTHENTICATION_ERROR_STATUS, defaultMessage(e, "认证失败,请重新登录"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(BaseSnailAiException.class)
|
||||
public Result<Void> handleSnailAiException(BaseSnailAiException e) {
|
||||
log.warn("Snail AI Chat request failed: {}", e.getMessage());
|
||||
return Result.fail(defaultMessage(e, "AI 服务请求失败,请稍后再试"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ModelCallException.class)
|
||||
public Result<Void> handleModelCallException(ModelCallException e) {
|
||||
log.warn("Snail AI Chat model call failed: {}", e.getMessage(), e);
|
||||
return Result.fail(defaultMessage(e, "模型调用失败,请稍后再试"));
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
BindException.class,
|
||||
MethodArgumentNotValidException.class,
|
||||
HandlerMethodValidationException.class
|
||||
})
|
||||
public Result<Void> handleValidationException(Exception e) {
|
||||
log.warn("Snail AI Chat validation failed: {}", e.getMessage());
|
||||
return Result.fail(validationMessage(e));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public Result<Void> handleConstraintViolationException(ConstraintViolationException e) {
|
||||
log.warn("Snail AI Chat constraint validation failed: {}", e.getMessage());
|
||||
String message = e.getConstraintViolations().stream()
|
||||
.map(ConstraintViolation::getMessage)
|
||||
.filter(this::hasText)
|
||||
.collect(Collectors.joining(", "));
|
||||
return Result.fail(hasText(message) ? message : "请求参数校验失败");
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public Result<Void> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
|
||||
log.warn("Snail AI Chat request body parse failed: {}", e.getMessage());
|
||||
return Result.fail("请求参数格式错误");
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public Result<Void> handleIllegalArgumentException(IllegalArgumentException e) {
|
||||
log.warn("Snail AI Chat illegal argument: {}", e.getMessage());
|
||||
return Result.fail(defaultMessage(e, "请求参数不合法"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public Result<Void> handleIllegalStateException(IllegalStateException e) {
|
||||
log.warn("Snail AI Chat illegal state: {}", e.getMessage());
|
||||
return Result.fail(defaultMessage(e, "AI 会话状态异常,请刷新后重试"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public Result<Void> handleException(Exception e) {
|
||||
log.error("Snail AI Chat unexpected exception", e);
|
||||
return Result.fail("AI 服务异常,请稍后再试");
|
||||
}
|
||||
|
||||
private String validationMessage(Exception e) {
|
||||
Collection<? extends MessageSourceResolvable> errors;
|
||||
if (e instanceof BindException bindException) {
|
||||
errors = bindException.getAllErrors();
|
||||
} else if (e instanceof MethodArgumentNotValidException methodArgumentNotValidException) {
|
||||
errors = methodArgumentNotValidException.getBindingResult().getAllErrors();
|
||||
} else if (e instanceof HandlerMethodValidationException handlerMethodValidationException) {
|
||||
errors = handlerMethodValidationException.getAllErrors();
|
||||
} else {
|
||||
return "请求参数校验失败";
|
||||
}
|
||||
String message = errors.stream()
|
||||
.map(MessageSourceResolvable::getDefaultMessage)
|
||||
.filter(this::hasText)
|
||||
.collect(Collectors.joining(", "));
|
||||
return hasText(message) ? message : "请求参数校验失败";
|
||||
}
|
||||
|
||||
private String defaultMessage(Throwable e, String fallback) {
|
||||
return hasText(e.getMessage()) ? e.getMessage() : fallback;
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
package org.dromara.common.ai;
|
||||
|
||||
import org.dromara.common.ai.config.SnailAiConfig;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
@DisplayName("common-ai 功能单元测试")
|
||||
class SnailAiConfigTest {
|
||||
|
||||
/**
|
||||
* 验证 AI 自动配置只在显式开启 snail-ai.enabled 时生效,避免默认启动外部 AI 客户端。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("声明 Snail AI 启用条件")
|
||||
void shouldDeclareSnailAiEnablementCondition() {
|
||||
ConditionalOnProperty condition = SnailAiConfig.class.getAnnotation(ConditionalOnProperty.class);
|
||||
|
||||
assertNotNull(condition);
|
||||
assertEquals("snail-ai", condition.prefix());
|
||||
assertArrayEquals(new String[]{"enabled"}, condition.name());
|
||||
assertEquals("true", condition.havingValue());
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@
|
||||
</description>
|
||||
|
||||
<properties>
|
||||
<revision>6.0.0</revision>
|
||||
<revision>6.0.0-BETA</revision>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@ -159,13 +159,6 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- liteflow模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-liteflow</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- mqtt模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
|
||||
@ -140,7 +140,7 @@ public class DateUtils extends DateUtil {
|
||||
|
||||
// 未来时间或非今年
|
||||
if (date.after(now) || year(date) != year(now)) {
|
||||
return formatDateTime(date);
|
||||
return formatDateTime(now);
|
||||
}
|
||||
|
||||
// 今天
|
||||
|
||||
@ -5,8 +5,6 @@ import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* sql操作工具类
|
||||
*
|
||||
@ -57,7 +55,7 @@ public class SqlUtil {
|
||||
}
|
||||
|
||||
// ==================== 原有逻辑不变 ====================
|
||||
String normalizedValue = value.replaceAll("[\\p{Z}\\s]+", " ").toLowerCase(Locale.ROOT);
|
||||
String normalizedValue = value.replaceAll("\\p{Z}|\\s", "");
|
||||
String[] sqlKeywords = StringUtils.split(SQL_REGEX, "\\|");
|
||||
for (String sqlKeyword : sqlKeywords) {
|
||||
if (StringUtils.indexOf(normalizedValue, sqlKeyword) > -1) {
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
package org.dromara.common.core.validate.enums;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
@ -16,17 +14,12 @@ import java.util.Set;
|
||||
* @author 秋辞未寒
|
||||
* @date 2024-12-09
|
||||
*/
|
||||
public class EnumPatternValidator implements ConstraintValidator<EnumPattern, Object> {
|
||||
public class EnumPatternValidator implements ConstraintValidator<EnumPattern, String> {
|
||||
|
||||
/**
|
||||
* 枚举允许值集合。
|
||||
*/
|
||||
private final Set<Object> values = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 枚举字段值类型。
|
||||
*/
|
||||
private Class<?> valueType;
|
||||
private final Set<String> values = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 初始化枚举允许值集合。
|
||||
@ -43,43 +36,24 @@ public class EnumPatternValidator implements ConstraintValidator<EnumPattern, Ob
|
||||
for (Object e : annotation.type().getEnumConstants()) {
|
||||
Object fieldValue = ReflectUtils.invokeGetter(e, fieldName);
|
||||
if (fieldValue != null) {
|
||||
if (valueType == null) {
|
||||
valueType = fieldValue.getClass();
|
||||
}
|
||||
values.add(fieldValue);
|
||||
values.add(String.valueOf(fieldValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验值是否在枚举允许值集合内。
|
||||
*
|
||||
* <p>以枚举字段的实际类型转换输入值后进行比较,可兼容字符串、数字、布尔值等字段类型,
|
||||
* 同时避免将无法转换的值视为合法值。字符串类型的空白值按未填写处理,其他类型仅 null
|
||||
* 按未填写处理。</p>
|
||||
* 校验字符串是否在枚举允许值集合内。
|
||||
*
|
||||
* @param value 待校验值
|
||||
* @param constraintValidatorContext 校验上下文
|
||||
* @return true 校验通过 false 校验失败
|
||||
*/
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
if (value == null) {
|
||||
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return true;
|
||||
}
|
||||
if (valueType == null) {
|
||||
return false;
|
||||
}
|
||||
if (value instanceof CharSequence && StringUtils.isBlank(value.toString())
|
||||
&& CharSequence.class.isAssignableFrom(valueType)) {
|
||||
return true;
|
||||
}
|
||||
if ((valueType == Boolean.class || valueType == boolean.class) && value instanceof CharSequence
|
||||
&& BooleanUtil.toBooleanObject(value.toString()) == null) {
|
||||
return false;
|
||||
}
|
||||
Object convertedValue = Convert.convertWithCheck(valueType, value, null, true);
|
||||
return convertedValue != null && values.contains(convertedValue);
|
||||
return values.contains(value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
package org.dromara.common.core.domain;
|
||||
|
||||
import org.dromara.common.core.constant.HttpStatus;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("公共响应模型单元测试")
|
||||
class ResponseModelTest {
|
||||
|
||||
/**
|
||||
* 验证响应工厂方法设置正确的状态码、消息和业务数据。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构建成功、失败和警告响应")
|
||||
void responseFactoriesShouldSetExpectedFields() {
|
||||
R<String> success = R.ok("done", "payload");
|
||||
R<String> failure = R.fail("failed", "payload");
|
||||
R<String> warning = R.warn("warning", "payload");
|
||||
|
||||
assertAll(
|
||||
() -> assertEquals(HttpStatus.SUCCESS, success.getCode()),
|
||||
() -> assertEquals("done", success.getMsg()),
|
||||
() -> assertEquals("payload", success.getData()),
|
||||
() -> assertEquals(HttpStatus.ERROR, failure.getCode()),
|
||||
() -> assertEquals(HttpStatus.WARN, warning.getCode())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证成功状态判断能够处理失败响应和空响应。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("正确识别响应成功状态")
|
||||
void responseStatusChecksShouldHandleNullAndError() {
|
||||
assertTrue(R.isSuccess(R.ok()));
|
||||
assertFalse(R.isSuccess(R.fail()));
|
||||
assertFalse(R.isSuccess(null));
|
||||
assertTrue(R.isError(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证仅传集合时分页总数使用集合实际大小。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("分页结果按集合大小计算总数")
|
||||
void pageResultShouldUseCollectionSizeAsTotal() {
|
||||
PageResult<String> result = PageResult.build(List.of("a", "b"));
|
||||
|
||||
assertEquals(2L, result.getTotal());
|
||||
assertEquals(List.of("a", "b"), result.getRows());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空行集合会被标准化为空列表,避免调用方判空。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("分页结果将空集合参数转换为空列表")
|
||||
void pageResultShouldNormalizeNullRows() {
|
||||
PageResult<String> result = PageResult.build(null, 10L);
|
||||
PageResult<String> constructed = new PageResult<>(null, 5L);
|
||||
|
||||
assertNotNull(result.getRows());
|
||||
assertTrue(result.getRows().isEmpty());
|
||||
assertTrue(constructed.getRows().isEmpty());
|
||||
assertEquals(10L, result.getTotal());
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
package org.dromara.common.core.enums;
|
||||
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("BusinessStatusEnum 单元测试")
|
||||
class BusinessStatusEnumTest {
|
||||
|
||||
/**
|
||||
* 验证状态码查询、描述查询以及运行中和已结束状态集合。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("查询业务状态")
|
||||
void shouldResolveBusinessStatuses() {
|
||||
assertEquals(BusinessStatusEnum.DRAFT, BusinessStatusEnum.getByStatus("draft"));
|
||||
assertNull(BusinessStatusEnum.getByStatus("unknown"));
|
||||
assertEquals("已完成", BusinessStatusEnum.findByStatus("finish"));
|
||||
assertEquals("", BusinessStatusEnum.findByStatus(" "));
|
||||
assertEquals(List.of("draft", "waiting", "back", "cancel"), BusinessStatusEnum.runningStatus());
|
||||
assertEquals(List.of("finish", "invalid", "termination"), BusinessStatusEnum.finishStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证流程状态分类能够正确识别可重新发起状态和终止类状态。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("分类业务状态")
|
||||
void shouldClassifyBusinessStatuses() {
|
||||
assertTrue(BusinessStatusEnum.isDraftOrCancelOrBack("draft"));
|
||||
assertTrue(BusinessStatusEnum.isDraftOrCancelOrBack("cancel"));
|
||||
assertTrue(BusinessStatusEnum.isDraftOrCancelOrBack("back"));
|
||||
assertFalse(BusinessStatusEnum.isDraftOrCancelOrBack("waiting"));
|
||||
assertTrue(BusinessStatusEnum.initialState("invalid"));
|
||||
assertTrue(BusinessStatusEnum.initialState("termination"));
|
||||
assertFalse(BusinessStatusEnum.initialState("waiting"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证启动流程仅允许草稿、撤销和退回等可发起状态,并为禁止状态返回业务异常。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("校验流程启动状态")
|
||||
void shouldValidateStartStatus() {
|
||||
assertDoesNotThrow(() -> BusinessStatusEnum.checkStartStatus("draft"));
|
||||
assertEquals("该单据已提交过申请,正在审批中!",
|
||||
assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus("waiting")).getMessage());
|
||||
assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus("finish"));
|
||||
assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus("invalid"));
|
||||
assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus("termination"));
|
||||
assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkStartStatus(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证撤销、退回及作废校验分别拒绝所有已结束或重复操作状态。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("校验流程变更状态")
|
||||
void shouldValidateCancelBackAndInvalidStatuses() {
|
||||
assertDoesNotThrow(() -> BusinessStatusEnum.checkCancelStatus("waiting"));
|
||||
for (String status : List.of("cancel", "finish", "invalid", "termination", "back", " ")) {
|
||||
assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkCancelStatus(status));
|
||||
}
|
||||
assertDoesNotThrow(() -> BusinessStatusEnum.checkBackStatus("waiting"));
|
||||
for (String status : List.of("back", "finish", "invalid", "termination", "cancel", " ")) {
|
||||
assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkBackStatus(status));
|
||||
}
|
||||
assertDoesNotThrow(() -> BusinessStatusEnum.checkInvalidStatus("waiting"));
|
||||
for (String status : List.of("finish", "invalid", "termination", " ")) {
|
||||
assertThrows(ServiceException.class, () -> BusinessStatusEnum.checkInvalidStatus(status));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
package org.dromara.common.core.factory;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.support.EncodedResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@DisplayName("YmlPropertySourceFactory 单元测试")
|
||||
class YmlPropertySourceFactoryTest {
|
||||
|
||||
/**
|
||||
* 验证公共配置源工厂可以将 YAML 层级结构展开为 Spring 属性键。
|
||||
*
|
||||
* @throws IOException 配置资源读取失败
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("解析 YAML 配置资源")
|
||||
void shouldLoadYamlAsProperties() throws IOException {
|
||||
ByteArrayResource resource = namedResource("common-test.yml", "feature:\n enabled: true\n timeout: 30\n");
|
||||
|
||||
PropertySource<?> source = new YmlPropertySourceFactory()
|
||||
.createPropertySource(null, new EncodedResource(resource, StandardCharsets.UTF_8));
|
||||
|
||||
assertEquals("common-test.yml", source.getName());
|
||||
assertEquals(true, source.getProperty("feature.enabled"));
|
||||
assertEquals(30, source.getProperty("feature.timeout"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非 YAML 资源仍委托 Spring 默认逻辑解析,避免公共工厂破坏 properties 配置。
|
||||
*
|
||||
* @throws IOException 配置资源读取失败
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("回退解析 properties 配置资源")
|
||||
void shouldDelegatePropertiesResourcesToSpring() throws IOException {
|
||||
ByteArrayResource resource = namedResource("common-test.properties", "feature.mode=strict\n");
|
||||
|
||||
PropertySource<?> source = new YmlPropertySourceFactory()
|
||||
.createPropertySource("fallback", new EncodedResource(resource, StandardCharsets.UTF_8));
|
||||
|
||||
assertEquals("fallback", source.getName());
|
||||
assertEquals("strict", source.getProperty("feature.mode"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建具有稳定文件名的内存资源,以触发配置源工厂对应的扩展名分支。
|
||||
*
|
||||
* @param filename 资源文件名
|
||||
* @param content 资源内容
|
||||
* @return 命名内存资源
|
||||
*/
|
||||
private static ByteArrayResource namedResource(String filename, String content) {
|
||||
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,251 +0,0 @@
|
||||
package org.dromara.common.core.utils;
|
||||
|
||||
import cn.hutool.core.lang.Dict;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import io.github.linpeilie.Converter;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import jakarta.validation.Validator;
|
||||
import org.dromara.common.core.utils.reflect.AnnotationUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.i18n.LocaleContextHolder;
|
||||
import org.springframework.context.support.StaticApplicationContext;
|
||||
import org.springframework.context.support.StaticMessageSource;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("common-core 框架工具契约单元测试")
|
||||
class CoreFrameworkUtilityContractTest {
|
||||
|
||||
private static Converter converter;
|
||||
private static Validator validator;
|
||||
private static StaticMessageSource messageSource;
|
||||
|
||||
/**
|
||||
* 初始化框架工具静态依赖的最小 Spring 容器,确保测试不需要启动完整应用。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeFrameworkUtilities() {
|
||||
converter = mock(Converter.class);
|
||||
validator = mock(Validator.class);
|
||||
StaticApplicationContext context = new StaticApplicationContext();
|
||||
context.getBeanFactory().registerSingleton("converter", converter);
|
||||
context.getBeanFactory().registerSingleton("validator", validator);
|
||||
messageSource = context.getStaticMessageSource();
|
||||
messageSource.addMessage("welcome", Locale.SIMPLIFIED_CHINESE, "欢迎 {0}");
|
||||
context.refresh();
|
||||
new SpringUtil().setApplicationContext(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理线程语言环境和 mock 调用记录,避免测试之间共享状态。
|
||||
*/
|
||||
@AfterEach
|
||||
void resetFrameworkState() {
|
||||
LocaleContextHolder.resetLocaleContext();
|
||||
reset(converter, validator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Mapstruct Plus 的对象转换和目标对象填充均委托给框架 Converter。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("委托对象转换和目标填充")
|
||||
void shouldDelegateObjectConversions() {
|
||||
Source source = new Source("alice");
|
||||
Destination converted = new Destination("converted");
|
||||
Destination target = new Destination("existing");
|
||||
when(converter.convert(source, Destination.class)).thenReturn(converted);
|
||||
when(converter.convert(source, target)).thenReturn(target);
|
||||
|
||||
assertSame(converted, MapstructUtils.convert(source, Destination.class));
|
||||
assertSame(target, MapstructUtils.convert(source, target));
|
||||
verify(converter).convert(source, Destination.class);
|
||||
verify(converter).convert(source, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证对象转换在来源或目标为空时直接返回空值,不误调用底层 Converter。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("短路空对象转换")
|
||||
void shouldShortCircuitNullObjectConversions() {
|
||||
Source source = new Source("alice");
|
||||
|
||||
assertNull(MapstructUtils.convert((Source) null, Destination.class));
|
||||
assertNull(MapstructUtils.convert(source, (Class<Destination>) null));
|
||||
assertNull(MapstructUtils.convert(source, (Destination) null));
|
||||
assertNull(MapstructUtils.convert((Source) null, new Destination("existing")));
|
||||
verifyNoInteractions(converter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证列表转换保留 null、空列表和普通列表各自约定的返回语义。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("转换列表并处理空输入")
|
||||
void shouldConvertListsAndHandleEmptyInputs() {
|
||||
List<Source> sources = List.of(new Source("alice"));
|
||||
List<Destination> targets = List.of(new Destination("converted"));
|
||||
when(converter.convert(sources, Destination.class)).thenReturn(targets);
|
||||
|
||||
assertNull(MapstructUtils.convert((List<Source>) null, Destination.class));
|
||||
assertEquals(List.of(), MapstructUtils.convert(List.<Source>of(), Destination.class));
|
||||
assertSame(targets, MapstructUtils.convert(sources, Destination.class));
|
||||
verify(converter).convert(sources, Destination.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Map 转 Bean 仅在数据和目标类型有效时调用 Mapstruct Plus。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("转换 Map 并处理无效输入")
|
||||
void shouldConvertMapsAndHandleInvalidInputs() {
|
||||
Map<String, Object> source = Map.of("name", "alice");
|
||||
Destination target = new Destination("converted");
|
||||
when(converter.convert(source, Destination.class)).thenReturn(target);
|
||||
|
||||
assertNull(MapstructUtils.convert((Map<String, Object>) null, Destination.class));
|
||||
assertNull(MapstructUtils.convert(Map.of(), Destination.class));
|
||||
assertNull(MapstructUtils.convert(source, null));
|
||||
assertSame(target, MapstructUtils.convert(source, Destination.class));
|
||||
verify(converter).convert(source, Destination.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证安全 Getter 只在对象和函数均有效时求值,并在无法求值时返回约定默认值。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("安全读取对象属性")
|
||||
void shouldReadObjectPropertiesSafely() {
|
||||
Source source = new Source("alice");
|
||||
|
||||
assertEquals("alice", ObjectUtils.notNullGetter(source, Source::name));
|
||||
assertNull(ObjectUtils.notNullGetter(null, Source::name));
|
||||
assertNull(ObjectUtils.notNullGetter(source, null));
|
||||
assertEquals("fallback", ObjectUtils.notNullGetter(null, Source::name, "fallback"));
|
||||
assertEquals("fallback", ObjectUtils.notNullGetter(source, null, "fallback"));
|
||||
assertEquals("alice", ObjectUtils.notNullGetter(source, Source::name, "fallback"));
|
||||
assertEquals("alice", ObjectUtils.notNull("alice", "fallback"));
|
||||
assertEquals("fallback", ObjectUtils.notNull(null, "fallback"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Bean Validation 在无约束违规时正常返回并传递指定校验组。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("通过 Bean Validation 校验有效对象")
|
||||
void shouldValidateObjectWithRequestedGroups() {
|
||||
Source source = new Source("alice");
|
||||
when(validator.validate(source, ValidationGroup.class)).thenReturn(Set.of());
|
||||
|
||||
assertDoesNotThrow(() -> ValidatorUtils.validate(source, ValidationGroup.class));
|
||||
verify(validator).validate(source, ValidationGroup.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Bean Validation 对空对象和约束违规分别抛出框架约定的异常。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("报告空对象和约束违规")
|
||||
void shouldReportNullObjectAndConstraintViolations() {
|
||||
RuntimeException nullException = assertThrows(RuntimeException.class,
|
||||
() -> ValidatorUtils.validate(null));
|
||||
@SuppressWarnings("unchecked")
|
||||
ConstraintViolation<Source> violation = mock(ConstraintViolation.class);
|
||||
Source source = new Source("");
|
||||
when(validator.validate(source)).thenReturn(Set.of(violation));
|
||||
|
||||
ConstraintViolationException validationException = assertThrows(ConstraintViolationException.class,
|
||||
() -> ValidatorUtils.validate(source));
|
||||
|
||||
assertEquals("请求参数不能为空", nullException.getMessage());
|
||||
assertEquals("参数校验异常", validationException.getMessage());
|
||||
assertEquals(Set.of(violation), validationException.getConstraintViolations());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证国际化工具使用线程当前语言环境和消息参数查询 MessageSource。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("按当前语言环境解析国际化消息")
|
||||
void shouldResolveMessageUsingCurrentLocale() {
|
||||
Locale locale = Locale.SIMPLIFIED_CHINESE;
|
||||
LocaleContextHolder.setLocale(locale);
|
||||
assertEquals("欢迎 alice", MessageUtils.message("welcome", "alice"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺少国际化资源时返回消息键,保持调用方可读的降级结果。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("缺少国际化资源时返回消息键")
|
||||
void shouldFallbackToCodeWhenMessageIsMissing() {
|
||||
assertEquals("missing.code", MessageUtils.message("missing.code"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证按注解全类名读取注解实例及属性字典,防止 Hutool 升级改变反射结果。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("按全类名读取注解和属性")
|
||||
void shouldReadAnnotationAndValuesByClassName() {
|
||||
String annotationName = ContractMarker.class.getName();
|
||||
|
||||
ContractMarker annotation = assertInstanceOf(ContractMarker.class,
|
||||
AnnotationUtils.getAnnotation(AnnotatedType.class, annotationName));
|
||||
Dict values = AnnotationUtils.getAnnotationValueMap(AnnotatedType.class, annotationName);
|
||||
|
||||
assertEquals("core", annotation.value());
|
||||
assertEquals(3, annotation.level());
|
||||
assertNotNull(values);
|
||||
assertEquals("core", values.getStr("value"));
|
||||
assertEquals(3, values.getInt("level"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证注解类不存在或目标元素未标注时返回空值,不把反射细节泄漏给调用方。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("处理缺失注解类型和未标注元素")
|
||||
void shouldHandleMissingAnnotationTypesAndValues() {
|
||||
assertNull(AnnotationUtils.getAnnotation(String.class, ContractMarker.class.getName()));
|
||||
assertNull(AnnotationUtils.getAnnotation(AnnotatedType.class, "missing.Annotation"));
|
||||
assertNull(AnnotationUtils.getAnnotationValueMap(AnnotatedType.class, "missing.Annotation"));
|
||||
}
|
||||
|
||||
private interface ValidationGroup {
|
||||
}
|
||||
|
||||
private record Source(String name) {
|
||||
}
|
||||
|
||||
private record Destination(String name) {
|
||||
}
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
private @interface ContractMarker {
|
||||
|
||||
String value();
|
||||
|
||||
int level();
|
||||
}
|
||||
|
||||
@ContractMarker(value = "core", level = 3)
|
||||
private static final class AnnotatedType {
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
package org.dromara.common.core.utils;
|
||||
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import org.dromara.common.core.service.DictService;
|
||||
import org.dromara.common.core.validate.dicts.DictPattern;
|
||||
import org.dromara.common.core.validate.dicts.DictPatternValidator;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("common-core 基础设施契约单元测试")
|
||||
class CoreInfrastructureContractTest {
|
||||
|
||||
/**
|
||||
* 清理线程绑定的请求上下文,避免测试之间共享 Servlet 状态。
|
||||
*/
|
||||
@AfterEach
|
||||
void resetRequestContext() {
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Servlet 工具从 Spring 请求上下文读取参数、响应和会话,并保持类型转换约定。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("读取线程绑定的 Servlet 上下文")
|
||||
void shouldReadRequestContextAndConvertParameters() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
request.setParameter("name", "alice");
|
||||
request.setParameter("age", "18");
|
||||
request.setParameter("enabled", "true");
|
||||
request.setParameter("roles", "admin", "user");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response));
|
||||
|
||||
assertSame(request, ServletUtils.getRequest());
|
||||
assertSame(response, ServletUtils.getResponse());
|
||||
assertEquals("alice", ServletUtils.getParameter("name"));
|
||||
assertEquals("fallback", ServletUtils.getParameter("missing", "fallback"));
|
||||
assertEquals(18, ServletUtils.getParameterToInt("age"));
|
||||
assertTrue(ServletUtils.getParameterToBool("enabled"));
|
||||
assertEquals("admin,user", ServletUtils.getParamMap(request).get("roles"));
|
||||
assertSame(request.getSession(), ServletUtils.getSession());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求头解码、Ajax 识别、代理 IP 解析和 JSON 响应渲染保持稳定。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("处理常用 HTTP 协议细节")
|
||||
void shouldHandleHeadersAjaxClientIpAndJsonRendering() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/report.json");
|
||||
request.addHeader("X-Name", ServletUtils.urlEncode("中文 value"));
|
||||
request.addHeader("X-Forwarded-For", "[2001:db8::1]");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
assertEquals("中文 value", ServletUtils.getHeader(request, "X-Name"));
|
||||
assertEquals("", ServletUtils.getHeader(request, "missing"));
|
||||
assertEquals(ServletUtils.getHeaders(request).get("x-name"), request.getHeader("X-Name"));
|
||||
assertTrue(ServletUtils.isAjaxRequest(request));
|
||||
assertEquals("2001:db8::1", ServletUtils.getClientIP(request));
|
||||
|
||||
ServletUtils.renderString(response, "{\"ok\":true}");
|
||||
|
||||
assertEquals(200, response.getStatus());
|
||||
assertTrue(response.getContentType().startsWith("application/json"));
|
||||
assertEquals("{\"ok\":true}", response.getContentAsString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证虚拟线程批量执行保持提交顺序,并把任务异常的真实原因传递给调用方。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("批量执行虚拟线程任务")
|
||||
void shouldPreserveVirtualTaskOrderAndFailureCause() {
|
||||
List<Integer> results = ThreadUtils.virtualSubmitAll(
|
||||
() -> 1,
|
||||
() -> 2,
|
||||
() -> 3);
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class,
|
||||
() -> ThreadUtils.virtualInvokeAll(() -> {
|
||||
throw new IllegalStateException("task-failed");
|
||||
}));
|
||||
|
||||
assertEquals(List.of(1, 2, 3), results);
|
||||
assertInstanceOf(IllegalStateException.class, exception.getCause());
|
||||
assertTrue(exception.getMessage().contains("task-failed"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字典校验器按注解分隔符调用字典服务,并正确处理空值、缺失类型和未知字典值。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("通过字典服务校验字段值")
|
||||
void shouldValidateDictionaryValuesThroughConfiguredService() {
|
||||
DictPattern annotation = mock(DictPattern.class);
|
||||
when(annotation.dictType()).thenReturn("sys_status");
|
||||
when(annotation.separator()).thenReturn("|");
|
||||
DictService dictService = mock(DictService.class);
|
||||
when(dictService.getDictLabel("sys_status", "0|1", "|")).thenReturn("正常|停用");
|
||||
when(dictService.getDictLabel("sys_status", "9", "|")).thenReturn("");
|
||||
DictPatternValidator validator = new DictPatternValidator();
|
||||
validator.initialize(annotation);
|
||||
|
||||
try (MockedStatic<SpringUtil> spring = mockStatic(SpringUtil.class)) {
|
||||
spring.when(() -> SpringUtil.getBean(DictService.class)).thenReturn(dictService);
|
||||
|
||||
assertTrue(validator.isValid(null, mock(ConstraintValidatorContext.class)));
|
||||
assertTrue(validator.isValid("0|1", null));
|
||||
assertFalse(validator.isValid("9", null));
|
||||
}
|
||||
|
||||
DictPattern invalidAnnotation = mock(DictPattern.class);
|
||||
when(invalidAnnotation.dictType()).thenReturn(" ");
|
||||
DictPatternValidator invalidValidator = new DictPatternValidator();
|
||||
invalidValidator.initialize(invalidAnnotation);
|
||||
assertFalse(invalidValidator.isValid("0", null));
|
||||
}
|
||||
}
|
||||
@ -1,156 +0,0 @@
|
||||
package org.dromara.common.core.utils;
|
||||
|
||||
import cn.hutool.core.exceptions.ValidateException;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.dromara.common.core.utils.file.FileUtils;
|
||||
import org.dromara.common.core.utils.file.MimeTypeUtils;
|
||||
import org.dromara.common.core.utils.reflect.ReflectUtils;
|
||||
import org.dromara.common.core.utils.regex.RegexUtils;
|
||||
import org.dromara.common.core.utils.regex.RegexValidator;
|
||||
import org.dromara.common.core.xss.XssValidator;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@DisplayName("common-core 公共工具边界单元测试")
|
||||
class CoreUtilityBoundaryTest {
|
||||
|
||||
/**
|
||||
* 验证下载文件名对中文、空格和加号执行 URL 编码,并同步写入浏览器可读取的响应头。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("编码并设置下载文件名响应头")
|
||||
void shouldEncodeAndExposeAttachmentFileName() {
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
String encoded = "%E6%B5%8B%E8%AF%95%20report%2B1.xlsx";
|
||||
|
||||
assertEquals(encoded, FileUtils.percentEncode("测试 report+1.xlsx"));
|
||||
|
||||
FileUtils.setAttachmentResponseHeader(response, "测试 report+1.xlsx");
|
||||
|
||||
verify(response).addHeader("Access-Control-Expose-Headers", "Content-Disposition,download-filename");
|
||||
verify(response).setHeader("Content-disposition",
|
||||
"attachment; filename=" + encoded + ";filename*=utf-8''" + encoded);
|
||||
verify(response).setHeader("download-filename", encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传文件类型判断忽略扩展名大小写,同时拒绝未列入白名单的可执行文件。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("识别允许的文件扩展名")
|
||||
void shouldRecognizeAllowedFileExtensionsCaseInsensitively() {
|
||||
assertTrue(MimeTypeUtils.isImage("JpEg"));
|
||||
assertTrue(MimeTypeUtils.isVideo("MP4"));
|
||||
assertTrue(MimeTypeUtils.isMedia("Mp3"));
|
||||
assertTrue(MimeTypeUtils.isDefaultAllowed("PDF"));
|
||||
assertFalse(MimeTypeUtils.isDefaultAllowed("exe"));
|
||||
assertFalse(MimeTypeUtils.isImage(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正则提取失败时稳定回退默认值,并校验账号与状态的有效边界。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("处理正则提取与业务格式边界")
|
||||
void shouldHandleRegexExtractionAndValidationBoundaries() {
|
||||
assertEquals("42", RegexUtils.extractFromString("order-42", "order-(\\d+)", "none"));
|
||||
assertEquals("none", RegexUtils.extractFromString("missing", "order-(\\d+)", "none"));
|
||||
assertEquals("none", RegexUtils.extractFromString("order-42", "([", "none"));
|
||||
|
||||
assertTrue(RegexValidator.isAccount("user_1"));
|
||||
assertFalse(RegexValidator.isAccount("1user"));
|
||||
assertFalse(RegexValidator.isAccount("user"));
|
||||
assertTrue(RegexValidator.isStatus("0"));
|
||||
assertTrue(RegexValidator.isStatus("1"));
|
||||
assertFalse(RegexValidator.isStatus("2"));
|
||||
ValidateException exception = assertThrows(ValidateException.class,
|
||||
() -> RegexValidator.validateAccount("bad", "账号格式错误"));
|
||||
assertEquals("账号格式错误", exception.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证反射工具可以沿 JavaBean 属性路径读取和修改嵌套对象,保障 Excel 等调用方的字段访问。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("读写嵌套 JavaBean 属性")
|
||||
void shouldReadAndWriteNestedBeanProperties() {
|
||||
TestRoot root = new TestRoot(new TestChild("before"));
|
||||
|
||||
assertEquals("before", ReflectUtils.invokeGetter(root, "child.name"));
|
||||
|
||||
ReflectUtils.invokeSetter(root, "child.name", "after");
|
||||
|
||||
assertEquals("after", root.getChild().getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 XSS 校验允许空值和普通文本,并拒绝任意 HTML 标签输入。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝包含 HTML 标签的文本")
|
||||
void shouldRejectHtmlMarkup() {
|
||||
XssValidator validator = new XssValidator();
|
||||
|
||||
assertTrue(validator.isValid(null, null));
|
||||
assertTrue(validator.isValid("plain text", null));
|
||||
assertFalse(validator.isValid("<script>alert(1)</script>", null));
|
||||
assertFalse(validator.isValid("hello <b>world</b>", null));
|
||||
}
|
||||
|
||||
private static class TestRoot {
|
||||
private final TestChild child;
|
||||
|
||||
/**
|
||||
* 创建带子对象的测试根对象。
|
||||
*
|
||||
* @param child 子对象
|
||||
*/
|
||||
private TestRoot(TestChild child) {
|
||||
this.child = child;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回子对象,供嵌套反射路径访问。
|
||||
*
|
||||
* @return 子对象
|
||||
*/
|
||||
public TestChild getChild() {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
private static class TestChild {
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 创建具有初始名称的测试子对象。
|
||||
*
|
||||
* @param name 初始名称
|
||||
*/
|
||||
private TestChild(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回名称,供嵌套反射路径读取。
|
||||
*
|
||||
* @return 名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改名称,供嵌套反射路径写入。
|
||||
*
|
||||
* @param name 新名称
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,114 +0,0 @@
|
||||
package org.dromara.common.core.utils;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("日期、网络与脱敏工具单元测试")
|
||||
class DateNetDesensitizedUtilsTest {
|
||||
|
||||
/**
|
||||
* 验证时间差可以忽略参数顺序并按秒精度格式化。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("格式化时间差")
|
||||
void shouldFormatTimeDifferenceBySecond() {
|
||||
Date start = new Date(0);
|
||||
Date end = new Date(3_661_000);
|
||||
|
||||
assertEquals("1小时1分1秒", DateUtils.formatBetweenBySecond(start, end));
|
||||
assertEquals("1小时1分1秒", DateUtils.formatBetweenBySecond(end, start));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证日期范围接受边界值,并拒绝倒序、超限和不支持的单位。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("校验日期范围")
|
||||
void shouldValidateDateRangeAndUnits() {
|
||||
Date start = DateUtil.parse("2026-01-01 00:00:00");
|
||||
Date end = DateUtil.parse("2026-01-03 00:00:00");
|
||||
|
||||
assertDoesNotThrow(() -> DateUtils.validateDateRange(start, end, 2, TimeUnit.DAYS));
|
||||
assertThrows(ServiceException.class, () -> DateUtils.validateDateRange(end, start, 2, TimeUnit.DAYS));
|
||||
assertThrows(ServiceException.class, () -> DateUtils.validateDateRange(start, end, 1, TimeUnit.DAYS));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> DateUtils.validateDateRange(start, end, 1, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证一天内各小时会映射到凌晨、上午、中午、下午和晚上。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("识别当天时间段")
|
||||
void shouldResolveTodayPeriod() {
|
||||
assertEquals("凌晨", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 06:00:00")));
|
||||
assertEquals("上午", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 09:00:00")));
|
||||
assertEquals("中午", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 12:00:00")));
|
||||
assertEquals("下午", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 15:00:00")));
|
||||
assertEquals("晚上", DateUtils.getTodayHour(DateUtil.parse("2026-01-01 20:00:00")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证友好时间处理空值、刚刚、分钟前以及未来时间,并确保未来时间使用目标日期。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("格式化友好时间")
|
||||
void shouldFormatFriendlyTimeUsingTargetDate() {
|
||||
Date now = new Date();
|
||||
Date future = DateUtil.offsetDay(now, 2);
|
||||
|
||||
assertEquals("", DateUtils.formatFriendlyTime(null));
|
||||
assertEquals("刚刚", DateUtils.formatFriendlyTime(DateUtil.offsetSecond(now, -10)));
|
||||
assertTrue(DateUtils.formatFriendlyTime(DateUtil.offsetMinute(now, -5)).endsWith("分钟前"));
|
||||
assertEquals(DateUtils.formatDateTime(future), DateUtils.formatFriendlyTime(future));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 IPv4、IPv6、精确地址、通配符和 CIDR 规则的匹配结果。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("匹配 IP 地址规则")
|
||||
void shouldMatchIpAddressRules() {
|
||||
assertTrue(NetUtils.isIPv4("192.168.1.1"));
|
||||
assertFalse(NetUtils.isIPv4("999.1.1.1"));
|
||||
assertTrue(NetUtils.isIPv6("::1"));
|
||||
assertTrue(NetUtils.isInnerIPv6("::1"));
|
||||
assertTrue(NetUtils.isMatchIpRule("192.168.1.10", "192.168.1.10"));
|
||||
assertTrue(NetUtils.isMatchIpRule("192.168.*.?", "192.168.1.8"));
|
||||
assertTrue(NetUtils.isMatchIpRule("10.0.0.0/8", "10.20.30.40"));
|
||||
assertFalse(NetUtils.isMatchIpRule("10.0.0.0/8", "11.20.30.40"));
|
||||
assertFalse(NetUtils.isMatchCidr("10.0.0.0/99", "10.0.0.1"));
|
||||
assertFalse(NetUtils.isMatchIpRule(" ", "10.0.0.1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通脱敏在短值、临界值和标准长度下应用固定掩码规则。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("应用固定长度脱敏")
|
||||
void shouldMaskValuesWithFixedLength() {
|
||||
assertNull(DesensitizedUtils.mask(null, 2, 2, 4));
|
||||
assertEquals("***", DesensitizedUtils.mask("abc", 2, 2, 4));
|
||||
assertEquals("ab****", DesensitizedUtils.mask("abcdef", 2, 2, 4));
|
||||
assertEquals("ab****g", DesensitizedUtils.mask("abcdefg", 2, 2, 4));
|
||||
assertEquals("ab****ij", DesensitizedUtils.mask("abcdefghij", 2, 2, 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证高安全脱敏对短 Token 全掩码,并在长 Token 中仅保留指定首尾字符。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("应用高安全脱敏")
|
||||
void shouldMaskHighSecurityValues() {
|
||||
assertEquals("***", DesensitizedUtils.maskHighSecurity("abc", 3, 2));
|
||||
assertEquals("ab***", DesensitizedUtils.maskHighSecurity("abcde", 3, 2));
|
||||
assertEquals("ab******ij", DesensitizedUtils.maskHighSecurity("abcdefghij", 2, 2));
|
||||
}
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
package org.dromara.common.core.utils;
|
||||
|
||||
import cn.hutool.core.exceptions.UtilException;
|
||||
import org.dromara.common.core.utils.sql.SqlUtil;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("SqlUtil 单元测试")
|
||||
class SqlUtilTest {
|
||||
|
||||
/**
|
||||
* 验证合法字段、逗号和表字段表达式可以通过排序校验。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("接受合法的多字段排序表达式")
|
||||
void escapeOrderBySqlShouldAcceptSafeColumns() {
|
||||
assertEquals("user_name,create_time", SqlUtil.escapeOrderBySql("user_name,create_time"));
|
||||
assertTrue(SqlUtil.isValidOrderBySql("table_name.column_name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证包含 SQL 分隔符等非法字符的排序参数会被拒绝。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝包含非法字符的排序表达式")
|
||||
void escapeOrderBySqlShouldRejectUnsafeCharacters() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> SqlUtil.escapeOrderBySql("create_time desc;drop table sys_user"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证单引号、关键字及大小写空白变体无法绕过过滤。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝单引号和 SQL 敏感关键词")
|
||||
void filterKeywordShouldRejectRiskyInput() {
|
||||
assertThrows(UtilException.class, () -> SqlUtil.filterKeyword("name='admin'"));
|
||||
assertThrows(UtilException.class, () -> SqlUtil.filterKeyword("union select password"));
|
||||
assertThrows(UtilException.class, () -> SqlUtil.filterKeyword("UNION\tSELECT password"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通业务文本和空值不会被误判为 SQL 注入。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("允许普通业务查询文本")
|
||||
void filterKeywordShouldAcceptRegularText() {
|
||||
assertDoesNotThrow(() -> SqlUtil.filterKeyword("normal-business-value"));
|
||||
assertDoesNotThrow(() -> SqlUtil.filterKeyword(null));
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
package org.dromara.common.core.utils;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("StreamUtils 单元测试")
|
||||
class StreamUtilsTest {
|
||||
|
||||
/**
|
||||
* 验证过滤结果保持可修改,并对空输入返回空集合。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("过滤结果可修改且空集合返回空列表")
|
||||
void filterShouldReturnMutableListAndHandleEmptyInput() {
|
||||
List<Integer> result = StreamUtils.filter(List.of(1, 2, 3), value -> value % 2 == 1);
|
||||
|
||||
assertEquals(List.of(1, 3), result);
|
||||
assertDoesNotThrow(() -> result.add(5));
|
||||
assertTrue(StreamUtils.<Integer>filter(null, value -> true).isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证集合拼接会跳过映射函数产生的空值。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拼接时忽略映射结果中的空值")
|
||||
void joinShouldIgnoreNullMappedValues() {
|
||||
String result = StreamUtils.join(List.of("first", "skip", "last"),
|
||||
value -> "skip".equals(value) ? null : value, "|");
|
||||
|
||||
assertEquals("first|last", result);
|
||||
assertEquals(StringUtils.EMPTY, StreamUtils.join(List.<String>of(), value -> value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证集合转 Map 时的空元素过滤和重复键处理规则。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("转 Map 时重复键保留第一个值并忽略空元素")
|
||||
void toMapShouldKeepFirstDuplicateValue() {
|
||||
List<TestItem> items = Arrays.asList(
|
||||
new TestItem(1L, "first"),
|
||||
new TestItem(1L, "second"),
|
||||
null,
|
||||
new TestItem(2L, "third")
|
||||
);
|
||||
|
||||
Map<Long, String> result = StreamUtils.toMap(items, TestItem::id, TestItem::value);
|
||||
|
||||
assertEquals(Map.of(1L, "first", 2L, "third"), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分组结果按照键在输入中的首次出现顺序排列。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("分组结果保持输入键的出现顺序")
|
||||
void groupByKeyShouldPreserveKeyOrder() {
|
||||
List<TestItem> items = List.of(
|
||||
new TestItem(2L, "a"),
|
||||
new TestItem(1L, "b"),
|
||||
new TestItem(2L, "c")
|
||||
);
|
||||
|
||||
Map<Long, List<TestItem>> result = StreamUtils.groupByKey(items, TestItem::id);
|
||||
|
||||
assertEquals(List.of(2L, 1L), result.keySet().stream().toList());
|
||||
assertEquals(List.of("a", "c"), result.get(2L).stream().map(TestItem::value).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证两个 Map 合并时同时处理独有键和共有键。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("合并 Map 时覆盖两侧独有键和共有键")
|
||||
void mergeShouldCoverUnionOfKeys() {
|
||||
Map<Long, String> left = new LinkedHashMap<>();
|
||||
left.put(1L, "L1");
|
||||
left.put(2L, "L2");
|
||||
Map<Long, String> right = new LinkedHashMap<>();
|
||||
right.put(2L, "R2");
|
||||
right.put(3L, "R3");
|
||||
|
||||
Map<Long, String> result = StreamUtils.merge(left, right,
|
||||
(leftValue, rightValue) -> String.valueOf(leftValue) + ":" + String.valueOf(rightValue));
|
||||
|
||||
assertEquals("L1:null", result.get(1L));
|
||||
assertEquals("L2:R2", result.get(2L));
|
||||
assertEquals("null:R3", result.get(3L));
|
||||
}
|
||||
|
||||
private record TestItem(Long id, String value) {
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
package org.dromara.common.core.utils;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("StringUtils 补充单元测试")
|
||||
class StringUtilsTest {
|
||||
|
||||
/**
|
||||
* 验证空白默认值、裁剪、截取、格式化和命名转换等基础字符串操作。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("处理基础字符串转换")
|
||||
void shouldHandleBasicStringTransformations() {
|
||||
assertEquals("fallback", StringUtils.blankToDefault(" ", "fallback"));
|
||||
assertTrue(StringUtils.isEmpty(""));
|
||||
assertTrue(StringUtils.isNotEmpty("value"));
|
||||
assertEquals("value", StringUtils.trim(" value "));
|
||||
assertEquals("bc", StringUtils.substring("abcd", 1, 3));
|
||||
assertEquals("id=12", StringUtils.format("id={}", 12));
|
||||
assertEquals("user_name", StringUtils.toUnderScoreCase("userName"));
|
||||
assertEquals("HelloWorld", StringUtils.convertToCamelCase("HELLO_WORLD"));
|
||||
assertEquals("userName", StringUtils.toCamelCase("user_name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分隔字符串时的空白过滤、裁剪、去重和自定义类型转换行为。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拆分字符串集合")
|
||||
void shouldSplitStringsIntoCollections() {
|
||||
assertEquals(List.of("a", "b"), StringUtils.str2List(" a, ,b ", ",", true, true));
|
||||
assertEquals(Set.of("a", "b"), StringUtils.str2Set("a,b,a", ","));
|
||||
assertEquals(List.of("a", "b"), StringUtils.splitList("a,b"));
|
||||
assertEquals(List.of(1, 2), StringUtils.splitTo("1|2", "|", value -> Integer.valueOf(value.toString())));
|
||||
assertTrue(StringUtils.splitList(" ").isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Ant 风格路径规则可以区分单层通配符、跨层通配符和空输入。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("匹配路径规则")
|
||||
void shouldMatchAntStylePaths() {
|
||||
assertTrue(StringUtils.isMatch("/system/**", "/system/user/list"));
|
||||
assertFalse(StringUtils.isMatch("/system/*", "/system/user/list"));
|
||||
assertTrue(StringUtils.matches("/system/user/list", List.of("/login", "/system/**")));
|
||||
assertFalse(StringUtils.matches("", List.of("/**")));
|
||||
assertFalse(StringUtils.matches("/system", List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证定长左补齐会补零、截取尾部,并正确处理空值。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("定长左补齐字符串")
|
||||
void shouldPadOrTruncateFromLeft() {
|
||||
assertEquals("0012", StringUtils.padl(12, 4));
|
||||
assertEquals("cdef", StringUtils.padl("abcdef", 4, '0'));
|
||||
assertEquals("***a", StringUtils.padl("a", 4, '*'));
|
||||
assertEquals("000", StringUtils.padl(null, 3, '0'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证大小写敏感和忽略大小写的查找、前后缀与替换方法保持不同语义。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("比较和替换字符串")
|
||||
void shouldCompareAndReplaceStrings() {
|
||||
assertTrue(StringUtils.containsAnyIgnoreCase("Hello", "WORLD", "he"));
|
||||
assertTrue(StringUtils.inStringIgnoreCase("ADMIN", "user", "admin"));
|
||||
assertTrue(StringUtils.startWithAnyIgnoreCase("Bearer token", "basic", "bearer"));
|
||||
assertTrue(StringUtils.equalsAny("a", "b", "a"));
|
||||
assertTrue(StringUtils.equalsAnyIgnoreCase("A", "b", "a"));
|
||||
assertTrue(StringUtils.containsIgnoreCase("Hello", "ELL"));
|
||||
assertTrue(StringUtils.endsWithIgnoreCase("report.XLSX", ".xlsx"));
|
||||
assertEquals(2, StringUtils.indexOf("abcabc", "ca"));
|
||||
assertEquals("path", StringUtils.removeStart("/path", "/"));
|
||||
assertEquals("a-b_c", StringUtils.replaceOnce("a_b_c", "_", "-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字符集转换、URL 判断与逗号拼接的公共便捷方法。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("处理编码和拼接")
|
||||
void shouldHandleEncodingUrlAndJoining() {
|
||||
assertEquals("中文", StringUtils.convert("中文", StandardCharsets.UTF_8, StandardCharsets.UTF_8));
|
||||
assertEquals("", StringUtils.convert("", StandardCharsets.UTF_8, StandardCharsets.UTF_16));
|
||||
assertTrue(StringUtils.ishttp("https://example.com/path"));
|
||||
assertEquals("a,b", StringUtils.joinComma(List.of("a", "b")));
|
||||
assertEquals("1,2", StringUtils.joinComma(new Integer[]{1, 2}));
|
||||
}
|
||||
}
|
||||
@ -1,98 +0,0 @@
|
||||
package org.dromara.common.core.utils;
|
||||
|
||||
import cn.hutool.core.lang.tree.Tree;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("TreeBuildUtils 单元测试")
|
||||
class TreeBuildUtilsTest {
|
||||
|
||||
static {
|
||||
// TreeBuildUtils 会将 Hutool 默认名称键调整为前端使用的 label。
|
||||
TreeBuildUtils.DEFAULT_CONFIG.getNameKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证递归遍历只返回没有子节点的叶子节点。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("获取树中所有叶子节点")
|
||||
void getLeafNodesShouldReturnOnlyLeaves() {
|
||||
Tree<Long> root = tree(1L, "root");
|
||||
Tree<Long> leaf = tree(2L, "leaf");
|
||||
Tree<Long> branch = tree(3L, "branch");
|
||||
Tree<Long> nestedLeaf = tree(4L, "nested");
|
||||
branch.setChildren(List.of(nestedLeaf));
|
||||
root.setChildren(List.of(leaf, branch));
|
||||
|
||||
List<Tree<Long>> result = TreeBuildUtils.getLeafNodes(List.of(root));
|
||||
|
||||
assertEquals(List.of(2L, 4L), result.stream().map(Tree::getId).toList());
|
||||
assertTrue(TreeBuildUtils.<Long>getLeafNodes(null).isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证树节点按照深度优先顺序生成完整路径键。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("按深度优先顺序构建节点路径映射")
|
||||
void buildTreeNodeMapShouldCreateFullPaths() {
|
||||
Tree<Long> root = tree(1L, "root");
|
||||
Tree<Long> child = tree(2L, "child");
|
||||
Tree<Long> leaf = tree(3L, "leaf");
|
||||
child.setChildren(List.of(leaf));
|
||||
root.setChildren(List.of(child));
|
||||
|
||||
Map<String, Tree<Long>> result = TreeBuildUtils.buildTreeNodeMap(List.of(root), "/", Tree::getName);
|
||||
|
||||
assertEquals(List.of("root", "root/child", "root/child/leaf"), result.keySet().stream().toList());
|
||||
assertEquals(3L, result.get("root/child/leaf").getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证不同父级来源的顶级节点可以合并为多根树。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构建包含多个顶级节点的树")
|
||||
void buildMultiRootShouldKeepAllRoots() {
|
||||
List<TestNode> nodes = List.of(
|
||||
new TestNode(1L, 0L, "root-a"),
|
||||
new TestNode(2L, 1L, "child-a"),
|
||||
new TestNode(10L, 9L, "root-b")
|
||||
);
|
||||
|
||||
List<Tree<Long>> result = TreeBuildUtils.buildMultiRoot(nodes, TestNode::id, TestNode::parentId,
|
||||
(node, treeNode) -> treeNode.setId(node.id()).setParentId(node.parentId()).setName(node.name()));
|
||||
|
||||
Set<Long> rootIds = result.stream().map(Tree::getId).collect(Collectors.toSet());
|
||||
assertEquals(Set.of(1L, 10L), rootIds);
|
||||
Tree<Long> firstRoot = result.stream().filter(tree -> tree.getId().equals(1L)).findFirst().orElseThrow();
|
||||
assertEquals(List.of(2L), firstRoot.getChildren().stream().map(Tree::getId).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试使用的最小树节点。
|
||||
*
|
||||
* @param id 节点 ID
|
||||
* @param name 节点名称
|
||||
* @return 树节点
|
||||
*/
|
||||
private static Tree<Long> tree(Long id, String name) {
|
||||
Tree<Long> tree = new Tree<>();
|
||||
tree.setId(id);
|
||||
tree.setName(name);
|
||||
return tree;
|
||||
}
|
||||
|
||||
private record TestNode(Long id, Long parentId, String name) {
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
package org.dromara.common.core.validate.enums;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("EnumPatternValidator 单元测试")
|
||||
class EnumPatternValidatorTest {
|
||||
|
||||
/**
|
||||
* 验证字符串形式的数字能够按枚举字段实际类型转换并完成合法值校验。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("校验枚举字段值")
|
||||
void shouldValidateConvertedEnumFieldValues() throws NoSuchFieldException {
|
||||
EnumPatternValidator validator = validatorFor("status");
|
||||
|
||||
assertTrue(validator.isValid(null, null));
|
||||
assertTrue(validator.isValid(1, null));
|
||||
assertTrue(validator.isValid("2", null));
|
||||
assertFalse(validator.isValid("invalid", null));
|
||||
assertFalse(validator.isValid(9, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未配置枚举字段名时不会错误接受非空输入。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝缺少枚举字段配置的输入")
|
||||
void shouldRejectValueWhenEnumFieldIsNotConfigured() throws NoSuchFieldException {
|
||||
EnumPatternValidator validator = validatorFor("unconfigured");
|
||||
|
||||
assertTrue(validator.isValid(null, null));
|
||||
assertFalse(validator.isValid(1, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据测试字段上的真实注解创建并初始化枚举校验器。
|
||||
*
|
||||
* @param fieldName 测试字段名
|
||||
* @return 已初始化的校验器
|
||||
*/
|
||||
private static EnumPatternValidator validatorFor(String fieldName) throws NoSuchFieldException {
|
||||
Field field = ValidationTarget.class.getDeclaredField(fieldName);
|
||||
EnumPatternValidator validator = new EnumPatternValidator();
|
||||
validator.initialize(field.getAnnotation(EnumPattern.class));
|
||||
return validator;
|
||||
}
|
||||
|
||||
private enum Status {
|
||||
ENABLED(1), DISABLED(2);
|
||||
|
||||
private final Integer code;
|
||||
|
||||
/**
|
||||
* 创建带校验码的测试状态。
|
||||
*
|
||||
* @param code 枚举校验码
|
||||
*/
|
||||
Status(Integer code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回供枚举校验器读取的测试状态码。
|
||||
*
|
||||
* @return 状态码
|
||||
*/
|
||||
public Integer getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
private static class ValidationTarget {
|
||||
@EnumPattern(type = Status.class, fieldName = "code")
|
||||
private Integer status;
|
||||
|
||||
@EnumPattern(type = Status.class, fieldName = "")
|
||||
private Integer unconfigured;
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ import org.dromara.common.doc.core.customizer.JavadocOperationCustomizer;
|
||||
import org.dromara.common.doc.core.resolver.JavadocResolver;
|
||||
import org.dromara.common.doc.core.resolver.SaTokenAnnotationMetadataJavadocResolver;
|
||||
import org.springdoc.core.configuration.SpringDocConfiguration;
|
||||
import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
|
||||
import org.springdoc.core.customizers.OpenApiCustomizer;
|
||||
import org.springdoc.core.providers.JavadocProvider;
|
||||
import org.springdoc.core.utils.PropertyResolverUtils;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
@ -108,7 +108,7 @@ public class SpringDocConfig {
|
||||
* 对已经生成好的 OpenApi 进行自定义操作
|
||||
*/
|
||||
@Bean
|
||||
public GlobalOpenApiCustomizer openApiCustomizer() {
|
||||
public OpenApiCustomizer openApiCustomizer() {
|
||||
String contextPath = serverProperties.getServlet().getContextPath();
|
||||
String finalContextPath;
|
||||
if (StringUtils.isBlank(contextPath) || "/".equals(contextPath)) {
|
||||
|
||||
@ -8,8 +8,8 @@ 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.GlobalOpenApiCustomizer;
|
||||
import org.springdoc.core.customizers.GlobalOperationCustomizer;
|
||||
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;
|
||||
@ -29,7 +29,7 @@ import java.util.stream.Stream;
|
||||
* @author Lion Li
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class ClassTagOperationCustomizer implements GlobalOperationCustomizer, GlobalOpenApiCustomizer {
|
||||
public class ClassTagOperationCustomizer implements OperationCustomizer, OpenApiCustomizer {
|
||||
|
||||
/**
|
||||
* JavaDoc 提供器。
|
||||
|
||||
@ -4,7 +4,7 @@ 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.GlobalOperationCustomizer;
|
||||
import org.springdoc.core.customizers.OperationCustomizer;
|
||||
import org.springdoc.core.providers.JavadocProvider;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
@ -18,7 +18,7 @@ import java.util.Optional;
|
||||
* @author Lion Li
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class JavadocOperationCustomizer implements GlobalOperationCustomizer {
|
||||
public class JavadocOperationCustomizer implements OperationCustomizer {
|
||||
|
||||
/**
|
||||
* JavaDoc 提供器。
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
package org.dromara.common.doc;
|
||||
|
||||
import org.dromara.common.doc.core.model.SaTokenSecurityMetadata;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("common-doc 功能单元测试")
|
||||
class SaTokenSecurityMetadataTest {
|
||||
|
||||
/**
|
||||
* 验证未声明权限和忽略权限时分别输出登录要求与忽略检查说明。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("生成基础权限文档")
|
||||
void shouldDescribeLoginAndIgnoredSecurity() {
|
||||
SaTokenSecurityMetadata metadata = new SaTokenSecurityMetadata();
|
||||
assertTrue(metadata.toMarkdownString().contains("需要登录"));
|
||||
|
||||
metadata.setIgnore(true);
|
||||
assertTrue(metadata.toMarkdownString().contains("忽略权限检查"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证权限、或角色和角色校验能够按 AND/OR 模式生成可读的 Markdown。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("生成权限和角色文档")
|
||||
void shouldDescribePermissionsAndRoles() {
|
||||
SaTokenSecurityMetadata metadata = new SaTokenSecurityMetadata();
|
||||
metadata.addPermission(new String[]{"system:user:list", "system:user:query"}, "AND", "permission",
|
||||
new String[]{"admin", "auditor"});
|
||||
metadata.addRole(new String[]{"manager", "operator"}, "OR", "role");
|
||||
|
||||
String markdown = metadata.toMarkdownString();
|
||||
|
||||
assertTrue(markdown.contains("`system:user:list` & `system:user:query`"));
|
||||
assertTrue(markdown.contains("或角色:`admin` & `auditor`"));
|
||||
assertTrue(markdown.contains("`manager` | `operator`"));
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals(1, metadata.getRoles().size());
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
package org.dromara.common.elasticsearch;
|
||||
|
||||
import org.dromara.common.elasticsearch.config.ActuatorEnvironmentPostProcessor;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@DisplayName("common-elasticsearch 功能单元测试")
|
||||
class ActuatorEnvironmentPostProcessorTest {
|
||||
|
||||
/**
|
||||
* 清理测试写入的 JVM 系统属性,避免影响同一测试进程中的其他用例。
|
||||
*/
|
||||
@AfterEach
|
||||
void clearHealthProperty() {
|
||||
System.clearProperty("management.health.elasticsearch.enabled");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Easy-ES 开关会同步到 Elasticsearch 健康检查,并以最高优先级执行。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("同步 Elasticsearch 健康检查开关")
|
||||
void shouldSynchronizeElasticsearchHealthFlag() {
|
||||
ActuatorEnvironmentPostProcessor processor = new ActuatorEnvironmentPostProcessor();
|
||||
MockEnvironment environment = new MockEnvironment().withProperty("easy-es.enable", "true");
|
||||
|
||||
processor.postProcessEnvironment(environment, null);
|
||||
|
||||
assertEquals("true", System.getProperty("management.health.elasticsearch.enabled"));
|
||||
assertEquals(Ordered.HIGHEST_PRECEDENCE, processor.getOrder());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺少 Easy-ES 配置时健康检查默认关闭,避免未配置连接时触发探测。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("默认关闭 Elasticsearch 健康检查")
|
||||
void shouldDisableElasticsearchHealthByDefault() {
|
||||
new ActuatorEnvironmentPostProcessor().postProcessEnvironment(new MockEnvironment(), null);
|
||||
|
||||
assertEquals("false", System.getProperty("management.health.elasticsearch.enabled"));
|
||||
}
|
||||
}
|
||||
@ -23,11 +23,11 @@ import java.util.Base64;
|
||||
public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static final Charset RESPONSE_CHARSET = StandardCharsets.UTF_8;
|
||||
|
||||
private final ByteArrayOutputStream byteArrayOutputStream;
|
||||
private final ServletOutputStream servletOutputStream;
|
||||
private PrintWriter printWriter;
|
||||
private Charset charset;
|
||||
|
||||
/**
|
||||
* 构造加密响应包装器。
|
||||
@ -39,6 +39,7 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
|
||||
super(response);
|
||||
this.byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
this.servletOutputStream = this.getOutputStream();
|
||||
this.charset = resolveCharset(response);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -49,7 +50,8 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
|
||||
@Override
|
||||
public PrintWriter getWriter() {
|
||||
if (printWriter == null) {
|
||||
printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream, RESPONSE_CHARSET));
|
||||
charset = resolveCharset((HttpServletResponse) getResponse());
|
||||
printWriter = new PrintWriter(new OutputStreamWriter(byteArrayOutputStream, charset));
|
||||
}
|
||||
return printWriter;
|
||||
}
|
||||
@ -104,7 +106,7 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
|
||||
*/
|
||||
public String getContent() throws IOException {
|
||||
flushBuffer();
|
||||
return byteArrayOutputStream.toString(RESPONSE_CHARSET);
|
||||
return byteArrayOutputStream.toString(charset);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -128,14 +130,14 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
|
||||
// vue版本需要设置
|
||||
servletResponse.addHeader("Access-Control-Expose-Headers", headerFlag);
|
||||
servletResponse.setHeader(headerFlag, encryptPassword);
|
||||
servletResponse.setCharacterEncoding(RESPONSE_CHARSET.name());
|
||||
servletResponse.setCharacterEncoding(charset.name());
|
||||
|
||||
|
||||
// 获取原始内容
|
||||
String originalBody = this.getContent();
|
||||
// 对内容进行加密
|
||||
String encryptContent = EncryptUtils.encryptByAes(originalBody, aesPassword);
|
||||
servletResponse.setContentLengthLong(encryptContent.getBytes(RESPONSE_CHARSET).length);
|
||||
servletResponse.setContentLengthLong(encryptContent.getBytes(charset).length);
|
||||
return encryptContent;
|
||||
}
|
||||
|
||||
@ -204,6 +206,20 @@ public class EncryptResponseBodyWrapper extends HttpServletResponseWrapper {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应字符集,未设置时默认使用 UTF-8。
|
||||
*
|
||||
* @param response 原始响应
|
||||
* @return 响应字符集
|
||||
*/
|
||||
private Charset resolveCharset(HttpServletResponse response) {
|
||||
String characterEncoding = response.getCharacterEncoding();
|
||||
if (characterEncoding == null) {
|
||||
return StandardCharsets.UTF_8;
|
||||
}
|
||||
return Charset.forName(characterEncoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成响应内容 AES 加密密钥。
|
||||
*
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
package org.dromara.common.encrypt.interceptor;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.apache.ibatis.cursor.Cursor;
|
||||
import org.apache.ibatis.executor.resultset.ResultSetHandler;
|
||||
import org.apache.ibatis.plugin.*;
|
||||
import org.dromara.common.encrypt.core.EncryptedFieldProcessor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.Statement;
|
||||
import java.util.Iterator;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
@ -16,10 +13,10 @@ import java.util.Properties;
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@Intercepts({
|
||||
@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}),
|
||||
// MP 3.5.17 起 selectOne/selectVoOne 走游标,需一并拦截解密
|
||||
@Signature(type = ResultSetHandler.class, method = "handleCursorResultSets", args = {Statement.class})
|
||||
@Intercepts({@Signature(
|
||||
type = ResultSetHandler.class,
|
||||
method = "handleResultSets",
|
||||
args = {Statement.class})
|
||||
})
|
||||
@AllArgsConstructor
|
||||
public class MybatisDecryptInterceptor implements Interceptor {
|
||||
@ -35,13 +32,11 @@ public class MybatisDecryptInterceptor implements Interceptor {
|
||||
*/
|
||||
@Override
|
||||
public Object intercept(Invocation invocation) throws Throwable {
|
||||
// 获取执行mysql执行结果
|
||||
Object result = invocation.proceed();
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
if (result instanceof Cursor<?> cursor) {
|
||||
return new DecryptCursor<>(cursor, encryptedFieldProcessor);
|
||||
}
|
||||
encryptedFieldProcessor.decrypt(result);
|
||||
return result;
|
||||
}
|
||||
@ -66,52 +61,4 @@ public class MybatisDecryptInterceptor implements Interceptor {
|
||||
public void setProperties(Properties properties) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询结果解密游标,保持 MyBatis 游标的惰性读取语义。
|
||||
*
|
||||
* @param <T> 查询结果类型
|
||||
*/
|
||||
private record DecryptCursor<T>(Cursor<T> delegate, EncryptedFieldProcessor encryptedFieldProcessor)
|
||||
implements Cursor<T> {
|
||||
|
||||
@Override
|
||||
public boolean isOpen() {
|
||||
return delegate.isOpen();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isConsumed() {
|
||||
return delegate.isConsumed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCurrentIndex() {
|
||||
return delegate.getCurrentIndex();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<T> iterator() {
|
||||
Iterator<T> iterator = delegate.iterator();
|
||||
return new Iterator<>() {
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return iterator.hasNext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public T next() {
|
||||
T result = iterator.next();
|
||||
encryptedFieldProcessor.decrypt(result);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
delegate.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
package org.dromara.common.encrypt;
|
||||
|
||||
import org.dromara.common.core.constant.Constants;
|
||||
import org.dromara.common.encrypt.annotation.EncryptField;
|
||||
import org.dromara.common.encrypt.core.EncryptContext;
|
||||
import org.dromara.common.encrypt.core.EncryptContextFactory;
|
||||
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.utils.EncryptUtils;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("common-encrypt 功能单元测试")
|
||||
class EncryptFunctionTest {
|
||||
|
||||
/**
|
||||
* 验证 Base64、AES 和 SM4 的常用加解密能够无损往返。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("常用对称算法加解密往返")
|
||||
void shouldRoundTripCommonSymmetricAlgorithms() {
|
||||
String text = "RuoYi-Vue-Plus";
|
||||
String aesKey = "1234567890abcdef";
|
||||
String sm4Key = "abcdef1234567890";
|
||||
|
||||
assertEquals(text, EncryptUtils.decryptByBase64(EncryptUtils.encryptByBase64(text)));
|
||||
assertEquals(text, EncryptUtils.decryptByAes(EncryptUtils.encryptByAes(text, aesKey), aesKey));
|
||||
assertEquals(text, EncryptUtils.decryptBySm4(EncryptUtils.encryptBySm4(text, sm4Key), sm4Key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法对称密钥长度在执行加密前被拒绝。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("校验对称算法密钥长度")
|
||||
void shouldRejectInvalidSymmetricKeys() {
|
||||
assertThrows(IllegalArgumentException.class, () -> EncryptUtils.encryptByAes("data", "short"));
|
||||
assertThrows(IllegalArgumentException.class, () -> EncryptUtils.encryptBySm4("data", "short"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证生成的 RSA 密钥满足校验要求并可以完成公钥加密、私钥解密。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("RSA 密钥生成和加解密往返")
|
||||
void shouldGenerateAndUseRsaKeys() {
|
||||
Map<String, String> keys = EncryptUtils.generateRsaKey();
|
||||
|
||||
assertDoesNotThrow(() -> EncryptUtils.validateRsaPublicKey(keys.get(EncryptUtils.PUBLIC_KEY)));
|
||||
assertDoesNotThrow(() -> EncryptUtils.validateRsaPrivateKey(keys.get(EncryptUtils.PRIVATE_KEY)));
|
||||
String encrypted = EncryptUtils.encryptByRsa("secure-data", keys.get(EncryptUtils.PUBLIC_KEY));
|
||||
assertEquals("secure-data", EncryptUtils.decryptByRsa(encrypted, keys.get(EncryptUtils.PRIVATE_KEY)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字段注解优先于默认配置构建加密上下文。
|
||||
*
|
||||
* @throws Exception 读取测试字段失败
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("合并字段注解和默认加密配置")
|
||||
void shouldCreateEncryptContextFromAnnotationAndDefaults() throws Exception {
|
||||
EncryptorProperties properties = new EncryptorProperties();
|
||||
properties.setAlgorithm(AlgorithmType.AES);
|
||||
properties.setEncode(EncodeType.BASE64);
|
||||
properties.setPassword("default-password");
|
||||
properties.setPublicKey("default-public");
|
||||
properties.setPrivateKey("default-private");
|
||||
Field field = TestEntity.class.getDeclaredField("secret");
|
||||
|
||||
EncryptContext context = new EncryptContextFactory(properties).create(field);
|
||||
|
||||
assertEquals(AlgorithmType.SM4, context.getAlgorithm());
|
||||
assertEquals(EncodeType.HEX, context.getEncode());
|
||||
assertEquals("field-password", context.getPassword());
|
||||
assertEquals("default-public", context.getPublicKey());
|
||||
assertEquals("default-private", context.getPrivateKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证加密管理器添加统一密文头,并避免对已有密文重复加密。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("管理带标识头的加密值")
|
||||
void shouldManageEncryptedValueHeader() {
|
||||
EncryptContext context = new EncryptContext();
|
||||
context.setAlgorithm(AlgorithmType.BASE64);
|
||||
context.setEncode(EncodeType.BASE64);
|
||||
EncryptorManager manager = new EncryptorManager("");
|
||||
|
||||
String encrypted = manager.encrypt("plain-text", context);
|
||||
|
||||
assertTrue(encrypted.startsWith(Constants.ENCRYPT_HEADER));
|
||||
assertEquals(encrypted, manager.encrypt(encrypted, context));
|
||||
assertEquals("plain-text", manager.decrypt(encrypted, context));
|
||||
assertEquals("plain-text", manager.decrypt("plain-text", context));
|
||||
}
|
||||
|
||||
private static class TestEntity {
|
||||
|
||||
@EncryptField(algorithm = AlgorithmType.SM4, encode = EncodeType.HEX, password = "field-password")
|
||||
private String secret;
|
||||
}
|
||||
}
|
||||
@ -1,156 +0,0 @@
|
||||
package org.dromara.common.encrypt;
|
||||
|
||||
import org.dromara.common.core.constant.Constants;
|
||||
import org.dromara.common.encrypt.annotation.EncryptField;
|
||||
import org.dromara.common.encrypt.core.EncryptContext;
|
||||
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.core.IEncryptor;
|
||||
import org.dromara.common.encrypt.enums.AlgorithmType;
|
||||
import org.dromara.common.encrypt.enums.EncodeType;
|
||||
import org.dromara.common.encrypt.filter.DecryptRequestBodyWrapper;
|
||||
import org.dromara.common.encrypt.filter.EncryptResponseBodyWrapper;
|
||||
import org.dromara.common.encrypt.properties.EncryptorProperties;
|
||||
import org.dromara.common.encrypt.utils.EncryptUtils;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("common-encrypt 基础设施单元测试")
|
||||
class EncryptInfrastructureTest {
|
||||
|
||||
/**
|
||||
* 验证集合和 Map 中的加密字段会被处理,且字段快照能够恢复持久化前的原始值。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("加密并恢复容器中的字段")
|
||||
void shouldEncryptAndRestoreFieldsInsideContainers() {
|
||||
EncryptorProperties properties = new EncryptorProperties();
|
||||
properties.setAlgorithm(AlgorithmType.BASE64);
|
||||
properties.setEncode(EncodeType.BASE64);
|
||||
EncryptorManager manager = new EncryptorManager("");
|
||||
EncryptedFieldProcessor processor = new EncryptedFieldProcessor(
|
||||
manager, new EncryptContextFactory(properties));
|
||||
TestSecretEntity first = new TestSecretEntity("first");
|
||||
TestSecretEntity second = new TestSecretEntity("second");
|
||||
List<Object> source = new ArrayList<>();
|
||||
source.add(first);
|
||||
source.add(Map.of("entity", second));
|
||||
source.add(source);
|
||||
|
||||
List<EncryptedFieldProcessor.FieldSnapshot> snapshots = processor.encrypt(source);
|
||||
|
||||
assertEquals(2, snapshots.size());
|
||||
assertTrue(first.secret.startsWith(Constants.ENCRYPT_HEADER));
|
||||
assertTrue(second.secret.startsWith(Constants.ENCRYPT_HEADER));
|
||||
snapshots.forEach(EncryptedFieldProcessor.FieldSnapshot::restore);
|
||||
assertEquals("first", first.secret);
|
||||
assertEquals("second", second.secret);
|
||||
|
||||
processor.encrypt(source);
|
||||
processor.decrypt(source);
|
||||
assertEquals("first", first.secret);
|
||||
assertEquals("second", second.secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证相同上下文复用加密器缓存,显式移除后会重新创建实例。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("复用和移除加密器缓存")
|
||||
void shouldReuseAndRemoveEncryptorCache() {
|
||||
EncryptorManager manager = new EncryptorManager("");
|
||||
EncryptContext context = new EncryptContext();
|
||||
context.setAlgorithm(AlgorithmType.BASE64);
|
||||
context.setEncode(EncodeType.BASE64);
|
||||
|
||||
IEncryptor first = manager.registAndGetEncryptor(context);
|
||||
IEncryptor cached = manager.registAndGetEncryptor(context);
|
||||
manager.removeEncryptor(context);
|
||||
IEncryptor recreated = manager.registAndGetEncryptor(context);
|
||||
|
||||
assertSame(first, cached);
|
||||
assertNotSame(first, recreated);
|
||||
assertTrue(manager.getFieldCache(String.class).isEmpty());
|
||||
assertTrue(manager.getFieldCache(null).isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证加密请求包装器能解开请求头中的 AES 密钥并提供可重复读取的 JSON 明文。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("解密 API 请求体")
|
||||
void shouldDecryptApiRequestBody() throws Exception {
|
||||
Map<String, String> rsaKeys = EncryptUtils.generateRsaKey();
|
||||
String aesPassword = "1234567890abcdef";
|
||||
String headerName = "encrypt-key";
|
||||
String encryptedHeader = EncryptUtils.encryptByRsa(
|
||||
EncryptUtils.encryptByBase64(aesPassword), rsaKeys.get(EncryptUtils.PUBLIC_KEY));
|
||||
String json = "{\"name\":\"测试\"}";
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(headerName, encryptedHeader);
|
||||
request.setContent(EncryptUtils.encryptByAes(json, aesPassword).getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
DecryptRequestBodyWrapper wrapper = new DecryptRequestBodyWrapper(
|
||||
request, rsaKeys.get(EncryptUtils.PRIVATE_KEY), headerName);
|
||||
|
||||
assertEquals(json, wrapper.getReader().readLine());
|
||||
assertEquals(json, new String(wrapper.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
|
||||
assertEquals("application/json", wrapper.getContentType());
|
||||
assertEquals(json.getBytes(StandardCharsets.UTF_8).length, wrapper.getContentLength());
|
||||
assertEquals(wrapper.getContentLength(), wrapper.getContentLengthLong());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证加密响应包装器输出可由响应头携带的密钥完整解密,并正确设置响应元数据。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("加密 API 响应体")
|
||||
void shouldEncryptApiResponseBody() throws Exception {
|
||||
Map<String, String> rsaKeys = EncryptUtils.generateRsaKey();
|
||||
String headerName = "encrypt-key";
|
||||
String body = "{\"message\":\"成功\"}";
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
EncryptResponseBodyWrapper wrapper = new EncryptResponseBodyWrapper(response);
|
||||
wrapper.getWriter().write(body);
|
||||
|
||||
String encryptedBody = wrapper.getEncryptContent(
|
||||
response, rsaKeys.get(EncryptUtils.PUBLIC_KEY), headerName);
|
||||
String encodedAes = EncryptUtils.decryptByRsa(
|
||||
response.getHeader(headerName), rsaKeys.get(EncryptUtils.PRIVATE_KEY));
|
||||
String aesPassword = EncryptUtils.decryptByBase64(encodedAes);
|
||||
|
||||
assertEquals(body, EncryptUtils.decryptByAes(encryptedBody, aesPassword));
|
||||
assertEquals(StandardCharsets.UTF_8.name(), response.getCharacterEncoding());
|
||||
assertEquals(encryptedBody.getBytes(StandardCharsets.UTF_8).length, response.getContentLength());
|
||||
assertEquals(headerName, response.getHeader("Access-Control-Expose-Headers"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class TestSecretEntity {
|
||||
|
||||
@EncryptField(algorithm = AlgorithmType.BASE64)
|
||||
String secret;
|
||||
|
||||
/**
|
||||
* 创建带待加密字段的顶层测试实体,以符合生产扫描器对实体类的约束。
|
||||
*
|
||||
* @param secret 原始明文
|
||||
*/
|
||||
TestSecretEntity(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
}
|
||||
@ -1,326 +0,0 @@
|
||||
package org.dromara.common.excel;
|
||||
|
||||
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
|
||||
import org.apache.fesod.sheet.annotation.ExcelProperty;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.dromara.common.excel.annotation.CellMerge;
|
||||
import org.dromara.common.excel.annotation.ExcelDictFormat;
|
||||
import org.dromara.common.excel.annotation.ExcelEnumFormat;
|
||||
import org.dromara.common.excel.annotation.ExcelNotation;
|
||||
import org.dromara.common.excel.annotation.ExcelRequired;
|
||||
import org.dromara.common.excel.core.CellMergeHandler;
|
||||
import org.dromara.common.excel.core.DropDownOptions;
|
||||
import org.dromara.common.excel.utils.ExcelBuilder;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("ExcelBuilder 单元测试")
|
||||
class ExcelBuilderTest {
|
||||
|
||||
/**
|
||||
* 验证内存导出同时应用表头样式、批注、字典和枚举下拉、外部下拉及单元格合并。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构建带内部增强的 Excel 工作簿")
|
||||
void shouldBuildWorkbookWithInternalEnhancements() throws Exception {
|
||||
byte[] bytes = buildWorkbook();
|
||||
|
||||
try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
|
||||
assertEquals("report", workbook.getSheetAt(0).getSheetName());
|
||||
assertFalse(workbook.getSheetAt(0).getDataValidations().isEmpty());
|
||||
assertFalse(workbook.getSheetAt(0).getMergedRegions().isEmpty());
|
||||
assertEquals("填写业务分类", workbook.getSheetAt(0).getRow(0).getCell(0).getCellComment().getString().getString());
|
||||
assertTrue(workbook.getNumberOfSheets() >= 3);
|
||||
assertTrue(workbook.isSheetHidden(workbook.getSheetIndex("options_0")));
|
||||
assertTrue(workbook.isSheetHidden(workbook.getSheetIndex("linkedOptions_0")));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证通过构造器写出的工作簿可按工作表编号、名称和读取选项重新解析为对象。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("写入并读取 Excel 数据")
|
||||
void shouldRoundTripWorkbookThroughReadBuilder() {
|
||||
byte[] bytes = buildWorkbook();
|
||||
|
||||
List<ExportRow> rows = ExcelBuilder.read(new ByteArrayInputStream(bytes), ExportRow.class)
|
||||
.validate(false)
|
||||
.failFast(false)
|
||||
.sheetNo(0)
|
||||
.sheetName("report")
|
||||
.headRowNumber(1)
|
||||
.ignoreEmptyRow(true)
|
||||
.autoTrim(true)
|
||||
.autoStrip(true)
|
||||
.numRows(20)
|
||||
.doReadSync();
|
||||
|
||||
assertEquals(3, rows.size());
|
||||
assertEquals("A", rows.getFirst().getCategory());
|
||||
assertEquals("1", rows.getFirst().getStatus());
|
||||
assertEquals(1, rows.getFirst().getLevel());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证构造器拒绝非法尺寸、ZIP 分页和空模板数据,避免生成不可用文件。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("校验 Excel 构造参数")
|
||||
void shouldValidateBuilderArguments() {
|
||||
ExcelBuilder<ExportRow> builder = ExcelBuilder.of(rows(), ExportRow.class);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.columnWidth(0));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.rowHeight((short) 0, (short) 10));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.zip(0));
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> builder.zip(2).toStream(new ByteArrayOutputStream()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ExcelBuilder.template("missing.xlsx").data(List.of()).toStream(new ByteArrayOutputStream()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ExcelBuilder.template("missing.xlsx").multiList(Map.of()).toStream(new ByteArrayOutputStream()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ExcelBuilder.template("missing.xlsx").multiSheet(List.of()).toStream(new ByteArrayOutputStream()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ExcelBuilder.read(new ByteArrayInputStream(new byte[0]), ExportRow.class).headRowNumber(-1));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> ExcelBuilder.read(new ByteArrayInputStream(new byte[0]), ExportRow.class).numRows(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证合并处理器按依赖字段切断重复段,空值中断合并,并正确计算多级表头偏移。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("计算依赖字段单元格合并区域")
|
||||
void shouldCalculateConditionalMergeRanges() {
|
||||
List<MergeRow> rows = List.of(
|
||||
new MergeRow("A", "east"),
|
||||
new MergeRow("A", "east"),
|
||||
new MergeRow("A", "west"),
|
||||
new MergeRow("", "west"),
|
||||
new MergeRow("B", "west"),
|
||||
new MergeRow("B", "west"));
|
||||
|
||||
List<String> ranges = CellMergeHandler.of(true).handle(rows).stream()
|
||||
.map(range -> range.formatAsString())
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
assertEquals(List.of("A3:A4", "A7:A8"), ranges);
|
||||
assertEquals("A1:A2", CellMergeHandler.of(false, 9).handle(rows.subList(0, 2)).getFirst().formatAsString());
|
||||
assertTrue(CellMergeHandler.of().handle(List.of()).isEmpty());
|
||||
assertTrue(CellMergeHandler.of().handle(List.of(new Object())).isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建覆盖简单、额外 Sheet 和级联下拉分支的内存工作簿。
|
||||
*
|
||||
* @return XLSX 字节数组
|
||||
*/
|
||||
private static byte[] buildWorkbook() {
|
||||
List<String> manyOptions = IntStream.rangeClosed(1, 11).mapToObj(i -> "选项" + i).toList();
|
||||
List<DropDownOptions> options = List.of(
|
||||
new DropDownOptions(3, List.of("是", "否")),
|
||||
new DropDownOptions(4, manyOptions),
|
||||
new DropDownOptions(5, 6, List.of("父级_1"), Map.of("父级_1", List.of("子级_1", "子级_2"))));
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
ExcelBuilder.of(rows(), ExportRow.class)
|
||||
.sheetName("report")
|
||||
.sheetNo(0)
|
||||
.merge()
|
||||
.options(options)
|
||||
.needHead(true)
|
||||
.automaticMergeHead(true)
|
||||
.columnWidth(18)
|
||||
.rowHeight((short) 22, (short) 18)
|
||||
.toStream(outputStream);
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用于导出和读取往返的测试数据。
|
||||
*
|
||||
* @return 测试数据行
|
||||
*/
|
||||
private static List<ExportRow> rows() {
|
||||
return List.of(
|
||||
new ExportRow("A", "1", 1),
|
||||
new ExportRow("A", "0", 2),
|
||||
new ExportRow("B", "1", 1));
|
||||
}
|
||||
|
||||
@ExcelIgnoreUnannotated
|
||||
public static class ExportRow {
|
||||
|
||||
@ExcelProperty("分类")
|
||||
@CellMerge
|
||||
@ExcelRequired
|
||||
@ExcelNotation("填写业务分类")
|
||||
private String category;
|
||||
|
||||
@ExcelProperty("状态")
|
||||
@ExcelDictFormat(readConverterExp = "0=停用,1=启用")
|
||||
private String status;
|
||||
|
||||
@ExcelProperty("级别")
|
||||
@ExcelEnumFormat(enumClass = Level.class)
|
||||
private Integer level;
|
||||
|
||||
/**
|
||||
* 创建供 Excel 反射实例化的空对象。
|
||||
*/
|
||||
public ExportRow() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含导出字段的测试数据行。
|
||||
*
|
||||
* @param category 分类
|
||||
* @param status 状态编码
|
||||
* @param level 级别编码
|
||||
*/
|
||||
public ExportRow(String category, String status, Integer level) {
|
||||
this.category = category;
|
||||
this.status = status;
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回业务分类。
|
||||
*
|
||||
* @return 业务分类
|
||||
*/
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务分类供 Excel 导入使用。
|
||||
*
|
||||
* @param category 业务分类
|
||||
*/
|
||||
public void setCategory(String category) {
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回状态编码。
|
||||
*
|
||||
* @return 状态编码
|
||||
*/
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态编码供 Excel 导入使用。
|
||||
*
|
||||
* @param status 状态编码
|
||||
*/
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回级别编码。
|
||||
*
|
||||
* @return 级别编码
|
||||
*/
|
||||
public Integer getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置级别编码供 Excel 导入使用。
|
||||
*
|
||||
* @param level 级别编码
|
||||
*/
|
||||
public void setLevel(Integer level) {
|
||||
this.level = level;
|
||||
}
|
||||
}
|
||||
|
||||
private enum Level {
|
||||
NORMAL(1, "普通"),
|
||||
HIGH(2, "高级");
|
||||
|
||||
private final int code;
|
||||
private final String text;
|
||||
|
||||
Level(int code, String text) {
|
||||
this.code = code;
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回级别编码供下拉处理器读取。
|
||||
*
|
||||
* @return 级别编码
|
||||
*/
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回级别文本供下拉处理器读取。
|
||||
*
|
||||
* @return 级别文本
|
||||
*/
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@ExcelIgnoreUnannotated
|
||||
private static class MergeRow {
|
||||
|
||||
@ExcelProperty({"业务", "分类"})
|
||||
@CellMerge(mergeBy = "region")
|
||||
private final String category;
|
||||
|
||||
@ExcelProperty({"业务", "区域"})
|
||||
private final String region;
|
||||
|
||||
/**
|
||||
* 创建用于验证依赖字段合并规则的数据行。
|
||||
*
|
||||
* @param category 分类
|
||||
* @param region 区域
|
||||
*/
|
||||
private MergeRow(String category, String region) {
|
||||
this.category = category;
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回待合并分类。
|
||||
*
|
||||
* @return 分类
|
||||
*/
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回控制分类是否允许合并的区域。
|
||||
*
|
||||
* @return 区域
|
||||
*/
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
package org.dromara.common.excel;
|
||||
|
||||
import org.apache.fesod.sheet.enums.CellDataTypeEnum;
|
||||
import org.apache.fesod.sheet.metadata.data.ReadCellData;
|
||||
import org.apache.fesod.sheet.metadata.data.WriteCellData;
|
||||
import org.apache.fesod.sheet.metadata.property.ExcelContentProperty;
|
||||
import org.dromara.common.excel.annotation.ExcelDictFormat;
|
||||
import org.dromara.common.excel.annotation.ExcelEnumFormat;
|
||||
import org.dromara.common.excel.convert.ExcelBigNumberConvert;
|
||||
import org.dromara.common.excel.convert.ExcelDictConvert;
|
||||
import org.dromara.common.excel.convert.ExcelEnumConvert;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@DisplayName("Excel 转换器单元测试")
|
||||
class ExcelConvertTest {
|
||||
|
||||
/**
|
||||
* 验证普通 Long 按数字写出,而超过 Excel 精度上限的 Long 按字符串写出。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("按精度范围写出 Long")
|
||||
void shouldWriteLongAccordingToExcelPrecisionLimit() {
|
||||
ExcelBigNumberConvert converter = new ExcelBigNumberConvert();
|
||||
|
||||
WriteCellData<Object> normal = converter.convertToExcelData(123456789012345L, null, null);
|
||||
WriteCellData<Object> large = converter.convertToExcelData(1234567890123456L, null, null);
|
||||
WriteCellData<Object> empty = converter.convertToExcelData(null, null, null);
|
||||
|
||||
assertEquals(CellDataTypeEnum.NUMBER, normal.getType());
|
||||
assertEquals(new BigDecimal("123456789012345"), normal.getNumberValue());
|
||||
assertEquals("1234567890123456", large.getStringValue());
|
||||
assertEquals("", empty.getStringValue());
|
||||
assertEquals(42L, converter.convertToJavaData(new ReadCellData<>("42"), null, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字典表达式支持单值和多值的双向转换,并保留目标字段类型。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("双向转换字典表达式")
|
||||
void shouldConvertDictionaryExpressionInBothDirections() throws Exception {
|
||||
ExcelDictConvert converter = new ExcelDictConvert();
|
||||
ExcelContentProperty singleProperty = property("status");
|
||||
ExcelContentProperty multiProperty = property("roles");
|
||||
|
||||
assertEquals("启用", converter.convertToExcelData(1, singleProperty, null).getStringValue());
|
||||
assertEquals(0, converter.convertToJavaData(new ReadCellData<>("停用"), singleProperty, null));
|
||||
assertEquals("管理员|访客", converter.convertToExcelData("A|G", multiProperty, null).getStringValue());
|
||||
assertEquals("A|G", converter.convertToJavaData(new ReadCellData<>("访客|管理员"), multiProperty, null));
|
||||
assertEquals("", converter.convertToExcelData(null, singleProperty, null).getStringValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证格式错误的字典表达式会被明确拒绝,避免静默生成错误导入导出值。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝格式错误的字典表达式")
|
||||
void shouldRejectMalformedDictionaryExpression() throws Exception {
|
||||
ExcelDictConvert converter = new ExcelDictConvert();
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> converter.convertToExcelData("1", property("malformed"), null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证枚举编码和显示文本可以双向转换,未知显示文本会返回可诊断异常。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("双向转换枚举编码与文本")
|
||||
void shouldConvertEnumCodeAndTextInBothDirections() throws Exception {
|
||||
ExcelEnumConvert converter = new ExcelEnumConvert();
|
||||
ExcelContentProperty property = property("level");
|
||||
|
||||
assertEquals("高级", converter.convertToExcelData(2, property, null).getStringValue());
|
||||
assertEquals(1, converter.convertToJavaData(new ReadCellData<>("普通"), property, null));
|
||||
assertEquals("", converter.convertToExcelData(null, property, null).getStringValue());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> converter.convertToJavaData(new ReadCellData<>("未知级别"), property, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建绑定指定测试字段的 Excel 内容属性。
|
||||
*
|
||||
* @param fieldName 测试字段名
|
||||
* @return Excel 内容属性
|
||||
*/
|
||||
private static ExcelContentProperty property(String fieldName) throws NoSuchFieldException {
|
||||
Field field = ExcelRow.class.getDeclaredField(fieldName);
|
||||
ExcelContentProperty property = new ExcelContentProperty();
|
||||
property.setField(field);
|
||||
return property;
|
||||
}
|
||||
|
||||
private static class ExcelRow {
|
||||
|
||||
@ExcelDictFormat(readConverterExp = "0=停用,1=启用")
|
||||
private Integer status;
|
||||
|
||||
@ExcelDictFormat(readConverterExp = "A=管理员,G=访客", separator = "|")
|
||||
private String roles;
|
||||
|
||||
@ExcelDictFormat(readConverterExp = "0=正常,错误项")
|
||||
private String malformed;
|
||||
|
||||
@ExcelEnumFormat(enumClass = Level.class)
|
||||
private Integer level;
|
||||
}
|
||||
|
||||
private enum Level {
|
||||
NORMAL(1, "普通"),
|
||||
HIGH(2, "高级");
|
||||
|
||||
private final int code;
|
||||
private final String text;
|
||||
|
||||
Level(int code, String text) {
|
||||
this.code = code;
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回枚举编码供转换器反射读取。
|
||||
*
|
||||
* @return 枚举编码
|
||||
*/
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回枚举显示文本供转换器反射读取。
|
||||
*
|
||||
* @return 显示文本
|
||||
*/
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
package org.dromara.common.excel;
|
||||
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.excel.core.DefaultExcelResult;
|
||||
import org.dromara.common.excel.core.DropDownOptions;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@DisplayName("common-excel 功能单元测试")
|
||||
class ExcelFunctionTest {
|
||||
|
||||
/**
|
||||
* 验证级联下拉选项值能够安全拼接和还原,确保生成的名称符合 Excel 名称规则。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("创建并解析 Excel 下拉选项")
|
||||
void shouldCreateAndAnalyzeDropDownOptionValue() {
|
||||
String option = DropDownOptions.createOptionValue("华东", 1001);
|
||||
|
||||
assertEquals("华东_1001", option);
|
||||
assertEquals(List.of("华东", "1001"), DropDownOptions.analyzeOptionValue(option));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数字开头、单元格引用和特殊字符不会进入 Excel 名称管理器。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝非法 Excel 下拉选项")
|
||||
void shouldRejectInvalidDropDownOptionValue() {
|
||||
assertThrows(ServiceException.class, () -> DropDownOptions.createOptionValue(1001, "华东"));
|
||||
assertThrows(ServiceException.class, () -> DropDownOptions.validateOptionValue("A1"));
|
||||
assertThrows(ServiceException.class, () -> DropDownOptions.createOptionValue("华东-一区"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Excel 导入结果对全失败、全成功和部分成功场景生成准确统计文案。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("汇总 Excel 导入结果")
|
||||
void shouldSummarizeExcelImportResult() {
|
||||
assertEquals("读取失败,未解析到数据", new DefaultExcelResult<>(List.of(), List.of("格式错误")).getAnalysis());
|
||||
assertEquals("恭喜您,全部读取成功!共2条",
|
||||
new DefaultExcelResult<>(List.of("a", "b"), List.of()).getAnalysis());
|
||||
assertEquals("共3条,成功导入2条,错误1条",
|
||||
new DefaultExcelResult<>(List.of("a", "b"), List.of("格式错误")).getAnalysis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证父子数据按父 ID 构建级联下拉,且没有有效父项的子数据不会进入结果。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构建父子级联下拉选项")
|
||||
void shouldBuildLinkedDropDownOptions() {
|
||||
List<OptionNode> parents = List.of(
|
||||
new OptionNode(1L, null, "华东_1"),
|
||||
new OptionNode(2L, null, "华北_2"));
|
||||
List<OptionNode> children = List.of(
|
||||
new OptionNode(11L, 1L, "上海_11"),
|
||||
new OptionNode(12L, 1L, "杭州_12"),
|
||||
new OptionNode(21L, 2L, "北京_21"),
|
||||
new OptionNode(99L, 9L, "孤立_99"));
|
||||
|
||||
DropDownOptions result = DropDownOptions.buildLinkedOptions(
|
||||
parents, 0, children, 1, OptionNode::id, OptionNode::parentId, OptionNode::label);
|
||||
|
||||
assertEquals(0, result.getIndex());
|
||||
assertEquals(1, result.getNextIndex());
|
||||
assertEquals(List.of("华东_1", "华北_2"), result.getOptions());
|
||||
assertEquals(Map.of(
|
||||
"华东_1", List.of("上海_11", "杭州_12"),
|
||||
"华北_2", List.of("北京_21")), result.getNextOptions());
|
||||
}
|
||||
|
||||
private record OptionNode(Long id, Long parentId, String label) {
|
||||
}
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
package org.dromara.common.excel;
|
||||
|
||||
import org.apache.fesod.sheet.ExcelWriter;
|
||||
import org.apache.fesod.sheet.context.WriteContext;
|
||||
import org.apache.fesod.sheet.write.metadata.WriteSheet;
|
||||
import org.apache.fesod.sheet.write.metadata.WriteTable;
|
||||
import org.apache.fesod.sheet.write.metadata.fill.FillConfig;
|
||||
import org.dromara.common.excel.utils.ExcelWriterWrapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Collection;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("ExcelWriterWrapper 契约单元测试")
|
||||
class ExcelWriterWrapperTest {
|
||||
|
||||
/**
|
||||
* 验证集合写出和 Supplier 写出都完整委托给 ExcelWriter,并在 Supplier 重载中只求值一次。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("委托四种集合写出")
|
||||
void shouldDelegateCollectionWrites() {
|
||||
ExcelWriter writer = mock(ExcelWriter.class);
|
||||
ExcelWriterWrapper<String> wrapper = ExcelWriterWrapper.of(writer);
|
||||
WriteSheet sheet = ExcelWriterWrapper.buildSheet(2, "users");
|
||||
WriteTable table = ExcelWriterWrapper.buildTable(3);
|
||||
List<String> data = List.of("a", "b");
|
||||
AtomicBoolean supplied = new AtomicBoolean();
|
||||
Supplier<Collection<String>> supplier = () -> {
|
||||
supplied.set(true);
|
||||
return data;
|
||||
};
|
||||
|
||||
wrapper.write(data, sheet);
|
||||
wrapper.write(supplier, sheet);
|
||||
wrapper.write(data, sheet, table);
|
||||
wrapper.write(supplier, sheet, table);
|
||||
|
||||
assertTrue(supplied.get());
|
||||
verify(writer, times(2)).write(data, sheet);
|
||||
verify(writer, times(2)).write(data, sheet, table);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通对象、填充配置和 Supplier 三类填充方法保持底层参数及 Supplier 实例不变。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("委托四种模板填充")
|
||||
void shouldDelegateFillOperations() {
|
||||
ExcelWriter writer = mock(ExcelWriter.class);
|
||||
ExcelWriterWrapper<String> wrapper = ExcelWriterWrapper.of(writer);
|
||||
WriteSheet sheet = ExcelWriterWrapper.buildSheet("report");
|
||||
FillConfig config = FillConfig.builder().forceNewRow(true).build();
|
||||
Supplier<Object> supplier = () -> "value";
|
||||
|
||||
wrapper.fill("value", sheet);
|
||||
wrapper.fill("value", config, sheet);
|
||||
wrapper.fill(supplier, sheet);
|
||||
wrapper.fill(supplier, config, sheet);
|
||||
|
||||
verify(writer).fill("value", sheet);
|
||||
verify(writer).fill("value", config, sheet);
|
||||
verify(writer).fill(supplier, sheet);
|
||||
verify(writer).fill(supplier, config, sheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证写出上下文和所有静态构造器均返回底层对象或包含请求元数据的 Fesod 对象。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("获取上下文并构造工作表和表格")
|
||||
void shouldExposeContextAndBuildMetadata() {
|
||||
ExcelWriter writer = mock(ExcelWriter.class);
|
||||
WriteContext writeContext = mock(WriteContext.class);
|
||||
when(writer.writeContext()).thenReturn(writeContext);
|
||||
ExcelWriterWrapper<String> wrapper = ExcelWriterWrapper.of(writer);
|
||||
|
||||
assertSame(writer, wrapper.excelWriter());
|
||||
assertSame(writeContext, wrapper.writeContext());
|
||||
assertEquals(4, ExcelWriterWrapper.buildSheet(4, "named").getSheetNo());
|
||||
assertEquals("named", ExcelWriterWrapper.buildSheet(4, "named").getSheetName());
|
||||
assertEquals(5, ExcelWriterWrapper.buildSheet(5).getSheetNo());
|
||||
assertEquals("report", ExcelWriterWrapper.buildSheet("report").getSheetName());
|
||||
assertNotNull(ExcelWriterWrapper.buildSheet());
|
||||
assertEquals(6, ExcelWriterWrapper.buildTable(6).getTableNo());
|
||||
assertNotNull(ExcelWriterWrapper.buildTable());
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
package org.dromara.common.job;
|
||||
|
||||
import org.dromara.common.job.config.SnailJobConfig;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
@DisplayName("common-job 功能单元测试")
|
||||
class SnailJobConfigTest {
|
||||
|
||||
/**
|
||||
* 验证定时任务配置仅在显式启用 Snail Job 时加载,并同时开启 Spring 调度能力。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("声明 Snail Job 启用条件")
|
||||
void shouldDeclareSnailJobEnablementCondition() {
|
||||
ConditionalOnProperty condition = SnailJobConfig.class.getAnnotation(ConditionalOnProperty.class);
|
||||
|
||||
assertNotNull(condition);
|
||||
assertEquals("snail-job", condition.prefix());
|
||||
assertArrayEquals(new String[]{"enabled"}, condition.name());
|
||||
assertEquals("true", condition.havingValue());
|
||||
assertNotNull(SnailJobConfig.class.getAnnotation(EnableScheduling.class));
|
||||
}
|
||||
}
|
||||
@ -191,8 +191,7 @@ public class JsonUtils {
|
||||
* @return true = 合法 JSON,false = 非法或空
|
||||
*/
|
||||
public static boolean isJson(String str) {
|
||||
JsonNode node = readTreeQuietly(str);
|
||||
return node != null && (node.isObject() || node.isArray());
|
||||
return readTreeQuietly(str) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
package org.dromara.common.json;
|
||||
|
||||
import org.dromara.common.core.utils.SpringUtils;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* 为依赖全局 JsonMapper 的测试初始化最小 Spring 容器。
|
||||
*/
|
||||
public final class JsonTestContext {
|
||||
|
||||
private static final GenericApplicationContext CONTEXT = createContext();
|
||||
|
||||
private JsonTestContext() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发最小 Spring 容器初始化,供 JsonUtils 获取 JsonMapper。
|
||||
*/
|
||||
public static void initialize() {
|
||||
CONTEXT.isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仅注册 JsonMapper 的测试容器。
|
||||
*
|
||||
* @return 已启动的测试容器
|
||||
*/
|
||||
private static GenericApplicationContext createContext() {
|
||||
GenericApplicationContext context = new GenericApplicationContext();
|
||||
context.registerBean(JsonMapper.class, () -> JsonMapper.builder().build());
|
||||
context.refresh();
|
||||
new SpringUtils().setApplicationContext(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
package org.dromara.common.json.config;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("JacksonConfig 单元测试")
|
||||
class JacksonConfigTest {
|
||||
|
||||
/**
|
||||
* 验证安全范围内整数保持数字类型,超出 JavaScript 安全范围的整数和 BigDecimal 输出字符串。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("安全序列化大数字")
|
||||
void shouldSerializeNumbersWithoutJavaScriptPrecisionLoss() {
|
||||
JsonMapper mapper = configuredMapper();
|
||||
|
||||
assertEquals("9007199254740991", mapper.writeValueAsString(9_007_199_254_740_991L));
|
||||
assertEquals("\"9007199254740992\"", mapper.writeValueAsString(9_007_199_254_740_992L));
|
||||
assertEquals("\"1234567890.123456789\"",
|
||||
mapper.writeValueAsString(new BigDecimal("1234567890.123456789")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 LocalDateTime 使用统一格式序列化,并支持带空白的日期和时间字符串反序列化。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("序列化和反序列化日期时间")
|
||||
void shouldSerializeAndDeserializeTemporalValues() {
|
||||
JsonMapper mapper = configuredMapper();
|
||||
LocalDateTime value = LocalDateTime.of(2026, 9, 15, 10, 20, 30);
|
||||
|
||||
assertEquals("\"2026-09-15 10:20:30\"", mapper.writeValueAsString(value));
|
||||
assertEquals(value, mapper.readValue("\" 2026-09-15 10:20:30 \"", LocalDateTime.class));
|
||||
Date date = mapper.readValue("\"2026-09-15 10:20:30\"", Date.class);
|
||||
assertTrue(date.getTime() > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建注册项目 Jackson 模块的独立 JsonMapper,避免依赖完整 Spring 容器。
|
||||
*
|
||||
* @return 配置完成的 JsonMapper
|
||||
*/
|
||||
private static JsonMapper configuredMapper() {
|
||||
return JsonMapper.builder()
|
||||
.addModule(new JacksonConfig().registerJavaTimeModule())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
package org.dromara.common.json.enhance;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("JsonEnhancementContext 单元测试")
|
||||
class JsonEnhancementContextTest {
|
||||
|
||||
/**
|
||||
* 验证上下文属性按需创建,并可读取、复用和移除。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("上下文属性支持创建、读取和移除")
|
||||
void shouldManageAttributes() {
|
||||
JsonEnhancementContext context = new JsonEnhancementContext(null);
|
||||
List<String> values = context.getOrCreateAttribute("values", ArrayList::new);
|
||||
values.add("first");
|
||||
|
||||
List<String> sameValues = context.getOrCreateAttribute("values", ArrayList::new);
|
||||
|
||||
assertSame(values, sameValues);
|
||||
assertEquals(List.of("first"), context.<List<String>>getAttribute("values"));
|
||||
assertTrue(context.containsAttribute("values"));
|
||||
context.removeAttribute("values");
|
||||
assertFalse(context.containsAttribute("values"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证响应增强处理标记能够被正确设置。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("可以标记响应需要增强处理")
|
||||
void shouldMarkProcessingRequired() {
|
||||
JsonEnhancementContext context = new JsonEnhancementContext(null);
|
||||
|
||||
context.markProcessingRequired();
|
||||
|
||||
assertTrue(context.isProcessingRequired());
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,174 +0,0 @@
|
||||
package org.dromara.common.json.enhance;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("JsonValueEnhancer 单元测试")
|
||||
class JsonValueEnhancerTest {
|
||||
|
||||
/**
|
||||
* 验证没有处理器或没有字段命中时直接返回原对象,避免无意义的 JSON 树转换。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("无处理需求时保留原对象")
|
||||
void shouldKeepOriginalBodyWhenProcessingIsNotRequired() {
|
||||
Payload body = new Payload("secret", List.of(), new Object[0], null);
|
||||
JsonValueEnhancer emptyEnhancer = new JsonValueEnhancer(JsonMapper.builder().build(), List.of());
|
||||
JsonValueEnhancer unmatchedEnhancer = new JsonValueEnhancer(
|
||||
JsonMapper.builder().build(), List.of(new RecordingProcessor(false)));
|
||||
|
||||
assertSame(body, emptyEnhancer.enhance(body));
|
||||
assertSame(body, unmatchedEnhancer.enhance(body));
|
||||
assertSame(null, emptyEnhancer.enhance(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证增强器递归处理 Map、集合、数组和 POJO,并且 prepare 在所有字段收集完成后只执行一次。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("递归增强混合对象结构")
|
||||
void shouldEnhanceMapIterableArrayAndPojoValues() {
|
||||
RecordingProcessor processor = new RecordingProcessor(true);
|
||||
JsonValueEnhancer enhancer = new JsonValueEnhancer(JsonMapper.builder().build(), List.of(processor));
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("payload", new Payload("root", List.of(new Child("list")),
|
||||
new Object[]{new Child("array"), 3}, null));
|
||||
body.put("plain", "value");
|
||||
|
||||
JsonNode result = (JsonNode) enhancer.enhance(body);
|
||||
|
||||
assertEquals("ROOT", result.get("payload").get("secret").stringValue());
|
||||
assertEquals("LIST", result.get("payload").get("children").get(0).get("secret").stringValue());
|
||||
assertEquals("ARRAY", result.get("payload").get("values").get(0).get("secret").stringValue());
|
||||
assertEquals(3, result.get("payload").get("values").get(1).intValue());
|
||||
assertEquals("value", result.get("plain").stringValue());
|
||||
assertEquals(3, processor.collectedValues.size());
|
||||
assertEquals(1, processor.prepareCalls);
|
||||
assertEquals(4, processor.processCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字段被替换为复杂对象后会执行二次增强,使新对象中的目标字段也得到处理。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("二次增强处理器生成的复杂对象")
|
||||
void shouldEnhanceComplexValueProducedByProcessor() {
|
||||
RecordingProcessor processor = new RecordingProcessor(true);
|
||||
JsonValueEnhancer enhancer = new JsonValueEnhancer(JsonMapper.builder().build(), List.of(processor));
|
||||
|
||||
JsonNode result = (JsonNode) enhancer.enhance(
|
||||
new Payload("root", List.of(), new Object[0], "translated"));
|
||||
|
||||
assertEquals("ROOT", result.get("secret").stringValue());
|
||||
assertEquals("TRANSLATED", result.get("replacement").get("secret").stringValue());
|
||||
assertEquals(2, processor.prepareCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证已有 JsonNode 不重复处理,并正确过滤字符串和字节数组消息转换器。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("判断响应转换器支持范围")
|
||||
void shouldFilterUnsupportedMessageConverters() {
|
||||
JsonMapper mapper = JsonMapper.builder().build();
|
||||
JsonValueEnhancer enhancer = new JsonValueEnhancer(mapper, List.of(new RecordingProcessor(true)));
|
||||
JsonNode tree = mapper.createObjectNode().put("secret", "value");
|
||||
|
||||
assertSame(tree, enhancer.enhance(tree));
|
||||
assertTrue(enhancer.supports(JacksonJsonHttpMessageConverter.class));
|
||||
assertFalse(enhancer.supports(StringHttpMessageConverter.class));
|
||||
assertFalse(enhancer.supports(ByteArrayHttpMessageConverter.class));
|
||||
assertFalse(new JsonValueEnhancer(mapper, List.of()).supports(JacksonJsonHttpMessageConverter.class));
|
||||
}
|
||||
|
||||
private record Payload(String secret, List<Child> children, Object[] values, String replacement) {
|
||||
}
|
||||
|
||||
private record Child(String secret) {
|
||||
}
|
||||
|
||||
private static class RecordingProcessor implements JsonFieldProcessor {
|
||||
|
||||
private final boolean enabled;
|
||||
private final List<Object> collectedValues = new java.util.ArrayList<>();
|
||||
private int prepareCalls;
|
||||
private int processCalls;
|
||||
|
||||
/**
|
||||
* 创建可控制是否命中字段的记录型处理器。
|
||||
*
|
||||
* @param enabled 是否处理目标字段
|
||||
*/
|
||||
private RecordingProcessor(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅匹配 secret 和 replacement 字段,控制测试覆盖的增强范围。
|
||||
*
|
||||
* @param fieldContext 字段上下文
|
||||
* @return 是否处理当前字段
|
||||
*/
|
||||
@Override
|
||||
public boolean supports(JsonFieldContext fieldContext) {
|
||||
return enabled && ("secret".equals(fieldContext.propertyName())
|
||||
|| "replacement".equals(fieldContext.propertyName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录非空字段值,验证递归收集覆盖了所有目标对象。
|
||||
*
|
||||
* @param fieldContext 字段上下文
|
||||
* @param context 增强上下文
|
||||
*/
|
||||
@Override
|
||||
public void collect(JsonFieldContext fieldContext, JsonEnhancementContext context) {
|
||||
if (fieldContext.value() != null) {
|
||||
collectedValues.add(fieldContext.value());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录预处理次数并写入跨阶段属性。
|
||||
*
|
||||
* @param context 增强上下文
|
||||
*/
|
||||
@Override
|
||||
public void prepare(JsonEnhancementContext context) {
|
||||
prepareCalls++;
|
||||
context.setAttribute("prepared", Boolean.TRUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 secret 转为大写,并把 replacement 文本转换为待二次增强的对象。
|
||||
*
|
||||
* @param fieldContext 字段上下文
|
||||
* @param value 当前字段值
|
||||
* @param context 增强上下文
|
||||
* @return 增强后的字段值
|
||||
*/
|
||||
@Override
|
||||
public Object process(JsonFieldContext fieldContext, Object value, JsonEnhancementContext context) {
|
||||
processCalls++;
|
||||
assertTrue(context.containsAttribute("prepared"));
|
||||
if ("replacement".equals(fieldContext.propertyName())) {
|
||||
return value == null ? null : new Child(String.valueOf(value));
|
||||
}
|
||||
return value == null ? null : String.valueOf(value).toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
package org.dromara.common.json.utils;
|
||||
|
||||
import cn.hutool.core.lang.Dict;
|
||||
import org.dromara.common.json.JsonTestContext;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("JsonUtils 单元测试")
|
||||
class JsonUtilsTest {
|
||||
|
||||
/**
|
||||
* 在 JsonUtils 类初始化前注册其依赖的全局 JsonMapper。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeJsonMapper() {
|
||||
JsonTestContext.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通对象能够完成 JSON 往返转换及空输入处理。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("对象可以完成 JSON 序列化和反序列化")
|
||||
void shouldSerializeAndDeserializeObject() {
|
||||
TestUser user = new TestUser(1L, "admin");
|
||||
|
||||
String json = JsonUtils.toJsonString(user);
|
||||
TestUser result = JsonUtils.parseObject(json, TestUser.class);
|
||||
|
||||
assertEquals(user, result);
|
||||
assertNull(JsonUtils.toJsonString(null));
|
||||
assertNull(JsonUtils.parseObject("", TestUser.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证对象 Map、Map 列表和指定类型列表的解析结果。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("解析对象 Map 和对象列表")
|
||||
void shouldParseMapsAndArrays() {
|
||||
Dict map = JsonUtils.parseMap("{\"name\":\"admin\",\"enabled\":true}");
|
||||
List<Dict> maps = JsonUtils.parseArrayMap("[{\"id\":1},{\"id\":2}]");
|
||||
List<TestUser> users = JsonUtils.parseArray("[{\"id\":1,\"name\":\"a\"}]", TestUser.class);
|
||||
|
||||
assertEquals("admin", map.getStr("name"));
|
||||
assertEquals(2, maps.size());
|
||||
assertEquals(new TestUser(1L, "a"), users.getFirst());
|
||||
assertTrue(JsonUtils.parseArray("", TestUser.class).isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证指定敏感字段会从嵌套对象和数组元素中递归移除。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("递归移除对象和数组中的指定字段")
|
||||
void shouldRemoveFieldsRecursively() {
|
||||
Map<String, Object> value = Map.of(
|
||||
"password", "root-secret",
|
||||
"profile", Map.of("name", "admin", "password", "profile-secret"),
|
||||
"items", List.of(Map.of("password", "item-secret", "value", 1))
|
||||
);
|
||||
|
||||
String json = JsonUtils.toJsonStringExcludeFields(value, "password");
|
||||
JsonNode node = JsonUtils.getJsonMapper().readTree(json);
|
||||
|
||||
assertFalse(node.has("password"));
|
||||
assertFalse(node.get("profile").has("password"));
|
||||
assertFalse(node.get("items").get(0).has("password"));
|
||||
assertEquals("admin", node.get("profile").get("name").asString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证业务 JSON 仅接受对象和数组,不接受 JSON 标量。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("仅将 JSON 对象或数组识别为业务 JSON")
|
||||
void shouldRecognizeOnlyObjectOrArrayJson() {
|
||||
assertTrue(JsonUtils.isJson("{\"id\":1}"));
|
||||
assertTrue(JsonUtils.isJson("[1,2]"));
|
||||
assertFalse(JsonUtils.isJson("1"));
|
||||
assertFalse(JsonUtils.isJson("\"text\""));
|
||||
assertFalse(JsonUtils.isJson("invalid"));
|
||||
assertFalse(JsonUtils.isJson(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证对象和数组类型判断不会相互混淆。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("区分 JSON 对象和数组")
|
||||
void shouldDistinguishObjectAndArray() {
|
||||
assertTrue(JsonUtils.isJsonObject("{}"));
|
||||
assertFalse(JsonUtils.isJsonObject("[]"));
|
||||
assertTrue(JsonUtils.isJsonArray("[]"));
|
||||
assertFalse(JsonUtils.isJsonArray("{}"));
|
||||
}
|
||||
|
||||
private record TestUser(Long id, String name) {
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
package org.dromara.common.json.validate;
|
||||
|
||||
import org.dromara.common.json.JsonTestContext;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("JsonPatternValidator 单元测试")
|
||||
class JsonPatternValidatorTest {
|
||||
|
||||
/**
|
||||
* 在校验器调用 JsonUtils 前初始化全局 JsonMapper。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeJsonMapper() {
|
||||
JsonTestContext.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空值由 NotNull 或 NotBlank 等其他注解负责约束。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("空值交由其他校验注解处理")
|
||||
void blankValueShouldBeValid() {
|
||||
JsonPatternValidator validator = validator(JsonType.ANY);
|
||||
|
||||
assertTrue(validator.isValid(null, null));
|
||||
assertTrue(validator.isValid(" ", null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 OBJECT、ARRAY 和 ANY 三种类型约束的分支行为。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("按注解配置校验 JSON 类型")
|
||||
void shouldValidateConfiguredJsonType() {
|
||||
assertTrue(validator(JsonType.OBJECT).isValid("{\"id\":1}", null));
|
||||
assertFalse(validator(JsonType.OBJECT).isValid("[1]", null));
|
||||
assertTrue(validator(JsonType.ARRAY).isValid("[1]", null));
|
||||
assertFalse(validator(JsonType.ARRAY).isValid("{\"id\":1}", null));
|
||||
assertFalse(validator(JsonType.ANY).isValid("1", null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据指定 JSON 类型创建已初始化的校验器。
|
||||
*
|
||||
* @param type JSON 类型
|
||||
* @return 校验器
|
||||
*/
|
||||
private static JsonPatternValidator validator(JsonType type) {
|
||||
JsonPattern annotation = mock(JsonPattern.class);
|
||||
when(annotation.type()).thenReturn(type);
|
||||
JsonPatternValidator validator = new JsonPatternValidator();
|
||||
validator.initialize(annotation);
|
||||
return validator;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
<?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-liteflow</artifactId>
|
||||
|
||||
<description>
|
||||
ruoyi-common-liteflow LiteFlow规则编排模块
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- 核心模块 -->
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>ruoyi-common-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- LiteFlow 规则编排 -->
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@ -1,19 +0,0 @@
|
||||
package org.dromara.common.liteflow.component;
|
||||
|
||||
import com.yomahub.liteflow.annotation.LiteflowComponent;
|
||||
import com.yomahub.liteflow.core.NodeBooleanComponent;
|
||||
|
||||
/**
|
||||
* LiteFlow 恒为 false 的条件节点。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@LiteflowComponent("alwaysFalse")
|
||||
public class AlwaysFalseComponent extends NodeBooleanComponent {
|
||||
|
||||
@Override
|
||||
public boolean processBoolean() {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
package org.dromara.common.liteflow.component;
|
||||
|
||||
import com.yomahub.liteflow.annotation.LiteflowComponent;
|
||||
import com.yomahub.liteflow.core.NodeBooleanComponent;
|
||||
|
||||
/**
|
||||
* LiteFlow 恒为 true 的条件节点。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@LiteflowComponent("alwaysTrue")
|
||||
public class AlwaysTrueComponent extends NodeBooleanComponent {
|
||||
|
||||
@Override
|
||||
public boolean processBoolean() {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
package org.dromara.common.liteflow.component;
|
||||
|
||||
import com.yomahub.liteflow.annotation.LiteflowComponent;
|
||||
import com.yomahub.liteflow.core.NodeComponent;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
|
||||
/**
|
||||
* LiteFlow 上下文必填校验节点。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@LiteflowComponent("contextRequired")
|
||||
public class ContextRequiredComponent extends NodeComponent {
|
||||
|
||||
@Override
|
||||
public void process() {
|
||||
if (getFirstContextBean() == null) {
|
||||
throw new ServiceException("LiteFlow 上下文不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
package org.dromara.common.liteflow.component;
|
||||
|
||||
import com.yomahub.liteflow.annotation.LiteflowComponent;
|
||||
import com.yomahub.liteflow.core.NodeComponent;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.liteflow.core.FailMessageProvider;
|
||||
|
||||
/**
|
||||
* LiteFlow 失败节点,用于显式表达链路分支不可继续执行。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@LiteflowComponent("fail")
|
||||
public class FailComponent extends NodeComponent {
|
||||
|
||||
private static final String DEFAULT_MESSAGE = "LiteFlow 链路执行失败";
|
||||
|
||||
@Override
|
||||
public void process() {
|
||||
throw new ServiceException(getFailMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先从上下文读取业务失败提示,未提供时使用默认提示。
|
||||
*
|
||||
* @return 失败提示
|
||||
*/
|
||||
private String getFailMessage() {
|
||||
Object context = getFirstContextBean();
|
||||
if (context instanceof FailMessageProvider provider) {
|
||||
String message = provider.getFailMessage();
|
||||
if (StringUtils.isNotBlank(message)) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return DEFAULT_MESSAGE;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
package org.dromara.common.liteflow.component;
|
||||
|
||||
import com.yomahub.liteflow.annotation.LiteflowComponent;
|
||||
import com.yomahub.liteflow.core.NodeComponent;
|
||||
|
||||
/**
|
||||
* LiteFlow 空节点,用于显式表达无需处理的分支。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@LiteflowComponent("noop")
|
||||
public class NoopComponent extends NodeComponent {
|
||||
|
||||
@Override
|
||||
public void process() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
package org.dromara.common.liteflow.config;
|
||||
|
||||
import org.dromara.common.liteflow.component.*;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* LiteFlow 公共节点自动配置。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(value = "liteflow.enable", havingValue = "true")
|
||||
public class LiteFlowAutoConfiguration {
|
||||
|
||||
/**
|
||||
* 空节点,用于显式表达无需处理的分支。
|
||||
*
|
||||
* @return 空节点
|
||||
*/
|
||||
@Bean("noop")
|
||||
public NoopComponent noopComponent() {
|
||||
return new NoopComponent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败节点,用于显式抛出业务失败提示。
|
||||
*
|
||||
* @return 失败节点
|
||||
*/
|
||||
@Bean("fail")
|
||||
public FailComponent failComponent() {
|
||||
return new FailComponent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文必填校验节点。
|
||||
*
|
||||
* @return 上下文必填校验节点
|
||||
*/
|
||||
@Bean("contextRequired")
|
||||
public ContextRequiredComponent contextRequiredComponent() {
|
||||
return new ContextRequiredComponent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 恒为 true 的条件节点。
|
||||
*
|
||||
* @return true 条件节点
|
||||
*/
|
||||
@Bean("alwaysTrue")
|
||||
public AlwaysTrueComponent alwaysTrueComponent() {
|
||||
return new AlwaysTrueComponent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 恒为 false 的条件节点。
|
||||
*
|
||||
* @return false 条件节点
|
||||
*/
|
||||
@Bean("alwaysFalse")
|
||||
public AlwaysFalseComponent alwaysFalseComponent() {
|
||||
return new AlwaysFalseComponent();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package org.dromara.common.liteflow.core;
|
||||
|
||||
/**
|
||||
* LiteFlow 失败提示提供者。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
public interface FailMessageProvider {
|
||||
|
||||
/**
|
||||
* 获取公共失败节点抛出的业务提示。
|
||||
*
|
||||
* @return 失败提示
|
||||
*/
|
||||
String getFailMessage();
|
||||
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
package org.dromara.common.liteflow.utils;
|
||||
|
||||
import com.yomahub.liteflow.core.FlowExecutor;
|
||||
import com.yomahub.liteflow.flow.LiteflowResponse;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.SpringUtils;
|
||||
|
||||
/**
|
||||
* LiteFlow 执行工具。
|
||||
*
|
||||
* @author Lion Li
|
||||
*/
|
||||
@Slf4j
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class LiteFlowUtils {
|
||||
|
||||
/**
|
||||
* 执行 LiteFlow 链路,并按 contextBean 语义透传业务上下文和失败原因。
|
||||
*
|
||||
* @param chainId 链路标识
|
||||
* @param context 链路上下文
|
||||
*/
|
||||
public static void execute(String chainId, Object context) {
|
||||
if (context == null) {
|
||||
throw new ServiceException("LiteFlow 上下文不能为空");
|
||||
}
|
||||
LiteflowResponse response = SpringUtils.getBean(FlowExecutor.class).execute2Resp(chainId, null, context);
|
||||
if (!response.isSuccess()) {
|
||||
Exception cause = response.getCause();
|
||||
log.error("LiteFlow 链路执行失败 chainId={} requestId={} message={} steps={}",
|
||||
chainId, response.getRequestId(), response.getMessage(), response.getExecuteStepStrWithTime(), cause);
|
||||
if (cause instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
throw new ServiceException(cause != null ? cause.getMessage() : response.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
org.dromara.common.liteflow.config.LiteFlowAutoConfiguration
|
||||
@ -1,73 +0,0 @@
|
||||
package org.dromara.common.liteflow;
|
||||
|
||||
import org.dromara.common.liteflow.component.AlwaysFalseComponent;
|
||||
import org.dromara.common.liteflow.component.AlwaysTrueComponent;
|
||||
import org.dromara.common.liteflow.component.NoopComponent;
|
||||
import org.dromara.common.liteflow.component.FailComponent;
|
||||
import org.dromara.common.liteflow.component.ContextRequiredComponent;
|
||||
import org.dromara.common.liteflow.core.FailMessageProvider;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.spy;
|
||||
|
||||
@DisplayName("common-liteflow 功能单元测试")
|
||||
class LiteFlowComponentTest {
|
||||
|
||||
/**
|
||||
* 验证内置 LiteFlow 条件节点始终返回其声明的固定布尔值。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("执行固定布尔条件节点")
|
||||
void shouldReturnFixedBooleanValues() {
|
||||
assertTrue(new AlwaysTrueComponent().processBoolean());
|
||||
assertFalse(new AlwaysFalseComponent().processBoolean());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空操作节点可被安全执行,作为无需业务动作的显式流程分支。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("执行空操作节点")
|
||||
void shouldExecuteNoopComponentSafely() {
|
||||
assertDoesNotThrow(() -> new NoopComponent().process());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证失败节点优先采用业务上下文消息,并在缺少上下文时使用统一默认消息。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("从流程上下文解析失败消息")
|
||||
void shouldResolveFailureMessageFromContextOrDefault() {
|
||||
FailComponent contextual = spy(new FailComponent());
|
||||
FailMessageProvider provider = () -> "库存不足";
|
||||
doReturn(provider).when(contextual).getFirstContextBean();
|
||||
FailComponent fallback = spy(new FailComponent());
|
||||
doReturn(null).when(fallback).getFirstContextBean();
|
||||
|
||||
assertEquals("库存不足", assertThrows(ServiceException.class, contextual::process).getMessage());
|
||||
assertEquals("LiteFlow 链路执行失败", assertThrows(ServiceException.class, fallback::process).getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上下文必填节点拒绝空上下文,并允许有效流程上下文继续执行。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("校验流程上下文是否存在")
|
||||
void shouldRequireLiteFlowContext() {
|
||||
ContextRequiredComponent missing = spy(new ContextRequiredComponent());
|
||||
doReturn(null).when(missing).getFirstContextBean();
|
||||
ContextRequiredComponent present = spy(new ContextRequiredComponent());
|
||||
doReturn(new Object()).when(present).getFirstContextBean();
|
||||
|
||||
assertEquals("LiteFlow 上下文不能为空", assertThrows(ServiceException.class, missing::process).getMessage());
|
||||
assertDoesNotThrow(present::process);
|
||||
}
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
package org.dromara.common.liteflow;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.yomahub.liteflow.core.FlowExecutor;
|
||||
import com.yomahub.liteflow.flow.LiteflowResponse;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.liteflow.utils.LiteFlowUtils;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.support.StaticApplicationContext;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("LiteFlow 工具契约单元测试")
|
||||
class LiteFlowUtilsTest {
|
||||
|
||||
private static FlowExecutor flowExecutor;
|
||||
|
||||
/**
|
||||
* 注册 LiteFlow 工具所需的 mock 执行器,验证工具逻辑时不启动真实流程引擎。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeFlowExecutor() {
|
||||
flowExecutor = mock(FlowExecutor.class);
|
||||
StaticApplicationContext context = new StaticApplicationContext();
|
||||
context.getBeanFactory().registerSingleton("flowExecutor", flowExecutor);
|
||||
context.refresh();
|
||||
new SpringUtil().setApplicationContext(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理执行器调用记录,确保每个用例只验证自身的链路交互。
|
||||
*/
|
||||
@BeforeEach
|
||||
void resetExecutorInteractions() {
|
||||
reset(flowExecutor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空上下文在调用引擎前被拒绝,并返回统一业务异常。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝空流程上下文")
|
||||
void shouldRejectNullContextBeforeCallingExecutor() {
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> LiteFlowUtils.execute("demo-chain", null));
|
||||
|
||||
assertEquals("LiteFlow 上下文不能为空", exception.getMessage());
|
||||
verifyNoInteractions(flowExecutor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证成功响应只透传链路标识、上下文和空配置,不额外抛出异常。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("执行成功链路")
|
||||
void shouldExecuteSuccessfulChain() {
|
||||
Object context = new Object();
|
||||
LiteflowResponse response = new LiteflowResponse();
|
||||
response.setSuccess(true);
|
||||
when(flowExecutor.execute2Resp(eq("demo-chain"), isNull(), any(Object.class))).thenReturn(response);
|
||||
|
||||
assertDoesNotThrow(() -> LiteFlowUtils.execute("demo-chain", context));
|
||||
verify(flowExecutor).execute2Resp("demo-chain", null, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 LiteFlow 返回运行时失败原因时原样抛出,保留业务异常类型和堆栈。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("原样传播运行时失败")
|
||||
void shouldRethrowRuntimeFailureCause() {
|
||||
IllegalStateException cause = new IllegalStateException("chain failed");
|
||||
LiteflowResponse response = failedResponse(cause, "failed");
|
||||
Object context = new Object();
|
||||
when(flowExecutor.execute2Resp(eq("runtime-chain"), isNull(), any(Object.class))).thenReturn(response);
|
||||
|
||||
assertSame(cause, assertThrows(IllegalStateException.class,
|
||||
() -> LiteFlowUtils.execute("runtime-chain", context)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证受检异常和无原因失败会转换为 ServiceException,并分别使用原因或响应消息。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("转换受检异常和无原因失败")
|
||||
void shouldWrapCheckedOrMissingFailureCause() {
|
||||
Exception checked = new Exception("checked failure");
|
||||
Object context = new Object();
|
||||
LiteflowResponse checkedResponse = failedResponse(checked, "ignored");
|
||||
LiteflowResponse emptyResponse = failedResponse(null, "response failure");
|
||||
when(flowExecutor.execute2Resp(eq("checked-chain"), isNull(), any(Object.class)))
|
||||
.thenReturn(checkedResponse);
|
||||
when(flowExecutor.execute2Resp(eq("empty-chain"), isNull(), any(Object.class)))
|
||||
.thenReturn(emptyResponse);
|
||||
|
||||
ServiceException checkedException = assertThrows(ServiceException.class,
|
||||
() -> LiteFlowUtils.execute("checked-chain", context));
|
||||
ServiceException emptyException = assertThrows(ServiceException.class,
|
||||
() -> LiteFlowUtils.execute("empty-chain", context));
|
||||
|
||||
assertEquals("checked failure", checkedException.getMessage());
|
||||
assertEquals("response failure", emptyException.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败响应并填充工具日志所需的最小消息字段。
|
||||
*
|
||||
* @param cause 流程失败原因
|
||||
* @param message 流程失败消息
|
||||
* @return 失败响应
|
||||
*/
|
||||
private static LiteflowResponse failedResponse(Exception cause, String message) {
|
||||
LiteflowResponse response = mock(LiteflowResponse.class);
|
||||
when(response.isSuccess()).thenReturn(false);
|
||||
when(response.getCause()).thenReturn(cause);
|
||||
when(response.getMessage()).thenReturn(message);
|
||||
when(response.getRequestId()).thenReturn("request-id");
|
||||
when(response.getExecuteStepStrWithTime()).thenReturn("steps");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package org.dromara.common.log.event;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
@ -33,19 +34,9 @@ public class LoginInfoEvent implements Serializable {
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 客户端IP
|
||||
* 请求体
|
||||
*/
|
||||
private String ip;
|
||||
|
||||
/**
|
||||
* 用户代理
|
||||
*/
|
||||
private String userAgent;
|
||||
|
||||
/**
|
||||
* 客户端标识
|
||||
*/
|
||||
private String clientId;
|
||||
private HttpServletRequest request;
|
||||
|
||||
/**
|
||||
* 其他参数
|
||||
|
||||
@ -1,184 +0,0 @@
|
||||
package org.dromara.common.log;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.log.aspect.LogAspect;
|
||||
import org.dromara.common.log.enums.BusinessStatus;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.log.enums.OperatorType;
|
||||
import org.dromara.common.log.event.OperLogEvent;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.Signature;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.PayloadApplicationEvent;
|
||||
import org.springframework.context.support.StaticApplicationContext;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
@DisplayName("common-log 功能单元测试")
|
||||
class LogFunctionTest {
|
||||
|
||||
private static final AtomicReference<OperLogEvent> LAST_EVENT = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* 初始化日志切面依赖的 JSON 映射器和事件容器,避免启动完整 Spring 应用。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeLogInfrastructure() {
|
||||
StaticApplicationContext context = new StaticApplicationContext();
|
||||
context.getBeanFactory().registerSingleton("jsonMapper", JsonMapper.builder().build());
|
||||
context.addApplicationListener(event -> {
|
||||
if (event instanceof PayloadApplicationEvent<?> payload
|
||||
&& payload.getPayload() instanceof OperLogEvent operLog) {
|
||||
LAST_EVENT.set(operLog);
|
||||
}
|
||||
});
|
||||
context.refresh();
|
||||
new SpringUtil().setApplicationContext(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为每个日志切面测试绑定独立 HTTP 请求并清空上一次捕获的事件。
|
||||
*/
|
||||
@BeforeEach
|
||||
void bindRequestContext() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/system/users");
|
||||
request.addHeader(LoginHelper.CLIENT_KEY, "web-client");
|
||||
request.addHeader("X-Forwarded-For", "10.0.0.8");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
LAST_EVENT.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理线程请求上下文,避免日志切面测试污染后续用例。
|
||||
*/
|
||||
@AfterEach
|
||||
void resetRequestContext() {
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传文件、Servlet 请求及包含这些对象的集合会从日志参数中排除。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("识别不可记录的请求参数")
|
||||
void shouldFilterServletAndUploadObjects() {
|
||||
LogAspect aspect = new LogAspect();
|
||||
MockMultipartFile file = new MockMultipartFile("file", "demo.txt", "text/plain", new byte[]{1});
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
assertTrue(aspect.isFilterObject(file));
|
||||
assertTrue(aspect.isFilterObject(request));
|
||||
assertTrue(aspect.isFilterObject(new Object[]{"value", file}));
|
||||
assertTrue(aspect.isFilterObject(List.of("value", file)));
|
||||
assertTrue(aspect.isFilterObject(Map.of("file", file)));
|
||||
assertFalse(aspect.isFilterObject(List.of("value", 1L)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证操作日志注解保存业务类型、操作人类型及请求响应开关。
|
||||
*
|
||||
* @throws Exception 读取测试方法注解失败
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("读取操作日志注解配置")
|
||||
void shouldExposeLogAnnotationConfiguration() throws Exception {
|
||||
Method method = TestController.class.getDeclaredMethod("update");
|
||||
Log log = method.getAnnotation(Log.class);
|
||||
|
||||
assertEquals("用户管理", log.title());
|
||||
assertEquals(BusinessType.UPDATE, log.businessType());
|
||||
assertEquals(OperatorType.MOBILE, log.operatorType());
|
||||
assertFalse(log.isSaveRequestData());
|
||||
assertTrue(log.isSaveResponseData());
|
||||
assertArrayEquals(new String[]{"password"}, log.excludeParamNames());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证日志切面执行目标方法后发布完整事件,并从序列化请求参数中排除密码等敏感字段。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("记录成功操作日志并排除敏感参数")
|
||||
void shouldPublishSuccessfulOperationLogWithFilteredParameters() throws Throwable {
|
||||
Log annotation = TestController.class.getDeclaredMethod("create", CreateRequest.class).getAnnotation(Log.class);
|
||||
ProceedingJoinPoint joinPoint = joinPoint(new CreateRequest("alice", "secret"), Map.of("id", 1L));
|
||||
|
||||
Object result;
|
||||
try (var login = mockStatic(LoginHelper.class)) {
|
||||
login.when(LoginHelper::getLoginUser).thenReturn(null);
|
||||
result = new LogAspect().doAround(joinPoint, annotation);
|
||||
}
|
||||
|
||||
OperLogEvent event = LAST_EVENT.get();
|
||||
assertEquals(Map.of("id", 1L), result);
|
||||
assertNotNull(event);
|
||||
assertEquals(BusinessStatus.SUCCESS.ordinal(), event.getStatus());
|
||||
assertEquals("新增用户", event.getTitle());
|
||||
assertEquals("POST", event.getRequestMethod());
|
||||
assertEquals("/system/users", event.getOperUrl());
|
||||
assertEquals("10.0.0.8", event.getOperIp());
|
||||
assertEquals("web-client", event.getClientKey());
|
||||
assertTrue(event.getOperParam().contains("alice"));
|
||||
assertFalse(event.getOperParam().contains("secret"));
|
||||
assertTrue(event.getJsonResult().contains("\"id\":1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可返回指定结果的切点,并提供日志方法名、目标类和请求参数。
|
||||
*
|
||||
* @param request 请求参数
|
||||
* @param result 目标方法返回值
|
||||
* @return 模拟切点
|
||||
*/
|
||||
private static ProceedingJoinPoint joinPoint(CreateRequest request, Object result) throws Throwable {
|
||||
ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
|
||||
Signature signature = mock(Signature.class);
|
||||
when(signature.getName()).thenReturn("create");
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(joinPoint.getTarget()).thenReturn(new TestController());
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{request});
|
||||
when(joinPoint.proceed()).thenReturn(result);
|
||||
return joinPoint;
|
||||
}
|
||||
|
||||
private static class TestController {
|
||||
|
||||
/**
|
||||
* 提供完整日志注解配置供反射测试读取。
|
||||
*/
|
||||
@Log(title = "用户管理", businessType = BusinessType.UPDATE, operatorType = OperatorType.MOBILE,
|
||||
isSaveRequestData = false, excludeParamNames = "password")
|
||||
private void update() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供保存请求和响应数据的日志配置供完整切面测试。
|
||||
*
|
||||
* @param request 新增请求
|
||||
*/
|
||||
@Log(title = "新增用户", businessType = BusinessType.INSERT, excludeParamNames = "password")
|
||||
private void create(CreateRequest request) {
|
||||
}
|
||||
}
|
||||
|
||||
private record CreateRequest(String username, String password) {
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
package org.dromara.common.mail;
|
||||
|
||||
import cn.hutool.extra.mail.MailAccount;
|
||||
import org.dromara.common.mail.config.properties.MailProperties;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("common-mail 功能单元测试")
|
||||
class MailPropertiesTest {
|
||||
|
||||
/**
|
||||
* 验证业务邮件属性能够完整转换为 Hutool 邮件账户,避免发送时遗漏安全和超时配置。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("转换邮件账户配置")
|
||||
void shouldConvertPropertiesToMailAccount() {
|
||||
MailProperties properties = new MailProperties();
|
||||
properties.setHost("smtp.example.com");
|
||||
properties.setPort(465);
|
||||
properties.setAuth(true);
|
||||
properties.setUser("sender@example.com");
|
||||
properties.setPass("secret");
|
||||
properties.setFrom("Sender <sender@example.com>");
|
||||
properties.setStarttlsEnable(true);
|
||||
properties.setSslEnable(true);
|
||||
properties.setTimeout(5000L);
|
||||
properties.setConnectionTimeout(3000L);
|
||||
|
||||
MailAccount account = properties.toMailAccount();
|
||||
|
||||
assertEquals("smtp.example.com", account.getHost());
|
||||
assertEquals(465, account.getPort());
|
||||
assertTrue(account.isAuth());
|
||||
assertEquals("sender@example.com", account.getUser());
|
||||
assertEquals("secret", account.getPass());
|
||||
assertEquals("Sender <sender@example.com>", account.getFrom());
|
||||
assertTrue(account.isStarttlsEnable());
|
||||
assertTrue(account.isSslEnable());
|
||||
assertEquals(5000L, ReflectionTestUtils.getField(account, "timeout"));
|
||||
assertEquals(3000L, ReflectionTestUtils.getField(account, "connectionTimeout"));
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
package org.dromara.common.mcp;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.dromara.common.mcp.core.McpResourceReadResult;
|
||||
import org.dromara.common.mcp.core.McpToolCallResult;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("common-mcp 功能单元测试")
|
||||
class McpResultTest {
|
||||
|
||||
/**
|
||||
* 验证 MCP 工具调用结果会保留服务端、内容、结构化数据和错误标记。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("转换 MCP 工具调用结果")
|
||||
void shouldConvertMcpToolCallResult() {
|
||||
McpSchema.CallToolResult sdkResult = mock(McpSchema.CallToolResult.class);
|
||||
McpSchema.Content content = mock(McpSchema.Content.class);
|
||||
when(sdkResult.isError()).thenReturn(true);
|
||||
when(sdkResult.content()).thenReturn(List.of(content));
|
||||
when(sdkResult.structuredContent()).thenReturn(Map.of("id", 1));
|
||||
|
||||
McpToolCallResult result = McpToolCallResult.of("server-a", sdkResult);
|
||||
|
||||
assertEquals("server-a", result.serverName());
|
||||
assertTrue(result.error());
|
||||
assertEquals(List.of(content), result.content());
|
||||
assertEquals(Map.of("id", 1), result.structuredContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MCP SDK 返回空错误标记时按成功处理,兼容未显式设置 isError 的服务端。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("兼容空 MCP 错误标记")
|
||||
void shouldTreatNullMcpErrorFlagAsSuccess() {
|
||||
McpSchema.CallToolResult sdkResult = mock(McpSchema.CallToolResult.class);
|
||||
|
||||
assertFalse(McpToolCallResult.of("server-a", sdkResult).error());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MCP 资源读取结果会附加来源服务端并保留资源内容列表。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("转换 MCP 资源读取结果")
|
||||
void shouldConvertMcpResourceReadResult() {
|
||||
McpSchema.ReadResourceResult sdkResult = mock(McpSchema.ReadResourceResult.class);
|
||||
McpSchema.ResourceContents content = mock(McpSchema.ResourceContents.class);
|
||||
when(sdkResult.contents()).thenReturn(List.of(content));
|
||||
|
||||
McpResourceReadResult result = McpResourceReadResult.of("server-b", sdkResult);
|
||||
|
||||
assertEquals("server-b", result.serverName());
|
||||
assertEquals(List.of(content), result.contents());
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
package org.dromara.common.mqtt;
|
||||
|
||||
import org.dromara.common.mqtt.config.MqttAutoConfiguration;
|
||||
import org.dromara.common.mqtt.listener.MqttClientConnectListener;
|
||||
import org.dromara.common.mqtt.listener.MqttClientGlobalMessageListener;
|
||||
import org.dromara.mica.mqtt.core.client.MqttClientCreator;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
@DisplayName("common-mqtt 功能单元测试")
|
||||
class MqttAutoConfigurationTest {
|
||||
|
||||
/**
|
||||
* 验证 MQTT 自动配置能够创建连接与全局消息监听器,且不需要建立真实网络连接。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("创建 MQTT 监听器")
|
||||
void shouldCreateMqttListeners() {
|
||||
MqttAutoConfiguration configuration = new MqttAutoConfiguration();
|
||||
|
||||
MqttClientConnectListener connectListener =
|
||||
configuration.mqttClientConnectListener(mock(MqttClientCreator.class));
|
||||
MqttClientGlobalMessageListener messageListener = configuration.mqttClientGlobalMessageListener();
|
||||
|
||||
assertNotNull(connectListener);
|
||||
assertNotNull(messageListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MQTT 自定义器 Bean 可以独立创建,避免自动配置方法意外依赖运行时连接状态。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("创建 MQTT 客户端自定义器")
|
||||
void shouldCreateMqttClientCustomizer() {
|
||||
assertNotNull(new MqttAutoConfiguration().mqttClientCustomizer());
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.core.injector.methods.SelectList;
|
||||
import com.baomidou.mybatisplus.core.mapper.Mapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import com.baomidou.mybatisplus.core.toolkit.ArrayUtils;
|
||||
import com.github.yulichang.adapter.base.tookit.VersionUtils;
|
||||
import com.github.yulichang.base.JoinMapper;
|
||||
import com.github.yulichang.method.*;
|
||||
import com.github.yulichang.toolkit.MPJTableMapperHelper;
|
||||
@ -112,6 +113,7 @@ public class MPJSqlInjector extends DefaultSqlInjector {
|
||||
*/
|
||||
private List<AbstractMethod> getJoinMethod() {
|
||||
List<AbstractMethod> list = new ArrayList<>();
|
||||
if (VersionUtils.compare(VersionUtils.getVersion(), "3.5.0") >= 0) {
|
||||
list.add(new DeleteJoin(SqlMethod.DELETE_JOIN.getMethod()));
|
||||
list.add(new UpdateJoin(SqlMethod.UPDATE_JOIN.getMethod()));
|
||||
list.add(new UpdateJoinAndNull(SqlMethod.UPDATE_JOIN_AND_NULL.getMethod()));
|
||||
@ -119,6 +121,15 @@ public class MPJSqlInjector extends DefaultSqlInjector {
|
||||
list.add(new SelectJoinOne(SqlMethod.SELECT_JOIN_ONE.getMethod()));
|
||||
list.add(new SelectJoinList(SqlMethod.SELECT_JOIN_LIST.getMethod()));
|
||||
list.add(new SelectJoinPage(SqlMethod.SELECT_JOIN_PAGE.getMethod()));
|
||||
} else {
|
||||
list.add(new DeleteJoin());
|
||||
list.add(new UpdateJoin());
|
||||
list.add(new UpdateJoinAndNull());
|
||||
list.add(new SelectJoinCount());
|
||||
list.add(new SelectJoinOne());
|
||||
list.add(new SelectJoinList());
|
||||
list.add(new SelectJoinPage());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
|
||||
@ -26,19 +26,14 @@ public class DataPermissionAdvice implements MethodInterceptor {
|
||||
public Object invoke(MethodInvocation invocation) throws Throwable {
|
||||
Object target = invocation.getThis();
|
||||
Method method = invocation.getMethod();
|
||||
DataPermission previousPermission = DataPermissionHelper.getPermission();
|
||||
// 设置权限注解
|
||||
DataPermissionHelper.setPermission(getDataPermissionAnnotation(target, method));
|
||||
try {
|
||||
// 执行代理方法
|
||||
return invocation.proceed();
|
||||
} finally {
|
||||
// 恢复上一层权限上下文,兼容嵌套 Mapper 调用
|
||||
if (previousPermission == null) {
|
||||
// 清除权限注解
|
||||
DataPermissionHelper.removePermission();
|
||||
} else {
|
||||
DataPermissionHelper.setPermission(previousPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,9 +6,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
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.metadata.MapperProxyMetadata;
|
||||
import com.baomidou.mybatisplus.core.toolkit.reflect.GenericTypeUtils;
|
||||
import com.baomidou.mybatisplus.core.toolkit.MybatisUtils;
|
||||
import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.toolkit.ChainWrappers;
|
||||
@ -16,7 +14,6 @@ import com.baomidou.mybatisplus.extension.toolkit.Db;
|
||||
import org.apache.ibatis.logging.Log;
|
||||
import org.apache.ibatis.logging.LogFactory;
|
||||
import org.dromara.common.core.utils.MapstructUtils;
|
||||
import org.dromara.common.core.utils.SpringUtils;
|
||||
import org.dromara.common.core.utils.StreamUtils;
|
||||
|
||||
import java.io.Serializable;
|
||||
@ -103,26 +100,7 @@ public interface BaseMapperPlus<T, V> extends BaseMapper<T> {
|
||||
* @return Lambda CRUD 链式包装器
|
||||
*/
|
||||
default LambdaCrudChainWrapper<T, V> lambda() {
|
||||
return new LambdaCrudChainWrapper<>(mapperProxy());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Mapper 对应的 Spring 代理对象,确保 Mapper 上的切面注解能够继续生效。
|
||||
*
|
||||
* @return Mapper 代理对象
|
||||
*/
|
||||
private BaseMapperPlus<T, V> mapperProxy() {
|
||||
BaseMapperPlus<T, V> mapper = this;
|
||||
try {
|
||||
MapperProxyMetadata metadata = MybatisUtils.getMapperProxy(this);
|
||||
Object proxy = SpringUtils.getBean(metadata.getMapperInterface());
|
||||
if (proxy instanceof BaseMapperPlus<?, ?>) {
|
||||
mapper = (BaseMapperPlus<T, V>) proxy;
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// Mapper 可能未托管于 Spring 容器,保留当前对象作为回退。
|
||||
}
|
||||
return mapper;
|
||||
return new LambdaCrudChainWrapper<>(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -131,7 +109,7 @@ public interface BaseMapperPlus<T, V> extends BaseMapper<T> {
|
||||
* @return Lambda 链式更新包装器
|
||||
*/
|
||||
default LambdaUpdateChainWrapper<T> lambdaUpdate() {
|
||||
return ChainWrappers.lambdaUpdateChain(mapperProxy());
|
||||
return ChainWrappers.lambdaUpdateChain(this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -49,44 +49,6 @@ public final class LambdaJoinQueryBuilder<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加分组字段。
|
||||
*
|
||||
* @param columns 分组字段
|
||||
* @param <E> 字段所属实体类型
|
||||
* @return 当前联表查询构造辅助对象
|
||||
*/
|
||||
@SafeVarargs
|
||||
public final <E> LambdaJoinQueryBuilder<T> groupBy(SFunction<E, ?>... columns) {
|
||||
if (columns == null) {
|
||||
return this;
|
||||
}
|
||||
for (SFunction<E, ?> column : columns) {
|
||||
wrapper.groupBy(column);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加带表别名的分组字段。
|
||||
*
|
||||
* @param tableAlias 表别名
|
||||
* @param columns 分组字段
|
||||
* @param <E> 字段所属实体类型
|
||||
* @return 当前联表查询构造辅助对象
|
||||
*/
|
||||
@SafeVarargs
|
||||
public final <E> LambdaJoinQueryBuilder<T> groupBy(String tableAlias, SFunction<E, ?>... columns) {
|
||||
if (columns == null || columns.length == 0) {
|
||||
return this;
|
||||
}
|
||||
String checkedAlias = AggregateSelectUtils.checkAlias(tableAlias);
|
||||
for (SFunction<E, ?> column : columns) {
|
||||
wrapper.groupBy(checkedAlias, column);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定主表查询字段。
|
||||
*
|
||||
@ -209,11 +171,7 @@ public final class LambdaJoinQueryBuilder<T> {
|
||||
*/
|
||||
public <S> LambdaJoinQueryBuilder<T> selectSub(Class<S> entityClass, Consumer<SubQuery<S>> consumer, String alias) {
|
||||
SubQuery<S> subQuery = buildPlaceholderSubQuery(entityClass, consumer);
|
||||
wrapper.selectFunc("(" + subQuery.build() + ")", func -> {
|
||||
// MPJ raw select functions require a non-null column argument array even when SQL has no column placeholders.
|
||||
func.setArgs(new SFunction[0]);
|
||||
return func.values(subQuery.params());
|
||||
},
|
||||
wrapper.selectFunc("(" + subQuery.build() + ")", func -> func.values(subQuery.params()),
|
||||
AggregateSelectUtils.checkAlias(alias));
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.system.api.model.LoginUser;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* MP注入处理器
|
||||
@ -62,9 +61,6 @@ public class InjectionMetaObjectHandler implements MetaObjectHandler {
|
||||
LocalDateTime date = LocalDateTime.now();
|
||||
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, date);
|
||||
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, date);
|
||||
Date legacyDate = new Date();
|
||||
this.strictInsertFill(metaObject, "createTime", Date.class, legacyDate);
|
||||
this.strictInsertFill(metaObject, "updateTime", Date.class, legacyDate);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new ServiceException("自动注入异常 => " + e.getMessage(), HttpStatus.HTTP_INTERNAL_ERROR);
|
||||
@ -90,7 +86,6 @@ public class InjectionMetaObjectHandler implements MetaObjectHandler {
|
||||
baseEntity.setUpdateBy(userId);
|
||||
} else {
|
||||
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
|
||||
this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new ServiceException("自动注入异常 => " + e.getMessage(), HttpStatus.HTTP_INTERNAL_ERROR);
|
||||
|
||||
@ -71,17 +71,13 @@ public class PlusDataPermissionHandler {
|
||||
*/
|
||||
public Expression getSqlSegment(Expression where, boolean isSelect) {
|
||||
try {
|
||||
DataPermission dataPermission = getDataPermission();
|
||||
if (dataPermission == null) {
|
||||
throw new ServiceException("数据权限上下文缺失,请检查 @DataPermission 注解处理生命周期");
|
||||
}
|
||||
LoginUser currentUser = currentUser();
|
||||
// 如果是超级管理员或租户管理员,则不过滤数据
|
||||
if (LoginHelper.isSuperAdmin()) {
|
||||
return where;
|
||||
}
|
||||
// 构造数据过滤条件的 SQL 片段
|
||||
String dataFilterSql = buildDataFilter(dataPermission, currentUser, isSelect);
|
||||
String dataFilterSql = buildDataFilter(getDataPermission(), currentUser, isSelect);
|
||||
if (StringUtils.isBlank(dataFilterSql)) {
|
||||
return where;
|
||||
}
|
||||
@ -95,6 +91,8 @@ public class PlusDataPermissionHandler {
|
||||
}
|
||||
} catch (JSQLParserException e) {
|
||||
throw new ServiceException("数据权限解析异常 => " + e.getMessage());
|
||||
} finally {
|
||||
DataPermissionHelper.removePermission();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,16 +20,14 @@ public class PlusPostInitTableInfoHandler implements PostInitTableInfoHandler {
|
||||
*
|
||||
* @param tableInfo 表信息
|
||||
* @param configuration MyBatis 配置
|
||||
* @return 调整后的表信息
|
||||
*/
|
||||
@Override
|
||||
public TableInfo postTableInfo(TableInfo tableInfo, Configuration configuration) {
|
||||
public void postTableInfo(TableInfo tableInfo, Configuration configuration) {
|
||||
String flag = SpringUtils.getProperty("mybatis-plus.enableLogicDelete", "true");
|
||||
// 只有关闭时 统一设置false 为true时mp自动判断不处理
|
||||
if (!Convert.toBool(flag)) {
|
||||
ReflectUtils.setFieldValue(tableInfo, "withLogicDelete", false);
|
||||
}
|
||||
return tableInfo;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -24,12 +24,13 @@ import java.sql.Statement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* 完整 SQL 日志拦截器。
|
||||
@ -70,6 +71,11 @@ public class SqlLogInterceptor implements Interceptor {
|
||||
*/
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
/**
|
||||
* 控制台输出锁,避免多线程 SQL 日志互相穿插。
|
||||
*/
|
||||
private static final ReentrantLock CONSOLE_LOCK = new ReentrantLock();
|
||||
|
||||
/**
|
||||
* SQL 日志配置。
|
||||
*/
|
||||
@ -334,7 +340,13 @@ public class SqlLogInterceptor implements Interceptor {
|
||||
* @param message 日志内容
|
||||
*/
|
||||
private void printConsole(String message) {
|
||||
System.err.print(message + System.lineSeparator() + System.lineSeparator());
|
||||
CONSOLE_LOCK.lock();
|
||||
try {
|
||||
System.err.println(message);
|
||||
System.err.println();
|
||||
} finally {
|
||||
CONSOLE_LOCK.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,253 +0,0 @@
|
||||
package org.dromara.common.mybatis.core.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("LambdaCrudChainWrapper 单元测试")
|
||||
class LambdaCrudChainWrapperTest {
|
||||
|
||||
/**
|
||||
* 初始化测试实体表元数据,确保测试覆盖真实的 MyBatis-Plus Lambda 字段解析。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeTableMetadata() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "crudEntity"), CrudEntity.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "crudRelation"), CrudRelation.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证构造器从 Mapper 获取实体类型,并组合查询字段、聚合字段、嵌套条件和共享参数。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构造 Mapper 级查询链")
|
||||
void shouldBuildMapperBoundQueryState() {
|
||||
LambdaCrudChainWrapper<CrudEntity, CrudVo> wrapper = wrapper()
|
||||
.select(CrudEntity::getId, CrudEntity::getName)
|
||||
.selectSum(CrudEntity::getScore, "totalScore")
|
||||
.selectCountAll(CrudVo::getTotal)
|
||||
.eq(CrudEntity::getName, "alice")
|
||||
.and(nested -> nested.ge(CrudEntity::getScore, 60)
|
||||
.or().isNull(CrudEntity::getRemark));
|
||||
|
||||
assertEquals(CrudEntity.class, wrapper.getEntityClass());
|
||||
assertTrue(wrapper.getSqlSelect().contains("id,name"));
|
||||
assertTrue(wrapper.getSqlSelect().contains("SUM(score) AS totalScore"));
|
||||
assertTrue(wrapper.getSqlSelect().contains("COUNT(*) AS total"));
|
||||
assertTrue(wrapper.getSqlSegment().contains("AND (score >="));
|
||||
assertEquals(new HashSet<>(List.of("alice", 60)),
|
||||
new HashSet<>(wrapper.getParamNameValuePairs().values()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证查询条件和更新 SET 片段可以共存,并保持可选赋值、原生 SQL、自增和自减语义。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("组合查询和更新片段")
|
||||
void shouldComposeQueryAndUpdateFragments() {
|
||||
LambdaCrudChainWrapper<CrudEntity, CrudVo> wrapper = wrapper()
|
||||
.eq(CrudEntity::getId, 1L)
|
||||
.set(CrudEntity::getName, "updated")
|
||||
.setIfPresent(CrudEntity::getRemark, null)
|
||||
.setIfText(CrudEntity::getRemark, " ")
|
||||
.setIfText(CrudEntity::getRemark, "memo")
|
||||
.setSql("score = {0}", 80)
|
||||
.setIncrBy(CrudEntity::getScore, new BigDecimal("1.50"))
|
||||
.setDecrBy(CrudEntity::getScore, 2);
|
||||
|
||||
String sqlSet = wrapper.getSqlSet();
|
||||
|
||||
assertTrue(wrapper.getSqlSegment().contains("id ="));
|
||||
assertTrue(sqlSet.contains("name="));
|
||||
assertTrue(sqlSet.contains("remark="));
|
||||
assertTrue(sqlSet.contains("score ="));
|
||||
assertTrue(sqlSet.contains("score=score + 1.50"));
|
||||
assertTrue(sqlSet.contains("score=score - 2"));
|
||||
assertFalse(wrapper.getParamNameValuePairs().values().contains(null));
|
||||
assertTrue(wrapper.getParamNameValuePairs().values().containsAll(List.of(1L, "updated", "memo", 80)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Mapper 链式包装器中的子查询沿用主 Wrapper 参数序列并生成逻辑删除条件。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构造 Mapper 链式子查询")
|
||||
void shouldBuildSubQueriesWithSharedParameters() {
|
||||
LambdaCrudChainWrapper<CrudEntity, CrudVo> wrapper = wrapper()
|
||||
.select(CrudEntity::getId)
|
||||
.selectSub(CrudRelation.class, sub -> sub
|
||||
.selectCountAll()
|
||||
.eqColumn(CrudRelation::getOwnerId, CrudEntity::getId)
|
||||
.eq(CrudRelation::getState, "selected"), CrudVo::getTotal)
|
||||
.inSub(CrudEntity::getId, CrudRelation.class, sub -> sub
|
||||
.select(CrudRelation::getOwnerId)
|
||||
.eq(CrudRelation::getState, "included"))
|
||||
.existsSub(CrudRelation.class, sub -> sub
|
||||
.selectCountAll()
|
||||
.eqColumn(CrudRelation::getOwnerId, CrudEntity::getId)
|
||||
.eq(CrudRelation::getState, "existing"));
|
||||
|
||||
assertTrue(wrapper.getSqlSelect().contains("SELECT COUNT(*) FROM crud_relation"));
|
||||
assertTrue(wrapper.getSqlSelect().contains("deleted=0"));
|
||||
assertTrue(wrapper.getSqlSegment().contains("id IN (SELECT owner_id FROM crud_relation"));
|
||||
assertTrue(wrapper.getSqlSegment().contains("EXISTS (SELECT COUNT(*) FROM crud_relation"));
|
||||
assertEquals(new HashSet<>(List.of("selected", "included", "existing")),
|
||||
new HashSet<>(wrapper.getParamNameValuePairs().values()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 clear 清除查询、更新、参数和附加 SQL,防止复用 Wrapper 时残留上一次状态。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("清空 Mapper 链式状态")
|
||||
void shouldClearQueryAndUpdateState() {
|
||||
LambdaCrudChainWrapper<CrudEntity, CrudVo> wrapper = wrapper()
|
||||
.select(CrudEntity::getName)
|
||||
.selectCountAll("total")
|
||||
.set(CrudEntity::getName, "updated")
|
||||
.eq(CrudEntity::getId, 1L)
|
||||
.first("/*+ INDEX */")
|
||||
.comment("update-comment")
|
||||
.last("LIMIT 1");
|
||||
|
||||
wrapper.clear();
|
||||
|
||||
assertNull(wrapper.getSqlSelect());
|
||||
assertNull(wrapper.getSqlSet());
|
||||
assertTrue(wrapper.getSqlSegment().isEmpty());
|
||||
assertTrue(wrapper.getCustomSqlSegment().isEmpty());
|
||||
assertTrue(wrapper.getParamNameValuePairs().isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 BaseMapperPlus 默认方法可以从具体 Mapper 泛型解析实体和 VO,并创建项目链式包装器。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("通过 BaseMapperPlus 默认入口创建查询链")
|
||||
void shouldResolveMapperGenericTypesAndCreateLambdaChain() {
|
||||
CrudMapper mapper = mock(CrudMapper.class, CALLS_REAL_METHODS);
|
||||
|
||||
LambdaCrudChainWrapper<CrudEntity, CrudVo> wrapper = mapper.lambda();
|
||||
|
||||
assertEquals(CrudEntity.class, mapper.currentModelClass());
|
||||
assertEquals(CrudVo.class, mapper.currentVoClass());
|
||||
assertEquals(CrudEntity.class, wrapper.getEntityClass());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建绑定测试实体类型的 Mapper 链式包装器。
|
||||
*
|
||||
* @return Mapper 链式包装器
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static LambdaCrudChainWrapper<CrudEntity, CrudVo> wrapper() {
|
||||
BaseMapperPlus<CrudEntity, CrudVo> mapper = mock(BaseMapperPlus.class);
|
||||
when(mapper.currentModelClass()).thenReturn(CrudEntity.class);
|
||||
return new LambdaCrudChainWrapper<>(mapper);
|
||||
}
|
||||
|
||||
private interface CrudMapper extends BaseMapperPlus<CrudEntity, CrudVo> {
|
||||
}
|
||||
|
||||
@TableName("crud_entity")
|
||||
private static class CrudEntity {
|
||||
@TableId
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer score;
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 返回实体主键,供 Lambda 字段解析。
|
||||
*
|
||||
* @return 实体主键
|
||||
*/
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回实体名称,供查询和更新字段解析。
|
||||
*
|
||||
* @return 实体名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回实体分值,供聚合和数值更新解析。
|
||||
*
|
||||
* @return 实体分值
|
||||
*/
|
||||
public Integer getScore() {
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回实体备注,供可选更新和空值查询解析。
|
||||
*
|
||||
* @return 实体备注
|
||||
*/
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CrudVo {
|
||||
private Long total;
|
||||
|
||||
/**
|
||||
* 返回聚合总数,供 Lambda 推导查询别名。
|
||||
*
|
||||
* @return 聚合总数
|
||||
*/
|
||||
public Long getTotal() {
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
@TableName("crud_relation")
|
||||
private static class CrudRelation {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long ownerId;
|
||||
private String state;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
/**
|
||||
* 返回关联记录所属实体主键,供关联子查询解析。
|
||||
*
|
||||
* @return 所属实体主键
|
||||
*/
|
||||
public Long getOwnerId() {
|
||||
return ownerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回关联记录状态,供子查询参数绑定。
|
||||
*
|
||||
* @return 关联状态
|
||||
*/
|
||||
public String getState() {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
package org.dromara.common.mybatis.core.page;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.OrderItem;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("PageQuery 单元测试")
|
||||
class PageQueryTest {
|
||||
|
||||
/**
|
||||
* 验证缺省分页参数会使用框架定义的页码和页大小。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("空分页参数使用默认值")
|
||||
void shouldUseDefaultPagination() {
|
||||
Page<Object> page = new PageQuery().build();
|
||||
|
||||
assertEquals(PageQuery.DEFAULT_PAGE_NUM, page.getCurrent());
|
||||
assertEquals(PageQuery.DEFAULT_PAGE_SIZE, page.getSize());
|
||||
assertTrue(page.orders().isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法页码回退到第一页,并正确计算起始行。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("页码小于等于零时回退到第一页")
|
||||
void shouldNormalizeInvalidPageNumber() {
|
||||
PageQuery query = new PageQuery(20, 0);
|
||||
|
||||
assertEquals(1L, query.build().getCurrent());
|
||||
assertEquals(0, query.getFirstNum());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证多字段排序支持驼峰转换及每列独立排序方向。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("将驼峰排序字段转换为下划线并支持独立方向")
|
||||
void shouldBuildMultipleOrderItems() {
|
||||
PageQuery query = new PageQuery(10, 2);
|
||||
query.setOrderByColumn("userName,createTime");
|
||||
query.setIsAsc("ascending,descending");
|
||||
|
||||
List<OrderItem> orders = query.build().orders();
|
||||
|
||||
assertEquals(2, orders.size());
|
||||
assertEquals("user_name", orders.get(0).getColumn());
|
||||
assertTrue(orders.get(0).isAsc());
|
||||
assertEquals("create_time", orders.get(1).getColumn());
|
||||
assertFalse(orders.get(1).isAsc());
|
||||
assertEquals(10, query.getFirstNum());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证排序字段数与方向数不匹配时抛出业务异常。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝排序字段和方向数量不一致")
|
||||
void shouldRejectMismatchedDirections() {
|
||||
PageQuery query = new PageQuery();
|
||||
query.setOrderByColumn("id,createTime");
|
||||
query.setIsAsc("asc,desc,asc");
|
||||
|
||||
assertThrows(ServiceException.class, query::build);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法排序字段和未知排序方向都会被拒绝。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝非法排序字段和排序方向")
|
||||
void shouldRejectInvalidOrderInput() {
|
||||
PageQuery unsafeColumn = new PageQuery();
|
||||
unsafeColumn.setOrderByColumn("id;drop table sys_user");
|
||||
unsafeColumn.setIsAsc("asc");
|
||||
assertThrows(IllegalArgumentException.class, unsafeColumn::build);
|
||||
|
||||
PageQuery invalidDirection = new PageQuery();
|
||||
invalidDirection.setOrderByColumn("id");
|
||||
invalidDirection.setIsAsc("random");
|
||||
assertThrows(ServiceException.class, invalidDirection::build);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
package org.dromara.common.mybatis.core.query;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@DisplayName("AggregateSelectUtils 单元测试")
|
||||
class AggregateSelectUtilsTest {
|
||||
|
||||
/**
|
||||
* 验证聚合字段、子查询字段和已有查询字段的 SQL 拼接格式。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拼接聚合字段和子查询字段")
|
||||
void shouldBuildSelectFragments() {
|
||||
assertEquals("COUNT(user_id) AS userCount",
|
||||
AggregateSelectUtils.aggregateSelect(SqlAggregateFunction.COUNT, "user_id", "userCount"));
|
||||
assertEquals("(SELECT MAX(id) FROM sys_user) AS maxId",
|
||||
AggregateSelectUtils.subquerySelect("SELECT MAX(id) FROM sys_user", "maxId"));
|
||||
assertEquals("id,COUNT(*) AS total",
|
||||
AggregateSelectUtils.appendSelect("id", "COUNT(*) AS total"));
|
||||
assertEquals("id", AggregateSelectUtils.appendSelect(null, "id"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Lambda getter 可以解析为对应的 Java 属性名。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("从 Lambda getter 提取字段别名")
|
||||
void shouldResolveAliasFromGetter() {
|
||||
SFunction<TestEntity, Long> getter = TestEntity::getTotalValue;
|
||||
|
||||
assertEquals("totalValue", AggregateSelectUtils.aliasName(getter));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法 SQL 标识符不能作为查询别名。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝非法 SQL 别名")
|
||||
void shouldRejectInvalidAlias() {
|
||||
assertThrows(RuntimeException.class, () -> AggregateSelectUtils.checkAlias("1total"));
|
||||
assertThrows(RuntimeException.class, () -> AggregateSelectUtils.checkAlias("total-value"));
|
||||
assertThrows(RuntimeException.class, () -> AggregateSelectUtils.checkAlias("total value"));
|
||||
}
|
||||
|
||||
private static class TestEntity {
|
||||
|
||||
private Long totalValue;
|
||||
|
||||
/**
|
||||
* 提供 Lambda 属性解析使用的测试 getter。
|
||||
*
|
||||
* @return 测试聚合值
|
||||
*/
|
||||
public Long getTotalValue() {
|
||||
return totalValue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,312 +0,0 @@
|
||||
package org.dromara.common.mybatis.core.query;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.github.yulichang.wrapper.MPJLambdaWrapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("LambdaJoinQueryBuilder 单元测试")
|
||||
class LambdaJoinQueryBuilderTest {
|
||||
|
||||
/**
|
||||
* 初始化联表测试实体的 MyBatis-Plus 元数据,供 MPJ 字段、表名和逻辑删除解析使用。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeTableMetadata() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "joinUser"), JoinUser.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "joinDept"), JoinDept.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, "joinOrder"), JoinOrder.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证实际列表查询常用的主表别名、多次左联、字段映射、聚合、筛选和排序可以组成完整 SQL。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构造多表列表查询")
|
||||
void shouldBuildAliasedMultiTableQuery() {
|
||||
MPJLambdaWrapper<JoinUser> wrapper = QueryBuilder.lambdaJoin("u", JoinUser.class)
|
||||
.distinct()
|
||||
.select("u", JoinUser::getId, JoinUser::getName)
|
||||
.selectAs("d", JoinDept::getName, JoinResult::getDeptName)
|
||||
.selectSum("o", JoinOrder::getAmount, "totalAmount")
|
||||
.selectCount("o", JoinOrder::getId, "orderCount")
|
||||
.leftJoin(JoinDept.class, "d", JoinDept::getId, JoinUser::getDeptId)
|
||||
.leftJoin(JoinOrder.class, "o", JoinOrder::getUserId, JoinUser::getId)
|
||||
.eqIfPresent("u", JoinUser::getName, null)
|
||||
.eqIfText("u", JoinUser::getName, "alice")
|
||||
.likeIfText("d", JoinDept::getName, "tech")
|
||||
.betweenParams("o", JoinOrder::getCreatedAt, null, "begin", "end")
|
||||
.betweenParams("o", JoinOrder::getCreatedAt, Map.of("begin", "2026-01-01"), "begin", "end")
|
||||
.betweenParams("o", JoinOrder::getCreatedAt,
|
||||
Map.of("begin", "2026-01-01", "end", "2026-01-31"), "begin", "end")
|
||||
.inIfNotEmpty("u", JoinUser::getId, List.of())
|
||||
.inIfNotEmpty("u", JoinUser::getId, List.of(1L, 2L))
|
||||
.notInIfNotEmpty("u", JoinUser::getId, List.of())
|
||||
.isNotNull("d", JoinDept::getId)
|
||||
.groupBy("u", JoinUser::getId, JoinUser::getName)
|
||||
.orderByAsc("u", JoinUser::getName)
|
||||
.orderByDesc("o", JoinOrder::getCreatedAt)
|
||||
.build();
|
||||
|
||||
String select = wrapper.getSqlSelect();
|
||||
String from = wrapper.getFrom();
|
||||
String sql = wrapper.getSqlSegment();
|
||||
|
||||
assertEquals("u", wrapper.getAlias());
|
||||
assertTrue(wrapper.getSelectDistinct());
|
||||
assertTrue(select.contains("u.id"));
|
||||
assertTrue(select.contains("u.name"));
|
||||
assertTrue(select.contains("d.name AS deptName"));
|
||||
assertTrue(select.contains("SUM(o.amount) AS totalAmount"));
|
||||
assertTrue(select.contains("COUNT(o.id) AS orderCount"));
|
||||
assertTrue(from.contains("LEFT JOIN test_dept d ON"));
|
||||
assertTrue(from.contains("d.id = u.dept_id"));
|
||||
assertTrue(from.contains("LEFT JOIN test_order o ON"));
|
||||
assertTrue(from.contains("o.user_id = u.id"));
|
||||
assertTrue(sql.contains("u.name ="));
|
||||
assertTrue(sql.contains("d.name LIKE"));
|
||||
assertEquals(sql.indexOf("o.created_at BETWEEN"), sql.lastIndexOf("o.created_at BETWEEN"));
|
||||
assertTrue(sql.contains("u.id IN"));
|
||||
assertTrue(sql.contains("d.id IS NOT NULL"));
|
||||
assertTrue(sql.contains("GROUP BY u.id,u.name"));
|
||||
assertTrue(sql.contains("ORDER BY u.name ASC,o.created_at DESC"));
|
||||
assertEquals(new HashSet<>(List.of("alice", "%tech%", "2026-01-01", "2026-01-31", 1L, 2L)),
|
||||
new HashSet<>(wrapper.getParamNameValuePairs().values()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证联表构造器的各类关联子查询使用主表别名、追加逻辑删除条件并完整绑定参数。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构造联表关联子查询")
|
||||
void shouldBuildCorrelatedSubQueriesForJoinQuery() {
|
||||
MPJLambdaWrapper<JoinUser> wrapper = QueryBuilder.lambdaJoin("u", JoinUser.class)
|
||||
.select("u", JoinUser::getId)
|
||||
.selectSub(JoinOrder.class, sub -> sub
|
||||
.selectCountAll()
|
||||
.eqColumn(JoinOrder::getUserId, "u", JoinUser::getId)
|
||||
.eq(JoinOrder::getState, "selected"), "paidCount")
|
||||
.eqSub("u", JoinUser::getScore, JoinOrder.class, sub -> sub
|
||||
.selectMax(JoinOrder::getAmount)
|
||||
.eqColumn(JoinOrder::getUserId, "u", JoinUser::getId)
|
||||
.eq(JoinOrder::getState, "scored"))
|
||||
.inSub("u", JoinUser::getId, JoinOrder.class, sub -> sub
|
||||
.select(JoinOrder::getUserId)
|
||||
.eq(JoinOrder::getState, "included"))
|
||||
.notInSub("u", JoinUser::getId, JoinOrder.class, sub -> sub
|
||||
.select(JoinOrder::getUserId)
|
||||
.eq(JoinOrder::getState, "excluded"))
|
||||
.existsSub(JoinOrder.class, sub -> sub
|
||||
.selectCountAll()
|
||||
.eqColumn(JoinOrder::getUserId, "u", JoinUser::getId)
|
||||
.eq(JoinOrder::getState, "existing"))
|
||||
.notExistsSub(JoinOrder.class, sub -> sub
|
||||
.selectCountAll()
|
||||
.eqColumn(JoinOrder::getUserId, "u", JoinUser::getId)
|
||||
.eq(JoinOrder::getState, "missing"))
|
||||
.build();
|
||||
|
||||
String select = wrapper.getSqlSelect();
|
||||
String sql = wrapper.getSqlSegment();
|
||||
|
||||
assertTrue(select.contains("(SELECT COUNT(*) FROM test_order"));
|
||||
assertTrue(select.contains("deleted=0"));
|
||||
assertTrue(select.contains("user_id=u.id"));
|
||||
assertTrue(select.contains("AS paidCount"));
|
||||
assertTrue(sql.contains("u.score = (SELECT MAX(amount) FROM test_order"));
|
||||
assertTrue(sql.contains("u.id IN (SELECT user_id FROM test_order"));
|
||||
assertTrue(sql.contains("u.id NOT IN (SELECT user_id FROM test_order"));
|
||||
assertTrue(sql.contains("EXISTS (SELECT COUNT(*) FROM test_order"));
|
||||
assertTrue(sql.contains("NOT EXISTS (SELECT COUNT(*) FROM test_order"));
|
||||
assertTrue(wrapper.getParamNameValuePairs().values().containsAll(
|
||||
List.of("selected", "scored", "included", "excluded", "existing", "missing")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MPJ 默认别名、无显式别名联表、全字段选择和底层 Wrapper 扩展入口保持兼容。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("兼容 MPJ 默认联表行为")
|
||||
void shouldPreserveNativeMpjDefaultsAndEscapeHatch() {
|
||||
MPJLambdaWrapper<JoinUser> wrapper = QueryBuilder.lambdaJoin(JoinUser.class)
|
||||
.selectAll()
|
||||
.selectAll(JoinDept.class)
|
||||
.leftJoin(JoinDept.class, JoinDept::getId, JoinUser::getDeptId)
|
||||
.neIfText("t", JoinUser::getName, "alice")
|
||||
.betweenIfPresent("t", JoinUser::getScore, 60, 100)
|
||||
.notInIfNotEmpty("t", JoinUser::getId, List.of(3L, 4L))
|
||||
.apply(nativeWrapper -> nativeWrapper.likeRight(JoinDept::getName, "tech"))
|
||||
.build();
|
||||
|
||||
String select = wrapper.getSqlSelect();
|
||||
String from = wrapper.getFrom();
|
||||
String sql = wrapper.getSqlSegment();
|
||||
|
||||
assertEquals("t", wrapper.getAlias());
|
||||
assertTrue(select.contains("t.id"));
|
||||
assertTrue(select.contains("t.name"));
|
||||
assertTrue(select.contains("t1.id"));
|
||||
assertTrue(select.contains("t1.name"));
|
||||
assertTrue(from.contains("LEFT JOIN test_dept t1 ON"));
|
||||
assertTrue(from.contains("t1.id = t.dept_id"));
|
||||
assertTrue(sql.contains("t.name <>"));
|
||||
assertTrue(sql.contains("t.score BETWEEN"));
|
||||
assertTrue(sql.contains("t.id NOT IN"));
|
||||
assertTrue(sql.contains("t1.name LIKE"));
|
||||
assertEquals(new HashSet<>(List.of("alice", 60, 100, 3L, 4L, "tech%")),
|
||||
new HashSet<>(wrapper.getParamNameValuePairs().values()));
|
||||
}
|
||||
|
||||
@TableName("test_user")
|
||||
private static class JoinUser {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long deptId;
|
||||
private String name;
|
||||
private Integer score;
|
||||
|
||||
/**
|
||||
* 返回用户主键,供主表与订单表关联。
|
||||
*
|
||||
* @return 用户主键
|
||||
*/
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回部门主键,供用户与部门表关联。
|
||||
*
|
||||
* @return 部门主键
|
||||
*/
|
||||
public Long getDeptId() {
|
||||
return deptId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回用户名称,供字段选择、筛选、分组和排序解析。
|
||||
*
|
||||
* @return 用户名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回用户分值,供等值子查询条件解析。
|
||||
*
|
||||
* @return 用户分值
|
||||
*/
|
||||
public Integer getScore() {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
|
||||
@TableName("test_dept")
|
||||
private static class JoinDept {
|
||||
@TableId
|
||||
private Long id;
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 返回部门主键,供联表条件和非空筛选解析。
|
||||
*
|
||||
* @return 部门主键
|
||||
*/
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回部门名称,供结果字段映射和模糊筛选解析。
|
||||
*
|
||||
* @return 部门名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
@TableName("test_order")
|
||||
private static class JoinOrder {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Integer amount;
|
||||
private String state;
|
||||
private String createdAt;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
/**
|
||||
* 返回订单主键,供聚合统计字段解析。
|
||||
*
|
||||
* @return 订单主键
|
||||
*/
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回订单所属用户,供联表和关联子查询解析。
|
||||
*
|
||||
* @return 用户主键
|
||||
*/
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回订单金额,供聚合字段和等值子查询解析。
|
||||
*
|
||||
* @return 订单金额
|
||||
*/
|
||||
public Integer getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回订单状态,供子查询参数条件解析。
|
||||
*
|
||||
* @return 订单状态
|
||||
*/
|
||||
public String getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回订单创建时间,供区间筛选和排序解析。
|
||||
*
|
||||
* @return 创建时间
|
||||
*/
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
private static class JoinResult {
|
||||
private String deptName;
|
||||
|
||||
/**
|
||||
* 返回结果部门名称,供 selectAs 推导字段别名。
|
||||
*
|
||||
* @return 部门名称
|
||||
*/
|
||||
public String getDeptName() {
|
||||
return deptName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,418 +0,0 @@
|
||||
package org.dromara.common.mybatis.core.query;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("LambdaQueryBuilder 单元测试")
|
||||
class LambdaQueryBuilderTest {
|
||||
|
||||
/**
|
||||
* 初始化测试实体的 MyBatis-Plus 表元数据,供 Lambda 字段解析和逻辑删除 SQL 使用。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeTableMetadata() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), TestEntity.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "testRelation"), TestRelation.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证常用比较、集合、空值、分组和排序条件能够通过查询 DSL 生成 SQL 片段。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构造常用 Lambda 查询条件")
|
||||
void shouldBuildCommonLambdaQueryConditions() {
|
||||
LambdaQueryWrapper<TestEntity> wrapper = QueryBuilder.lambda(TestEntity.class)
|
||||
.eq(TestEntity::getName, "alice")
|
||||
.ne(false, TestEntity::getName, "ignored")
|
||||
.gt(TestEntity::getScore, 60)
|
||||
.ge(TestEntity::getScore, 61)
|
||||
.lt(TestEntity::getScore, 100)
|
||||
.le(TestEntity::getScore, 99)
|
||||
.like(TestEntity::getName, "ali")
|
||||
.notLike(TestEntity::getName, "bob")
|
||||
.between(TestEntity::getScore, 60, 100)
|
||||
.notBetween(false, TestEntity::getScore, 0, 10)
|
||||
.isNull(TestEntity::getRemark)
|
||||
.isNotNull(false, TestEntity::getRemark)
|
||||
.in(TestEntity::getId, List.of(1L, 2L))
|
||||
.notIn(TestEntity::getId, 3L, 4L)
|
||||
.groupBy(TestEntity::getName)
|
||||
.having("COUNT(*) > {0}", 1)
|
||||
.orderByDesc(TestEntity::getScore)
|
||||
.last("LIMIT 10")
|
||||
.build();
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
|
||||
assertTrue(sql.contains("name"));
|
||||
assertTrue(sql.contains("score"));
|
||||
assertTrue(sql.contains("remark IS NULL"));
|
||||
assertTrue(sql.contains("GROUP BY name"));
|
||||
assertTrue(sql.contains("ORDER BY score DESC"));
|
||||
assertTrue(wrapper.getCustomSqlSegment().contains("LIMIT 10"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通字段、聚合字段和 COUNT(*) 可以组合为稳定的 SELECT 列表。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构造聚合查询字段")
|
||||
void shouldBuildAggregateSelectColumns() {
|
||||
LambdaQueryWrapper<TestEntity> wrapper = QueryBuilder.lambda(TestEntity.class)
|
||||
.select(TestEntity::getName)
|
||||
.selectSum(TestEntity::getScore, "totalScore")
|
||||
.selectMax(TestEntity::getScore, "maxScore")
|
||||
.selectMin(TestEntity::getScore, "minScore")
|
||||
.selectAvg(TestEntity::getScore, "avgScore")
|
||||
.selectCount(TestEntity::getId, "idCount")
|
||||
.selectCountDistinct(TestEntity::getName, "nameCount")
|
||||
.selectCountAll("total")
|
||||
.build();
|
||||
|
||||
String select = wrapper.getSqlSelect();
|
||||
|
||||
assertTrue(select.contains("name"));
|
||||
assertTrue(select.contains("SUM(score) AS totalScore"));
|
||||
assertTrue(select.contains("MAX(score) AS maxScore"));
|
||||
assertTrue(select.contains("MIN(score) AS minScore"));
|
||||
assertTrue(select.contains("AVG(score) AS avgScore"));
|
||||
assertTrue(select.contains("COUNT(id) AS idCount"));
|
||||
assertTrue(select.contains("COUNT(DISTINCT name) AS nameCount"));
|
||||
assertTrue(select.contains("COUNT(*) AS total"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证子查询自动加入逻辑删除条件、收集占位参数,并支持显式关闭逻辑删除。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("构造带参数的子查询")
|
||||
void shouldBuildSubQueryWithLogicDeleteAndParameters() {
|
||||
SubQuery<TestEntity> subQuery = SubQuery.ofPlaceholders(TestEntity.class)
|
||||
.select(TestEntity::getId)
|
||||
.eq(TestEntity::getName, "alice")
|
||||
.gt(TestEntity::getScore, 60)
|
||||
.in(TestEntity::getId, 1L, 2L)
|
||||
.between(TestEntity::getScore, 60, 100)
|
||||
.when(false, query -> query.eq(TestEntity::getName, "ignored"));
|
||||
|
||||
String sql = subQuery.build();
|
||||
|
||||
assertTrue(sql.startsWith("SELECT id FROM test_entity WHERE"));
|
||||
assertTrue(sql.contains("deleted=0"));
|
||||
assertTrue(sql.contains("name = {0}"));
|
||||
assertArrayEquals(new Object[]{"alice", 60, 1L, 2L, 60, 100}, subQuery.params());
|
||||
|
||||
String withoutLogicDelete = SubQuery.ofPlaceholders(TestEntity.class)
|
||||
.selectCountAll()
|
||||
.disableLogicDelete()
|
||||
.build();
|
||||
assertFalse(withoutLogicDelete.contains("deleted"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证项目查询辅助方法只为有效输入生成条件,避免空筛选值污染业务 SQL。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("按输入有效性追加查询条件")
|
||||
void shouldAddOnlyMeaningfulOptionalConditions() {
|
||||
LambdaQueryWrapper<TestEntity> wrapper = QueryBuilder.lambda(TestEntity.class)
|
||||
.eqIfPresent(TestEntity::getName, null)
|
||||
.eqIfText(TestEntity::getName, " ")
|
||||
.eqIfText(TestEntity::getName, "alice")
|
||||
.neIfText(TestEntity::getRemark, "")
|
||||
.gtIfPresent(TestEntity::getScore, null)
|
||||
.geIfPresent(TestEntity::getScore, 60)
|
||||
.likeIfText(TestEntity::getRemark, "memo")
|
||||
.betweenParams(TestEntity::getScore, null, "begin", "end")
|
||||
.betweenParams(TestEntity::getScore, Map.of("begin", 10), "begin", "end")
|
||||
.betweenParams(TestEntity::getScore, Map.of("begin", 10, "end", 20), "begin", "end")
|
||||
.inIfNotEmpty(TestEntity::getId, List.of())
|
||||
.inIfNotEmpty(TestEntity::getId, List.of(1L, 2L))
|
||||
.notInIfNotEmpty(TestEntity::getId, new Object[0])
|
||||
.notInIfNotEmpty(TestEntity::getId, 3L, 4L)
|
||||
.build();
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
|
||||
assertTrue(sql.contains("name ="));
|
||||
assertTrue(sql.contains("score >="));
|
||||
assertTrue(sql.contains("remark LIKE"));
|
||||
assertEquals(sql.indexOf("score BETWEEN"), sql.lastIndexOf("score BETWEEN"));
|
||||
assertTrue(sql.contains("id IN"));
|
||||
assertTrue(sql.contains("id NOT IN"));
|
||||
assertEquals(new HashSet<>(List.of("alice", 60, "%memo%", 10, 20, 1L, 2L, 3L, 4L)),
|
||||
new HashSet<>(wrapper.getParamNameValuePairs().values()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证查询字段和各类子查询条件能关联外层字段、保留逻辑删除并绑定独立参数。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("组合关联子查询")
|
||||
void shouldComposeCorrelatedSubQueries() {
|
||||
LambdaQueryWrapper<TestEntity> wrapper = QueryBuilder.lambda(TestEntity.class)
|
||||
.select(TestEntity::getId, TestEntity::getName)
|
||||
.selectSub(TestRelation.class, sub -> sub
|
||||
.selectCountAll()
|
||||
.eqColumn(TestRelation::getOwnerId, TestEntity::getId)
|
||||
.eq(TestRelation::getState, "selected"), "relationCount")
|
||||
.eqSub(TestEntity::getScore, TestRelation.class, sub -> sub
|
||||
.selectMax(TestRelation::getPoints)
|
||||
.eqColumn(TestRelation::getOwnerId, TestEntity::getId)
|
||||
.eq(TestRelation::getState, "scored"))
|
||||
.inSub(TestEntity::getId, TestRelation.class, sub -> sub
|
||||
.select(TestRelation::getOwnerId)
|
||||
.eq(TestRelation::getState, "included"))
|
||||
.notInSub(TestEntity::getId, TestRelation.class, sub -> sub
|
||||
.select(TestRelation::getOwnerId)
|
||||
.eq(TestRelation::getState, "excluded"))
|
||||
.existsSub(TestRelation.class, sub -> sub
|
||||
.selectCountAll()
|
||||
.eqColumn(TestRelation::getOwnerId, TestEntity::getId)
|
||||
.eq(TestRelation::getState, "existing"))
|
||||
.notExistsSub(TestRelation.class, sub -> sub
|
||||
.selectCountAll()
|
||||
.eqColumn(TestRelation::getOwnerId, TestEntity::getId)
|
||||
.eq(TestRelation::getState, "missing"))
|
||||
.build();
|
||||
|
||||
String select = wrapper.getSqlSelect();
|
||||
String sql = wrapper.getSqlSegment();
|
||||
|
||||
assertTrue(select.contains("(SELECT COUNT(*) FROM test_relation"));
|
||||
assertTrue(select.contains("deleted=0"));
|
||||
assertTrue(select.contains("owner_id=test_entity.id"));
|
||||
assertTrue(select.contains("AS relationCount"));
|
||||
assertTrue(sql.contains("score = (SELECT MAX(points) FROM test_relation"));
|
||||
assertTrue(sql.contains("id IN (SELECT owner_id FROM test_relation"));
|
||||
assertTrue(sql.contains("id NOT IN (SELECT owner_id FROM test_relation"));
|
||||
assertTrue(sql.contains("EXISTS (SELECT COUNT(*) FROM test_relation"));
|
||||
assertTrue(sql.contains("NOT EXISTS (SELECT COUNT(*) FROM test_relation"));
|
||||
assertTrue(wrapper.getParamNameValuePairs().values().containsAll(
|
||||
List.of("selected", "scored", "included", "excluded", "existing", "missing")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空集合不会产生非法 IN 子句,显式外层别名仍能正确生成关联条件。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("跳过子查询空集合条件")
|
||||
void shouldSkipEmptySubQueryCollections() {
|
||||
SubQuery<TestRelation> subQuery = SubQuery.ofPlaceholders(TestRelation.class)
|
||||
.select(TestRelation::getOwnerId)
|
||||
.in(TestRelation::getId, List.of())
|
||||
.in(TestRelation::getId, (Object[]) null)
|
||||
.eqColumn(TestRelation::getOwnerId, "u", TestEntity::getId)
|
||||
.when(true, query -> query.eq(TestRelation::getState, "enabled"));
|
||||
|
||||
String sql = subQuery.build();
|
||||
|
||||
assertFalse(sql.contains(" IN ("));
|
||||
assertTrue(sql.contains("owner_id=u.id"));
|
||||
assertArrayEquals(new Object[]{"enabled"}, subQuery.params());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺少查询字段的子查询会在构造阶段失败,防止生成无法执行的 SQL。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("拒绝没有查询字段的子查询")
|
||||
void shouldRejectSubQueryWithoutSelectColumn() {
|
||||
assertThrows(MybatisPlusException.class,
|
||||
() -> SubQuery.ofPlaceholders(TestRelation.class).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证项目构造器透传的 MyBatis-Plus 嵌套逻辑、批量等值和函数式扩展保持原生语义。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("兼容 MyBatis-Plus 组合条件")
|
||||
void shouldPreserveNativeMybatisPlusConditionSemantics() {
|
||||
Map<SFunction<TestEntity, ?>, Object> values = new LinkedHashMap<>();
|
||||
values.put(TestEntity::getName, "alice");
|
||||
values.put(TestEntity::getRemark, null);
|
||||
values.put(TestEntity::getScore, 80);
|
||||
|
||||
LambdaQueryWrapper<TestEntity> wrapper = QueryBuilder.lambda(TestEntity.class)
|
||||
.allEq((column, value) -> !Integer.valueOf(80).equals(value), values, true)
|
||||
.and(nested -> nested.gt(TestEntity::getScore, 60).lt(TestEntity::getScore, 100))
|
||||
.or(nested -> nested.eq(TestEntity::getName, "backup").isNull(TestEntity::getRemark))
|
||||
.nested(nested -> nested.likeLeft(TestEntity::getName, "ice")
|
||||
.or().likeRight(TestEntity::getName, "ali"))
|
||||
.not(nested -> nested.eq(TestEntity::getScore, 0))
|
||||
.func(nested -> nested.ge(TestEntity::getScore, 70))
|
||||
.apply(nested -> nested.le(TestEntity::getScore, 90))
|
||||
.build();
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
|
||||
assertTrue(sql.contains("name ="));
|
||||
assertTrue(sql.contains("remark IS NULL"));
|
||||
assertFalse(wrapper.getParamNameValuePairs().values().contains(80));
|
||||
assertTrue(sql.contains("AND (score >"));
|
||||
assertTrue(sql.contains("OR (name ="));
|
||||
assertTrue(sql.contains("NOT (score ="));
|
||||
assertTrue(wrapper.getParamNameValuePairs().values().containsAll(
|
||||
List.of("alice", 60, 100, "backup", "%ice", "ali%", 0, 70, 90)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证项目暴露的原生 SQL 入口仍由 MyBatis-Plus 完成占位参数绑定和 SQL 片段拼装。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("兼容 MyBatis-Plus 原生 SQL 条件")
|
||||
void shouldPreserveNativeSqlFragmentsAndParameterBinding() {
|
||||
LambdaQueryWrapper<TestEntity> wrapper = QueryBuilder.lambda(TestEntity.class)
|
||||
.eqSql(TestEntity::getScore, "SELECT MAX(points) FROM test_relation")
|
||||
.inSql(TestEntity::getId, "SELECT owner_id FROM test_relation WHERE state = 'enabled'")
|
||||
.notInSql(TestEntity::getId, "SELECT owner_id FROM test_relation WHERE state = 'disabled'")
|
||||
.exists("SELECT 1 FROM test_relation r WHERE r.owner_id = test_entity.id AND r.state = {0}", "active")
|
||||
.notExists("SELECT 1 FROM test_relation r WHERE r.owner_id = test_entity.id AND r.state = {0}", "removed")
|
||||
.apply("FIND_IN_SET({0}, name)", "alice")
|
||||
.build();
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
|
||||
assertTrue(sql.contains("score = (SELECT MAX(points) FROM test_relation)"));
|
||||
assertTrue(sql.contains("id IN (SELECT owner_id FROM test_relation WHERE state = 'enabled')"));
|
||||
assertTrue(sql.contains("id NOT IN (SELECT owner_id FROM test_relation WHERE state = 'disabled')"));
|
||||
assertTrue(sql.contains("EXISTS (SELECT 1 FROM test_relation"));
|
||||
assertTrue(sql.contains("NOT EXISTS (SELECT 1 FROM test_relation"));
|
||||
assertTrue(sql.contains("FIND_IN_SET("));
|
||||
assertEquals(new HashSet<>(List.of("active", "removed", "alice")),
|
||||
new HashSet<>(wrapper.getParamNameValuePairs().values()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证清空底层 Wrapper 后查询条件、参数、SELECT、注释和尾部 SQL 都不会泄漏到后续查询。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("清空完整查询状态")
|
||||
void shouldClearAllQueryState() {
|
||||
LambdaQueryWrapper<TestEntity> wrapper = QueryBuilder.lambda(TestEntity.class)
|
||||
.select(TestEntity::getName)
|
||||
.selectCountAll("total")
|
||||
.eq(TestEntity::getName, "alice")
|
||||
.comment("query-comment")
|
||||
.last("LIMIT 1")
|
||||
.build();
|
||||
|
||||
wrapper.clear();
|
||||
|
||||
assertNull(wrapper.getSqlSelect());
|
||||
assertTrue(wrapper.getSqlSegment().isEmpty());
|
||||
assertTrue(wrapper.getCustomSqlSegment().isEmpty());
|
||||
assertTrue(wrapper.getParamNameValuePairs().isEmpty());
|
||||
}
|
||||
|
||||
@TableName("test_entity")
|
||||
private static class TestEntity {
|
||||
@TableId
|
||||
private Long id;
|
||||
private String name;
|
||||
private Integer score;
|
||||
private String remark;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
/**
|
||||
* 返回测试实体主键,供 Lambda 字段解析。
|
||||
*
|
||||
* @return 主键
|
||||
*/
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回测试实体名称,供 Lambda 字段解析。
|
||||
*
|
||||
* @return 名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回测试实体分数,供 Lambda 字段解析。
|
||||
*
|
||||
* @return 分数
|
||||
*/
|
||||
public Integer getScore() {
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回测试实体备注,供 Lambda 字段解析。
|
||||
*
|
||||
* @return 备注
|
||||
*/
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
}
|
||||
|
||||
@TableName("test_relation")
|
||||
private static class TestRelation {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long ownerId;
|
||||
private String state;
|
||||
private Integer points;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
/**
|
||||
* 返回关联记录主键,供 Lambda 字段解析。
|
||||
*
|
||||
* @return 主键
|
||||
*/
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回关联记录所属主键,供关联子查询字段解析。
|
||||
*
|
||||
* @return 所属主键
|
||||
*/
|
||||
public Long getOwnerId() {
|
||||
return ownerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回关联记录状态,供子查询参数条件解析。
|
||||
*
|
||||
* @return 状态
|
||||
*/
|
||||
public String getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回关联记录分值,供聚合子查询字段解析。
|
||||
*
|
||||
* @return 分值
|
||||
*/
|
||||
public Integer getPoints() {
|
||||
return points;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
package org.dromara.common.mybatis.helper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
|
||||
import org.dromara.common.mybatis.annotation.DataPermission;
|
||||
import org.dromara.common.mybatis.core.domain.DataPermissionAccess;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
@DisplayName("DataPermissionHelper 单元测试")
|
||||
class DataPermissionHelperTest {
|
||||
|
||||
/**
|
||||
* 清理数据权限测试使用的线程变量和 MyBatis-Plus 忽略策略。
|
||||
*/
|
||||
@AfterEach
|
||||
void clearThreadContext() {
|
||||
DataPermissionHelper.removePermission();
|
||||
InterceptorIgnoreHelper.clearIgnoreStrategy();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Mapper 数据权限注解可以在线程内设置、读取并清理。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("管理数据权限注解线程变量")
|
||||
void shouldManagePermissionThreadLocal() {
|
||||
DataPermission permission = mock(DataPermission.class);
|
||||
|
||||
DataPermissionHelper.setPermission(permission);
|
||||
assertSame(permission, DataPermissionHelper.getPermission());
|
||||
|
||||
DataPermissionHelper.removePermission();
|
||||
assertNull(DataPermissionHelper.getPermission());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据权限访问上下文根据接口权限或角色集合判断是否存在约束。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("判断数据权限访问约束")
|
||||
void shouldDetectDataPermissionConstraints() {
|
||||
assertFalse(DataPermissionAccess.EMPTY.constrained());
|
||||
assertTrue(new DataPermissionAccess(Set.of("system:user:list"), Set.of()).constrained());
|
||||
assertTrue(new DataPermissionAccess(Set.of(), Set.of("admin")).constrained());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证嵌套忽略数据权限会在内部保持忽略状态,并在退出后恢复原始线程状态。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("嵌套忽略并恢复数据权限")
|
||||
void shouldNestAndRestoreDataPermissionIgnoreState() {
|
||||
assertFalse(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select"));
|
||||
|
||||
String result = DataPermissionHelper.ignore(() -> {
|
||||
assertTrue(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select"));
|
||||
DataPermissionHelper.ignore(() ->
|
||||
assertTrue(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select")));
|
||||
assertTrue(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select"));
|
||||
return "done";
|
||||
});
|
||||
|
||||
assertEquals("done", result);
|
||||
assertFalse(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证忽略数据权限的业务代码抛出异常时仍会在 finally 中恢复线程状态。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("异常后恢复数据权限忽略状态")
|
||||
void shouldRestoreIgnoreStateAfterException() {
|
||||
assertThrows(IllegalStateException.class, () -> DataPermissionHelper.ignore(() -> {
|
||||
throw new IllegalStateException("failed");
|
||||
}));
|
||||
|
||||
assertFalse(InterceptorIgnoreHelper.willIgnoreDataPermission("test.select"));
|
||||
}
|
||||
}
|
||||
@ -1,187 +0,0 @@
|
||||
package org.dromara.common.mybatis.helper;
|
||||
|
||||
import cn.hutool.core.exceptions.UtilException;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
|
||||
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
|
||||
import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator;
|
||||
import org.dromara.common.mybatis.enums.DataBaseType;
|
||||
import org.dromara.common.mybatis.utils.IdGeneratorUtil;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.context.support.StaticApplicationContext;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("common-mybatis 工具契约单元测试")
|
||||
class MybatisUtilityContractTest {
|
||||
|
||||
private static DynamicRoutingDataSource dataSource;
|
||||
private static IdentifierGenerator identifierGenerator;
|
||||
|
||||
/**
|
||||
* 注册数据库助手和 ID 工具的最小 Spring 依赖,避免连接真实数据库或使用应用数据源。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initializeMybatisUtilities() {
|
||||
dataSource = mock(DynamicRoutingDataSource.class);
|
||||
identifierGenerator = mock(IdentifierGenerator.class);
|
||||
StaticApplicationContext context = new StaticApplicationContext();
|
||||
context.getBeanFactory().registerSingleton("dynamicRoutingDataSource", dataSource);
|
||||
context.getBeanFactory().registerSingleton("identifierGenerator", identifierGenerator);
|
||||
context.refresh();
|
||||
new SpringUtil().setApplicationContext(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个用例前清理 mock 调用记录,保证数据库类型缓存断言只针对当前数据源名称。
|
||||
*/
|
||||
@BeforeEach
|
||||
void resetMybatisMocks() {
|
||||
reset(dataSource, identifierGenerator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证指定数据源从 JDBC 元数据识别四种常用数据库,并缓存同一数据源的识别结果。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("识别并缓存指定数据源类型")
|
||||
void shouldResolveAndCacheNamedDatabaseTypes() throws Exception {
|
||||
Map<String, DataSource> sources = Map.of(
|
||||
"mysql-contract", dataSource("MySQL"),
|
||||
"oracle-contract", dataSource("Oracle"),
|
||||
"postgres-contract", dataSource("PostgreSQL"),
|
||||
"sqlserver-contract", dataSource("Microsoft SQL Server"));
|
||||
when(dataSource.getDataSource(any(String.class))).thenAnswer(invocation -> sources.get(invocation.getArgument(0)));
|
||||
|
||||
assertEquals(DataBaseType.MY_SQL, DataBaseHelper.getDataBaseType("mysql-contract"));
|
||||
assertEquals(DataBaseType.ORACLE, DataBaseHelper.getDataBaseType("oracle-contract"));
|
||||
assertEquals(DataBaseType.POSTGRE_SQL, DataBaseHelper.getDataBaseType("postgres-contract"));
|
||||
assertEquals(DataBaseType.SQL_SERVER, DataBaseHelper.getDataBaseType("sqlserver-contract"));
|
||||
assertEquals(DataBaseType.MY_SQL, DataBaseHelper.getDataBaseType("mysql-contract"));
|
||||
|
||||
verify(dataSource, times(2)).getDataSource("mysql-contract");
|
||||
verify(sources.get("mysql-contract"), times(1)).getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前线程数据源识别、默认 primary 键以及异常包装行为。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("识别当前数据源并包装 JDBC 异常")
|
||||
void shouldResolveCurrentDataSourceAndWrapSqlException() throws Exception {
|
||||
DataSource current = dataSource("Oracle");
|
||||
when(dataSource.determineDataSource()).thenReturn(current);
|
||||
AtomicReference<String> currentKey = new AtomicReference<>("current-contract");
|
||||
when(dataSource.getDataSources()).thenReturn(Map.of("primary", current));
|
||||
|
||||
try (MockedStatic<DynamicDataSourceContextHolder> holder = mockStatic(DynamicDataSourceContextHolder.class)) {
|
||||
holder.when(DynamicDataSourceContextHolder::peek).thenAnswer(invocation -> currentKey.get());
|
||||
assertEquals(DataBaseType.ORACLE, DataBaseHelper.getDataBaseType());
|
||||
|
||||
currentKey.set(null);
|
||||
when(current.getConnection()).thenThrow(new SQLException("connection unavailable"));
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, DataBaseHelper::getDataBaseType);
|
||||
assertEquals("获取数据库类型失败", exception.getMessage());
|
||||
assertInstanceOf(SQLException.class, exception.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证不同数据库方言生成对应 FIND_IN_SET 片段,并拒绝 SQL 关键字和引号注入。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("生成数据库方言 FIND_IN_SET")
|
||||
void shouldBuildDialectSpecificFindInSetSql() throws Exception {
|
||||
Map<String, DataSource> sources = Map.of(
|
||||
"find-oracle", dataSource("Oracle"),
|
||||
"find-postgres", dataSource("PostgreSQL"),
|
||||
"find-sqlserver", dataSource("Microsoft SQL Server"),
|
||||
"find-mysql", dataSource("MySQL"));
|
||||
AtomicReference<String> currentKey = new AtomicReference<>();
|
||||
when(dataSource.determineDataSource()).thenAnswer(invocation -> sources.get(currentKey.get()));
|
||||
|
||||
try (MockedStatic<DynamicDataSourceContextHolder> holder = mockStatic(DynamicDataSourceContextHolder.class)) {
|
||||
holder.when(DynamicDataSourceContextHolder::peek).thenAnswer(invocation -> currentKey.get());
|
||||
currentKey.set("find-oracle");
|
||||
assertEquals("instr(','||role_ids||',' , ',100,') <> 0", DataBaseHelper.findInSet(100, "role_ids"));
|
||||
currentKey.set("find-postgres");
|
||||
assertEquals("(select strpos(','||role_ids||',' , ',100,')) <> 0", DataBaseHelper.findInSet(100, "role_ids"));
|
||||
currentKey.set("find-sqlserver");
|
||||
assertEquals("charindex(',100,' , ','+role_ids+',') <> 0", DataBaseHelper.findInSet(100, "role_ids"));
|
||||
currentKey.set("find-mysql");
|
||||
assertEquals("find_in_set('100' , role_ids) <> 0", DataBaseHelper.findInSet(100, "role_ids"));
|
||||
assertThrows(UtilException.class, () -> DataBaseHelper.findInSet("100'", "role_ids"));
|
||||
assertThrows(UtilException.class, () -> DataBaseHelper.findInSet(100, "select role_ids"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据源名称列表复制动态数据源集合,调用方修改返回列表不会污染路由器状态。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("读取数据源名称列表")
|
||||
void shouldCopyDataSourceNameList() throws Exception {
|
||||
DataSource primary = dataSource("MySQL");
|
||||
DataSource archive = dataSource("Oracle");
|
||||
when(dataSource.getDataSources()).thenReturn(Map.of("primary", primary, "archive", archive));
|
||||
|
||||
var names = DataBaseHelper.getDataSourceNameList();
|
||||
|
||||
assertEquals(2, names.size());
|
||||
assertTrue(names.containsAll(java.util.List.of("primary", "archive")));
|
||||
names.clear();
|
||||
assertEquals(2, dataSource.getDataSources().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 ID 工具覆盖生成器的 Number、Long、String、实体、UUID 和前缀 API。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("委托主键生成器和 UUID 生成")
|
||||
void shouldDelegateIdentifierGenerationApis() {
|
||||
when(identifierGenerator.nextId(any())).thenReturn(123456789L);
|
||||
when(identifierGenerator.nextUUID(any())).thenReturn("entity-uuid");
|
||||
Object entity = new Object();
|
||||
|
||||
assertEquals("123456789", IdGeneratorUtil.nextId());
|
||||
assertEquals(123456789L, IdGeneratorUtil.nextLongId());
|
||||
assertEquals(123456789L, IdGeneratorUtil.nextNumberId());
|
||||
assertEquals(123456789L, IdGeneratorUtil.nextId(entity));
|
||||
assertEquals("123456789", IdGeneratorUtil.nextStringId(entity));
|
||||
assertEquals("entity-uuid", IdGeneratorUtil.nextUUID(entity));
|
||||
assertEquals("ORD123456789", IdGeneratorUtil.nextIdWithPrefix("ORD"));
|
||||
assertTrue(IdGeneratorUtil.nextUUIDWithPrefix("ID").startsWith("ID"));
|
||||
assertEquals(34, IdGeneratorUtil.nextUUIDWithPrefix("ID").length());
|
||||
assertEquals(32, IdGeneratorUtil.nextUUID().length());
|
||||
verify(identifierGenerator, atLeastOnce()).nextId(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建返回指定数据库产品名的 JDBC mock,集中复用连接和元数据契约。
|
||||
*
|
||||
* @param productName 数据库产品名
|
||||
* @return JDBC 数据源 mock
|
||||
*/
|
||||
private static DataSource dataSource(String productName) throws SQLException {
|
||||
DataSource source = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
DatabaseMetaData metadata = mock(DatabaseMetaData.class);
|
||||
when(metadata.getDatabaseProductName()).thenReturn(productName);
|
||||
when(connection.getMetaData()).thenReturn(metadata);
|
||||
when(source.getConnection()).thenReturn(connection);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@ -15,14 +15,6 @@ import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.async.AsyncRequestBody;
|
||||
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
|
||||
import software.amazon.awssdk.core.async.ResponsePublisher;
|
||||
import software.amazon.awssdk.http.SdkHttpFullRequest;
|
||||
import software.amazon.awssdk.http.SdkHttpMethod;
|
||||
import software.amazon.awssdk.http.auth.aws.signer.AwsV4FamilyHttpSigner;
|
||||
import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner;
|
||||
import software.amazon.awssdk.http.auth.spi.signer.HttpSigner;
|
||||
import software.amazon.awssdk.http.auth.spi.signer.SignRequest;
|
||||
import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
@ -33,7 +25,6 @@ import software.amazon.awssdk.transfer.s3.S3TransferManager;
|
||||
import software.amazon.awssdk.transfer.s3.model.CompletedUpload;
|
||||
import software.amazon.awssdk.transfer.s3.model.DownloadRequest;
|
||||
import software.amazon.awssdk.transfer.s3.progress.TransferListener;
|
||||
import software.amazon.awssdk.utils.http.SdkHttpUtils;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.channels.Channels;
|
||||
@ -42,11 +33,8 @@ import java.nio.channels.SeekableByteChannel;
|
||||
import java.nio.channels.WritableByteChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
@ -728,9 +716,6 @@ public abstract class AbstractOssClientImpl implements OssClient {
|
||||
*/
|
||||
@Override
|
||||
public String bucketPresignGetUrl(String bucket, String key, Duration expiredTime) {
|
||||
if (useBucketBoundDomain(bucket)) {
|
||||
return bucketBoundDomainPresignUrl(SdkHttpMethod.GET, key, expiredTime, Collections.emptyMap());
|
||||
}
|
||||
try {
|
||||
return s3Presigner.presignGetObject(getObjectPresignRequestBuilder -> {
|
||||
getObjectPresignRequestBuilder.signatureDuration(expiredTime)
|
||||
@ -754,9 +739,6 @@ public abstract class AbstractOssClientImpl implements OssClient {
|
||||
*/
|
||||
@Override
|
||||
public String bucketPresignPutUrl(String bucket, String key, Duration expiredTime, Map<String, String> metadata) {
|
||||
if (useBucketBoundDomain(bucket)) {
|
||||
return bucketBoundDomainPresignUrl(SdkHttpMethod.PUT, key, expiredTime, metadata);
|
||||
}
|
||||
try {
|
||||
return s3Presigner.presignPutObject(putObjectPresignRequestBuilder -> {
|
||||
putObjectPresignRequestBuilder.signatureDuration(expiredTime)
|
||||
@ -769,94 +751,6 @@ public abstract class AbstractOssClientImpl implements OssClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否使用已绑定默认桶的自定义域名生成无桶名预签名 URL。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @return 是否使用自定义域名签名
|
||||
*/
|
||||
private boolean useBucketBoundDomain(String bucket) {
|
||||
return config.domain()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.isPresent() && config.bucket()
|
||||
.filter(defaultBucket -> Objects.equals(defaultBucket, bucket))
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用绑定默认桶的自定义域名生成预签名 URL,避免 S3 SDK 自动拼接桶名。
|
||||
*
|
||||
* @param method HTTP 方法
|
||||
* @param key 对象键
|
||||
* @param expiredTime 过期时间
|
||||
* @param metadata 对象元数据
|
||||
* @return 预签名 URL
|
||||
*/
|
||||
private String bucketBoundDomainPresignUrl(SdkHttpMethod method, String key, Duration expiredTime, Map<String, String> metadata) {
|
||||
try {
|
||||
URI domainUri = URI.create(config.getDomainUrl());
|
||||
SdkHttpFullRequest.Builder requestBuilder = SdkHttpFullRequest.builder()
|
||||
.method(method)
|
||||
.uri(domainUri)
|
||||
.encodedPath(bucketBoundDomainPath(domainUri, key));
|
||||
if (metadata != null) {
|
||||
metadata.forEach((metadataKey, metadataValue) -> {
|
||||
if (StringUtils.isNotBlank(metadataKey)) {
|
||||
requestBuilder.putHeader("x-amz-meta-" + metadataKey, String.valueOf(metadataValue));
|
||||
}
|
||||
});
|
||||
}
|
||||
AwsCredentialsIdentity credentials = AwsCredentialsIdentity.create(
|
||||
config.accessKey()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.orElseThrow(() -> S3StorageException.form("accessKey is not configured.")),
|
||||
config.secretKey()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.orElseThrow(() -> S3StorageException.form("secretKey is not configured."))
|
||||
);
|
||||
Clock signingClock = Clock.fixed(Instant.now(), ZoneOffset.UTC);
|
||||
return AwsV4HttpSigner.create()
|
||||
.sign(SignRequest.builder(credentials)
|
||||
.request(requestBuilder.build())
|
||||
.putProperty(AwsV4HttpSigner.REGION_NAME, config.region().orElse(Region.US_EAST_1).id())
|
||||
.putProperty(AwsV4FamilyHttpSigner.SERVICE_SIGNING_NAME, "s3")
|
||||
.putProperty(AwsV4FamilyHttpSigner.AUTH_LOCATION, AwsV4FamilyHttpSigner.AuthLocation.QUERY_STRING)
|
||||
.putProperty(AwsV4FamilyHttpSigner.PAYLOAD_SIGNING_ENABLED, false)
|
||||
.putProperty(AwsV4FamilyHttpSigner.EXPIRATION_DURATION, expiredTime)
|
||||
.putProperty(HttpSigner.SIGNING_CLOCK, signingClock)
|
||||
.putProperty(AwsV4FamilyHttpSigner.DOUBLE_URL_ENCODE, false)
|
||||
.putProperty(AwsV4FamilyHttpSigner.NORMALIZE_PATH, false)
|
||||
.build())
|
||||
.request()
|
||||
.getUri()
|
||||
.toString();
|
||||
} catch (Exception e) {
|
||||
throw toStorageException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建绑定桶域名下的对象访问路径。
|
||||
*
|
||||
* @param domainUri 自定义域名 URI
|
||||
* @param key 对象键
|
||||
* @return 编码后的访问路径
|
||||
*/
|
||||
private String bucketBoundDomainPath(URI domainUri, String key) {
|
||||
String basePath = Optional.ofNullable(domainUri.getRawPath())
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.filter(path -> !"/".equals(path))
|
||||
.orElse("");
|
||||
String objectPath = SdkHttpUtils.urlEncodeIgnoreSlashes(key);
|
||||
if (!basePath.startsWith("/")) {
|
||||
basePath = "/" + basePath;
|
||||
}
|
||||
if (!basePath.endsWith("/")) {
|
||||
basePath = basePath + "/";
|
||||
}
|
||||
return basePath + objectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传本地路径文件到默认存储桶。
|
||||
*
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package org.dromara.common.oss.config;
|
||||
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import lombok.Builder;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -195,8 +196,8 @@ public class OssClientConfig implements Config<OssClientConfig, OssClientConfig.
|
||||
public String getDomainUrl() {
|
||||
return domain()
|
||||
// 如果已经配置了自定义域名,则优先使用域名
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.map(domain -> BucketUrlUtil.rebuildUrlHeader(useHttps, domain.trim()))
|
||||
// 检查携带协议头
|
||||
.filter(OssClientConfig::hasHttpHeader)
|
||||
// 否则使用站点
|
||||
.orElseGet(this::getEndpointUrl);
|
||||
}
|
||||
@ -220,37 +221,11 @@ public class OssClientConfig implements Config<OssClientConfig, OssClientConfig.
|
||||
* @return 桶URL地址
|
||||
*/
|
||||
public String getBucketUrl(String bucket) {
|
||||
// 云厂商的自定义域名通常是桶绑定的 CDN/映射域名,域名本身已经包含桶信息,不能再拼接桶名。
|
||||
// 自定义域名仅对默认桶生效;其它桶仍使用访问站点生成标准 S3 地址。
|
||||
if (!usePathStyleAccess && isDefaultBucket(bucket) && hasCustomDomain()) {
|
||||
return getDomainUrl();
|
||||
}
|
||||
String url = (!usePathStyleAccess && hasCustomDomain() && !isDefaultBucket(bucket))
|
||||
? getEndpoint()
|
||||
: getAccessBaseUrl();
|
||||
String url = getAccessBaseUrl();
|
||||
// 根据是否使用路径风格配置项决定存储桶的URL风格
|
||||
return usePathStyleAccess ? BucketUrlUtil.getPathStyleBucketUrl(useHttps, url, bucket) : BucketUrlUtil.getSiteStyleBucketUrl(useHttps, url, bucket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否配置了自定义域名。
|
||||
*
|
||||
* @return 是否配置了非空自定义域名
|
||||
*/
|
||||
private boolean hasCustomDomain() {
|
||||
return domain().filter(StringUtils::isNotBlank).isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定桶是否为当前客户端配置的默认桶。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @return 是否为默认桶
|
||||
*/
|
||||
private boolean isDefaultBucket(String bucket) {
|
||||
return bucket().filter(defaultBucket -> defaultBucket.equals(bucket)).isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 S3 Region。
|
||||
*
|
||||
@ -299,8 +274,7 @@ public class OssClientConfig implements Config<OssClientConfig, OssClientConfig.
|
||||
*/
|
||||
private String getAccessBaseUrl() {
|
||||
return domain()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.map(String::trim)
|
||||
.filter(OssClientConfig::hasHttpHeader)
|
||||
.orElseGet(this::getEndpoint);
|
||||
}
|
||||
|
||||
@ -315,6 +289,16 @@ public class OssClientConfig implements Config<OssClientConfig, OssClientConfig.
|
||||
.orElseThrow(() -> S3StorageException.form("endpoint is not configured."));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 URL 是否包含 HTTP 协议头。
|
||||
*
|
||||
* @param url URL
|
||||
* @return 是否包含 HTTP 协议头
|
||||
*/
|
||||
private static boolean hasHttpHeader(String url) {
|
||||
return HttpUtil.isHttp(url) || HttpUtil.isHttps(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* ACL访问策略配置
|
||||
*/
|
||||
|
||||
@ -79,11 +79,9 @@ public class BucketUrlUtil {
|
||||
* @return 移除HTTP/HTTPS协议头后的地址
|
||||
*/
|
||||
public static String removeHttpProtocolHeader(String url) {
|
||||
if (StringUtils.startsWithIgnoreCase(url, HTTPS_PROTOCOL_HEADER)) {
|
||||
return url.substring(HTTPS_PROTOCOL_HEADER.length());
|
||||
}
|
||||
if (StringUtils.startsWithIgnoreCase(url, HTTP_PROTOCOL_HEADER)) {
|
||||
return url.substring(HTTP_PROTOCOL_HEADER.length());
|
||||
if (StringUtils.startsWithIgnoreCase(url, HTTP_PROTOCOL_HEADER) || StringUtils.startsWithIgnoreCase(url, HTTPS_PROTOCOL_HEADER)) {
|
||||
return url.replace(HTTP_PROTOCOL_HEADER, EMPTY_STRING)
|
||||
.replace(HTTPS_PROTOCOL_HEADER, EMPTY_STRING);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
package org.dromara.common.oss;
|
||||
|
||||
import org.dromara.common.oss.enums.AccessPolicy;
|
||||
import org.dromara.common.oss.exception.S3StorageException;
|
||||
import org.dromara.common.oss.util.BucketUrlUtil;
|
||||
import org.dromara.common.oss.config.OssClientConfig;
|
||||
import org.dromara.common.oss.properties.OssProperties;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@DisplayName("common-oss 功能单元测试")
|
||||
class OssFunctionTest {
|
||||
|
||||
/**
|
||||
* 验证桶地址生成会规范化已有协议头,并正确区分路径风格与站点风格。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("生成规范的桶访问地址")
|
||||
void shouldBuildNormalizedBucketUrls() {
|
||||
assertEquals("https://s3.example.com/images",
|
||||
BucketUrlUtil.getPathStyleBucketUrl(true, "http://s3.example.com", "images"));
|
||||
assertEquals("http://images.s3.example.com",
|
||||
BucketUrlUtil.getSiteStyleBucketUrl(false, "https://s3.example.com", "images"));
|
||||
assertEquals("https://s3.example.com", BucketUrlUtil.rebuildUrlHeader(true, "HTTP://s3.example.com"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证访问策略类型可以映射到枚举,未知类型会抛出明确的存储异常。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("解析 OSS 访问策略")
|
||||
void shouldResolveAccessPolicyOrRejectUnknownType() {
|
||||
assertEquals(AccessPolicy.PRIVATE, AccessPolicy.formType("0"));
|
||||
assertEquals(AccessPolicy.PUBLIC_READ_WRITE, AccessPolicy.formType("1"));
|
||||
assertEquals(AccessPolicy.PUBLIC_READ, AccessPolicy.formType("2"));
|
||||
assertThrows(S3StorageException.class, () -> AccessPolicy.formType("9"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证旧版 OSS 属性可转换为客户端配置,并按 endpoint 类型推断路径风格和默认 Region。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("从兼容属性构建 OSS 客户端配置")
|
||||
void shouldBuildClientConfigFromLegacyProperties() {
|
||||
OssProperties properties = properties("http://minio.example.com", null, "images");
|
||||
properties.setRegion(" ");
|
||||
properties.setIsHttps("Y");
|
||||
properties.setAccessPolicy("2");
|
||||
|
||||
OssClientConfig config = OssClientConfig.formProperties(properties);
|
||||
|
||||
assertTrue(config.useHttps());
|
||||
assertTrue(config.usePathStyleAccess());
|
||||
assertEquals(Region.US_EAST_1, config.region().orElseThrow());
|
||||
assertEquals("https://minio.example.com", config.getEndpointUrl());
|
||||
assertEquals("https://minio.example.com/images", config.getBucketUrl());
|
||||
assertTrue(config.accessControlPolicyConfig().enabled());
|
||||
assertEquals(AccessPolicy.PUBLIC_READ, config.accessControlPolicyConfig().accessPolicy());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证自定义域名只直接服务默认桶,访问其他桶时仍回退标准 S3 endpoint 地址。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("区分默认桶和其他桶的自定义域名")
|
||||
void shouldUseCustomDomainOnlyForDefaultBucket() {
|
||||
OssProperties properties = properties("https://oss-cn-hangzhou.aliyuncs.com", "https://cdn.example.com", "images");
|
||||
properties.setRegion("ap-southeast-1");
|
||||
|
||||
OssClientConfig config = OssClientConfig.formProperties(properties);
|
||||
|
||||
assertFalse(config.usePathStyleAccess());
|
||||
assertEquals("https://cdn.example.com", config.getBucketUrl());
|
||||
assertEquals("https://archive.oss-cn-hangzhou.aliyuncs.com", config.getBucketUrl("archive"));
|
||||
assertEquals(Region.AP_SOUTHEAST_1, config.region().orElseThrow());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证必要 endpoint 或 bucket 缺失时明确失败,并且复制配置会深复制嵌套配置对象。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("校验 OSS 必要配置并复制客户端配置")
|
||||
void shouldValidateRequiredConfigAndCopyNestedSettings() {
|
||||
OssClientConfig missingEndpoint = OssClientConfig.builder().bucket("images").build();
|
||||
OssClientConfig missingBucket = OssClientConfig.builder().endpoint("s3.example.com").build();
|
||||
OssClientConfig config = OssClientConfig.formProperties(properties("s3.example.com", null, "images"));
|
||||
|
||||
assertThrows(S3StorageException.class, missingEndpoint::getEndpointUrl);
|
||||
assertThrows(S3StorageException.class, missingBucket::getBucketUrl);
|
||||
|
||||
OssClientConfig copied = config.copy();
|
||||
assertNotSame(config, copied);
|
||||
assertEquals(config.getEndpointUrl(), copied.getEndpointUrl());
|
||||
assertEquals(config.getBucketUrl(), copied.getBucketUrl());
|
||||
assertEquals(config.region(), copied.region());
|
||||
assertEquals(config.prefix(), copied.prefix());
|
||||
assertEquals(config.accessControlPolicyConfig(), copied.accessControlPolicyConfig());
|
||||
assertEquals(config.asyncExecutorConfig(), copied.asyncExecutorConfig());
|
||||
assertNotSame(config.accessControlPolicyConfig(), copied.accessControlPolicyConfig());
|
||||
assertNotSame(config.asyncExecutorConfig(), copied.asyncExecutorConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建覆盖 URL 构造所需字段的 OSS 属性。
|
||||
*
|
||||
* @param endpoint endpoint 地址
|
||||
* @param domain 自定义域名
|
||||
* @param bucket 默认桶
|
||||
* @return OSS 属性
|
||||
*/
|
||||
private static OssProperties properties(String endpoint, String domain, String bucket) {
|
||||
OssProperties properties = new OssProperties();
|
||||
properties.setEndpoint(endpoint);
|
||||
properties.setDomainUrl(domain);
|
||||
properties.setBucketName(bucket);
|
||||
properties.setAccessKey("access-key");
|
||||
properties.setSecretKey("secret-key");
|
||||
properties.setPrefix("business");
|
||||
properties.setIsHttps("Y");
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
@ -31,9 +31,4 @@ public interface MessageConstants {
|
||||
* 心跳响应标识
|
||||
*/
|
||||
String PONG = "pong";
|
||||
|
||||
/**
|
||||
* 同一 token 的新连接替换旧连接时发送给旧连接的控制消息。
|
||||
*/
|
||||
String KICKED = "kicked";
|
||||
}
|
||||
|
||||
@ -65,7 +65,6 @@ public class SseEmitterSessionManager implements PushSessionManager {
|
||||
// 关闭已存在的SseEmitter,防止超过最大连接数
|
||||
SseEmitter oldEmitter = emitters.remove(token);
|
||||
if (oldEmitter != null) {
|
||||
sendKickedMessage(oldEmitter);
|
||||
oldEmitter.complete();
|
||||
}
|
||||
|
||||
@ -104,21 +103,6 @@ public class SseEmitterSessionManager implements PushSessionManager {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知旧连接已被同 token 新连接替换。
|
||||
*
|
||||
* @param emitter 旧 SSE 连接
|
||||
*/
|
||||
private void sendKickedMessage(SseEmitter emitter) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("message")
|
||||
.data(MessageConstants.KICKED));
|
||||
} catch (Exception ignore) {
|
||||
// 旧连接可能已断开,忽略通知失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开指定用户的 SSE 连接
|
||||
*
|
||||
|
||||
@ -5,7 +5,6 @@ import cn.hutool.core.map.MapUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.utils.ThreadUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.common.push.constant.MessageConstants;
|
||||
import org.dromara.common.push.dto.PushDTO;
|
||||
import org.dromara.common.push.properties.MessageProperties;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
@ -62,7 +61,6 @@ public class WebSocketSessionManager implements PushSessionManager {
|
||||
Map<String, WebSocketSession> sessions = USER_TOKEN_SESSIONS.computeIfAbsent(userId, key -> new ConcurrentHashMap<>());
|
||||
// 移除并关闭旧的同token会话,避免重复连接
|
||||
WebSocketSession oldSession = sessions.remove(token);
|
||||
sendKickedMessage(oldSession);
|
||||
closeSession(oldSession, CloseStatus.NORMAL);
|
||||
// 存储新会话
|
||||
sessions.put(token, session);
|
||||
@ -131,18 +129,6 @@ public class WebSocketSessionManager implements PushSessionManager {
|
||||
toRemoveUsers.forEach(USER_TOKEN_SESSIONS::remove);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知旧连接已被同 token 新连接替换。
|
||||
*
|
||||
* @param session 旧 WebSocket 会话
|
||||
*/
|
||||
private void sendKickedMessage(WebSocketSession session) {
|
||||
if (session == null || !session.isOpen()) {
|
||||
return;
|
||||
}
|
||||
sendMessage(session, MessageConstants.KICKED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅消息通道
|
||||
* 注册消息消费者,监听Redis消息推送
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
package org.dromara.common.push;
|
||||
|
||||
import org.dromara.common.push.annotation.ConditionalOnMessageTransport;
|
||||
import org.dromara.common.push.condition.MessageTransportCondition;
|
||||
import org.dromara.common.push.enums.MessageTransportEnum;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("common-push 功能单元测试")
|
||||
class PushFunctionTest {
|
||||
|
||||
/**
|
||||
* 验证消息传输枚举忽略大小写解析,并在未知配置下回退到 SSE。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("解析消息传输方式")
|
||||
void shouldResolveMessageTransportWithSseFallback() {
|
||||
assertEquals(MessageTransportEnum.WEBSOCKET, MessageTransportEnum.of("WebSocket"));
|
||||
assertEquals(MessageTransportEnum.SSE, MessageTransportEnum.of("unknown"));
|
||||
assertEquals(MessageTransportEnum.SSE, MessageTransportEnum.of(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证传输条件同时受启用开关和传输类型约束,防止错误装配推送实现。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("匹配消息传输装配条件")
|
||||
void shouldMatchEnabledMessageTransportOnly() {
|
||||
MessageTransportCondition condition = new MessageTransportCondition();
|
||||
ConditionContext context = mock(ConditionContext.class);
|
||||
AnnotatedTypeMetadata metadata = mock(AnnotatedTypeMetadata.class);
|
||||
MockEnvironment environment = new MockEnvironment()
|
||||
.withProperty("message.enabled", "true")
|
||||
.withProperty("message.transport", "websocket");
|
||||
when(context.getEnvironment()).thenReturn(environment);
|
||||
when(metadata.getAnnotationAttributes(ConditionalOnMessageTransport.class.getName()))
|
||||
.thenReturn(Map.of("value", "websocket"));
|
||||
|
||||
assertTrue(condition.matches(context, metadata));
|
||||
|
||||
environment.setProperty("message.enabled", "false");
|
||||
assertFalse(condition.matches(context, metadata));
|
||||
}
|
||||
}
|
||||
@ -38,8 +38,8 @@ public class CacheConfig {
|
||||
* 自定义缓存管理器 整合spring-cache
|
||||
*/
|
||||
@Bean
|
||||
public CacheManager cacheManager(Cache<Object, Object> caffeine) {
|
||||
return new PlusSpringCacheManager(caffeine);
|
||||
public CacheManager cacheManager() {
|
||||
return new PlusSpringCacheManager();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user