diff --git a/README.md b/README.md index 8fb04f3a..303fdff7 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@
-[![在线预览](https://img.shields.io/badge/在线预览-2D8CF0?style=for-the-badge&logo=google-chrome&logoColor=white)](https://vue.youlai.tech) -[![移动端预览](https://img.shields.io/badge/移动端预览-19BE6B?style=for-the-badge&logo=android&logoColor=white)](https://app.youlai.tech) -[![阅读文档](https://img.shields.io/badge/阅读文档-8B5CF6?style=for-the-badge&logo=gitbook&logoColor=white)](https://www.youlai.tech/docs/server/spring-boot/) -[![English](https://img.shields.io/badge/English-00B4D8?style=for-the-badge&logo=google-translate&logoColor=white)](./README.en.md) +[![在线预览](https://img.shields.io/badge/在线预览-2D8CF0?style=for-the-badge\&logo=google-chrome\&logoColor=white)](https://vue.youlai.tech) +[![移动端预览](https://img.shields.io/badge/移动端预览-19BE6B?style=for-the-badge\&logo=android\&logoColor=white)](https://app.youlai.tech) +[![阅读文档](https://img.shields.io/badge/阅读文档-8B5CF6?style=for-the-badge\&logo=gitbook\&logoColor=white)](https://www.youlai.tech/docs/server/spring-boot/) +[![English](https://img.shields.io/badge/English-00B4D8?style=for-the-badge\&logo=google-translate\&logoColor=white)](./README.en.md)
@@ -31,10 +31,17 @@ ## 核心特性 - 🔐 **安全体系** — Spring Security + JWT/Redis Token 双会话模式、令牌续期、多端互斥 + - 🛡️ **细粒度权限** — RBAC 五级:数据 → 菜单 → 按钮 → 接口 → 字段 + - ⚡ **代码生成器** — 一键生成前后端 CRUD 代码 + - 📦 **模块齐全** — 用户、角色、菜单、部门、字典、文件(OSS/RustFS/本地)、定时任务、消息中心、操作日志 + +- 🔀 **工作流** — Flowable 审批流:BPMN 在线设计、条件网关、角色审批、驳回回退 + - 🌐 **多租户 SaaS** — 数据隔离 + 租户配置,独立 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) 版本 + - 🔌 **实时通信** — SSE 推送:在线用户数、字典同步、通知广播 ## 系统预览 @@ -50,9 +57,9 @@ PC预览3 PC预览4 - - PC预览3 - PC预览4 + + PC预览5 + PC预览6 @@ -72,20 +79,24 @@ **环境要求**:JDK 17+ · MySQL 8.0+ · Redis 6.0+ 1. 克隆项目: + ```bash git clone https://gitee.com/youlaiorg/youlai-boot.git ``` 2. 导入数据库: + ```bash # 基础库(必装) sql/youlai-admin.sql # 可选扩展:不装不影响基础功能,需在基础库之后执行 - sql/extensions/form.sql # 动态表单 + sql/extensions/dynamic-form.sql # 动态表单 + sql/extensions/workflow.sql # 工作流(Flowable 演示流程与演示账号,非引擎表) ``` 3. 修改配置(可选,默认已配置线上只读数据源): + ```bash src/main/resources/application-dev.yml ``` @@ -96,17 +107,19 @@ 用 IDEA 打开项目,等待 Maven 依赖下载完成,运行启动类 `YouLaiBootApplication` 即可。 **方式二:命令行启动** + ```bash mvn spring-boot:run ``` - 启动后访问 [http://localhost:8000/doc.html](http://localhost:8000/doc.html),能打开接口文档即说明后端已正常运行。 + 启动后访问 ,能打开接口文档即说明后端已正常运行。 5. 启动前端(可选): - 如需可视化操作界面,启动配套前端 [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin),访问 [http://localhost:3000](http://localhost:3000),使用 `admin` / `123456` 登录。 + 如需可视化操作界面,启动配套前端 [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin),访问 ,使用 `admin` / `123456` 登录。 > 更多内容详见官方文档:[快速开始](https://www.youlai.tech/docs/server/spring-boot/quick-start.html) · [部署指南](https://www.youlai.tech/docs/server/spring-boot/deploy.html) + ## 目录结构 ``` @@ -114,14 +127,31 @@ youlai-boot/ ├── docs/ # 项目文档与图片资源 ├── sql/ # 数据库初始化脚本 ├── src/ # 源码目录 -│ ├── auth/ # 认证模块(登录/登出/令牌/验证码) +│ ├── auth/ # 认证模块 +│ │ ├── password/ # 账号密码登录(含图形验证码) +│ │ ├── sms/ # 短信验证码登录 +│ │ ├── qrcode/ # 扫码登录 +│ │ ├── wxma/ # 微信小程序登录 +│ │ └── session/ # 登录态管理(登出 / 刷新令牌) │ ├── codegen/ # 代码生成器 │ ├── common/ # 公共模块(常量/枚举/异常/统一响应/工具类) -│ ├── config/ # 配置类(Security/Redis/Captcha/OSS/邮件等) +│ ├── config/ # 配置类(Security/Redis/MyBatis/接口文档/跨域/定时任务等) │ ├── framework/ # 基础设施层 │ │ ├── mybatis/ # MyBatis-Plus 配置 -│ │ ├── security/ # Spring Security(过滤器/Token/工具类) +│ │ ├── security/ # Spring Security +│ │ │ ├── config/ # 安全配置与配置属性 +│ │ │ ├── filter/ # Token 认证过滤器 +│ │ │ ├── handler/ # 认证 / 授权失败处理器 +│ │ │ ├── model/ # 安全模型(SecurityUser / 令牌 / 数据范围) +│ │ │ ├── port/ # 认证与权限端口(由 system 提供实现) +│ │ │ ├── service/ # 用户详情与权限服务 +│ │ │ ├── token/ # Token 管理(JWT / Redis 双模式) +│ │ │ └── util/ # 安全工具类 │ │ └── web/ # Web 层(全局异常/跨域/限流) +│ ├── module/ # 业务扩展模块 +│ │ ├── config/ # 模块装配(工作流按 flowable.enabled 开关) +│ │ ├── form/ # 动态表单 +│ │ └── workflow/ # 工作流(Flowable 审批流) │ ├── support/ # 能力服务 │ │ ├── file/ # 文件存储(RustFS/阿里云OSS/本地) │ │ ├── mail/ # 邮件服务 @@ -136,37 +166,62 @@ youlai-boot/ **前端** -| 项目 | 技术栈 | 说明 | 更新状态 | -|:-----|:-------|:-----|:---------| -| [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) | Vue 3 + Element Plus | PC 管理前端(主推) | ✅️ | -| [youlai-app](https://gitee.com/youlaiorg/youlai-app) | Vue 3 + UniApp | 移动端 App | ✅️ | +| 项目 | 技术栈 | 说明 | 更新状态 | +| :------------------------------------------------------------------- | :------------------- | :---------- | :--- | +| [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin) | Vue 3 + Element Plus | PC 管理前端(主推) | ✅️ | +| [youlai-app](https://gitee.com/youlaiorg/youlai-app) | Vue 3 + UniApp | 移动端 App | ✅️ | **后端** -| 项目 | 技术栈 | 说明 | 更新状态 | -|:-----|:-------|:-----|:---------| -| [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) | Spring Boot + MyBatis-Plus | Java(主推) | ✅️ | -| [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) | NestJS + TypeORM | Node.js | ✅️ | -| [youlai-gin](https://gitee.com/youlaiorg/youlai-gin) | Go + Gorm | Go | ✅️ | -| [youlai-django](https://gitee.com/youlaiorg/youlai-django) | Django + DRF | Python | ✅️ | -| [youlai-fastapi](https://gitee.com/youlaiorg/youlai-fastapi) | FastAPI + SQLAlchemy | Python | ✅️ | -| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ✅️ | -| [youlai-think](https://gitee.com/youlaiorg/youlai-think) | ThinkPHP + ThinkORM | PHP | ✅️ | -| [youlai-aspnet](https://gitee.com/youlaiorg/youlai-aspnet) | ASP.NET Core + EF Core | C# | ✅️ | -| [youlai-axum](https://gitee.com/youlaiorg/youlai-axum) | Axum + SeaORM | Rust | ✅️ | +| 项目 | 技术栈 | 说明 | 更新状态 | +| :----------------------------------------------------------- | :------------------------- | :------- | :--- | +| [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) | Spring Boot + MyBatis-Plus | Java(主推) | ✅️ | +| [youlai-nest](https://gitee.com/youlaiorg/youlai-nest) | NestJS + TypeORM | Node.js | ✅️ | +| [youlai-gin](https://gitee.com/youlaiorg/youlai-gin) | Go + Gorm | Go | ✅️ | +| [youlai-django](https://gitee.com/youlaiorg/youlai-django) | Django + DRF | Python | ✅️ | +| [youlai-fastapi](https://gitee.com/youlaiorg/youlai-fastapi) | FastAPI + SQLAlchemy | Python | ✅️ | +| [youlai-laravel](https://gitee.com/youlaiorg/youlai-laravel) | Laravel + Eloquent | PHP | ✅️ | +| [youlai-think](https://gitee.com/youlaiorg/youlai-think) | ThinkPHP + ThinkORM | PHP | ✅️ | +| [youlai-aspnet](https://gitee.com/youlaiorg/youlai-aspnet) | ASP.NET Core + EF Core | C# | ✅️ | +| [youlai-axum](https://gitee.com/youlaiorg/youlai-axum) | Axum + SeaORM | Rust | ✅️ | > 九种后端共享同一套 **RESTful API 规范** 和 **数据库结构**,前端可无缝切换。 **衍生版本** -| 项目 | 基于 | 类型 | 说明 | 更新状态 | -|:-----|:-----|:-----|:-----|:---------| -| [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) | youlai-boot | 独立仓库 | 多租户 SaaS,租户隔离与租户配置 | ✅️ | -| [youlai-boot-flex](https://gitee.com/youlaiorg/youlai-boot-flex) | youlai-boot | 独立仓库 | 改用 MyBatis-Flex | ✅️ | -| [youlai-boot (db-pg)](https://gitee.com/youlaiorg/youlai-boot/tree/db-pg) | youlai-boot | 分支 | PostgreSQL 数据库分支 | ✅️ | -| [youlai-boot (multi-module)](https://gitee.com/youlaiorg/youlai-boot/tree/multi-module) | youlai-boot | 分支 | 多模块工程拆分 | ✅️ | -| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | 分支 | Spring Boot 3 兼容分支 | ✅️ | -| [youlai-nest (multi-tenant)](https://gitee.com/youlaiorg/youlai-nest/tree/multi-tenant) | youlai-nest | 分支 | 多租户 SaaS,租户隔离与租户配置 | ✅️ | +| 项目 | 基于 | 类型 | 说明 | 更新状态 | +| :---------------------------------------------------------------------------------------- | :---------- | :--- | :----------------- | :--- | +| [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) | youlai-boot | 独立仓库 | 多租户 SaaS,租户隔离与租户配置 | ✅️ | +| [youlai-boot-flex](https://gitee.com/youlaiorg/youlai-boot-flex) | youlai-boot | 独立仓库 | 改用 MyBatis-Flex | ✅️ | +| [youlai-boot (db-pg)](https://gitee.com/youlaiorg/youlai-boot/tree/db-pg) | youlai-boot | 分支 | PostgreSQL 数据库分支 | ✅️ | +| [youlai-boot (multi-module)](https://gitee.com/youlaiorg/youlai-boot/tree/multi-module) | youlai-boot | 分支 | 多模块工程拆分 | ✅️ | +| [youlai-boot (spring-boot-3)](https://gitee.com/youlaiorg/youlai-boot/tree/spring-boot-3) | youlai-boot | 分支 | Spring Boot 3 兼容分支 | ✅️ | +| [youlai-nest (multi-tenant)](https://gitee.com/youlaiorg/youlai-nest/tree/multi-tenant) | youlai-nest | 分支 | 多租户 SaaS,租户隔离与租户配置 | ✅️ | + +## 可选扩展 + +**工作流(默认关闭)** + +两步开启: + +1. 导入演示数据(演示流程与演示账号): + + ```bash + sql/extensions/workflow.sql + ``` + + ACT_* 引擎表无需导入,启动时由 Flowable 自动创建(已存在则不重复处理)。 + +2. 在 `application-{profile}.yml` 中开启: + + ```yaml + flowable: + enabled: true + ``` + +**多租户** + +需要租户隔离与租户配置,使用独立仓库 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant)。 ## 技术合作 @@ -174,23 +229,8 @@ youlai-boot/ 如需技术支持、商务合作、二次开发、项目定制或私有化部署,可联系作者微信(见下方二维码)。 - - - - - - - - -
- 公众号「有来技术」
- 公众号「有来技术」 -
     - 小程序「有来技术」
- 小程序「有来技术」 -
     - 添加作者微信
- 添加作者微信 -
+| 公众号「有来技术」 |
| 小程序「有来技术」 |
| 添加作者微信 | +| --------- | ------ | --------- | ------ | ------ | +|
|
|
|
|
|

技术交流 · 问题反馈 · 商务合作

diff --git a/pom.xml b/pom.xml index c1071eaa..7bc8cca9 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.youlai youlai-boot - 4.8.0 + 4.9.0 基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。 @@ -22,6 +22,9 @@ 5.8.41 + + 8.0.0 + 9.1.0 1.2.24 @@ -62,6 +65,9 @@ 2.14.5 + + 1.3.0 + 4.8.1.B @@ -113,6 +119,13 @@ test + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + + org.springframework.boot spring-boot-starter-security @@ -282,6 +295,13 @@ ${weixin-java-miniapp.version} + + + org.flowable + flowable-spring-boot-starter-process + ${flowable.version} + + diff --git a/sql/extensions/form.sql b/sql/extensions/dynamic-form.sql similarity index 78% rename from sql/extensions/form.sql rename to sql/extensions/dynamic-form.sql index 3076c87e..658402ae 100644 --- a/sql/extensions/form.sql +++ b/sql/extensions/dynamic-form.sql @@ -23,6 +23,7 @@ CREATE TABLE `form_definition` ( `options_json` json DEFAULT NULL COMMENT '表单全局配置(labelPosition/size/submitBtn 等)', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态(0草稿 1已发布 -1已停用)', `is_public` tinyint NOT NULL DEFAULT 0 COMMENT '是否允许匿名公开访问(0否 1是)', + `category` varchar(16) NOT NULL DEFAULT 'normal' COMMENT '表单类型(normal通用表单 workflow审批表单)', `menu_id` bigint DEFAULT NULL COMMENT '生成的访问菜单ID(NULL未生成,幂等防重复)', `version` int NOT NULL DEFAULT 1 COMMENT '版本号,每次发布+1', `create_time` datetime NOT NULL COMMENT '创建时间', @@ -74,50 +75,50 @@ CREATE TABLE `form_snapshot` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单版本快照表'; -- ---------------------------- --- 菜单(顶级目录 ID=10 顺延;目录设 always_show=1:仅一个可见子菜单时父级不被吞,LayoutSidebarItem 折叠逻辑) +-- 菜单(扩展段顶级 ID=7,基础段 1-6 见 youlai-admin.sql;目录设 always_show=1:仅一个可见子菜单时父级不被吞,LayoutSidebarItem 折叠逻辑) -- 目录与视图目录/URL 全链路对齐:views/dynamic-form + /dynamic-form(route_path/component 字段) -- ---------------------------- -- params 携 {"badge":"NEW"}:挂顶级"动态表单"一级可见(二级折叠态角标不可见),宣传期过后清空 params 即消失 -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10, 0, '0', '动态表单', 'C', '', '/dynamic-form', 'Layout', NULL, 1, NULL, 1, 3, 'el-icon-Document', '/dynamic-form/definition', now(), now(), '{"badge":"NEW"}'); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (300, 10, '0,10', '表单列表', 'M', 'FormDefinition', 'definition', 'dynamic-form/index', NULL, NULL, 1, 1, 1, 'el-icon-List', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (3001, 300, '0,10,300', '表单查询', 'B', NULL, '', NULL, 'form:definition:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (3002, 300, '0,10,300', '表单新增', 'B', NULL, '', NULL, 'form:definition:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (3003, 300, '0,10,300', '表单修改', 'B', NULL, '', NULL, 'form:definition:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (3004, 300, '0,10,300', '表单删除', 'B', NULL, '', NULL, 'form:definition:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (7, 0, '0', '动态表单', 'C', '', '/dynamic-form', 'Layout', NULL, 1, NULL, 1, 7, 'el-icon-Document', '/dynamic-form/definition', now(), now(), '{\"badge\":\"NEW\"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (701, 7, '0,7', '表单列表', 'M', 'FormDefinition', 'definition', 'dynamic-form/index', NULL, NULL, 1, 1, 1, 'el-icon-List', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70101, 701, '0,7,701', '表单查询', 'B', NULL, '', NULL, 'form:definition:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70102, 701, '0,7,701', '表单新增', 'B', NULL, '', NULL, 'form:definition:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70103, 701, '0,7,701', '表单修改', 'B', NULL, '', NULL, 'form:definition:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70104, 701, '0,7,701', '表单删除', 'B', NULL, '', NULL, 'form:definition:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); -- 表单设计器(隐藏菜单,visible=0,列表"设计"按钮携 id 跳转,复刻 dict-item 模式) -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (302, 10, '0,10', '表单设计器', 'M', 'FormDesigner', 'designer', 'dynamic-form/designer', NULL, NULL, 1, 0, 3, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (703, 7, '0,7', '表单设计器', 'M', 'FormDesigner', 'designer', 'dynamic-form/designer', NULL, NULL, 1, 0, 3, '', NULL, now(), now(), NULL); -- 表单预览(隐藏菜单,visible=0,设计器"预览"按钮携 id 跳转,运行态真预览) -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (303, 10, '0,10', '表单预览', 'M', 'FormPreview', 'preview', 'dynamic-form/preview', NULL, NULL, 1, 0, 4, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (704, 7, '0,7', '表单预览', 'M', 'FormPreview', 'preview', 'dynamic-form/preview', NULL, NULL, 1, 0, 4, '', NULL, now(), now(), NULL); -- 表单数据(隐藏菜单,visible=0,列表"数据"按钮携 formKey 跳转,复刻 dict-item 模式) -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (301, 10, '0,10', '表单数据', 'M', 'FormData', 'data', 'dynamic-form/data', NULL, NULL, 1, 0, 2, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (3005, 301, '0,10,301', '数据查询', 'B', NULL, '', NULL, 'form:data:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (3006, 301, '0,10,301', '数据删除', 'B', NULL, '', NULL, 'form:data:delete', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (702, 7, '0,7', '表单数据', 'M', 'FormData', 'data', 'dynamic-form/data', NULL, NULL, 1, 0, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70201, 702, '0,7,702', '数据查询', 'B', NULL, '', NULL, 'form:data:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70202, 702, '0,7,702', '数据删除', 'B', NULL, '', NULL, 'form:data:delete', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -- ---------------------------- -- 示例表单入口(预置双入口:内嵌菜单 + 公开外链;与 saveFormMenu 生成的结构完全一致—— -- type=M、component=dynamic-form/render、route_path=formKey、params 携 formKey、 -- route_name 遵循 FormRender+驼峰(formKey) 全局唯一约束,防发布向导重复生成) -- ---------------------------- -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (12, 10, '0,10', '表单示例', 'C', '', 'sample', 'Layout', NULL, 1, NULL, 1, 5, 'el-icon-Files', '/dynamic-form/sample/open_source_survey', now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (304, 12, '0,10,12', '开源项目问卷收集', 'M', 'FormRenderOpenSourceSurvey', 'open_source_survey', 'dynamic-form/render', NULL, NULL, NULL, 1, 1, 'el-icon-Tickets', NULL, now(), now(), '{"formKey":"open_source_survey"}'); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (305, 12, '0,10,12', '活动报名表', 'M', 'FormRenderActivityRegistration', 'activity_registration', 'dynamic-form/render', NULL, NULL, NULL, 1, 2, 'el-icon-Calendar', NULL, now(), now(), '{"formKey":"activity_registration"}'); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (306, 12, '0,10,12', '意见反馈', 'M', 'FormRenderFeedback', 'feedback', 'dynamic-form/render', NULL, NULL, NULL, 1, 3, 'el-icon-ChatLineRound', NULL, now(), now(), '{"formKey":"feedback"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (705, 7, '0,7', '表单示例', 'C', '', 'sample', 'Layout', NULL, 1, NULL, 1, 5, 'el-icon-Files', '/dynamic-form/sample/open_source_survey', now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70501, 705, '0,7,705', '开源项目问卷收集', 'M', 'FormRenderOpenSourceSurvey', 'open_source_survey', 'dynamic-form/render', NULL, NULL, NULL, 1, 1, 'el-icon-Tickets', NULL, now(), now(), '{"formKey":"open_source_survey"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70502, 705, '0,7,705', '活动报名表', 'M', 'FormRenderActivityRegistration', 'activity_registration', 'dynamic-form/render', NULL, NULL, NULL, 1, 2, 'el-icon-Calendar', NULL, now(), now(), '{"formKey":"activity_registration"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (70503, 705, '0,7,705', '意见反馈', 'M', 'FormRenderFeedback', 'feedback', 'dynamic-form/render', NULL, NULL, NULL, 1, 3, 'el-icon-ChatLineRound', NULL, now(), now(), '{"formKey":"feedback"}'); -- ---------------------------- -- 系统管理员角色授权(role_id=2) -- ---------------------------- -INSERT IGNORE INTO `sys_role_menu` VALUES (2, 10), (2, 300), (2, 3001), (2, 3002), (2, 3003), (2, 3004), (2, 302), (2, 303), (2, 301), (2, 3005), (2, 3006), (2, 12), (2, 304), (2, 305), (2, 306); +INSERT IGNORE INTO `sys_role_menu` VALUES (2, 7), (2, 701), (2, 70101), (2, 70102), (2, 70103), (2, 70104), (2, 702), (2, 70201), (2, 70202), (2, 703), (2, 704), (2, 705), (2, 70501), (2, 70502), (2, 70503); -- ---------------------------- -- 预置示例表单(开箱即用,覆盖常用组件类型;ID 段 1001-1003,避开用户自建的自增 ID) -- 已发布(status=1)+ 双入口预置: --- 内嵌:菜单 304/305/306(动态表单 > 表单示例),menu_id 已回填,发布向导"入口管理"可直接改挂载/角色 +-- 内嵌:菜单 70501/70502/70503(动态表单 > 表单示例),menu_id 已回填,发布向导"入口管理"可直接改挂载/角色 -- 外链:is_public=1,匿名访问 /f/{form_key}(问卷/报名/反馈天然适合对外收集) -- ---------------------------- INSERT IGNORE INTO `form_definition` (`id`, `form_key`, `form_name`, `description`, `form_json`, `options_json`, `status`, `is_public`, `menu_id`, `version`, `create_time`, `update_time`, `create_by`, `update_by`, `is_deleted`) VALUES -(1001, 'open_source_survey', '开源项目问卷收集', '面向社区的开源项目推荐与信息收集问卷', '[{"type":"input","field":"project_name","title":"项目名称","info":"","props":{"placeholder":"如 vue3-element-admin"},"validate":[{"required":true,"message":"请输入项目名称"}]},{"type":"input","field":"repo_url","title":"项目地址","info":"","props":{"placeholder":"https://github.com/xxx/xxx"},"validate":[{"required":true,"message":"请输入项目地址"}]},{"type":"select","field":"category","title":"项目领域","info":"","props":{},"options":[{"label":"前端","value":"frontend"},{"label":"后端","value":"backend"},{"label":"移动端","value":"mobile"},{"label":"AI 与大模型","value":"ai"},{"label":"开发工具","value":"tools"},{"label":"其他","value":"other"}],"validate":[{"required":true,"message":"请选择项目领域"}]},{"type":"radio","field":"license","title":"开源协议","info":"","options":[{"label":"MIT","value":"MIT"},{"label":"Apache-2.0","value":"Apache-2.0"},{"label":"GPLv3","value":"GPLv3"},{"label":"其他","value":"other"}]},{"type":"select","field":"stars","title":"Star 数量级","info":"","props":{},"options":[{"label":"100 以内","value":"lt100"},{"label":"100 ~ 1k","value":"100to1k"},{"label":"1k ~ 10k","value":"1kto10k"},{"label":"10k 以上","value":"gt10k"}]},{"type":"textarea","field":"recommend","title":"推荐理由","info":"","props":{"placeholder":"一句话说说它解决了什么问题"},"validate":[{"required":true,"message":"请填写推荐理由"}]},{"type":"input","field":"contact","title":"联系邮箱","info":"","props":{"placeholder":"选填,便于回访"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 304, 1, now(), now(), NULL, NULL, 0), -(1002, 'activity_registration', '活动报名表', '线下活动/聚会报名登记,含场次选择与用餐需求', '[{"type":"input","field":"name","title":"姓名","info":"","props":{},"validate":[{"required":true,"message":"请输入姓名"}]},{"type":"input","field":"phone","title":"手机号码","info":"","props":{"placeholder":"用于接收活动通知"},"validate":[{"required":true,"message":"请输入手机号码"}]},{"type":"radio","field":"gender","title":"性别","info":"","options":[{"label":"男","value":"male"},{"label":"女","value":"female"}]},{"type":"checkbox","field":"sessions","title":"参加场次","info":"","options":[{"label":"主论坛","value":"main"},{"label":"工作坊 A","value":"workshop_a"},{"label":"工作坊 B","value":"workshop_b"},{"label":"晚间交流","value":"evening"}]},{"type":"inputNumber","field":"attendees","title":"同行人数","info":"","props":{"min":0,"max":10}},{"type":"radio","field":"dinner","title":"用餐需求","info":"","options":[{"label":"正常","value":"normal"},{"label":"素食","value":"vegetarian"},{"label":"无需用餐","value":"none"}]},{"type":"textarea","field":"remark","title":"备注","info":"","props":{"placeholder":"选填"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 305, 1, now(), now(), NULL, NULL, 0), -(1003, 'feedback', '意见反馈', '产品/系统通用意见反馈表,含满意度评分', '[{"type":"select","field":"type","title":"反馈类型","info":"","props":{},"options":[{"label":"功能建议","value":"feature"},{"label":"问题报告","value":"bug"},{"label":"体验吐槽","value":"ux"},{"label":"其他","value":"other"}],"validate":[{"required":true,"message":"请选择反馈类型"}]},{"type":"rate","field":"rating","title":"整体满意度","info":"","props":{}},{"type":"textarea","field":"content","title":"反馈内容","info":"","props":{"placeholder":"请具体描述你的建议或遇到的问题"},"validate":[{"required":true,"message":"请填写反馈内容"}]},{"type":"input","field":"contact","title":"联系方式","info":"","props":{"placeholder":"选填,便于跟进回复"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 306, 1, now(), now(), NULL, NULL, 0); +(1001, 'open_source_survey', '开源项目问卷收集', '面向社区的开源项目推荐与信息收集问卷', '[{"type":"input","field":"project_name","title":"项目名称","info":"","props":{"placeholder":"如 vue3-element-admin"},"validate":[{"required":true,"message":"请输入项目名称"}]},{"type":"input","field":"repo_url","title":"项目地址","info":"","props":{"placeholder":"https://github.com/xxx/xxx"},"validate":[{"required":true,"message":"请输入项目地址"}]},{"type":"select","field":"category","title":"项目领域","info":"","props":{},"options":[{"label":"前端","value":"frontend"},{"label":"后端","value":"backend"},{"label":"移动端","value":"mobile"},{"label":"AI 与大模型","value":"ai"},{"label":"开发工具","value":"tools"},{"label":"其他","value":"other"}],"validate":[{"required":true,"message":"请选择项目领域"}]},{"type":"radio","field":"license","title":"开源协议","info":"","options":[{"label":"MIT","value":"MIT"},{"label":"Apache-2.0","value":"Apache-2.0"},{"label":"GPLv3","value":"GPLv3"},{"label":"其他","value":"other"}]},{"type":"select","field":"stars","title":"Star 数量级","info":"","props":{},"options":[{"label":"100 以内","value":"lt100"},{"label":"100 ~ 1k","value":"100to1k"},{"label":"1k ~ 10k","value":"1kto10k"},{"label":"10k 以上","value":"gt10k"}]},{"type":"textarea","field":"recommend","title":"推荐理由","info":"","props":{"placeholder":"一句话说说它解决了什么问题"},"validate":[{"required":true,"message":"请填写推荐理由"}]},{"type":"input","field":"contact","title":"联系邮箱","info":"","props":{"placeholder":"选填,便于回访"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 70501, 1, now(), now(), NULL, NULL, 0), +(1002, 'activity_registration', '活动报名表', '线下活动/聚会报名登记,含场次选择与用餐需求', '[{"type":"input","field":"name","title":"姓名","info":"","props":{},"validate":[{"required":true,"message":"请输入姓名"}]},{"type":"input","field":"phone","title":"手机号码","info":"","props":{"placeholder":"用于接收活动通知"},"validate":[{"required":true,"message":"请输入手机号码"}]},{"type":"radio","field":"gender","title":"性别","info":"","options":[{"label":"男","value":"male"},{"label":"女","value":"female"}]},{"type":"checkbox","field":"sessions","title":"参加场次","info":"","options":[{"label":"主论坛","value":"main"},{"label":"工作坊 A","value":"workshop_a"},{"label":"工作坊 B","value":"workshop_b"},{"label":"晚间交流","value":"evening"}]},{"type":"inputNumber","field":"attendees","title":"同行人数","info":"","props":{"min":0,"max":10}},{"type":"radio","field":"dinner","title":"用餐需求","info":"","options":[{"label":"正常","value":"normal"},{"label":"素食","value":"vegetarian"},{"label":"无需用餐","value":"none"}]},{"type":"textarea","field":"remark","title":"备注","info":"","props":{"placeholder":"选填"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 70502, 1, now(), now(), NULL, NULL, 0), +(1003, 'feedback', '意见反馈', '产品/系统通用意见反馈表,含满意度评分', '[{"type":"select","field":"type","title":"反馈类型","info":"","props":{},"options":[{"label":"功能建议","value":"feature"},{"label":"问题报告","value":"bug"},{"label":"体验吐槽","value":"ux"},{"label":"其他","value":"other"}],"validate":[{"required":true,"message":"请选择反馈类型"}]},{"type":"rate","field":"rating","title":"整体满意度","info":"","props":{}},{"type":"textarea","field":"content","title":"反馈内容","info":"","props":{"placeholder":"请具体描述你的建议或遇到的问题"},"validate":[{"required":true,"message":"请填写反馈内容"}]},{"type":"input","field":"contact","title":"联系方式","info":"","props":{"placeholder":"选填,便于跟进回复"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 70503, 1, now(), now(), NULL, NULL, 0); -- 预置表单版本快照(已发布语义 = 固化 v1 规则,防止后续编辑定义导致历史数据回显漂移) INSERT IGNORE INTO `form_snapshot` (`id`, `form_id`, `version`, `form_json`, `options_json`, `create_time`, `update_time`, `is_deleted`) VALUES diff --git a/sql/extensions/workflow.sql b/sql/extensions/workflow.sql new file mode 100644 index 00000000..dd4f80ad --- /dev/null +++ b/sql/extensions/workflow.sql @@ -0,0 +1,110 @@ +# 工作流模块(可选扩展) +# Copyright (c) 2021-present, youlai.tech + +-- ---------------------------------------------------------------------------- +-- 工作流模块增量脚本 +-- 前置:先执行 ../youlai-admin.sql 与 dynamic-form.sql +-- 引擎表(ACT_*)由 Flowable 按 flowable.database-schema-update 策略自动创建,本脚本不含建表 +-- ---------------------------------------------------------------------------- + +USE youlai_admin; + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- 菜单(顶级 ID=8,顺延动态表单 ID=7) +-- ---------------------------- +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (8, 0, '0', '工作流', 'C', '', '/workflow', 'Layout', NULL, 1, NULL, 1, 8, 'el-icon-Connection', '/workflow/launch', now(), now(), '{\"badge\":\"NEW\"}'); +UPDATE `sys_menu` SET redirect = '/workflow/launch', update_time = now() WHERE id = 8 AND redirect = '/workflow/model'; +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (801, 8, '0,8', '流程设计', 'M', 'WorkflowModel', 'model', 'workflow/model', NULL, NULL, NULL, 1, 1, 'el-icon-Coin', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80101, 801, '0,8,801', '流程查询', 'B', NULL, '', NULL, 'workflow:model:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80102, 801, '0,8,801', '流程新增', 'B', NULL, '', NULL, 'workflow:model:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80103, 801, '0,8,801', '流程修改', 'B', NULL, '', NULL, 'workflow:model:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80104, 801, '0,8,801', '流程删除', 'B', NULL, '', NULL, 'workflow:model:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80105, 801, '0,8,801', '流程发布', 'B', NULL, '', NULL, 'workflow:model:deploy', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80106, 801, '0,8,801', '流程重置', 'B', NULL, '', NULL, 'workflow:model:reset', NULL, NULL, 1, 6, '', NULL, now(), now(), NULL); +-- 流程启停:启用开关走 definition:update,删除复用 model:delete +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80107, 801, '0,8,801', '流程启停', 'B', NULL, '', NULL, 'workflow:definition:update', NULL, NULL, 1, 7, '', NULL, now(), now(), NULL); +-- 流程设计器(隐藏菜单,列表"设计"按钮跳转) +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (802, 8, '0,8', '流程设计器', 'M', 'WorkflowDesigner', 'designer', 'workflow/designer', NULL, NULL, 1, 0, 2, '', NULL, now(), now(), NULL); + +-- ---------------------------- +-- 存量环境迁移(幂等可重复执行) +-- 1) 菜单重命名:INSERT IGNORE 不覆盖已有行,改名需显式 UPDATE +-- 2) 「流程发布」并入「流程设计」,删除 803 系菜单及角色关联 +-- ---------------------------- +UPDATE `sys_menu` SET name = '流程设计', update_time = now() WHERE id = 801 AND name = '流程模型'; +UPDATE `sys_menu` SET name = '流程查询', update_time = now() WHERE id = 80101 AND name = '模型查询'; +UPDATE `sys_menu` SET name = '流程新增', update_time = now() WHERE id = 80102 AND name = '模型新增'; +UPDATE `sys_menu` SET name = '流程修改', update_time = now() WHERE id = 80103 AND name = '模型修改'; +UPDATE `sys_menu` SET name = '流程删除', update_time = now() WHERE id = 80104 AND name = '模型删除'; +UPDATE `sys_menu` SET name = '流程发布', update_time = now() WHERE id = 80105 AND name IN ('模型部署', '流程部署'); +UPDATE `sys_menu` SET name = '流程重置', update_time = now() WHERE id = 80106 AND name = '模型重置'; +UPDATE `sys_menu` SET name = '流程启停', update_time = now() WHERE id = 80107 AND name IN ('发布停用', '流程停用'); +-- 先迁移 803 授权到 80107 再删菜单,避免角色丢失启停能力 +INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`) +SELECT rm.role_id, 80107 FROM `sys_role_menu` rm WHERE rm.menu_id = 80302; +DELETE FROM `sys_role_menu` WHERE menu_id IN (803, 80301, 80302, 80303, 80108); +DELETE FROM `sys_menu` WHERE id IN (803, 80301, 80302, 80303, 80108); +-- 发起/审批中心仅登录校验,不设按钮权限 +-- 审批中心 = 待办/已办/我发起 三个页签 +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (804, 8, '0,8', '发起流程', 'M', 'WorkflowLaunch', 'launch', 'workflow/launch', NULL, NULL, NULL, 1, 4, 'el-icon-Promotion', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (805, 8, '0,8', '审批中心', 'M', 'WorkflowTask', 'task', 'workflow/task/index', NULL, NULL, NULL, 1, 5, 'el-icon-Stamp', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80501, 805, '0,8,805', '终止流程', 'B', NULL, '', NULL, 'workflow:instance:terminate', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +-- 存量环境迁移:805 升级为「审批中心」,806/807 并入 805 +UPDATE `sys_menu` SET name = '审批中心', route_name = 'WorkflowTask', route_path = 'task', component = 'workflow/task/index', icon = 'el-icon-Stamp', update_time = now() WHERE id = 805 AND component = 'workflow/instance'; +DELETE FROM `sys_role_menu` WHERE menu_id IN (806, 807); +DELETE FROM `sys_menu` WHERE id IN (806, 807); + +-- ---------------------------- +-- 系统管理员角色授权(role_id=2) +-- ---------------------------- +INSERT IGNORE INTO `sys_role_menu` VALUES (2, 8), (2, 801), (2, 80101), (2, 80102), (2, 80103), (2, 80104), (2, 80105), (2, 80106), (2, 80107), (2, 802), (2, 804), (2, 805), (2, 80501); + +-- ---------------------------- +-- 演示角色与用户(ID 段 8-10,顺延 youlai-admin.sql 预置 1-7;密码 123456) +-- 审批环节按 BPMN candidateGroups 绑定专用角色,不依赖 ADMIN +-- ---------------------------- +INSERT IGNORE INTO `sys_role` VALUES (8, '总经理', 'GENERAL_MANAGER', 8, 1, 4, NULL, now(), NULL, now(), 0); +INSERT IGNORE INTO `sys_role` VALUES (9, '财务', 'FINANCE', 9, 1, 4, NULL, now(), NULL, now(), 0); +INSERT IGNORE INTO `sys_role` VALUES (10, '行政', 'ADMINISTRATION', 10, 1, 4, NULL, now(), NULL, now(), 0); + +INSERT IGNORE INTO `sys_user` VALUES (8, 'manager', '总经理', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345684', 1, 'manager@youlaitech.com', now(), NULL, now(), NULL, 0); +INSERT IGNORE INTO `sys_user` VALUES (9, 'finance', '财务专员', 2, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345685', 1, 'finance@youlaitech.com', now(), NULL, now(), NULL, 0); +INSERT IGNORE INTO `sys_user` VALUES (10, 'clerk', '行政专员', 2, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345686', 1, 'clerk@youlaitech.com', now(), NULL, now(), NULL, 0); + +INSERT IGNORE INTO `sys_user_role` VALUES (8, 8), (9, 9), (10, 10); + +-- ---------------------------- +-- 演示角色的工作流菜单授权(不含流程设计等管理菜单) +-- ---------------------------- +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 8), (4, 804), (4, 805); +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 8), (5, 804), (5, 805); +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 8), (6, 804), (6, 805); +INSERT IGNORE INTO `sys_role_menu` VALUES (8, 8), (8, 804), (8, 805); +INSERT IGNORE INTO `sys_role_menu` VALUES (9, 8), (9, 804), (9, 805); +INSERT IGNORE INTO `sys_role_menu` VALUES (10, 8), (10, 804), (10, 805); + +-- ---------------------------- +-- 演示流程的发起表单(依赖动态表单扩展) +-- form_key 与 BPMN 开始节点 flowable:formKey 一一对应 +-- ID 段 1004-1007 顺延 dynamic-form.sql 预置的 1001-1003 +-- ---------------------------- +INSERT IGNORE INTO `form_definition` (`id`, `form_key`, `form_name`, `description`, `form_json`, `options_json`, `status`, `is_public`, `category`, `menu_id`, `version`, `create_time`, `update_time`, `create_by`, `update_by`, `is_deleted`) VALUES +(1004, 'leave_apply', '请假申请', '演示流程"请假审批"的发起表单,天数超过 3 天自动流转总经理审批', '[{"type":"select","field":"leave_type","title":"请假类型","info":"","props":{},"options":[{"label":"事假","value":"personal"},{"label":"病假","value":"sick"},{"label":"年假","value":"annual"},{"label":"调休","value":"compensatory"}],"validate":[{"required":true,"message":"请选择请假类型"}]},{"type":"inputNumber","field":"days","title":"请假天数","info":"","props":{"min":0.5,"max":30,"precision":1},"validate":[{"required":true,"message":"请输入请假天数"}]},{"type":"datePicker","field":"start_date","title":"开始日期","info":"","props":{"valueFormat":"YYYY-MM-DD"},"validate":[{"required":true,"message":"请选择开始日期"}]},{"type":"datePicker","field":"end_date","title":"结束日期","info":"","props":{"valueFormat":"YYYY-MM-DD"},"validate":[{"required":true,"message":"请选择结束日期"}]},{"type":"textarea","field":"reason","title":"请假事由","info":"","props":{"placeholder":"请简要说明请假事由"},"validate":[{"required":true,"message":"请填写请假事由"}]}]', '{"labelPosition":"right","size":"default"}', 1, 0, 'workflow', NULL, 1, now(), now(), NULL, NULL, 0), +(1005, 'expense_apply', '报销申请', '演示流程"报销审批"的发起表单,金额超过 5000 元自动流转总经理审批', '[{"type":"select","field":"expense_type","title":"报销类型","info":"","props":{},"options":[{"label":"差旅费","value":"travel"},{"label":"办公费","value":"office"},{"label":"招待费","value":"hospitality"},{"label":"培训费","value":"training"}],"validate":[{"required":true,"message":"请选择报销类型"}]},{"type":"inputNumber","field":"amount","title":"报销金额(元)","info":"","props":{"min":0,"max":1000000,"precision":2},"validate":[{"required":true,"message":"请输入报销金额"}]},{"type":"datePicker","field":"expense_date","title":"费用发生日期","info":"","props":{"valueFormat":"YYYY-MM-DD"},"validate":[{"required":true,"message":"请选择费用发生日期"}]},{"type":"textarea","field":"reason","title":"报销说明","info":"","props":{"placeholder":"请简要说明费用用途"},"validate":[{"required":true,"message":"请填写报销说明"}]}]', '{"labelPosition":"right","size":"default"}', 1, 0, 'workflow', NULL, 1, now(), now(), NULL, NULL, 0), +(1006, 'purchase_apply', '采购申请', '演示流程"采购申请"的发起表单,金额超过 1 万元流转总经理审批,通过后回退发起人执行采购', '[{"type":"input","field":"item_name","title":"采购物品","info":"","props":{"placeholder":"请输入采购物品名称"},"validate":[{"required":true,"message":"请填写采购物品"}]},{"type":"inputNumber","field":"quantity","title":"采购数量","info":"","props":{"min":1,"precision":0},"validate":[{"required":true,"message":"请输入采购数量"}]},{"type":"inputNumber","field":"amount","title":"预估金额(元)","info":"","props":{"min":0,"precision":2},"validate":[{"required":true,"message":"请输入预估金额"}]},{"type":"textarea","field":"reason","title":"采购事由","info":"","props":{"placeholder":"请简要说明采购背景与用途"},"validate":[{"required":true,"message":"请填写采购事由"}]}]', '{"labelPosition":"right","size":"default"}', 1, 0, 'workflow', NULL, 1, now(), now(), NULL, NULL, 0), +(1007, 'seal_apply', '用章申请', '演示流程"用章申请"的发起表单,部门领导与行政两级审批', '[{"type":"select","field":"seal_type","title":"印章类型","info":"","props":{},"options":[{"label":"公章","value":"official"},{"label":"合同章","value":"contract"},{"label":"财务章","value":"finance"},{"label":"法人章","value":"legal"}],"validate":[{"required":true,"message":"请选择印章类型"}]},{"type":"datePicker","field":"usage_date","title":"用章日期","info":"","props":{"valueFormat":"YYYY-MM-DD"},"validate":[{"required":true,"message":"请选择用章日期"}]},{"type":"textarea","field":"usage","title":"用章事由","info":"","props":{"placeholder":"请说明用章文件及事由"},"validate":[{"required":true,"message":"请填写用章事由"}]}]', '{"labelPosition":"right","size":"default"}', 1, 0, 'workflow', NULL, 1, now(), now(), NULL, NULL, 0); + +-- 按 form_key 幂等纠正 category,确保设计器能检索到全部演示表单 +UPDATE `form_definition` SET category = 'workflow' +WHERE form_key IN ('leave_apply', 'expense_apply', 'purchase_apply', 'seal_apply') + AND category <> 'workflow'; + +-- 预置表单版本快照(固化 v1 规则,防止编辑定义后历史数据回显漂移) +INSERT IGNORE INTO `form_snapshot` (`id`, `form_id`, `version`, `form_json`, `options_json`, `create_time`, `update_time`, `is_deleted`) VALUES +(1004, 1004, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1004), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1004), now(), now(), 0), +(1005, 1005, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1005), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1005), now(), now(), 0), +(1006, 1006, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1006), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1006), now(), now(), 0), +(1007, 1007, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1007), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1007), now(), now(), 0); diff --git a/sql/youlai-admin.sql b/sql/youlai-admin.sql index c5d4de4b..1295cafb 100644 --- a/sql/youlai-admin.sql +++ b/sql/youlai-admin.sql @@ -137,118 +137,127 @@ CREATE TABLE `sys_menu` ( -- ---------------------------- -- Records of sys_menu -- ---------------------------- --- 顶级目录(1-9):系统管理/代码生成/项目文档/功能演示(动态表单目录见 extensions/form.sql) +-- 顶级菜单(基础段 ID=sort 1-6):系统管理(1)/代码生成(2)/通用组件(3)/多级菜单(4)/路由参数(5)/项目资源(6) +-- 扩展段 7-99 由 sql/extensions/ 脚本占用:动态表单(7,dynamic-form.sql)/工作流(8,workflow.sql),新扩展模块从 9 领号顺延 +-- ID 规则:子 ID = 父 ID × 100 + 两位序号(如 10101 即用户管理 101 的第 1 个按钮),tree_path 与 ID 同构可机器校验 INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (1, 0, '0', '系统管理', 'C', '', '/system', 'Layout', NULL, NULL, NULL, 1, 1, 'system', '/system/user', now(), now(), NULL); INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2, 0, '0', '代码生成', 'C', '', '/codegen', 'Layout', NULL, NULL, NULL, 1, 2, 'code', '/codegen/index', now(), now(), NULL); --- 顶级目录:系统管理(1)/代码生成(2)/动态表单(10,见 extensions/form.sql)/项目文档(4)/通用组件(610)/多级菜单(910)/功能演示(6) --- 原"组件封装/功能演示/多级菜单/路由参数"诸顶级收敛为通用组件+多级菜单+功能演示三个顶级,按用户查找意图重新划分 -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (4, 0, '0', '项目文档', 'C', '', '/doc', 'Layout', NULL, NULL, NULL, 1, 4, 'document', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (6, 0, '0', '功能演示', 'C', '', '/demo', 'Layout', NULL, NULL, NULL, 1, 8, 'el-icon-Grid', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (6, 0, '0', '项目资源', 'C', '', '/resource', 'Layout', NULL, NULL, NULL, 1, 6, 'document', '/resource/practice-doc', now(), now(), NULL); -- 系统管理 -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (210, 1, '0,1', '用户管理', 'M', 'User', 'user', 'system/user/index', NULL, NULL, 1, 1, 1, 'el-icon-User', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2101, 210, '0,1,210', '用户查询', 'B', NULL, '', NULL, 'sys:user:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2102, 210, '0,1,210', '用户新增', 'B', NULL, '', NULL, 'sys:user:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2103, 210, '0,1,210', '用户编辑', 'B', NULL, '', NULL, 'sys:user:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2104, 210, '0,1,210', '用户删除', 'B', NULL, '', NULL, 'sys:user:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2105, 210, '0,1,210', '重置密码', 'B', NULL, '', NULL, 'sys:user:reset-password', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2106, 210, '0,1,210', '用户导入', 'B', NULL, '', NULL, 'sys:user:import', NULL, NULL, 1, 6, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2107, 210, '0,1,210', '用户导出', 'B', NULL, '', NULL, 'sys:user:export', NULL, NULL, 1, 7, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (101, 1, '0,1', '用户管理', 'M', 'User', 'user', 'system/user/index', NULL, NULL, 1, 1, 1, 'el-icon-User', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10101, 101, '0,1,101', '用户查询', 'B', NULL, '', NULL, 'sys:user:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10102, 101, '0,1,101', '用户新增', 'B', NULL, '', NULL, 'sys:user:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10103, 101, '0,1,101', '用户编辑', 'B', NULL, '', NULL, 'sys:user:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10104, 101, '0,1,101', '用户删除', 'B', NULL, '', NULL, 'sys:user:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10105, 101, '0,1,101', '重置密码', 'B', NULL, '', NULL, 'sys:user:reset-password', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10106, 101, '0,1,101', '用户导入', 'B', NULL, '', NULL, 'sys:user:import', NULL, NULL, 1, 6, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10107, 101, '0,1,101', '用户导出', 'B', NULL, '', NULL, 'sys:user:export', NULL, NULL, 1, 7, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (220, 1, '0,1', '角色管理', 'M', 'Role', 'role', 'system/role/index', NULL, NULL, 1, 1, 2, 'role', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2201, 220, '0,1,220', '角色查询', 'B', NULL, '', NULL, 'sys:role:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2202, 220, '0,1,220', '角色新增', 'B', NULL, '', NULL, 'sys:role:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2203, 220, '0,1,220', '角色编辑', 'B', NULL, '', NULL, 'sys:role:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2204, 220, '0,1,220', '角色删除', 'B', NULL, '', NULL, 'sys:role:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2205, 220, '0,1,220', '角色分配权限', 'B', NULL, '', NULL, 'sys:role:assign', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (102, 1, '0,1', '角色管理', 'M', 'Role', 'role', 'system/role/index', NULL, NULL, 1, 1, 2, 'role', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10201, 102, '0,1,102', '角色查询', 'B', NULL, '', NULL, 'sys:role:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10202, 102, '0,1,102', '角色新增', 'B', NULL, '', NULL, 'sys:role:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10203, 102, '0,1,102', '角色编辑', 'B', NULL, '', NULL, 'sys:role:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10204, 102, '0,1,102', '角色删除', 'B', NULL, '', NULL, 'sys:role:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10205, 102, '0,1,102', '角色分配权限', 'B', NULL, '', NULL, 'sys:role:assign', NULL, NULL, 1, 5, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (230, 1, '0,1', '菜单管理', 'M', 'SysMenu', 'menu', 'system/menu/index', NULL, NULL, 1, 1, 3, 'menu', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2301, 230, '0,1,230', '菜单查询', 'B', NULL, '', NULL, 'sys:menu:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2302, 230, '0,1,230', '菜单新增', 'B', NULL, '', NULL, 'sys:menu:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2303, 230, '0,1,230', '菜单编辑', 'B', NULL, '', NULL, 'sys:menu:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2304, 230, '0,1,230', '菜单删除', 'B', NULL, '', NULL, 'sys:menu:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (103, 1, '0,1', '菜单管理', 'M', 'SysMenu', 'menu', 'system/menu/index', NULL, NULL, 1, 1, 3, 'menu', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10301, 103, '0,1,103', '菜单查询', 'B', NULL, '', NULL, 'sys:menu:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10302, 103, '0,1,103', '菜单新增', 'B', NULL, '', NULL, 'sys:menu:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10303, 103, '0,1,103', '菜单编辑', 'B', NULL, '', NULL, 'sys:menu:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10304, 103, '0,1,103', '菜单删除', 'B', NULL, '', NULL, 'sys:menu:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (240, 1, '0,1', '部门管理', 'M', 'Dept', 'dept', 'system/dept/index', NULL, NULL, 1, 1, 4, 'tree', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2401, 240, '0,1,240', '部门查询', 'B', NULL, '', NULL, 'sys:dept:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2402, 240, '0,1,240', '部门新增', 'B', NULL, '', NULL, 'sys:dept:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2403, 240, '0,1,240', '部门编辑', 'B', NULL, '', NULL, 'sys:dept:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2404, 240, '0,1,240', '部门删除', 'B', NULL, '', NULL, 'sys:dept:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (104, 1, '0,1', '部门管理', 'M', 'Dept', 'dept', 'system/dept/index', NULL, NULL, 1, 1, 4, 'tree', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10401, 104, '0,1,104', '部门查询', 'B', NULL, '', NULL, 'sys:dept:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10402, 104, '0,1,104', '部门新增', 'B', NULL, '', NULL, 'sys:dept:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10403, 104, '0,1,104', '部门编辑', 'B', NULL, '', NULL, 'sys:dept:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10404, 104, '0,1,104', '部门删除', 'B', NULL, '', NULL, 'sys:dept:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (250, 1, '0,1', '字典管理', 'M', 'Dict', 'dict', 'system/dict/index', NULL, NULL, 1, 1, 5, 'dict', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2501, 250, '0,1,250', '字典查询', 'B', NULL, '', NULL, 'sys:dict:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2502, 250, '0,1,250', '字典新增', 'B', NULL, '', NULL, 'sys:dict:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2503, 250, '0,1,250', '字典编辑', 'B', NULL, '', NULL, 'sys:dict:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2504, 250, '0,1,250', '字典删除', 'B', NULL, '', NULL, 'sys:dict:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (105, 1, '0,1', '字典管理', 'M', 'Dict', 'dict', 'system/dict/index', NULL, NULL, 1, 1, 5, 'dict', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10501, 105, '0,1,105', '字典查询', 'B', NULL, '', NULL, 'sys:dict:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10502, 105, '0,1,105', '字典新增', 'B', NULL, '', NULL, 'sys:dict:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10503, 105, '0,1,105', '字典编辑', 'B', NULL, '', NULL, 'sys:dict:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10504, 105, '0,1,105', '字典删除', 'B', NULL, '', NULL, 'sys:dict:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (251, 1, '0,1', '字典项', 'M', 'DictItem', 'dict-item', 'system/dict/dict-item', NULL, 0, 1, 0, 6, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2511, 251, '0,1,251', '字典项查询', 'B', NULL, '', NULL, 'sys:dict-item:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2512, 251, '0,1,251', '字典项新增', 'B', NULL, '', NULL, 'sys:dict-item:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2513, 251, '0,1,251', '字典项编辑', 'B', NULL, '', NULL, 'sys:dict-item:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2514, 251, '0,1,251', '字典项删除', 'B', NULL, '', NULL, 'sys:dict-item:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (106, 1, '0,1', '字典项', 'M', 'DictItem', 'dict-item', 'system/dict/dict-item', NULL, 0, 1, 0, 6, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10601, 106, '0,1,106', '字典项查询', 'B', NULL, '', NULL, 'sys:dict-item:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10602, 106, '0,1,106', '字典项新增', 'B', NULL, '', NULL, 'sys:dict-item:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10603, 106, '0,1,106', '字典项编辑', 'B', NULL, '', NULL, 'sys:dict-item:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10604, 106, '0,1,106', '字典项删除', 'B', NULL, '', NULL, 'sys:dict-item:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (260, 1, '0,1', '系统日志', 'M', 'Log', 'log', 'system/log/index', NULL, 0, 1, 1, 7, 'document', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2601, 260, '0,1,260', '日志查询', 'B', NULL, '', NULL, 'sys:log:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (107, 1, '0,1', '系统日志', 'M', 'Log', 'log', 'system/log/index', NULL, 0, 1, 1, 7, 'document', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10701, 107, '0,1,107', '日志查询', 'B', NULL, '', NULL, 'sys:log:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (270, 1, '0,1', '系统配置', 'M', 'Config', 'config', 'system/config/index', NULL, 0, 1, 1, 8, 'setting', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2701, 270, '0,1,270', '系统配置查询', 'B', NULL, '', NULL, 'sys:config:list', 0, 1, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2702, 270, '0,1,270', '系统配置新增', 'B', NULL, '', NULL, 'sys:config:create', 0, 1, 1, 2, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2703, 270, '0,1,270', '系统配置修改', 'B', NULL, '', NULL, 'sys:config:update', 0, 1, 1, 3, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2704, 270, '0,1,270', '系统配置删除', 'B', NULL, '', NULL, 'sys:config:delete', 0, 1, 1, 4, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2705, 270, '0,1,270', '系统配置刷新', 'B', NULL, '', NULL, 'sys:config:refresh', 0, 1, 1, 5, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (108, 1, '0,1', '系统配置', 'M', 'Config', 'config', 'system/config/index', NULL, 0, 1, 1, 8, 'setting', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10801, 108, '0,1,108', '系统配置查询', 'B', NULL, '', NULL, 'sys:config:list', 0, 1, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10802, 108, '0,1,108', '系统配置新增', 'B', NULL, '', NULL, 'sys:config:create', 0, 1, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10803, 108, '0,1,108', '系统配置修改', 'B', NULL, '', NULL, 'sys:config:update', 0, 1, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10804, 108, '0,1,108', '系统配置删除', 'B', NULL, '', NULL, 'sys:config:delete', 0, 1, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10805, 108, '0,1,108', '系统配置刷新', 'B', NULL, '', NULL, 'sys:config:refresh', 0, 1, 1, 5, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (280, 1, '0,1', '通知公告', 'M', 'Notice', 'notice', 'system/notice/index', NULL, NULL, NULL, 1, 9, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2801, 280, '0,1,280', '通知查询', 'B', NULL, '', NULL, 'sys:notice:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2802, 280, '0,1,280', '通知新增', 'B', NULL, '', NULL, 'sys:notice:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2803, 280, '0,1,280', '通知编辑', 'B', NULL, '', NULL, 'sys:notice:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2804, 280, '0,1,280', '通知删除', 'B', NULL, '', NULL, 'sys:notice:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2805, 280, '0,1,280', '通知发布', 'B', NULL, '', NULL, 'sys:notice:publish', 0, 1, 1, 5, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2806, 280, '0,1,280', '通知撤回', 'B', NULL, '', NULL, 'sys:notice:revoke', 0, 1, 1, 6, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (109, 1, '0,1', '通知公告', 'M', 'Notice', 'notice', 'system/notice/index', NULL, NULL, NULL, 1, 9, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10901, 109, '0,1,109', '通知查询', 'B', NULL, '', NULL, 'sys:notice:list', NULL, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10902, 109, '0,1,109', '通知新增', 'B', NULL, '', NULL, 'sys:notice:create', NULL, NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10903, 109, '0,1,109', '通知编辑', 'B', NULL, '', NULL, 'sys:notice:update', NULL, NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10904, 109, '0,1,109', '通知删除', 'B', NULL, '', NULL, 'sys:notice:delete', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10905, 109, '0,1,109', '通知发布', 'B', NULL, '', NULL, 'sys:notice:publish', 0, 1, 1, 5, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10906, 109, '0,1,109', '通知撤回', 'B', NULL, '', NULL, 'sys:notice:revoke', 0, 1, 1, 6, '', NULL, now(), now(), NULL); -- 代码生成 -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (310, 2, '0,2', '代码生成', 'M', 'Codegen', 'codegen', 'codegen/index', NULL, NULL, 1, 1, 1, 'code', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (201, 2, '0,2', '代码生成', 'M', 'Codegen', 'index', 'codegen/index', NULL, NULL, 1, 1, 1, 'code', NULL, now(), now(), NULL); --- 项目文档(原"平台文档";删除 501 平台文档(外链)——与 504 同 URL 重复;原顶级"接口文档"(5) 撤级,Apifox 并入) +-- 项目资源(原"项目文档";范围扩展为文档 + 接口 + 源码仓库的资源导航) -- 实战文档双形态置顶(内嵌 iframe + 外链新标签),直观演示系统对两种外链打开方式的支持 -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (504, 4, '0,4', '实战文档(内嵌)', 'E', 'PracticeDoc', 'practice-doc', 'iframe', 'https://juejin.cn/post/7228990409909108793', NULL, NULL, 1, 1, 1, 'el-icon-Document', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (505, 4, '0,4', '实战文档(外链)', 'E', NULL, NULL, NULL, 'https://juejin.cn/post/7228990409909108793', NULL, NULL, NULL, 1, 2, 'el-icon-Link', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (601, 4, '0,4', '接口文档', 'E', 'Apifox', 'apifox', 'iframe', 'https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5', NULL, NULL, 1, 1, 3, 'api', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (502, 4, '0,4', '后端文档', 'E', NULL, NULL, NULL, 'https://youlai.blog.csdn.net/article/details/145178880', NULL, NULL, NULL, 1, 4, 'document', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (503, 4, '0,4', '移动端文档', 'E', NULL, NULL, NULL, 'https://youlai.blog.csdn.net/article/details/143222890', NULL, NULL, NULL, 1, 5, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (601, 6, '0,6', '实战文档(内嵌)', 'E', 'PracticeDoc', 'practice-doc', 'iframe', 'https://juejin.cn/post/7228990409909108793', NULL, NULL, 1, 1, 1, 'el-icon-Document', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (602, 6, '0,6', '实战文档(外链)', 'E', NULL, NULL, NULL, 'https://juejin.cn/post/7228990409909108793', NULL, NULL, NULL, 1, 2, 'el-icon-Link', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (603, 6, '0,6', '接口文档', 'E', 'Apifox', 'apifox', 'iframe', 'https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5', NULL, NULL, 1, 1, 3, 'api', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (604, 6, '0,6', '后端文档', 'E', NULL, NULL, NULL, 'https://youlai.blog.csdn.net/article/details/145178880', NULL, NULL, NULL, 1, 4, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (605, 6, '0,6', '移动端文档', 'E', NULL, NULL, NULL, 'https://youlai.blog.csdn.net/article/details/143222890', NULL, NULL, NULL, 1, 5, 'document', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (606, 6, '0,6', '前端仓库', 'E', NULL, NULL, NULL, 'https://gitee.com/youlaiorg/vue3-element-admin', NULL, NULL, NULL, 1, 6, 'gitee', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (607, 6, '0,6', '后端仓库', 'E', NULL, NULL, NULL, 'https://gitee.com/youlaiorg/youlai-boot', NULL, NULL, NULL, 1, 7, 'gitee', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (608, 6, '0,6', '移动端仓库', 'E', NULL, NULL, NULL, 'https://gitee.com/youlaiorg/vue-uniapp-template', NULL, NULL, NULL, 1, 8, 'gitee', '', now(), now(), NULL); --- ============================ 通用组件(顶级) ============================ --- 前端模板招牌能力聚合,独立顶级便于访客沉淀组件库;平铺基础组件 + 表格组件二级分组 --- 物理目录 views/demo/component(基础组件)与 /table(表格组件)一一对应 -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (610, 0, '0', '通用组件', 'C', '', '/component', 'Layout', NULL, NULL, NULL, 1, 6, 'el-icon-SetUp', '/component/drag', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (701, 610, '0,610', '富文本编辑器', 'M', 'WangEditor', 'wang-editor', 'demo/component/wang-editor', NULL, NULL, 1, 1, 1, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (702, 610, '0,610', '图片上传', 'M', 'Upload', 'upload', 'demo/component/upload', NULL, NULL, 1, 1, 2, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (703, 610, '0,610', '图标选择器', 'M', 'DemoIconSelect', 'icon-select', 'demo/component/icon-select', NULL, NULL, 1, 1, 3, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (704, 610, '0,610', '字典组件', 'M', 'DictDemo', 'dict-demo', 'demo/component/dictionary', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (706, 610, '0,610', '列表选择器', 'M', 'TableSelect', 'table-select', 'demo/component/table-select/index', NULL, NULL, 1, 1, 5, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (707, 610, '0,610', '拖拽组件', 'M', 'Drag', 'drag', 'demo/component/drag', NULL, NULL, NULL, 1, 6, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (708, 610, '0,610', '滚动文本', 'M', 'TextScroll', 'text-scroll', 'demo/component/text-scroll', NULL, NULL, NULL, 1, 7, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (801, 610, '0,610', 'Icons', 'M', 'IconDemo', 'icon-demo', 'demo/component/icons', NULL, NULL, 1, 1, 8, 'el-icon-Notification', '', now(), now(), NULL); +-- ============================ 通用组件(顶级 ID=3) ============================ +-- 前端模板招牌能力聚合,独立顶级便于访客沉淀组件库;按用途三分组:基础/表单/表格 +-- 物理目录 views/demo/component(基础与表单)与 views/demo/table(表格)一一对应 +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (3, 0, '0', '通用组件', 'C', '', '/component', 'Layout', NULL, NULL, NULL, 1, 3, 'el-icon-SetUp', '/component/basic/icon-demo', now(), now(), NULL); --- 表格组件(通用组件下的二级分组,物理目录 views/demo/table 与之对应) -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (620, 610, '0,610', '表格组件', 'C', '', 'table', 'Layout', NULL, 1, NULL, 1, 10, 'el-icon-Grid', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (705, 620, '0,610,620', '增删改查', 'M', 'Curd', 'curd', 'demo/table/curd/index', NULL, NULL, 1, 1, 1, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (804, 620, '0,610,620', 'CURD单文件', 'M', 'CurdSingle', 'curd-single', 'demo/table/curd-single', NULL, NULL, 1, 1, 2, 'el-icon-Reading', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (803, 620, '0,610,620', 'VxeTable', 'M', 'VxeTable', 'vxe-table', 'demo/table/vxe-table/index', NULL, NULL, 1, 1, 3, 'el-icon-MagicStick', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (709, 620, '0,610,620', '自适应表格操作列', 'M', 'AutoOperationColumn', 'operation-column', 'demo/table/auto-operation-column', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); +-- 基础组件(不依附表单/表格场景的通用 UI 能力) +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (301, 3, '0,3', '基础组件', 'C', '', 'basic', 'Layout', NULL, 1, NULL, 1, 1, 'el-icon-Brush', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30101, 301, '0,3,301', 'Icons', 'M', 'IconDemo', 'icon-demo', 'demo/component/icons', NULL, NULL, 1, 1, 1, 'el-icon-Notification', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30102, 301, '0,3,301', '拖拽组件', 'M', 'Drag', 'drag', 'demo/component/drag', NULL, NULL, NULL, 1, 2, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30103, 301, '0,3,301', '滚动文本', 'M', 'TextScroll', 'text-scroll', 'demo/component/text-scroll', NULL, NULL, NULL, 1, 3, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30104, 301, '0,3,301', '字典实时同步', 'M', 'DictSync', 'dict-sync', 'demo/component/dict-sync', NULL, NULL, NULL, 1, 4, '', '', now(), now(), NULL); --- ============================ 多级菜单(顶级) ============================ +-- 表单组件(数据录入场景) +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (302, 3, '0,3', '表单组件', 'C', '', 'form', 'Layout', NULL, 1, NULL, 1, 2, 'el-icon-EditPen', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30201, 302, '0,3,302', '富文本编辑器', 'M', 'WangEditor', 'wang-editor', 'demo/component/wang-editor', NULL, NULL, 1, 1, 1, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30202, 302, '0,3,302', '图片上传', 'M', 'Upload', 'upload', 'demo/component/upload', NULL, NULL, 1, 1, 2, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30203, 302, '0,3,302', '图标选择器', 'M', 'DemoIconSelect', 'icon-select', 'demo/component/icon-select', NULL, NULL, 1, 1, 3, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30204, 302, '0,3,302', '字典组件', 'M', 'DictDemo', 'dict-demo', 'demo/component/dictionary', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30205, 302, '0,3,302', '列表选择器', 'M', 'TableSelect', 'table-select', 'demo/component/table-select/index', NULL, NULL, 1, 1, 5, '', '', now(), now(), NULL); + +-- 表格组件(数据展示场景,物理目录 views/demo/table 与之对应) +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (303, 3, '0,3', '表格组件', 'C', '', 'table', 'Layout', NULL, 1, NULL, 1, 3, 'el-icon-Grid', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30301, 303, '0,3,303', '增删改查', 'M', 'Curd', 'curd', 'demo/table/curd/index', NULL, NULL, 1, 1, 1, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30302, 303, '0,3,303', 'CURD单文件', 'M', 'CurdSingle', 'curd-single', 'demo/table/curd-single', NULL, NULL, 1, 1, 2, 'el-icon-Reading', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30303, 303, '0,3,303', 'VxeTable', 'M', 'VxeTable', 'vxe-table', 'demo/table/vxe-table/index', NULL, NULL, 1, 1, 3, 'el-icon-MagicStick', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (30304, 303, '0,3,303', '自适应表格操作列', 'M', 'AutoOperationColumn', 'operation-column', 'demo/table/auto-operation-column', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL); + +-- ============================ 多级菜单(顶级 ID=4) ============================ -- 独立顶级,直观演示菜单驱动动态路由的嵌套能力,避免折叠导致顶级菜单不可见 -- 物理目录 views/demo/route/multi-level 与之对应 -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910, 0, '0', '多级菜单', 'C', NULL, '/multi-level', 'Layout', NULL, 1, NULL, 1, 7, 'el-icon-Guide', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (911, 910, '0,910', '一级菜单', 'C', NULL, 'level-one', 'Layout', NULL, 1, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (912, 911, '0,910,911', '二级菜单', 'C', NULL, 'level-two', 'Layout', NULL, 0, NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (913, 912, '0,910,911,912', '三级菜单 A', 'M', 'MultiLevelLevelThreeA', 'level-three-a', 'demo/route/multi-level/level-one/level-two/level-three-a/index', NULL, 0, 1, 1, 1, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (914, 912, '0,910,911,912', '三级菜单 B', 'M', 'MultiLevelLevelThreeB', 'level-three-b', 'demo/route/multi-level/level-one/level-two/level-three-b/index', NULL, 0, 1, 1, 2, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (4, 0, '0', '多级菜单', 'C', '', '/multi-level', 'Layout', NULL, 1, NULL, 1, 4, 'el-icon-Guide', '/multi-level/level-one/level-two/level-three-a', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (401, 4, '0,4', '一级菜单', 'C', '', 'level-one', 'Layout', NULL, 1, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40101, 401, '0,4,401', '二级菜单', 'C', '', 'level-two', 'Layout', NULL, 0, NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (4010101, 40101, '0,4,401,40101', '三级菜单 A', 'M', 'MultiLevelLevelThreeA', 'level-three-a', 'demo/route/multi-level/level-one/level-two/level-three-a/index', NULL, 0, 1, 1, 1, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (4010102, 40101, '0,4,401,40101', '三级菜单 B', 'M', 'MultiLevelLevelThreeB', 'level-three-b', 'demo/route/multi-level/level-one/level-two/level-three-b/index', NULL, 0, 1, 1, 2, '', '', now(), now(), NULL); --- ============================ 功能演示(顶级,承接受窄内容) ============================ --- 收窄为行为/功能演示:字典实时同步 + 路由参数(详情页缓存经增删改查详情 /detail/:id 进入,无独立菜单) -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (802, 6, '0,6', '字典实时同步', 'M', 'DictSync', 'dict-sync', 'demo/component/dict-sync', NULL, NULL, NULL, 1, 1, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (1001, 6, '0,6', '路由参数(type=1)', 'M', 'RouteParamType1', 'route-param-type1', 'demo/route/route-param', NULL, 0, 1, 1, 2, 'el-icon-Star', NULL, now(), now(), '{\"type\": \"1\"}'); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (1002, 6, '0,6', '路由参数(type=2)', 'M', 'RouteParamType2', 'route-param-type2', 'demo/route/route-param', NULL, 0, 1, 1, 3, 'el-icon-StarFilled', NULL, now(), now(), '{\"type\": \"2\"}'); +-- ============================ 路由参数(顶级 ID=5) ============================ +-- 同一页面按 params 区分菜单入口的传参演示;详情页缓存经增删改查详情 /detail/:id 进入,无独立菜单 +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (5, 0, '0', '路由参数', 'C', '', '/route-param', 'Layout', NULL, 1, NULL, 1, 5, 'el-icon-Share', '/route-param/route-param-type1', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (501, 5, '0,5', '路由参数(type=1)', 'M', 'RouteParamType1', 'route-param-type1', 'demo/route/route-param', NULL, 0, 1, 1, 1, 'el-icon-Star', NULL, now(), now(), '{\"type\": \"1\"}'); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (502, 5, '0,5', '路由参数(type=2)', 'M', 'RouteParamType2', 'route-param-type2', 'demo/route/route-param', NULL, 0, 1, 1, 2, 'el-icon-StarFilled', NULL, now(), now(), '{\"type\": \"2\"}'); -- ---------------------------- -- Table structure for sys_role @@ -309,44 +318,45 @@ INSERT IGNORE INTO `sys_role_dept` VALUES (7, 1); INSERT IGNORE INTO `sys_role_dept` VALUES (7, 2); -- ============================================ --- 系统管理员角色菜单权限(role_id=2) --- 顶级目录(5/7/8/9 已撤级合并,授权同步收敛) -INSERT INTO `sys_role_menu` VALUES (2, 1), (2, 2), (2, 4), (2, 6); +-- 系统管理员角色菜单权限(role_id=2,基础段顶级 1-6 全量) +INSERT INTO `sys_role_menu` VALUES (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (2, 6); -- 系统管理 -INSERT INTO `sys_role_menu` VALUES (2, 210), (2, 2101), (2, 2102), (2, 2103), (2, 2104), (2, 2105), (2, 2106), (2, 2107); -INSERT INTO `sys_role_menu` VALUES (2, 220), (2, 2201), (2, 2202), (2, 2203), (2, 2204), (2, 2205); -INSERT INTO `sys_role_menu` VALUES (2, 230), (2, 2301), (2, 2302), (2, 2303), (2, 2304); -INSERT INTO `sys_role_menu` VALUES (2, 240), (2, 2401), (2, 2402), (2, 2403), (2, 2404); -INSERT INTO `sys_role_menu` VALUES (2, 250), (2, 2501), (2, 2502), (2, 2503), (2, 2504); -INSERT INTO `sys_role_menu` VALUES (2, 251), (2, 2511), (2, 2512), (2, 2513), (2, 2514); -INSERT INTO `sys_role_menu` VALUES (2, 260), (2, 2601); -INSERT INTO `sys_role_menu` VALUES (2, 270), (2, 2701), (2, 2702), (2, 2703), (2, 2704), (2, 2705); -INSERT INTO `sys_role_menu` VALUES (2, 280), (2, 2801), (2, 2802), (2, 2803), (2, 2804), (2, 2805), (2, 2806); +INSERT INTO `sys_role_menu` VALUES (2, 101), (2, 10101), (2, 10102), (2, 10103), (2, 10104), (2, 10105), (2, 10106), (2, 10107); +INSERT INTO `sys_role_menu` VALUES (2, 102), (2, 10201), (2, 10202), (2, 10203), (2, 10204), (2, 10205); +INSERT INTO `sys_role_menu` VALUES (2, 103), (2, 10301), (2, 10302), (2, 10303), (2, 10304); +INSERT INTO `sys_role_menu` VALUES (2, 104), (2, 10401), (2, 10402), (2, 10403), (2, 10404); +INSERT INTO `sys_role_menu` VALUES (2, 105), (2, 10501), (2, 10502), (2, 10503), (2, 10504); +INSERT INTO `sys_role_menu` VALUES (2, 106), (2, 10601), (2, 10602), (2, 10603), (2, 10604); +INSERT INTO `sys_role_menu` VALUES (2, 107), (2, 10701); +INSERT INTO `sys_role_menu` VALUES (2, 108), (2, 10801), (2, 10802), (2, 10803), (2, 10804), (2, 10805); +INSERT INTO `sys_role_menu` VALUES (2, 109), (2, 10901), (2, 10902), (2, 10903), (2, 10904), (2, 10905), (2, 10906); INSERT IGNORE INTO `sys_role_menu` VALUES (4, 1); -INSERT IGNORE INTO `sys_role_menu` VALUES (4, 210), (4, 2101), (4, 2102), (4, 2103), (4, 2104), (4, 2105), (4, 2106), (4, 2107); -INSERT IGNORE INTO `sys_role_menu` VALUES (4, 220), (4, 2201), (4, 2202), (4, 2203), (4, 2204), (4, 2205); +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 101), (4, 10101), (4, 10102), (4, 10103), (4, 10104), (4, 10105), (4, 10106), (4, 10107); +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 102), (4, 10201), (4, 10202), (4, 10203), (4, 10204), (4, 10205); INSERT IGNORE INTO `sys_role_menu` VALUES (5, 1); -INSERT IGNORE INTO `sys_role_menu` VALUES (5, 210), (5, 2101), (5, 2102), (5, 2103), (5, 2104), (5, 2105), (5, 2106), (5, 2107); -INSERT IGNORE INTO `sys_role_menu` VALUES (5, 220), (5, 2201), (5, 2202), (5, 2203), (5, 2204), (5, 2205); +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 101), (5, 10101), (5, 10102), (5, 10103), (5, 10104), (5, 10105), (5, 10106), (5, 10107); +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 102), (5, 10201), (5, 10202), (5, 10203), (5, 10204), (5, 10205); INSERT IGNORE INTO `sys_role_menu` VALUES (6, 1); -INSERT IGNORE INTO `sys_role_menu` VALUES (6, 210), (6, 2101), (6, 2102), (6, 2103), (6, 2104), (6, 2105), (6, 2106), (6, 2107); -INSERT IGNORE INTO `sys_role_menu` VALUES (6, 220), (6, 2201), (6, 2202), (6, 2203), (6, 2204), (6, 2205); +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 101), (6, 10101), (6, 10102), (6, 10103), (6, 10104), (6, 10105), (6, 10106), (6, 10107); +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 102), (6, 10201), (6, 10202), (6, 10203), (6, 10204), (6, 10205); INSERT IGNORE INTO `sys_role_menu` VALUES (7, 1); -INSERT IGNORE INTO `sys_role_menu` VALUES (7, 210), (7, 2101), (7, 2102), (7, 2103), (7, 2104), (7, 2105), (7, 2106), (7, 2107); -INSERT IGNORE INTO `sys_role_menu` VALUES (7, 220), (7, 2201), (7, 2202), (7, 2203), (7, 2204), (7, 2205); +INSERT IGNORE INTO `sys_role_menu` VALUES (7, 101), (7, 10101), (7, 10102), (7, 10103), (7, 10104), (7, 10105), (7, 10106), (7, 10107); +INSERT IGNORE INTO `sys_role_menu` VALUES (7, 102), (7, 10201), (7, 10202), (7, 10203), (7, 10204), (7, 10205); -- 代码生成 -INSERT INTO `sys_role_menu` VALUES (2, 310); --- 项目文档(501 已删除;601 随"接口文档"撤级并入顶级 4;505 实战文档外链) -INSERT INTO `sys_role_menu` VALUES (2, 502), (2, 503), (2, 504), (2, 505), (2, 601); --- 通用组件(610 顶级 / 620 表格组件分组)/ 多级菜单(910)/ -INSERT INTO `sys_role_menu` VALUES (2, 610), (2, 620); -INSERT INTO `sys_role_menu` VALUES (2, 701), (2, 702), (2, 703), (2, 704), (2, 705), (2, 706), (2, 707), (2, 708), (2, 709); -INSERT INTO `sys_role_menu` VALUES (2, 801), (2, 802), (2, 803), (2, 804), (2, 910), (2, 911), (2, 912), (2, 913), (2, 914); -INSERT INTO `sys_role_menu` VALUES (2, 1001), (2, 1002); +INSERT INTO `sys_role_menu` VALUES (2, 201); +-- 项目资源 +INSERT INTO `sys_role_menu` VALUES (2, 601), (2, 602), (2, 603), (2, 604), (2, 605), (2, 606), (2, 607), (2, 608); +-- 通用组件(301 基础 / 302 表单 / 303 表格) +INSERT INTO `sys_role_menu` VALUES (2, 301), (2, 30101), (2, 30102), (2, 30103), (2, 30104); +INSERT INTO `sys_role_menu` VALUES (2, 302), (2, 30201), (2, 30202), (2, 30203), (2, 30204), (2, 30205); +INSERT INTO `sys_role_menu` VALUES (2, 303), (2, 30301), (2, 30302), (2, 30303), (2, 30304); +-- 多级菜单 / 路由参数 +INSERT INTO `sys_role_menu` VALUES (2, 401), (2, 40101), (2, 4010101), (2, 4010102); +INSERT INTO `sys_role_menu` VALUES (2, 501), (2, 502); -- ---------------------------- -- Table structure for sys_user @@ -374,7 +384,7 @@ CREATE TABLE `sys_user` ( -- ---------------------------- -- Records of sys_user -- ---------------------------- -INSERT INTO `sys_user` VALUES (1, 'root', '有来技术', 0, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', NULL, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345677', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); +INSERT INTO `sys_user` VALUES (1, 'youlai', '有来技术', 0, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', NULL, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345677', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); INSERT INTO `sys_user` VALUES (2, 'admin', '系统管理员', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18888888888', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); INSERT INTO `sys_user` VALUES (3, 'test', '测试小用户', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 3, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345679', 1, 'youlaitech@163.com', now(), NULL, now(), NULL, 0); INSERT INTO `sys_user` VALUES (4, 'dept_manager', '部门主管', 1, '$2a$10$xVWsNOhHrCxh5UbpCE7/HuJ.PAOKcYAqRxD2CO2nVnJS.IAXkr5aq', 1, 'https://foruda.gitee.com/images/1723603502796844527/03cdca2a_716974.gif', '18812345680', 1, 'manager@youlaitech.com', now(), NULL, now(), NULL, 0); diff --git a/src/main/java/com/youlai/boot/YouLaiBootApplication.java b/src/main/java/com/youlai/boot/YouLaiBootApplication.java index 1b2761fd..b5f11c68 100644 --- a/src/main/java/com/youlai/boot/YouLaiBootApplication.java +++ b/src/main/java/com/youlai/boot/YouLaiBootApplication.java @@ -1,17 +1,34 @@ package com.youlai.boot; import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.scheduling.annotation.EnableScheduling; /** * 应用启动类 * + *

等价于 @SpringBootApplication,但显式声明 @ComponentScan 以排除工作流包, + * 改由 {@link com.youlai.boot.module.config.WorkflowModuleConfig} 按 flowable.enabled 决定是否扫描

+ * * @author Ray.Hao * @since 3.0.0 */ +@ConfigurationPropertiesScan @EnableScheduling -@SpringBootApplication +@SpringBootConfiguration +@EnableAutoConfiguration +@ComponentScan(basePackages = "com.youlai.boot", excludeFilters = { + // 保留 @SpringBootApplication 默认的排除器 + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "com\\.youlai\\.boot\\.module\\.workflow\\..*") +}) public class YouLaiBootApplication { public static void main(String[] args) { diff --git a/src/main/java/com/youlai/boot/auth/enums/CaptchaTypeEnum.java b/src/main/java/com/youlai/boot/auth/enums/CaptchaTypeEnum.java deleted file mode 100644 index ab9972a6..00000000 --- a/src/main/java/com/youlai/boot/auth/enums/CaptchaTypeEnum.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.youlai.boot.auth.enums; - -/** - * EasyCaptcha 验证码类型 - * - * @author haoxr - * @since 2.5.1 - */ -public enum CaptchaTypeEnum { - CIRCLE, - GIF, - LINE, - SHEAR -} diff --git a/src/main/java/com/youlai/boot/config/CaptchaConfig.java b/src/main/java/com/youlai/boot/auth/password/config/CaptchaConfig.java similarity index 91% rename from src/main/java/com/youlai/boot/config/CaptchaConfig.java rename to src/main/java/com/youlai/boot/auth/password/config/CaptchaConfig.java index d5a68e1f..75d25261 100644 --- a/src/main/java/com/youlai/boot/config/CaptchaConfig.java +++ b/src/main/java/com/youlai/boot/auth/password/config/CaptchaConfig.java @@ -1,9 +1,8 @@ -package com.youlai.boot.config; +package com.youlai.boot.auth.password.config; import cn.hutool.captcha.generator.CodeGenerator; import cn.hutool.captcha.generator.MathGenerator; import cn.hutool.captcha.generator.RandomGenerator; -import com.youlai.boot.config.property.CaptchaProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,8 +12,8 @@ import java.awt.*; /** * 验证码自动装配配置 * - * @author haoxr - * @since 2023/11/24 + * @author Ray.Hao + * @since 1.0.0 */ @Configuration public class CaptchaConfig { diff --git a/src/main/java/com/youlai/boot/config/property/CaptchaProperties.java b/src/main/java/com/youlai/boot/auth/password/config/CaptchaProperties.java similarity index 93% rename from src/main/java/com/youlai/boot/config/property/CaptchaProperties.java rename to src/main/java/com/youlai/boot/auth/password/config/CaptchaProperties.java index 4067d51e..e1b10c4b 100644 --- a/src/main/java/com/youlai/boot/config/property/CaptchaProperties.java +++ b/src/main/java/com/youlai/boot/auth/password/config/CaptchaProperties.java @@ -1,16 +1,14 @@ -package com.youlai.boot.config.property; +package com.youlai.boot.auth.password.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; /** - * 验证码 属性配置 + * 验证码配置属性 * * @author Ray.Hao * @since 2023/11/24 */ -@Component @ConfigurationProperties(prefix = "captcha") @Data public class CaptchaProperties { diff --git a/src/main/java/com/youlai/boot/auth/password/controller/PasswordAuthController.java b/src/main/java/com/youlai/boot/auth/password/controller/PasswordAuthController.java new file mode 100644 index 00000000..d0e74cb4 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/password/controller/PasswordAuthController.java @@ -0,0 +1,52 @@ +package com.youlai.boot.auth.password.controller; + +import com.youlai.boot.auth.password.model.form.LoginForm; +import com.youlai.boot.auth.password.model.vo.CaptchaVO; +import com.youlai.boot.auth.password.service.PasswordAuthService; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.web.ratelimit.RateLimit; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 账号密码认证接口 + * + * @author Ray.Hao + * @since 4.8.0 + */ +@Tag(name = "01.认证中心") +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class PasswordAuthController { + + private final PasswordAuthService passwordAuthService; + + @Operation(summary = "获取验证码") + @GetMapping("/captcha") + public Result getCaptcha() { + CaptchaVO captcha = passwordAuthService.getCaptcha(); + return Result.success(captcha); + } + + @Operation(summary = "账号密码登录") + @PostMapping("/login") + @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) + @RateLimit + public Result login(@RequestBody @Valid LoginForm request) { + AuthenticationToken authenticationToken = passwordAuthService.login(request.getUsername(), request.getPassword()); + return Result.success(authenticationToken); + } + +} diff --git a/src/main/java/com/youlai/boot/auth/password/enums/CaptchaTypeEnum.java b/src/main/java/com/youlai/boot/auth/password/enums/CaptchaTypeEnum.java new file mode 100644 index 00000000..fdcc70f5 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/password/enums/CaptchaTypeEnum.java @@ -0,0 +1,22 @@ +package com.youlai.boot.auth.password.enums; + +/** + * 验证码类型 + * + * @author Ray.Hao + * @since 2.5.1 + */ +public enum CaptchaTypeEnum { + + /** 圆圈干扰验证码 */ + CIRCLE, + + /** Gif 动画验证码 */ + GIF, + + /** 干扰线验证码 */ + LINE, + + /** 扭曲干扰验证码 */ + SHEAR +} diff --git a/src/main/java/com/youlai/boot/auth/exception/CaptchaException.java b/src/main/java/com/youlai/boot/auth/password/exception/CaptchaException.java similarity index 90% rename from src/main/java/com/youlai/boot/auth/exception/CaptchaException.java rename to src/main/java/com/youlai/boot/auth/password/exception/CaptchaException.java index 78702000..9c5c6b6d 100644 --- a/src/main/java/com/youlai/boot/auth/exception/CaptchaException.java +++ b/src/main/java/com/youlai/boot/auth/password/exception/CaptchaException.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.exception; +package com.youlai.boot.auth.password.exception; import com.youlai.boot.common.result.ResultCode; import lombok.Getter; diff --git a/src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java b/src/main/java/com/youlai/boot/auth/password/filter/CaptchaValidationFilter.java similarity index 93% rename from src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java rename to src/main/java/com/youlai/boot/auth/password/filter/CaptchaValidationFilter.java index 67423451..201ab338 100644 --- a/src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java +++ b/src/main/java/com/youlai/boot/auth/password/filter/CaptchaValidationFilter.java @@ -1,13 +1,13 @@ -package com.youlai.boot.auth.security.filter; +package com.youlai.boot.auth.password.filter; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; +import com.youlai.boot.auth.password.exception.CaptchaException; +import com.youlai.boot.auth.password.service.CaptchaService; import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.web.util.ResponseWriter; -import com.youlai.boot.auth.exception.CaptchaException; -import com.youlai.boot.auth.service.CaptchaService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.ServletInputStream; @@ -30,6 +30,10 @@ import java.nio.charset.StandardCharsets; /** * 图形验证码校验过滤器 + *

+ * 仅拦截账号密码登录请求:先校验验证码,校验通过后把请求体包装成可重复读取的 Request 再放行。 + * + * @author Ray.Hao */ public class CaptchaValidationFilter extends OncePerRequestFilter { diff --git a/src/main/java/com/youlai/boot/auth/model/req/LoginReq.java b/src/main/java/com/youlai/boot/auth/password/model/form/LoginForm.java similarity index 84% rename from src/main/java/com/youlai/boot/auth/model/req/LoginReq.java rename to src/main/java/com/youlai/boot/auth/password/model/form/LoginForm.java index bb8c9002..c60c1289 100644 --- a/src/main/java/com/youlai/boot/auth/model/req/LoginReq.java +++ b/src/main/java/com/youlai/boot/auth/password/model/form/LoginForm.java @@ -1,15 +1,17 @@ -package com.youlai.boot.auth.model.req; +package com.youlai.boot.auth.password.model.form; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Data; /** - * 登录请求参数 + * 账号密码登录请求参数 + * + * @author Ray.Hao */ @Schema(description = "登录请求参数") @Data -public class LoginReq { +public class LoginForm { @Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") @NotBlank(message = "用户名不能为空") diff --git a/src/main/java/com/youlai/boot/auth/model/resp/CaptchaInfo.java b/src/main/java/com/youlai/boot/auth/password/model/vo/CaptchaVO.java similarity index 82% rename from src/main/java/com/youlai/boot/auth/model/resp/CaptchaInfo.java rename to src/main/java/com/youlai/boot/auth/password/model/vo/CaptchaVO.java index 8897c982..e4189e4f 100644 --- a/src/main/java/com/youlai/boot/auth/model/resp/CaptchaInfo.java +++ b/src/main/java/com/youlai/boot/auth/password/model/vo/CaptchaVO.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.model.resp; +package com.youlai.boot.auth.password.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -8,13 +8,15 @@ import lombok.NoArgsConstructor; /** * 验证码信息 + * + * @author Ray.Hao */ @Data @Builder @NoArgsConstructor @AllArgsConstructor @Schema(description = "验证码信息") -public class CaptchaInfo { +public class CaptchaVO { @Schema(description = "验证码缓存ID") private String captchaId; diff --git a/src/main/java/com/youlai/boot/auth/service/CaptchaService.java b/src/main/java/com/youlai/boot/auth/password/service/CaptchaService.java similarity index 89% rename from src/main/java/com/youlai/boot/auth/service/CaptchaService.java rename to src/main/java/com/youlai/boot/auth/password/service/CaptchaService.java index 4ce21b58..ec188074 100644 --- a/src/main/java/com/youlai/boot/auth/service/CaptchaService.java +++ b/src/main/java/com/youlai/boot/auth/password/service/CaptchaService.java @@ -1,16 +1,16 @@ -package com.youlai.boot.auth.service; +package com.youlai.boot.auth.password.service; import cn.hutool.captcha.AbstractCaptcha; import cn.hutool.captcha.CaptchaUtil; import cn.hutool.captcha.generator.CodeGenerator; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; +import com.youlai.boot.auth.password.config.CaptchaProperties; +import com.youlai.boot.auth.password.enums.CaptchaTypeEnum; +import com.youlai.boot.auth.password.exception.CaptchaException; +import com.youlai.boot.auth.password.model.vo.CaptchaVO; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.auth.enums.CaptchaTypeEnum; import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.config.property.CaptchaProperties; -import com.youlai.boot.auth.exception.CaptchaException; -import com.youlai.boot.auth.model.resp.CaptchaInfo; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -35,8 +35,10 @@ public class CaptchaService { /** * 生成验证码 + * + * @return 验证码ID与图片Base64 */ - public CaptchaInfo generate() { + public CaptchaVO generate() { String captchaType = captchaProperties.getType(); int width = captchaProperties.getWidth(); int height = captchaProperties.getHeight(); @@ -71,7 +73,7 @@ public class CaptchaService { TimeUnit.SECONDS ); - return CaptchaInfo.builder() + return CaptchaVO.builder() .captchaId(captchaId) .captchaBase64(imageBase64Data) .build(); diff --git a/src/main/java/com/youlai/boot/auth/password/service/PasswordAuthService.java b/src/main/java/com/youlai/boot/auth/password/service/PasswordAuthService.java new file mode 100644 index 00000000..e969bbec --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/password/service/PasswordAuthService.java @@ -0,0 +1,63 @@ +package com.youlai.boot.auth.password.service; + +import com.youlai.boot.auth.password.model.vo.CaptchaVO; +import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.token.TokenManager; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +/** + * 账号密码登录服务 + * + * @author Ray.Hao + * @since 4.8.0 + */ +@Service +@RequiredArgsConstructor +public class PasswordAuthService { + + private final AuthenticationManager authenticationManager; + private final TokenManager tokenManager; + private final CaptchaService captchaService; + + /** + * 用户名密码登录 + * + * @param username 用户名 + * @param password 密码 + * @return 访问令牌 + */ + public AuthenticationToken login(String username, String password) { + // 1. 创建用于密码认证的令牌(未认证) + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(username.trim(), password); + + // 2. 执行认证(认证中) + // 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。 + // 默认情况下会委托给 DaoAuthenticationProvider: + // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现) + // 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配) + // 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。 + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) + AuthenticationToken authenticationTokenResponse = + tokenManager.generateToken(authentication); + SecurityContextHolder.getContext().setAuthentication(authentication); + return authenticationTokenResponse; + } + + /** + * 获取验证码 + * + * @return 验证码ID与图片Base64 + */ + public CaptchaVO getCaptcha() { + return captchaService.generate(); + } + +} diff --git a/src/main/java/com/youlai/boot/auth/controller/QrCodeAuthController.java b/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeAuthController.java similarity index 76% rename from src/main/java/com/youlai/boot/auth/controller/QrCodeAuthController.java rename to src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeAuthController.java index 221ef724..773fbebe 100644 --- a/src/main/java/com/youlai/boot/auth/controller/QrCodeAuthController.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeAuthController.java @@ -1,14 +1,14 @@ -package com.youlai.boot.auth.controller; +package com.youlai.boot.auth.qrcode.controller; -import com.youlai.boot.auth.model.req.QrCodeTicketReq; -import com.youlai.boot.auth.model.resp.QrCodeGenerateResp; -import com.youlai.boot.auth.model.resp.QrCodeStatusResp; -import com.youlai.boot.auth.service.QrCodeLoginService; -import com.youlai.boot.common.util.IPUtils; -import com.youlai.boot.common.annotation.RateLimit; +import com.youlai.boot.auth.qrcode.model.form.QrCodeTicketForm; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO; +import com.youlai.boot.auth.qrcode.service.QrCodeLoginService; import com.youlai.boot.common.result.Result; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.framework.web.ratelimit.RateLimit; +import com.youlai.boot.framework.web.util.IPUtils; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -44,7 +44,7 @@ public class QrCodeAuthController { @Operation(summary = "[扫码]生成扫码登录票据") @PostMapping("/generate") @RateLimit(limit = 30, window = 60) - public Result generate(HttpServletRequest request) { + public Result generate(HttpServletRequest request) { return Result.success(qrCodeLoginService.generate(IPUtils.getIpAddr(request))); } @@ -54,7 +54,7 @@ public class QrCodeAuthController { @Operation(summary = "[扫码]查询扫码状态") @GetMapping("/status") @RateLimit(limit = 60, window = 60) - public Result status(@RequestParam String ticket) { + public Result status(@RequestParam String ticket) { return Result.success(qrCodeLoginService.status(ticket)); } @@ -63,7 +63,7 @@ public class QrCodeAuthController { */ @Operation(summary = "[扫码]APP 标记已扫码") @PostMapping("/scan") - public Result scan(@RequestBody @Valid QrCodeTicketReq form) { + public Result scan(@RequestBody @Valid QrCodeTicketForm form) { return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId())); } @@ -72,7 +72,7 @@ public class QrCodeAuthController { */ @Operation(summary = "[扫码]APP 确认登录") @PostMapping("/confirm") - public Result confirm(@RequestBody @Valid QrCodeTicketReq form) { + public Result confirm(@RequestBody @Valid QrCodeTicketForm form) { return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId())); } @@ -81,7 +81,7 @@ public class QrCodeAuthController { */ @Operation(summary = "[扫码]APP 取消登录") @PostMapping("/cancel") - public Result cancel(@RequestBody @Valid QrCodeTicketReq form) { + public Result cancel(@RequestBody @Valid QrCodeTicketForm form) { return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId())); } @@ -90,7 +90,7 @@ public class QrCodeAuthController { */ @Operation(summary = "[扫码]PC 端换取会话令牌") @PostMapping("/login") - public Result login(@RequestBody @Valid QrCodeTicketReq form) { + public Result login(@RequestBody @Valid QrCodeTicketForm form) { return Result.success(qrCodeLoginService.login(form.getTicket())); } } diff --git a/src/main/java/com/youlai/boot/auth/enums/QrCodeLoginStatusEnum.java b/src/main/java/com/youlai/boot/auth/qrcode/enums/QrCodeLoginStatusEnum.java similarity index 92% rename from src/main/java/com/youlai/boot/auth/enums/QrCodeLoginStatusEnum.java rename to src/main/java/com/youlai/boot/auth/qrcode/enums/QrCodeLoginStatusEnum.java index 181bca5d..94c6d37e 100644 --- a/src/main/java/com/youlai/boot/auth/enums/QrCodeLoginStatusEnum.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/enums/QrCodeLoginStatusEnum.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.enums; +package com.youlai.boot.auth.qrcode.enums; /** * 扫码登录票据状态 diff --git a/src/main/java/com/youlai/boot/auth/model/dto/QrCodeLoginContext.java b/src/main/java/com/youlai/boot/auth/qrcode/model/context/QrCodeLoginContext.java similarity index 83% rename from src/main/java/com/youlai/boot/auth/model/dto/QrCodeLoginContext.java rename to src/main/java/com/youlai/boot/auth/qrcode/model/context/QrCodeLoginContext.java index 357b67d6..95885e0d 100644 --- a/src/main/java/com/youlai/boot/auth/model/dto/QrCodeLoginContext.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/context/QrCodeLoginContext.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.model.dto; +package com.youlai.boot.auth.qrcode.model.context; import lombok.Data; @@ -14,7 +14,7 @@ public class QrCodeLoginContext { /** 票据,UUID 无连字符 */ private String ticket; - /** 状态枚举名,取 {@link com.youlai.boot.auth.enums.QrCodeLoginStatusEnum#name()} */ + /** 状态枚举名,取 {@link com.youlai.boot.auth.qrcode.enums.QrCodeLoginStatusEnum#name()} */ private String status; /** 扫码用户 ID,scan 时写入 */ diff --git a/src/main/java/com/youlai/boot/auth/model/req/QrCodeTicketReq.java b/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java similarity index 77% rename from src/main/java/com/youlai/boot/auth/model/req/QrCodeTicketReq.java rename to src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java index 9e8e86d1..a847b159 100644 --- a/src/main/java/com/youlai/boot/auth/model/req/QrCodeTicketReq.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/form/QrCodeTicketForm.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.model.req; +package com.youlai.boot.auth.qrcode.model.form; import jakarta.validation.constraints.NotBlank; import lombok.Data; @@ -10,7 +10,7 @@ import lombok.Data; * @since 4.5.0 */ @Data -public class QrCodeTicketReq { +public class QrCodeTicketForm { @NotBlank(message = "票据不能为空") private String ticket; diff --git a/src/main/java/com/youlai/boot/auth/model/resp/QrCodeGenerateResp.java b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java similarity index 83% rename from src/main/java/com/youlai/boot/auth/model/resp/QrCodeGenerateResp.java rename to src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java index dc830b15..5982b0dd 100644 --- a/src/main/java/com/youlai/boot/auth/model/resp/QrCodeGenerateResp.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeGenerateVO.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.model.resp; +package com.youlai.boot.auth.qrcode.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -13,7 +13,7 @@ import lombok.Data; @Data @Builder @Schema(description = "扫码票据生成结果") -public class QrCodeGenerateResp { +public class QrCodeGenerateVO { @Schema(description = "票据") private String ticket; diff --git a/src/main/java/com/youlai/boot/auth/model/resp/QrCodeStatusResp.java b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java similarity index 90% rename from src/main/java/com/youlai/boot/auth/model/resp/QrCodeStatusResp.java rename to src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java index b2ea3a2d..d3ef62b7 100644 --- a/src/main/java/com/youlai/boot/auth/model/resp/QrCodeStatusResp.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/vo/QrCodeStatusVO.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.model.resp; +package com.youlai.boot.auth.qrcode.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -13,7 +13,7 @@ import lombok.Data; @Data @Builder @Schema(description = "扫码登录状态") -public class QrCodeStatusResp { +public class QrCodeStatusVO { @Schema(description = "票据") private String ticket; diff --git a/src/main/java/com/youlai/boot/auth/service/QrCodeLoginService.java b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java similarity index 92% rename from src/main/java/com/youlai/boot/auth/service/QrCodeLoginService.java rename to src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java index cd807d75..66ed0e86 100644 --- a/src/main/java/com/youlai/boot/auth/service/QrCodeLoginService.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java @@ -1,14 +1,14 @@ -package com.youlai.boot.auth.service; +package com.youlai.boot.auth.qrcode.service; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; -import com.youlai.boot.auth.enums.QrCodeLoginStatusEnum; -import com.youlai.boot.auth.model.dto.QrCodeLoginContext; -import com.youlai.boot.auth.model.resp.QrCodeGenerateResp; -import com.youlai.boot.auth.model.resp.QrCodeStatusResp; +import com.youlai.boot.auth.qrcode.enums.QrCodeLoginStatusEnum; +import com.youlai.boot.auth.qrcode.model.context.QrCodeLoginContext; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO; +import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.exception.TokenInvalidException; +import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.model.SecurityUserDetails; @@ -54,7 +54,7 @@ public class QrCodeLoginService { /** * 生成扫码票据,写入 Redis 后返回 ticket 和有效期 */ - public QrCodeGenerateResp generate(String clientIp) { + public QrCodeGenerateVO generate(String clientIp) { String ticket = IdUtil.fastSimpleUUID(); QrCodeLoginContext ctx = new QrCodeLoginContext(); ctx.setTicket(ticket); @@ -62,7 +62,7 @@ public class QrCodeLoginService { ctx.setCreatedAt(System.currentTimeMillis()); ctx.setClientIp(clientIp); save(ctx, DEFAULT_EXPIRE_SECONDS); - return QrCodeGenerateResp.builder() + return QrCodeGenerateVO.builder() .ticket(ticket) .expireSeconds(DEFAULT_EXPIRE_SECONDS) .build(); @@ -71,7 +71,7 @@ public class QrCodeLoginService { /** * PC 端轮询扫码状态,未登录调用 */ - public QrCodeStatusResp status(String ticket) { + public QrCodeStatusVO status(String ticket) { QrCodeLoginContext ctx = loadOrThrow(ticket); return toResp(ctx, remainingSeconds(ticket)); } @@ -79,7 +79,7 @@ public class QrCodeLoginService { /** * APP 端标记已扫码,需已登录。WAITING → SCANNED */ - public QrCodeStatusResp scan(String ticket, Long userId) { + public QrCodeStatusVO scan(String ticket, Long userId) { QrCodeLoginContext ctx = loadOrThrow(ticket); requireStatus(ctx, QrCodeLoginStatusEnum.WAITING); fillUserInfo(ctx, userId); @@ -92,7 +92,7 @@ public class QrCodeLoginService { /** * APP 端确认登录,需扫码本人操作。SCANNED → CONFIRMED */ - public QrCodeStatusResp confirm(String ticket, Long userId) { + public QrCodeStatusVO confirm(String ticket, Long userId) { QrCodeLoginContext ctx = loadOrThrow(ticket); requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED); requireSameUser(ctx, userId); @@ -105,7 +105,7 @@ public class QrCodeLoginService { /** * APP 端取消登录,仅流程中的票据可取消 */ - public QrCodeStatusResp cancel(String ticket, Long userId) { + public QrCodeStatusVO cancel(String ticket, Long userId) { QrCodeLoginContext ctx = loadOrThrow(ticket); QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus()); // 只有还在流程中的票据(等待扫码 / 已扫码 / 已确认)允许取消 @@ -223,7 +223,7 @@ public class QrCodeLoginService { /** * 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露 */ - private QrCodeStatusResp toResp(QrCodeLoginContext ctx, int expireSeconds) { + private QrCodeStatusVO toResp(QrCodeLoginContext ctx, int expireSeconds) { QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus()); String nickname = null; String avatar = null; @@ -233,7 +233,7 @@ public class QrCodeLoginService { nickname = maskNickname(ctx.getNickname()); avatar = ctx.getAvatar(); } - return QrCodeStatusResp.builder() + return QrCodeStatusVO.builder() .ticket(ctx.getTicket()) .status(status.name()) .nickname(nickname) diff --git a/src/main/java/com/youlai/boot/auth/controller/AuthController.java b/src/main/java/com/youlai/boot/auth/session/controller/AuthSessionController.java similarity index 52% rename from src/main/java/com/youlai/boot/auth/controller/AuthController.java rename to src/main/java/com/youlai/boot/auth/session/controller/AuthSessionController.java index f29a684a..960be83d 100644 --- a/src/main/java/com/youlai/boot/auth/controller/AuthController.java +++ b/src/main/java/com/youlai/boot/auth/session/controller/AuthSessionController.java @@ -1,64 +1,42 @@ -package com.youlai.boot.auth.controller; +package com.youlai.boot.auth.session.controller; -import com.youlai.boot.auth.model.req.LoginReq; +import com.youlai.boot.auth.session.service.AuthSessionService; +import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; -import com.youlai.boot.auth.service.AuthService; -import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RateLimit; -import com.youlai.boot.auth.model.resp.CaptchaInfo; import com.youlai.boot.framework.security.model.AuthenticationToken; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * 认证控制层 + * 登录态管理接口 + * + *

注销与刷新令牌,各登录方式共用。

* * @author Ray.Hao - * @since 0.0.1 + * @since 4.8.0 */ @Tag(name = "01.认证中心") @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor -@Slf4j -public class AuthController { +public class AuthSessionController { - private final AuthService authService; - - @Operation(summary = "获取验证码") - @GetMapping("/captcha") - public Result getCaptcha() { - CaptchaInfo captcha = authService.getCaptcha(); - return Result.success(captcha); - } - - @Operation(summary = "账号密码登录") - @PostMapping("/login") - @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) - @RateLimit - public Result login(@RequestBody @Valid LoginReq request) { - AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword()); - return Result.success(authenticationToken); - } + private final AuthSessionService authSessionService; @Operation(summary = "退出登录") @DeleteMapping("/logout") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGOUT) public Result logout() { - authService.logout(); + authSessionService.logout(); return Result.success(); } @@ -67,7 +45,7 @@ public class AuthController { public Result refreshToken( @Parameter(description = "刷新令牌", example = "xxx.xxx.xxx") @RequestParam String refreshToken ) { - AuthenticationToken authenticationToken = authService.refreshToken(refreshToken); + AuthenticationToken authenticationToken = authSessionService.refreshToken(refreshToken); return Result.success(authenticationToken); } diff --git a/src/main/java/com/youlai/boot/auth/session/service/AuthSessionService.java b/src/main/java/com/youlai/boot/auth/session/service/AuthSessionService.java new file mode 100644 index 00000000..c5f8860f --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/session/service/AuthSessionService.java @@ -0,0 +1,47 @@ +package com.youlai.boot.auth.session.service; + +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.util.SecurityUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +/** + * 登录态服务 + * + *

承载各登录方式共用的注销与令牌刷新。

+ * + * @author Ray.Hao + * @since 4.8.0 + */ +@Service +@RequiredArgsConstructor +public class AuthSessionService { + + private final TokenManager tokenManager; + + /** + * 注销登录 + */ + public void logout() { + String token = SecurityUtils.getAccessToken(); + if (StrUtil.isNotBlank(token)) { + tokenManager.invalidateToken(token); + // 清除Security上下文 + SecurityContextHolder.clearContext(); + } + } + + /** + * 刷新令牌 + * + * @param refreshToken 刷新令牌 + * @return 新的访问令牌 + */ + public AuthenticationToken refreshToken(String refreshToken) { + return tokenManager.refreshToken(refreshToken); + } + +} diff --git a/src/main/java/com/youlai/boot/auth/controller/SmsAuthController.java b/src/main/java/com/youlai/boot/auth/sms/controller/SmsAuthController.java similarity index 82% rename from src/main/java/com/youlai/boot/auth/controller/SmsAuthController.java rename to src/main/java/com/youlai/boot/auth/sms/controller/SmsAuthController.java index 8eba7c72..466e2536 100644 --- a/src/main/java/com/youlai/boot/auth/controller/SmsAuthController.java +++ b/src/main/java/com/youlai/boot/auth/sms/controller/SmsAuthController.java @@ -1,17 +1,16 @@ -package com.youlai.boot.auth.controller; +package com.youlai.boot.auth.sms.controller; -import com.youlai.boot.auth.service.AuthService; +import com.youlai.boot.auth.sms.service.SmsAuthService; +import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; -import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RateLimit; import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.web.ratelimit.RateLimit; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -27,10 +26,9 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/auth/sms") @RequiredArgsConstructor -@Slf4j public class SmsAuthController { - private final AuthService authService; + private final SmsAuthService smsAuthService; @Operation(summary = "[短信]发送登录短信验证码") @PostMapping("/code") @@ -38,7 +36,7 @@ public class SmsAuthController { public Result sendCode( @Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile ) { - authService.sendSmsCode(mobile); + smsAuthService.sendSmsCode(mobile); return Result.success(); } @@ -49,7 +47,7 @@ public class SmsAuthController { @Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile, @Parameter(description = "验证码", example = "123456") @RequestParam String code ) { - AuthenticationToken loginResult = authService.loginBySms(mobile, code); + AuthenticationToken loginResult = smsAuthService.loginBySms(mobile, code); return Result.success(loginResult); } diff --git a/src/main/java/com/youlai/boot/auth/exception/SmsCaptchaException.java b/src/main/java/com/youlai/boot/auth/sms/exception/SmsCaptchaException.java similarity index 86% rename from src/main/java/com/youlai/boot/auth/exception/SmsCaptchaException.java rename to src/main/java/com/youlai/boot/auth/sms/exception/SmsCaptchaException.java index a49e50f8..809153de 100644 --- a/src/main/java/com/youlai/boot/auth/exception/SmsCaptchaException.java +++ b/src/main/java/com/youlai/boot/auth/sms/exception/SmsCaptchaException.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.exception; +package com.youlai.boot.auth.sms.exception; import org.springframework.security.core.AuthenticationException; diff --git a/src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/sms/provider/SmsAuthenticationProvider.java similarity index 95% rename from src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java rename to src/main/java/com/youlai/boot/auth/sms/provider/SmsAuthenticationProvider.java index 3423a033..dc5d9592 100644 --- a/src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/auth/sms/provider/SmsAuthenticationProvider.java @@ -1,13 +1,13 @@ -package com.youlai.boot.auth.security.provider; +package com.youlai.boot.auth.sms.provider; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; +import com.youlai.boot.auth.sms.exception.SmsCaptchaException; +import com.youlai.boot.auth.sms.token.SmsAuthenticationToken; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.port.UserAuthenticationPort; -import com.youlai.boot.auth.exception.SmsCaptchaException; -import com.youlai.boot.auth.security.token.SmsAuthenticationToken; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.AuthenticationProvider; diff --git a/src/main/java/com/youlai/boot/auth/service/AuthService.java b/src/main/java/com/youlai/boot/auth/sms/service/SmsAuthService.java similarity index 51% rename from src/main/java/com/youlai/boot/auth/service/AuthService.java rename to src/main/java/com/youlai/boot/auth/sms/service/SmsAuthService.java index acca88df..f032919e 100644 --- a/src/main/java/com/youlai/boot/auth/service/AuthService.java +++ b/src/main/java/com/youlai/boot/auth/sms/service/SmsAuthService.java @@ -1,19 +1,16 @@ -package com.youlai.boot.auth.service; +package com.youlai.boot.auth.sms.service; import cn.hutool.core.util.StrUtil; +import com.youlai.boot.auth.sms.token.SmsAuthenticationToken; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.auth.model.resp.CaptchaInfo; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.auth.security.token.SmsAuthenticationToken; -import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.support.sms.SmsTypeEnum; -import com.youlai.boot.support.sms.SmsService; +import com.youlai.boot.support.sms.service.SmsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -23,49 +20,21 @@ import java.util.Map; import java.util.concurrent.TimeUnit; /** - * 认证服务 + * 短信验证码登录服务 * * @author Ray.Hao - * @since 2.4.0 + * @since 4.8.0 */ @Service @RequiredArgsConstructor @Slf4j -public class AuthService { +public class SmsAuthService { private final AuthenticationManager authenticationManager; private final TokenManager tokenManager; private final SmsService smsService; private final RedisTemplate redisTemplate; - private final CaptchaService captchaService; - - /** - * 用户名密码登录 - * - * @param username 用户名 - * @param password 密码 - * @return 访问令牌 - */ - public AuthenticationToken login(String username, String password) { - // 1. 创建用于密码认证的令牌(未认证) - UsernamePasswordAuthenticationToken authenticationToken = - new UsernamePasswordAuthenticationToken(username.trim(), password); - - // 2. 执行认证(认证中) - // 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。 - // 默认情况下会委托给 DaoAuthenticationProvider: - // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现) - // 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配) - // 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。 - Authentication authentication = authenticationManager.authenticate(authenticationToken); - - // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) - AuthenticationToken authenticationTokenResponse = - tokenManager.generateToken(authentication); - SecurityContextHolder.getContext().setAuthentication(authentication); - return authenticationTokenResponse; - } /** * 发送登录短信验证码 @@ -111,33 +80,4 @@ public class AuthService { return authenticationToken; } - /** - * 注销登录 - */ - public void logout() { - String token = SecurityUtils.getAccessToken(); - if (StrUtil.isNotBlank(token)) { - tokenManager.invalidateToken(token); - // 清除Security上下文 - SecurityContextHolder.clearContext(); - } - } - - /** - * 获取验证码 - */ - public CaptchaInfo getCaptcha() { - return captchaService.generate(); - } - - /** - * 刷新token - * - * @param refreshToken 刷新令牌 - * @return 新的访问令牌 - */ - public AuthenticationToken refreshToken(String refreshToken) { - return tokenManager.refreshToken(refreshToken); - } - } diff --git a/src/main/java/com/youlai/boot/auth/security/token/SmsAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/sms/token/SmsAuthenticationToken.java similarity index 97% rename from src/main/java/com/youlai/boot/auth/security/token/SmsAuthenticationToken.java rename to src/main/java/com/youlai/boot/auth/sms/token/SmsAuthenticationToken.java index e04de05f..83e8ac50 100644 --- a/src/main/java/com/youlai/boot/auth/security/token/SmsAuthenticationToken.java +++ b/src/main/java/com/youlai/boot/auth/sms/token/SmsAuthenticationToken.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.security.token; +package com.youlai.boot.auth.sms.token; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; diff --git a/src/main/java/com/youlai/boot/config/WxMaConfig.java b/src/main/java/com/youlai/boot/auth/wxma/config/WxMaConfig.java similarity index 75% rename from src/main/java/com/youlai/boot/config/WxMaConfig.java rename to src/main/java/com/youlai/boot/auth/wxma/config/WxMaConfig.java index 1093044b..28b80b74 100644 --- a/src/main/java/com/youlai/boot/config/WxMaConfig.java +++ b/src/main/java/com/youlai/boot/auth/wxma/config/WxMaConfig.java @@ -1,10 +1,8 @@ -package com.youlai.boot.config; +package com.youlai.boot.auth.wxma.config; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl; import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl; -import com.youlai.boot.config.property.WxMaProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,12 +10,17 @@ import org.springframework.context.annotation.Configuration; * 微信小程序配置 * * @author Ray.Hao - * @since 2024/01/01 + * @since 1.0.0 */ @Configuration -@EnableConfigurationProperties(WxMaProperties.class) public class WxMaConfig { + /** + * 微信小程序客户端,用于调用 jscode2session、手机号解析等接口 + * + * @param properties 小程序 appid 与密钥 + * @return WxMaService + */ @Bean public WxMaService wxMaService(WxMaProperties properties) { WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl(); diff --git a/src/main/java/com/youlai/boot/config/property/WxMaProperties.java b/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java similarity index 69% rename from src/main/java/com/youlai/boot/config/property/WxMaProperties.java rename to src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java index d4333c17..428c8709 100644 --- a/src/main/java/com/youlai/boot/config/property/WxMaProperties.java +++ b/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java @@ -1,16 +1,20 @@ -package com.youlai.boot.config.property; +package com.youlai.boot.auth.wxma.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; /** * 微信小程序配置属性 + * + * @author Ray.Hao */ @Data @ConfigurationProperties(prefix = "wx.miniapp") public class WxMaProperties { + /** 小程序 appid */ private String appid; + /** 小程序 appsecret */ private String secret; } diff --git a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java b/src/main/java/com/youlai/boot/auth/wxma/controller/WxMaAuthController.java similarity index 86% rename from src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java rename to src/main/java/com/youlai/boot/auth/wxma/controller/WxMaAuthController.java index 1cb64564..8ff8e019 100644 --- a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java +++ b/src/main/java/com/youlai/boot/auth/wxma/controller/WxMaAuthController.java @@ -1,28 +1,26 @@ -package com.youlai.boot.auth.controller; +package com.youlai.boot.auth.wxma.controller; -import com.youlai.boot.auth.model.req.WxMaBindMobileReq; -import com.youlai.boot.auth.model.req.WxMaPhoneLoginReq; -import com.youlai.boot.auth.model.resp.WxMaLoginResp; -import com.youlai.boot.auth.service.WxMaAuthService; +import com.youlai.boot.auth.wxma.model.form.WxMaBindMobileForm; +import com.youlai.boot.auth.wxma.model.form.WxMaPhoneLoginForm; +import com.youlai.boot.auth.wxma.model.vo.WxMaLoginVO; +import com.youlai.boot.auth.wxma.service.WxMaAuthService; import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RateLimit; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.web.ratelimit.RateLimit; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import jakarta.validation.Valid; - /** * 微信小程序认证控制层 * @@ -33,7 +31,6 @@ import jakarta.validation.Valid; @RestController @RequestMapping("/api/v1/auth/wxma") @RequiredArgsConstructor -@Slf4j public class WxMaAuthController { private final WxMaAuthService wxMaAuthService; @@ -51,11 +48,11 @@ public class WxMaAuthController { @PostMapping("/silent-login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) @RateLimit - public Result silentLogin( + public Result silentLogin( @Parameter(description = "微信登录凭证(wx.login 获取)", required = true, example = "0xxx") @RequestParam String code ) { - WxMaLoginResp result = wxMaAuthService.silentLogin(code); + WxMaLoginVO result = wxMaAuthService.silentLogin(code); return Result.success(result); } @@ -70,7 +67,7 @@ public class WxMaAuthController { @PostMapping("/phone-login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) @RateLimit - public Result phoneLogin(@Valid @RequestBody WxMaPhoneLoginReq req) { + public Result phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) { AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode()); return Result.success(result); } @@ -86,7 +83,7 @@ public class WxMaAuthController { @PostMapping("/bind-mobile") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) @RateLimit - public Result bindMobile(@Valid @RequestBody WxMaBindMobileReq req) { + public Result bindMobile(@Valid @RequestBody WxMaBindMobileForm req) { AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode()); return Result.success(result); } diff --git a/src/main/java/com/youlai/boot/common/exception/MobileNotBoundException.java b/src/main/java/com/youlai/boot/auth/wxma/exception/MobileNotBoundException.java similarity index 93% rename from src/main/java/com/youlai/boot/common/exception/MobileNotBoundException.java rename to src/main/java/com/youlai/boot/auth/wxma/exception/MobileNotBoundException.java index 48c50ac3..68ff1f1f 100644 --- a/src/main/java/com/youlai/boot/common/exception/MobileNotBoundException.java +++ b/src/main/java/com/youlai/boot/auth/wxma/exception/MobileNotBoundException.java @@ -1,4 +1,4 @@ -package com.youlai.boot.common.exception; +package com.youlai.boot.auth.wxma.exception; import org.springframework.security.core.AuthenticationException; @@ -13,6 +13,7 @@ import org.springframework.security.core.AuthenticationException; public class MobileNotBoundException extends AuthenticationException { private final String openid; + private final String sessionKey; public MobileNotBoundException(String openid, String sessionKey) { diff --git a/src/main/java/com/youlai/boot/auth/model/req/WxMaBindMobileReq.java b/src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaBindMobileForm.java similarity index 87% rename from src/main/java/com/youlai/boot/auth/model/req/WxMaBindMobileReq.java rename to src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaBindMobileForm.java index 2dfea5c2..bb7c353f 100644 --- a/src/main/java/com/youlai/boot/auth/model/req/WxMaBindMobileReq.java +++ b/src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaBindMobileForm.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.model.req; +package com.youlai.boot.auth.wxma.model.form; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @@ -6,10 +6,12 @@ import lombok.Data; /** * 微信小程序绑定手机号请求 + * + * @author Ray.Hao */ @Schema(description = "微信小程序绑定手机号请求") @Data -public class WxMaBindMobileReq { +public class WxMaBindMobileForm { @NotBlank(message = "openid 不能为空") @Schema(description = "微信用户唯一标识", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4") diff --git a/src/main/java/com/youlai/boot/auth/model/req/WxMaPhoneLoginReq.java b/src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaPhoneLoginForm.java similarity index 85% rename from src/main/java/com/youlai/boot/auth/model/req/WxMaPhoneLoginReq.java rename to src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaPhoneLoginForm.java index 0fa3041b..f8c568dd 100644 --- a/src/main/java/com/youlai/boot/auth/model/req/WxMaPhoneLoginReq.java +++ b/src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaPhoneLoginForm.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.model.req; +package com.youlai.boot.auth.wxma.model.form; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @@ -6,10 +6,12 @@ import lombok.Data; /** * 微信小程序手机号快捷登录请求 + * + * @author Ray.Hao */ @Schema(description = "微信小程序手机号快捷登录请求") @Data -public class WxMaPhoneLoginReq { +public class WxMaPhoneLoginForm { @NotBlank(message = "微信登录凭证不能为空") @Schema(description = "微信登录凭证(wx.login 获取)", example = "0xxx") diff --git a/src/main/java/com/youlai/boot/auth/model/resp/WxMaLoginResp.java b/src/main/java/com/youlai/boot/auth/wxma/model/vo/WxMaLoginVO.java similarity index 90% rename from src/main/java/com/youlai/boot/auth/model/resp/WxMaLoginResp.java rename to src/main/java/com/youlai/boot/auth/wxma/model/vo/WxMaLoginVO.java index 5e168d8a..62552565 100644 --- a/src/main/java/com/youlai/boot/auth/model/resp/WxMaLoginResp.java +++ b/src/main/java/com/youlai/boot/auth/wxma/model/vo/WxMaLoginVO.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.model.resp; +package com.youlai.boot.auth.wxma.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -8,13 +8,15 @@ import lombok.NoArgsConstructor; /** * 微信小程序登录响应 + * + * @author Ray.Hao */ @Data @Builder @NoArgsConstructor @AllArgsConstructor @Schema(description = "微信小程序登录响应") -public class WxMaLoginResp { +public class WxMaLoginVO { @Schema(description = "是否新用户") private Boolean isNewUser; diff --git a/src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/wxma/provider/WxMaAuthenticationProvider.java similarity index 91% rename from src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java rename to src/main/java/com/youlai/boot/auth/wxma/provider/WxMaAuthenticationProvider.java index 7ebfc92c..10ce36f2 100644 --- a/src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/auth/wxma/provider/WxMaAuthenticationProvider.java @@ -1,14 +1,14 @@ -package com.youlai.boot.auth.security.provider; +package com.youlai.boot.auth.wxma.provider; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult; import cn.hutool.core.util.ObjectUtil; +import com.youlai.boot.auth.wxma.exception.MobileNotBoundException; +import com.youlai.boot.auth.wxma.token.WxMaAuthenticationToken; import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.port.UserAuthenticationPort; -import com.youlai.boot.common.exception.MobileNotBoundException; -import com.youlai.boot.auth.security.token.WxMaAuthenticationToken; import com.youlai.boot.system.model.entity.UserSocial; import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; @@ -22,6 +22,10 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; /** * 微信小程序认证 Provider + *

+ * 用微信登录凭证换取 openid,已绑定手机号的用户返回认证结果,未绑定抛出 MobileNotBoundException。 + * + * @author Ray.Hao */ @Slf4j @RequiredArgsConstructor diff --git a/src/main/java/com/youlai/boot/auth/service/WxMaAuthService.java b/src/main/java/com/youlai/boot/auth/wxma/service/WxMaAuthService.java similarity index 94% rename from src/main/java/com/youlai/boot/auth/service/WxMaAuthService.java rename to src/main/java/com/youlai/boot/auth/wxma/service/WxMaAuthService.java index aff402bb..21886847 100644 --- a/src/main/java/com/youlai/boot/auth/service/WxMaAuthService.java +++ b/src/main/java/com/youlai/boot/auth/wxma/service/WxMaAuthService.java @@ -1,22 +1,22 @@ -package com.youlai.boot.auth.service; +package com.youlai.boot.auth.wxma.service; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult; import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; -import com.youlai.boot.auth.model.resp.WxMaLoginResp; +import com.youlai.boot.auth.wxma.exception.MobileNotBoundException; +import com.youlai.boot.auth.wxma.model.vo.WxMaLoginVO; +import com.youlai.boot.auth.wxma.token.WxMaAuthenticationToken; import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.common.exception.MobileNotBoundException; -import com.youlai.boot.auth.security.token.WxMaAuthenticationToken; -import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.system.model.entity.SysUser; -import com.youlai.boot.system.service.UserSocialService; -import com.youlai.boot.system.service.UserService; import com.youlai.boot.system.service.UserRoleService; +import com.youlai.boot.system.service.UserService; +import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; @@ -59,14 +59,14 @@ public class WxMaAuthService { * @param code 微信登录凭证(wx.login 获取) * @return 登录结果(成功返回 token,需绑定返回 openid) */ - public WxMaLoginResp silentLogin(String code) { + public WxMaLoginVO silentLogin(String code) { WxMaAuthenticationToken token = new WxMaAuthenticationToken(code); try { Authentication authentication = authenticationManager.authenticate(token); AuthenticationToken authToken = tokenManager.generateToken(authentication); SecurityContextHolder.getContext().setAuthentication(authentication); - return WxMaLoginResp.builder() + return WxMaLoginVO.builder() .isNewUser(false) .needBindMobile(false) .accessToken(authToken.getAccessToken()) @@ -75,7 +75,7 @@ public class WxMaAuthService { .expiresIn(authToken.getExpiresIn()) .build(); } catch (MobileNotBoundException e) { - return WxMaLoginResp.builder() + return WxMaLoginVO.builder() .isNewUser(true) .needBindMobile(true) .openid(e.getOpenid()) @@ -122,9 +122,9 @@ public class WxMaAuthService { * 绑定成功后自动完成登录。 *

* - * @param openid 微信用户唯一标识 - * @param mobile 手机号码 - * @param smsCode 短信验证码 + * @param openid 微信用户唯一标识 + * @param mobile 手机号码 + * @param smsCode 短信验证码 * @return 认证令牌 */ @Transactional(rollbackFor = Exception.class) diff --git a/src/main/java/com/youlai/boot/auth/security/token/WxMaAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/wxma/token/WxMaAuthenticationToken.java similarity index 97% rename from src/main/java/com/youlai/boot/auth/security/token/WxMaAuthenticationToken.java rename to src/main/java/com/youlai/boot/auth/wxma/token/WxMaAuthenticationToken.java index 7d21d92c..970ec5d8 100644 --- a/src/main/java/com/youlai/boot/auth/security/token/WxMaAuthenticationToken.java +++ b/src/main/java/com/youlai/boot/auth/wxma/token/WxMaAuthenticationToken.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.security.token; +package com.youlai.boot.auth.wxma.token; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; diff --git a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java index ccbd9b70..77a14631 100644 --- a/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java +++ b/src/main/java/com/youlai/boot/codegen/controller/CodegenController.java @@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.Result; -import com.youlai.boot.codegen.config.CodegenProperties; +import com.youlai.boot.codegen.property.CodegenProperties; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.codegen.service.CodegenService; @@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.ServletOutputStream; +import jakarta.validation.Valid; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -48,7 +49,7 @@ public class CodegenController { @Operation(summary = "获取数据表分页列表") @GetMapping("/table") public PageResult getTablePage( - TableQuery queryParams + @Valid TableQuery queryParams ) { Page result = codegenService.getTablePage(queryParams); return PageResult.success(result); @@ -66,14 +67,14 @@ public class CodegenController { @Operation(summary = "保存代码生成配置") @PostMapping("/{tableName}/config") @Log(module = LogModuleEnum.CODEGEN, value = ActionTypeEnum.UPDATE) - public Result saveGenConfig(@RequestBody GenConfigForm formData) { + public Result saveGenConfig(@RequestBody @Valid GenConfigForm formData) { genTableService.saveGenConfig(formData); return Result.success(); } @Operation(summary = "删除代码生成配置") @DeleteMapping("/{tableName}/config") - public Result deleteGenConfig( + public Result deleteGenConfig( @Parameter(description = "表名", example = "sys_user") @PathVariable String tableName ) { genTableService.deleteGenConfig(tableName); diff --git a/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java b/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java index 5e584e0d..60fb6076 100644 --- a/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java +++ b/src/main/java/com/youlai/boot/codegen/model/form/GenConfigForm.java @@ -3,6 +3,8 @@ package com.youlai.boot.codegen.model.form; import com.youlai.boot.codegen.enums.FormTypeEnum; import com.youlai.boot.codegen.enums.QueryTypeEnum; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.Data; import java.util.List; @@ -21,18 +23,26 @@ public class GenConfigForm { private Long id; @Schema(description = "表名",example = "sys_user") + @NotBlank(message = "表名不能为空") private String tableName; @Schema(description = "业务名",example = "用户") + @NotBlank(message = "业务名不能为空") private String businessName; @Schema(description = "模块名",example = "system") + @NotBlank(message = "模块名不能为空") + @Pattern(regexp = "[a-zA-Z][a-zA-Z0-9_]*", message = "模块名只能包含字母、数字和下划线,且以字母开头") private String moduleName; @Schema(description = "包名",example = "com.youlai") + @NotBlank(message = "包名不能为空") + @Pattern(regexp = "([a-zA-Z_][a-zA-Z0-9_]*\\.)*[a-zA-Z_][a-zA-Z0-9_]*", message = "包名格式不正确") private String packageName; @Schema(description = "实体名",example = "User") + @NotBlank(message = "实体名不能为空") + @Pattern(regexp = "[a-zA-Z][a-zA-Z0-9]*", message = "实体名只能包含字母和数字,且以字母开头") private String entityName; @Schema(description = "作者",example = "youlaitech") diff --git a/src/main/java/com/youlai/boot/codegen/config/CodegenProperties.java b/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java similarity index 87% rename from src/main/java/com/youlai/boot/codegen/config/CodegenProperties.java rename to src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java index 5f327996..ec229833 100644 --- a/src/main/java/com/youlai/boot/codegen/config/CodegenProperties.java +++ b/src/main/java/com/youlai/boot/codegen/property/CodegenProperties.java @@ -1,11 +1,9 @@ -package com.youlai.boot.codegen.config; +package com.youlai.boot.codegen.property; import cn.hutool.core.io.file.FileNameUtil; import cn.hutool.core.map.MapUtil; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; @@ -16,8 +14,6 @@ import java.util.Map; * @author Ray * @since 2.11.0 */ -@Component -@EnableConfigurationProperties(CodegenProperties.class) @ConfigurationProperties(prefix = "codegen") @Data public class CodegenProperties { @@ -95,4 +91,4 @@ public class CodegenProperties { } -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java index 259cbf06..dc09e96d 100644 --- a/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java +++ b/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java @@ -12,7 +12,7 @@ import cn.hutool.extra.template.TemplateUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.codegen.enums.JavaTypeEnum; -import com.youlai.boot.codegen.config.CodegenProperties; +import com.youlai.boot.codegen.property.CodegenProperties; import com.youlai.boot.codegen.service.GenTableService; import com.youlai.boot.codegen.service.GenTableColumnService; import com.youlai.boot.codegen.service.CodegenService; diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java index 6df5b3d5..287218a4 100644 --- a/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java +++ b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java @@ -11,7 +11,7 @@ import com.youlai.boot.codegen.enums.FormTypeEnum; import com.youlai.boot.codegen.enums.JavaTypeEnum; import com.youlai.boot.codegen.enums.QueryTypeEnum; import com.youlai.boot.common.exception.BusinessException; -import com.youlai.boot.codegen.config.CodegenProperties; +import com.youlai.boot.codegen.property.CodegenProperties; import com.youlai.boot.codegen.converter.CodegenConverter; import com.youlai.boot.codegen.mapper.DatabaseMapper; import com.youlai.boot.codegen.mapper.GenTableMapper; @@ -22,6 +22,7 @@ import com.youlai.boot.codegen.model.entity.GenTableColumn; import com.youlai.boot.codegen.model.form.GenConfigForm; import com.youlai.boot.codegen.service.GenTableService; import com.youlai.boot.codegen.service.GenTableColumnService; +import com.youlai.boot.system.model.dto.MenuCreateDTO; import com.youlai.boot.system.service.MenuService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -188,7 +189,13 @@ public class GenTableServiceImpl extends ServiceImpl i // 如果选择上级菜单且当前环境不是生产环境,则保存菜单 Long parentMenuId = formData.getParentMenuId(); if (parentMenuId != null && !EnvEnum.PROD.getValue().equals(springProfilesActive)) { - menuService.addMenuForCodegen(parentMenuId, genTable); + MenuCreateDTO dto = new MenuCreateDTO( + genTable.getBusinessName(), + genTable.getEntityName(), + genTable.getModuleName(), + genTable.getTableName() + ); + menuService.addMenuForCodegen(parentMenuId, dto); } List genTableColumns = codegenConverter.toGenTableColumn(formData.getFieldConfigs()); diff --git a/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java b/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java deleted file mode 100644 index 053f1320..00000000 --- a/src/main/java/com/youlai/boot/common/annotation/RepeatSubmit.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.youlai.boot.common.annotation; - - -import java.lang.annotation.*; - -/** - * 防止重复提交注解 - *

- * 该注解用于方法上,防止在指定时间内的重复提交。 默认时间为5秒。 - * - * @author Ray.Hao - * @since 2.3.0 - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@Inherited -public @interface RepeatSubmit { - - /** - * 锁过期时间(秒) - *

- * 默认5秒内不允许重复提交 - */ - int expire() default 5; - -} diff --git a/src/main/java/com/youlai/boot/common/aspect/RepeatSubmitAspect.java b/src/main/java/com/youlai/boot/common/aspect/RepeatSubmitAspect.java deleted file mode 100644 index 256930ee..00000000 --- a/src/main/java/com/youlai/boot/common/aspect/RepeatSubmitAspect.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.youlai.boot.common.aspect; - -import cn.hutool.core.util.StrUtil; -import cn.hutool.crypto.digest.DigestUtil; -import cn.hutool.json.JSONUtil; -import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.common.exception.BusinessException; -import com.youlai.boot.common.annotation.RepeatSubmit; -import com.youlai.boot.common.util.IPUtils; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.annotation.Pointcut; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.springframework.http.HttpHeaders; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -import java.util.concurrent.TimeUnit; - -/** - * 防重复提交切面 - * - * @author Ray.Hao - * @since 4.3.1 - */ -@Aspect -@Component -@RequiredArgsConstructor -public class RepeatSubmitAspect { - - private final RedissonClient redissonClient; - - @Pointcut("@annotation(repeatSubmit)") - public void repeatSubmitPointCut(RepeatSubmit repeatSubmit) { - } - - @Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit") - public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable { - String lockKey = buildLockKey(pjp); - - int expire = repeatSubmit.expire(); - RLock lock = redissonClient.getLock(lockKey); - - boolean locked = lock.tryLock(0, expire, TimeUnit.SECONDS); - if (!locked) { - throw new BusinessException(ResultCode.DUPLICATE_SUBMISSION); - } - return pjp.proceed(); - } - - /** - * 生成防重复提交锁的 key - * - *

请求体哈希用于区分同一接口的不同提交(如新增菜单与新增目录)

- */ - private String buildLockKey(ProceedingJoinPoint pjp) { - HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); - String userIdentifier = getUserIdentifier(request); - String requestIdentifier = StrUtil.join(":", request.getMethod(), request.getRequestURI()); - String bodyHash = DigestUtil.sha256Hex(JSONUtil.toJsonStr(pjp.getArgs())); - return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier + ":" + bodyHash); - } - - /** - * 获取用户唯一标识 - * 1. 从请求头中获取 Token,使用 SHA-256 加密 Token 作为用户唯一标识 - * 2. 如果 Token 为空,使用 IP 作为用户唯一标识 - */ - private String getUserIdentifier(HttpServletRequest request) { - String userIdentifier; - String tokenHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - if (StrUtil.isNotBlank(tokenHeader) && tokenHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { - String rawToken = tokenHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); - userIdentifier = DigestUtil.sha256Hex(rawToken); - } else { - userIdentifier = IPUtils.getIpAddr(request); - } - return userIdentifier; - } - -} diff --git a/src/main/java/com/youlai/boot/common/base/BaseQuery.java b/src/main/java/com/youlai/boot/common/base/BaseQuery.java index 7548937d..697d1c72 100644 --- a/src/main/java/com/youlai/boot/common/base/BaseQuery.java +++ b/src/main/java/com/youlai/boot/common/base/BaseQuery.java @@ -2,6 +2,8 @@ package com.youlai.boot.common.base; import com.youlai.boot.common.annotation.ValidField; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.Data; import java.io.Serial; @@ -21,16 +23,20 @@ public class BaseQuery implements Serializable { private static final long serialVersionUID = 1L; @Schema(description = "页码", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "1") + @Min(value = 1, message = "页码不能小于 1") private Integer pageNum = 1; @Schema(description = "每页记录数", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "10") + @Min(value = 1, message = "每页记录数不能小于 1") + @Max(value = 200, message = "每页记录数不能超过 200") private Integer pageSize = 10; @Schema(description = "排序字段", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @ValidField(allowedValues = {"create_time", "update_time"}) private String sortBy; - @Schema(description = "排序方式(正序:ASC;反序:DESC)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "排序方式(正序:asc;反序:desc,不区分大小写)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @ValidField(allowedValues = {"asc", "desc", "ASC", "DESC"}) private String order; public boolean isPaged() { diff --git a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java index fe0441b3..8e927691 100644 --- a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java @@ -28,6 +28,7 @@ public enum LogModuleEnum implements IBaseEnum { LOG(10, "日志管理"), CODEGEN(11, "代码生成"), FORM(12, "动态表单"), + WORKFLOW(13, "工作流"), OTHER(99, "其他"); @EnumValue diff --git a/src/main/java/com/youlai/boot/common/result/PageResult.java b/src/main/java/com/youlai/boot/common/result/PageResult.java index 72fee0b1..3b453d19 100644 --- a/src/main/java/com/youlai/boot/common/result/PageResult.java +++ b/src/main/java/com/youlai/boot/common/result/PageResult.java @@ -25,7 +25,7 @@ public class PageResult implements Serializable { /** * 构建分页结果(MyBatis-Plus {@link IPage})。 * - *

data 为当前页记录列表;page 提供分页元信息。

+ *

list 为当前页记录列表,total 为符合条件的总记录数。

*/ public static PageResult success(IPage page) { PageResult result = new PageResult<>(); @@ -47,7 +47,7 @@ public class PageResult implements Serializable { /** * 构建列表结果(无分页)。 * - *

page 置为 null,用于与分页返回区分。

+ *

total 固定为 0,用于与分页返回区分。

*/ public static PageResult success(List list) { PageResult result = new PageResult<>(); diff --git a/src/main/java/com/youlai/boot/config/OpenApiConfig.java b/src/main/java/com/youlai/boot/config/OpenApiConfig.java index 3fdcb10a..2fb197e6 100644 --- a/src/main/java/com/youlai/boot/config/OpenApiConfig.java +++ b/src/main/java/com/youlai/boot/config/OpenApiConfig.java @@ -1,7 +1,7 @@ package com.youlai.boot.config; import cn.hutool.core.util.ArrayUtil; -import com.youlai.boot.config.property.SecurityProperties; +import com.youlai.boot.framework.security.config.SecurityProperties; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; @@ -78,12 +78,12 @@ public class OpenApiConfig { if (openApi.getPaths() != null) { openApi.getPaths().forEach((path, pathItem) -> { - // 忽略认证的请求无需携带 Authorization - String[] ignoreUrls = securityProperties.getIgnoreUrls(); - if (ArrayUtil.isNotEmpty(ignoreUrls)) { - // Ant 匹配忽略的路径,不添加Authorization + // 匿名放行的请求无需携带 Authorization + String[] permitAllUrls = securityProperties.getPermitAllUrls(); + if (ArrayUtil.isNotEmpty(permitAllUrls)) { + // Ant 匹配放行路径,不添加Authorization AntPathMatcher antPathMatcher = new AntPathMatcher(); - if (Stream.of(ignoreUrls).anyMatch(ignoreUrl -> antPathMatcher.match(ignoreUrl, path))) { + if (Stream.of(permitAllUrls).anyMatch(permitAllUrl -> antPathMatcher.match(permitAllUrl, path))) { return; } } diff --git a/src/main/java/com/youlai/boot/config/SecurityConfig.java b/src/main/java/com/youlai/boot/config/SecurityConfig.java index 8a3b450d..5fc58f72 100644 --- a/src/main/java/com/youlai/boot/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java @@ -2,17 +2,17 @@ package com.youlai.boot.config; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.hutool.core.util.ArrayUtil; -import com.youlai.boot.auth.service.CaptchaService; -import com.youlai.boot.config.property.SecurityProperties; +import com.youlai.boot.auth.password.service.CaptchaService; +import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter; +import com.youlai.boot.framework.security.handler.JsonAccessDeniedHandler; +import com.youlai.boot.framework.security.handler.JsonAuthenticationEntryPoint; import com.youlai.boot.framework.security.port.UserAuthenticationPort; import com.youlai.boot.framework.security.service.SecurityUserDetailsService; import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.auth.security.filter.CaptchaValidationFilter; -import com.youlai.boot.auth.security.handler.JsonAccessDeniedHandler; -import com.youlai.boot.auth.security.handler.JsonAuthenticationEntryPoint; -import com.youlai.boot.auth.security.provider.SmsAuthenticationProvider; -import com.youlai.boot.auth.security.provider.WxMaAuthenticationProvider; +import com.youlai.boot.auth.password.filter.CaptchaValidationFilter; +import com.youlai.boot.auth.sms.provider.SmsAuthenticationProvider; +import com.youlai.boot.auth.wxma.provider.WxMaAuthenticationProvider; import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -24,7 +24,6 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; @@ -60,9 +59,9 @@ public class SecurityConfig { .authorizeHttpRequests(requestMatcherRegistry -> { // ASYNC 分发(SSE 超时/完成后的异步 dispatch)无 Authorization 头,直接放行 requestMatcherRegistry.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll(); - String[] ignoreUrls = securityProperties.getIgnoreUrls(); - if (ArrayUtil.isNotEmpty(ignoreUrls)) { - requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll(); + String[] permitAllUrls = securityProperties.getPermitAllUrls(); + if (ArrayUtil.isNotEmpty(permitAllUrls)) { + requestMatcherRegistry.requestMatchers(permitAllUrls).permitAll(); } requestMatcherRegistry.anyRequest().authenticated(); } @@ -86,16 +85,6 @@ public class SecurityConfig { .build(); } - @Bean - public WebSecurityCustomizer webSecurityCustomizer() { - return (web) -> { - String[] unsecuredUrls = securityProperties.getUnsecuredUrls(); - if (ArrayUtil.isNotEmpty(unsecuredUrls)) { - web.ignoring().requestMatchers(unsecuredUrls); - } - }; - } - @Bean public DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); diff --git a/src/main/java/com/youlai/boot/config/PasswordEncoderConfig.java b/src/main/java/com/youlai/boot/framework/security/config/PasswordEncoderConfig.java similarity index 91% rename from src/main/java/com/youlai/boot/config/PasswordEncoderConfig.java rename to src/main/java/com/youlai/boot/framework/security/config/PasswordEncoderConfig.java index 3e6e0e0b..6ee0f46d 100644 --- a/src/main/java/com/youlai/boot/config/PasswordEncoderConfig.java +++ b/src/main/java/com/youlai/boot/framework/security/config/PasswordEncoderConfig.java @@ -1,4 +1,4 @@ -package com.youlai.boot.config; +package com.youlai.boot.framework.security.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java b/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java similarity index 72% rename from src/main/java/com/youlai/boot/config/property/SecurityProperties.java rename to src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java index 5e4b3725..96337564 100644 --- a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java +++ b/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java @@ -1,12 +1,12 @@ -package com.youlai.boot.config.property; +package com.youlai.boot.framework.security.config; +import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; import org.springframework.validation.annotation.Validated; /** @@ -18,44 +18,38 @@ import org.springframework.validation.annotation.Validated; * @since 2024/4/18 */ @Data -@Component @Validated @ConfigurationProperties(prefix = "security") public class SecurityProperties { /** - * 会话管理配置 + * 令牌策略配置 */ - private SessionConfig session; + @Valid + @NotNull(message = "令牌策略配置不能为空") + private TokenConfig token; /** - * 安全白名单路径(完全绕过安全过滤器) - *

示例值:/api/v1/auth/login/**, /ws/** + * 匿名放行路径(仍经过安全过滤器链,仅跳过认证与授权判断,保留安全响应头) + *

示例值:/api/v1/auth/login/**, /doc.html

*/ @NotEmpty - private String[] ignoreUrls; + private String[] permitAllUrls; /** - * 非安全端点路径(允许匿名访问的API) - *

示例值:/doc.html, /v3/api-docs/** - */ - @NotEmpty - private String[] unsecuredUrls; - - /** - * 会话配置嵌套类 + * 令牌策略嵌套类 */ @Data - public static class SessionConfig { + public static class TokenConfig { /** - * 认证策略类型 + * 令牌策略类型 *

    *
  • jwt - 基于JWT的无状态认证
  • *
  • redis-token - 基于Redis的有状态认证
  • *
*/ - @NotNull(message = "会话类型不能为空") - @Pattern(regexp = "jwt|redis-token", message = "会话类型只能是 jwt 或 redis-token") + @NotNull(message = "令牌类型不能为空") + @Pattern(regexp = "jwt|redis-token", message = "令牌类型只能是 jwt 或 redis-token") private String type; /** @@ -75,13 +69,15 @@ public class SecurityProperties { private Integer refreshTokenTimeToLive = 604800; /** - * JWT 配置项 + * JWT 配置项(type 为 jwt 时必填) */ + @Valid private JwtConfig jwt; /** - * Redis令牌配置项 + * Redis令牌配置项(type 为 redis-token 时使用) */ + @Valid private RedisTokenConfig redisToken; } @@ -95,7 +91,7 @@ public class SecurityProperties { *

HS256算法要求至少32个字符

*

示例:SecretKey012345678901234567890123456789

*/ - @NotNull + @NotNull(message = "JWT 密钥不能为空") private String secretKey; } diff --git a/src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java b/src/main/java/com/youlai/boot/framework/security/handler/JsonAccessDeniedHandler.java similarity index 93% rename from src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java rename to src/main/java/com/youlai/boot/framework/security/handler/JsonAccessDeniedHandler.java index 20de72db..b80fe234 100644 --- a/src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java +++ b/src/main/java/com/youlai/boot/framework/security/handler/JsonAccessDeniedHandler.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.security.handler; +package com.youlai.boot.framework.security.handler; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.web.util.ResponseWriter; diff --git a/src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java b/src/main/java/com/youlai/boot/framework/security/handler/JsonAuthenticationEntryPoint.java similarity index 96% rename from src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java rename to src/main/java/com/youlai/boot/framework/security/handler/JsonAuthenticationEntryPoint.java index b7d11849..791c9126 100644 --- a/src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java +++ b/src/main/java/com/youlai/boot/framework/security/handler/JsonAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package com.youlai.boot.auth.security.handler; +package com.youlai.boot.framework.security.handler; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.web.util.ResponseWriter; diff --git a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java index 433322e5..f9754989 100644 --- a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java @@ -13,7 +13,7 @@ import com.youlai.boot.common.constant.JwtClaimConstants; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.config.property.SecurityProperties; +import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.common.exception.TokenInvalidException; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.model.RoleDataScope; @@ -40,7 +40,7 @@ import java.util.stream.Collectors; * @author Ray.Hao * @since 2024/11/15 */ -@ConditionalOnProperty(value = "security.session.type", havingValue = "jwt") +@ConditionalOnProperty(value = "security.token.type", havingValue = "jwt") @Service public class JwtTokenManager implements TokenManager { @@ -51,13 +51,13 @@ public class JwtTokenManager implements TokenManager { public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate redisTemplate) { this.securityProperties = securityProperties; this.redisTemplate = redisTemplate; - this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes(); + this.secretKey = securityProperties.getToken().getJwt().getSecretKey().getBytes(); } @Override public AuthenticationToken generateToken(Authentication authentication) { - int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive(); - int refreshTokenTimeToLive = securityProperties.getSession().getRefreshTokenTimeToLive(); + int accessTokenTimeToLive = securityProperties.getToken().getAccessTokenTimeToLive(); + int refreshTokenTimeToLive = securityProperties.getToken().getRefreshTokenTimeToLive(); String accessToken = generateToken(authentication, accessTokenTimeToLive); String refreshToken = generateToken(authentication, refreshTokenTimeToLive, true); @@ -189,7 +189,7 @@ public class JwtTokenManager implements TokenManager { throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID); } Authentication authentication = parseToken(refreshToken); - int accessTokenExpiration = securityProperties.getSession().getAccessTokenTimeToLive(); + int accessTokenExpiration = securityProperties.getToken().getAccessTokenTimeToLive(); String newAccessToken = generateToken(authentication, accessTokenExpiration); return AuthenticationToken.builder() .accessToken(newAccessToken) diff --git a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java index 07937d01..54ae7a3c 100644 --- a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java +++ b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java @@ -5,7 +5,7 @@ import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.config.property.SecurityProperties; +import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.common.exception.TokenInvalidException; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.model.SecurityUserDetails; @@ -31,7 +31,7 @@ import java.util.concurrent.TimeUnit; * @author Ray.Hao * @since 2024/11/15 */ -@ConditionalOnProperty(value = "security.session.type", havingValue = "redis-token") +@ConditionalOnProperty(value = "security.token.type", havingValue = "redis-token") @Service public class RedisTokenManager implements TokenManager { @@ -62,7 +62,7 @@ public class RedisTokenManager implements TokenManager { return AuthenticationToken.builder() .accessToken(accessToken) .refreshToken(refreshToken) - .expiresIn(securityProperties.getSession().getAccessTokenTimeToLive()) + .expiresIn(securityProperties.getToken().getAccessTokenTimeToLive()) .build(); } @@ -102,7 +102,7 @@ public class RedisTokenManager implements TokenManager { String newAccessToken = IdUtil.fastSimpleUUID(); storeAccessToken(newAccessToken, sessionUser); - int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); + int accessTtl = securityProperties.getToken().getAccessTokenTimeToLive(); return AuthenticationToken.builder() .accessToken(newAccessToken) .refreshToken(refreshToken) @@ -152,16 +152,16 @@ public class RedisTokenManager implements TokenManager { private void storeTokensInRedis(String accessToken, String refreshToken, SecurityUserDetails sessionUser) { setRedisValue(formatTokenKey(accessToken), sessionUser, - securityProperties.getSession().getAccessTokenTimeToLive()); + securityProperties.getToken().getAccessTokenTimeToLive()); String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); setRedisValue(refreshTokenKey, sessionUser, - securityProperties.getSession().getRefreshTokenTimeToLive()); + securityProperties.getToken().getRefreshTokenTimeToLive()); setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, sessionUser.getUserId()), - refreshToken, securityProperties.getSession().getRefreshTokenTimeToLive()); + refreshToken, securityProperties.getToken().getRefreshTokenTimeToLive()); } private void handleSingleDeviceLogin(Long userId, String accessToken) { - Boolean allowMultiLogin = securityProperties.getSession().getRedisToken().getAllowMultiLogin(); + Boolean allowMultiLogin = securityProperties.getToken().getRedisToken().getAllowMultiLogin(); String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); if (!allowMultiLogin) { Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey); @@ -169,14 +169,14 @@ public class RedisTokenManager implements TokenManager { redisTemplate.delete(formatTokenKey(oldAccessToken)); } } - setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive()); + setRedisValue(userAccessKey, accessToken, securityProperties.getToken().getAccessTokenTimeToLive()); } private void storeAccessToken(String newAccessToken, SecurityUserDetails sessionUser) { setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), sessionUser, - securityProperties.getSession().getAccessTokenTimeToLive()); + securityProperties.getToken().getAccessTokenTimeToLive()); String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId()); - setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive()); + setRedisValue(userAccessKey, newAccessToken, securityProperties.getToken().getAccessTokenTimeToLive()); } private String formatTokenKey(String token) { diff --git a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java index 09219484..ed629549 100644 --- a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java +++ b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java @@ -3,9 +3,9 @@ package com.youlai.boot.framework.web.filter; import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.common.util.IPUtils; -import com.youlai.boot.config.property.RateLimitProperties; -import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript; +import com.youlai.boot.framework.web.util.IPUtils; +import com.youlai.boot.framework.web.ratelimit.RateLimitProperties; +import com.youlai.boot.framework.web.ratelimit.RedisSlidingWindowRateLimiter; import com.youlai.boot.framework.web.util.ResponseWriter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -15,7 +15,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -51,7 +50,7 @@ import java.io.IOException; @Slf4j public class IpRateLimitFilter extends OncePerRequestFilter { - private final RedisTemplate redisTemplate; + private final RedisSlidingWindowRateLimiter rateLimiter; private final RateLimitProperties rateLimitProperties; @Override @@ -70,14 +69,8 @@ public class IpRateLimitFilter extends OncePerRequestFilter { String key = StrUtil.format(RedisConstants.RateLimit.IP, ip); long windowMs = ipConfig.getWindow().toMillis(); - // 执行滑动窗口计数(Lua 原子操作);Redis 异常时 Fail-Open 放行 - Long count; - try { - count = SlidingWindowScript.execute(redisTemplate, key, windowMs); - } catch (Exception e) { - log.warn("IP 限流 Redis 异常,Fail-Open 放行 ip={}", ip, e); - count = null; - } + // limiter 内部已统一 Fail-Open:Redis 异常返回 null,按未计数放行 + Long count = rateLimiter.count(key, windowMs); int limit = ipConfig.getLimit(); int current = count != null ? count.intValue() : 0; diff --git a/src/main/java/com/youlai/boot/framework/web/filter/RequestLogFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/RequestLogFilter.java index f9ca070a..fd992b0b 100644 --- a/src/main/java/com/youlai/boot/framework/web/filter/RequestLogFilter.java +++ b/src/main/java/com/youlai/boot/framework/web/filter/RequestLogFilter.java @@ -1,9 +1,9 @@ package com.youlai.boot.framework.web.filter; -import com.youlai.boot.common.util.IPUtils; +import com.youlai.boot.framework.web.util.IPUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; import org.springframework.web.filter.CommonsRequestLoggingFilter; /** @@ -12,7 +12,7 @@ import org.springframework.web.filter.CommonsRequestLoggingFilter; * @author haoxr * @since 2023/03/03 */ -@Configuration +@Component @Slf4j public class RequestLogFilter extends CommonsRequestLoggingFilter { diff --git a/src/main/java/com/youlai/boot/framework/web/log/OperationLogAspect.java b/src/main/java/com/youlai/boot/framework/web/log/OperationLogAspect.java index ec9e294d..1cc010cf 100644 --- a/src/main/java/com/youlai/boot/framework/web/log/OperationLogAspect.java +++ b/src/main/java/com/youlai/boot/framework/web/log/OperationLogAspect.java @@ -6,7 +6,7 @@ import cn.hutool.http.useragent.UserAgentUtil; import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; -import com.youlai.boot.common.util.IPUtils; +import com.youlai.boot.framework.web.util.IPUtils; import com.youlai.boot.framework.security.util.SecurityUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/youlai/boot/common/annotation/RateLimit.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimit.java similarity index 68% rename from src/main/java/com/youlai/boot/common/annotation/RateLimit.java rename to src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimit.java index e7e73424..8192d1e3 100644 --- a/src/main/java/com/youlai/boot/common/annotation/RateLimit.java +++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimit.java @@ -1,9 +1,14 @@ -package com.youlai.boot.common.annotation; +package com.youlai.boot.framework.web.ratelimit; -import java.lang.annotation.*; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; /** - * 接口限流 + * 接口限流注解 + * *

标注在 Controller 方法上,基于 Redis 滑动窗口实现

* * @author Ray.Hao diff --git a/src/main/java/com/youlai/boot/common/aspect/RateLimitAspect.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitAspect.java similarity index 87% rename from src/main/java/com/youlai/boot/common/aspect/RateLimitAspect.java rename to src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitAspect.java index b11b5377..8f415dcb 100644 --- a/src/main/java/com/youlai/boot/common/aspect/RateLimitAspect.java +++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitAspect.java @@ -1,15 +1,12 @@ -package com.youlai.boot.common.aspect; +package com.youlai.boot.framework.web.ratelimit; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; -import com.youlai.boot.common.annotation.RateLimit; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.common.util.IPUtils; -import com.youlai.boot.config.property.RateLimitProperties; import com.youlai.boot.common.exception.RateLimitException; -import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.framework.web.util.IPUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -17,7 +14,6 @@ import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; @@ -27,7 +23,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; * 接口级限流切面 *

* 对标注了 {@link RateLimit} 的 Controller 方法做 Redis 滑动窗口计数。 - * 超阈值抛出 {@link BusinessException}(A0502),全局异常处理器统一返回 JSON。 + * 超阈值抛出 {@link RateLimitException}(A0502),全局异常处理器统一返回 JSON。 *

* *

升级历史

@@ -45,7 +41,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; @Slf4j public class RateLimitAspect { - private final RedisTemplate redisTemplate; + private final RedisSlidingWindowRateLimiter rateLimiter; private final RateLimitProperties rateLimitProperties; @Around("@annotation(rateLimit)") @@ -59,7 +55,8 @@ public class RateLimitAspect { ? rateLimit.window() * 1000L : rateLimitProperties.getDefaultWindow().toMillis(); - Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs); + // limiter 内部已统一 Fail-Open:Redis 异常返回 null,按未计数放行 + Long count = rateLimiter.count(key, windowMs); int current = count != null ? count.intValue() : 0; setRateLimitHeaders(request, limit, current, windowMs); diff --git a/src/main/java/com/youlai/boot/config/property/RateLimitProperties.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitProperties.java similarity index 92% rename from src/main/java/com/youlai/boot/config/property/RateLimitProperties.java rename to src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitProperties.java index 72d03b26..a48abc7a 100644 --- a/src/main/java/com/youlai/boot/config/property/RateLimitProperties.java +++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/RateLimitProperties.java @@ -1,8 +1,7 @@ -package com.youlai.boot.config.property; +package com.youlai.boot.framework.web.ratelimit; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; import java.time.Duration; @@ -14,7 +13,6 @@ import java.time.Duration; * @since 4.3.1 */ @Data -@Component @ConfigurationProperties(prefix = "rate-limit") public class RateLimitProperties { diff --git a/src/main/java/com/youlai/boot/framework/web/ratelimit/RedisSlidingWindowRateLimiter.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/RedisSlidingWindowRateLimiter.java new file mode 100644 index 00000000..87530387 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/RedisSlidingWindowRateLimiter.java @@ -0,0 +1,76 @@ +package com.youlai.boot.framework.web.ratelimit; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.UUID; + +/** + * Redis 滑动窗口限流器 + * + *

基于 Redis Sorted Set 实现原子化的滑动窗口计数: + *

    + *
  1. ZREMRANGEBYSCORE 清除窗口外旧请求
  2. + *
  3. ZADD 添加当前请求
  4. + *
  5. ZCARD 统计窗口内请求数
  6. + *
  7. PEXPIRE 设置 Key 过期(窗口 + 1s 冗余)
  8. + *
+ * 一次网络往返完成全部操作,避免多次 Redis 调用之间的竞态条件。

+ * + *

Redis 异常时返回 null(本次不计数),调用方统一按 Fail-Open 放行, + * 保证限流组件故障不影响业务可用性。

+ * + * @author Ray.Hao + * @since 4.4.0 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisSlidingWindowRateLimiter { + + private final RedisTemplate redisTemplate; + + private static final DefaultRedisScript SCRIPT; + + static { + SCRIPT = new DefaultRedisScript<>(); + SCRIPT.setScriptText( + "local key = KEYS[1] " + + "local now = tonumber(ARGV[1]) " + + "local window = tonumber(ARGV[2]) " + + "local member = ARGV[3] " + + "redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " + + "redis.call('ZADD', key, now, member) " + + "redis.call('PEXPIRE', key, window + 1000) " + + "return redis.call('ZCARD', key)" + ); + SCRIPT.setResultType(Long.class); + } + + /** + * 记录一次请求并返回窗口内计数 + * + * @param key 限流 Key(如 {@code rate_limit:ip:192.168.1.1}) + * @param windowMs 窗口大小(毫秒) + * @return 当前窗口内请求数(包含本次);Redis 异常时返回 null,调用方按 Fail-Open 放行 + */ + public Long count(String key, long windowMs) { + try { + // 传 Long 而非 String,避免 Jackson 序列化器加双引号导致 Lua tonumber() 返回 nil + return redisTemplate.execute( + SCRIPT, + List.of(key), + System.currentTimeMillis(), + windowMs, + UUID.randomUUID().toString() + ); + } catch (Exception e) { + log.warn("限流 Redis 异常,本次未计数(Fail-Open) key={}", key, e); + return null; + } + } +} diff --git a/src/main/java/com/youlai/boot/framework/web/repeatsubmit/RepeatSubmit.java b/src/main/java/com/youlai/boot/framework/web/repeatsubmit/RepeatSubmit.java new file mode 100644 index 00000000..fa798be8 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/repeatsubmit/RepeatSubmit.java @@ -0,0 +1,37 @@ +package com.youlai.boot.framework.web.repeatsubmit; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 防重复提交注解 + * + *

标注在写接口方法上,指定时间窗口内同一用户对同一请求只允许提交一次,默认 5 秒。

+ * + * @author Ray.Hao + * @since 2.3.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RepeatSubmit { + + /** + * 防重占位过期时间(秒) + * + *

默认 5 秒内不允许重复提交,占位到期后自动释放。

+ */ + int expire() default 5; + + /** + * 请求标识 SpEL 表达式(可选) + * + *

非空时以表达式的值区分不同请求,如 {@code "#form.name"}; + * 为空时基于方法参数自动生成请求指纹,自动排除 ServletRequest/ServletResponse、 + * MultipartFile、BindingResult 等不可稳定序列化的参数。

+ */ + String key() default ""; +} diff --git a/src/main/java/com/youlai/boot/framework/web/repeatsubmit/RepeatSubmitAspect.java b/src/main/java/com/youlai/boot/framework/web/repeatsubmit/RepeatSubmitAspect.java new file mode 100644 index 00000000..b5bda566 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/repeatsubmit/RepeatSubmitAspect.java @@ -0,0 +1,126 @@ +package com.youlai.boot.framework.web.repeatsubmit; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.json.JSONUtil; +import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.common.constant.SecurityConstants; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.framework.web.util.IPUtils; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.context.expression.MethodBasedEvaluationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.validation.BindingResult; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; + +/** + * 防重复提交切面 + * + *

用 Redis 的「不存在才写入 + TTL」原子占位实现:同一请求标识在过期时间内 + * 第二次提交直接拒绝,占位到期自动释放,无需手动解锁。

+ * + * @author Ray.Hao + * @since 4.3.1 + */ +@Aspect +@Component +@RequiredArgsConstructor +public class RepeatSubmitAspect { + + private final RedissonClient redissonClient; + + private static final ExpressionParser SPEL_PARSER = new SpelExpressionParser(); + private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); + + /** 生成请求指纹时排除的参数类型,这些对象序列化结果不稳定或无业务含义 */ + private static final List> EXCLUDED_PARAM_TYPES = + List.of(ServletRequest.class, ServletResponse.class, MultipartFile.class, BindingResult.class); + + @Pointcut("@annotation(repeatSubmit)") + public void repeatSubmitPointCut(RepeatSubmit repeatSubmit) { + } + + @Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit") + public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable { + String lockKey = buildLockKey(pjp, repeatSubmit); + + RBucket bucket = redissonClient.getBucket(lockKey); + boolean acquired = bucket.setIfAbsent("1", Duration.ofSeconds(repeatSubmit.expire())); + if (!acquired) { + throw new BusinessException(ResultCode.DUPLICATE_SUBMISSION); + } + return pjp.proceed(); + } + + /** + * 生成防重占位的 key + * + *

注解配置了 key 表达式时以表达式的值区分请求;否则对过滤后的业务参数做哈希。

+ */ + private String buildLockKey(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) { + HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); + String userIdentifier = getUserIdentifier(request); + String requestIdentifier = StrUtil.join(":", request.getMethod(), request.getRequestURI()); + String requestFingerprint = StrUtil.isNotBlank(repeatSubmit.key()) + ? DigestUtil.sha256Hex(evaluateKey(pjp, repeatSubmit.key())) + : DigestUtil.sha256Hex(JSONUtil.toJsonStr(filterArgs(pjp))); + return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier + ":" + requestFingerprint); + } + + /** 过滤掉不可稳定序列化的参数后再生成指纹 */ + private Object[] filterArgs(ProceedingJoinPoint pjp) { + return Arrays.stream(pjp.getArgs()) + .filter(arg -> arg == null || EXCLUDED_PARAM_TYPES.stream().noneMatch(type -> type.isInstance(arg))) + .toArray(); + } + + /** 解析注解上的 SpEL key 表达式,方法参数可直接引用 */ + private String evaluateKey(ProceedingJoinPoint pjp, String spel) { + Method method = ((MethodSignature) pjp.getSignature()).getMethod(); + MethodBasedEvaluationContext context = new MethodBasedEvaluationContext( + pjp.getTarget(), method, pjp.getArgs(), PARAMETER_NAME_DISCOVERER); + Object value = SPEL_PARSER.parseExpression(spel).getValue(context); + return value != null ? value.toString() : ""; + } + + /** + * 获取用户唯一标识 + * 1. 从请求头中获取 Token,使用 SHA-256 加密 Token 作为用户唯一标识 + * 2. 如果 Token 为空,使用 IP 作为用户唯一标识 + */ + private String getUserIdentifier(HttpServletRequest request) { + String userIdentifier; + String tokenHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StrUtil.isNotBlank(tokenHeader) && tokenHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { + String rawToken = tokenHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); + userIdentifier = DigestUtil.sha256Hex(rawToken); + } else { + userIdentifier = IPUtils.getIpAddr(request); + } + return userIdentifier; + } + +} diff --git a/src/main/java/com/youlai/boot/common/util/IPUtils.java b/src/main/java/com/youlai/boot/framework/web/util/IPUtils.java similarity index 98% rename from src/main/java/com/youlai/boot/common/util/IPUtils.java rename to src/main/java/com/youlai/boot/framework/web/util/IPUtils.java index 68bd3e89..b43d2cab 100644 --- a/src/main/java/com/youlai/boot/common/util/IPUtils.java +++ b/src/main/java/com/youlai/boot/framework/web/util/IPUtils.java @@ -1,4 +1,4 @@ -package com.youlai.boot.common.util; +package com.youlai.boot.framework.web.util; import cn.hutool.core.util.StrUtil; import jakarta.annotation.PostConstruct; diff --git a/src/main/java/com/youlai/boot/module/config/WorkflowEnvironmentPostProcessor.java b/src/main/java/com/youlai/boot/module/config/WorkflowEnvironmentPostProcessor.java new file mode 100644 index 00000000..0abaf945 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/config/WorkflowEnvironmentPostProcessor.java @@ -0,0 +1,64 @@ +package com.youlai.boot.module.config; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +/** + * 工作流关闭时排除 Flowable 引擎自动配置 + * + * @author Ray.Hao + * @since 4.9.0 + */ +public class WorkflowEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { + + /** 工作流开关属性 */ + private static final String WORKFLOW_ENABLED = "flowable.enabled"; + + /** 自动配置排除列表属性 */ + private static final String AUTOCONFIGURE_EXCLUDE = "spring.autoconfigure.exclude"; + + /** Flowable 引擎相关的自动配置类 */ + private static final List FLOWABLE_AUTOCONFIGURATIONS = List.of( + "org.flowable.spring.boot.ProcessEngineAutoConfiguration", + "org.flowable.spring.boot.ProcessEngineServicesAutoConfiguration" + ); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + // 与 WorkflowModuleConfig 的条件一致:未配置视为关闭 + if ("true".equalsIgnoreCase(environment.getProperty(WORKFLOW_ENABLED))) { + return; + } + // 合并已有的排除项,避免覆盖用户自己的 spring.autoconfigure.exclude + List excluded = new ArrayList<>(Binder.get(environment) + .bind(AUTOCONFIGURE_EXCLUDE, Bindable.listOf(String.class)) + .orElseGet(List::of)); + for (String className : FLOWABLE_AUTOCONFIGURATIONS) { + if (!excluded.contains(className)) { + excluded.add(className); + } + } + Map properties = new HashMap<>(); + properties.put(AUTOCONFIGURE_EXCLUDE, String.join(",", excluded)); + environment.getPropertySources().addFirst(new MapPropertySource("workflowDisabled", properties)); + } + + /** + * 晚于 ConfigDataEnvironmentPostProcessor 执行,保证 profile 配置文件已加载 + */ + @Override + public int getOrder() { + return ConfigDataEnvironmentPostProcessor.ORDER + 1; + } +} diff --git a/src/main/java/com/youlai/boot/module/config/WorkflowModuleConfig.java b/src/main/java/com/youlai/boot/module/config/WorkflowModuleConfig.java new file mode 100644 index 00000000..d28c306d --- /dev/null +++ b/src/main/java/com/youlai/boot/module/config/WorkflowModuleConfig.java @@ -0,0 +1,19 @@ +package com.youlai.boot.module.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * 工作流模块装配 + * + *

仅当 flowable.enabled 为 true 时扫描工作流包,未配置视为关闭

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Configuration +@ConditionalOnProperty(name = "flowable.enabled", havingValue = "true") +@ComponentScan(basePackages = "com.youlai.boot.module.workflow") +public class WorkflowModuleConfig { +} diff --git a/src/main/java/com/youlai/boot/module/form/controller/FormController.java b/src/main/java/com/youlai/boot/module/form/controller/FormController.java index a00bdae0..fb65cff1 100644 --- a/src/main/java/com/youlai/boot/module/form/controller/FormController.java +++ b/src/main/java/com/youlai/boot/module/form/controller/FormController.java @@ -2,16 +2,18 @@ package com.youlai.boot.module.form.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.Result; -import com.youlai.boot.common.util.IPUtils; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.model.Option; +import com.youlai.boot.framework.web.util.IPUtils; import com.youlai.boot.module.form.model.form.FormDefinitionForm; import com.youlai.boot.module.form.model.form.FormMenuForm; -import com.youlai.boot.module.form.model.query.FormDataQuery; -import com.youlai.boot.module.form.model.query.FormDefinitionQuery; +import com.youlai.boot.module.form.model.query.FormDataPageQuery; +import com.youlai.boot.module.form.model.query.FormDefinitionPageQuery; import com.youlai.boot.module.form.model.vo.FormDataVO; import com.youlai.boot.module.form.model.vo.FormDefinitionPageVO; import com.youlai.boot.module.form.model.vo.FormMenuVO; @@ -28,13 +30,14 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.Arrays; +import java.util.List; import java.util.Map; /** * 表单控制层(表单定义、访问菜单、表单数据) * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Tag(name = "12.表单接口") @RestController @@ -49,12 +52,18 @@ public class FormController { @GetMapping @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.LIST) public PageResult getFormDefinitionPage( - FormDefinitionQuery queryParams + @Valid FormDefinitionPageQuery queryParams ) { Page result = formDefinitionService.getFormDefinitionPage(queryParams); return PageResult.success(result); } + @Operation(summary = "获取审批表单下拉选项(工作流设计器绑定表单用)") + @GetMapping("/options") + public Result>> listWorkflowFormOptions() { + return Result.success(formDefinitionService.listWorkflowFormOptions()); + } + @Operation(summary = "获取表单定义表单数据") @GetMapping("/{formId}/form") public Result getFormDefinitionForm( @@ -69,7 +78,7 @@ public class FormController { @PreAuthorize("@ss.hasPerm('form:definition:create')") @RepeatSubmit @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.INSERT) - public Result saveFormDefinition(@Valid @RequestBody FormDefinitionForm formData) { + public Result saveFormDefinition(@Valid @RequestBody FormDefinitionForm formData) { boolean result = formDefinitionService.saveFormDefinition(formData); return Result.judge(result); } @@ -78,9 +87,9 @@ public class FormController { @PutMapping("/{formId}") @PreAuthorize("@ss.hasPerm('form:definition:update')") @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.UPDATE) - public Result updateFormDefinition( + public Result updateFormDefinition( @Parameter(description = "表单ID") @PathVariable Long formId, - @RequestBody FormDefinitionForm formData + @RequestBody @Valid FormDefinitionForm formData ) { boolean result = formDefinitionService.updateFormDefinition(formId, formData); return Result.judge(result); @@ -145,7 +154,7 @@ public class FormController { @PreAuthorize("@ss.hasPerm('form:definition:update')") @RepeatSubmit @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.INSERT) - public Result saveFormMenu( + public Result saveFormMenu( @Parameter(description = "表单ID") @PathVariable Long formId, @Valid @RequestBody FormMenuForm menuForm ) { @@ -168,7 +177,7 @@ public class FormController { @Operation(summary = "匿名提交公开表单数据") @PostMapping("/public/{formKey}/data") @RepeatSubmit - public Result savePublicFormData( + public Result savePublicFormData( @Parameter(description = "表单唯一标识") @PathVariable String formKey, @RequestBody Map data, HttpServletRequest request @@ -186,7 +195,7 @@ public class FormController { @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.LIST) public PageResult getFormDataPage( @Parameter(description = "表单唯一标识") @PathVariable String formKey, - FormDataQuery queryParams + @Valid FormDataPageQuery queryParams ) { Page result = formDataService.getFormDataPage(formKey, queryParams); return PageResult.success(result); @@ -196,7 +205,7 @@ public class FormController { @PostMapping("/{formKey}/data") @RepeatSubmit @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.INSERT) - public Result saveFormData( + public Result saveFormData( @Parameter(description = "表单唯一标识") @PathVariable String formKey, @RequestBody Map data ) { @@ -212,6 +221,10 @@ public class FormController { @Parameter(description = "数据ID") @PathVariable Long dataId ) { FormDataVO dataVO = formDataService.getFormDataDetail(dataId); + // 直查数据详情时明确提示;工作流回显场景由服务层调用方自行降级 + if (dataVO == null) { + throw new BusinessException("表单数据不存在"); + } return Result.success(dataVO); } diff --git a/src/main/java/com/youlai/boot/module/form/converter/FormConverter.java b/src/main/java/com/youlai/boot/module/form/converter/FormConverter.java index 1eb60631..94a5bc22 100644 --- a/src/main/java/com/youlai/boot/module/form/converter/FormConverter.java +++ b/src/main/java/com/youlai/boot/module/form/converter/FormConverter.java @@ -10,7 +10,7 @@ import org.mapstruct.Mapper; * 表单定义 对象转换器 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Mapper(componentModel = "spring") public interface FormConverter { diff --git a/src/main/java/com/youlai/boot/module/form/enums/FormCategoryEnum.java b/src/main/java/com/youlai/boot/module/form/enums/FormCategoryEnum.java new file mode 100644 index 00000000..61e6b97e --- /dev/null +++ b/src/main/java/com/youlai/boot/module/form/enums/FormCategoryEnum.java @@ -0,0 +1,53 @@ +package com.youlai.boot.module.form.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import com.youlai.boot.common.base.IBaseEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +/** + * 表单类型枚举 + * + *

类型驱动消费方行为:设计器绑定流程时仅列出 workflow 类型, + * 避免"意见反馈"等通用收集表单混入流程表单候选项

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(enumAsRef = true) +@Getter +public enum FormCategoryEnum implements IBaseEnum { + + /** + * 通用表单:公开收集 / 菜单入口,与流程无关 + */ + NORMAL("normal", "普通表单"), + + /** + * 工作流表单:可被工作流绑定为发起 / 办理表单 + */ + WORKFLOW("workflow", "工作流表单"); + + @EnumValue + private final String value; + + @JsonValue + private final String label; + + FormCategoryEnum(String value, String label) { + this.value = value; + this.label = label; + } + + @Override + public String getValue() { + return this.value; + } + + @Override + public String getLabel() { + return this.label; + } + +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/module/form/enums/FormStatusEnum.java b/src/main/java/com/youlai/boot/module/form/enums/FormStatusEnum.java index d64cd17b..299e6810 100644 --- a/src/main/java/com/youlai/boot/module/form/enums/FormStatusEnum.java +++ b/src/main/java/com/youlai/boot/module/form/enums/FormStatusEnum.java @@ -12,7 +12,7 @@ import lombok.Getter; *

-1 为停用态,其余为正常生命周期

* * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Schema(enumAsRef = true) @Getter diff --git a/src/main/java/com/youlai/boot/module/form/mapper/FormDataMapper.java b/src/main/java/com/youlai/boot/module/form/mapper/FormDataMapper.java index 051b2249..16b67a0b 100644 --- a/src/main/java/com/youlai/boot/module/form/mapper/FormDataMapper.java +++ b/src/main/java/com/youlai/boot/module/form/mapper/FormDataMapper.java @@ -3,7 +3,7 @@ package com.youlai.boot.module.form.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.module.form.model.entity.FormData; -import com.youlai.boot.module.form.model.query.FormDataQuery; +import com.youlai.boot.module.form.model.query.FormDataPageQuery; import com.youlai.boot.module.form.model.vo.FormDataVO; import org.apache.ibatis.annotations.Mapper; @@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Mapper; * 表单数据 访问层 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Mapper public interface FormDataMapper extends BaseMapper { @@ -24,6 +24,6 @@ public interface FormDataMapper extends BaseMapper { * @param queryParams 查询参数 * @return 表单数据分页列表 */ - Page getFormDataPage(Page page, Long formId, FormDataQuery queryParams); + Page getFormDataPage(Page page, Long formId, FormDataPageQuery queryParams); } diff --git a/src/main/java/com/youlai/boot/module/form/mapper/FormDefinitionMapper.java b/src/main/java/com/youlai/boot/module/form/mapper/FormDefinitionMapper.java index 1284d3d9..39196b94 100644 --- a/src/main/java/com/youlai/boot/module/form/mapper/FormDefinitionMapper.java +++ b/src/main/java/com/youlai/boot/module/form/mapper/FormDefinitionMapper.java @@ -3,7 +3,7 @@ package com.youlai.boot.module.form.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.module.form.model.entity.FormDefinition; -import com.youlai.boot.module.form.model.query.FormDefinitionQuery; +import com.youlai.boot.module.form.model.query.FormDefinitionPageQuery; import com.youlai.boot.module.form.model.vo.FormDefinitionPageVO; import org.apache.ibatis.annotations.Mapper; @@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Mapper; * 表单定义 访问层 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Mapper public interface FormDefinitionMapper extends BaseMapper { @@ -23,6 +23,6 @@ public interface FormDefinitionMapper extends BaseMapper { * @param queryParams 查询参数 * @return 表单定义分页列表 */ - Page getFormDefinitionPage(Page page, FormDefinitionQuery queryParams); + Page getFormDefinitionPage(Page page, FormDefinitionPageQuery queryParams); } diff --git a/src/main/java/com/youlai/boot/module/form/mapper/FormSnapshotMapper.java b/src/main/java/com/youlai/boot/module/form/mapper/FormSnapshotMapper.java index 874ba2f3..a16e2505 100644 --- a/src/main/java/com/youlai/boot/module/form/mapper/FormSnapshotMapper.java +++ b/src/main/java/com/youlai/boot/module/form/mapper/FormSnapshotMapper.java @@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Mapper; * 表单版本快照访问层 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Mapper public interface FormSnapshotMapper extends BaseMapper { diff --git a/src/main/java/com/youlai/boot/module/form/model/entity/FormData.java b/src/main/java/com/youlai/boot/module/form/model/entity/FormData.java index 2586a0f9..f2e049f9 100644 --- a/src/main/java/com/youlai/boot/module/form/model/entity/FormData.java +++ b/src/main/java/com/youlai/boot/module/form/model/entity/FormData.java @@ -11,7 +11,7 @@ import lombok.EqualsAndHashCode; * 表单数据实体 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @EqualsAndHashCode(callSuper = true) @TableName("form_data") diff --git a/src/main/java/com/youlai/boot/module/form/model/entity/FormDefinition.java b/src/main/java/com/youlai/boot/module/form/model/entity/FormDefinition.java index fce32187..53c17da2 100644 --- a/src/main/java/com/youlai/boot/module/form/model/entity/FormDefinition.java +++ b/src/main/java/com/youlai/boot/module/form/model/entity/FormDefinition.java @@ -12,7 +12,7 @@ import lombok.EqualsAndHashCode; * 表单定义实体 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @EqualsAndHashCode(callSuper = true) @TableName("form_definition") @@ -56,6 +56,11 @@ public class FormDefinition extends BaseEntity { */ private Integer isPublic; + /** + * 表单类型(normal通用表单 workflow审批表单) + */ + private String category; + /** * 生成的访问菜单ID(NULL未生成,幂等防重复) */ diff --git a/src/main/java/com/youlai/boot/module/form/model/entity/FormSnapshot.java b/src/main/java/com/youlai/boot/module/form/model/entity/FormSnapshot.java index 46c7dfb5..d84aebf6 100644 --- a/src/main/java/com/youlai/boot/module/form/model/entity/FormSnapshot.java +++ b/src/main/java/com/youlai/boot/module/form/model/entity/FormSnapshot.java @@ -13,7 +13,7 @@ import lombok.EqualsAndHashCode; * 快照只增不改,审计字段仅保留 BaseEntity 公共部分

* * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @EqualsAndHashCode(callSuper = true) @TableName("form_snapshot") diff --git a/src/main/java/com/youlai/boot/module/form/model/form/FormDefinitionForm.java b/src/main/java/com/youlai/boot/module/form/model/form/FormDefinitionForm.java index 1fff0269..fbe7f06b 100644 --- a/src/main/java/com/youlai/boot/module/form/model/form/FormDefinitionForm.java +++ b/src/main/java/com/youlai/boot/module/form/model/form/FormDefinitionForm.java @@ -10,7 +10,7 @@ import lombok.Data; * 表单定义表单对象 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Schema(description = "表单定义") @Data @@ -43,4 +43,8 @@ public class FormDefinitionForm { @Schema(description = "是否允许匿名公开访问(0否 1是),公开访问总开关") private Integer isPublic; + @Schema(description = "表单类型(normal通用表单 workflow工作流表单,缺省normal)", example = "normal") + @Pattern(regexp = "^(normal|workflow)$", message = "表单类型不合法") + private String category; + } diff --git a/src/main/java/com/youlai/boot/module/form/model/form/FormMenuForm.java b/src/main/java/com/youlai/boot/module/form/model/form/FormMenuForm.java index 71619adb..f7c8df4c 100644 --- a/src/main/java/com/youlai/boot/module/form/model/form/FormMenuForm.java +++ b/src/main/java/com/youlai/boot/module/form/model/form/FormMenuForm.java @@ -11,7 +11,7 @@ import java.util.List; * 表单菜单发布表单对象(发布向导"入口配置"步骤提交) * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Schema(description = "表单菜单发布对象") @Data diff --git a/src/main/java/com/youlai/boot/module/form/model/query/FormDataQuery.java b/src/main/java/com/youlai/boot/module/form/model/query/FormDataPageQuery.java similarity index 63% rename from src/main/java/com/youlai/boot/module/form/model/query/FormDataQuery.java rename to src/main/java/com/youlai/boot/module/form/model/query/FormDataPageQuery.java index 6d24bbe2..577a3c37 100644 --- a/src/main/java/com/youlai/boot/module/form/model/query/FormDataQuery.java +++ b/src/main/java/com/youlai/boot/module/form/model/query/FormDataPageQuery.java @@ -6,14 +6,14 @@ import lombok.Data; import lombok.EqualsAndHashCode; /** - * 表单数据查询对象 + * 表单数据分页查询对象 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Data @EqualsAndHashCode(callSuper = false) -@Schema(description = "表单数据查询对象") -public class FormDataQuery extends BaseQuery { +@Schema(description = "表单数据分页查询对象") +public class FormDataPageQuery extends BaseQuery { } diff --git a/src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionQuery.java b/src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionPageQuery.java similarity index 62% rename from src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionQuery.java rename to src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionPageQuery.java index cdef485f..4f00d428 100644 --- a/src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionQuery.java +++ b/src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionPageQuery.java @@ -6,15 +6,15 @@ import lombok.Data; import lombok.EqualsAndHashCode; /** - * 表单定义查询对象 + * 表单定义分页查询对象 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Data @EqualsAndHashCode(callSuper = false) -@Schema(description = "表单定义查询对象") -public class FormDefinitionQuery extends BaseQuery { +@Schema(description = "表单定义分页查询对象") +public class FormDefinitionPageQuery extends BaseQuery { @Schema(description = "关键字(表单名称/标识)") private String keywords; @@ -22,4 +22,7 @@ public class FormDefinitionQuery extends BaseQuery { @Schema(description = "状态(0草稿 1已发布 -1已停用)") private Integer status; + @Schema(description = "表单类型(normal通用表单 workflow审批表单)") + private String category; + } diff --git a/src/main/java/com/youlai/boot/module/form/model/vo/FormDataVO.java b/src/main/java/com/youlai/boot/module/form/model/vo/FormDataVO.java index 571d6d35..1496cd0d 100644 --- a/src/main/java/com/youlai/boot/module/form/model/vo/FormDataVO.java +++ b/src/main/java/com/youlai/boot/module/form/model/vo/FormDataVO.java @@ -11,7 +11,7 @@ import java.time.LocalDateTime; * 表单数据VO(分页行与详情回显共用:数据只读,无独立表单对象) * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Schema(description = "表单数据对象") @Getter diff --git a/src/main/java/com/youlai/boot/module/form/model/vo/FormDefinitionPageVO.java b/src/main/java/com/youlai/boot/module/form/model/vo/FormDefinitionPageVO.java index f6100144..2c393075 100644 --- a/src/main/java/com/youlai/boot/module/form/model/vo/FormDefinitionPageVO.java +++ b/src/main/java/com/youlai/boot/module/form/model/vo/FormDefinitionPageVO.java @@ -10,7 +10,7 @@ import java.time.LocalDateTime; * 表单定义分页VO * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Schema(description = "表单定义分页对象") @Getter @@ -38,6 +38,9 @@ public class FormDefinitionPageVO { @Schema(description = "是否允许匿名公开访问(0否 1是)") private Integer isPublic; + @Schema(description = "表单类型(normal通用表单 workflow审批表单)") + private String category; + @Schema(description = "创建时间") private LocalDateTime createTime; diff --git a/src/main/java/com/youlai/boot/module/form/model/vo/FormMenuVO.java b/src/main/java/com/youlai/boot/module/form/model/vo/FormMenuVO.java index a891b357..b1de85d9 100644 --- a/src/main/java/com/youlai/boot/module/form/model/vo/FormMenuVO.java +++ b/src/main/java/com/youlai/boot/module/form/model/vo/FormMenuVO.java @@ -10,7 +10,7 @@ import java.util.List; * 表单菜单配置VO(发布向导回显已生成的菜单) * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Schema(description = "表单菜单配置对象") @Getter diff --git a/src/main/java/com/youlai/boot/module/form/model/vo/FormRenderVO.java b/src/main/java/com/youlai/boot/module/form/model/vo/FormRenderVO.java index f81905ac..5ef35561 100644 --- a/src/main/java/com/youlai/boot/module/form/model/vo/FormRenderVO.java +++ b/src/main/java/com/youlai/boot/module/form/model/vo/FormRenderVO.java @@ -8,7 +8,7 @@ import lombok.Setter; * 表单渲染VO(渲染端按 formKey 加载的已发布规则) * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Schema(description = "表单渲染对象") @Getter diff --git a/src/main/java/com/youlai/boot/module/form/service/FormDataService.java b/src/main/java/com/youlai/boot/module/form/service/FormDataService.java index ac882643..b1f40ecf 100644 --- a/src/main/java/com/youlai/boot/module/form/service/FormDataService.java +++ b/src/main/java/com/youlai/boot/module/form/service/FormDataService.java @@ -1,9 +1,7 @@ package com.youlai.boot.module.form.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.IService; -import com.youlai.boot.module.form.model.entity.FormData; -import com.youlai.boot.module.form.model.query.FormDataQuery; +import com.youlai.boot.module.form.model.query.FormDataPageQuery; import com.youlai.boot.module.form.model.vo.FormDataVO; import java.util.List; @@ -13,9 +11,9 @@ import java.util.Map; * 表单数据业务接口 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ -public interface FormDataService extends IService { +public interface FormDataService { /** * 提交表单数据(仅已发布表单可提交,提交时快照表单版本) @@ -28,6 +26,17 @@ public interface FormDataService extends IService { */ boolean saveFormData(String formKey, Map data); + /** + * 提交表单数据并返回数据ID + * + *

供工作流模块发起流程时建立 businessKey 关联(form_data.id),校验规则同 {@link #saveFormData}

+ * + * @param formKey 表单唯一标识 + * @param data 表单数据(field -> value 映射) + * @return 表单数据ID + */ + Long saveFormDataReturnId(String formKey, Map data); + /** * 匿名提交公开表单数据(公开分享链接入口) * @@ -48,7 +57,7 @@ public interface FormDataService extends IService { * @param queryParams 查询参数 * @return 表单数据分页列表 */ - Page getFormDataPage(String formKey, FormDataQuery queryParams); + Page getFormDataPage(String formKey, FormDataPageQuery queryParams); /** * 获取表单数据详情(只读回显) @@ -58,6 +67,16 @@ public interface FormDataService extends IService { */ FormDataVO getFormDataDetail(Long dataId); + /** + * 按表单标识删除全部提交数据(演示数据重置用) + * + *

跨模块窄接口:formKey 到 formId 的解析与删除条件封装在 form 模块内部, + * 调用方(workflow)不感知表单实体与查询条件

+ * + * @param formKey 表单唯一标识 + */ + void removeByFormKey(String formKey); + /** * 批量删除表单数据 * diff --git a/src/main/java/com/youlai/boot/module/form/service/FormDefinitionService.java b/src/main/java/com/youlai/boot/module/form/service/FormDefinitionService.java index 587b5221..d33af54d 100644 --- a/src/main/java/com/youlai/boot/module/form/service/FormDefinitionService.java +++ b/src/main/java/com/youlai/boot/module/form/service/FormDefinitionService.java @@ -1,11 +1,11 @@ package com.youlai.boot.module.form.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.common.model.Option; import com.youlai.boot.module.form.model.entity.FormDefinition; import com.youlai.boot.module.form.model.form.FormDefinitionForm; import com.youlai.boot.module.form.model.form.FormMenuForm; -import com.youlai.boot.module.form.model.query.FormDefinitionQuery; +import com.youlai.boot.module.form.model.query.FormDefinitionPageQuery; import com.youlai.boot.module.form.model.vo.FormDefinitionPageVO; import com.youlai.boot.module.form.model.vo.FormMenuVO; import com.youlai.boot.module.form.model.vo.FormRenderVO; @@ -16,9 +16,9 @@ import java.util.List; * 表单定义业务接口 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ -public interface FormDefinitionService extends IService { +public interface FormDefinitionService { /** * 表单定义分页列表 @@ -26,7 +26,15 @@ public interface FormDefinitionService extends IService { * @param queryParams 查询参数 * @return 表单定义分页列表 */ - Page getFormDefinitionPage(FormDefinitionQuery queryParams); + Page getFormDefinitionPage(FormDefinitionPageQuery queryParams); + + /** + * 按主键查询表单定义(供模块内回退取规则等场景) + * + * @param id 表单ID + * @return 表单定义实体;不存在返回 null + */ + FormDefinition getFormDefinition(Long id); /** * 获取表单定义表单数据(编辑回显) @@ -119,4 +127,14 @@ public interface FormDefinitionService extends IService { */ FormMenuVO getFormMenu(Long formId); + /** + * 审批表单下拉选项(供工作流设计器绑定表单) + * + *

仅返回已发布且类型为 workflow 的表单;value 为 formKey(BPMN formKey 引用), + * 草稿/停用表单结构未定不应被流程绑定

+ * + * @return 审批表单下拉选项列表 + */ + List> listWorkflowFormOptions(); + } diff --git a/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java b/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java index 02185698..40778ab4 100644 --- a/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java +++ b/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java @@ -13,7 +13,7 @@ import com.youlai.boot.module.form.mapper.FormSnapshotMapper; import com.youlai.boot.module.form.model.entity.FormData; import com.youlai.boot.module.form.model.entity.FormDefinition; import com.youlai.boot.module.form.model.entity.FormSnapshot; -import com.youlai.boot.module.form.model.query.FormDataQuery; +import com.youlai.boot.module.form.model.query.FormDataPageQuery; import com.youlai.boot.module.form.model.vo.FormDataVO; import com.youlai.boot.module.form.service.FormDataService; import com.youlai.boot.module.form.service.FormDefinitionService; @@ -31,7 +31,7 @@ import java.util.Map; * 表单数据业务实现类 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Service @RequiredArgsConstructor @@ -55,13 +55,22 @@ public class FormDataServiceImpl extends ServiceImpl i /** * 提交表单数据 * - *

仅已发布表单可提交;提交时快照表单版本,供数据追溯到产出规则

- * * @param formKey 表单唯一标识 * @param data 表单数据(field -> value 映射) */ @Override public boolean saveFormData(String formKey, Map data) { + return this.saveFormDataReturnId(formKey, data) != null; + } + + /** + * 提交表单数据并返回数据ID + * + * @param formKey 表单唯一标识 + * @param data 表单数据(field -> value 映射) + */ + @Override + public Long saveFormDataReturnId(String formKey, Map data) { FormDefinition form = formDefinitionService.getByFormKey(formKey); if (!FormStatusEnum.PUBLISHED.getValue().equals(form.getStatus())) { throw new BusinessException("表单未发布或已停用"); @@ -86,7 +95,7 @@ public class FormDataServiceImpl extends ServiceImpl i } checkPublicSubmitLimit(formKey, ip); log.info("公开表单匿名提交 formKey={} ip={}", formKey, ip); - return this.persistFormData(form, data); + return this.persistFormData(form, data) != null; } /** @@ -94,15 +103,16 @@ public class FormDataServiceImpl extends ServiceImpl i * * @param form 表单定义 * @param data 提交数据 + * @return 数据ID(落库失败为 null) */ - private boolean persistFormData(FormDefinition form, Map data) { + private Long persistFormData(FormDefinition form, Map data) { Map filteredData = FormRuleValidator.validateAndFilter(form.getFormJson(), data); FormData entity = new FormData(); entity.setFormId(form.getId()); entity.setFormVersion(form.getVersion()); entity.setDataJson(JSONUtil.toJsonStr(filteredData)); - return this.save(entity); + return this.save(entity) ? entity.getId() : null; } /** @@ -138,7 +148,7 @@ public class FormDataServiceImpl extends ServiceImpl i * @param queryParams 查询参数 */ @Override - public Page getFormDataPage(String formKey, FormDataQuery queryParams) { + public Page getFormDataPage(String formKey, FormDataPageQuery queryParams) { FormDefinition form = formDefinitionService.getByFormKey(formKey); int pageNum = queryParams.getPageNum(); int pageSize = queryParams.getPageSize(); @@ -148,13 +158,15 @@ public class FormDataServiceImpl extends ServiceImpl i /** * 获取表单数据详情(只读回显) * + *

数据不存在时返回 null,由调用方决定语义:控制器直查需提示用户,工作流回显需静默降级

+ * * @param dataId 数据ID */ @Override public FormDataVO getFormDataDetail(Long dataId) { FormData entity = this.getById(dataId); if (entity == null) { - throw new BusinessException("表单数据不存在"); + return null; } FormDataVO vo = new FormDataVO(); vo.setId(entity.getId()); @@ -186,7 +198,7 @@ public class FormDataServiceImpl extends ServiceImpl i vo.setOptionsJson(snapshot.getOptionsJson()); return; } - FormDefinition form = formDefinitionService.getById(formId); + FormDefinition form = formDefinitionService.getFormDefinition(formId); if (form != null) { vo.setFormJson(form.getFormJson()); vo.setOptionsJson(form.getOptionsJson()); @@ -203,4 +215,21 @@ public class FormDataServiceImpl extends ServiceImpl i this.removeByIds(ids); } + /** + * 按表单标识删除全部提交数据(演示数据重置用) + * + *

formKey 到 formId 的解析与删除条件封装在本模块内部,跨模块调用方不感知表单实体与查询条件

+ * + * @param formKey 表单唯一标识 + */ + @Override + public void removeByFormKey(String formKey) { + FormDefinition form = formDefinitionService.getByFormKey(formKey); + if (form == null) { + return; + } + this.remove(new LambdaQueryWrapper() + .eq(FormData::getFormId, form.getId())); + } + } diff --git a/src/main/java/com/youlai/boot/module/form/service/impl/FormDefinitionServiceImpl.java b/src/main/java/com/youlai/boot/module/form/service/impl/FormDefinitionServiceImpl.java index a7b70755..b31f645c 100644 --- a/src/main/java/com/youlai/boot/module/form/service/impl/FormDefinitionServiceImpl.java +++ b/src/main/java/com/youlai/boot/module/form/service/impl/FormDefinitionServiceImpl.java @@ -5,20 +5,25 @@ import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.model.KeyValue; +import com.youlai.boot.common.model.Option; import com.youlai.boot.module.form.converter.FormConverter; +import com.youlai.boot.module.form.enums.FormCategoryEnum; import com.youlai.boot.module.form.enums.FormStatusEnum; +import com.youlai.boot.module.form.mapper.FormDataMapper; import com.youlai.boot.module.form.mapper.FormDefinitionMapper; import com.youlai.boot.module.form.mapper.FormSnapshotMapper; +import com.youlai.boot.module.form.model.entity.FormData; import com.youlai.boot.module.form.model.entity.FormDefinition; import com.youlai.boot.module.form.model.entity.FormSnapshot; import com.youlai.boot.module.form.model.form.FormDefinitionForm; import com.youlai.boot.module.form.model.form.FormMenuForm; -import com.youlai.boot.module.form.model.query.FormDefinitionQuery; +import com.youlai.boot.module.form.model.query.FormDefinitionPageQuery; import com.youlai.boot.module.form.model.vo.FormDefinitionPageVO; import com.youlai.boot.module.form.model.vo.FormMenuVO; import com.youlai.boot.module.form.model.vo.FormRenderVO; @@ -38,13 +43,14 @@ import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * 表单定义业务实现类 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ @Service @RequiredArgsConstructor @@ -55,6 +61,16 @@ public class FormDefinitionServiceImpl extends ServiceImpl getFormDefinitionPage(FormDefinitionQuery queryParams) { + public Page getFormDefinitionPage(FormDefinitionPageQuery queryParams) { int pageNum = queryParams.getPageNum(); int pageSize = queryParams.getPageSize(); return this.baseMapper.getFormDefinitionPage(new Page<>(pageNum, pageSize), queryParams); } + /** + * 按主键查询表单定义 + * + * @param id 表单ID + */ + @Override + public FormDefinition getFormDefinition(Long id) { + return this.getById(id); + } + /** * 获取表单定义表单数据 * @@ -100,6 +126,10 @@ public class FormDefinitionServiceImpl extends ServiceImpl级联清理关联数据,避免孤儿残留:生成的访问菜单(含其角色授权)、 + * 提交数据、版本快照一并删除

+ * * @param ids 表单ID列表 */ @Override + @Transactional(rollbackFor = Exception.class) public void removeFormDefinition(List ids) { List forms = this.listByIds(ids); this.removeByIds(ids); - // 已发布表单的渲染缓存一并清除,防止删除后仍可渲染 - forms.stream() - .filter(form -> FormStatusEnum.PUBLISHED.getValue().equals(form.getStatus())) - .forEach(form -> this.evictRenderCache(form.getFormKey())); + for (FormDefinition form : forms) { + // 级联删除已生成的访问菜单(deleteMenu 内部同步清理其角色授权并刷新权限缓存) + if (form.getMenuId() != null) { + menuService.deleteMenu(form.getMenuId()); + } + // 同步清理提交数据与版本快照,防止孤儿数据与死链接 + formDataMapper.delete(new LambdaQueryWrapper() + .eq(FormData::getFormId, form.getId())); + formSnapshotMapper.delete(new LambdaQueryWrapper() + .eq(FormSnapshot::getFormId, form.getId())); + // 已发布表单的渲染缓存一并清除,防止删除后仍可渲染 + if (FormStatusEnum.PUBLISHED.getValue().equals(form.getStatus())) { + this.evictRenderCache(form.getFormKey()); + } + } } /** * 发布表单 * + *

状态翻转 + 版本自增走单条原子 UPDATE,从 DB 侧消除并发发布的重号竞态; + * 更新成功后回读最新表单,用同一份规则固化版本快照

+ * * @param id 表单ID */ @Override + @Transactional(rollbackFor = Exception.class) public boolean publishFormDefinition(Long id) { FormDefinition entity = this.getById(id); if (entity == null) { throw new BusinessException("表单不存在"); } - FormDefinition updateEntity = new FormDefinition(); - updateEntity.setId(id); - updateEntity.setStatus(FormStatusEnum.PUBLISHED.getValue()); - // 每次发布版本+1,供数据提交时记录版本快照 - int newVersion = entity.getVersion() + 1; - updateEntity.setVersion(newVersion); - boolean result = this.updateById(updateEntity); + boolean result = this.update(new LambdaUpdateWrapper() + .eq(FormDefinition::getId, id) + .set(FormDefinition::getStatus, FormStatusEnum.PUBLISHED.getValue()) + .setSql("version = version + 1") + ); if (result) { + // 回读原子自增后的最新表单,快照记录与当前定义一致的规则 + FormDefinition published = this.getById(id); // 固化版本快照:数据回显按提交时版本加载,防止规则变更(字段删除/改名)导致历史数据漂移 FormSnapshot snapshot = new FormSnapshot(); snapshot.setFormId(id); - snapshot.setVersion(newVersion); - snapshot.setFormJson(entity.getFormJson()); - snapshot.setOptionsJson(entity.getOptionsJson()); + snapshot.setVersion(published.getVersion()); + snapshot.setFormJson(published.getFormJson()); + snapshot.setOptionsJson(published.getOptionsJson()); formSnapshotMapper.insert(snapshot); - this.evictRenderCache(entity.getFormKey()); + this.evictRenderCache(published.getFormKey()); } return result; } @@ -220,7 +271,7 @@ public class FormDefinitionServiceImpl extends ServiceImpl仅返回已发布且类型为 workflow 的表单;value 为 formKey(BPMN formKey 引用), + * 草稿/停用表单结构未定不应被流程绑定

+ * + * @return 审批表单下拉选项列表 + */ + @Override + public List> listWorkflowFormOptions() { + return this.list(new LambdaQueryWrapper() + .eq(FormDefinition::getCategory, FormCategoryEnum.WORKFLOW.getValue()) + .eq(FormDefinition::getStatus, FormStatusEnum.PUBLISHED.getValue()) + .select(FormDefinition::getFormKey, FormDefinition::getFormName) + .orderByAsc(FormDefinition::getCreateTime) + ).stream() + .map(form -> new Option<>(form.getFormKey(), form.getFormName())) + .toList(); + } + } diff --git a/src/main/java/com/youlai/boot/module/form/util/FormRuleValidator.java b/src/main/java/com/youlai/boot/module/form/util/FormRuleValidator.java index 8e2bbb24..60834c36 100644 --- a/src/main/java/com/youlai/boot/module/form/util/FormRuleValidator.java +++ b/src/main/java/com/youlai/boot/module/form/util/FormRuleValidator.java @@ -17,7 +17,7 @@ import java.util.Map; * 并按 rule 声明的 field 过滤数据

* * @author Ray.Hao - * @since 4.8.0 + * @since 4.9.0 */ public final class FormRuleValidator { diff --git a/src/main/java/com/youlai/boot/module/workflow/config/WorkflowEngineConfig.java b/src/main/java/com/youlai/boot/module/workflow/config/WorkflowEngineConfig.java new file mode 100644 index 00000000..6575ac66 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/config/WorkflowEngineConfig.java @@ -0,0 +1,38 @@ +package com.youlai.boot.module.workflow.config; + +import com.youlai.boot.module.workflow.listener.ProcessCompletedListener; +import org.flowable.common.engine.api.delegate.event.FlowableEventListener; +import org.flowable.spring.SpringProcessEngineConfiguration; +import org.flowable.spring.boot.EngineConfigurationConfigurer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; + +/** + * 工作流引擎配置 + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Configuration(proxyBeanMethods = false) +public class WorkflowEngineConfig { + + /** + * 流程完成事件监听器登记到引擎全局(任意流程结束均派发,由监听器按定义 Key 路由业务后处理) + */ + @Bean + public EngineConfigurationConfigurer processCompletedListenerRegistrar( + ProcessCompletedListener listener) { + return configuration -> { + // 追加而非覆盖:保留引擎/starter 已登记的监听器 + List listeners = new ArrayList<>(); + if (configuration.getEventListeners() != null) { + listeners.addAll(configuration.getEventListeners()); + } + listeners.add(listener); + configuration.setEventListeners(listeners); + }; + } +} diff --git a/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowDefinitionController.java b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowDefinitionController.java new file mode 100644 index 00000000..a44163ba --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowDefinitionController.java @@ -0,0 +1,69 @@ +package com.youlai.boot.module.workflow.controller; + +import com.youlai.boot.common.result.Result; +import com.youlai.boot.module.workflow.model.form.DefinitionStateForm; +import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; +import com.youlai.boot.module.workflow.model.vo.ProcessStageVO; +import com.youlai.boot.module.workflow.model.vo.StartableProcessVO; +import com.youlai.boot.module.workflow.service.WorkflowDefinitionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 流程定义控制层(发布产物:发布后供发起、审批与流程图查看) + * + *

定义的发布入口在模型接口(deploy),删除随模型级联,本层不再提供独立分页与删除

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Tag(name = "13.工作流定义接口") +@RestController +@RequestMapping("/api/v1/workflow/definitions") +@RequiredArgsConstructor +public class WorkflowDefinitionController { + + private final WorkflowDefinitionService workflowDefinitionService; + + @Operation(summary = "可发起流程列表(发起页下拉数据源)") + @GetMapping("/startable") + public Result> listStartableProcesses() { + return Result.success(workflowDefinitionService.listStartableProcesses()); + } + + @Operation(summary = "流程审批阶段预览(发起页/审批弹窗展示流程走向与各环节办理人)") + @GetMapping("/{definitionId}/stages") + public Result> listProcessStages( + @Parameter(description = "流程定义ID") @PathVariable String definitionId + ) { + return Result.success(workflowDefinitionService.listProcessStages(definitionId)); + } + + @Operation(summary = "获取流程定义 BPMN XML(流程图查看)") + @GetMapping("/{definitionId}/xml") + @PreAuthorize("@ss.hasPerm('workflow:model:list')") + public Result getDefinitionXml( + @Parameter(description = "流程定义ID") @PathVariable String definitionId + ) { + return Result.success(workflowDefinitionService.getDefinitionXml(definitionId)); + } + + @Operation(summary = "启用/停用流程(停用后不可发起新流程,运行中的实例不受影响)") + @PutMapping("/{definitionId}/state") + @PreAuthorize("@ss.hasPerm('workflow:definition:update')") + public Result updateDefinitionState( + @Parameter(description = "流程定义ID") @PathVariable String definitionId, + @Valid @RequestBody DefinitionStateForm formData + ) { + workflowDefinitionService.updateDefinitionState(definitionId, formData); + return Result.success(); + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowInstanceController.java b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowInstanceController.java new file mode 100644 index 00000000..ddc09a38 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowInstanceController.java @@ -0,0 +1,92 @@ +package com.youlai.boot.module.workflow.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.module.workflow.model.form.StartProcessForm; +import com.youlai.boot.module.workflow.model.query.WorkflowInstancePageQuery; +import com.youlai.boot.module.workflow.model.vo.InstanceDetailVO; +import com.youlai.boot.module.workflow.model.vo.InstancePageVO; +import com.youlai.boot.module.workflow.model.vo.ProcessDiagramVO; +import com.youlai.boot.module.workflow.service.WorkflowInstanceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +/** + * 流程实例控制层(发起、我的流程、详情、流程图、撤销/终止) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Tag(name = "13.工作流实例接口") +@RestController +@RequestMapping("/api/v1/workflow/instances") +@RequiredArgsConstructor +public class WorkflowInstanceController { + + private final WorkflowInstanceService workflowInstanceService; + + @Operation(summary = "发起流程") + @PostMapping + @RepeatSubmit + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.INSERT, title = "发起流程") + public Result startProcess(@Valid @RequestBody StartProcessForm formData) { + return Result.success(workflowInstanceService.startProcess(formData)); + } + + @Operation(summary = "我的流程分页列表") + @GetMapping + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.LIST, title = "我的流程") + public PageResult getInstancePage(@Valid WorkflowInstancePageQuery queryParams) { + Page result = workflowInstanceService.getInstancePage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "获取流程图数据(节点高亮)") + @GetMapping("/{instanceId}/diagram") + public Result getProcessDiagram( + @Parameter(description = "流程实例ID") @PathVariable String instanceId + ) { + return Result.success(workflowInstanceService.getProcessDiagram(instanceId)); + } + + @Operation(summary = "获取流程实例详情") + @GetMapping("/{instanceId}") + public Result getInstanceDetail( + @Parameter(description = "流程实例ID") @PathVariable String instanceId + ) { + return Result.success(workflowInstanceService.getInstanceDetail(instanceId)); + } + + @Operation(summary = "撤销流程(仅发起人)") + @PutMapping("/{instanceId}/cancel") + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.DELETE, title = "撤销流程") + public Result cancelProcessInstance( + @Parameter(description = "流程实例ID") @PathVariable String instanceId + ) { + workflowInstanceService.cancelProcessInstance(instanceId); + return Result.success(); + } + + @Operation(summary = "终止流程(管理员)") + @PutMapping("/{instanceId}/terminate") + @PreAuthorize("@ss.hasPerm('workflow:instance:terminate')") + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.DELETE, title = "终止流程") + public Result terminateProcessInstance( + @Parameter(description = "流程实例ID") @PathVariable String instanceId, + @Parameter(description = "终止原因") @RequestParam(required = false) String reason + ) { + workflowInstanceService.terminateProcessInstance(instanceId, reason); + return Result.success(); + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowModelController.java b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowModelController.java new file mode 100644 index 00000000..0d1448af --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowModelController.java @@ -0,0 +1,127 @@ +package com.youlai.boot.module.workflow.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.module.workflow.model.form.ModelForm; +import com.youlai.boot.module.workflow.model.form.ModelXmlForm; +import com.youlai.boot.module.workflow.model.query.WorkflowModelPageQuery; +import com.youlai.boot.module.workflow.model.vo.ModelVO; +import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; +import com.youlai.boot.module.workflow.service.WorkflowDemoService; +import com.youlai.boot.module.workflow.service.WorkflowModelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; + +/** + * 流程模型控制层(设计器侧:模型 CRUD、XML、部署) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Tag(name = "13.工作流模型接口") +@RestController +@RequestMapping("/api/v1/workflow/models") +@RequiredArgsConstructor +public class WorkflowModelController { + + private final WorkflowModelService workflowModelService; + private final WorkflowDemoService workflowDemoService; + + @Operation(summary = "流程模型分页列表") + @GetMapping + @PreAuthorize("@ss.hasPerm('workflow:model:list')") + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.LIST) + public PageResult getModelPage(@Valid WorkflowModelPageQuery queryParams) { + Page result = workflowModelService.getModelPage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "新增流程模型") + @PostMapping + @PreAuthorize("@ss.hasPerm('workflow:model:create')") + @RepeatSubmit + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.INSERT) + public Result saveModel(@Valid @RequestBody ModelForm formData) { + boolean result = workflowModelService.saveModel(formData); + return Result.judge(result); + } + + @Operation(summary = "修改流程模型") + @PutMapping("/{modelId}") + @PreAuthorize("@ss.hasPerm('workflow:model:update')") + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.UPDATE) + public Result updateModel( + @Parameter(description = "模型ID") @PathVariable String modelId, + @Valid @RequestBody ModelForm formData + ) { + boolean result = workflowModelService.updateModel(modelId, formData); + return Result.judge(result); + } + + @Operation(summary = "删除流程模型") + @DeleteMapping("/{ids}") + @PreAuthorize("@ss.hasPerm('workflow:model:delete')") + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.DELETE) + public Result removeModels( + @Parameter(description = "模型ID,多个以英文逗号(,)拼接") @PathVariable String ids + ) { + workflowModelService.removeModels(Arrays.asList(ids.split(","))); + return Result.success(); + } + + @Operation(summary = "获取模型 BPMN XML(设计器回显)") + @GetMapping("/{modelId}/xml") + @PreAuthorize("@ss.hasPerm('workflow:model:list')") + public Result getModelXml( + @Parameter(description = "模型ID") @PathVariable String modelId + ) { + return Result.success(workflowModelService.getModelXml(modelId)); + } + + @Operation(summary = "保存模型 BPMN XML(设计器保存)") + @PutMapping("/{modelId}/xml") + @PreAuthorize("@ss.hasPerm('workflow:model:update')") + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.UPDATE) + public Result saveModelXml( + @Parameter(description = "模型ID") @PathVariable String modelId, + @Valid @RequestBody ModelXmlForm formData + ) { + boolean result = workflowModelService.saveModelXml(modelId, formData); + return Result.judge(result); + } + + @Operation(summary = "部署流程模型") + @PostMapping("/{modelId}/deploy") + @PreAuthorize("@ss.hasPerm('workflow:model:deploy')") + @RepeatSubmit + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.INSERT, title = "部署流程") + public Result deployModel( + @Parameter(description = "模型ID") @PathVariable String modelId + ) { + workflowModelService.deployModel(modelId); + return Result.success(); + } + + @Operation(summary = "重置工作流数据", description = "清空所有流程(含用户自建)的模型、定义、实例与历史,清理关联表单数据后重建初始演示流程") + @PostMapping("/demo/reset") + @PreAuthorize("@ss.hasPerm('workflow:model:reset')") + @RepeatSubmit + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.DELETE, title = "重置工作流数据") + public Result resetDemoProcesses() { + workflowDemoService.reset(); + return Result.success(); + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowTaskController.java b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowTaskController.java new file mode 100644 index 00000000..ed29fa28 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowTaskController.java @@ -0,0 +1,99 @@ +package com.youlai.boot.module.workflow.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.PageResult; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.module.workflow.model.form.CompleteTaskForm; +import com.youlai.boot.module.workflow.model.form.RejectTaskForm; +import com.youlai.boot.module.workflow.model.query.WorkflowTaskPageQuery; +import com.youlai.boot.module.workflow.model.vo.DoneTaskVO; +import com.youlai.boot.module.workflow.model.vo.RejectTargetVO; +import com.youlai.boot.module.workflow.model.vo.TaskDetailVO; +import com.youlai.boot.module.workflow.model.vo.TodoTaskVO; +import com.youlai.boot.module.workflow.service.WorkflowTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 流程任务控制层(待办/已办、审批通过/驳回) + * + *

待办、已办为当前用户自己的数据,仅登录校验、不加权限注解

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Tag(name = "13.工作流任务接口") +@RestController +@RequestMapping("/api/v1/workflow/tasks") +@RequiredArgsConstructor +public class WorkflowTaskController { + + private final WorkflowTaskService workflowTaskService; + + @Operation(summary = "待办任务分页列表") + @GetMapping("/todo") + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.LIST, title = "待办任务") + public PageResult getTodoTaskPage(@Valid WorkflowTaskPageQuery queryParams) { + Page result = workflowTaskService.getTodoTaskPage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "已办任务分页列表") + @GetMapping("/done") + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.LIST, title = "已办任务") + public PageResult getDoneTaskPage(@Valid WorkflowTaskPageQuery queryParams) { + Page result = workflowTaskService.getDoneTaskPage(queryParams); + return PageResult.success(result); + } + + @Operation(summary = "获取待办任务详情(发起表单回显 + 审批记录)") + @GetMapping("/{taskId}") + public Result getTaskDetail( + @Parameter(description = "任务ID") @PathVariable String taskId + ) { + return Result.success(workflowTaskService.getTaskDetail(taskId)); + } + + @Operation(summary = "获取驳回目标节点列表") + @GetMapping("/{taskId}/reject-targets") + public Result> listRejectTargets( + @Parameter(description = "任务ID") @PathVariable String taskId + ) { + return Result.success(workflowTaskService.listRejectTargets(taskId)); + } + + @Operation(summary = "审批通过") + @PutMapping("/{taskId}/complete") + @RepeatSubmit + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.UPDATE, title = "审批通过") + public Result completeTask( + @Parameter(description = "任务ID") @PathVariable String taskId, + @Valid @RequestBody CompleteTaskForm formData + ) { + workflowTaskService.completeTask(taskId, formData); + return Result.success(); + } + + @Operation(summary = "驳回") + @PutMapping("/{taskId}/reject") + @RepeatSubmit + @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.UPDATE, title = "驳回") + public Result rejectTask( + @Parameter(description = "任务ID") @PathVariable String taskId, + @Valid @RequestBody RejectTaskForm formData + ) { + workflowTaskService.rejectTask(taskId, formData); + return Result.success(); + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/enums/InstanceStatusEnum.java b/src/main/java/com/youlai/boot/module/workflow/enums/InstanceStatusEnum.java new file mode 100644 index 00000000..4fefb7c1 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/enums/InstanceStatusEnum.java @@ -0,0 +1,40 @@ +package com.youlai.boot.module.workflow.enums; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; + +/** + * 流程实例状态枚举 + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Getter +public enum InstanceStatusEnum { + + /** + * 运行中 + */ + RUNNING("running", "运行中"), + + /** + * 已完成(正常走完结束节点) + */ + FINISHED("finished", "已完成"), + + /** + * 已终止(被删除,含管理员终止与发起人撤销) + */ + TERMINATED("terminated", "已终止"); + + @JsonValue + private final String value; + + private final String label; + + InstanceStatusEnum(String value, String label) { + this.value = value; + this.label = label; + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/handler/ProcessCompletedContext.java b/src/main/java/com/youlai/boot/module/workflow/handler/ProcessCompletedContext.java new file mode 100644 index 00000000..5ee1fd4a --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/handler/ProcessCompletedContext.java @@ -0,0 +1,18 @@ +package com.youlai.boot.module.workflow.handler; + +/** + * 流程完成上下文(路由业务后处理时携带的实例信息) + * + * @param processInstanceId 流程实例ID + * @param processDefinitionKey 流程定义Key(BPMN 中 process 节点的 id) + * @param businessKey 业务Key(本系统约定为发起时提交的表单数据ID) + * @param startUserId 发起人账号(发起时写入 ACT_HI_PROCINST.START_USER_ID_) + * @author Ray.Hao + * @since 4.9.0 + */ +public record ProcessCompletedContext( + String processInstanceId, + String processDefinitionKey, + String businessKey, + String startUserId) { +} diff --git a/src/main/java/com/youlai/boot/module/workflow/handler/ProcessCompletedHandler.java b/src/main/java/com/youlai/boot/module/workflow/handler/ProcessCompletedHandler.java new file mode 100644 index 00000000..1d916e1e --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/handler/ProcessCompletedHandler.java @@ -0,0 +1,26 @@ +package com.youlai.boot.module.workflow.handler; + +/** + * 流程审批通过后的业务处理器(扩展点) + * + *

一个流程定义 Key 对应一个处理器,由 ProcessCompletedListener 统一路由; + * 新增"审批通过后处理"(如报销入账、采购入库、离职禁用账号)只需实现本接口并注册为 Spring Bean, + * 无需改动审批与引擎代码

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +public interface ProcessCompletedHandler { + + /** + * 适配的流程定义 Key(BPMN 中 process 节点的 id,如 expense) + */ + String getProcessDefinitionKey(); + + /** + * 审批通过后的业务处理 + * + * @param context 流程完成上下文 + */ + void handle(ProcessCompletedContext context); +} diff --git a/src/main/java/com/youlai/boot/module/workflow/handler/expense/ExpenseProcessCompletedHandler.java b/src/main/java/com/youlai/boot/module/workflow/handler/expense/ExpenseProcessCompletedHandler.java new file mode 100644 index 00000000..75684459 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/handler/expense/ExpenseProcessCompletedHandler.java @@ -0,0 +1,47 @@ +package com.youlai.boot.module.workflow.handler.expense; + +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.module.form.model.vo.FormDataVO; +import com.youlai.boot.module.form.service.FormDataService; +import com.youlai.boot.module.workflow.handler.ProcessCompletedContext; +import com.youlai.boot.module.workflow.handler.ProcessCompletedHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 报销流程审批通过后的业务处理(示例:财务入账) + * + *

演示项目的入账为日志占位,未真实对接财务系统; + * businessKey 即发起时提交的报销表单数据ID,可回读申请人填写的金额等内容

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpenseProcessCompletedHandler implements ProcessCompletedHandler { + + private final FormDataService formDataService; + + /** + * 适配的流程定义 Key:报销(对应 expense.bpmn20.xml 的 process id) + */ + @Override + public String getProcessDefinitionKey() { + return "expense"; + } + + @Override + public void handle(ProcessCompletedContext context) { + if (StrUtil.isBlank(context.businessKey())) { + log.info("[报销审批通过] 发起人={},实例未关联表单数据,跳过入账", context.startUserId()); + return; + } + FormDataVO formData = formDataService.getFormDataDetail(Long.valueOf(context.businessKey())); + // TODO 正式环境:调用财务系统按报销金额生成入账凭证,并将凭证号回写业务表 + log.info("[报销审批通过] 发起人={},报销内容={} → 调用财务系统入账(演示占位,未真实执行)", + context.startUserId(), formData == null ? null : formData.getDataJson()); + } +} diff --git a/src/main/java/com/youlai/boot/module/workflow/handler/purchase/PurchaseProcessCompletedHandler.java b/src/main/java/com/youlai/boot/module/workflow/handler/purchase/PurchaseProcessCompletedHandler.java new file mode 100644 index 00000000..e87a2ff0 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/handler/purchase/PurchaseProcessCompletedHandler.java @@ -0,0 +1,47 @@ +package com.youlai.boot.module.workflow.handler.purchase; + +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.module.form.model.vo.FormDataVO; +import com.youlai.boot.module.form.service.FormDataService; +import com.youlai.boot.module.workflow.handler.ProcessCompletedContext; +import com.youlai.boot.module.workflow.handler.ProcessCompletedHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 采购流程审批通过后的业务处理(示例:采购入库) + * + *

演示项目的入库为日志占位,未真实对接仓储系统; + * 正式对接时在 handle 中完成入库单生成与库存变更

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PurchaseProcessCompletedHandler implements ProcessCompletedHandler { + + private final FormDataService formDataService; + + /** + * 适配的流程定义 Key:采购(对应 purchase.bpmn20.xml 的 process id) + */ + @Override + public String getProcessDefinitionKey() { + return "purchase"; + } + + @Override + public void handle(ProcessCompletedContext context) { + if (StrUtil.isBlank(context.businessKey())) { + log.info("[采购审批通过] 发起人={},实例未关联表单数据,跳过入库", context.startUserId()); + return; + } + FormDataVO formData = formDataService.getFormDataDetail(Long.valueOf(context.businessKey())); + // TODO 正式环境:调用仓储系统生成入库单并变更库存 + log.info("[采购审批通过] 发起人={},采购内容={} → 调用仓储系统入库(演示占位,未真实执行)", + context.startUserId(), formData == null ? null : formData.getDataJson()); + } +} diff --git a/src/main/java/com/youlai/boot/module/workflow/listener/ProcessCompletedListener.java b/src/main/java/com/youlai/boot/module/workflow/listener/ProcessCompletedListener.java new file mode 100644 index 00000000..83ab0230 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/listener/ProcessCompletedListener.java @@ -0,0 +1,93 @@ +package com.youlai.boot.module.workflow.listener; + +import com.youlai.boot.module.workflow.handler.ProcessCompletedContext; +import com.youlai.boot.module.workflow.handler.ProcessCompletedHandler; +import lombok.extern.slf4j.Slf4j; +import org.flowable.common.engine.api.delegate.event.FlowableEngineEvent; +import org.flowable.common.engine.api.delegate.event.FlowableEngineEventType; +import org.flowable.common.engine.api.delegate.event.FlowableEvent; +import org.flowable.common.engine.api.delegate.event.FlowableEventListener; +import org.flowable.engine.HistoryService; +import org.flowable.engine.history.HistoricProcessInstance; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 流程完成事件监听器:审批通过(实例正常结束)后按流程定义 Key 路由到业务处理器 + * + *

挂在引擎全局(见 WorkflowEngineConfig),任意流程结束均会派发本监听器; + * 只响应 PROCESS_COMPLETED,取消/删除流程不触发业务后处理

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Slf4j +@Component +public class ProcessCompletedListener implements FlowableEventListener { + + private final HistoryService historyService; + private final Map handlers; + + public ProcessCompletedListener(@Lazy HistoryService historyService, List handlerList) { + this.historyService = historyService; + // 以流程定义 Key 索引处理器:新增业务后处理实现接口即自动被路由 + this.handlers = handlerList.stream() + .collect(Collectors.toMap(ProcessCompletedHandler::getProcessDefinitionKey, Function.identity())); + } + + @Override + public void onEvent(FlowableEvent event) { + if (event.getType() != FlowableEngineEventType.PROCESS_COMPLETED) { + return; + } + String processInstanceId = ((FlowableEngineEvent) event).getProcessInstanceId(); + HistoricProcessInstance instance = historyService.createHistoricProcessInstanceQuery() + .processInstanceId(processInstanceId).singleResult(); + if (instance == null) { + return; + } + ProcessCompletedHandler handler = handlers.get(instance.getProcessDefinitionKey()); + if (handler == null) { + // 未登记后处理的流程(如请假、用章)静默跳过,避免日志噪音 + log.debug("流程 [{}] 审批通过,未登记业务后处理", instance.getProcessDefinitionKey()); + return; + } + handler.handle(new ProcessCompletedContext( + instance.getId(), + instance.getProcessDefinitionKey(), + instance.getBusinessKey(), + instance.getStartUserId())); + } + + /** + * 监听器异常不回滚审批事务:后处理(入账/入库)失败不阻断流程结束 + * + *

强一致场景(后处理失败必须连带回滚审批)改为 true, + * 异常将随 completeTask 的事务一起回滚

+ */ + @Override + public boolean isFailOnException() { + return false; + } + + /** + * 常规事件派发(默认):不挂事务生命周期回调 + */ + @Override + public boolean isFireOnTransactionLifecycleEvent() { + return false; + } + + /** + * 事务生命周期回调时机(仅 isFireOnTransactionLifecycleEvent 为 true 时生效),此处无需 + */ + @Override + public String getOnTransaction() { + return null; + } +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/form/CompleteTaskForm.java b/src/main/java/com/youlai/boot/module/workflow/model/form/CompleteTaskForm.java new file mode 100644 index 00000000..3d6111a9 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/form/CompleteTaskForm.java @@ -0,0 +1,26 @@ +package com.youlai.boot.module.workflow.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.Map; + +/** + * 审批任务表单对象(通过) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "审批任务表单对象") +@Data +public class CompleteTaskForm { + + @Schema(description = "审批意见(写入引擎评论,随任务历史归档)") + @Size(max = 500, message = "审批意见长度不能超过500个字符") + private String comment; + + @Schema(description = "流程变量(驱动后续网关条件)") + private Map variables; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/form/DefinitionStateForm.java b/src/main/java/com/youlai/boot/module/workflow/model/form/DefinitionStateForm.java new file mode 100644 index 00000000..8e5f4ba1 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/form/DefinitionStateForm.java @@ -0,0 +1,21 @@ +package com.youlai.boot.module.workflow.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 流程定义状态表单对象(挂起/激活) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程定义状态表单对象") +@Data +public class DefinitionStateForm { + + @Schema(description = "是否挂起(true 挂起;false 激活)") + @NotNull(message = "挂起状态不能为空") + private Boolean suspend; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/form/ModelForm.java b/src/main/java/com/youlai/boot/module/workflow/model/form/ModelForm.java new file mode 100644 index 00000000..2fb1e88f --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/form/ModelForm.java @@ -0,0 +1,34 @@ +package com.youlai.boot.module.workflow.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 流程模型表单对象 + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程模型") +@Data +public class ModelForm { + + @Schema(description = "模型名称", example = "请假审批") + @NotBlank(message = "模型名称不能为空") + @Size(max = 100, message = "模型名称长度不能超过100个字符") + private String name; + + @Schema(description = "模型标识(创建后不可修改,部署时作为流程定义 key)", example = "leave") + @NotBlank(message = "模型标识不能为空") + @Size(max = 64, message = "模型标识长度不能超过64个字符") + @Pattern(regexp = "^[a-z][a-z0-9_]*$", message = "模型标识须以小写字母开头,仅含小写字母、数字、下划线") + private String key; + + @Schema(description = "模型描述") + @Size(max = 255, message = "模型描述长度不能超过255个字符") + private String description; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/form/ModelXmlForm.java b/src/main/java/com/youlai/boot/module/workflow/model/form/ModelXmlForm.java new file mode 100644 index 00000000..86f0a8fd --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/form/ModelXmlForm.java @@ -0,0 +1,21 @@ +package com.youlai.boot.module.workflow.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 流程模型 XML 表单对象(设计器保存) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程模型XML表单对象") +@Data +public class ModelXmlForm { + + @Schema(description = "BPMN 2.0 XML(bpmn-js 产出)") + @NotBlank(message = "流程模型内容不能为空") + private String xml; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/form/RejectTaskForm.java b/src/main/java/com/youlai/boot/module/workflow/model/form/RejectTaskForm.java new file mode 100644 index 00000000..db6b5177 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/form/RejectTaskForm.java @@ -0,0 +1,26 @@ +package com.youlai.boot.module.workflow.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 驳回任务表单对象 + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "驳回任务表单对象") +@Data +public class RejectTaskForm { + + @Schema(description = "目标节点ID(驳回到的历史节点)") + @NotBlank(message = "目标节点不能为空") + private String targetActivityId; + + @Schema(description = "驳回意见(写入引擎评论,随任务历史归档)") + @Size(max = 500, message = "驳回意见长度不能超过500个字符") + private String comment; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/form/StartProcessForm.java b/src/main/java/com/youlai/boot/module/workflow/model/form/StartProcessForm.java new file mode 100644 index 00000000..62870767 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/form/StartProcessForm.java @@ -0,0 +1,32 @@ +package com.youlai.boot.module.workflow.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.Map; + +/** + * 发起流程表单对象 + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "发起流程表单对象") +@Data +public class StartProcessForm { + + @Schema(description = "流程定义ID") + @NotBlank(message = "流程定义ID不能为空") + private String processDefinitionId; + + @Schema(description = "表单数据(field -> value 映射;流程绑定 formKey 时必填)") + private Map formData; + + @Schema(description = "流程变量(驱动网关条件;随实例提交)") + private Map variables; + + @Schema(description = "流程实例名称(未传时默认取流程定义名称)") + private String name; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstancePageQuery.java b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstancePageQuery.java new file mode 100644 index 00000000..ca3c03ec --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstancePageQuery.java @@ -0,0 +1,22 @@ +package com.youlai.boot.module.workflow.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 流程实例分页查询对象 + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description = "流程实例分页查询对象") +public class WorkflowInstancePageQuery extends BaseQuery { + + @Schema(description = "关键字(流程定义名称)") + private String keywords; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelPageQuery.java b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelPageQuery.java new file mode 100644 index 00000000..b47b0d9d --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelPageQuery.java @@ -0,0 +1,22 @@ +package com.youlai.boot.module.workflow.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 流程模型分页查询对象 + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description = "流程模型分页查询对象") +public class WorkflowModelPageQuery extends BaseQuery { + + @Schema(description = "关键字(模型名称)") + private String keywords; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskPageQuery.java b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskPageQuery.java new file mode 100644 index 00000000..8f7457fa --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskPageQuery.java @@ -0,0 +1,22 @@ +package com.youlai.boot.module.workflow.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 任务分页查询对象(待办/已办共用) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(description = "任务分页查询对象") +public class WorkflowTaskPageQuery extends BaseQuery { + + @Schema(description = "关键字(任务名称)") + private String keywords; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/ApprovalHistoryVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/ApprovalHistoryVO.java new file mode 100644 index 00000000..b9a70799 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/ApprovalHistoryVO.java @@ -0,0 +1,41 @@ +package com.youlai.boot.module.workflow.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; + +/** + * 审批记录VO(已办用户任务 + 审批意见) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "审批记录对象") +@Getter +@Setter +public class ApprovalHistoryVO { + + @Schema(description = "节点ID(与流程走向环节 nodeId 对应,供前端按环节归位记录)") + private String activityId; + + @Schema(description = "节点名称") + private String activityName; + + @Schema(description = "办理人(用户名)") + private String assignee; + + @Schema(description = "审批意见(无则为空)") + private String comment; + + @Schema(description = "任务开始时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date startTime; + + @Schema(description = "任务办理时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date endTime; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/DoneTaskVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/DoneTaskVO.java new file mode 100644 index 00000000..4421224e --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/DoneTaskVO.java @@ -0,0 +1,40 @@ +package com.youlai.boot.module.workflow.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; + +/** + * 已办任务VO(已办分页列表) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "已办任务对象") +@Getter +@Setter +public class DoneTaskVO { + + @Schema(description = "任务ID") + private String id; + + @Schema(description = "任务名称") + private String name; + + @Schema(description = "流程实例ID") + private String processInstanceId; + + @Schema(description = "流程定义名称") + private String processName; + + @Schema(description = "任务办理时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date endTime; + + @Schema(description = "处理意见") + private String comment; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/InstanceDetailVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/InstanceDetailVO.java new file mode 100644 index 00000000..c57fdc1f --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/InstanceDetailVO.java @@ -0,0 +1,67 @@ +package com.youlai.boot.module.workflow.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.youlai.boot.module.workflow.enums.InstanceStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; +import java.util.List; + +/** + * 流程实例详情VO(详情页渲染:发起表单回显 + 审批记录) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程实例详情对象") +@Getter +@Setter +public class InstanceDetailVO { + + @Schema(description = "流程实例ID") + private String id; + + @Schema(description = "流程定义名称") + private String processName; + + @Schema(description = "发起人(用户名)") + private String startUser; + + @Schema(description = "绑定表单标识(未绑定表单为空)") + private String formKey; + + @Schema(description = "发起表单规则(按提交时快照严格回显)") + private String formJson; + + @Schema(description = "发起表单全局配置(快照)") + private String optionsJson; + + @Schema(description = "发起表单数据(field -> value 映射的 JSON 字符串)") + private String dataJson; + + @Schema(description = "实例状态") + private InstanceStatusEnum status; + + @Schema(description = "终止原因(status=terminated 时有值)") + private String deleteReason; + + @Schema(description = "发起时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date startTime; + + @Schema(description = "结束时间(运行中为空)") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date endTime; + + @Schema(description = "审批记录(按时间正序)") + private List history; + + @Schema(description = "流程走向预览(按 BPMN 编排顺序,展示各环节审批角色与办理人)") + private List stages; + + @Schema(description = "当前进行中的环节下标(正常结束为环节总数即全部完成;终止/撤销为最后办完环节的下一位置)") + private Integer activeStageIndex; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/InstancePageVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/InstancePageVO.java new file mode 100644 index 00000000..b8f9ab7a --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/InstancePageVO.java @@ -0,0 +1,51 @@ +package com.youlai.boot.module.workflow.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.youlai.boot.module.workflow.enums.InstanceStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; + +/** + * 流程实例VO(我的流程分页列表) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程实例对象") +@Getter +@Setter +public class InstancePageVO { + + @Schema(description = "流程实例ID") + private String id; + + @Schema(description = "流程定义标识") + private String processDefinitionKey; + + @Schema(description = "流程定义名称") + private String processName; + + @Schema(description = "业务主键(绑定的表单数据ID,未绑定表单为空)") + private String businessKey; + + @Schema(description = "绑定表单标识(未绑定为空)") + private String formKey; + + @Schema(description = "实例状态") + private InstanceStatusEnum status; + + @Schema(description = "终止原因(status=terminated 时有值)") + private String deleteReason; + + @Schema(description = "发起时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date startTime; + + @Schema(description = "结束时间(运行中为空)") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date endTime; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/ModelVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/ModelVO.java new file mode 100644 index 00000000..124249ad --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/ModelVO.java @@ -0,0 +1,57 @@ +package com.youlai.boot.module.workflow.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; + +/** + * 流程模型VO(分页列表) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程模型对象") +@Getter +@Setter +public class ModelVO { + + @Schema(description = "模型ID") + private String id; + + @Schema(description = "模型名称") + private String name; + + @Schema(description = "模型标识") + private String key; + + @Schema(description = "模型描述") + private String description; + + @Schema(description = "版本号") + private Integer version; + + @Schema(description = "最新发布定义ID(未发布为 null)") + private String definitionId; + + @Schema(description = "最新发布版本号(未发布为 null)") + private Integer publishedVersion; + + @Schema(description = "发布状态:true=已停用,false=已启用(未发布为 null)") + private Boolean suspended; + + @Schema(description = "发布时间(未发布为 null)") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date deploymentTime; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + + @Schema(description = "最后更新时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updateTime; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/ModelXmlVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/ModelXmlVO.java new file mode 100644 index 00000000..27801206 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/ModelXmlVO.java @@ -0,0 +1,25 @@ +package com.youlai.boot.module.workflow.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 流程模型 XML VO(设计器回显) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程模型XML对象") +@Getter +@Setter +public class ModelXmlVO { + + @Schema(description = "BPMN 2.0 XML(bpmn-js 导入渲染;新模型为空串)") + private String xml; + + public ModelXmlVO(String xml) { + this.xml = xml; + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/ProcessDiagramVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/ProcessDiagramVO.java new file mode 100644 index 00000000..c36f7776 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/ProcessDiagramVO.java @@ -0,0 +1,29 @@ +package com.youlai.boot.module.workflow.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 流程图数据VO(bpmn-js 渲染 + 节点高亮) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程图数据对象") +@Getter +@Setter +public class ProcessDiagramVO { + + @Schema(description = "BPMN 2.0 XML(bpmn-js 导入渲染)") + private String bpmnXml; + + @Schema(description = "已办节点ID列表(高亮为走过路径)") + private List executedActivityIds; + + @Schema(description = "进行中节点ID列表(高亮为当前待办)") + private List activeActivityIds; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/ProcessStageVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/ProcessStageVO.java new file mode 100644 index 00000000..b14fcabf --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/ProcessStageVO.java @@ -0,0 +1,35 @@ +package com.youlai.boot.module.workflow.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** + * 流程审批阶段VO(发起页与审批弹窗的流程走向预览) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "流程审批阶段对象") +@Getter +@Builder +public class ProcessStageVO { + + @Schema(description = "节点ID") + private final String nodeId; + + @Schema(description = "节点名称(审批环节)") + private final String nodeName; + + @Schema(description = "审批角色名称列表(角色候选组任务)") + private final List roleNames; + + @Schema(description = "角色成员账号列表(演示环境直观展示哪个用户办理该环节)") + private final List userNames; + + @Schema(description = "是否发起人本人办理(assignee=${initiator})") + private final Boolean initiator; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/RejectTargetVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/RejectTargetVO.java new file mode 100644 index 00000000..d12ebc8a --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/RejectTargetVO.java @@ -0,0 +1,29 @@ +package com.youlai.boot.module.workflow.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 驳回目标节点VO(驳回下拉选择) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "驳回目标节点对象") +@Getter +@Setter +public class RejectTargetVO { + + @Schema(description = "节点ID") + private String activityId; + + @Schema(description = "节点名称") + private String activityName; + + public RejectTargetVO(String activityId, String activityName) { + this.activityId = activityId; + this.activityName = activityName; + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/StartableProcessVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/StartableProcessVO.java new file mode 100644 index 00000000..3c9482a1 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/StartableProcessVO.java @@ -0,0 +1,33 @@ +package com.youlai.boot.module.workflow.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 可发起流程VO(发起页流程选择列表) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "可发起流程对象") +@Getter +@Setter +public class StartableProcessVO { + + @Schema(description = "流程定义ID") + private String id; + + @Schema(description = "流程定义标识") + private String key; + + @Schema(description = "流程定义名称") + private String name; + + @Schema(description = "版本号") + private Integer version; + + @Schema(description = "绑定表单标识(开始节点 formKey,未绑定为空;空则直接发起,无表单填写)") + private String formKey; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/TaskDetailVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/TaskDetailVO.java new file mode 100644 index 00000000..d67b546c --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/TaskDetailVO.java @@ -0,0 +1,56 @@ +package com.youlai.boot.module.workflow.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 待办任务详情VO(审批页渲染:发起表单只读回显 + 审批记录) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "待办任务详情对象") +@Getter +@Setter +public class TaskDetailVO { + + @Schema(description = "任务ID") + private String taskId; + + @Schema(description = "任务名称") + private String taskName; + + @Schema(description = "当前任务节点ID(BPMN 节点,用于流程走向高亮定位)") + private String taskDefinitionKey; + + @Schema(description = "流程实例ID") + private String processInstanceId; + + @Schema(description = "流程定义名称") + private String processName; + + @Schema(description = "发起表单标识(未绑定表单为空)") + private String formKey; + + @Schema(description = "发起表单规则(按提交时快照严格回显)") + private String formJson; + + @Schema(description = "发起表单全局配置(快照)") + private String optionsJson; + + @Schema(description = "发起表单数据(field -> value 映射的 JSON 字符串)") + private String dataJson; + + @Schema(description = "当前任务绑定表单标识(任务节点 formKey,未绑定为空)") + private String taskFormKey; + + @Schema(description = "审批记录(按时间正序)") + private List history; + + @Schema(description = "流程走向预览(按 BPMN 编排顺序,展示各环节审批角色与办理人)") + private List stages; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/model/vo/TodoTaskVO.java b/src/main/java/com/youlai/boot/module/workflow/model/vo/TodoTaskVO.java new file mode 100644 index 00000000..f157cfa3 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/model/vo/TodoTaskVO.java @@ -0,0 +1,43 @@ +package com.youlai.boot.module.workflow.model.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; + +/** + * 待办任务VO(待办分页列表) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Schema(description = "待办任务对象") +@Getter +@Setter +public class TodoTaskVO { + + @Schema(description = "任务ID") + private String id; + + @Schema(description = "任务名称") + private String name; + + @Schema(description = "流程实例ID") + private String processInstanceId; + + @Schema(description = "流程定义名称") + private String processName; + + @Schema(description = "业务主键(绑定的表单数据ID,未绑定表单为空)") + private String businessKey; + + @Schema(description = "绑定表单标识(未绑定为空)") + private String formKey; + + @Schema(description = "任务创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/runner/WorkflowDemoInitializer.java b/src/main/java/com/youlai/boot/module/workflow/runner/WorkflowDemoInitializer.java new file mode 100644 index 00000000..62229515 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/runner/WorkflowDemoInitializer.java @@ -0,0 +1,31 @@ +package com.youlai.boot.module.workflow.runner; + +import com.youlai.boot.module.workflow.service.WorkflowDemoService; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * 演示流程启动初始化器 + * + *

委托 {@link WorkflowDemoService} 幂等初始化,重置能力由管理端接口提供。 + * 由 flowable.demo.enabled 控制(默认开启),生产环境置 false 后本组件不注册,启动零开销

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Component +@ConditionalOnProperty(name = "flowable.demo.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class WorkflowDemoInitializer implements ApplicationRunner { + + private final WorkflowDemoService workflowDemoService; + + @Override + public void run(ApplicationArguments args) { + workflowDemoService.initIfAbsent(); + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowDefinitionService.java b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowDefinitionService.java new file mode 100644 index 00000000..9c60c1a7 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowDefinitionService.java @@ -0,0 +1,49 @@ +package com.youlai.boot.module.workflow.service; + +import com.youlai.boot.module.workflow.model.form.DefinitionStateForm; +import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; +import com.youlai.boot.module.workflow.model.vo.ProcessStageVO; +import com.youlai.boot.module.workflow.model.vo.StartableProcessVO; + +import java.util.List; + +/** + * 流程定义业务接口(发布产物:可发起列表、审批阶段、流程图、启用停用) + * + * @author Ray.Hao + * @since 4.9.0 + */ +public interface WorkflowDefinitionService { + + /** + * 可发起流程列表(最新版本且激活,发起页下拉数据源) + * + * @return 流程定义集合 + */ + List listStartableProcesses(); + + /** + * 流程审批阶段预览(按 BPMN 编排顺序解析各环节的审批角色与角色成员) + * + * @param definitionId 流程定义ID + * @return 审批阶段集合 + */ + List listProcessStages(String definitionId); + + /** + * 获取流程定义 BPMN XML(流程图查看) + * + * @param definitionId 流程定义ID + * @return BPMN 2.0 XML + */ + ModelXmlVO getDefinitionXml(String definitionId); + + /** + * 启用/停用流程(停用后不可发起新实例,存量实例不受影响) + * + * @param definitionId 流程定义ID + * @param formData 状态表单数据 + */ + void updateDefinitionState(String definitionId, DefinitionStateForm formData); + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowDemoService.java b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowDemoService.java new file mode 100644 index 00000000..b074732e --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowDemoService.java @@ -0,0 +1,23 @@ +package com.youlai.boot.module.workflow.service; + +/** + * 演示流程服务接口(请假、报销、采购、用章) + * + * @author Ray.Hao + * @since 4.9.0 + */ +public interface WorkflowDemoService { + + /** + * 幂等初始化演示流程(已存在则跳过):部署流程定义 + 创建模型草稿 + * + *

单个流程失败仅记录日志,不阻断其余流程与应用启动

+ */ + void initIfAbsent(); + + /** + * 重置工作流数据(恢复出厂):清空所有流程定义、模型、实例与历史(含用户自建)及关联表单提交数据后重建演示流程 + */ + void reset(); + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowInstanceService.java b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowInstanceService.java new file mode 100644 index 00000000..a0e44be5 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowInstanceService.java @@ -0,0 +1,65 @@ +package com.youlai.boot.module.workflow.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.module.workflow.model.form.StartProcessForm; +import com.youlai.boot.module.workflow.model.query.WorkflowInstancePageQuery; +import com.youlai.boot.module.workflow.model.vo.InstanceDetailVO; +import com.youlai.boot.module.workflow.model.vo.InstancePageVO; +import com.youlai.boot.module.workflow.model.vo.ProcessDiagramVO; + +/** + * 流程实例业务接口(发起、我的流程、详情、流程图、撤销/终止) + * + * @author Ray.Hao + * @since 4.9.0 + */ +public interface WorkflowInstanceService { + + /** + * 发起流程(绑定表单时保存表单数据并以数据ID作为 businessKey,表单字段随流程变量提交) + * + * @param formData 发起流程表单数据 + * @return 流程实例ID + */ + String startProcess(StartProcessForm formData); + + /** + * 我的流程分页列表(当前用户发起,含运行中/已完成/已终止) + * + * @param queryParams 查询参数 + * @return 分页结果 + */ + Page getInstancePage(WorkflowInstancePageQuery queryParams); + + /** + * 获取流程图数据(BPMN XML + 已办/进行中节点,bpmn-js 高亮渲染) + * + * @param instanceId 流程实例ID + * @return 流程图数据 + */ + ProcessDiagramVO getProcessDiagram(String instanceId); + + /** + * 获取流程实例详情(发起表单回显 + 审批记录) + * + * @param instanceId 流程实例ID + * @return 实例详情 + */ + InstanceDetailVO getInstanceDetail(String instanceId); + + /** + * 撤销流程(仅发起人可撤销运行中实例) + * + * @param instanceId 流程实例ID + */ + void cancelProcessInstance(String instanceId); + + /** + * 终止流程(管理员终止任意运行中实例) + * + * @param instanceId 流程实例ID + * @param reason 终止原因 + */ + void terminateProcessInstance(String instanceId, String reason); + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowModelService.java b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowModelService.java new file mode 100644 index 00000000..d3805c7e --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowModelService.java @@ -0,0 +1,76 @@ +package com.youlai.boot.module.workflow.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.module.workflow.model.form.ModelForm; +import com.youlai.boot.module.workflow.model.form.ModelXmlForm; +import com.youlai.boot.module.workflow.model.query.WorkflowModelPageQuery; +import com.youlai.boot.module.workflow.model.vo.ModelVO; +import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; + +import java.util.List; + +/** + * 流程模型业务接口(设计器侧:模型 CRUD、XML 保存、部署) + * + * @author Ray.Hao + * @since 4.9.0 + */ +public interface WorkflowModelService { + + /** + * 流程模型分页列表 + * + * @param queryParams 查询参数 + * @return 分页结果 + */ + Page getModelPage(WorkflowModelPageQuery queryParams); + + /** + * 新增流程模型(内置开始-审批-结束模板,设计器开箱即画) + * + * @param formData 模型表单数据 + * @return 是否成功 + */ + boolean saveModel(ModelForm formData); + + /** + * 修改流程模型(标识创建后不可修改) + * + * @param modelId 模型ID + * @param formData 模型表单数据 + * @return 是否成功 + */ + boolean updateModel(String modelId, ModelForm formData); + + /** + * 删除流程模型(未部署的草稿模型;已部署定义不受影响) + * + * @param modelIds 模型ID集合 + */ + void removeModels(List modelIds); + + /** + * 获取模型 BPMN XML(设计器回显) + * + * @param modelId 模型ID + * @return BPMN 2.0 XML + */ + ModelXmlVO getModelXml(String modelId); + + /** + * 保存模型 BPMN XML(设计器产出) + * + * @param modelId 模型ID + * @param formData XML 表单数据 + * @return 是否成功 + */ + boolean saveModelXml(String modelId, ModelXmlForm formData); + + /** + * 部署流程模型(校验 XML 并发布为流程定义新版本) + * + * @param modelId 模型ID + */ + void deployModel(String modelId); + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowTaskService.java b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowTaskService.java new file mode 100644 index 00000000..bcffcdf3 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowTaskService.java @@ -0,0 +1,70 @@ +package com.youlai.boot.module.workflow.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.module.workflow.model.form.CompleteTaskForm; +import com.youlai.boot.module.workflow.model.form.RejectTaskForm; +import com.youlai.boot.module.workflow.model.query.WorkflowTaskPageQuery; +import com.youlai.boot.module.workflow.model.vo.DoneTaskVO; +import com.youlai.boot.module.workflow.model.vo.RejectTargetVO; +import com.youlai.boot.module.workflow.model.vo.TaskDetailVO; +import com.youlai.boot.module.workflow.model.vo.TodoTaskVO; + +import java.util.List; + +/** + * 流程任务业务接口(待办/已办、审批通过/驳回) + * + * @author Ray.Hao + * @since 4.9.0 + */ +public interface WorkflowTaskService { + + /** + * 待办任务分页列表(当前用户的候选/分配任务) + * + * @param queryParams 查询参数 + * @return 分页结果 + */ + Page getTodoTaskPage(WorkflowTaskPageQuery queryParams); + + /** + * 已办任务分页列表(当前用户已办理的任务) + * + * @param queryParams 查询参数 + * @return 分页结果 + */ + Page getDoneTaskPage(WorkflowTaskPageQuery queryParams); + + /** + * 获取待办任务详情(发起表单只读回显 + 审批记录) + * + * @param taskId 任务ID + * @return 任务详情 + */ + TaskDetailVO getTaskDetail(String taskId); + + /** + * 获取驳回目标节点列表(当前实例已办理过的用户任务) + * + * @param taskId 任务ID + * @return 驳回目标集合 + */ + List listRejectTargets(String taskId); + + /** + * 审批通过(意见归档为引擎评论,变量驱动后续网关) + * + * @param taskId 任务ID + * @param formData 审批表单数据 + */ + void completeTask(String taskId, CompleteTaskForm formData); + + /** + * 驳回(流转到指定历史节点,任务与意见随历史归档) + * + * @param taskId 任务ID + * @param formData 驳回表单数据 + */ + void rejectTask(String taskId, RejectTaskForm formData); + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowDefinitionServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowDefinitionServiceImpl.java new file mode 100644 index 00000000..91aa2fde --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowDefinitionServiceImpl.java @@ -0,0 +1,187 @@ +package com.youlai.boot.module.workflow.service.impl; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.youlai.boot.module.workflow.model.form.DefinitionStateForm; +import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; +import com.youlai.boot.module.workflow.model.vo.ProcessStageVO; +import com.youlai.boot.module.workflow.model.vo.StartableProcessVO; +import com.youlai.boot.module.workflow.service.WorkflowDefinitionService; +import com.youlai.boot.module.workflow.util.BpmnModelHelper; +import com.youlai.boot.system.model.entity.Role; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.model.entity.UserRole; +import com.youlai.boot.system.service.RoleService; +import com.youlai.boot.system.service.UserService; +import com.youlai.boot.system.service.UserRoleService; +import lombok.RequiredArgsConstructor; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.repository.ProcessDefinition; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 流程定义业务实现类 + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Service +@RequiredArgsConstructor +public class WorkflowDefinitionServiceImpl implements WorkflowDefinitionService { + + private final RepositoryService repositoryService; + private final BpmnModelHelper bpmnModelHelper; + private final RoleService roleService; + private final UserService userService; + private final UserRoleService userRoleService; + + /** + * 可发起流程列表 + */ + @Override + public List listStartableProcesses() { + return repositoryService.createProcessDefinitionQuery().latestVersion().active() + .orderByProcessDefinitionKey().asc().list().stream() + .map(definition -> { + StartableProcessVO processVO = new StartableProcessVO(); + processVO.setId(definition.getId()); + processVO.setKey(definition.getKey()); + processVO.setName(definition.getName()); + processVO.setVersion(definition.getVersion()); + processVO.setFormKey(bpmnModelHelper.getStartFormKey(definition.getId())); + return processVO; + }).toList(); + } + + /** + * 流程审批阶段预览:BPMN 节点的候选组(角色编码)与办理人表达式翻译为角色名和角色成员账号, + * 让发起人在提交前即可看到"哪个环节由谁审批" + */ + @Override + public List listProcessStages(String definitionId) { + getDefinition(definitionId); + List userTasks = bpmnModelHelper.getUserTasks(definitionId); + if (userTasks.isEmpty()) { + return List.of(); + } + + // 涉及角色一次性批量解析(角色名 + 成员账号),避免逐节点回查 + Set roleCodes = userTasks.stream() + .flatMap(task -> task.candidateGroups().stream()) + .collect(Collectors.toSet()); + RoleDirectory directory = loadRoleDirectory(roleCodes); + + return userTasks.stream().map(task -> { + // 候选组任务的办理人:角色成员账号;字面量 assignee(固定用户)与未知表达式原样展示 + List userNames = new ArrayList<>(task.candidateGroups().stream() + .flatMap(code -> directory.roleUsers().getOrDefault(code, List.of()).stream()) + .distinct() + .toList()); + boolean byInitiator = StrUtil.isNotBlank(task.assignee()) + && task.assignee().contains("initiator"); + if (StrUtil.isNotBlank(task.assignee()) && !byInitiator) { + userNames.add(task.assignee()); + } + return ProcessStageVO.builder() + .nodeId(task.nodeId()) + .nodeName(task.nodeName()) + // 角色已删除或未初始化时回退展示编码,环节不因数据缺失而空白 + .roleNames(task.candidateGroups().stream() + .map(code -> directory.roleNames().getOrDefault(code, code)) + .toList()) + .userNames(userNames) + .initiator(byInitiator) + .build(); + }).toList(); + } + + /** + * 角色目录(编码 → 角色名 / 成员账号) + * + * @param roleNames 编码 → 角色名称 + * @param roleUsers 编码 → 成员账号列表 + */ + private record RoleDirectory(Map roleNames, Map> roleUsers) { + } + + /** + * 批量解析角色名称与成员账号(角色 → 用户ID → 账号 三段批查) + */ + private RoleDirectory loadRoleDirectory(Set roleCodes) { + if (roleCodes.isEmpty()) { + return new RoleDirectory(Map.of(), Map.of()); + } + List roles = roleService.list( + new LambdaQueryWrapper().in(Role::getCode, roleCodes)); + if (roles.isEmpty()) { + return new RoleDirectory(Map.of(), Map.of()); + } + List roleIds = roles.stream().map(Role::getId).toList(); + Map> userIdsByRole = userRoleService.list( + new LambdaQueryWrapper().in(UserRole::getRoleId, roleIds)) + .stream().collect(Collectors.groupingBy(UserRole::getRoleId, + Collectors.mapping(UserRole::getUserId, Collectors.toList()))); + Set userIds = userIdsByRole.values().stream() + .flatMap(List::stream).collect(Collectors.toSet()); + Map usernameById = userIds.isEmpty() ? Collections.emptyMap() + : userService.listByIds(userIds).stream() + .collect(Collectors.toMap(SysUser::getId, SysUser::getUsername)); + + Map> roleUsers = new HashMap<>(); + roles.forEach(role -> roleUsers.put(role.getCode(), + userIdsByRole.getOrDefault(role.getId(), List.of()).stream() + .map(usernameById::get) + .filter(StrUtil::isNotBlank) + .toList())); + Map roleNames = roles.stream() + .collect(Collectors.toMap(Role::getCode, Role::getName)); + return new RoleDirectory(roleNames, roleUsers); + } + + /** + * 获取流程定义 BPMN XML + * + * @param definitionId 流程定义ID + */ + @Override + public ModelXmlVO getDefinitionXml(String definitionId) { + getDefinition(definitionId); + return new ModelXmlVO(bpmnModelHelper.getBpmnXml(definitionId)); + } + + /** + * 修改流程定义状态 + * + * @param definitionId 流程定义ID + * @param formData 状态表单数据 + */ + @Override + public void updateDefinitionState(String definitionId, DefinitionStateForm formData) { + getDefinition(definitionId); + if (Boolean.TRUE.equals(formData.getSuspend())) { + repositoryService.suspendProcessDefinitionById(definitionId); + } else { + repositoryService.activateProcessDefinitionById(definitionId); + } + } + + /** + * 获取流程定义(不存在即抛业务异常) + */ + private ProcessDefinition getDefinition(String definitionId) { + ProcessDefinition definition = repositoryService.createProcessDefinitionQuery() + .processDefinitionId(definitionId).singleResult(); + Assert.notNull(definition, "流程定义不存在"); + return definition; + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowDemoServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowDemoServiceImpl.java new file mode 100644 index 00000000..bf875623 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowDemoServiceImpl.java @@ -0,0 +1,185 @@ +package com.youlai.boot.module.workflow.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.module.form.service.FormDataService; +import com.youlai.boot.module.workflow.service.WorkflowDemoService; +import com.youlai.boot.module.workflow.service.WorkflowDemoService; +import com.youlai.boot.module.workflow.util.BpmnModelHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.repository.Model; +import org.flowable.engine.repository.ProcessDefinition; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.FileCopyUtils; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 演示流程服务实现(请假、报销、采购、用章) + * + *

初始化按 key 幂等双写:部署流程定义(可发起)+ 创建模型草稿(流程模型页可见),两者各自查重互不依赖。 + * 引擎表(ACT_*)不走 SQL 预置:流程定义跨三表且主键由引擎生成,直接写库绕过解析校验,重置同理依赖引擎级联删除

+ * + *

重置为工作流数据全量恢复出厂:清空所有流程定义、模型、实例、历史(含用户自建)及关联表单提交数据后重建演示流程, + * 供演示环境定期复原,等价于业务脚本"DROP 重建"的数据效果而不动引擎表结构

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class WorkflowDemoServiceImpl implements WorkflowDemoService { + + private final RepositoryService repositoryService; + private final FormDataService formDataService; + private final BpmnModelHelper bpmnModelHelper; + + /** + * 演示流程清单(key 与 resources/workflow/ 下同名 BPMN 文件的 process id 一致) + */ + private static final List DEMO_PROCESSES = List.of( + new DemoProcess("leave", "请假审批"), + new DemoProcess("expense", "报销审批"), + new DemoProcess("purchase", "采购申请"), + new DemoProcess("seal", "用章申请") + ); + + @Override + public void initIfAbsent() { + DEMO_PROCESSES.forEach(this::initDemoProcess); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void reset() { + // 删除前收集全部流程定义的发起表单 key:引擎级联不覆盖业务库表单数据,删除后 businessKey 无从追溯 + Set formKeys = collectStartFormKeys(); + // 全量清空:定义(级联删除实例与历史,含用户自建流程)→ 模型草稿 → 流程关联的表单提交数据 + repositoryService.createDeploymentQuery().list() + .forEach(deployment -> repositoryService.deleteDeployment(deployment.getId(), true)); + repositoryService.createModelQuery().list() + .forEach(model -> repositoryService.deleteModel(model.getId())); + formKeys.forEach(this::deleteFormData); + // 重建演示流程(失败抛异常中断,整体回滚保证重置原子性) + DEMO_PROCESSES.forEach(this::redeployDemoProcess); + log.info("工作流数据已重置:清理 {} 个表单的提交数据,重建演示流程 {} 个", formKeys.size(), DEMO_PROCESSES.size()); + } + + /** + * 收集全部流程定义的发起表单 key(单个定义解析失败不影响其余收集) + */ + private Set collectStartFormKeys() { + Set formKeys = new HashSet<>(); + for (ProcessDefinition definition : repositoryService.createProcessDefinitionQuery().list()) { + try { + String formKey = bpmnModelHelper.getStartFormKey(definition.getId()); + if (StrUtil.isNotBlank(formKey)) { + formKeys.add(formKey); + } + } catch (Exception e) { + log.warn("流程定义 [{}] 表单标识解析失败,跳过其表单数据清理", definition.getKey()); + } + } + return formKeys; + } + + /** + * 重建单个演示流程(全量清空后调用) + */ + private void redeployDemoProcess(DemoProcess process) { + try { + String bpmnXml = readBpmnXml(process.key()); + repositoryService.createDeployment() + .name(process.name()) + .key(process.key()) + .addString(process.key() + ".bpmn20.xml", bpmnXml) + .deploy(); + createModelIfAbsent(process, bpmnXml); + } catch (Exception e) { + throw new BusinessException("演示流程 [" + process.name() + "] 重建失败:" + e.getMessage()); + } + } + + /** + * 初始化单个演示流程(资源文件与 key 同名,放 workflow 目录避开 processes/ 自动部署) + */ + private void initDemoProcess(DemoProcess process) { + try { + String bpmnXml = readBpmnXml(process.key()); + deployDefinitionIfAbsent(process, bpmnXml); + createModelIfAbsent(process, bpmnXml); + } catch (Exception e) { + log.error("演示流程 [{}] 初始化失败", process.name(), e); + } + } + + /** + * 幂等部署流程定义(已存在同 key 定义则跳过,删除定义后重启可重新部署) + */ + private void deployDefinitionIfAbsent(DemoProcess process, String bpmnXml) { + long count = repositoryService.createProcessDefinitionQuery() + .processDefinitionKey(process.key()).count(); + if (count > 0) { + return; + } + repositoryService.createDeployment() + .name(process.name()) + .key(process.key()) + .addString(process.key() + ".bpmn20.xml", bpmnXml) + .deploy(); + log.info("演示流程 [{}] 部署完成", process.name()); + } + + /** + * 幂等创建模型草稿(流程模型页数据源),editorSource 与部署 XML 同源,metaInfo 与服务层约定一致 + */ + private void createModelIfAbsent(DemoProcess process, String bpmnXml) { + long count = repositoryService.createModelQuery() + .modelKey(process.key()).count(); + if (count > 0) { + return; + } + Model model = repositoryService.newModel(); + model.setKey(process.key()); + model.setName(process.name()); + model.setMetaInfo(JSONUtil.createObj().set("description", "演示流程").toString()); + repositoryService.saveModel(model); + repositoryService.addModelEditorSource(model.getId(), bpmnXml.getBytes(StandardCharsets.UTF_8)); + log.info("演示流程 [{}] 模型创建完成", process.name()); + } + + /** + * 清理演示表单提交数据(流程 businessKey 指向 form_data,随流程一并复位) + * + *

formKey 到删除条件的解析封装在 form 模块(removeByFormKey 窄接口),本模块不感知表单实体

+ */ + private void deleteFormData(String formKey) { + formDataService.removeByFormKey(formKey); + } + + /** + * 读取演示流程 BPMN 资源 + */ + private String readBpmnXml(String key) throws Exception { + return FileCopyUtils.copyToString(new InputStreamReader( + new ClassPathResource("workflow/" + key + ".bpmn20.xml").getInputStream(), + StandardCharsets.UTF_8)); + } + + /** + * 演示流程定义(key 即 BPMN 文件名与流程标识) + */ + private record DemoProcess(String key, String name) { + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java new file mode 100644 index 00000000..c8c3e71c --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java @@ -0,0 +1,304 @@ +package com.youlai.boot.module.workflow.service.impl; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.module.form.model.vo.FormDataVO; +import com.youlai.boot.module.form.service.FormDataService; +import com.youlai.boot.module.workflow.enums.InstanceStatusEnum; +import com.youlai.boot.module.workflow.model.form.StartProcessForm; +import com.youlai.boot.module.workflow.model.query.WorkflowInstancePageQuery; +import com.youlai.boot.module.workflow.model.vo.InstanceDetailVO; +import com.youlai.boot.module.workflow.model.vo.InstancePageVO; +import com.youlai.boot.module.workflow.model.vo.ProcessDiagramVO; +import com.youlai.boot.module.workflow.model.vo.ProcessStageVO; +import com.youlai.boot.module.workflow.service.WorkflowDefinitionService; +import com.youlai.boot.module.workflow.service.WorkflowInstanceService; +import com.youlai.boot.module.workflow.util.ApprovalHistoryHelper; +import com.youlai.boot.module.workflow.util.BpmnModelHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.flowable.common.engine.impl.identity.Authentication; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.history.HistoricActivityInstance; +import org.flowable.engine.history.HistoricProcessInstance; +import org.flowable.engine.history.HistoricProcessInstanceQuery; +import org.flowable.engine.repository.ProcessDefinition; +import org.flowable.engine.runtime.ProcessInstance; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 流程实例业务实现类 + * + *

发起时绑定动态表单:表单数据落库后以数据ID作为 businessKey,表单字段随流程变量提交, + * 网关条件可直接引用表单字段(如 ${days > 3})

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkflowInstanceServiceImpl implements WorkflowInstanceService { + + private final RuntimeService runtimeService; + private final HistoryService historyService; + private final RepositoryService repositoryService; + private final FormDataService formDataService; + private final WorkflowDefinitionService workflowDefinitionService; + private final BpmnModelHelper bpmnModelHelper; + private final ApprovalHistoryHelper approvalHistoryHelper; + + /** + * 发起流程 + * + * @param formData 发起流程表单数据 + */ + @Override + public String startProcess(StartProcessForm formData) { + ProcessDefinition definition = repositoryService.createProcessDefinitionQuery() + .processDefinitionId(formData.getProcessDefinitionId()).singleResult(); + Assert.notNull(definition, "流程定义不存在"); + Assert.isTrue(!definition.isSuspended(), "流程定义已挂起,无法发起"); + + Map variables = new HashMap<>(); + String businessKey = null; + String formKey = bpmnModelHelper.getStartFormKey(definition.getId()); + if (StrUtil.isNotBlank(formKey)) { + Map data = formData.getFormData() == null ? Map.of() : formData.getFormData(); + Long dataId = formDataService.saveFormDataReturnId(formKey, data); + Assert.notNull(dataId, "表单数据保存失败"); + businessKey = String.valueOf(dataId); + // 表单字段即流程变量,网关条件按字段名直接引用 + variables.putAll(data); + } + if (formData.getVariables() != null) { + variables.putAll(formData.getVariables()); + } + + // 发起人写入 ACT_HI_PROCINST.START_USER_ID_,供"我的流程"与发起人撤销校验使用 + Authentication.setAuthenticatedUserId(SecurityUtils.getUsername()); + try { + // 实例名:未传时回退流程定义名(实例级 NAME_,区分同流程的多次发起) + String instanceName = StrUtil.blankToDefault(formData.getName(), definition.getName()); + // RuntimeService 无 (id, businessKey, name, variables) 重载,实例名须走 builder 链式构建 + ProcessInstance instance = runtimeService.createProcessInstanceBuilder() + .processDefinitionId(definition.getId()) + .businessKey(businessKey) + .name(instanceName) + .variables(variables) + .start(); + return instance.getId(); + } finally { + Authentication.setAuthenticatedUserId(null); + } + } + + /** + * 我的流程分页列表 + * + * @param queryParams 查询参数 + */ + @Override + public Page getInstancePage(WorkflowInstancePageQuery queryParams) { + HistoricProcessInstanceQuery query = historyService.createHistoricProcessInstanceQuery() + .startedBy(SecurityUtils.getUsername()); + if (StrUtil.isNotBlank(queryParams.getKeywords())) { + query.processDefinitionNameLike("%" + queryParams.getKeywords() + "%"); + } + query.orderByProcessInstanceStartTime().desc(); + long total = query.count(); + List instances = query.listPage( + (queryParams.getPageNum() - 1) * queryParams.getPageSize(), queryParams.getPageSize()); + + // 同一定义只解析一次 formKey + Map formKeys = new HashMap<>(); + List records = instances.stream().map(instance -> { + InstancePageVO pageVO = new InstancePageVO(); + pageVO.setId(instance.getId()); + pageVO.setProcessDefinitionKey(instance.getProcessDefinitionKey()); + // 实例名优先(发起时可自定义标题区分批次),旧实例 NAME_ 为空则回退流程定义名 + pageVO.setProcessName(StrUtil.isBlank(instance.getName()) + ? instance.getProcessDefinitionName() : instance.getName()); + pageVO.setBusinessKey(instance.getBusinessKey()); + pageVO.setFormKey(formKeys.computeIfAbsent(instance.getProcessDefinitionId(), + bpmnModelHelper::getStartFormKey)); + pageVO.setStatus(resolveStatus(instance)); + pageVO.setDeleteReason(instance.getDeleteReason()); + pageVO.setStartTime(instance.getStartTime()); + pageVO.setEndTime(instance.getEndTime()); + return pageVO; + }).toList(); + + Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize(), total); + page.setRecords(records); + return page; + } + + /** + * 获取流程图数据 + * + * @param instanceId 流程实例ID + */ + @Override + public ProcessDiagramVO getProcessDiagram(String instanceId) { + HistoricProcessInstance instance = getHistoricInstance(instanceId); + ProcessDiagramVO diagramVO = new ProcessDiagramVO(); + diagramVO.setBpmnXml(bpmnModelHelper.getBpmnXml(instance.getProcessDefinitionId())); + diagramVO.setExecutedActivityIds(historyService.createHistoricActivityInstanceQuery() + .processInstanceId(instanceId).finished().list().stream() + .map(HistoricActivityInstance::getActivityId).distinct().toList()); + // 已结束实例无进行中节点,查询会因执行流不存在而报错 + diagramVO.setActiveActivityIds(instance.getEndTime() == null + ? runtimeService.getActiveActivityIds(instanceId) : List.of()); + return diagramVO; + } + + /** + * 获取流程实例详情 + * + * @param instanceId 流程实例ID + */ + @Override + public InstanceDetailVO getInstanceDetail(String instanceId) { + HistoricProcessInstance instance = getHistoricInstance(instanceId); + InstanceDetailVO detailVO = new InstanceDetailVO(); + detailVO.setId(instance.getId()); + // 实例名优先(与列表保持一致),旧实例 NAME_ 为空则回退流程定义名 + detailVO.setProcessName(StrUtil.isBlank(instance.getName()) + ? instance.getProcessDefinitionName() : instance.getName()); + detailVO.setStartUser(instance.getStartUserId()); + detailVO.setStatus(resolveStatus(instance)); + detailVO.setDeleteReason(instance.getDeleteReason()); + detailVO.setStartTime(instance.getStartTime()); + detailVO.setEndTime(instance.getEndTime()); + + String formKey = bpmnModelHelper.getStartFormKey(instance.getProcessDefinitionId()); + detailVO.setFormKey(formKey); + this.fillFormData(detailVO, formKey, instance.getBusinessKey()); + detailVO.setHistory(approvalHistoryHelper.getHistory(instanceId)); + + // 流程走向预览:发起人视角直观看到流程走到哪个环节 + List stages = workflowDefinitionService.listProcessStages( + instance.getProcessDefinitionId()); + detailVO.setStages(stages); + detailVO.setActiveStageIndex(resolveActiveStageIndex(instance, stages)); + return detailVO; + } + + /** + * 计算流程走向的高亮位置:运行中取当前活动节点,正常结束为全部完成, + * 终止/撤销取最后办完环节的下一位置(已办环节仍显示完成态) + */ + private int resolveActiveStageIndex(HistoricProcessInstance instance, List stages) { + if (stages.isEmpty()) { + return 0; + } + // 正常结束:全部环节完成 + if (instance.getEndTime() != null && StrUtil.isBlank(instance.getDeleteReason())) { + return stages.size(); + } + if (instance.getEndTime() == null) { + // 运行中:第一个活动节点即为进行中的环节 + Set activeActivityIds = new HashSet<>(runtimeService.getActiveActivityIds(instance.getId())); + return stages.stream() + .filter(stage -> activeActivityIds.contains(stage.getNodeId())) + .findFirst() + .map(stages::indexOf) + .orElse(0); + } + // 终止/撤销:已办环节保持完成态,高亮停在最后办完环节之后 + return approvalHistoryHelper.getHistory(instance.getId()).stream() + .filter(record -> record.getEndTime() != null) + .reduce((first, second) -> second) + .flatMap(last -> stages.stream() + .filter(stage -> stage.getNodeName().equals(last.getActivityName())) + .findFirst()) + .map(stages::indexOf) + .map(index -> index + 1) + .orElse(0); + } + + /** + * 撤销流程 + * + * @param instanceId 流程实例ID + */ + @Override + public void cancelProcessInstance(String instanceId) { + HistoricProcessInstance instance = getHistoricInstance(instanceId); + Assert.isTrue(instance.getEndTime() == null, "流程已结束,无法撤销"); + Assert.isTrue(SecurityUtils.getUsername().equals(instance.getStartUserId()), "仅发起人可撤销流程"); + runtimeService.deleteProcessInstance(instanceId, "发起人撤销"); + } + + /** + * 终止流程 + * + * @param instanceId 流程实例ID + * @param reason 终止原因 + */ + @Override + public void terminateProcessInstance(String instanceId, String reason) { + HistoricProcessInstance instance = getHistoricInstance(instanceId); + Assert.isTrue(instance.getEndTime() == null, "流程已结束,无法终止"); + runtimeService.deleteProcessInstance(instanceId, StrUtil.blankToDefault(reason, "管理员终止")); + } + + /** + * 发起表单数据回显(按提交时快照严格回显) + * + *

表单数据缺失时静默降级:业务表重装(dynamic-form.sql 重建)而引擎表保留、 + * 或数据被手动删除时实例仍在,详情页可继续展示审批记录/流程走向/流程图,不因表单缺失整体报错

+ */ + private void fillFormData(InstanceDetailVO detailVO, String formKey, String businessKey) { + if (StrUtil.isBlank(formKey) || StrUtil.isBlank(businessKey)) { + return; + } + // 非数字 businessKey(未经过本系统表单提交发起)无表单可回显 + if (!NumberUtil.isLong(businessKey)) { + log.warn("跳过表单回显:businessKey 非表单数据ID instanceId={} businessKey={}", detailVO.getId(), businessKey); + return; + } + FormDataVO dataVO = formDataService.getFormDataDetail(Long.valueOf(businessKey)); + if (dataVO == null) { + log.warn("发起表单数据不存在,跳过回显 instanceId={} businessKey={}", detailVO.getId(), businessKey); + return; + } + detailVO.setFormJson(dataVO.getFormJson()); + detailVO.setOptionsJson(dataVO.getOptionsJson()); + detailVO.setDataJson(dataVO.getDataJson()); + } + + /** + * 实例状态映射:未结束即运行中;有终止原因为已终止;其余为已完成 + */ + private InstanceStatusEnum resolveStatus(HistoricProcessInstance instance) { + if (instance.getEndTime() == null) { + return InstanceStatusEnum.RUNNING; + } + return StrUtil.isNotBlank(instance.getDeleteReason()) + ? InstanceStatusEnum.TERMINATED : InstanceStatusEnum.FINISHED; + } + + /** + * 获取历史流程实例(不存在即抛业务异常) + */ + private HistoricProcessInstance getHistoricInstance(String instanceId) { + HistoricProcessInstance instance = historyService.createHistoricProcessInstanceQuery() + .processInstanceId(instanceId).singleResult(); + Assert.notNull(instance, "流程实例不存在"); + return instance; + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java new file mode 100644 index 00000000..2536becb --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java @@ -0,0 +1,319 @@ +package com.youlai.boot.module.workflow.service.impl; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.module.workflow.model.form.ModelForm; +import com.youlai.boot.module.workflow.model.form.ModelXmlForm; +import com.youlai.boot.module.workflow.model.query.WorkflowModelPageQuery; +import com.youlai.boot.module.workflow.model.vo.ModelVO; +import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; +import com.youlai.boot.module.workflow.service.WorkflowModelService; +import com.youlai.boot.module.workflow.util.BpmnModelHelper; +import com.youlai.boot.system.model.entity.Role; +import com.youlai.boot.system.service.RoleService; +import lombok.RequiredArgsConstructor; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.repository.Deployment; +import org.flowable.engine.repository.Model; +import org.flowable.engine.repository.ModelQuery; +import org.flowable.engine.repository.ProcessDefinition; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 流程模型业务实现类 + * + *

模型即设计草稿,editorSource 持久化 bpmn-js 产出的 BPMN XML;部署后生成不可变的流程定义

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Service +@RequiredArgsConstructor +public class WorkflowModelServiceImpl implements WorkflowModelService { + + private final RepositoryService repositoryService; + private final BpmnModelHelper bpmnModelHelper; + private final RoleService roleService; + + /** + * 新模型初始模板:开始 → 审批 → 结束,设计器打开即可直接编辑 + */ + private static final String INITIAL_XML_TEMPLATE = """ + + + + + flow1 + + + flow1 + flow2 + + + flow2 + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + /** + * 流程模型分页列表 + * + * @param queryParams 查询参数 + */ + @Override + public Page getModelPage(WorkflowModelPageQuery queryParams) { + ModelQuery query = repositoryService.createModelQuery(); + if (StrUtil.isNotBlank(queryParams.getKeywords())) { + query.modelNameLike("%" + queryParams.getKeywords() + "%"); + } + query.orderByLastUpdateTime().desc(); + long total = query.count(); + List models = query.listPage((queryParams.getPageNum() - 1) * queryParams.getPageSize(), + queryParams.getPageSize()); + + Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize(), total); + List records = models.stream().map(this::toModelVO).toList(); + fillPublishState(records); + page.setRecords(records); + return page; + } + + /** + * 填充发布状态:模型与流程定义经 key 关联,未发布的模型无定义信息 + * + *

列表页即呈现"未发布 / v2 已启用 / v3 已停用",发布管理不必独立成页

+ */ + private void fillPublishState(List records) { + if (records.isEmpty()) { + return; + } + Map latestDefinitions = repositoryService.createProcessDefinitionQuery() + .latestVersion().list().stream() + .collect(Collectors.toMap(ProcessDefinition::getKey, definition -> definition, (a, b) -> a)); + if (latestDefinitions.isEmpty()) { + return; + } + // 部署时间在 ACT_RE_DEPLOYMENT,批量取避免逐行查询 + Map deploymentTimes = latestDefinitions.values().stream() + .map(ProcessDefinition::getDeploymentId).distinct() + .map(id -> repositoryService.createDeploymentQuery().deploymentId(id).singleResult()) + .collect(Collectors.toMap(Deployment::getId, Deployment::getDeploymentTime)); + records.forEach(modelVO -> { + ProcessDefinition definition = latestDefinitions.get(modelVO.getKey()); + if (definition == null) { + return; + } + modelVO.setDefinitionId(definition.getId()); + modelVO.setPublishedVersion(definition.getVersion()); + modelVO.setSuspended(definition.isSuspended()); + modelVO.setDeploymentTime(deploymentTimes.get(definition.getDeploymentId())); + }); + } + + /** + * 新增流程模型 + * + * @param formData 模型表单数据 + */ + @Override + public boolean saveModel(ModelForm formData) { + long count = repositoryService.createModelQuery().modelKey(formData.getKey()).count(); + Assert.isTrue(count == 0, "模型标识已存在"); + + Model model = repositoryService.newModel(); + model.setKey(formData.getKey()); + model.setName(formData.getName()); + model.setMetaInfo(buildMetaInfo(formData.getDescription())); + repositoryService.saveModel(model); + repositoryService.addModelEditorSource(model.getId(), + INITIAL_XML_TEMPLATE.formatted(formData.getKey(), formData.getName()) + .getBytes(StandardCharsets.UTF_8)); + return true; + } + + /** + * 修改流程模型 + * + * @param modelId 模型ID + * @param formData 模型表单数据 + */ + @Override + public boolean updateModel(String modelId, ModelForm formData) { + Model model = getModel(modelId); + model.setName(formData.getName()); + model.setMetaInfo(buildMetaInfo(formData.getDescription())); + repositoryService.saveModel(model); + return true; + } + + /** + * 删除流程模型 + * + *

「流程」对用户是整体:删除设计草稿时级联删除其全部发布版本(含运行实例与历史), + * 避免残留孤儿定义导致列表出现无模型可管理的流程

+ * + * @param modelIds 模型ID集合 + */ + @Override + public void removeModels(List modelIds) { + modelIds.forEach(modelId -> { + Model model = repositoryService.getModel(modelId); + if (model == null) { + return; + } + repositoryService.createDeploymentQuery() + .processDefinitionKey(model.getKey()).list() + .forEach(deployment -> repositoryService.deleteDeployment(deployment.getId(), true)); + repositoryService.deleteModel(modelId); + }); + } + + /** + * 获取模型 BPMN XML + * + * @param modelId 模型ID + */ + @Override + public ModelXmlVO getModelXml(String modelId) { + getModel(modelId); + byte[] source = repositoryService.getModelEditorSource(modelId); + return new ModelXmlVO(source == null ? "" : new String(source, StandardCharsets.UTF_8)); + } + + /** + * 保存模型 BPMN XML + * + * @param modelId 模型ID + * @param formData XML 表单数据 + */ + @Override + public boolean saveModelXml(String modelId, ModelXmlForm formData) { + getModel(modelId); + repositoryService.addModelEditorSource(modelId, formData.getXml().getBytes(StandardCharsets.UTF_8)); + return true; + } + + /** + * 部署流程模型 + * + * @param modelId 模型ID + */ + @Override + public void deployModel(String modelId) { + Model model = getModel(modelId); + byte[] source = repositoryService.getModelEditorSource(modelId); + if (source == null || source.length == 0) { + throw new BusinessException("模型内容为空,请先设计流程图"); + } + String bpmnXml = new String(source, StandardCharsets.UTF_8); + // 无主任务发布前拦截:未配置办理人的节点不会进入任何人待办,流程将永久卡死 + List unassignedTasks = bpmnModelHelper.getUnassignedTaskNames(bpmnXml); + if (!unassignedTasks.isEmpty()) { + throw new BusinessException("节点【" + String.join("、", unassignedTasks) + + "】未配置办理人,发布后将无人可审批,请补充后重试"); + } + // 候选组角色存在性校验:指向不存在的角色(拼写错误/已删除)同样产生无人可办的任务 + Set groupCodes = bpmnModelHelper.getCandidateGroups(bpmnXml); + if (!groupCodes.isEmpty()) { + Set existingCodes = roleService.list( + new LambdaQueryWrapper().in(Role::getCode, groupCodes)) + .stream().map(Role::getCode).collect(Collectors.toSet()); + List invalidCodes = groupCodes.stream() + .filter(code -> !existingCodes.contains(code)).sorted().toList(); + if (!invalidCodes.isEmpty()) { + throw new BusinessException("候选组【" + String.join("、", invalidCodes) + + "】不存在,请核对角色编码后重试"); + } + } + try { + repositoryService.createDeployment() + .name(model.getName()) + .key(model.getKey()) + .addString(model.getKey() + ".bpmn20.xml", bpmnXml) + .deploy(); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + // 引擎部署时做 BPMN 解析,节点未连线、非法 XML 均在此暴露 + throw new BusinessException("流程文件校验失败,请检查节点连线与属性配置"); + } + } + + /** + * 获取模型(不存在即抛业务异常) + */ + private Model getModel(String modelId) { + Model model = repositoryService.getModel(modelId); + Assert.notNull(model, "流程模型不存在"); + return model; + } + + /** + * 构建 metaInfo(Flowable 模型约定格式,description 存放于 JSON) + */ + private String buildMetaInfo(String description) { + JSONObject metaInfo = JSONUtil.createObj().set("description", description); + return metaInfo.toString(); + } + + private ModelVO toModelVO(Model model) { + ModelVO modelVO = new ModelVO(); + modelVO.setId(model.getId()); + modelVO.setName(model.getName()); + modelVO.setKey(model.getKey()); + modelVO.setDescription(parseMetaDescription(model.getMetaInfo())); + modelVO.setVersion(model.getVersion()); + modelVO.setCreateTime(model.getCreateTime()); + modelVO.setUpdateTime(model.getLastUpdateTime()); + return modelVO; + } + + private String parseMetaDescription(String metaInfo) { + if (StrUtil.isBlank(metaInfo) || !JSONUtil.isTypeJSONObject(metaInfo)) { + return null; + } + return JSONUtil.parseObj(metaInfo).getStr("description"); + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java new file mode 100644 index 00000000..f501841d --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java @@ -0,0 +1,383 @@ +package com.youlai.boot.module.workflow.service.impl; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.module.form.model.vo.FormDataVO; +import com.youlai.boot.module.form.service.FormDataService; +import com.youlai.boot.module.workflow.model.form.CompleteTaskForm; +import com.youlai.boot.module.workflow.model.form.RejectTaskForm; +import com.youlai.boot.module.workflow.model.query.WorkflowTaskPageQuery; +import com.youlai.boot.module.workflow.model.vo.DoneTaskVO; +import com.youlai.boot.module.workflow.model.vo.RejectTargetVO; +import com.youlai.boot.module.workflow.model.vo.TaskDetailVO; +import com.youlai.boot.module.workflow.model.vo.TodoTaskVO; +import com.youlai.boot.module.workflow.service.WorkflowDefinitionService; +import com.youlai.boot.module.workflow.service.WorkflowTaskService; +import com.youlai.boot.module.workflow.util.ApprovalHistoryHelper; +import com.youlai.boot.module.workflow.util.BpmnModelHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.flowable.common.engine.impl.identity.Authentication; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.engine.history.HistoricProcessInstance; +import org.flowable.engine.repository.ProcessDefinition; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.task.api.Task; +import org.flowable.task.api.TaskQuery; +import org.flowable.task.api.history.HistoricTaskInstance; +import org.flowable.task.api.history.HistoricTaskInstanceQuery; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 流程任务业务实现类 + * + *

任务归属以用户名为准(引擎 assignee 与登录用户名一致);候选组任务(candidateGroups 对应角色编码) + * 待办可见、办理时自动认领,审批意见归档为引擎评论

+ * + * @author Ray.Hao + * @since 4.9.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkflowTaskServiceImpl implements WorkflowTaskService { + + private final TaskService taskService; + private final RuntimeService runtimeService; + private final HistoryService historyService; + private final RepositoryService repositoryService; + private final FormDataService formDataService; + private final WorkflowDefinitionService workflowDefinitionService; + private final BpmnModelHelper bpmnModelHelper; + private final ApprovalHistoryHelper approvalHistoryHelper; + + /** + * 待办任务分页列表 + * + * @param queryParams 查询参数 + */ + @Override + public Page getTodoTaskPage(WorkflowTaskPageQuery queryParams) { + TaskQuery query = this.createCandidateTaskQuery(); + if (StrUtil.isNotBlank(queryParams.getKeywords())) { + query.taskNameLike("%" + queryParams.getKeywords() + "%"); + } + query.orderByTaskCreateTime().desc(); + long total = query.count(); + List tasks = query.listPage( + (queryParams.getPageNum() - 1) * queryParams.getPageSize(), queryParams.getPageSize()); + + // 流程名称、businessKey 不在任务表上,批量回查避免逐行查询 + Map processNames = getProcessNames( + tasks.stream().map(Task::getProcessDefinitionId).toList()); + Map instances = getProcessInstances( + tasks.stream().map(Task::getProcessInstanceId).collect(Collectors.toSet())); + Map formKeys = new HashMap<>(); + + List records = tasks.stream().map(task -> { + TodoTaskVO taskVO = new TodoTaskVO(); + taskVO.setId(task.getId()); + taskVO.setName(task.getName()); + taskVO.setProcessInstanceId(task.getProcessInstanceId()); + ProcessInstance instance = instances.get(task.getProcessInstanceId()); + // 实例名优先(发起时可自定义标题区分批次),旧实例 NAME_ 为空则回退流程定义名 + taskVO.setProcessName(resolveProcessName( + instance == null ? null : instance.getName(), + processNames.get(task.getProcessDefinitionId()))); + taskVO.setBusinessKey(instance == null ? null : instance.getBusinessKey()); + taskVO.setFormKey(formKeys.computeIfAbsent(task.getProcessDefinitionId(), + bpmnModelHelper::getStartFormKey)); + taskVO.setCreateTime(task.getCreateTime()); + return taskVO; + }).toList(); + + Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize(), total); + page.setRecords(records); + return page; + } + + /** + * 已办任务分页列表 + * + * @param queryParams 查询参数 + */ + @Override + public Page getDoneTaskPage(WorkflowTaskPageQuery queryParams) { + HistoricTaskInstanceQuery query = historyService.createHistoricTaskInstanceQuery() + .taskAssignee(SecurityUtils.getUsername()).finished(); + if (StrUtil.isNotBlank(queryParams.getKeywords())) { + query.taskNameLike("%" + queryParams.getKeywords() + "%"); + } + query.orderByHistoricTaskInstanceEndTime().desc(); + long total = query.count(); + List tasks = query.listPage( + (queryParams.getPageNum() - 1) * queryParams.getPageSize(), queryParams.getPageSize()); + + Map processNames = getProcessNames( + tasks.stream().map(HistoricTaskInstance::getProcessDefinitionId).toList()); + // 已办实例可能已结束,运行时表查不到,实例名须从历史表批量取(key 为流程实例ID) + Map instances = getHistoricProcessInstances( + tasks.stream().map(HistoricTaskInstance::getProcessInstanceId).collect(Collectors.toSet())); + // 意见存于 ACT_HI_COMMENT(按流程实例归档),分页内按实例去重批量回查,避免逐任务查询 + Map comments = new HashMap<>(); + tasks.stream().map(HistoricTaskInstance::getProcessInstanceId).distinct() + .forEach(processInstanceId -> taskService.getProcessInstanceComments(processInstanceId) + .forEach(comment -> { + if (StrUtil.isNotBlank(comment.getTaskId())) { + comments.put(comment.getTaskId(), comment.getFullMessage()); + } + })); + List records = tasks.stream().map(task -> { + DoneTaskVO taskVO = new DoneTaskVO(); + taskVO.setId(task.getId()); + taskVO.setName(task.getName()); + taskVO.setProcessInstanceId(task.getProcessInstanceId()); + HistoricProcessInstance instance = instances.get(task.getProcessInstanceId()); + taskVO.setProcessName(resolveProcessName( + instance == null ? null : instance.getName(), + processNames.get(task.getProcessDefinitionId()))); + taskVO.setEndTime(task.getEndTime()); + taskVO.setComment(comments.get(task.getId())); + return taskVO; + }).toList(); + + Page page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize(), total); + page.setRecords(records); + return page; + } + + /** + * 获取待办任务详情 + * + * @param taskId 任务ID + */ + @Override + public TaskDetailVO getTaskDetail(String taskId) { + Task task = getCurrentUserTask(taskId); + HistoricProcessInstance instance = historyService.createHistoricProcessInstanceQuery() + .processInstanceId(task.getProcessInstanceId()).singleResult(); + Assert.notNull(instance, "流程实例不存在"); + + TaskDetailVO detailVO = new TaskDetailVO(); + detailVO.setTaskId(task.getId()); + detailVO.setTaskName(task.getName()); + detailVO.setTaskDefinitionKey(task.getTaskDefinitionKey()); + detailVO.setProcessInstanceId(task.getProcessInstanceId()); + // 实例名优先(与列表口径一致),旧实例 NAME_ 为空则回退流程定义名 + detailVO.setProcessName(resolveProcessName(instance.getName(), instance.getProcessDefinitionName())); + detailVO.setTaskFormKey(bpmnModelHelper.getUserTaskFormKey( + task.getProcessDefinitionId(), task.getTaskDefinitionKey())); + + String formKey = bpmnModelHelper.getStartFormKey(task.getProcessDefinitionId()); + detailVO.setFormKey(formKey); + this.fillFormData(detailVO, formKey, instance.getBusinessKey()); + detailVO.setHistory(approvalHistoryHelper.getHistory(task.getProcessInstanceId())); + // 流程走向预览(审批人办理前可见完整审批链,明确前后环节办理人) + detailVO.setStages(workflowDefinitionService.listProcessStages(task.getProcessDefinitionId())); + return detailVO; + } + + /** + * 获取驳回目标节点列表 + * + * @param taskId 任务ID + */ + @Override + public List listRejectTargets(String taskId) { + Task task = getCurrentUserTask(taskId); + // 已办节点去重取首个名称;驳回当前节点自身无意义,排除 + Map targets = new LinkedHashMap<>(); + historyService.createHistoricActivityInstanceQuery() + .processInstanceId(task.getProcessInstanceId()) + .activityType("userTask") + .finished() + .orderByHistoricActivityInstanceStartTime().asc() + .list() + .forEach(activity -> { + if (!activity.getActivityId().equals(task.getTaskDefinitionKey())) { + targets.putIfAbsent(activity.getActivityId(), activity.getActivityName()); + } + }); + return targets.entrySet().stream() + .map(entry -> new RejectTargetVO(entry.getKey(), entry.getValue())) + .toList(); + } + + /** + * 审批通过 + * + * @param taskId 任务ID + * @param formData 审批表单数据 + */ + @Override + public void completeTask(String taskId, CompleteTaskForm formData) { + Task task = getCurrentUserTask(taskId); + this.addComment(task, formData.getComment()); + // 或签(候选组)任务办理即认领,历史表写入办理人,已办列表可追溯 + this.claimIfCandidate(task); + taskService.complete(taskId, formData.getVariables() == null ? Map.of() : formData.getVariables()); + } + + /** + * 驳回 + * + * @param taskId 任务ID + * @param formData 驳回表单数据 + */ + @Override + public void rejectTask(String taskId, RejectTaskForm formData) { + Task task = getCurrentUserTask(taskId); + // 目标必须是本实例已办理过的节点,防止驳回到未走过的分支 + long count = historyService.createHistoricActivityInstanceQuery() + .processInstanceId(task.getProcessInstanceId()) + .activityType("userTask") + .activityId(formData.getTargetActivityId()) + .finished() + .count(); + Assert.isTrue(count > 0, "驳回目标必须是本实例已办理过的节点"); + + this.addComment(task, formData.getComment()); + this.claimIfCandidate(task); + runtimeService.createChangeActivityStateBuilder() + .moveExecutionToActivityId(task.getExecutionId(), formData.getTargetActivityId()) + .changeState(); + } + + /** + * 获取当前用户的待办任务(不存在或非本人待办即抛业务异常) + */ + private Task getCurrentUserTask(String taskId) { + Task task = this.createCandidateTaskQuery() + .taskId(taskId) + .singleResult(); + Assert.notNull(task, "任务不存在或非你的待办"); + return task; + } + + /** + * 当前用户的候选任务查询:本人任务(办理人/候选用户)或所属角色候选组的任务 + * + *

引擎未同步用户-角色关系,单参 candidateOrAssigned 匹配不到候选组, + * 以 or 查询显式传入登录用户的角色编码集合(BPMN candidateGroups 对应系统角色编码)

+ */ + private TaskQuery createCandidateTaskQuery() { + String username = SecurityUtils.getUsername(); + List roles = new ArrayList<>(SecurityUtils.getRoles()); + if (roles.isEmpty()) { + return taskService.createTaskQuery() + .active().taskCandidateOrAssigned(username); + } + return taskService.createTaskQuery() + .active().or() + .taskCandidateOrAssigned(username) + .taskCandidateGroupIn(roles) + .endOr(); + } + + /** + * 候选组任务办理前自动认领(认领后写入办理人,已办列表与审批记录可追溯) + */ + private void claimIfCandidate(Task task) { + if (task.getAssignee() == null) { + taskService.claim(task.getId(), SecurityUtils.getUsername()); + } + } + + /** + * 审批意见写入引擎评论(发起人上下文决定评论作者) + */ + private void addComment(Task task, String comment) { + if (StrUtil.isBlank(comment)) { + return; + } + Authentication.setAuthenticatedUserId(SecurityUtils.getUsername()); + try { + taskService.addComment(task.getId(), task.getProcessInstanceId(), comment); + } finally { + Authentication.setAuthenticatedUserId(null); + } + } + + /** + * 发起表单数据回显(按提交时快照严格回显) + * + *

表单数据缺失时静默降级(业务表重装或数据被删而实例仍在), + * 办理弹窗可继续展示审批记录/流程走向,不因表单缺失整体报错

+ */ + private void fillFormData(TaskDetailVO detailVO, String formKey, String businessKey) { + if (StrUtil.isBlank(formKey) || StrUtil.isBlank(businessKey)) { + return; + } + // 非数字 businessKey(未经过本系统表单提交发起)无表单可回显 + if (!NumberUtil.isLong(businessKey)) { + log.warn("跳过表单回显:businessKey 非表单数据ID taskId={} businessKey={}", detailVO.getTaskId(), businessKey); + return; + } + FormDataVO dataVO = formDataService.getFormDataDetail(Long.valueOf(businessKey)); + if (dataVO == null) { + log.warn("发起表单数据不存在,跳过回显 taskId={} businessKey={}", detailVO.getTaskId(), businessKey); + return; + } + detailVO.setFormJson(dataVO.getFormJson()); + detailVO.setOptionsJson(dataVO.getOptionsJson()); + detailVO.setDataJson(dataVO.getDataJson()); + } + + /** + * 批量取流程定义名称(key 为流程定义ID) + */ + private Map getProcessNames(List processDefinitionIds) { + return processDefinitionIds.stream().distinct() + .collect(Collectors.toMap(id -> id, id -> { + ProcessDefinition definition = repositoryService.createProcessDefinitionQuery() + .processDefinitionId(id).singleResult(); + return definition == null ? "" : definition.getName(); + })); + } + + /** + * 解析流程名称:实例级名称优先(发起时可自定义标题区分同流程多次发起),旧实例 NAME_ 为空回退流程定义名 + */ + private String resolveProcessName(String instanceName, String definitionName) { + return StrUtil.isBlank(instanceName) ? definitionName : instanceName; + } + + /** + * 批量取历史流程实例(key 为流程实例ID;已办任务对应的实例可能已结束,须查历史表) + */ + private Map getHistoricProcessInstances(Set processInstanceIds) { + if (processInstanceIds.isEmpty()) { + return Map.of(); + } + return historyService.createHistoricProcessInstanceQuery() + .processInstanceIds(processInstanceIds).list().stream() + .collect(Collectors.toMap(HistoricProcessInstance::getId, instance -> instance)); + } + + /** + * 批量取运行中流程实例(key 为流程实例ID) + */ + private Map getProcessInstances(Set processInstanceIds) { + if (processInstanceIds.isEmpty()) { + return Map.of(); + } + return runtimeService.createProcessInstanceQuery() + .processInstanceIds(processInstanceIds).list().stream() + .collect(Collectors.toMap(ProcessInstance::getId, instance -> instance)); + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/util/ApprovalHistoryHelper.java b/src/main/java/com/youlai/boot/module/workflow/util/ApprovalHistoryHelper.java new file mode 100644 index 00000000..5089f55b --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/util/ApprovalHistoryHelper.java @@ -0,0 +1,63 @@ +package com.youlai.boot.module.workflow.util; + +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.module.workflow.model.vo.ApprovalHistoryVO; +import lombok.RequiredArgsConstructor; +import org.flowable.engine.HistoryService; +import org.flowable.engine.TaskService; +import org.flowable.engine.history.HistoricActivityInstance; +import org.flowable.engine.task.Comment; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 审批记录辅助类(用户任务含进行中节点 + 引擎评论按 taskId 归位) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Component +@RequiredArgsConstructor +public class ApprovalHistoryHelper { + + private final HistoryService historyService; + private final TaskService taskService; + + /** + * 获取流程实例的审批记录(按任务开始时间正序,含当前办理中节点) + * + * @param processInstanceId 流程实例ID + * @return 审批记录集合 + */ + public List getHistory(String processInstanceId) { + // 不限 finished:进行中节点 endTime 为空,时间线据此展示"进行中"态 + List activities = historyService.createHistoricActivityInstanceQuery() + .processInstanceId(processInstanceId) + .activityType("userTask") + .orderByHistoricActivityInstanceStartTime() + .asc() + .list(); + if (activities.isEmpty()) { + return List.of(); + } + // 同一节点驳回后重办会产生多条记录,评论按 taskId 精确归位、后写覆盖先写 + Map comments = taskService.getProcessInstanceComments(processInstanceId).stream() + .filter(comment -> StrUtil.isNotBlank(comment.getTaskId())) + .collect(Collectors.toMap(Comment::getTaskId, Comment::getFullMessage, (first, second) -> second)); + + return activities.stream().map(activity -> { + ApprovalHistoryVO historyVO = new ApprovalHistoryVO(); + historyVO.setActivityId(activity.getActivityId()); + historyVO.setActivityName(activity.getActivityName()); + historyVO.setAssignee(activity.getAssignee()); + historyVO.setComment(comments.get(activity.getTaskId())); + historyVO.setStartTime(activity.getStartTime()); + historyVO.setEndTime(activity.getEndTime()); + return historyVO; + }).toList(); + } + +} diff --git a/src/main/java/com/youlai/boot/module/workflow/util/BpmnModelHelper.java b/src/main/java/com/youlai/boot/module/workflow/util/BpmnModelHelper.java new file mode 100644 index 00000000..b3e593e4 --- /dev/null +++ b/src/main/java/com/youlai/boot/module/workflow/util/BpmnModelHelper.java @@ -0,0 +1,179 @@ +package com.youlai.boot.module.workflow.util; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.youlai.boot.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.flowable.bpmn.converter.BpmnXMLConverter; +import org.flowable.bpmn.model.BpmnModel; +import org.flowable.bpmn.model.Process; +import org.flowable.bpmn.model.StartEvent; +import org.flowable.bpmn.model.UserTask; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.repository.ProcessDefinition; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * BPMN 模型读取辅助类(formKey 与 XML 解析统一收口,避免散落各 Service) + * + * @author Ray.Hao + * @since 4.9.0 + */ +@Component +@RequiredArgsConstructor +public class BpmnModelHelper { + + private final RepositoryService repositoryService; + + /** + * 用户任务节点的 BPMN 原始信息(办理人配置由上层翻译为角色/用户展示) + * + * @param nodeId 节点ID + * @param nodeName 节点名称 + * @param candidateGroups 候选组(角色编码)列表,可为空 + * @param assignee 办理人表达式(如 ${initiator}),可为空 + */ + public record UserTaskInfo(String nodeId, String nodeName, List candidateGroups, String assignee) { + } + + /** + * 获取主流程的全部用户任务节点(BPMN 文档序,即流程模型中的节点编排顺序) + * + * @param processDefinitionId 流程定义ID + */ + public List getUserTasks(String processDefinitionId) { + Process process = getMainProcess(processDefinitionId); + if (process == null) { + return List.of(); + } + return process.findFlowElementsOfType(UserTask.class).stream() + .map(task -> new UserTaskInfo(task.getId(), task.getName(), + task.getCandidateGroups(), task.getAssignee())) + .toList(); + } + + /** + * 获取开始节点绑定的表单标识 + * + * @param processDefinitionId 流程定义ID + * @return formKey,未绑定为 null + */ + public String getStartFormKey(String processDefinitionId) { + Process process = getMainProcess(processDefinitionId); + if (process == null) { + return null; + } + return process.findFlowElementsOfType(StartEvent.class).stream() + .findFirst() + .map(StartEvent::getFormKey) + .orElse(null); + } + + /** + * 获取用户任务节点绑定的表单标识 + * + * @param processDefinitionId 流程定义ID + * @param taskDefinitionKey 任务节点ID + * @return formKey,未绑定或节点非用户任务为 null + */ + public String getUserTaskFormKey(String processDefinitionId, String taskDefinitionKey) { + Process process = getMainProcess(processDefinitionId); + if (process == null) { + return null; + } + return process.findFlowElementsOfType(UserTask.class).stream() + .filter(task -> task.getId().equals(taskDefinitionKey)) + .findFirst() + .map(UserTask::getFormKey) + .orElse(null); + } + + /** + * 获取流程定义的 BPMN XML + * + * @param processDefinitionId 流程定义ID + * @return BPMN 2.0 XML 字符串 + */ + public String getBpmnXml(String processDefinitionId) { + try (InputStream in = repositoryService.getProcessModel(processDefinitionId)) { + if (in == null) { + throw new BusinessException("流程图不存在"); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException("流程图读取失败"); + } + } + + /** + * 校验 BPMN XML 中用户任务节点的办理人配置 + * + *

assignee/候选组/候选用户三者均未配置的任务会成为无主任务: + * 不出现在任何人的待办列表,流程将永久卡在该节点

+ * + * @param bpmnXml BPMN 2.0 XML 字符串 + * @return 未配置办理人的节点名称列表(全部已配置返回空列表) + */ + public List getUnassignedTaskNames(String bpmnXml) { + // validateSchema=false:bpmn-js 产出的命名空间前缀未必严格符合 XSD,引擎部署同样不强制 + BpmnModel bpmnModel = new BpmnXMLConverter().convertToBpmnModel( + () -> new ByteArrayInputStream(bpmnXml.getBytes(StandardCharsets.UTF_8)), false, true); + Process process = bpmnModel.getMainProcess(); + if (process == null) { + throw new BusinessException("流程文件校验失败,未找到主流程"); + } + List unassigned = new ArrayList<>(); + for (UserTask task : process.findFlowElementsOfType(UserTask.class)) { + boolean noAssignee = StrUtil.isBlank(task.getAssignee()); + boolean noGroups = CollUtil.isEmpty(task.getCandidateGroups()); + boolean noUsers = CollUtil.isEmpty(task.getCandidateUsers()); + if (noAssignee && noGroups && noUsers) { + unassigned.add(task.getName()); + } + } + return unassigned; + } + + /** + * 提取 BPMN XML 中全部用户任务引用的候选组编码(去重) + * + *

供发布校验核对角色存在性:候选组指向不存在的角色时任务无人可见,同样会卡死流程

+ * + * @param bpmnXml BPMN 2.0 XML 字符串 + * @return 候选组角色编码集合(无候选组返回空集合) + */ + public Set getCandidateGroups(String bpmnXml) { + BpmnModel bpmnModel = new BpmnXMLConverter().convertToBpmnModel( + () -> new ByteArrayInputStream(bpmnXml.getBytes(StandardCharsets.UTF_8)), false, true); + Process process = bpmnModel.getMainProcess(); + if (process == null) { + throw new BusinessException("流程文件校验失败,未找到主流程"); + } + return process.findFlowElementsOfType(UserTask.class).stream() + .flatMap(task -> task.getCandidateGroups().stream()) + .collect(Collectors.toSet()); + } + + /** + * 获取主流程(泳道等子流程不参与 formKey 解析) + */ + private Process getMainProcess(String processDefinitionId) { + ProcessDefinition definition = repositoryService.createProcessDefinitionQuery() + .processDefinitionId(processDefinitionId).singleResult(); + if (definition == null) { + throw new BusinessException("流程定义不存在"); + } + return repositoryService.getBpmnModel(processDefinitionId).getMainProcess(); + } + +} diff --git a/src/main/java/com/youlai/boot/config/property/StorageProperties.java b/src/main/java/com/youlai/boot/support/file/property/StorageProperties.java similarity index 95% rename from src/main/java/com/youlai/boot/config/property/StorageProperties.java rename to src/main/java/com/youlai/boot/support/file/property/StorageProperties.java index 9c67e94b..f130b8c4 100644 --- a/src/main/java/com/youlai/boot/config/property/StorageProperties.java +++ b/src/main/java/com/youlai/boot/support/file/property/StorageProperties.java @@ -1,8 +1,7 @@ -package com.youlai.boot.config.property; +package com.youlai.boot.support.file.property; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; import java.util.Collections; import java.util.List; @@ -19,7 +18,6 @@ import java.util.stream.Collectors; * @since 4.8.0 */ @Data -@Component @ConfigurationProperties(prefix = "storage") public class StorageProperties { @@ -73,4 +71,4 @@ public class StorageProperties { .map(String::toLowerCase) .collect(Collectors.toUnmodifiableSet()); } -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/config/MailConfig.java b/src/main/java/com/youlai/boot/support/mail/MailConfig.java similarity index 83% rename from src/main/java/com/youlai/boot/config/MailConfig.java rename to src/main/java/com/youlai/boot/support/mail/MailConfig.java index 6f980b70..4ae0d88b 100644 --- a/src/main/java/com/youlai/boot/config/MailConfig.java +++ b/src/main/java/com/youlai/boot/support/mail/MailConfig.java @@ -1,7 +1,5 @@ -package com.youlai.boot.config; +package com.youlai.boot.support.mail; -import com.youlai.boot.config.property.MailProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.mail.javamail.JavaMailSender; @@ -12,11 +10,10 @@ import java.util.Properties; /** * 邮件发送配置 * - * @author Ray + * @author Ray.Hao * @since 2024/8/17 */ @Configuration -@EnableConfigurationProperties(MailProperties.class) public class MailConfig { private final MailProperties mailProperties; @@ -39,4 +36,4 @@ public class MailConfig { return mailSender; } -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/config/property/MailProperties.java b/src/main/java/com/youlai/boot/support/mail/MailProperties.java similarity index 96% rename from src/main/java/com/youlai/boot/config/property/MailProperties.java rename to src/main/java/com/youlai/boot/support/mail/MailProperties.java index 4e7cad90..0bed75c6 100644 --- a/src/main/java/com/youlai/boot/config/property/MailProperties.java +++ b/src/main/java/com/youlai/boot/support/mail/MailProperties.java @@ -1,4 +1,4 @@ -package com.youlai.boot.config.property; +package com.youlai.boot.support.mail; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -49,4 +49,4 @@ public class MailProperties { } } } -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/support/mail/EmailService.java b/src/main/java/com/youlai/boot/support/mail/service/EmailService.java similarity index 94% rename from src/main/java/com/youlai/boot/support/mail/EmailService.java rename to src/main/java/com/youlai/boot/support/mail/service/EmailService.java index 1a60dcae..6e1ebc8c 100644 --- a/src/main/java/com/youlai/boot/support/mail/EmailService.java +++ b/src/main/java/com/youlai/boot/support/mail/service/EmailService.java @@ -1,4 +1,4 @@ -package com.youlai.boot.support.mail; +package com.youlai.boot.support.mail.service; /** * 邮件发送服务接口。 @@ -29,4 +29,4 @@ public interface EmailService { */ void sendWithAttachment(String to, String subject, String text, String filePath); -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/support/mail/impl/SmtpEmailServiceImpl.java b/src/main/java/com/youlai/boot/support/mail/service/impl/SmtpEmailServiceImpl.java similarity index 93% rename from src/main/java/com/youlai/boot/support/mail/impl/SmtpEmailServiceImpl.java rename to src/main/java/com/youlai/boot/support/mail/service/impl/SmtpEmailServiceImpl.java index b76a2450..060157f7 100644 --- a/src/main/java/com/youlai/boot/support/mail/impl/SmtpEmailServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/mail/service/impl/SmtpEmailServiceImpl.java @@ -1,7 +1,7 @@ -package com.youlai.boot.support.mail.impl; +package com.youlai.boot.support.mail.service.impl; -import com.youlai.boot.config.property.MailProperties; -import com.youlai.boot.support.mail.EmailService; +import com.youlai.boot.support.mail.MailProperties; +import com.youlai.boot.support.mail.service.EmailService; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; @@ -71,4 +71,4 @@ public class SmtpEmailServiceImpl implements EmailService { log.error("发送带附件邮件失败: {}", e.getMessage()); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/config/property/AliyunSmsProperties.java b/src/main/java/com/youlai/boot/support/sms/AliyunSmsProperties.java similarity index 86% rename from src/main/java/com/youlai/boot/config/property/AliyunSmsProperties.java rename to src/main/java/com/youlai/boot/support/sms/AliyunSmsProperties.java index 317e366b..22e6651f 100644 --- a/src/main/java/com/youlai/boot/config/property/AliyunSmsProperties.java +++ b/src/main/java/com/youlai/boot/support/sms/AliyunSmsProperties.java @@ -1,8 +1,7 @@ -package com.youlai.boot.config.property; +package com.youlai.boot.support.sms; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; import java.util.Map; @@ -12,9 +11,8 @@ import java.util.Map; * @author Ray * @since 2024/8/17 */ -@Configuration -@ConfigurationProperties(prefix = "sms.aliyun") @Data +@ConfigurationProperties(prefix = "sms.aliyun") public class AliyunSmsProperties { /** Access Key ID */ @@ -35,4 +33,4 @@ public class AliyunSmsProperties { /** 短信模板集合,key 为模板用途(login/register),value 为模板CODE */ private Map templates; -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/support/sms/SmsTypeEnum.java b/src/main/java/com/youlai/boot/support/sms/SmsTypeEnum.java index 55154ffc..c27ac54d 100644 --- a/src/main/java/com/youlai/boot/support/sms/SmsTypeEnum.java +++ b/src/main/java/com/youlai/boot/support/sms/SmsTypeEnum.java @@ -36,4 +36,4 @@ public enum SmsTypeEnum implements IBaseEnum { this.value = value; this.label = label; } -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/support/sms/SmsService.java b/src/main/java/com/youlai/boot/support/sms/service/SmsService.java similarity index 86% rename from src/main/java/com/youlai/boot/support/sms/SmsService.java rename to src/main/java/com/youlai/boot/support/sms/service/SmsService.java index 1f229f03..cd068aa0 100644 --- a/src/main/java/com/youlai/boot/support/sms/SmsService.java +++ b/src/main/java/com/youlai/boot/support/sms/service/SmsService.java @@ -1,4 +1,6 @@ -package com.youlai.boot.support.sms; +package com.youlai.boot.support.sms.service; + +import com.youlai.boot.support.sms.SmsTypeEnum; import java.util.Map; @@ -22,4 +24,4 @@ public interface SmsService { */ boolean send(String mobile, SmsTypeEnum smsType, Map templateParams); -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/support/sms/impl/AliyunSmsServiceImpl.java b/src/main/java/com/youlai/boot/support/sms/service/impl/AliyunSmsServiceImpl.java similarity index 93% rename from src/main/java/com/youlai/boot/support/sms/impl/AliyunSmsServiceImpl.java rename to src/main/java/com/youlai/boot/support/sms/service/impl/AliyunSmsServiceImpl.java index 24cf0623..ee9d9848 100644 --- a/src/main/java/com/youlai/boot/support/sms/impl/AliyunSmsServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/sms/service/impl/AliyunSmsServiceImpl.java @@ -1,4 +1,4 @@ -package com.youlai.boot.support.sms.impl; +package com.youlai.boot.support.sms.service.impl; import cn.hutool.json.JSONUtil; import com.aliyuncs.CommonRequest; @@ -8,9 +8,9 @@ import com.aliyuncs.IAcsClient; import com.aliyuncs.exceptions.ClientException; import com.aliyuncs.http.MethodType; import com.aliyuncs.profile.DefaultProfile; +import com.youlai.boot.support.sms.AliyunSmsProperties; import com.youlai.boot.support.sms.SmsTypeEnum; -import com.youlai.boot.config.property.AliyunSmsProperties; -import com.youlai.boot.support.sms.SmsService; +import com.youlai.boot.support.sms.service.SmsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -60,4 +60,4 @@ public class AliyunSmsServiceImpl implements SmsService { } return false; } -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/support/sse/model/OnlineUserDTO.java b/src/main/java/com/youlai/boot/support/sse/OnlineUserDTO.java similarity index 94% rename from src/main/java/com/youlai/boot/support/sse/model/OnlineUserDTO.java rename to src/main/java/com/youlai/boot/support/sse/OnlineUserDTO.java index 81cb9e51..9f09e3f7 100644 --- a/src/main/java/com/youlai/boot/support/sse/model/OnlineUserDTO.java +++ b/src/main/java/com/youlai/boot/support/sse/OnlineUserDTO.java @@ -1,4 +1,4 @@ -package com.youlai.boot.support.sse.model; +package com.youlai.boot.support.sse; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/youlai/boot/support/sse/SseService.java b/src/main/java/com/youlai/boot/support/sse/SseService.java index 6613a78d..d3e45186 100644 --- a/src/main/java/com/youlai/boot/support/sse/SseService.java +++ b/src/main/java/com/youlai/boot/support/sse/SseService.java @@ -2,7 +2,6 @@ package com.youlai.boot.support.sse; import com.youlai.boot.support.sse.constant.SseEventConstants; import com.youlai.boot.support.sse.constant.SseTopics; -import com.youlai.boot.support.sse.model.OnlineUserDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/youlai/boot/system/controller/ConfigController.java b/src/main/java/com/youlai/boot/system/controller/ConfigController.java index 73984b64..b6c2e327 100644 --- a/src/main/java/com/youlai/boot/system/controller/ConfigController.java +++ b/src/main/java/com/youlai/boot/system/controller/ConfigController.java @@ -39,7 +39,7 @@ public class ConfigController { @GetMapping @PreAuthorize("@ss.hasPerm('sys:config:list')") @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.LIST) - public PageResult page(@ParameterObject ConfigQuery queryParams) { + public PageResult page(@ParameterObject @Valid ConfigQuery queryParams) { IPage result = configService.page(queryParams); return PageResult.success(result); } @@ -48,7 +48,7 @@ public class ConfigController { @PostMapping @PreAuthorize("@ss.hasPerm('sys:config:create')") @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.INSERT) - public Result save(@RequestBody @Valid ConfigForm configForm) { + public Result save(@RequestBody @Valid ConfigForm configForm) { return Result.judge(configService.save(configForm)); } @@ -73,7 +73,7 @@ public class ConfigController { @PutMapping(value = "/{configId}") @PreAuthorize("@ss.hasPerm('sys:config:update')") @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.UPDATE) - public Result update(@Valid @PathVariable Long configId, @RequestBody ConfigForm configForm) { + public Result update(@PathVariable Long configId, @RequestBody @Valid ConfigForm configForm) { return Result.judge(configService.edit(configId, configForm)); } @@ -81,7 +81,7 @@ public class ConfigController { @DeleteMapping("/{configId}") @PreAuthorize("@ss.hasPerm('sys:config:delete')") @Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.DELETE) - public Result delete(@PathVariable Long configId) { + public Result delete(@PathVariable Long configId) { return Result.judge(configService.delete(configId)); } diff --git a/src/main/java/com/youlai/boot/system/controller/DeptController.java b/src/main/java/com/youlai/boot/system/controller/DeptController.java index 7a54a8fd..68c43c34 100644 --- a/src/main/java/com/youlai/boot/system/controller/DeptController.java +++ b/src/main/java/com/youlai/boot/system/controller/DeptController.java @@ -2,7 +2,7 @@ package com.youlai.boot.system.controller; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; import com.youlai.boot.common.model.Option; import com.youlai.boot.common.result.Result; import com.youlai.boot.system.model.form.DeptForm; @@ -38,7 +38,7 @@ public class DeptController { @GetMapping @Log(module = LogModuleEnum.DEPT, value = ActionTypeEnum.LIST) public Result> getDeptList( - DeptQuery queryParams + @Valid DeptQuery queryParams ) { List list = deptService.getDeptList(queryParams); return Result.success(list); @@ -56,7 +56,7 @@ public class DeptController { @PreAuthorize("@ss.hasPerm('sys:dept:create')") @RepeatSubmit @Log(module = LogModuleEnum.DEPT, value = ActionTypeEnum.INSERT) - public Result saveDept( + public Result saveDept( @Valid @RequestBody DeptForm formData ) { Long id = deptService.saveDept(formData); @@ -76,7 +76,7 @@ public class DeptController { @PutMapping(value = "/{deptId}") @PreAuthorize("@ss.hasPerm('sys:dept:update')") @Log(module = LogModuleEnum.DEPT, value = ActionTypeEnum.UPDATE) - public Result updateDept( + public Result updateDept( @PathVariable Long deptId, @Valid @RequestBody DeptForm formData ) { @@ -88,7 +88,7 @@ public class DeptController { @DeleteMapping("/{ids}") @PreAuthorize("@ss.hasPerm('sys:dept:delete')") @Log(module = LogModuleEnum.DEPT, value = ActionTypeEnum.DELETE) - public Result deleteDepartments( + public Result deleteDepartments( @Parameter(description ="部门ID,多个以英文逗号(,)分割") @PathVariable("ids") String ids ) { boolean result = deptService.deleteByIds(ids); diff --git a/src/main/java/com/youlai/boot/system/controller/DictController.java b/src/main/java/com/youlai/boot/system/controller/DictController.java index ef8e0487..0d2f604e 100644 --- a/src/main/java/com/youlai/boot/system/controller/DictController.java +++ b/src/main/java/com/youlai/boot/system/controller/DictController.java @@ -12,14 +12,14 @@ import com.youlai.boot.system.model.query.DictQuery; import com.youlai.boot.system.model.vo.DictItemOptionVO; import com.youlai.boot.system.model.vo.DictItemPageVO; import com.youlai.boot.system.model.vo.DictPageVO; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; import com.youlai.boot.system.model.form.DictForm; import com.youlai.boot.common.annotation.Log; import com.youlai.boot.system.service.DictItemService; import com.youlai.boot.system.service.DictService; import com.youlai.boot.support.sse.SseService; import com.youlai.boot.support.sse.constant.SseTopics; -import com.youlai.boot.system.model.dto.DictChangeEvent; +import com.youlai.boot.system.model.dto.DictChangeDTO; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; @@ -53,7 +53,7 @@ public class DictController { * @param dictCode 字典编码 */ private void sendDictChange(String dictCode) { - sseService.broadcast(SseTopics.DICT, new DictChangeEvent(dictCode)); + sseService.broadcast(SseTopics.DICT, new DictChangeDTO(dictCode)); } //--------------------------------------------------- @@ -63,7 +63,7 @@ public class DictController { @GetMapping @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.LIST) public PageResult getDictPage( - DictQuery queryParams + @Valid DictQuery queryParams ) { Page result = dictService.getDictPage(queryParams); return PageResult.success(result); @@ -91,7 +91,7 @@ public class DictController { @PreAuthorize("@ss.hasPerm('sys:dict:create')") @RepeatSubmit @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.INSERT) - public Result saveDict(@Valid @RequestBody DictForm formData) { + public Result saveDict(@Valid @RequestBody DictForm formData) { boolean result = dictService.saveDict(formData); // 发送字典更新通知 if (result) { @@ -104,9 +104,9 @@ public class DictController { @PutMapping("/{dictId}") @PreAuthorize("@ss.hasPerm('sys:dict:update')") @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.UPDATE) - public Result updateDict( + public Result updateDict( @PathVariable Long dictId, - @RequestBody DictForm dictForm + @RequestBody @Valid DictForm dictForm ) { boolean status = dictService.updateDict(dictId, dictForm); // 发送字典更新通知 @@ -120,7 +120,7 @@ public class DictController { @DeleteMapping("/{ids}") @PreAuthorize("@ss.hasPerm('sys:dict:delete')") @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.DELETE) - public Result deleteDictionaries( + public Result deleteDictionaries( @Parameter(description = "字典ID,多个以英文逗号(,)拼接") @PathVariable String ids ) { // 获取字典编码列表,用于发送删除通知 @@ -144,7 +144,7 @@ public class DictController { @GetMapping("/{dictCode}/items") public PageResult getDictItemPage( @PathVariable String dictCode, - DictItemQuery queryParams + @Valid DictItemQuery queryParams ) { queryParams.setDictCode(dictCode); Page result = dictItemService.getDictItemPage(queryParams); @@ -195,10 +195,10 @@ public class DictController { @PreAuthorize("@ss.hasPerm('sys:dict-item:update')") @RepeatSubmit @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.UPDATE) - public Result updateDictItem( + public Result updateDictItem( @PathVariable String dictCode, @PathVariable Long itemId, - @RequestBody DictItemForm formData + @RequestBody @Valid DictItemForm formData ) { formData.setId(itemId); formData.setDictCode(dictCode); diff --git a/src/main/java/com/youlai/boot/system/controller/FileController.java b/src/main/java/com/youlai/boot/system/controller/FileController.java index 0fb84ea4..fdaff799 100644 --- a/src/main/java/com/youlai/boot/system/controller/FileController.java +++ b/src/main/java/com/youlai/boot/system/controller/FileController.java @@ -5,7 +5,7 @@ import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.Result; import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.config.property.StorageProperties; +import com.youlai.boot.support.file.property.StorageProperties; import com.youlai.boot.support.file.model.FileInfo; import com.youlai.boot.support.file.service.FileService; import io.swagger.v3.oas.annotations.Parameter; @@ -55,7 +55,7 @@ public class FileController { @DeleteMapping @Operation(summary = "文件删除") @SneakyThrows - public Result deleteFile( + public Result deleteFile( @Parameter(description = "文件路径") @RequestParam String filePath ) { boolean result = fileService.deleteFile(filePath); diff --git a/src/main/java/com/youlai/boot/system/controller/LogController.java b/src/main/java/com/youlai/boot/system/controller/LogController.java index c717b80e..74f7c50e 100644 --- a/src/main/java/com/youlai/boot/system/controller/LogController.java +++ b/src/main/java/com/youlai/boot/system/controller/LogController.java @@ -14,6 +14,7 @@ import com.youlai.boot.system.service.LogService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -37,7 +38,7 @@ public class LogController { @GetMapping @Log(module = LogModuleEnum.LOG, value = ActionTypeEnum.LIST) public PageResult getLogPage( - LogQuery queryParams + @Valid LogQuery queryParams ) { Page result = logService.getLogPage(queryParams); return PageResult.success(result); diff --git a/src/main/java/com/youlai/boot/system/controller/MenuController.java b/src/main/java/com/youlai/boot/system/controller/MenuController.java index f4a614cc..bdfd55da 100644 --- a/src/main/java/com/youlai/boot/system/controller/MenuController.java +++ b/src/main/java/com/youlai/boot/system/controller/MenuController.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.controller; import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.model.Option; @@ -14,6 +14,7 @@ import com.youlai.boot.system.service.MenuService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.access.prepost.PreAuthorize; @@ -39,7 +40,7 @@ public class MenuController { @Operation(summary = "菜单列表") @GetMapping @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.LIST) - public Result> getMenus(MenuQuery queryParams) { + public Result> getMenus(@Valid MenuQuery queryParams) { List menuList = menuService.listMenus(queryParams); return Result.success(menuList); } @@ -76,7 +77,7 @@ public class MenuController { @PreAuthorize("@ss.hasPerm('sys:menu:create')") @RepeatSubmit @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.INSERT) - public Result addMenu(@RequestBody MenuForm menuForm) { + public Result addMenu(@RequestBody @Valid MenuForm menuForm) { boolean result = menuService.saveMenu(menuForm); return Result.judge(result); } @@ -85,9 +86,9 @@ public class MenuController { @PutMapping(value = "/{menuId}") @PreAuthorize("@ss.hasPerm('sys:menu:update')") @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.UPDATE) - public Result updateMenu( + public Result updateMenu( @Parameter(description = "菜单ID") @PathVariable Long menuId, - @RequestBody MenuForm menuForm + @RequestBody @Valid MenuForm menuForm ) { boolean result = menuService.saveMenu(menuForm); return Result.judge(result); @@ -97,7 +98,7 @@ public class MenuController { @DeleteMapping("/{menuId}") @PreAuthorize("@ss.hasPerm('sys:menu:delete')") @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.DELETE) - public Result deleteMenu( + public Result deleteMenu( @Parameter(description = "菜单ID,多个以英文(,)分割") @PathVariable Long menuId ) { boolean result = menuService.deleteMenu(menuId); @@ -108,7 +109,7 @@ public class MenuController { @PatchMapping("/{menuId}") @PreAuthorize("@ss.hasPerm('sys:menu:update')") @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.UPDATE) - public Result updateMenuVisible( + public Result updateMenuVisible( @Parameter(description = "菜单ID") @PathVariable Long menuId, @Parameter(description = "显示状态(1:显示;0:隐藏)") Integer visible diff --git a/src/main/java/com/youlai/boot/system/controller/NoticeController.java b/src/main/java/com/youlai/boot/system/controller/NoticeController.java index 639f794c..2e484a7e 100644 --- a/src/main/java/com/youlai/boot/system/controller/NoticeController.java +++ b/src/main/java/com/youlai/boot/system/controller/NoticeController.java @@ -42,7 +42,7 @@ public class NoticeController { @GetMapping @PreAuthorize("@ss.hasPerm('sys:notice:list')") @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.LIST) - public PageResult getNoticePage(NoticeQuery queryParams) { + public PageResult getNoticePage(@Valid NoticeQuery queryParams) { IPage result = noticeService.getNoticePage(queryParams); return PageResult.success(result); } @@ -51,7 +51,7 @@ public class NoticeController { @PostMapping @PreAuthorize("@ss.hasPerm('sys:notice:create')") @Log(module = LogModuleEnum.NOTICE, value = ActionTypeEnum.INSERT) - public Result saveNotice(@RequestBody @Valid NoticeForm formData) { + public Result saveNotice(@RequestBody @Valid NoticeForm formData) { boolean result = noticeService.saveNotice(formData); return Result.judge(result); } @@ -130,7 +130,7 @@ public class NoticeController { @Operation(summary = "获取我的通知公告分页列表") @GetMapping("/my") public PageResult getMyNoticePage( - NoticeQuery queryParams + @Valid NoticeQuery queryParams ) { IPage result = noticeService.getMyNoticePage(queryParams); return PageResult.success(result); diff --git a/src/main/java/com/youlai/boot/system/controller/RoleController.java b/src/main/java/com/youlai/boot/system/controller/RoleController.java index b665896f..d52aaec6 100644 --- a/src/main/java/com/youlai/boot/system/controller/RoleController.java +++ b/src/main/java/com/youlai/boot/system/controller/RoleController.java @@ -3,7 +3,7 @@ package com.youlai.boot.system.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; import com.youlai.boot.common.model.Option; import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.Result; @@ -41,7 +41,7 @@ public class RoleController { @GetMapping @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.LIST) public PageResult getRolePage( - RoleQuery queryParams + @Valid RoleQuery queryParams ) { Page result = roleService.getRolePage(queryParams); return PageResult.success(result); @@ -54,12 +54,18 @@ public class RoleController { return Result.success(list); } + @Operation(summary = "角色编码下拉列表") + @GetMapping("/code-options") + public Result>> listRoleCodeOptions() { + return Result.success(roleService.listRoleCodeOptions()); + } + @Operation(summary = "新增角色") @PostMapping @PreAuthorize("@ss.hasPerm('sys:role:create')") @RepeatSubmit @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.INSERT) - public Result addRole(@Valid @RequestBody RoleForm roleForm) { + public Result addRole(@Valid @RequestBody RoleForm roleForm) { boolean result = roleService.saveRole(roleForm); return Result.judge(result); } @@ -78,7 +84,7 @@ public class RoleController { @PutMapping(value = "/{roleId}") @PreAuthorize("@ss.hasPerm('sys:role:update')") @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.UPDATE) - public Result updateRole( + public Result updateRole( @Parameter(description = "角色ID") @PathVariable Long roleId, @Valid @RequestBody RoleForm roleForm) { boolean result = roleService.saveRole(roleForm); @@ -100,7 +106,7 @@ public class RoleController { @PutMapping(value = "/{roleId}/status") @PreAuthorize("@ss.hasPerm('sys:role:update')") @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.UPDATE) - public Result updateRoleStatus( + public Result updateRoleStatus( @Parameter(description = "角色ID") @PathVariable Long roleId, @Parameter(description = "状态(1:启用;0:禁用)") @RequestParam Integer status ) { diff --git a/src/main/java/com/youlai/boot/system/controller/UserController.java b/src/main/java/com/youlai/boot/system/controller/UserController.java index 26ab457b..d3dffece 100644 --- a/src/main/java/com/youlai/boot/system/controller/UserController.java +++ b/src/main/java/com/youlai/boot/system/controller/UserController.java @@ -3,7 +3,7 @@ package com.youlai.boot.system.controller; import cn.idev.excel.EasyExcel; import cn.idev.excel.ExcelWriter; import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.enums.StatusEnum; @@ -18,6 +18,7 @@ import com.youlai.boot.system.service.UserImportReader; import com.youlai.boot.system.model.form.EmailUpdateForm; import com.youlai.boot.system.model.form.MobileUpdateForm; import com.youlai.boot.system.model.form.PasswordUpdateForm; +import com.youlai.boot.system.model.form.PasswordResetForm; import com.youlai.boot.system.model.form.PasswordVerifyForm; import com.youlai.boot.system.model.form.UserForm; import com.youlai.boot.system.model.form.UserImportForm; @@ -87,7 +88,7 @@ public class UserController { @PreAuthorize("@ss.hasPerm('sys:user:create')") @RepeatSubmit @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.INSERT) - public Result saveUser( + public Result saveUser( @RequestBody @Valid UserForm userForm ) { boolean result = userService.saveUser(userForm); @@ -147,11 +148,11 @@ public class UserController { @PutMapping(value = "/{userId}/password/reset") @PreAuthorize("@ss.hasPerm('sys:user:reset-password')") @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.RESET_PASSWORD) - public Result resetUserPassword( + public Result resetUserPassword( @Parameter(description = "用户ID") @PathVariable Long userId, - @RequestParam String password + @Parameter(description = "重置密码表单") @RequestBody @Valid PasswordResetForm formData ) { - boolean result = userService.resetUserPassword(userId, password); + boolean result = userService.resetUserPassword(userId, formData.getPassword()); return Result.judge(result); } @@ -194,7 +195,7 @@ public class UserController { @GetMapping("/export") @PreAuthorize("@ss.hasPerm('sys:user:export')") @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.EXPORT) - public void exportUsers(UserQuery queryParams, HttpServletResponse response) throws IOException { + public void exportUsers(@Valid UserQuery queryParams, HttpServletResponse response) throws IOException { String fileName = "用户列表.xlsx"; response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); @@ -222,7 +223,7 @@ public class UserController { @Operation(summary = "个人中心修改用户信息") @PutMapping("/profile") @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.UPDATE) - public Result updateUserProfile(@RequestBody UserProfileForm formData) { + public Result updateUserProfile(@RequestBody @Valid UserProfileForm formData) { boolean result = userService.updateUserProfile(formData); return Result.judge(result); } @@ -231,8 +232,8 @@ public class UserController { @Operation(summary = "当前用户修改密码") @PutMapping(value = "/password") @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.CHANGE_PASSWORD) - public Result changeCurrentUserPassword( - @RequestBody PasswordUpdateForm data + public Result changeCurrentUserPassword( + @RequestBody @Valid PasswordUpdateForm data ) { Long currUserId = SecurityUtils.getUserId(); boolean result = userService.changeUserPassword(currUserId, data); @@ -241,7 +242,7 @@ public class UserController { @Operation(summary = "发送短信验证码(绑定或更换手机号)") @PostMapping(value = "/mobile/code") - public Result sendMobileCode( + public Result sendMobileCode( @Parameter(description = "手机号码", required = true) @RequestParam String mobile ) { boolean result = userService.sendMobileCode(mobile); @@ -250,7 +251,7 @@ public class UserController { @Operation(summary = "绑定或更换手机号") @PutMapping(value = "/mobile") - public Result bindOrChangeMobile( + public Result bindOrChangeMobile( @RequestBody @Validated MobileUpdateForm data ) { boolean result = userService.bindOrChangeMobile(data); @@ -259,7 +260,7 @@ public class UserController { @Operation(summary = "解绑手机号") @DeleteMapping(value = "/mobile") - public Result unbindMobile( + public Result unbindMobile( @RequestBody @Validated PasswordVerifyForm data ) { boolean result = userService.unbindMobile(data); @@ -277,7 +278,7 @@ public class UserController { @Operation(summary = "绑定或更换邮箱") @PutMapping(value = "/email") - public Result bindOrChangeEmail( + public Result bindOrChangeEmail( @RequestBody @Validated EmailUpdateForm data ) { boolean result = userService.bindOrChangeEmail(data); @@ -286,7 +287,7 @@ public class UserController { @Operation(summary = "解绑邮箱") @DeleteMapping(value = "/email") - public Result unbindEmail( + public Result unbindEmail( @RequestBody @Validated PasswordVerifyForm data ) { boolean result = userService.unbindEmail(data); diff --git a/src/main/java/com/youlai/boot/system/model/dto/DictChangeEvent.java b/src/main/java/com/youlai/boot/system/model/dto/DictChangeDTO.java similarity index 61% rename from src/main/java/com/youlai/boot/system/model/dto/DictChangeEvent.java rename to src/main/java/com/youlai/boot/system/model/dto/DictChangeDTO.java index d6d0f5e3..5c30b210 100644 --- a/src/main/java/com/youlai/boot/system/model/dto/DictChangeEvent.java +++ b/src/main/java/com/youlai/boot/system/model/dto/DictChangeDTO.java @@ -8,9 +8,9 @@ import java.io.Serial; import java.io.Serializable; /** - * 字典变更事件 + * 字典变更通知数据 *

- * 当字典数据发生变更时,通过 SSE 广播此事件通知前端清除缓存。 + * 字典数据发生变更时作为 SSE 推送载荷广播至前端(主题 {@code SseTopics.DICT}),前端据此清除字典缓存。 * * @author Ray.Hao * @since 3.0.0 @@ -18,7 +18,7 @@ import java.io.Serializable; @Data @NoArgsConstructor @AllArgsConstructor -public class DictChangeEvent implements Serializable { +public class DictChangeDTO implements Serializable { @Serial private static final long serialVersionUID = 1L; @@ -26,16 +26,16 @@ public class DictChangeEvent implements Serializable { /** 字典编码 */ private String dictCode; - /** 事件时间戳 */ + /** 变更时间戳 */ private long timestamp; /** - * 创建字典变更事件(自动设置当前时间戳) + * 创建字典变更通知数据(自动设置当前时间戳) * * @param dictCode 字典编码 */ - public DictChangeEvent(String dictCode) { + public DictChangeDTO(String dictCode) { this.dictCode = dictCode; this.timestamp = System.currentTimeMillis(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/dto/MenuCreateDTO.java b/src/main/java/com/youlai/boot/system/model/dto/MenuCreateDTO.java new file mode 100644 index 00000000..7d5b33c2 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/dto/MenuCreateDTO.java @@ -0,0 +1,27 @@ +package com.youlai.boot.system.model.dto; + +/** + * 创建菜单DTO + * + *

承载创建菜单及其 CRUD 按钮权限所需的全部信息,由调用方(如代码生成)构造后 + * 传给 {@link com.youlai.boot.system.service.MenuService#addMenuForCodegen(Long, MenuCreateDTO)}, + * 使 system 模块无需依赖调用方的实体类型。

+ * + * @author Ray.Hao + * @since 4.8.0 + */ +public record MenuCreateDTO( + + /** 菜单名称 */ + String name, + + /** 路由名称,同时作为前端组件目录名 */ + String routeName, + + /** 前端组件所属模块名,如 system */ + String moduleName, + + /** 按钮权限前缀使用的表名,如 sys_user */ + String tableName +) { +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/form/DictItemForm.java b/src/main/java/com/youlai/boot/system/model/form/DictItemForm.java index 373a5216..472fc745 100644 --- a/src/main/java/com/youlai/boot/system/model/form/DictItemForm.java +++ b/src/main/java/com/youlai/boot/system/model/form/DictItemForm.java @@ -2,7 +2,11 @@ package com.youlai.boot.system.model.form; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Data; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; /** * 字典项表单对象 @@ -14,25 +18,32 @@ import lombok.Data; @Data public class DictItemForm { - @Schema(description = "字典项ID") + @Schema(description = "字典项ID(由请求路径传入并覆盖,仅用于表单回显)") private Long id; - @Schema(description = "字典编码") + @Schema(description = "字典编码(由请求路径传入并覆盖,仅用于表单回显)") private String dictCode; @Schema(description = "字典项值") + @NotBlank(message = "字典项值不能为空") + @Length(max = 100, message = "字典项值长度不能超过 100 个字符") private String value; @Schema(description = "字典项标签") + @NotBlank(message = "字典项标签不能为空") + @Length(max = 100, message = "字典项标签长度不能超过 100 个字符") private String label; @Schema(description = "排序") + @Range(min = 0, max = 9999, message = "排序值需在 0-9999 之间") private Integer sort; @Schema(description = "状态(0:禁用,1:启用)") + @Range(min = 0, max = 1, message = "状态取值不正确") private Integer status; @Schema(description = "字典类型(用于显示样式)") + @Size(max = 30, message = "字典类型长度不能超过 30 个字符") private String tagType; } diff --git a/src/main/java/com/youlai/boot/system/model/form/PasswordResetForm.java b/src/main/java/com/youlai/boot/system/model/form/PasswordResetForm.java new file mode 100644 index 00000000..8396dfb9 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/form/PasswordResetForm.java @@ -0,0 +1,21 @@ +package com.youlai.boot.system.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 重置密码表单 + * + * @author youlai.tech + */ +@Schema(description = "重置密码表单") +@Data +public class PasswordResetForm { + + @Schema(description = "新密码") + @NotBlank(message = "新密码不能为空") + @Size(min = 6, max = 20, message = "密码长度需在 6-20 个字符之间") + private String password; +} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/form/PasswordUpdateForm.java b/src/main/java/com/youlai/boot/system/model/form/PasswordUpdateForm.java index aaab86d7..f2c33344 100644 --- a/src/main/java/com/youlai/boot/system/model/form/PasswordUpdateForm.java +++ b/src/main/java/com/youlai/boot/system/model/form/PasswordUpdateForm.java @@ -1,6 +1,8 @@ package com.youlai.boot.system.model.form; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Data; /** @@ -14,11 +16,15 @@ import lombok.Data; public class PasswordUpdateForm { @Schema(description = "原密码") + @NotBlank(message = "原密码不能为空") private String oldPassword; @Schema(description = "新密码") + @NotBlank(message = "新密码不能为空") + @Size(min = 6, max = 20, message = "密码长度需在 6-20 个字符之间") private String newPassword; @Schema(description = "确认密码") + @NotBlank(message = "确认密码不能为空") private String confirmPassword; } diff --git a/src/main/java/com/youlai/boot/system/model/form/UserProfileForm.java b/src/main/java/com/youlai/boot/system/model/form/UserProfileForm.java index 25953345..5f0bf3ff 100644 --- a/src/main/java/com/youlai/boot/system/model/form/UserProfileForm.java +++ b/src/main/java/com/youlai/boot/system/model/form/UserProfileForm.java @@ -1,7 +1,10 @@ package com.youlai.boot.system.model.form; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.Data; +import org.hibernate.validator.constraints.Range; /** * 个人中心用户信息 @@ -12,13 +15,18 @@ import lombok.Data; @Schema(description = "个人中心用户信息") @Data public class UserProfileForm { + @Schema(description = "用户昵称") + @NotBlank(message = "用户昵称不能为空") + @Size(max = 50, message = "用户昵称长度不能超过 50 个字符") private String nickname; @Schema(description = "头像URL") + @Size(max = 255, message = "头像地址长度不能超过 255 个字符") private String avatar; @Schema(description = "性别") + @Range(min = 0, max = 2, message = "性别取值不正确") private Integer gender; } diff --git a/src/main/java/com/youlai/boot/system/model/query/UserQuery.java b/src/main/java/com/youlai/boot/system/model/query/UserQuery.java index 43bdeed0..0f4212dd 100644 --- a/src/main/java/com/youlai/boot/system/model/query/UserQuery.java +++ b/src/main/java/com/youlai/boot/system/model/query/UserQuery.java @@ -1,6 +1,5 @@ package com.youlai.boot.system.model.query; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.youlai.boot.common.base.BaseQuery; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -10,7 +9,7 @@ import java.util.List; @Data @EqualsAndHashCode(callSuper = false) -@Schema(description = "用户查询对象") +@Schema(description = "用户分页查询对象") public class UserQuery extends BaseQuery { @Schema(description = "关键字(用户名/昵称/手机号)") @@ -25,10 +24,9 @@ public class UserQuery extends BaseQuery { @Schema(description = "角色ID") private List roleIds; - @Schema(description = "创建时间范围") + /** + * 创建时间范围,下标 0 为开始时间、下标 1 为结束时间,只传日期时自动补全为当天 00:00:00 / 23:59:59 + */ + @Schema(description = "创建时间范围,格式为 [开始时间, 结束时间]") private List createTime; - - @JsonIgnore - @Schema(hidden = true) - private Boolean isRoot; } diff --git a/src/main/java/com/youlai/boot/system/service/MenuService.java b/src/main/java/com/youlai/boot/system/service/MenuService.java index 1429226c..b465b6fd 100644 --- a/src/main/java/com/youlai/boot/system/service/MenuService.java +++ b/src/main/java/com/youlai/boot/system/service/MenuService.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.service; import com.baomidou.mybatisplus.extension.service.IService; -import com.youlai.boot.codegen.model.entity.GenTable; +import com.youlai.boot.system.model.dto.MenuCreateDTO; import com.youlai.boot.system.model.form.MenuForm; import com.youlai.boot.common.model.Option; import com.youlai.boot.system.model.entity.Menu; @@ -73,10 +73,12 @@ public interface MenuService extends IService { boolean deleteMenu(Long id); /** - * 代码生成时添加菜单 + * 为代码生成创建菜单 + * + *

菜单已存在(routeName 相同)时直接返回;创建成功后一并生成查询/新增/修改/删除四个按钮权限。 * * @param parentMenuId 父菜单ID - * @param genConfig 实体名 + * @param dto 创建菜单数据载体 */ - void addMenuForCodegen(Long parentMenuId, GenTable genTable); + void addMenuForCodegen(Long parentMenuId, MenuCreateDTO dto); } diff --git a/src/main/java/com/youlai/boot/system/service/RoleService.java b/src/main/java/com/youlai/boot/system/service/RoleService.java index 8ade9edc..6ee665cb 100644 --- a/src/main/java/com/youlai/boot/system/service/RoleService.java +++ b/src/main/java/com/youlai/boot/system/service/RoleService.java @@ -37,6 +37,15 @@ public interface RoleService extends IService { */ List> listRoleOptions(); + /** + * 角色编码下拉列表(value 为角色编码) + * + *

供流程设计器候选组等按编码引用角色的场景选择

+ * + * @return 角色编码选项列表 + */ + List> listRoleCodeOptions(); + /** * * @param roleForm diff --git a/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java index d3026401..3d922cf1 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java @@ -7,11 +7,11 @@ import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.youlai.boot.codegen.model.entity.GenTable; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.system.converter.MenuConverter; import com.youlai.boot.system.mapper.MenuMapper; +import com.youlai.boot.system.model.dto.MenuCreateDTO; import com.youlai.boot.system.model.entity.Menu; import com.youlai.boot.system.model.form.MenuForm; import com.youlai.boot.system.model.query.MenuQuery; @@ -441,19 +441,17 @@ public class MenuServiceImpl extends ServiceImpl implements Me } /** - * 代码生成时添加菜单 + * 为代码生成创建菜单 * * @param parentMenuId 父菜单ID - * @param genTable 实体名称 + * @param dto 创建菜单数据载体 */ @Override - public void addMenuForCodegen(Long parentMenuId, GenTable genTable) { + public void addMenuForCodegen(Long parentMenuId, MenuCreateDTO dto) { Menu parentMenu = this.getById(parentMenuId); Assert.notNull(parentMenu, "上级菜单不存在"); - String entityName = genTable.getEntityName(); - - long count = this.count(new LambdaQueryWrapper().eq(Menu::getRouteName, entityName)); + long count = this.count(new LambdaQueryWrapper().eq(Menu::getRouteName, dto.routeName())); if (count > 0) { return; } @@ -470,11 +468,11 @@ public class MenuServiceImpl extends ServiceImpl implements Me Menu menu = new Menu(); menu.setParentId(parentMenuId); - menu.setName(genTable.getBusinessName()); + menu.setName(dto.name()); - menu.setRouteName(entityName); - menu.setRoutePath(StrUtil.toSymbolCase(entityName, '-')); - menu.setComponent(genTable.getModuleName() + "/" + StrUtil.toSymbolCase(entityName, '-') + "/index"); + menu.setRouteName(dto.routeName()); + menu.setRoutePath(StrUtil.toSymbolCase(dto.routeName(), '-')); + menu.setComponent(dto.moduleName() + "/" + StrUtil.toSymbolCase(dto.routeName(), '-') + "/index"); menu.setType(MenuTypeEnum.MENU.getValue()); menu.setSort(sort); menu.setVisible(1); @@ -486,8 +484,8 @@ public class MenuServiceImpl extends ServiceImpl implements Me menu.setTreePath(treePath); this.updateById(menu); - // 生成CURD按钮权限 - String permPrefix = genTable.getModuleName() + ":" + genTable.getTableName().replace("_", "-") + ":"; + // 生成 CRUD 按钮权限 + String permPrefix = dto.moduleName() + ":" + dto.tableName().replace("_", "-") + ":"; String[] actions = {"查询", "新增", "修改", "删除"}; String[] perms = {"list", "create", "update", "delete"}; diff --git a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java index 28bcc7cf..c03cf597 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java @@ -9,7 +9,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.framework.security.util.SecurityUtils; -import com.youlai.boot.support.sse.model.OnlineUserDTO; +import com.youlai.boot.support.sse.OnlineUserDTO; import com.youlai.boot.system.converter.NoticeConverter; import com.youlai.boot.system.enums.NoticePublishStatusEnum; import com.youlai.boot.system.enums.NoticeTargetEnum; diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java index b62faf43..5bdaba0b 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java @@ -99,6 +99,19 @@ public class RoleServiceImpl extends ServiceImpl implements Ro return roleConverter.toOptions(roleList); } + @Override + public List> listRoleCodeOptions() { + // value 为角色编码(引擎候选组按编码匹配),与按 ID 取值的 listRoleOptions 区分 + List roleList = this.list(new LambdaQueryWrapper() + .ne(!SecurityUtils.isRoot(), Role::getCode, SystemConstants.ROOT_ROLE_CODE) + .select(Role::getCode, Role::getName) + .orderByAsc(Role::getSort) + ); + return roleList.stream() + .map(role -> new Option<>(role.getCode(), role.getName())) + .toList(); + } + /** * 保存角色 * diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java index 89825984..f6cf0602 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java @@ -12,13 +12,13 @@ import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.constant.SystemConstants; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.model.Option; -import com.youlai.boot.support.sms.SmsTypeEnum; -import com.youlai.boot.support.mail.EmailService; -import com.youlai.boot.support.sms.SmsService; import com.youlai.boot.framework.security.model.RoleDataScope; import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.token.TokenManager; import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.support.mail.service.EmailService; +import com.youlai.boot.support.sms.SmsTypeEnum; +import com.youlai.boot.support.sms.service.SmsService; import com.youlai.boot.system.converter.UserConverter; import com.youlai.boot.system.enums.DictCodeEnum; import com.youlai.boot.system.mapper.UserMapper; @@ -102,9 +102,6 @@ public class UserServiceImpl extends ServiceImpl implements int pageSize = queryParams.getPageSize(); Page page = new Page<>(pageNum, pageSize); - boolean isRoot = SecurityUtils.isRoot(); - queryParams.setIsRoot(isRoot); - // 查询数据 return this.baseMapper.getUserPage(page, queryParams); } @@ -264,9 +261,6 @@ public class UserServiceImpl extends ServiceImpl implements @Override public List listExportUsers(UserQuery queryParams) { - boolean isRoot = SecurityUtils.isRoot(); - queryParams.setIsRoot(isRoot); - List exportUsers = this.baseMapper.listExportUsers(queryParams); if (CollectionUtil.isNotEmpty(exportUsers)) { //获取性别的字典项 diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..f1851329 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.EnvironmentPostProcessor=\ + com.youlai.boot.module.config.WorkflowEnvironmentPostProcessor diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 5819eab2..8a9fa177 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -8,7 +8,6 @@ spring: url: jdbc:mysql://www.youlai.tech:3306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true username: youlai password: 123456 - data: redis: database: 0 @@ -79,16 +78,16 @@ rate-limit: # 安全配置 security: - session: - type: jwt # 会话方式 [jwt|redis-token] + token: + type: jwt # 令牌策略 [jwt|redis-token] access-token-time-to-live: 7200 # 访问令牌 有效期(单位:秒),默认 2 小时,-1 表示永不过期 refresh-token-time-to-live: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 jwt: secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符) redis-token: allow-multi-login: true # 是否允许多设备登录 - # 安全白名单路径,仅跳过 AuthorizationFilter 过滤器,还是会走 Spring Security 的其他过滤器(CSRF、CORS等) - ignore-urls: + # 匿名放行路径,仅跳过认证与授权判断,仍走过滤器链(保留安全响应头) + permit-all-urls: - /api/v1/auth/login/** # 登录接口(账号密码登录) - /api/v1/auth/captcha # 验证码获取接口 - /api/v1/auth/sms/** # 短信验证码接口(发送验证码、短信登录) @@ -97,9 +96,7 @@ security: - /api/v1/logs/** # 日志接口(访问日志列表) - /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌) - /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流) - # 非安全端点路径,完全绕过 Spring Security 的过滤器 - unsecured-urls: - - ${springdoc.swagger-ui.path} + - ${springdoc.swagger-ui.path} # Swagger UI 路径 - /doc.html - /swagger-ui/** - /v3/api-docs/** @@ -169,11 +166,15 @@ springdoc: - group: "系统管理" # 分组名称 paths-to-match: "/**" # 匹配的请求路径 packages-to-scan: # 扫描的 Controller 包,限制只生成指定包的接口文档 - - com.youlai.boot.auth.controller + - com.youlai.boot.auth.password.controller + - com.youlai.boot.auth.sms.controller + - com.youlai.boot.auth.qrcode.controller + - com.youlai.boot.auth.wxma.controller + - com.youlai.boot.auth.session.controller - com.youlai.boot.system.controller - - com.youlai.boot.file.controller - com.youlai.boot.codegen.controller - - com.youlai.boot.message.controller + - com.youlai.boot.module.form.controller + - com.youlai.boot.module.workflow.controller default-flat-param-object: true # knife4j 配置 @@ -235,3 +236,13 @@ wx: miniapp: appid: Your_AppId secret: Your_AppSecret + +# 工作流(默认关闭,开启见 README「可选扩展」) +flowable: + # 工作流总开关,未配置视为关闭 + enabled: true + # 引擎表初始化策略,true 启动时检查并自动建/更新 ACT_* 表;可选 true/false/create/create-drop + database-schema-update: true + demo: + # 演示流程自动部署开关 + enabled: true diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bbffad64..bc33bb0e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -78,16 +78,16 @@ rate-limit: # 安全配置 security: - session: - type: jwt # 会话方式 [jwt|redis-token] + token: + type: jwt # 令牌策略 [jwt|redis-token] access-token-time-to-live: 7200 # 访问令牌 有效期(单位:秒),默认 2 小时,-1 表示永不过期 refresh-token-time-to-live: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 jwt: secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符) redis-token: allow-multi-login: true # 是否允许多设备登录 - # 安全白名单路径,仅跳过 AuthorizationFilter 过滤器,还是会走 Spring Security 的其他过滤器(CSRF、CORS等) - ignore-urls: + # 匿名放行路径,仅跳过认证与授权判断,仍走过滤器链(保留安全响应头) + permit-all-urls: - /api/v1/auth/login/** # 登录接口(账号密码登录) - /api/v1/auth/captcha # 验证码获取接口 - /api/v1/auth/sms/** # 短信验证码接口(发送验证码、短信登录) @@ -96,9 +96,7 @@ security: - /api/v1/logs/** # 日志接口(访问日志列表) - /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌) - /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流) - # 非安全端点路径,完全绕过 Spring Security 的过滤器 - unsecured-urls: - - ${springdoc.swagger-ui.path} + - ${springdoc.swagger-ui.path} # Swagger UI 路径 - /doc.html - /swagger-ui/** - /v3/api-docs/** @@ -167,11 +165,15 @@ springdoc: - group: "系统管理" # 分组名称 paths-to-match: "/**" # 匹配的请求路径 packages-to-scan: # 扫描的 Controller 包,限制只生成指定包的接口文档 - - com.youlai.boot.auth.controller + - com.youlai.boot.auth.password.controller + - com.youlai.boot.auth.sms.controller + - com.youlai.boot.auth.qrcode.controller + - com.youlai.boot.auth.wxma.controller + - com.youlai.boot.auth.session.controller - com.youlai.boot.system.controller - - com.youlai.boot.file.controller - com.youlai.boot.codegen.controller - - com.youlai.boot.message.controller + - com.youlai.boot.module.form.controller + - com.youlai.boot.module.workflow.controller default-flat-param-object: true # 将对象参数扁平化显示在文档中 # knife4j 配置 @@ -232,3 +234,13 @@ wx: miniapp: appid: Your_AppId secret: Your_AppSecret + +# 工作流(默认关闭,开启见 README「可选扩展」) +flowable: + # 工作流总开关,未配置视为关闭 + enabled: false + # 引擎表初始化策略,false 要求 ACT_* 表已存在 + database-schema-update: false + demo: + # 演示流程自动部署开关 + enabled: false diff --git a/src/main/resources/mapper/module/form/FormDataMapper.xml b/src/main/resources/mapper/form/FormDataMapper.xml similarity index 100% rename from src/main/resources/mapper/module/form/FormDataMapper.xml rename to src/main/resources/mapper/form/FormDataMapper.xml diff --git a/src/main/resources/mapper/module/form/FormDefinitionMapper.xml b/src/main/resources/mapper/form/FormDefinitionMapper.xml similarity index 87% rename from src/main/resources/mapper/module/form/FormDefinitionMapper.xml rename to src/main/resources/mapper/form/FormDefinitionMapper.xml index 6a7cdc62..bde8b1ad 100644 --- a/src/main/resources/mapper/module/form/FormDefinitionMapper.xml +++ b/src/main/resources/mapper/form/FormDefinitionMapper.xml @@ -13,6 +13,7 @@ t1.status, t1.version, t1.is_public, + t1.category, t1.create_time FROM form_definition t1 @@ -28,6 +29,9 @@ AND t1.status = #{queryParams.status} + + AND t1.category = #{queryParams.category} + ORDER BY t1.create_time DESC diff --git a/src/main/resources/mapper/system/UserMapper.xml b/src/main/resources/mapper/system/UserMapper.xml index c6ddbe80..46d4caf6 100644 --- a/src/main/resources/mapper/system/UserMapper.xml +++ b/src/main/resources/mapper/system/UserMapper.xml @@ -71,7 +71,11 @@ - ORDER BY u.${queryParams.sortBy} ${queryParams.order} + ORDER BY u.${queryParams.sortBy} + + DESC + ASC + diff --git a/src/main/resources/templates/codegen/frontend/js/index.curd.js.vue.vm b/src/main/resources/templates/codegen/frontend/js/index.curd.js.vue.vm index ee1fec35..2909cfe2 100644 --- a/src/main/resources/templates/codegen/frontend/js/index.curd.js.vue.vm +++ b/src/main/resources/templates/codegen/frontend/js/index.curd.js.vue.vm @@ -105,11 +105,11 @@ const searchConfig = reactive({ }, #else { -#if($fieldConfig.javaType == "String") +#if($fieldConfig.fieldType == "String") type: "input", -#elseif($fieldConfig.javaType == "Integer" || $fieldConfig.javaType == "Long") +#elseif($fieldConfig.fieldType == "Integer" || $fieldConfig.fieldType == "Long") type: "input-number", -#elseif($fieldConfig.javaType == "Date" || $fieldConfig.javaType == "LocalDateTime") +#elseif($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime") type: "date-picker", #else type: "input", @@ -118,7 +118,7 @@ const searchConfig = reactive({ prop: "$fieldConfig.fieldName", attrs: { placeholder: "$fieldConfig.fieldComment", -#if($fieldConfig.javaType == "Date" || $fieldConfig.javaType == "LocalDateTime") +#if($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime") type: "daterange", "range-separator": "~", "start-placeholder": "开始时间", @@ -228,7 +228,7 @@ const addModalConfig = reactive({ }, #else { -#if($fieldConfig.javaType == "String") +#if($fieldConfig.fieldType == "String") #if($fieldConfig.fieldName.contains("password") || $fieldConfig.fieldName.contains("pwd")) type: "input", attrs: { @@ -272,7 +272,7 @@ const addModalConfig = reactive({ rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }], #end #end -#elseif($fieldConfig.javaType == "Integer" || $fieldConfig.javaType == "Long") +#elseif($fieldConfig.fieldType == "Integer" || $fieldConfig.fieldType == "Long") type: "input-number", attrs: { placeholder: "$fieldConfig.fieldComment", @@ -281,7 +281,7 @@ const addModalConfig = reactive({ #if($fieldConfig.isRequired == 1) rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }], #end -#elseif($fieldConfig.javaType == "BigDecimal" || $fieldConfig.javaType == "Double") +#elseif($fieldConfig.fieldType == "BigDecimal" || $fieldConfig.fieldType == "Double") type: "input-number", attrs: { placeholder: "$fieldConfig.fieldComment", @@ -291,7 +291,7 @@ const addModalConfig = reactive({ #if($fieldConfig.isRequired == 1) rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }], #end -#elseif($fieldConfig.javaType == "Date" || $fieldConfig.javaType == "LocalDateTime") +#elseif($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime") type: "date-picker", attrs: { type: "datetime", @@ -303,7 +303,7 @@ const addModalConfig = reactive({ #if($fieldConfig.isRequired == 1) rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "change" }], #end -#elseif($fieldConfig.javaType == "Boolean" || $fieldConfig.fieldName.contains("status")) +#elseif($fieldConfig.fieldType == "Boolean" || $fieldConfig.fieldName.contains("status")) type: "switch", attrs: { activeText: "启用", diff --git a/src/main/resources/templates/codegen/frontend/ts/index.curd.vue.vm b/src/main/resources/templates/codegen/frontend/ts/index.curd.vue.vm index fff17201..c10a31f9 100644 --- a/src/main/resources/templates/codegen/frontend/ts/index.curd.vue.vm +++ b/src/main/resources/templates/codegen/frontend/ts/index.curd.vue.vm @@ -107,11 +107,11 @@ const searchConfig: ISearchConfig = reactive({ }, #else { -#if($fieldConfig.javaType == "String") +#if($fieldConfig.fieldType == "String") type: "input", -#elseif($fieldConfig.javaType == "Integer" || $fieldConfig.javaType == "Long") +#elseif($fieldConfig.fieldType == "Integer" || $fieldConfig.fieldType == "Long") type: "input-number", -#elseif($fieldConfig.javaType == "Date" || $fieldConfig.javaType == "LocalDateTime") +#elseif($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime") type: "date-picker", #else type: "input", @@ -120,7 +120,7 @@ const searchConfig: ISearchConfig = reactive({ prop: "$fieldConfig.fieldName", attrs: { placeholder: "$fieldConfig.fieldComment", -#if($fieldConfig.javaType == "Date" || $fieldConfig.javaType == "LocalDateTime") +#if($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime") type: "daterange", "range-separator": "~", "start-placeholder": "开始时间", @@ -230,7 +230,7 @@ const addModalConfig: IModalConfig<${entityName}Form> = reactive({ }, #else { -#if($fieldConfig.javaType == "String") +#if($fieldConfig.fieldType == "String") #if($fieldConfig.fieldName.contains("password") || $fieldConfig.fieldName.contains("pwd")) type: "input", attrs: { @@ -274,7 +274,7 @@ const addModalConfig: IModalConfig<${entityName}Form> = reactive({ rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }], #end #end -#elseif($fieldConfig.javaType == "Integer" || $fieldConfig.javaType == "Long") +#elseif($fieldConfig.fieldType == "Integer" || $fieldConfig.fieldType == "Long") type: "input-number", attrs: { placeholder: "$fieldConfig.fieldComment", @@ -283,7 +283,7 @@ const addModalConfig: IModalConfig<${entityName}Form> = reactive({ #if($fieldConfig.isRequired == 1) rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }], #end -#elseif($fieldConfig.javaType == "BigDecimal" || $fieldConfig.javaType == "Double") +#elseif($fieldConfig.fieldType == "BigDecimal" || $fieldConfig.fieldType == "Double") type: "input-number", attrs: { placeholder: "$fieldConfig.fieldComment", @@ -293,7 +293,7 @@ const addModalConfig: IModalConfig<${entityName}Form> = reactive({ #if($fieldConfig.isRequired == 1) rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "blur" }], #end -#elseif($fieldConfig.javaType == "Date" || $fieldConfig.javaType == "LocalDateTime") +#elseif($fieldConfig.fieldType == "LocalDate" || $fieldConfig.fieldType == "LocalDateTime") type: "date-picker", attrs: { type: "datetime", @@ -305,7 +305,7 @@ const addModalConfig: IModalConfig<${entityName}Form> = reactive({ #if($fieldConfig.isRequired == 1) rules: [{ required: true, message: "$fieldConfig.fieldComment不能为空", trigger: "change" }], #end -#elseif($fieldConfig.javaType == "Boolean" || $fieldConfig.fieldName.contains("status")) +#elseif($fieldConfig.fieldType == "Boolean" || $fieldConfig.fieldName.contains("status")) type: "switch", attrs: { activeText: "启用", diff --git a/src/main/resources/workflow/expense.bpmn20.xml b/src/main/resources/workflow/expense.bpmn20.xml new file mode 100644 index 00000000..ba1b7b53 --- /dev/null +++ b/src/main/resources/workflow/expense.bpmn20.xml @@ -0,0 +1,92 @@ + + + + + flow1 + + + flow1 + flow2 + + + flow2 + flow3 + flow4 + + + flow3 + flow5 + + + flow4 + flow5 + flow6 + + + flow6 + + + + + ${amount > 5000} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/workflow/leave.bpmn20.xml b/src/main/resources/workflow/leave.bpmn20.xml new file mode 100644 index 00000000..39f46a01 --- /dev/null +++ b/src/main/resources/workflow/leave.bpmn20.xml @@ -0,0 +1,79 @@ + + + + + flow1 + + + flow1 + flow2 + + + flow2 + flow3 + flow4 + + + flow3 + flow5 + + + flow4 + flow5 + + + + + ${days > 3} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/workflow/purchase.bpmn20.xml b/src/main/resources/workflow/purchase.bpmn20.xml new file mode 100644 index 00000000..5de37dcd --- /dev/null +++ b/src/main/resources/workflow/purchase.bpmn20.xml @@ -0,0 +1,93 @@ + + + + + + flow1 + + + flow1 + flow2 + + + flow2 + flow3 + flow4 + + + flow3 + flow5 + + + flow4 + flow5 + flow6 + + + flow6 + + + + + ${amount > 10000} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/workflow/seal.bpmn20.xml b/src/main/resources/workflow/seal.bpmn20.xml new file mode 100644 index 00000000..443c91ce --- /dev/null +++ b/src/main/resources/workflow/seal.bpmn20.xml @@ -0,0 +1,55 @@ + + + + + flow1 + + + flow1 + flow2 + + + flow2 + flow3 + + + flow3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/youlai/boot/ArchitectureTest.java b/src/test/java/com/youlai/boot/ArchitectureTest.java new file mode 100644 index 00000000..62077228 --- /dev/null +++ b/src/test/java/com/youlai/boot/ArchitectureTest.java @@ -0,0 +1,61 @@ +package com.youlai.boot; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +/** + * 分层依赖约束 + * + *

固化包之间的依赖方向,防止 common 反向依赖上层、业务模块之间形成环。

+ * + * @author Ray.Hao + * @since 4.8.0 + */ +@AnalyzeClasses(packages = "com.youlai.boot") +class ArchitectureTest { + + @ArchTest + static final ArchRule commonShouldNotDependOnUpperLayers = + noClasses().that().resideInAPackage("com.youlai.boot.common..") + .should().dependOnClassesThat().resideInAnyPackage( + "com.youlai.boot.config..", + "com.youlai.boot.framework..", + "com.youlai.boot.system..", + "com.youlai.boot.module..", + "com.youlai.boot.auth..", + "com.youlai.boot.codegen..", + "com.youlai.boot.support..") + .because("common 是纯共享层,只提供基础类型,不感知装配层、框架层和业务模块"); + + @ArchTest + static final ArchRule frameworkShouldNotDependOnBusinessModules = + noClasses().that().resideInAPackage("com.youlai.boot.framework..") + .should().dependOnClassesThat().resideInAnyPackage( + "com.youlai.boot.system..", + "com.youlai.boot.module..", + "com.youlai.boot.auth..", + "com.youlai.boot.codegen..") + .because("framework 提供通用技术能力,不依赖具体业务模块"); + + @ArchTest + static final ArchRule systemShouldNotDependOnCodegen = + noClasses().that().resideInAPackage("com.youlai.boot.system..") + .should().dependOnClassesThat().resideInAPackage("com.youlai.boot.codegen..") + .because("依赖方向为 codegen -> system,system 不得反向依赖代码生成"); + + @ArchTest + static final ArchRule controllersShouldNotDependOnMappers = + noClasses().that().haveSimpleNameEndingWith("Controller") + .should().dependOnClassesThat().haveSimpleNameEndingWith("Mapper") + .because("Controller 只能通过 Service 访问数据层"); + + @ArchTest + static final ArchRule businessPackagesShouldBeFreeOfCycles = + slices().matching("com.youlai.boot.(*)..") + .should().beFreeOfCycles() + .because("顶层包之间应单向依赖,出现环说明模块边界被绕过"); +} diff --git a/src/test/java/com/youlai/boot/validation/ValidationTest.java b/src/test/java/com/youlai/boot/validation/ValidationTest.java new file mode 100644 index 00000000..ba043e14 --- /dev/null +++ b/src/test/java/com/youlai/boot/validation/ValidationTest.java @@ -0,0 +1,123 @@ +package com.youlai.boot.validation; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.constraints.NotNull; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 参数校验约束测试 + * + *

验证关键 Form 的约束行为,并扫描全部 Controller 保证每个 @RequestBody 参数都触发 Bean Validation。

+ * + * @author Ray.Hao + * @since 4.8.0 + */ +class ValidationTest { + + private static Validator validator; + + @BeforeAll + static void setUp() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Test + void passwordResetFormShouldRejectBlankAndShortPassword() { + var blank = new com.youlai.boot.system.model.form.PasswordResetForm(); + assertThat(validator.validate(blank)).isNotEmpty(); + + var form = new com.youlai.boot.system.model.form.PasswordResetForm(); + form.setPassword("12345"); + assertThat(validator.validate(form)).isNotEmpty(); + + form.setPassword("123456"); + assertThat(validator.validate(form)).isEmpty(); + } + + @Test + void passwordUpdateFormShouldRequireAllThreeFields() { + var form = new com.youlai.boot.system.model.form.PasswordUpdateForm(); + assertThat(validator.validate(form)).hasSize(3); + + form.setOldPassword("123456"); + form.setNewPassword("123456"); + form.setConfirmPassword("123456"); + assertThat(validator.validate(form)).isEmpty(); + } + + @Test + void baseQueryShouldRejectOutOfRangePaging() { + var query = new com.youlai.boot.system.model.query.UserQuery(); + assertThat(validator.validate(query)).isEmpty(); + + query.setPageNum(0); + assertThat(validator.validate(query)).isNotEmpty(); + + query.setPageNum(1); + query.setPageSize(201); + assertThat(validator.validate(query)).isNotEmpty(); + + query.setPageSize(200); + query.setOrder("DROP TABLE"); + assertThat(validator.validate(query)).isNotEmpty(); + + query.setOrder("DESC"); + assertThat(validator.validate(query)).isEmpty(); + } + + @Test + void genConfigFormShouldRequireCoreFields() { + var form = new com.youlai.boot.codegen.model.form.GenConfigForm(); + assertThat(validator.validate(form)).isNotEmpty(); + + form.setTableName("sys_user"); + form.setBusinessName("用户"); + form.setModuleName("system"); + form.setPackageName("com.youlai"); + form.setEntityName("User"); + assertThat(validator.validate(form)).isEmpty(); + } + + @Test + void everyRequestBodyParameterShouldTriggerValidation() throws Exception { + var scanner = new org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(RestController.class)); + + List violations = new ArrayList<>(); + for (var definition : scanner.findCandidateComponents("com.youlai.boot")) { + Class controller = Class.forName(definition.getBeanClassName()); + for (Method method : controller.getDeclaredMethods()) { + for (Parameter parameter : method.getParameters()) { + // Bean Validation 只作用于 Bean 类型,Map/List 等动态数据由 Service 层业务校验 + boolean isBeanType = parameter.getType().getName().startsWith("com.youlai.boot"); + boolean missingValidation = isBeanType + && parameter.isAnnotationPresent(RequestBody.class) + && !parameter.isAnnotationPresent(Valid.class) + && !parameter.isAnnotationPresent(Validated.class); + if (missingValidation) { + violations.add(controller.getSimpleName() + "#" + method.getName() + + " 的参数 " + parameter.getName() + " 缺少 @Valid"); + } + } + } + } + assertThat(violations).as(String.join(";", violations)).isEmpty(); + } +}