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

@@ -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集合
*