feat: 添加动态表单引擎模块

This commit is contained in:
Ray.Hao
2026-08-30 11:23:30 +08:00
parent b7d11aea4c
commit fd3d7fc654
40 changed files with 2311 additions and 125 deletions

View File

@@ -146,7 +146,14 @@
1. Clone: `git clone https://gitee.com/youlaiorg/youlai-boot.git`
2. Import database: `sql/youlai-admin.sql`
2. Import database:
```bash
# Base schema (required)
sql/youlai-admin.sql
# Optional extensions: safe to skip, run after the base schema
sql/extensions/form.sql # Dynamic Form
```
3. Adjust config (optional, a read-only online data source is configured by default): `src/main/resources/application-dev.yml`

View File

@@ -33,7 +33,7 @@
- 🔐 **安全体系** — Spring Security + JWT/Redis Token 双会话模式、令牌续期、多端互斥
- 🛡️ **细粒度权限** — RBAC 五级:数据 → 菜单 → 按钮 → 接口 → 字段
- ⚡ **代码生成器** — 一键生成前后端 CRUD 代码
- 📦 **模块齐全** — 用户、角色、菜单、部门、字典、文件、定时任务、消息中心、操作日志
- 📦 **模块齐全** — 用户、角色、菜单、部门、字典、文件(OSS/RustFS/本地)、定时任务、消息中心、操作日志
- 🌐 **多租户 SaaS** — 数据隔离 + 租户配置,独立 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) 版本
- 🔌 **实时通信** — SSE 推送:在线用户数、字典同步、通知广播
@@ -78,7 +78,11 @@
2. 导入数据库:
```bash
# 基础库(必装)
sql/youlai-admin.sql
# 可选扩展:不装不影响基础功能,需在基础库之后执行
sql/extensions/form.sql # 动态表单
```
3. 修改配置(可选,默认已配置线上只读数据源):
@@ -119,12 +123,12 @@ youlai-boot/
│ │ ├── security/ # Spring Security(过滤器/Token/工具类)
│ │ └── web/ # Web 层(全局异常/跨域/限流)
│ ├── support/ # 能力服务
│ │ ├── file/ # 文件存储(MinIO/阿里云OSS/本地)
│ │ ├── file/ # 文件存储(RustFS/阿里云OSS/本地)
│ │ ├── mail/ # 邮件服务
│ │ ├── sms/ # 短信服务
│ │ └── sse/ # SSE 消息推送
│ └── system/ # 系统业务(用户/角色/菜单/部门/字典等)
├── docker-compose.yml # 中间件一键编排(MySQL/Redis/MinIO/Xxl-Job)
├── docker-compose.yml # 中间件一键编排(MySQL/Redis/RustFS/Xxl-Job)
└── pom.xml # Maven 依赖管理
```

127
sql/extensions/form.sql Normal file
View File

@@ -0,0 +1,127 @@
# 动态表单模块(可选扩展)
# Copyright (c) 2021-present, youlai.tech
-- ----------------------------------------------------------------------------
-- 动态表单模块增量脚本
-- 前置:请先执行 ../youlai-admin.sql(本脚本向 sys_menu/sys_role_menu 插入数据)
-- ----------------------------------------------------------------------------
USE youlai_admin;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for form_definition
-- ----------------------------
DROP TABLE IF EXISTS `form_definition`;
CREATE TABLE `form_definition` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`form_key` varchar(64) NOT NULL COMMENT '表单唯一标识(业务编码,如 employee_onboarding)',
`form_name` varchar(100) NOT NULL COMMENT '表单名称',
`description` varchar(255) DEFAULT NULL COMMENT '表单描述',
`form_json` json NOT NULL COMMENT '表单规则(form-create rule 数组)',
`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是)',
`menu_id` bigint DEFAULT NULL COMMENT '生成的访问菜单ID(NULL未生成,幂等防重复)',
`version` int NOT NULL DEFAULT 1 COMMENT '版本号,每次发布+1',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
`create_by` bigint DEFAULT NULL COMMENT '创建人ID',
`update_by` bigint DEFAULT NULL COMMENT '更新人ID',
`is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除(0未删 1已删)',
PRIMARY KEY (`id`),
KEY `idx_form_key` (`form_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单定义表';
-- ----------------------------
-- Table structure for form_data
-- ----------------------------
DROP TABLE IF EXISTS `form_data`;
CREATE TABLE `form_data` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`form_id` bigint NOT NULL COMMENT '表单定义ID',
`form_version` int NOT NULL COMMENT '表单版本(提交时快照)',
`data_json` json NOT NULL COMMENT '表单数据(field -> value 映射)',
`create_by` bigint DEFAULT NULL COMMENT '提交人ID(匿名提交为空)',
`update_by` bigint DEFAULT NULL COMMENT '修改人ID(数据修正时记录)',
`create_time` datetime NOT NULL COMMENT '提交时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
`is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除(0未删 1已删)',
PRIMARY KEY (`id`),
KEY `idx_form_id` (`form_id`),
KEY `idx_create_by` (`create_by`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单数据表';
-- ----------------------------
-- Table structure for form_snapshot
-- ----------------------------
-- 版本快照表:发布时固化规则,数据回显按提交时版本加载,
-- 防止表单定义变更(字段删除/改名)导致历史数据回显漂移;
-- 快照只增不改,update_time 仅因实体继承 BaseEntity 保留列,无业务语义
DROP TABLE IF EXISTS `form_snapshot`;
CREATE TABLE `form_snapshot` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`form_id` bigint NOT NULL COMMENT '表单定义ID',
`version` int NOT NULL COMMENT '版本号(发布时递增后的值)',
`form_json` json NOT NULL COMMENT '表单规则快照(form-create rule 数组)',
`options_json` json DEFAULT NULL COMMENT '表单全局配置快照',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
`is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除(0未删 1已删)',
PRIMARY KEY (`id`),
KEY `idx_form_version` (`form_id`, `version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单版本快照表';
-- ----------------------------
-- 菜单(顶级目录 ID=10 顺延;目录设 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);
-- 表单设计器(隐藏菜单,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);
-- 表单预览(隐藏菜单,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);
-- 表单数据(隐藏菜单,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);
-- ----------------------------
-- 示例表单入口(预置双入口:内嵌菜单 + 公开外链;与 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"}');
-- ----------------------------
-- 系统管理员角色授权(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);
-- ----------------------------
-- 预置示例表单(开箱即用,覆盖常用组件类型;ID 段 1001-1003,避开用户自建的自增 ID)
-- 已发布(status=1)+ 双入口预置:
-- 内嵌:菜单 304/305/306(动态表单 > 表单示例),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);
-- 预置表单版本快照(已发布语义 = 固化 v1 规则,防止后续编辑定义导致历史数据回显漂移)
INSERT IGNORE INTO `form_snapshot` (`id`, `form_id`, `version`, `form_json`, `options_json`, `create_time`, `update_time`, `is_deleted`) VALUES
(1001, 1001, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1001), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1001), now(), now(), 0),
(1002, 1002, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1002), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1002), now(), now(), 0),
(1003, 1003, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1003), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1003), now(), now(), 0);

View File

@@ -137,15 +137,13 @@ CREATE TABLE `sys_menu` (
-- ----------------------------
-- Records of sys_menu
-- ----------------------------
-- 顶级目录(1-9):系统/代码生成/文档/接口文档/组件/演示/多级/路由
-- 顶级目录(1-9):系统管理/代码生成/项目文档/功能演示(动态表单目录见 extensions/form.sql)
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);
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 (5, 0, '0', '接口文档', 'C', '', '/api', 'Layout', NULL, NULL, NULL, 1, 5, 'api', '', 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', '', '/component', 'Layout', NULL, NULL, NULL, 1, 6, 'menu', '', 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 (7, 0, '0', '功能演示', 'C', '', '/function', 'Layout', NULL, NULL, NULL, 1, 7, 'menu', '', 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 (8, 0, '0', '多级菜单', 'C', NULL, '/multi-level', 'Layout', NULL, 1, NULL, 1, 8, 'cascader', '', 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 (9, 0, '0', '路由参数', 'C', '', '/route-param', 'Layout', NULL, NULL, NULL, 1, 9, 'el-icon-ElementPlus', '', 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 (210, 1, '0,1', '用户管理', 'M', 'User', 'user', 'system/user/index', NULL, NULL, 1, 1, 1, 'el-icon-User', NULL, now(), now(), NULL);
@@ -209,41 +207,48 @@ INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_n
-- 代码生成
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`, `external_url`, `perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (501, 4, '0,4', '平台文档(外链)', 'E', NULL, NULL, NULL, 'https://juejin.cn/post/7228990409909108793', NULL, NULL, NULL, 1, 1, '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 (502, 4, '0,4', '后端文档', 'E', NULL, NULL, NULL, 'https://youlai.blog.csdn.net/article/details/145178880', NULL, NULL, NULL, 1, 2, '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, 3, '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 (504, 4, '0,4', '内部文档', 'E', 'InternalDoc', 'internal-doc', 'iframe', 'https://juejin.cn/post/7228990409909108793', NULL, NULL, 1, 1, 4, 'document', '', 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, 5, '0,5', 'Apifox', 'E', 'Apifox', 'apifox', 'iframe', 'https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5', NULL, NULL, 1, 1, 1, 'api', '', 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);
-- 组件封装
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, 6, '0,6', '富文本编辑器', 'M', 'WangEditor', 'wang-editor', 'demo/wang-editor', 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 (702, 6, '0,6', '图片上传', 'M', 'Upload', 'upload', 'demo/upload', 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 (703, 6, '0,6', '图标选择器', 'M', 'DemoIconSelect', 'icon-select', 'demo/icon-select', 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 (704, 6, '0,6', '字典组件', 'M', 'DictDemo', 'dict-demo', 'demo/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 (705, 6, '0,6', '增删改查', 'M', 'Curd', 'curd', 'demo/curd/index', NULL, NULL, 1, 1, 0, '', '', 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, 6, '0,6', '列表选择器', 'M', 'TableSelect', 'table-select', 'demo/table-select/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 (707, 6, '0,6', '拖拽组件', 'M', 'Drag', 'drag', 'demo/drag', NULL, NULL, NULL, 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 (708, 6, '0,6', '滚动文本', 'M', 'TextScroll', 'text-scroll', 'demo/text-scroll', 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 (709, 6, '0,6', '自适应表格操作列', 'M', 'AutoOperationColumn', 'operation-column', 'demo/auto-operation-column', NULL, NULL, 1, 1, 1, '', '', 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);
-- 功能演示
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, 7, '0,7', 'Icons', 'M', 'IconDemo', 'icon-demo', 'demo/icons', NULL, NULL, 1, 1, 2, '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 (802, 7, '0,7', '字典实时同步', 'M', 'DictSync', 'dict-sync', 'demo/dict-sync', 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 (803, 7, '0,7', 'VxeTable', 'M', 'VxeTable', 'vxe-table', 'demo/vxe-table/index', NULL, NULL, 1, 1, 4, '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 (804, 7, '0,7', 'CURD单文件', 'M', 'CurdSingle', 'curd-single', 'demo/curd-single', NULL, NULL, 1, 1, 5, 'el-icon-Reading', '', now(), now(), NULL);
-- ============================ 多级菜单(顶级) ============================
-- 独立顶级,直观演示菜单驱动动态路由的嵌套能力,避免折叠导致顶级菜单不可见
-- 物理目录 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 (910, 8, '0,8', '一级菜单', 'C', NULL, 'level-one', 'Layout', NULL, 1, 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 (911, 910, '0,8,910', '二级菜单', '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 (912, 911, '0,8,910,911', '三级菜单 A', 'M', 'MultiLevelLevelThreeA', 'level-three-a', 'demo/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 (913, 911, '0,8,910,911', '三级菜单 B', 'M', 'MultiLevelLevelThreeB', 'level-three-b', 'demo/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 (1001, 9, '0,9', '参数(type=1)', 'M', 'RouteParamType1', 'route-param-type1', 'demo/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 (1002, 9, '0,9', '参数(type=2)', 'M', 'RouteParamType2', 'route-param-type2', 'demo/route-param', NULL, 0, 1, 1, 2, 'el-icon-StarFilled', NULL, now(), now(), '{\"type\": \"2\"}');
-- ============================ 功能演示(顶级,承接受窄内容) ============================
-- 收窄为行为/功能演示:字典实时同步 + 路由参数(详情页缓存经增删改查详情 /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\"}');
-- ----------------------------
-- Table structure for sys_role
@@ -305,8 +310,8 @@ INSERT IGNORE INTO `sys_role_dept` VALUES (7, 2);
-- ============================================
-- 系统管理员角色菜单权限(role_id=2)
-- 顶级目录
INSERT INTO `sys_role_menu` VALUES (2, 1), (2, 2), (2, 4), (2, 5), (2, 6), (2, 7), (2, 8), (2, 9);
-- 顶级目录(5/7/8/9 已撤级合并,授权同步收敛)
INSERT INTO `sys_role_menu` VALUES (2, 1), (2, 2), (2, 4), (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);
@@ -335,15 +340,12 @@ INSERT IGNORE INTO `sys_role_menu` VALUES (7, 210), (7, 2101), (7, 2102), (7, 21
INSERT IGNORE INTO `sys_role_menu` VALUES (7, 220), (7, 2201), (7, 2202), (7, 2203), (7, 2204), (7, 2205);
-- 代码生成
INSERT INTO `sys_role_menu` VALUES (2, 310);
-- 平台文档
INSERT INTO `sys_role_menu` VALUES (2, 501), (2, 502), (2, 503), (2, 504);
-- 接口文档
INSERT INTO `sys_role_menu` VALUES (2, 601);
-- 组件封装
-- 项目文档(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);
-- 路由参数
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);
-- ----------------------------

View File

@@ -66,4 +66,14 @@ public interface RedisConstants {
String ROLE_PERMS = "system:role:perms"; // 系统角色和权限映射
}
/**
* 表单模块
*/
interface Form {
/** 已发布表单渲染规则(formKey -> FormRenderVO,发布/停用/改规则/删除时主动失效) */
String RENDER_RULE = "form:render:{}";
/** 公开表单匿名提交计数(formKey:ip 维度滑动窗口,防刷限流) */
String PUBLIC_SUBMIT_COUNT = "form:public:submit:{}:{}";
}
}

View File

@@ -27,6 +27,7 @@ public enum LogModuleEnum implements IBaseEnum<Integer> {
NOTICE(9, "通知公告"),
LOG(10, "日志管理"),
CODEGEN(11, "代码生成"),
FORM(12, "动态表单"),
OTHER(99, "其他");
@EnumValue

View File

@@ -10,49 +10,57 @@ import java.util.Set;
import java.util.stream.Collectors;
/**
* 文件存储配置。
* <p>
* 绑定前缀 {@code file-storage},承载存储类型与上传限制;
* rustfs / aliyun / local 子配置由各自的 {@code FileService} 实现类绑定。
* 文件存储配置属性类
*
* @author youlai.tech
* <p>映射 application.yml 中 storage 前缀的配置;s3 / aliyun / local
* 子配置由各自的 {@code FileService} 实现类绑定</p>
*
* @author Ray.Hao
* @since 4.8.0
*/
@Data
@Component
@ConfigurationProperties(prefix = "file-storage")
public class FileStorageProperties {
@ConfigurationProperties(prefix = "storage")
public class StorageProperties {
/**
* 存储类型,决定激活哪个 FileService 实现。
* <p>
* 可选值:rustfs、aliyun、local。
* 存储类型,决定激活哪个 FileService 实现类
* <ul>
* <li>s3 - S3 协议兼容对象存储(当前部署 RustFS)</li>
* <li>aliyun - 阿里云 OSS</li>
* <li>local - 本地磁盘存储</li>
* </ul>
*/
private String type;
/** 上传限制 */
/**
* 上传限制
*/
private Upload upload = new Upload();
/**
* 上传限制,绑定前缀 {@code file-storage.upload}。
* 上传限制配置嵌套类
*/
@Data
public static class Upload {
/**
* 单文件大小上限,如 50MB。
* <p>
* 同时作为 spring.servlet.multipart 的上限,单一来源。
* 单文件大小上限,如 50MB
* <p>同时作为 spring.servlet.multipart 的上限,保持单一来源</p>
*/
private String maxFileSize;
/**
* 允许的文件扩展名白名单,置空表示不限制。
* 允许上传的文件扩展名白名单
* <p>置空表示不限制</p>
*/
private List<String> allowedExtensions;
}
/**
* 允许扩展名集合(小写、不含点);空集合表示不限制。
* 获取规范化后的扩展名白名单
*
* @return 小写、不含点的扩展名集合;空集合表示不限制
*/
public Set<String> getAllowedExtensions() {
if (upload == null || upload.allowedExtensions == null) {

View File

@@ -0,0 +1,230 @@
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.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.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.vo.FormDataVO;
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;
import com.youlai.boot.module.form.service.FormDataService;
import com.youlai.boot.module.form.service.FormDefinitionService;
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.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Map;
/**
* 表单控制层(表单定义、访问菜单、表单数据)
*
* @author Ray.Hao
* @since 4.8.0
*/
@Tag(name = "12.表单接口")
@RestController
@RequestMapping("/api/v1/forms")
@RequiredArgsConstructor
public class FormController {
private final FormDefinitionService formDefinitionService;
private final FormDataService formDataService;
@Operation(summary = "表单定义分页列表")
@GetMapping
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.LIST)
public PageResult<FormDefinitionPageVO> getFormDefinitionPage(
FormDefinitionQuery queryParams
) {
Page<FormDefinitionPageVO> result = formDefinitionService.getFormDefinitionPage(queryParams);
return PageResult.success(result);
}
@Operation(summary = "获取表单定义表单数据")
@GetMapping("/{formId}/form")
public Result<FormDefinitionForm> getFormDefinitionForm(
@Parameter(description = "表单ID") @PathVariable Long formId
) {
FormDefinitionForm formData = formDefinitionService.getFormDefinitionForm(formId);
return Result.success(formData);
}
@Operation(summary = "新增表单定义")
@PostMapping
@PreAuthorize("@ss.hasPerm('form:definition:create')")
@RepeatSubmit
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.INSERT)
public Result<?> saveFormDefinition(@Valid @RequestBody FormDefinitionForm formData) {
boolean result = formDefinitionService.saveFormDefinition(formData);
return Result.judge(result);
}
@Operation(summary = "修改表单定义")
@PutMapping("/{formId}")
@PreAuthorize("@ss.hasPerm('form:definition:update')")
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.UPDATE)
public Result<?> updateFormDefinition(
@Parameter(description = "表单ID") @PathVariable Long formId,
@RequestBody FormDefinitionForm formData
) {
boolean result = formDefinitionService.updateFormDefinition(formId, formData);
return Result.judge(result);
}
@Operation(summary = "删除表单定义")
@DeleteMapping("/{ids}")
@PreAuthorize("@ss.hasPerm('form:definition:delete')")
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.DELETE)
public Result<Void> removeFormDefinition(
@Parameter(description = "表单ID,多个以英文逗号(,)拼接") @PathVariable String ids
) {
formDefinitionService.removeFormDefinition(Arrays.stream(ids.split(",")).map(Long::valueOf).toList());
return Result.success();
}
@Operation(summary = "发布表单")
@PutMapping("/{formId}/publish")
@PreAuthorize("@ss.hasPerm('form:definition:update')")
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.UPDATE)
public Result<Void> publishFormDefinition(
@Parameter(description = "表单ID") @PathVariable Long formId
) {
boolean result = formDefinitionService.publishFormDefinition(formId);
return Result.judge(result);
}
@Operation(summary = "停用表单")
@PutMapping("/{formId}/disable")
@PreAuthorize("@ss.hasPerm('form:definition:update')")
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.UPDATE)
public Result<Void> disableFormDefinition(
@Parameter(description = "表单ID") @PathVariable Long formId
) {
boolean result = formDefinitionService.disableFormDefinition(formId);
return Result.judge(result);
}
@Operation(summary = "获取表单渲染规则")
@GetMapping("/{formKey}/render")
public Result<FormRenderVO> getFormRender(
@Parameter(description = "表单唯一标识") @PathVariable String formKey
) {
FormRenderVO renderVO = formDefinitionService.getFormRender(formKey);
return Result.success(renderVO);
}
//---------------------------------------------------
// 表单菜单相关接口(发布向导)
//---------------------------------------------------
@Operation(summary = "获取表单菜单配置(向导回显)")
@GetMapping("/{formId}/menu")
public Result<FormMenuVO> getFormMenu(
@Parameter(description = "表单ID") @PathVariable Long formId
) {
FormMenuVO menuVO = formDefinitionService.getFormMenu(formId);
return Result.success(menuVO);
}
@Operation(summary = "生成表单访问菜单")
@PostMapping("/{formId}/menu")
@PreAuthorize("@ss.hasPerm('form:definition:update')")
@RepeatSubmit
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.INSERT)
public Result<?> saveFormMenu(
@Parameter(description = "表单ID") @PathVariable Long formId,
@Valid @RequestBody FormMenuForm menuForm
) {
boolean result = formDefinitionService.saveFormMenu(formId, menuForm);
return Result.judge(result);
}
//---------------------------------------------------
// 公开表单相关接口(匿名,Security 白名单 + Service 校验 is_public + IP 限流)
//---------------------------------------------------
@Operation(summary = "获取公开表单渲染规则(匿名)")
@GetMapping("/public/{formKey}/render")
public Result<FormRenderVO> getPublicFormRender(
@Parameter(description = "表单唯一标识") @PathVariable String formKey
) {
FormRenderVO renderVO = formDefinitionService.getPublicFormRender(formKey);
return Result.success(renderVO);
}
@Operation(summary = "匿名提交公开表单数据")
@PostMapping("/public/{formKey}/data")
@RepeatSubmit
public Result<?> savePublicFormData(
@Parameter(description = "表单唯一标识") @PathVariable String formKey,
@RequestBody Map<String, Object> data,
HttpServletRequest request
) {
boolean result = formDataService.savePublicFormData(formKey, data, IPUtils.getIpAddr(request));
return Result.judge(result);
}
//---------------------------------------------------
// 表单数据相关接口
//---------------------------------------------------
@Operation(summary = "表单数据分页列表")
@GetMapping("/{formKey}/data")
@PreAuthorize("@ss.hasPerm('form:data:list')")
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.LIST)
public PageResult<FormDataVO> getFormDataPage(
@Parameter(description = "表单唯一标识") @PathVariable String formKey,
FormDataQuery queryParams
) {
Page<FormDataVO> result = formDataService.getFormDataPage(formKey, queryParams);
return PageResult.success(result);
}
@Operation(summary = "提交表单数据")
@PostMapping("/{formKey}/data")
@RepeatSubmit
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.INSERT)
public Result<?> saveFormData(
@Parameter(description = "表单唯一标识") @PathVariable String formKey,
@RequestBody Map<String, Object> data
) {
boolean result = formDataService.saveFormData(formKey, data);
return Result.judge(result);
}
@Operation(summary = "获取表单数据详情(只读回显)")
@GetMapping("/{formKey}/data/{dataId}")
@PreAuthorize("@ss.hasPerm('form:data:list')")
public Result<FormDataVO> getFormDataDetail(
@Parameter(description = "表单唯一标识") @PathVariable String formKey,
@Parameter(description = "数据ID") @PathVariable Long dataId
) {
FormDataVO dataVO = formDataService.getFormDataDetail(dataId);
return Result.success(dataVO);
}
@Operation(summary = "删除表单数据")
@DeleteMapping("/{formKey}/data/{ids}")
@PreAuthorize("@ss.hasPerm('form:data:delete')")
@Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.DELETE)
public Result<Void> removeFormData(
@Parameter(description = "表单唯一标识") @PathVariable String formKey,
@Parameter(description = "数据ID,多个以英文逗号(,)拼接") @PathVariable String ids
) {
formDataService.removeFormData(Arrays.stream(ids.split(",")).map(Long::valueOf).toList());
return Result.success();
}
}

View File

@@ -0,0 +1,24 @@
package com.youlai.boot.module.form.converter;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.vo.FormDefinitionPageVO;
import org.mapstruct.Mapper;
/**
* 表单定义 对象转换器
*
* @author Ray.Hao
* @since 4.8.0
*/
@Mapper(componentModel = "spring")
public interface FormConverter {
Page<FormDefinitionPageVO> toPageVo(Page<FormDefinition> page);
FormDefinitionForm toForm(FormDefinition entity);
FormDefinition toEntity(FormDefinitionForm form);
}

View File

@@ -0,0 +1,47 @@
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;
/**
* 表单状态枚举
*
* <p>正数为有效生命周期(草稿/已发布),-1 为退役态(已停用),
* 与字典等模块"启停"语义(1/0)不同类,故独立定义</p>
*
* @author Ray.Hao
* @since 4.8.0
*/
@Schema(enumAsRef = true)
@Getter
public enum FormStatusEnum implements IBaseEnum<Integer> {
DRAFT(0, "草稿"),
PUBLISHED(1, "已发布"),
DISABLED(-1, "已停用");
@EnumValue
private final Integer value;
@JsonValue
private final String label;
FormStatusEnum(Integer value, String label) {
this.value = value;
this.label = label;
}
@Override
public Integer getValue() {
return this.value;
}
@Override
public String getLabel() {
return this.label;
}
}

View File

@@ -0,0 +1,29 @@
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.vo.FormDataVO;
import org.apache.ibatis.annotations.Mapper;
/**
* 表单数据 访问层
*
* @author Ray.Hao
* @since 4.8.0
*/
@Mapper
public interface FormDataMapper extends BaseMapper<FormData> {
/**
* 表单数据分页列表(联查提交人昵称)
*
* @param page 分页参数
* @param formId 表单定义ID
* @param queryParams 查询参数
* @return 表单数据分页列表
*/
Page<FormDataVO> getFormDataPage(Page<FormDataVO> page, Long formId, FormDataQuery queryParams);
}

View File

@@ -0,0 +1,28 @@
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.vo.FormDefinitionPageVO;
import org.apache.ibatis.annotations.Mapper;
/**
* 表单定义 访问层
*
* @author Ray.Hao
* @since 4.8.0
*/
@Mapper
public interface FormDefinitionMapper extends BaseMapper<FormDefinition> {
/**
* 表单定义分页列表
*
* @param page 分页参数
* @param queryParams 查询参数
* @return 表单定义分页列表
*/
Page<FormDefinitionPageVO> getFormDefinitionPage(Page<FormDefinitionPageVO> page, FormDefinitionQuery queryParams);
}

View File

@@ -0,0 +1,16 @@
package com.youlai.boot.module.form.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.module.form.model.entity.FormSnapshot;
import org.apache.ibatis.annotations.Mapper;
/**
* 表单版本快照访问层
*
* @author Ray.Hao
* @since 4.8.0
*/
@Mapper
public interface FormSnapshotMapper extends BaseMapper<FormSnapshot> {
}

View File

@@ -0,0 +1,53 @@
package com.youlai.boot.module.form.model.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 表单数据实体
*
* @author Ray.Hao
* @since 4.8.0
*/
@EqualsAndHashCode(callSuper = true)
@TableName("form_data")
@Data
public class FormData extends BaseEntity {
/**
* 表单定义ID
*/
private Long formId;
/**
* 表单版本(提交时快照,用于追溯该数据由哪版规则产出)
*/
private Integer formVersion;
/**
* 表单数据(field -> value 映射的 JSON 字符串)
*/
private String dataJson;
/**
* 提交人ID(匿名提交为空)
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 修改人ID(数据修正时记录)
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 逻辑删除标识(0未删除 1已删除)
*/
private Integer isDeleted;
}

View File

@@ -0,0 +1,86 @@
package com.youlai.boot.module.form.model.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 表单定义实体
*
* @author Ray.Hao
* @since 4.8.0
*/
@EqualsAndHashCode(callSuper = true)
@TableName("form_definition")
@Data
public class FormDefinition extends BaseEntity {
/**
* 表单唯一标识(业务编码,创建后不可修改)
*/
private String formKey;
/**
* 表单名称
*/
private String formName;
/**
* 表单描述
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String description;
/**
* 表单规则(form-create rule 数组 JSON 字符串)
*/
private String formJson;
/**
* 表单全局配置 JSON 字符串(labelPosition/size/submitBtn 等)
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String optionsJson;
/**
* 状态(0草稿 1已发布 -1已停用)
*/
private Integer status;
/**
* 是否允许匿名公开访问(0否 1是)
*/
private Integer isPublic;
/**
* 生成的访问菜单ID(NULL未生成,幂等防重复)
*/
private Long menuId;
/**
* 版本号,每次发布+1
*/
private Integer version;
/**
* 创建人ID
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 更新人ID
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 逻辑删除标识(0未删除 1已删除)
*/
private Integer isDeleted;
}

View File

@@ -0,0 +1,48 @@
package com.youlai.boot.module.form.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 表单版本快照实体
*
* <p>发布时固化规则快照,数据回显按提交时版本加载,
* 防止表单定义变更(字段删除/改名)导致历史数据回显漂移;
* 快照只增不改,审计字段仅保留 BaseEntity 公共部分</p>
*
* @author Ray.Hao
* @since 4.8.0
*/
@EqualsAndHashCode(callSuper = true)
@TableName("form_snapshot")
@Data
public class FormSnapshot extends BaseEntity {
/**
* 表单定义ID
*/
private Long formId;
/**
* 版本号(发布时递增后的值)
*/
private Integer version;
/**
* 表单规则快照(form-create rule 数组 JSON 字符串)
*/
private String formJson;
/**
* 表单全局配置快照 JSON 字符串
*/
private String optionsJson;
/**
* 逻辑删除标识(0未删除 1已删除)
*/
private Integer isDeleted;
}

View File

@@ -0,0 +1,46 @@
package com.youlai.boot.module.form.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.8.0
*/
@Schema(description = "表单定义")
@Data
public class FormDefinitionForm {
@Schema(description = "表单ID", example = "1")
private Long id;
@Schema(description = "表单唯一标识(创建后不可修改)", example = "employee_onboarding")
@NotBlank(message = "表单标识不能为空")
@Size(max = 64, message = "表单标识长度不能超过64个字符")
@Pattern(regexp = "^[a-z][a-z0-9_]*$", message = "表单标识须以小写字母开头,仅含小写字母、数字、下划线")
private String formKey;
@Schema(description = "表单名称", example = "新员工入职信息采集")
@NotBlank(message = "表单名称不能为空")
@Size(max = 100, message = "表单名称长度不能超过100个字符")
private String formName;
@Schema(description = "表单描述")
@Size(max = 255, message = "表单描述长度不能超过255个字符")
private String description;
@Schema(description = "表单规则(form-create rule 数组 JSON 字符串,由设计器产出)")
private String formJson;
@Schema(description = "表单全局配置 JSON 字符串")
private String optionsJson;
@Schema(description = "是否允许匿名公开访问(0否 1是),公开访问总开关")
private Integer isPublic;
}

View File

@@ -0,0 +1,31 @@
package com.youlai.boot.module.form.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 表单菜单发布表单对象(发布向导"入口配置"步骤提交)
*
* @author Ray.Hao
* @since 4.8.0
*/
@Schema(description = "表单菜单发布对象")
@Data
public class FormMenuForm {
@Schema(description = "菜单名称(侧边栏展示)", example = "入职信息采集")
@NotBlank(message = "菜单名称不能为空")
@Size(max = 50, message = "菜单名称长度不能超过50个字符")
private String menuName;
@Schema(description = "上级菜单ID(须为目录类型;为空时自动使用/创建默认目录\"表单中心\")", example = "11")
private Long parentId;
@Schema(description = "可见角色ID列表(ROOT 角色无需授权,天然全量可见)")
private List<Long> roleIds;
}

View File

@@ -0,0 +1,19 @@
package com.youlai.boot.module.form.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.8.0
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(description = "表单数据查询对象")
public class FormDataQuery extends BaseQuery {
}

View File

@@ -0,0 +1,25 @@
package com.youlai.boot.module.form.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.8.0
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Schema(description = "表单定义查询对象")
public class FormDefinitionQuery extends BaseQuery {
@Schema(description = "关键字(表单名称/标识)")
private String keywords;
@Schema(description = "状态(0草稿 1已发布 -1已停用)")
private Integer status;
}

View File

@@ -0,0 +1,46 @@
package com.youlai.boot.module.form.model.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 表单数据VO(分页行与详情回显共用:数据只读,无独立表单对象)
*
* @author Ray.Hao
* @since 4.8.0
*/
@Schema(description = "表单数据对象")
@Getter
@Setter
public class FormDataVO {
@Schema(description = "数据ID")
private Long id;
@Schema(description = "表单版本(提交时快照)")
private Integer formVersion;
@Schema(description = "表单数据(field -> value 映射的 JSON 字符串)")
private String dataJson;
@Schema(description = "回显规则(提交时版本的快照,严格回显用;仅详情接口填充)")
private String formJson;
@Schema(description = "回显全局配置(提交时版本的快照;仅详情接口填充)")
private String optionsJson;
@Schema(description = "提交人ID(匿名提交为空)")
private Long createBy;
@Schema(description = "提交人昵称(匿名提交为空)")
private String createByName;
@Schema(description = "提交时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,44 @@
package com.youlai.boot.module.form.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 表单定义分页VO
*
* @author Ray.Hao
* @since 4.8.0
*/
@Schema(description = "表单定义分页对象")
@Getter
@Setter
public class FormDefinitionPageVO {
@Schema(description = "表单ID")
private Long id;
@Schema(description = "表单唯一标识")
private String formKey;
@Schema(description = "表单名称")
private String formName;
@Schema(description = "表单描述")
private String description;
@Schema(description = "状态(0草稿 1已发布 -1已停用)")
private Integer status;
@Schema(description = "版本号")
private Integer version;
@Schema(description = "是否允许匿名公开访问(0否 1是)")
private Integer isPublic;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,32 @@
package com.youlai.boot.module.form.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.8.0
*/
@Schema(description = "表单菜单配置对象")
@Getter
@Setter
public class FormMenuVO {
@Schema(description = "菜单ID")
private Long menuId;
@Schema(description = "菜单名称")
private String menuName;
@Schema(description = "上级菜单ID")
private Long parentId;
@Schema(description = "可见角色ID列表")
private List<Long> roleIds;
}

View File

@@ -0,0 +1,33 @@
package com.youlai.boot.module.form.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/**
* 表单渲染VO(渲染端按 formKey 加载的已发布规则)
*
* @author Ray.Hao
* @since 4.8.0
*/
@Schema(description = "表单渲染对象")
@Getter
@Setter
public class FormRenderVO {
@Schema(description = "表单唯一标识")
private String formKey;
@Schema(description = "表单名称")
private String formName;
@Schema(description = "表单版本号")
private Integer version;
@Schema(description = "表单规则(form-create rule 数组 JSON 字符串)")
private String formJson;
@Schema(description = "表单全局配置 JSON 字符串")
private String optionsJson;
}

View File

@@ -0,0 +1,68 @@
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.vo.FormDataVO;
import java.util.List;
import java.util.Map;
/**
* 表单数据业务接口
*
* @author Ray.Hao
* @since 4.8.0
*/
public interface FormDataService extends IService<FormData> {
/**
* 提交表单数据(仅已发布表单可提交,提交时快照表单版本)
*
* <p>提交前执行后端基础校验(必填/类型,基于 rule validate)与字段白名单过滤</p>
*
* @param formKey 表单唯一标识
* @param data 表单数据(field -> value 映射)
* @return 是否提交成功
*/
boolean saveFormData(String formKey, Map<String, Object> data);
/**
* 匿名提交公开表单数据(公开分享链接入口)
*
* <p>公开访问总闸:仅 is_public=1 且已发布的表单可提交;
* 按 formKey + IP 维度做 Redis 限流防刷,create_by 为空(匿名无身份)</p>
*
* @param formKey 表单唯一标识
* @param data 表单数据(field -> value 映射)
* @param ip 提交方 IP(限流维度)
* @return 是否提交成功
*/
boolean savePublicFormData(String formKey, Map<String, Object> data, String ip);
/**
* 表单数据分页列表
*
* @param formKey 表单唯一标识
* @param queryParams 查询参数
* @return 表单数据分页列表
*/
Page<FormDataVO> getFormDataPage(String formKey, FormDataQuery queryParams);
/**
* 获取表单数据详情(只读回显)
*
* @param dataId 数据ID
* @return 表单数据详情
*/
FormDataVO getFormDataDetail(Long dataId);
/**
* 批量删除表单数据
*
* @param ids 数据ID列表
*/
void removeFormData(List<Long> ids);
}

View File

@@ -0,0 +1,128 @@
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.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.vo.FormDefinitionPageVO;
import com.youlai.boot.module.form.model.vo.FormMenuVO;
import com.youlai.boot.module.form.model.vo.FormRenderVO;
import java.util.List;
/**
* 表单定义业务接口
*
* @author Ray.Hao
* @since 4.8.0
*/
public interface FormDefinitionService extends IService<FormDefinition> {
/**
* 表单定义分页列表
*
* @param queryParams 查询参数
* @return 表单定义分页列表
*/
Page<FormDefinitionPageVO> getFormDefinitionPage(FormDefinitionQuery queryParams);
/**
* 获取表单定义表单数据(编辑回显)
*
* @param id 表单ID
* @return 表单定义表单数据
*/
FormDefinitionForm getFormDefinitionForm(Long id);
/**
* 新增表单定义(初始为草稿态)
*
* @param formData 表单定义表单数据
* @return 是否新增成功
*/
boolean saveFormDefinition(FormDefinitionForm formData);
/**
* 修改表单定义
*
* <p>formKey 创建后不可修改;草稿态可修改元数据与规则,非草稿态仅可修改规则</p>
*
* @param id 表单ID
* @param formData 表单定义表单数据
* @return 是否修改成功
*/
boolean updateFormDefinition(Long id, FormDefinitionForm formData);
/**
* 批量删除表单定义
*
* @param ids 表单ID列表
*/
void removeFormDefinition(List<Long> ids);
/**
* 发布表单(版本号+1,固化版本快照,刷新渲染缓存)
*
* @param id 表单ID
* @return 是否发布成功
*/
boolean publishFormDefinition(Long id);
/**
* 停用表单(清渲染缓存,已发出去的入口立即失效)
*
* @param id 表单ID
* @return 是否停用成功
*/
boolean disableFormDefinition(Long id);
/**
* 按 formKey 获取已发布表单的渲染规则(Redis 缓存)
*
* @param formKey 表单唯一标识
* @return 表单渲染数据
*/
FormRenderVO getFormRender(String formKey);
/**
* 按 formKey 获取公开表单的渲染规则(匿名接口)
*
* <p>公开访问总闸:仅 is_public=1 且已发布的表单可获取,
* 否则视为不存在(分享链接立即失效,无需召回)</p>
*
* @param formKey 表单唯一标识
* @return 表单渲染数据
*/
FormRenderVO getPublicFormRender(String formKey);
/**
* 按 formKey 查询表单定义(任意状态:已停用表单的历史数据仍可查看)
*
* @param formKey 表单唯一标识
* @return 表单定义实体
*/
FormDefinition getByFormKey(String formKey);
/**
* 生成表单访问菜单并授权可见角色(幂等:已生成时更新原菜单)
*
* <p>菜单 component 指向通用承载页 dynamic-form/render,路由参数携带 formKey;
* 角色授权沿 tree_path 补齐祖先目录,防止侧边栏构树出现孤儿节点</p>
*
* @param formId 表单ID
* @param menuForm 菜单发布参数
* @return 是否生成成功
*/
boolean saveFormMenu(Long formId, FormMenuForm menuForm);
/**
* 获取表单已生成的菜单配置(发布向导回显)
*
* @param formId 表单ID
* @return 菜单配置;未生成过返回 null
*/
FormMenuVO getFormMenu(Long formId);
}

View File

@@ -0,0 +1,215 @@
package com.youlai.boot.module.form.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.module.form.enums.FormStatusEnum;
import com.youlai.boot.module.form.mapper.FormDataMapper;
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.vo.FormDataVO;
import com.youlai.boot.module.form.service.FormDataService;
import com.youlai.boot.module.form.service.FormDefinitionService;
import com.youlai.boot.module.form.util.FormRuleValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* 表单数据业务实现类
*
* @author Ray.Hao
* @since 4.8.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FormDataServiceImpl extends ServiceImpl<FormDataMapper, FormData> implements FormDataService {
private final FormDefinitionService formDefinitionService;
private final FormSnapshotMapper formSnapshotMapper;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 公开表单单 IP 提交上限(次/分钟):匿名接口防刷阈值
*/
private static final int PUBLIC_SUBMIT_LIMIT = 10;
/**
* 公开表单限流窗口:1 分钟
*/
private static final Duration PUBLIC_SUBMIT_WINDOW = Duration.ofMinutes(1);
/**
* 提交表单数据
*
* <p>仅已发布表单可提交;提交时快照表单版本,供数据追溯到产出规则</p>
*
* @param formKey 表单唯一标识
* @param data 表单数据(field -> value 映射)
*/
@Override
public boolean saveFormData(String formKey, Map<String, Object> data) {
FormDefinition form = formDefinitionService.getByFormKey(formKey);
if (!FormStatusEnum.PUBLISHED.getValue().equals(form.getStatus())) {
throw new BusinessException("表单未发布或已停用");
}
return this.persistFormData(form, data);
}
/**
* 匿名提交公开表单数据
*
* @param formKey 表单唯一标识
* @param data 表单数据(field -> value 映射)
* @param ip 提交方 IP(限流维度)
*/
@Override
public boolean savePublicFormData(String formKey, Map<String, Object> data, String ip) {
FormDefinition form = formDefinitionService.getByFormKey(formKey);
// 公开访问总闸:未开启公开或未发布的表单拿 key 也提交不了
if (!FormStatusEnum.PUBLISHED.getValue().equals(form.getStatus())
|| !Integer.valueOf(1).equals(form.getIsPublic())) {
throw new BusinessException("表单不存在或未开放公开访问");
}
checkPublicSubmitLimit(formKey, ip);
log.info("公开表单匿名提交 formKey={} ip={}", formKey, ip);
return this.persistFormData(form, data);
}
/**
* 数据落库(登录/匿名提交共用)
*
* <p>后端基础校验(必填/类型)+ 字段白名单过滤:前端校验可被绕过,
* 恶意构造的未声明字段不会入库;匿名提交 create_by 由字段填充器留空</p>
*
* @param form 表单定义
* @param data 提交数据
*/
private boolean persistFormData(FormDefinition form, Map<String, Object> data) {
Map<String, Object> 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);
}
/**
* 公开表单按 formKey + IP 限流(固定窗口计数)
*
* <p>Redis 异常时 Fail-Open 放行,与 IpRateLimitFilter 策略一致:
* 限流是防刷兜底而非业务正确性保障,不能因 Redis 抖动拒绝正常提交</p>
*
* @param formKey 表单唯一标识
* @param ip 提交方 IP
*/
private void checkPublicSubmitLimit(String formKey, String ip) {
String key = StrUtil.format(RedisConstants.Form.PUBLIC_SUBMIT_COUNT, formKey, ip);
try {
Long count = redisTemplate.opsForValue().increment(key);
if (count != null) {
// 首次计数时设置过期,窗口结束后自动清零
if (count == 1) {
redisTemplate.expire(key, PUBLIC_SUBMIT_WINDOW);
}
if (count > PUBLIC_SUBMIT_LIMIT) {
throw new BusinessException("提交过于频繁,请稍后再试");
}
}
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.warn("公开表单限流 Redis 异常,Fail-Open 放行 formKey={} ip={}", formKey, ip, e);
}
}
/**
* 表单数据分页列表
*
* @param formKey 表单唯一标识
* @param queryParams 查询参数
*/
@Override
public Page<FormDataVO> getFormDataPage(String formKey, FormDataQuery queryParams) {
FormDefinition form = formDefinitionService.getByFormKey(formKey);
int pageNum = queryParams.getPageNum();
int pageSize = queryParams.getPageSize();
return this.baseMapper.getFormDataPage(new Page<>(pageNum, pageSize), form.getId(), queryParams);
}
/**
* 获取表单数据详情(只读回显)
*
* <p>版本快照严格回显:优先按提交时版本加载规则快照,
* 防止表单定义变更(字段删除/改名)导致历史数据回显漂移</p>
*
* @param dataId 数据ID
*/
@Override
public FormDataVO getFormDataDetail(Long dataId) {
FormData entity = this.getById(dataId);
if (entity == null) {
throw new BusinessException("表单数据不存在");
}
FormDataVO vo = new FormDataVO();
vo.setId(entity.getId());
vo.setFormVersion(entity.getFormVersion());
vo.setDataJson(entity.getDataJson());
vo.setCreateBy(entity.getCreateBy());
vo.setCreateTime(entity.getCreateTime());
this.fillRuleSnapshot(vo, entity.getFormId(), entity.getFormVersion());
return vo;
}
/**
* 填充回显规则:命中版本快照用快照规则,否则回退当前定义
*
* <p>回退分支兼容快照表启用前提交的历史数据</p>
*
* @param vo 数据详情 VO
* @param formId 表单定义ID
* @param version 提交时版本
*/
private void fillRuleSnapshot(FormDataVO vo, Long formId, Integer version) {
FormSnapshot snapshot = formSnapshotMapper.selectOne(new LambdaQueryWrapper<FormSnapshot>()
.eq(FormSnapshot::getFormId, formId)
.eq(FormSnapshot::getVersion, version)
.last("LIMIT 1")
);
if (snapshot != null) {
vo.setFormJson(snapshot.getFormJson());
vo.setOptionsJson(snapshot.getOptionsJson());
return;
}
FormDefinition form = formDefinitionService.getById(formId);
if (form != null) {
vo.setFormJson(form.getFormJson());
vo.setOptionsJson(form.getOptionsJson());
}
}
/**
* 批量删除表单数据
*
* @param ids 数据ID列表
*/
@Override
public void removeFormData(List<Long> ids) {
this.removeByIds(ids);
}
}

View File

@@ -0,0 +1,508 @@
package com.youlai.boot.module.form.service.impl;
import cn.hutool.core.bean.BeanUtil;
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.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.module.form.converter.FormConverter;
import com.youlai.boot.module.form.enums.FormStatusEnum;
import com.youlai.boot.module.form.mapper.FormDefinitionMapper;
import com.youlai.boot.module.form.mapper.FormSnapshotMapper;
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.vo.FormDefinitionPageVO;
import com.youlai.boot.module.form.model.vo.FormMenuVO;
import com.youlai.boot.module.form.model.vo.FormRenderVO;
import com.youlai.boot.module.form.service.FormDefinitionService;
import com.youlai.boot.system.enums.MenuTypeEnum;
import com.youlai.boot.system.model.entity.Menu;
import com.youlai.boot.system.model.entity.RoleMenu;
import com.youlai.boot.system.model.form.MenuForm;
import com.youlai.boot.system.service.MenuService;
import com.youlai.boot.system.service.RoleMenuService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 表单定义业务实现类
*
* @author Ray.Hao
* @since 4.8.0
*/
@Service
@RequiredArgsConstructor
public class FormDefinitionServiceImpl extends ServiceImpl<FormDefinitionMapper, FormDefinition> implements FormDefinitionService {
private final FormConverter formConverter;
private final RedisTemplate<String, Object> redisTemplate;
private final MenuService menuService;
private final RoleMenuService roleMenuService;
private final FormSnapshotMapper formSnapshotMapper;
/**
* 表单定义分页列表
*
* @param queryParams 查询参数
*/
@Override
public Page<FormDefinitionPageVO> getFormDefinitionPage(FormDefinitionQuery queryParams) {
int pageNum = queryParams.getPageNum();
int pageSize = queryParams.getPageSize();
return this.baseMapper.getFormDefinitionPage(new Page<>(pageNum, pageSize), queryParams);
}
/**
* 获取表单定义表单数据
*
* @param id 表单ID
*/
@Override
public FormDefinitionForm getFormDefinitionForm(Long id) {
FormDefinition entity = this.getById(id);
if (entity == null) {
throw new BusinessException("表单不存在");
}
return formConverter.toForm(entity);
}
/**
* 新增表单定义
*
* @param formData 表单定义表单数据
*/
@Override
public boolean saveFormDefinition(FormDefinitionForm formData) {
// 校验 formKey 唯一(逻辑删除查询默认过滤,同 key 删除后可重建)
String formKey = formData.getFormKey();
long count = this.count(new LambdaQueryWrapper<FormDefinition>()
.eq(FormDefinition::getFormKey, formKey)
);
Assert.isTrue(count == 0, "表单标识已存在");
FormDefinition entity = formConverter.toEntity(formData);
// 新表单初始为草稿态;规则未设计时空数组占位,设计器打开为空白画布
entity.setStatus(FormStatusEnum.DRAFT.getValue());
entity.setVersion(1);
if (entity.getFormJson() == null) {
entity.setFormJson("[]");
}
return this.save(entity);
}
/**
* 修改表单定义
*
* <p>formKey 创建后不可修改;草稿态可修改元数据与规则,
* 非草稿态仅可修改规则(元数据只读,防止已发布表单的语义漂移)</p>
*
* @param id 表单ID
* @param formData 表单定义表单数据
*/
@Override
public boolean updateFormDefinition(Long id, FormDefinitionForm formData) {
FormDefinition entity = this.getById(id);
if (entity == null) {
throw new BusinessException("表单不存在");
}
// formKey 为 null 表示部分更新未携带该字段(如设计器仅保存规则),不视为修改;
// 显式传入不同标识才拒绝,防止已发布表单的业务编码被越权变更
if (formData.getFormKey() != null && !formData.getFormKey().equals(entity.getFormKey())) {
throw new BusinessException("表单标识创建后不可修改");
}
FormDefinition updateEntity = new FormDefinition();
updateEntity.setId(id);
// 仅草稿态允许修改元数据
if (FormStatusEnum.DRAFT.getValue().equals(entity.getStatus())) {
updateEntity.setFormName(formData.getFormName());
updateEntity.setDescription(formData.getDescription());
}
updateEntity.setFormJson(formData.getFormJson());
updateEntity.setOptionsJson(formData.getOptionsJson());
// 公开开关是访问控制而非身份元数据,任意状态可切换(发布向导/编辑弹窗均可操作)
updateEntity.setIsPublic(formData.getIsPublic());
boolean result = this.updateById(updateEntity);
// 已发布表单规则/公开开关变更直接影响渲染端,需清缓存即时生效
if (result && FormStatusEnum.PUBLISHED.getValue().equals(entity.getStatus())) {
this.evictRenderCache(entity.getFormKey());
}
return result;
}
/**
* 批量删除表单定义
*
* @param ids 表单ID列表
*/
@Override
public void removeFormDefinition(List<Long> ids) {
List<FormDefinition> forms = this.listByIds(ids);
this.removeByIds(ids);
// 已发布表单的渲染缓存一并清除,防止删除后仍可渲染
forms.stream()
.filter(form -> FormStatusEnum.PUBLISHED.getValue().equals(form.getStatus()))
.forEach(form -> this.evictRenderCache(form.getFormKey()));
}
/**
* 发布表单
*
* @param id 表单ID
*/
@Override
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);
if (result) {
// 固化版本快照:数据回显按提交时版本加载,防止规则变更(字段删除/改名)导致历史数据漂移
FormSnapshot snapshot = new FormSnapshot();
snapshot.setFormId(id);
snapshot.setVersion(newVersion);
snapshot.setFormJson(entity.getFormJson());
snapshot.setOptionsJson(entity.getOptionsJson());
formSnapshotMapper.insert(snapshot);
this.evictRenderCache(entity.getFormKey());
}
return result;
}
/**
* 停用表单
*
* @param id 表单ID
*/
@Override
public boolean disableFormDefinition(Long id) {
FormDefinition entity = this.getById(id);
if (entity == null) {
throw new BusinessException("表单不存在");
}
FormDefinition updateEntity = new FormDefinition();
updateEntity.setId(id);
updateEntity.setStatus(FormStatusEnum.DISABLED.getValue());
boolean result = this.updateById(updateEntity);
if (result) {
this.evictRenderCache(entity.getFormKey());
}
return result;
}
/**
* 按 formKey 获取已发布表单的渲染规则
*
* @param formKey 表单唯一标识
*/
@Override
public FormRenderVO getFormRender(String formKey) {
String cacheKey = RedisConstants.Form.RENDER_RULE.replace("{}", formKey);
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof FormRenderVO renderVO) {
return renderVO;
}
if (cached != null) {
// Redis JSON 序列化读回为无类型 Map,转换为 VO
return BeanUtil.toBean(cached, FormRenderVO.class);
}
FormDefinition entity = this.getOne(new LambdaQueryWrapper<FormDefinition>()
.eq(FormDefinition::getFormKey, formKey)
.eq(FormDefinition::getStatus, FormStatusEnum.PUBLISHED.getValue())
.last("LIMIT 1")
);
if (entity == null) {
throw new BusinessException("表单不存在或未发布");
}
FormRenderVO renderVO = new FormRenderVO();
renderVO.setFormKey(entity.getFormKey());
renderVO.setFormName(entity.getFormName());
renderVO.setVersion(entity.getVersion());
renderVO.setFormJson(entity.getFormJson());
renderVO.setOptionsJson(entity.getOptionsJson());
redisTemplate.opsForValue().set(cacheKey, renderVO);
return renderVO;
}
/**
* 清除表单渲染缓存
*
* @param formKey 表单唯一标识
*/
private void evictRenderCache(String formKey) {
redisTemplate.delete(RedisConstants.Form.RENDER_RULE.replace("{}", formKey));
}
/**
* 按 formKey 获取公开表单的渲染规则(匿名接口)
*
* <p>不复用 {@link #getFormRender(String)} 的 Redis 缓存:缓存 VO 不含 isPublic,
* 开关切换需额外失效逻辑;公开页非高频路径(IP 全局限流兜底),直查更简单可靠</p>
*
* @param formKey 表单唯一标识
*/
@Override
public FormRenderVO getPublicFormRender(String formKey) {
FormDefinition entity = this.getOne(new LambdaQueryWrapper<FormDefinition>()
.eq(FormDefinition::getFormKey, formKey)
.eq(FormDefinition::getStatus, FormStatusEnum.PUBLISHED.getValue())
.eq(FormDefinition::getIsPublic, 1)
.last("LIMIT 1")
);
if (entity == null) {
throw new BusinessException("表单不存在或未开放公开访问");
}
FormRenderVO renderVO = new FormRenderVO();
renderVO.setFormKey(entity.getFormKey());
renderVO.setFormName(entity.getFormName());
renderVO.setVersion(entity.getVersion());
renderVO.setFormJson(entity.getFormJson());
renderVO.setOptionsJson(entity.getOptionsJson());
return renderVO;
}
/**
* 按 formKey 查询表单定义
*
* @param formKey 表单唯一标识
*/
@Override
public FormDefinition getByFormKey(String formKey) {
FormDefinition form = this.getOne(new LambdaQueryWrapper<FormDefinition>()
.eq(FormDefinition::getFormKey, formKey)
.last("LIMIT 1")
);
if (form == null) {
throw new BusinessException("表单不存在");
}
return form;
}
/** 默认挂载目录名称:发布向导未显式选目录时自动使用/创建 */
private static final String DEFAULT_CATALOG_NAME = "表单中心";
/**
* 生成表单访问菜单并授权可见角色
*
* <p>菜单 component 指向通用承载页 dynamic-form/render,路由参数携带 formKey,
* 承载页从 route.meta.params 读取后按 formKey 加载渲染规则</p>
*
* @param formId 表单ID
* @param menuForm 菜单发布参数
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveFormMenu(Long formId, FormMenuForm menuForm) {
FormDefinition form = this.getById(formId);
if (form == null) {
throw new BusinessException("表单不存在");
}
// 上级菜单必须为目录类型:挂到菜单/按钮下构树异常;根级菜单不套 Layout,得到无侧边栏的孤立页
// parentId 为空 = 走默认目录"表单中心"(懒创建:首个表单发布时才建目录,避免空目录在侧边栏成点不开的孤菜单)
Menu parentMenu;
if (menuForm.getParentId() == null) {
parentMenu = this.ensureDefaultCatalog();
} else {
parentMenu = menuService.getById(menuForm.getParentId());
if (parentMenu == null) {
throw new BusinessException("上级菜单不存在");
}
if (!MenuTypeEnum.CATALOG.getValue().equals(parentMenu.getType())) {
throw new BusinessException("上级菜单必须为目录类型");
}
}
// 组装菜单:类型 M,指向通用承载页;已生成过时携带原菜单ID走更新语义(幂等)
String routeName = "FormRender" + StrUtil.upperFirst(StrUtil.toCamelCase(form.getFormKey(), '_'));
MenuForm menuFormData = new MenuForm();
menuFormData.setId(form.getMenuId());
menuFormData.setParentId(parentMenu.getId());
menuFormData.setName(menuForm.getMenuName());
menuFormData.setType(MenuTypeEnum.MENU.getValue());
menuFormData.setRouteName(routeName);
menuFormData.setRoutePath(form.getFormKey());
menuFormData.setComponent("dynamic-form/render");
menuFormData.setParams(List.of(new KeyValue("formKey", form.getFormKey())));
menuFormData.setVisible(1);
menuFormData.setSort(1);
// keepAlive 不开启:多个表单菜单共用 render 组件,组件级缓存会导致切换表单时回显错乱
boolean result = menuService.saveMenu(menuFormData);
if (!result) {
return false;
}
// saveMenu 内部 saveOrUpdate 不回写表单对象主键,按全局唯一路由名称反查菜单ID
Menu menu = menuService.getOne(new LambdaQueryWrapper<Menu>()
.eq(Menu::getRouteName, routeName)
.last("LIMIT 1")
);
// 回写 menu_id:下次发布走更新语义,防重复生成菜单
FormDefinition updateForm = new FormDefinition();
updateForm.setId(formId);
updateForm.setMenuId(menu.getId());
this.updateById(updateForm);
// 追加式角色授权(沿 tree_path 补齐祖先目录)
this.assignMenuToRoles(menu.getId(), menuForm.getRoleIds());
return true;
}
/** 表单管理目录名称(顶级,form.sql 预置):"表单中心"默认挂载其下,顶级入口聚合 */
private static final String FORM_ADMIN_CATALOG_NAME = "动态表单";
/**
* 确保默认挂载目录"表单中心"存在,不存在则创建
*
* <p>挂载位置:"动态表单"目录下(顶级入口聚合,员工侧边栏为 动态表单 > 表单中心 > 各表单;
* 管理菜单不授权给填写角色即不渲染,无需顶级分离);
* "动态表单"目录缺失时(未执行 form.sql 的环境)退化为顶级创建兜底</p>
*
* <p>懒创建而非 SQL 预置:预置空目录在侧边栏是点不开的孤菜单(无子路由不注册);
* 首个表单发布时才建目录,目录一出现就有子菜单</p>
*
* <p>踩坑:saveMenu 内部 saveOrUpdate 不回写表单对象主键,需按路由名称反查实体</p>
*
* @return 默认目录实体
*/
private Menu ensureDefaultCatalog() {
// 已有"表单中心"目录(任意层级)直接复用:兼容旧环境顶级目录等历史生成位置
Menu catalog = menuService.getOne(new LambdaQueryWrapper<Menu>()
.eq(Menu::getName, DEFAULT_CATALOG_NAME)
.eq(Menu::getType, MenuTypeEnum.CATALOG.getValue())
.last("LIMIT 1")
);
if (catalog != null) {
return catalog;
}
Menu adminCatalog = menuService.getOne(new LambdaQueryWrapper<Menu>()
.eq(Menu::getParentId, 0L)
.eq(Menu::getName, FORM_ADMIN_CATALOG_NAME)
.eq(Menu::getType, MenuTypeEnum.CATALOG.getValue())
.last("LIMIT 1")
);
// 挂"动态表单"下用相对路由段(父级 /dynamic-form + center);退化顶级时用绝对路径
// 退化路径避开 /dynamic-form 前缀:与"动态表单"目录路由并存时会重复注册顶级路由
boolean underAdminCatalog = adminCatalog != null;
MenuForm menuFormData = new MenuForm();
menuFormData.setParentId(underAdminCatalog ? adminCatalog.getId() : 0L);
menuFormData.setName(DEFAULT_CATALOG_NAME);
menuFormData.setType(MenuTypeEnum.CATALOG.getValue());
menuFormData.setRouteName("FormCenter");
menuFormData.setRoutePath(underAdminCatalog ? "center" : "/form-center");
menuFormData.setComponent("Layout");
// alwaysShow=1:角色仅有单个表单入口时父级目录不被侧边栏折叠吞掉
menuFormData.setAlwaysShow(1);
menuFormData.setVisible(1);
menuFormData.setSort(5);
// EP 图标(前端永久可渲染),避免空/失效图标名导致菜单显示异常
menuFormData.setIcon("el-icon-EditPen");
menuService.saveMenu(menuFormData);
return menuService.getOne(new LambdaQueryWrapper<Menu>()
.eq(Menu::getRouteName, "FormCenter")
.last("LIMIT 1")
);
}
/**
* 获取表单已生成的菜单配置
*
* @param formId 表单ID
*/
@Override
public FormMenuVO getFormMenu(Long formId) {
FormDefinition form = this.getById(formId);
if (form == null || form.getMenuId() == null) {
return null;
}
Menu menu = menuService.getById(form.getMenuId());
if (menu == null) {
return null;
}
FormMenuVO menuVO = new FormMenuVO();
menuVO.setMenuId(menu.getId());
menuVO.setMenuName(menu.getName());
menuVO.setParentId(menu.getParentId());
List<RoleMenu> roleMenus = roleMenuService.list(new LambdaQueryWrapper<RoleMenu>()
.eq(RoleMenu::getMenuId, menu.getId())
);
menuVO.setRoleIds(roleMenus.stream().map(RoleMenu::getRoleId).distinct().toList());
return menuVO;
}
/**
* 追加式角色授权:向角色授予菜单及 tree_path 祖先目录
*
* <p>踩坑1:只授子菜单不授父目录,buildRoutes 构树出孤儿节点,侧边栏不渲染;
* 踩坑2:不能复用 assignMenusToRole——它是整体替换语义,会清空角色原有菜单授权</p>
*
* @param menuId 菜单ID
* @param roleIds 角色ID列表
*/
private void assignMenuToRoles(Long menuId, List<Long> roleIds) {
if (CollectionUtil.isEmpty(roleIds)) {
return;
}
// 授权集合 = 菜单本身 + tree_path 祖先目录(如 "0,11" → 补 11,根节点 0 排除)
Menu menu = menuService.getById(menuId);
Set<Long> menuIdsToGrant = new HashSet<>();
menuIdsToGrant.add(menuId);
if (StrUtil.isNotBlank(menu.getTreePath())) {
Arrays.stream(menu.getTreePath().split(","))
.map(String::trim)
.filter(StrUtil::isNotBlank)
.filter(id -> !"0".equals(id))
.forEach(id -> menuIdsToGrant.add(Long.parseLong(id)));
}
// 查询已有关联,仅补插缺失项
List<RoleMenu> existing = roleMenuService.list(new LambdaQueryWrapper<RoleMenu>()
.in(RoleMenu::getRoleId, roleIds)
.in(RoleMenu::getMenuId, menuIdsToGrant)
);
Set<String> existingKeys = existing.stream()
.map(rm -> rm.getRoleId() + ":" + rm.getMenuId())
.collect(Collectors.toSet());
List<RoleMenu> toInsert = roleIds.stream()
.flatMap(roleId -> menuIdsToGrant.stream()
.filter(grantMenuId -> !existingKeys.contains(roleId + ":" + grantMenuId))
.map(grantMenuId -> new RoleMenu(roleId, grantMenuId))
)
.toList();
if (CollectionUtil.isNotEmpty(toInsert)) {
roleMenuService.saveBatch(toInsert);
// 授权变化后刷新角色权限缓存
roleMenuService.refreshRolePermsCache();
}
}
}

View File

@@ -0,0 +1,192 @@
package com.youlai.boot.module.form.util;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.youlai.boot.common.exception.BusinessException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 表单规则校验器(后端基础校验 + 字段白名单)
*
* <p>前端校验可被绕过,后端基于 form-create rule 的 validate 规则做兜底校验:
* 必填(required)与基础类型(number/array),并按 rule 声明的 field 做白名单过滤,
* 恶意构造的未声明字段不会入库</p>
*
* <p>仅做基础校验(与 rule validate 语义对齐),pattern/min/max 等复杂规则
* 由前端 form-create 完整执行,后端不重复实现整套校验引擎</p>
*
* @author Ray.Hao
* @since 4.8.0
*/
public final class FormRuleValidator {
/**
* Jackson 线程安全,静态复用
*/
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private FormRuleValidator() {
}
/**
* 校验并过滤表单数据
*
* <p>白名单:仅保留 rule 中声明 field 的数据项;
* 校验:逐字段执行 required/类型校验,任一失败抛业务异常并列出全部错误</p>
*
* @param formJson 表单规则(form-create rule 数组 JSON 字符串)
* @param data 提交数据(field -> value 映射)
* @return 白名单过滤后的数据(保持 rule 字段顺序)
*/
public static Map<String, Object> validateAndFilter(String formJson, Map<String, Object> data) {
JsonNode rules;
try {
rules = OBJECT_MAPPER.readTree(formJson);
} catch (Exception e) {
throw new BusinessException("表单规则解析失败");
}
if (!rules.isArray()) {
throw new BusinessException("表单规则格式非法");
}
Map<String, Object> filtered = new LinkedHashMap<>();
List<String> errors = new ArrayList<>();
walk(rules, data, filtered, errors);
if (!errors.isEmpty()) {
throw new BusinessException(String.join(";", errors));
}
return filtered;
}
/**
* 递归遍历规则节点:布局容器(row/col)自身无 field,其 children 递归收集
*
* @param nodes 规则节点数组
* @param data 提交数据
* @param filtered 白名单过滤结果(累积写入)
* @param errors 校验错误信息(累积写入)
*/
private static void walk(JsonNode nodes, Map<String, Object> data,
Map<String, Object> filtered, List<String> errors) {
for (JsonNode node : nodes) {
if (node == null || !node.isObject()) {
continue;
}
JsonNode fieldNode = node.get("field");
if (fieldNode != null && fieldNode.isTextual() && !fieldNode.asText().isEmpty()) {
String field = fieldNode.asText();
// 字段白名单:rule 未声明的 field 直接丢弃
if (data.containsKey(field)) {
filtered.put(field, data.get(field));
}
validateField(node, field, data.get(field), errors);
}
JsonNode children = node.get("children");
if (children != null && children.isArray()) {
walk(children, data, filtered, errors);
}
}
}
/**
* 单字段校验:必填与基础类型
*
* @param ruleNode 字段规则节点
* @param field 字段名
* @param value 提交值(可能为 null)
* @param errors 校验错误信息(累积写入)
*/
private static void validateField(JsonNode ruleNode, String field, Object value, List<String> errors) {
JsonNode validateArray = ruleNode.get("validate");
if (validateArray == null || !validateArray.isArray()) {
return;
}
String title = resolveTitle(ruleNode, field);
for (JsonNode validate : validateArray) {
// 必填:null/空串/空集合均视为未填写
if (validate.path("required").asBoolean(false) && isEmpty(value)) {
errors.add(requiredMessage(validate, title));
}
// 类型:仅值非空时校验,避免与 required 错误重复
if (!isEmpty(value)) {
String type = validate.path("type").asText("");
if (!checkType(type, value)) {
errors.add("「" + title + "」数据类型非法");
}
}
}
}
/**
* 解析字段标题(错误提示用),缺省回退字段名
*/
private static String resolveTitle(JsonNode ruleNode, String field) {
JsonNode title = ruleNode.get("title");
return title != null && title.isTextual() && !title.asText().isEmpty() ? title.asText() : field;
}
/**
* 必填错误消息:优先取 rule validate 里配置的 message,缺省用通用文案
*/
private static String requiredMessage(JsonNode validate, String title) {
JsonNode message = validate.get("message");
return message != null && message.isTextual() && !message.asText().isEmpty()
? message.asText() : "「" + title + "」不能为空";
}
/**
* 空值判定:null、空字符串、空集合
*
* @param value 提交值
*/
private static boolean isEmpty(Object value) {
if (value == null) {
return true;
}
if (value instanceof String str) {
return str.trim().isEmpty();
}
return value instanceof Collection<?> collection && collection.isEmpty();
}
/**
* 基础类型校验:number(数字或数字串)/ array(集合),
* string/date 等文本类型无可靠判据,不做强校验
*
* @param type validate 规则声明的类型
* @param value 提交值(非空)
*/
private static boolean checkType(String type, Object value) {
return switch (type) {
case "number", "integer", "float" -> isNumeric(value);
case "array" -> value instanceof Collection<?>;
default -> true;
};
}
/**
* 数字判定:Number 实例或可解析为 double 的字符串
*
* @param value 提交值(非空)
*/
private static boolean isNumeric(Object value) {
if (value instanceof Number) {
return true;
}
if (value instanceof String str) {
try {
Double.parseDouble(str.trim());
return true;
} catch (NumberFormatException ignored) {
return false;
}
}
return false;
}
}

View File

@@ -28,14 +28,14 @@ import java.time.LocalDateTime;
/**
* 阿里云 OSS 文件存储服务。
* <p>
* 当 {@code file-storage.type=aliyun} 时生效。
* 当 {@code storage.type=aliyun} 时生效。
* 上传文件按日期分目录存储,URL 格式:{@code 协议://bucket.endpoint/yyyyMMdd/uuid.suffix},
* 协议取自 endpoint 配置(未指定时默认 https)。
*/
@Component
@Slf4j
@ConditionalOnProperty(value = "file-storage.type", havingValue = "aliyun")
@ConfigurationProperties(prefix = "file-storage.aliyun")
@ConditionalOnProperty(value = "storage.type", havingValue = "aliyun")
@ConfigurationProperties(prefix = "storage.aliyun")
@RequiredArgsConstructor
@Data
public class AliyunFileServiceImpl implements FileService {

View File

@@ -10,7 +10,6 @@ import com.youlai.boot.support.file.service.FileService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@@ -23,20 +22,19 @@ import java.time.LocalDateTime;
/**
* 本地文件存储服务。
* <p>
* 当 {@code file-storage.type=local} 时生效。
* 文件写入本地磁盘 {@code file-storage.local.path} 下按日期分目录存储。
* 当 {@code storage.type=local} 时生效。
* 文件写入本地磁盘 {@code storage.local.path} 下按日期分目录存储。
* URL 为相对于存储根目录的路径,如 {@code /yyyyMMdd/uuid.suffix}。
*/
@Data
@Slf4j
@Component
@ConditionalOnProperty(value = "file-storage.type", havingValue = "local")
@ConfigurationProperties(prefix = "file-storage.local")
@ConditionalOnProperty(value = "storage.type", havingValue = "local")
@ConfigurationProperties(prefix = "storage.local")
@RequiredArgsConstructor
public class LocalFileServiceImpl implements FileService {
/** 本地存储根目录,来自 {@code file-storage.local.path} */
@Value("${file-storage.local.path}")
/** 本地存储根目录,来自 {@code storage.local.path} */
private String path;
/**

View File

@@ -39,25 +39,24 @@ import java.time.Duration;
import java.time.LocalDateTime;
/**
* RustFS 文件存储服务(S3 协议)。
* <p>
* 当 {@code file-storage.type=rustfs} 时生效。通过 AWS SDK 的 S3Client
* 连接 RustFS(S3 兼容对象存储),IP 端点必须走 path-style 寻址。
* 上传文件按日期分目录存储,路径格式:{@code yyyyMMdd/uuid.suffix}。
* S3 协议文件存储实现类(当前部署为 RustFS)
*
* <p>当 {@code storage.type=s3} 时生效。</p>
* <ul>
* <li>配置了 {@code file-storage.rustfs.domain} 时直接用域名拼接 URL</li>
* <li>配置了 {@code storage.s3.domain} 时直接用域名拼接 URL</li>
* <li>未配置则用预签名 GET URL(去掉 query 参数后的纯路径)</li>
* </ul>
*
* @author youlai.tech
* @author Ray.Hao
* @since 4.8.0
*/
@Component
@ConditionalOnProperty(value = "file-storage.type", havingValue = "rustfs")
@ConfigurationProperties(prefix = "file-storage.rustfs")
@ConditionalOnProperty(value = "storage.type", havingValue = "s3")
@ConfigurationProperties(prefix = "storage.s3")
@RequiredArgsConstructor
@Data
@Slf4j
public class RustFSFileServiceImpl implements FileService {
public class S3FileServiceImpl implements FileService {
/** S3 对象存储服务端点,如 {@code http://localhost:9000} */
private String endpoint;
@@ -97,7 +96,7 @@ public class RustFSFileServiceImpl implements FileService {
}
/**
* 上传文件到 RustFS,按日期分目录,自动创建存储桶,返回文件访问 URL。
* 上传文件到 S3(RustFS),按日期分目录,自动创建存储桶,返回文件访问 URL。
*/
@Override
@SneakyThrows

View File

@@ -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.FileStorageProperties;
import com.youlai.boot.config.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;
@@ -33,7 +33,7 @@ import java.util.Set;
public class FileController {
private final FileService fileService;
private final FileStorageProperties fileStorageProperties;
private final StorageProperties storageProperties;
@PostMapping
@Operation(summary = "文件上传")
@@ -66,7 +66,7 @@ public class FileController {
* 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制。
*/
private void validateFileExtension(MultipartFile file) {
Set<String> allowedExtensions = fileStorageProperties.getAllowedExtensions();
Set<String> allowedExtensions = storageProperties.getAllowedExtensions();
if (allowedExtensions.isEmpty()) {
return;
}

View File

@@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.*;
import java.util.stream.Collectors;
@@ -26,6 +27,13 @@ import java.util.stream.Collectors;
@Slf4j
public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> implements RoleMenuService {
/**
* 权限缓存TTL:与 Caffeine 菜单缓存 expireAfterWrite=600s 对齐。
* Hash 写入默认永不过期,直接改库(SQL 脚本/手工修数)不触发任何刷新点时会永久陈旧;
* 加 TTL 后过期整 Key 消失,Read-Through 按角色逐个回源重建,实现最终一致自愈
*/
private static final Duration ROLE_PERMS_TTL = Duration.ofSeconds(600);
private final RedisTemplate<String, Object> redisTemplate;
/**
@@ -48,6 +56,7 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
redisTemplate.opsForHash().put(cacheKey, roleCode, perms);
}
});
refreshCacheTtl(cacheKey);
}
log.info("权限缓存刷新完成");
@@ -71,6 +80,7 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
Set<String> perms = rolePerms.getPerms();
if (CollectionUtil.isNotEmpty(perms)) {
redisTemplate.opsForHash().put(cacheKey, roleCode, perms);
refreshCacheTtl(cacheKey);
}
}
}
@@ -97,6 +107,7 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
Set<String> perms = rolePerms.getPerms();
if (CollectionUtil.isNotEmpty(perms)) {
redisTemplate.opsForHash().put(cacheKey, newRoleCode, perms);
refreshCacheTtl(cacheKey);
}
}
}
@@ -163,11 +174,21 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
redisTemplate.opsForHash().put(cacheKey, roleCode, dbPerms);
perms.addAll(dbPerms);
}
refreshCacheTtl(cacheKey);
}
return perms;
}
/**
* 刷新权限缓存TTL(写后滑动续期:有写入的周期内缓存常驻,静置超过 TTL 整 Key 过期触发回源重建)
*
* @param cacheKey 缓存Key
*/
private void refreshCacheTtl(String cacheKey) {
redisTemplate.expire(cacheKey, ROLE_PERMS_TTL);
}
/**
* 获取角色拥有的菜单ID集合
*

View File

@@ -1,67 +1,67 @@
{
"properties": [
{
"name": "file-storage.type",
"name": "storage.type",
"type": "java.lang.String",
"description": "文件存储类型:rustfs | aliyun | local"
"description": "文件存储类型:s3 | aliyun | local"
},
{
"name": "file-storage.upload.max-file-size",
"name": "storage.upload.max-file-size",
"type": "java.lang.String",
"description": "单文件大小上限(如 50MB),同时作为 spring.servlet.multipart 的上限"
},
{
"name": "file-storage.upload.allowed-extensions",
"name": "storage.upload.allowed-extensions",
"type": "java.util.List<java.lang.String>",
"description": "允许的文件扩展名白名单(置空表示不限制)"
},
{
"name": "file-storage.rustfs.endpoint",
"name": "storage.s3.endpoint",
"type": "java.lang.String",
"description": "RustFS(S3) 服务 Endpoint"
"description": "S3 服务 Endpoint(S3 协议兼容存储,默认部署 RustFS)"
},
{
"name": "file-storage.rustfs.access-key",
"name": "storage.s3.access-key",
"type": "java.lang.String",
"description": "RustFS 访问凭据"
"description": "S3 访问凭据"
},
{
"name": "file-storage.rustfs.secret-key",
"name": "storage.s3.secret-key",
"type": "java.lang.String",
"description": "RustFS 凭据密钥"
"description": "S3 凭据密钥"
},
{
"name": "file-storage.rustfs.bucket",
"name": "storage.s3.bucket",
"type": "java.lang.String",
"description": "RustFS 存储桶名称"
"description": "S3 存储桶名称"
},
{
"name": "file-storage.rustfs.domain",
"name": "storage.s3.domain",
"type": "java.lang.String",
"description": "RustFS 自定义域名(文件 URL 域名)"
"description": "S3 自定义域名(文件 URL 域名)"
},
{
"name": "file-storage.aliyun.endpoint",
"name": "storage.aliyun.endpoint",
"type": "java.lang.String",
"description": "阿里云 OSS 服务 Endpoint"
},
{
"name": "file-storage.aliyun.access-key-id",
"name": "storage.aliyun.access-key-id",
"type": "java.lang.String",
"description": "阿里云 OSS 访问凭据 ID"
},
{
"name": "file-storage.aliyun.access-key-secret",
"name": "storage.aliyun.access-key-secret",
"type": "java.lang.String",
"description": "阿里云 OSS 凭据密钥"
},
{
"name": "file-storage.aliyun.bucket",
"name": "storage.aliyun.bucket",
"type": "java.lang.String",
"description": "阿里云 OSS 存储桶名称"
},
{
"name": "file-storage.local.path",
"name": "storage.local.path",
"type": "java.lang.String",
"description": "本地存储根目录(相对路径基于工作目录)"
},

View File

@@ -96,6 +96,7 @@ security:
- /api/v1/auth/wxma/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
- /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌)
- /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
- ${springdoc.swagger-ui.path}
@@ -107,9 +108,9 @@ security:
- /error
# 文件存储
file-storage:
# 存储类型:rustfs | aliyun | local
type: rustfs
storage:
# 存储类型:s3 | aliyun | local
type: s3
# 上传限制
upload:
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
@@ -120,8 +121,8 @@ file-storage:
- jpeg
- png
- gif
# RustFS(S3) 对象存储
rustfs:
# S3 对象存储(S3 协议兼容服务,默认部署 RustFS)
s3:
endpoint: http://111.229.83.153:9000
access-key: bybaddp7zyARpgNbEGKf
secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj

View File

@@ -95,6 +95,7 @@ security:
- /api/v1/auth/wxma/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
- /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌)
- /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
- ${springdoc.swagger-ui.path}
@@ -106,9 +107,9 @@ security:
- /error
# 文件存储
file-storage:
# 存储类型:rustfs | aliyun | local
type: rustfs
storage:
# 存储类型:s3 | aliyun | local
type: s3
# 上传限制
upload:
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
@@ -119,8 +120,8 @@ file-storage:
- jpeg
- png
- gif
# RustFS(S3) 对象存储
rustfs:
# S3 对象存储(S3 协议兼容服务,默认部署 RustFS)
s3:
endpoint: http://localhost:9000
access-key: rustfs-admin
secret-key: rustfs-admin

View File

@@ -8,6 +8,6 @@ spring:
import: classpath:codegen.yml
servlet:
multipart:
# 单文件大小上限,取自 file-storage.upload.max-file-size(单一来源)
max-file-size: ${file-storage.upload.max-file-size:50MB}
max-request-size: ${file-storage.upload.max-file-size:50MB}
# 单文件大小上限,取自 storage.upload.max-file-size(单一来源)
max-file-size: ${storage.upload.max-file-size:50MB}
max-request-size: ${storage.upload.max-file-size:50MB}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youlai.boot.module.form.mapper.FormDataMapper">
<!-- 表单数据分页列表(LEFT JOIN 提交人昵称,匿名提交 create_by 为空则昵称为空) -->
<select id="getFormDataPage" resultType="com.youlai.boot.module.form.model.vo.FormDataVO">
SELECT
t1.id,
t1.form_version,
t1.data_json,
t1.create_by,
t2.nickname AS createByName,
t1.create_time
FROM
form_data t1
LEFT JOIN sys_user t2 ON t1.create_by = t2.id AND t2.is_deleted = 0
WHERE
t1.form_id = #{formId}
AND t1.is_deleted = 0
ORDER BY
t1.create_time DESC
</select>
</mapper>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youlai.boot.module.form.mapper.FormDefinitionMapper">
<!-- 表单定义分页列表(分页列出不含 form_json/options_json 大字段,避免无效 IO) -->
<select id="getFormDefinitionPage" resultType="com.youlai.boot.module.form.model.vo.FormDefinitionPageVO">
SELECT
t1.id,
t1.form_key,
t1.form_name,
t1.description,
t1.status,
t1.version,
t1.is_public,
t1.create_time
FROM
form_definition t1
<where>
t1.is_deleted = 0
<if test='queryParams.keywords!=null and queryParams.keywords.trim() neq ""'>
AND (
t1.form_name LIKE CONCAT('%',#{queryParams.keywords},'%')
OR
t1.form_key LIKE CONCAT('%',#{queryParams.keywords},'%')
)
</if>
<if test='queryParams.status!=null'>
AND t1.status = #{queryParams.status}
</if>
</where>
ORDER BY
t1.create_time DESC
</select>
</mapper>