feat: 添加动态表单引擎模块
This commit is contained in:
@@ -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:{}:{}";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public enum LogModuleEnum implements IBaseEnum<Integer> {
|
||||
NOTICE(9, "通知公告"),
|
||||
LOG(10, "日志管理"),
|
||||
CODEGEN(11, "代码生成"),
|
||||
FORM(12, "动态表单"),
|
||||
OTHER(99, "其他");
|
||||
|
||||
@EnumValue
|
||||
|
||||
@@ -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) {
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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集合
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user